Git, CI/CD & GitOpsIX · MergingMerging
Three-way merges — when fast-forward is not possible
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
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:
- 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.
- The current branch tip (called
oursin the merge strategy) — the tree of the checked-out branch’s HEAD. This is what the merge is producing a new version of. - The merged-in branch tip (called
theirsin 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
oursandtheirs(whether or not it changed from base), the merged blob is that value. No conflict. - Changed in one side only. If
oursandbaseagree buttheirsdiffers, ortheirsandbaseagree butoursdiffers, 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:
- 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.
- Prefer
git merge-treefor CI gates. A CI pipeline that wants to assert “this merge would be clean” should rungit merge-treeagainst the two candidate tips and assert that noCONFLICTmarkers appear in the output. This is cheaper and safer than attempting the merge, aborting on conflict, and assertinggit statusafterwards. - Use the default strategy. The recursive strategy is the
default for a reason: it handles every case
resolvehandles plus the criss-cross case. Selectingresolveexplicitly 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
dpkgconflict 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
Q1. In a three-way merge, what are the three tree inputs that the merge algorithm combines?
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.
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.
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.