Three-Way Merge

A three-way merge combines two branch tips by comparing them with a shared earlier snapshot. Git calls the checked-out tip ours, the named source tip theirs, and their best common ancestor the merge base. The base is what turns a two-sided disagreement into a question Git can often answer: which side changed, and which side stayed put?

This lesson separates two jobs that are easy to blur together:

  • The commit graph chooses the merge base and classifies the relationship between the tips.
  • The three snapshots decide the result, one path at a time.

Only after every path has a clean result does the engine write a merged tree, store a commit with two parent lines, and advance the current branch. If a path conflicts, none of those writes happen.

The four path rules

For one path, let base, ours, and theirs be the file values in the three snapshots. The scoped merge uses four rules, in this order:

ConditionResult
ours == theirsTake either value. Both sides agree.
ours == baseTake theirs. Only the source side changed.
theirs == baseTake ours. Only the current side changed.
All three differStop with a conflict.

The order matters. If both branches independently reach the same value, the first rule accepts it even when that value differs from base.

These are deliberately small rules for two flat text files. Production Git also handles directories, file modes, deletions, renames, binary files, merge drivers, and strategy options. The role of the base does not change: it supplies the before-state for both sides.

Step by Step

The default run executes this operation trace:

commit snapshot "base":         app.py=print("v1"), README.md=hello
git branch feature
commit snapshot "main-api":     app.py=print("v2"), README.md=hello
git switch feature
commit snapshot "feature-docs": app.py=print("v1"), README.md=quickstart
git switch main
git merge feature

The object IDs are deterministic Git-format SHA-1 IDs. Fixed identity and time fields keep the lesson focused on trees and parents.

  1. The root snapshot becomes tree a7c4b8a... and commit cd23e8b.... main advances from unborn to that commit.
  2. git branch feature writes a second ref at cd23e8b.... It does not copy the commit, and HEAD remains attached to main.
  3. The main-api snapshot changes only app.py. It becomes commit 8602b19..., whose parent is cd23e8b.... Only main advances.
  4. HEAD switches to feature. The branch still points at the base, so feature-docs creates sibling commit 61f01e6.... Its README.md changed, while its app.py still matches the base.
  5. HEAD switches back to main. The merge freezes ours = 8602b19... and theirs = 61f01e6...; neither input can move during the comparison.
  6. The ancestry probe marks every commit reachable from ours, then walks the source history. The unique best common ancestor is cd23e8b....
  7. The base differs from both tips, so this is neither “already up to date” nor a fast-forward. The engine loads all three snapshots.
  8. For app.py, theirs equals base: both contain print("v1"). The third rule takes ours, print("v2").
  9. For README.md, ours equals base: both contain hello. The second rule takes theirs, quickstart.
  10. The clean result is app.py=print("v2") and README.md=quickstart. Git serializes it as tree 2bcb0a8....
  11. The merge commit body names that tree, then writes parent 1 as old HEAD 8602b19... and parent 2 as source tip 61f01e6.... The stored commit is 7614647....
  12. Finally, main advances to 7614647.... feature remains at 61f01e6.... The merge adds one commit object, writes the current ref once, and never writes the source ref.

The parent order records the command's point of view. Parent 1 is the pre-merge current tip; parent 2 is the tip named on the merge command. Swapping those lines while keeping the same result tree would still produce a different commit ID because the commit hash covers the serialized parent headers.

Why the merge base must be the best one

The first common ancestor found during a walk is not automatically the right base. A common ancestor can have a newer common descendant. In that case the older commit is still shared history, but it is not a best candidate.

The scoped engine collects all common ancestors, then removes any candidate that is an ancestor of another common candidate. Ordinary split histories leave one answer. Some criss-cross histories leave several best candidates. This visualizer stops at that boundary; Git's default ort strategy can merge multiple bases into a virtual base before it merges the branch tips.

That distinction prevents an older snapshot from being treated as the before-state when a more recent shared snapshot exists. The path rules are only as trustworthy as the base supplied to them.

Edge Cases

Merge base equals ours

The current tip is an ancestor of the source tip. Git can fast-forward by moving the current ref to the existing source commit. No snapshot reconciliation or merge commit is needed. The workbench stays closed because there are not three distinct roles to compare.

Merge base equals theirs

The current history already contains the source tip. Git reports “already up to date.” There are zero object writes and zero ref writes, even when the two branch names point to different commits.

Both sides make the same edit

If ours and theirs contain the same value for a path, the first rule takes it. The base may differ. Agreement at the tips is enough for that path.

Both sides change one path differently

When base, ours, and theirs are pairwise different, the scoped engine stops before writing the result tree. Real Git records the three versions in index stages 1, 2, and 3 and writes merge-state files such as MERGE_HEAD; the working tree may contain conflict markers for a text merge. A merge commit appears only after the conflict is resolved and committed.

More than one best merge base

Criss-cross merges can produce multiple best common ancestors. The animation labels this as a model boundary rather than pretending the history is invalid. Git's recursive-style strategies can synthesize a virtual merge base.

Eight displayed commits

Eight is a layout cap for this visualizer, not a Git limit. The trace emits a scope verdict and leaves the proposed commit unstored, so the graph never implies that real Git rejects a larger repository.

Common Mistakes

“Three-way means three branches.” The three inputs are snapshots: base, ours, and theirs. Only two branch tips participate in the command.

“The merge base is whichever common ancestor the walk reaches first.” Git needs a best common ancestor. An older shared commit is discarded when a newer shared candidate descends from it.

“Ours always wins.” Ours wins only when theirs still matches base. If ours stayed unchanged, the clean rule takes theirs. If both sides agree, their shared value wins regardless of base.

“A conflict creates a special conflict commit.” It does not. The merge pauses with unresolved index state. The eventual commit is created only after every path has a resolved result.

“Both branch refs move after a merge.” Only the checked-out destination branch advances. The source ref remains where it was.

“The merge commit stores a patch.” It names one complete result tree and two parent commits. Diffs are views computed between those snapshots.

“Parent order is cosmetic.” Parent lines are ordered bytes in the commit body. Changing their order changes the object ID and changes which parent commands such as first-parent history traversal treat as the mainline.

Scope of the model

The animation models a local clean repository with attached HEAD, two flat 100644 files, deterministic SHA-1 objects, up to three branch refs, and one unique best merge base. Its path merge compares whole string values. It omits the working tree and index during clean merges, rename detection, deletions, file modes, binary drivers, custom attributes, hooks, signatures, tags, remotes, submodules, octopus merges, strategy options, reflogs, concurrent ref updates, and recursive virtual-base construction.

For production behavior, see git merge, git merge-base, and the index-stage description in git read-tree.