Why two-way merge creates false conflicts
Start with the simplest possible merge algorithm: two-way merge. You have two versions of a file — version A and version B. For every line, you check: is it the same in both? If yes, keep it. If no, conflict.
This sounds reasonable until you think about what "conflict" means here. If Alice changes line 42 and Bob doesn't touch it, two-way merge still sees a difference and marks it as a conflict. It doesn't know who changed what — it only sees that the two versions disagree.
# The problem with two-way merge:
# Alice's version Bob's version
# --- ---
const timeout = 5000; const timeout = 5000; <- same, no conflict
const retries = 3; const retries = 5; <- CONFLICT?
# But Bob never touched retries!
# Alice added it. Bob's version
# just doesn't have it yet.
# Two-way merge can't tell.Without knowing the baseline — the common ancestor — two-way merge can't distinguish "Alice changed this line" from "Bob changed this line" from "both changed this line differently." All it sees is disagreement.
How the common ancestor resolves ambiguity
Three-way merge adds a third input: the merge base, the common ancestor commit from which both branches diverged. Now for every section of the file, the algorithm can ask a more precise question: compared to the base, what did branch A change? What did branch B change?
The rules become clear and correct:
- If A changed a section and B didn't (relative to base): take A's version. No conflict.
- If B changed a section and A didn't: take B's version. No conflict.
- If neither changed it: keep the base version.
- If both changed the same section to different results: that's a genuine conflict. Human intervention required.
- If both changed the same section to the same result: take that version. No conflict — they independently made the same change.
# Three-way merge with the base: # Base (common ancestor) Alice's branch Bob's branch # --- --- --- const retries = 3; const retries = 3; const retries = 5; # Analysis: # - Base has retries = 3 # - Alice: no change # - Bob: changed 3 -> 5 # Decision: take Bob's version (only one side changed). No conflict. # Result: const retries = 5; // taken from Bob's branch, automatically
How Git finds the merge base
Finding the right merge base is harder than it sounds. In a simple fork-and-merge scenario, it's obvious: the commit where the branch split off. But in a real project with a busy main branch and multiple merges, the commit graph is a DAG (directed acyclic graph), and there may be multiple candidates for "most recent common ancestor."
Git uses the "recursive" merge strategy by default (now called "ort" in newer versions, which is a faster re-implementation). When there are multiple merge bases, it recursively merges them to produce a virtual merge base. This is why Git can handle complex criss-cross merge scenarios that simpler systems can't.
# Find the merge base manually: git merge-base main feature-branch # See the full merge base in a criss-cross scenario: git merge-base --all main feature-branch # Inspect what will be in a merge before doing it: git merge --no-commit --no-ff feature-branch git diff --cached # see what the merge would produce git merge --abort # don't actually commit it
Rebase is just three-way merge, repeated
Here is the insight that most Git tutorials bury or skip entirely: git rebase is not a fundamentally different operation from git merge. It is three-way merge applied commit by commit.
When you run git rebase main on your feature branch, Git does this for each commit on your branch:
- Find the merge base (the point where your branch diverged).
- Take the diff between the merge base and your commit — this is "what you changed."
- Apply that diff on top of the new base (the tip of main) using three-way merge.
- If there's a conflict, pause and ask you to resolve it.
- Move to the next commit and repeat.
This is why rebasing can produce conflicts even when the same merge with git mergewould not — the merge base for each individual commit in the rebase is different from the merge base of the branch as a whole. And it's why a rebase with 10 commits can produce 10 rounds of conflict resolution where a single merge would have produced one.
Cherry-picking: three-way merge with a cherry-picked diff
git cherry-pickis the same algorithm with a different merge base. When you cherry-pick commit C onto branch B, Git uses C's parent as the merge base, computes the diff between C's parent and C (what that commit changed), and applies that diff to the tip of B using three-way merge.
This is why cherry-picks can produce surprising conflicts: the diff was designed to apply cleanly on top of C's parent, but the context at the tip of B might be different. A change to line 42 in the cherry-picked commit might conflict because line 42 in the target branch is completely different code.
# Cherry-pick a specific commit (e.g., a hotfix): git cherry-pick abc1234 # Cherry-pick a range of commits: git cherry-pick abc1234..def5678 # Cherry-pick without committing (inspect the result first): git cherry-pick --no-commit abc1234 git diff --cached # review what was applied git cherry-pick --continue # or --abort
Practical implications: conflicts are information, not failures
Understanding three-way merge reframes how you think about conflicts. A conflict doesn't mean Git is broken or that you did something wrong. It means both branches made changes to the same region of the same file, and Git correctly identified that it cannot automatically determine which version to keep. It is surfacing a genuine ambiguity.
The practical advice that flows from this: when you see a conflict, don't just look at the two conflicting versions. Look at the merge base too. git checkout --conflict=diff3 <file> shows you all three versions inline — base, ours, and theirs. Understanding what the base looked like often makes the right resolution obvious.
# Show three-way conflict markers (base + ours + theirs): git checkout --conflict=diff3 src/config.ts # Set diff3 style as your default conflict format: git config --global merge.conflictstyle diff3 # The output now shows: # <<<<<<< HEAD # your version # ||||||| base # the common ancestor version # ======= # their version # >>>>>>> feature-branch
The diff3 style is one of the most underused Git features. Seeing the base version alongside both branches makes conflicts significantly easier to resolve correctly. If you take one thing from this article, make it this: set merge.conflictstyle diff3 in your global config right now.
Try it yourself
Diff Checker — compare two texts online →