Skip to main content
RunBook Academy

Git, CI/CD & GitOpsIX · MergingMerging

Three-way merges — when fast-forward is not possible

Intermediate⏱ ~22 mingit

What you'll learn

  • Explain why fast-forward is not possible when the two branch tips have diverged
  • Describe the role of the merge base in a three-way merge and how `git merge-base` finds it
  • Walk through the three-input merge algorithm: base, ours, theirs, and the resulting tree
  • Recognise the cases where the three-way merge produces a conflict versus a clean result
  • Distinguish the recursive strategy from the historical resolve strategy

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

A three-way merge is what git merge does when fast-forward is not possible. It is the default behaviour for any merge where the two branch tips have diverged — neither is a descendant of the other, the merge base is strictly older than both tips, and there is nothing to “move forward” to. The merge that results is not a pointer motion but a constructed commit: Git reads three snapshots from the object store, combines them according to an algorithm, and writes the result as a new commit with two parents. Understanding the three inputs and the algorithm that combines them is what makes merge conflicts legible instead of mysterious.

When fast-forward is not possible

The condition is the negation of IX-01: fast-forward is not possible when the two branch tips have no ancestor relationship, i.e. neither tip is reachable from the other. In that case, the merge base is some commit strictly older than both tips, and the two branches have moved independently since the fork point.

git checkout main
git merge feature/iam-rotation
# Merge made by the 'recursive' strategy.
#  terraform/main.tf | 4 ++--
#  1 file changed, 2 insertions(+), 2 deletions(-)

The output is recognisably different from a fast-forward: the header line says Merge made by the 'recursive' strategy. rather than Fast-forward, and the commit graph acquires a new two-parent node. The hash printed after the merge is the new merge commit; its first parent is the previous main tip, its second parent is the feature/iam-rotation tip.

flowchart LR
    subgraph BEFORE["before merge"]
        A1["6f4e5a6 merge base"] --> B1["8a3f9d2 main"]
        A1 --> C1["9f3c1d7 feature"]
        B1 --> D1["4d2c8e0 main"]
        C1 --> E1["a1b2c3d feature"]
    end
    subgraph AFTER["after git merge"]
        A2["6f4e5a6"] --> B2["8a3f9d2"] --> D2["4d2c8e0 main"]
        A2 --> C2["9f3c1d7 feature"]
        C2 --> E2["a1b2c3d feature"]
        D2 --> F2["MERGE main"]
        E2 --> F2
    end

The “after” diagram shows the merge commit with two parents (4d2c8e0 and a1b2c3d) and a single tree built from the three inputs 6f4e5a6, 4d2c8e0, and a1b2c3d. The two parents are the tip commits of the branches being combined; the third input (the merge base) is the common ancestor, used by the algorithm but not present in the resulting graph as a parent.

The three inputs

Every three-way merge takes exactly three tree objects as input:

  1. The merge base — the tree of the common-ancestor commit. This is the state of the repository before either branch started to diverge. It is the ground truth against which “what did this branch change?” is measured.
  2. The current branch tip (called ours in the merge strategy) — the tree of the checked-out branch’s HEAD. This is what the merge is producing a new version of.
  3. The merged-in branch tip (called theirs in the merge strategy) — the tree of the branch passed on the command line. This is what is being combined with the current branch.

The merge base is found by walking parent edges from both tips and choosing the youngest commit reachable from both. The plumbing command is git merge-base:

git merge-base main feature/iam-rotation
# 6f4e5a6f4e5a6f4e5a6f4e5a6f4e5a6f4e5a6f4e

For a graph with a single fork point, this is the commit at the fork. For a graph where the branches have crossed and re-forked, Git uses the recursive strategy to find a virtual base by merging intermediate bases — see the strategies lesson in IX-06.

You can also preview the merge result without writing it to the working tree:

git merge-tree --write-tree main feature/iam-rotation
# <merged-tree-oid>
# changed in both
#  base   100644 6f4e5a6... terraform/main.tf
#  our    100644 4d2c8e0... terraform/main.tf
#  their  100644 a1b2c3d... terraform/main.tf

git merge-tree (with --write-tree in Git 2.38+) is the read-only way to inspect what a merge would do. It runs the merge algorithm against three OIDs, prints the resulting tree, and reports which files changed in which direction. CI pipelines that want to assert “this merge would produce no conflicts” without checking out a branch run git merge-tree in a build step.

How the three-way merge constructs the result

The algorithm is per-file, not per-repository. For each path in the index, Git compares the blob OID at that path across the three trees (base, ours, theirs) and decides what the merged blob should be.

