Layerbit
Version Control

Three-Way Merge Explained: Why Git Needs the Base Version and What a Conflict Really Is

Two people edit the same file. Combining their work looks like it should need only the two results, yet every serious merge tool insists on a third input: the version both started from. This guide explains why that base is the whole trick, how a three-way merge decides what to keep, exactly when it gives up and declares a conflict, and how to read and resolve the markers it leaves behind.

The short version

  • A two-way diff shows that two versions differ. It cannot tell which side changed, so it cannot merge automatically.
  • With the common ancestor, each line falls into one of four cases: unchanged, changed by one side, changed identically by both, or changed differently by both. Only the last is a conflict.
  • Conflict markers <<<<<<<, =======, >>>>>>> wrap the two competing versions; the diff3 style adds the base between ||||||| and =======, and is worth switching on.
  • "Clean merge" means no textual overlap, not "the code works". Two non-overlapping edits can still be semantically wrong together.
  • Rebase runs the same three-way merge once per commit, with a different base each time; that is why rebase conflicts feel repetitive.

Why two versions are not enough

Take a configuration file with a line timeout = 30. Your copy says timeout = 60; your colleague's says timeout = 30. A tool holding only those two copies sees a difference and nothing else. Did you raise the timeout, or did your colleague lower it? Each interpretation leads to a different merge result, and the two copies alone cannot distinguish them.

Now add the version you both started from: timeout = 30. Your copy differs from the base; your colleague's does not. You changed it; they left it alone; the merge should take your value. The base turns an ambiguous difference into an attributable change, and attribution is what lets the merge be automatic.

The four cases, line by line

A three-way merge runs two diffs — base against ours and base against theirs — and walks through the base aligning the two sets of changes. For each region of the base there are four possibilities:

Ours vs baseTheirs vs baseResult
unchangedunchangedbase (everyone agrees)
changedunchangedours (only we touched it)
unchangedchangedtheirs (only they touched it)
changedchanged, identicallythe shared change (both made the same edit)
changedchanged, differentlyconflict — the tool cannot choose

The fourth row surprises people: if both sides applied the same fix, the merge takes it once and does not complain. That is why cherry-picking the same commit onto two branches and merging them later is usually painless.

The fifth row is the only one that stops the process. Note what it does and does not require. It requires that both sides changed the same region of the base — overlapping or adjacent hunks — in different ways. It does not care what the changes mean. Two edits to the same line are a conflict even if they are compatible; two edits to different functions are not a conflict even if together they break the build.

Adjacent hunks and why "I didn't touch that line" is not a defence

Hunks are regions of change plus a little context. When your hunk ends on line 40 and theirs begins on line 41, the diff algorithm cannot safely say the regions are independent: the boundary itself moved, and placing both changes requires deciding an order. Git treats touching hunks as conflicting. This is the source of most "but we edited different things" conflicts — one person added a function at the end of a file, the other added a different one at the same end, and the tool, quite reasonably, does not know which should come first.

Reading conflict markers

When a conflict is found, the merge writes both versions into the file:

<<<<<<< HEAD
timeout = 60
=======
timeout = 15
>>>>>>> feature/faster-retries

Everything between <<<<<<< and ======= is ours; everything between ======= and >>>>>>> is theirs. What this layout omits is the base — what the line said before either of you changed it — and without it you are back to the two-way guessing problem. Git's diff3 conflict style adds it:

<<<<<<< HEAD
timeout = 60
||||||| base
timeout = 30
=======
timeout = 15
>>>>>>> feature/faster-retries

Now the intent is legible: we doubled the timeout, they halved it. That is a decision for a person — perhaps the retry logic they wrote makes the shorter timeout correct — but at least it is the right decision being made. Enable it once with git config --global merge.conflictStyle diff3 (or zdiff3 in Git 2.35+, which trims lines common to both sides out of the markers). Most engineers who try it do not go back.

Resolving: four honest options

For each conflict there are exactly four outcomes, and a good merge tool offers them as buttons rather than making you edit markers by hand:

  • Ours — keep our version, discard theirs.
  • Theirs — the reverse.
  • Both — keep ours followed by theirs. Correct more often than it sounds: two functions added at the same place, two entries appended to a list, two import lines.
  • Base — revert the region to the common ancestor, discarding both edits. Rare, but it is the right answer when both changes were attempts at the same thing and a third approach is coming.

Anything else — combining half of one side with half of the other, or writing new code — is a fifth option: editing. It is sometimes necessary and always the one to double-check, because the merged result then contains lines that appeared in neither branch and were never reviewed.

"Merged cleanly" is a textual claim

A clean merge means no region of the base was changed differently by both sides. It says nothing about whether the combination makes sense. The canonical example: one branch renames a function, the other adds a new call to it under the old name, in a different file. Neither hunk overlaps; the merge is clean; the build fails. Static languages catch this at compile time, dynamic ones at run time, and a configuration file never catches it at all. The merge commit deserves the same tests as any other commit — more, since it is the first time the two sets of changes have existed together.

Rebase is a merge in a loop

Rebasing a branch replays each of its commits on top of a new base. Each replay is a three-way merge: base is the commit's original parent, ours is the new tip, theirs is the commit's own change. The conflicts you resolve during a rebase can recur on the next commit because the base moves one step each time and the same region is touched again. Git's rerere ("reuse recorded resolution") records how you resolved a conflict and reapplies it when the identical conflict reappears — switch it on with git config --global rerere.enabled true and long rebases become far less tedious.

Three-way merge outside version control

The algorithm has nothing to do with Git in particular. It applies wherever two parties edit a shared starting point: two engineers editing the same server config by hand, two translators working on the same strings file, two versions of a document that each diverged from a sent copy. In all of these you usually still have the original — the file as deployed, the file as sent — and with it the merge is mostly automatic. Without it you are reduced to reading both versions side by side and guessing at intent, which is exactly the two-way problem the base was invented to solve.

Run a three-way merge in your browser Paste the base and the two edited versions into the diff tool's three-way mode: non-overlapping changes merge automatically, conflicts show ours, base and theirs, and each resolves with one click.
Open the diff & merge tool

Frequently asked questions

Because the two changed regions were adjacent or overlapping once context is included. When one hunk ends where the next begins, the merge cannot determine the order in which to place them, so it asks. Adding content at the same end of a file is the most common case: both additions are kept with the Both option and the order is your decision.

They fence off the two competing versions of a conflicting region: the lines between the first marker and ======= are the current branch's version, and the lines between ======= and the last marker are the incoming version. With the diff3 conflict style a ||||||| section between them shows the original text both sides changed. Resolving means replacing the whole block, markers included, with the text you want.

Look at the base. If both sides changed the same value to different values, one of them is the intended one and you pick it. If both sides added different things at the same place, both are usually wanted and the order is the only question. If both sides tried to do the same job in different ways, revert to the base and reimplement once. Read what each side was trying to achieve rather than which text looks tidier.

Yes. A clean merge only guarantees that no text region was edited differently by both sides. Changes in separate files or separate functions that depend on each other — a rename on one branch and a new use of the old name on the other — merge cleanly and fail at build or run time. Treat the merge result as new code and run the tests on it.

Mechanically none: both are three-way merges. A merge does it once with the branch tips and their common ancestor. A rebase does it once per commit, with the base moving each time, which is why the same conflict can reappear on consecutive commits. Git's rerere feature records each resolution and replays it when the identical conflict returns.