The cases:

  • Unchanged in both. If the blob at this path is identical in ours and theirs (whether or not it changed from base), the merged blob is that value. No conflict.
  • Changed in one side only. If ours and base agree but theirs differs, or theirs and base agree but ours differs, the merged blob takes the side that changed. No conflict — the change is unilateral.
  • Changed identically in both. If both sides changed the blob to the same new value (different from base but identical to each other), the merged blob takes that value. No conflict.
  • Changed differently in both. If both sides changed the blob to different new values, Git cannot decide. It writes both versions into the working tree wrapped in conflict markers (<<<<<<<, =======, >>>>>>>) and stops with a non-zero exit. The merge is left in an in-progress state (covered in IX-05).

The conflict is the only case where the merge produces a result that requires human attention. Everything else is mechanical, and the recursive strategy applies it across the whole tree.

# A file changed identically on both sides: no conflict
git show 6f4e5a6:terraform/main.tf | sha256sum
# a1b2c3d4...
git show 4d2c8e0:terraform/main.tf | sha256sum
# a1b2c3d4...   <-- identical to theirs
git show a1b2c3d:terraform/main.tf | sha256sum
# a1b2c3d4...   <-- identical to ours

Recursive versus resolve

The default strategy for a two-parent merge is recursive. The historical default was resolve. The difference matters when the branch history has more than one merge base — that is, when the graph has criss-crossed merges.

resolve picks one merge base arbitrarily and uses it as the single base for the three-way merge. If that base is not a true ancestor of both tips — only a “best candidate” — the resulting merge can miss conflicts.

recursive walks the graph, finds all merge bases, merges them together into a virtual base (a recursive call to itself), and then performs the three-way merge against the virtual base. The virtual base is a tree that represents the best possible common ancestor given the criss-crossed history. This is what makes recursive safe for repositories with regular release-branch flows, where main, release/x, and feature branches get merged into each other over the lifecycle of a release.

# The default for two-parent merges is recursive
git merge feature/iam-rotation
# Merge made by the 'recursive' strategy.

# Explicit selection of the older strategy
git merge --strategy=resolve feature/iam-rotation
# Merge made by the 'resolve' strategy.

In modern Git (2.30+), there is essentially no reason to use resolve over recursive for a two-parent merge. The recursive strategy handles the common case and the criss-cross case correctly; the resolve strategy handles only the common case and can produce surprising results on a complex graph. The choice is covered in detail in IX-06.

Production discipline

Three rules for three-way merges in a production-grade workflow:

  1. Read the merge-base output before merging. The merge base tells you where the branches diverged. If the base surprises you — it is not the commit you expected to be the fork point — the graph has a criss-cross that the recursive strategy will handle but that you should understand before signing off.
  2. Prefer git merge-tree for CI gates. A CI pipeline that wants to assert “this merge would be clean” should run git merge-tree against the two candidate tips and assert that no CONFLICT markers appear in the output. This is cheaper and safer than attempting the merge, aborting on conflict, and asserting git status afterwards.
  3. Use the default strategy. The recursive strategy is the default for a reason: it handles every case resolve handles plus the criss-cross case. Selecting resolve explicitly is a code smell unless you have a specific reason to do so.

Cross-course references

  • Linux for Production Sysadmins - Parts XII (RepoSecurity) covers package conflicts; the per-path resolution model is the same shape as dpkg conflict resolution.
  • Ansible for Production Sysadmins - Part XXXVII (RepoArch) covers role-vs-playbook conflicts at the YAML level; the three-way merge is the same algorithm applied to YAML trees.
  • Terraform for Production Sysadmins - Parts IX-XII (State) cover Terraform state files; the merge of two state files is a different operation (state files are not text-merged) but the topology reasoning is shared.

Quiz

Knowledge check · 4 questions

  1. Q1. In a three-way merge, what are the three tree inputs that the merge algorithm combines?

  2. Q2. A conflict in a three-way merge can only occur when both sides changed the same path to different values from the merge base.

  3. Q3. Name the plumbing command that finds the merge base of two commits, and the command that previews a three-way merge without writing it to the working tree.

  4. Q4. Decide what to do when a CI gate reports that `git merge-tree` would produce a conflict on a PR.

    Your CI runs `git merge-tree origin/main..HEAD` for every PR and asserts that no conflict markers would be produced. On a Tuesday afternoon, the gate fails for PR #417 with the message 'CONFLICT (content): Merge conflict in terraform/iam/main.tf' and the per-path report shows base=6f4e5a6, our=4d2c8e0, their=a1b2c3d, all different.

Passing score: 75%. Answers are checked in this browser.