Merging and Resolving Conflicts
Handle a genuinely hairy merge with a process rather than luck: the merge base, zdiff3 conflict style, modify/delete and rename conflicts, and verifying a merge before you trust it.
Handle a genuinely hairy merge with a process rather than luck: the merge base, zdiff3 conflict style, modify/delete and rename conflicts, and verifying a merge before you trust it.
Your first conflict was easy because it was small: one file, one line, one obvious winner. Real merges are rarely that polite. A branch that sat for three weeks comes back with four conflicting files, a rename Git had to infer, and a file one side deleted while the other was still editing it.
By the end you will have a repeatable process for that merge: what Git compared, how to read a conflict line by line, which shortcuts are safe, and how to check the result.
A merge does not compare your branch to their branch. It compares both against a third commit: the merge base, the most recent commit the two branches share.
git merge-base main feature # prints the common ancestor
git diff main...feature # what feature did since that basePicture two people marking up identical photocopies of one recipe card. To combine their notes you compare each card to the clean original, not to each other. That original is the merge base.
This is why a conflict is a question, not an error. If only one side changed a region relative to the base, Git takes that side without asking. A conflict appears only when both sides changed the same region differently — at which point there is no fact Git could look up, so it asks the one person who knows the intent.
The code doing this work is a merge strategy. Since Git 2.34
the default is ort, replacing the older recursive. It computes
the merge in memory rather than thrashing the working tree and
index, making it far faster on large repositories, and it handles
renames — especially whole-directory renames — much more
accurately.
If your branch's base is still the tip of main, there is nothing
to reconcile: Git slides the main label forward onto your
commits. That is a fast-forward, leaving no merge commit and
no trace a branch existed. If main moved, you get a true
merge — a new commit with two parents holding the result.
--ff-only is the safety catch: it turns "quietly do something
complicated" into "stop and tell me", which is why many teams make
it the default for git pull. --no-ff preserves the shape of
the work instead, so git log --first-parent reads as one line
per feature. Either can be config:
Teams that want linear history pick --ff-only plus rebasing;
teams that want auditable feature boundaries pick --no-ff. Both
are defensible. What hurts is not deciding.
When Git stops, it writes both versions into the file between markers. The default style shows only the two outcomes:
The top region is ours — the branch you are standing on — and the bottom is theirs, the branch you named. Those words are positional, not moral, and they flip during a rebase, which replays your commits onto their branch. Keep that in mind; lesson 2 deals with it properly.
The trouble with that style is that you get two answers with the
question missing. Turn on diff3 and Git inserts the base region
between them:
Now the story is legible. The base had a hard-coded rate; one side
replaced it with a function call, the other with a lookup table,
and both independently added the same logRate line. Without the
middle region you might have guessed one side deleted logging.
zdiff3 goes further, lifting lines both sides agree on out of
the conflict entirely:
The conflict has shrunk to the one line that genuinely disagrees.
Two answers, question missing.
You see what each side ended up with and nothing about where they started, so you can misread an addition on one side as a deletion on the other.
Adds the common ancestor.
Now the story is legible: the base had a hard-coded rate, one side replaced it with a function call, the other with a lookup table.
And lifts the agreements out.
Lines both sides added identically leave the conflict region entirely, so what remains is only what genuinely disagrees.
That is the whole game with a hairy merge: make the region you must think about as small as it honestly is.
With a dozen conflicted files, memory is not a plan. git status
is your checklist — it lists every unmerged path and the kind of
conflict each one is, and it shrinks as you resolve them. Run it
between every file. git diff with no arguments shows a
combined diff: only the conflicted files, with two columns of
markers, one per parent.
Some files need no line-level thought — a generated lockfile, a file one side rewrote wholesale. Take one side entire:
You will also see this written git checkout --ours <file>;
git restore is the modern spelling for "put this content back in
my working tree" and, unlike checkout, cannot be confused with a
branch switch. Either way, taking a side is safe only when the
file is one team's artifact — never on a source file both sides
edited in different places.
Once a file is right, git add marks it resolved — that is all
staging means during a merge. When no unmerged paths remain,
finish. If the merge has become a mess, back out of it.
git merge --abort returns you to the exact state you were in
before the merge began. Use it freely rather than wrestling a
merge you have lost the thread of.
In unfamiliar code, side-by-side panes beat markers.
git mergetool opens each conflicted file in turn and stages the
result when you save and quit; keepBackup false stops Git
scattering .orig files through your working tree.
A modify/delete conflict means one side deleted the file and
the other changed it. Git cannot guess whether the deletion was
deliberate cleanup or ignorance of the new work, so it asks.
Answer with git rm <file> to accept the deletion or
git add <file> to keep it — after reading the deleting commit's
message, which usually says which.
A rename/rename conflict is both sides moving one file to
different names. Git leaves both paths in the tree, contents
merged as far as possible. Pick the name the team should live
with, git rm the other path, git add the survivor, then search
for imports of the losing name.
A rename/modify conflict is one side renaming a file while the
other edits it. ort handles this well and usually applies the
edits to the new path automatically. When the edits themselves
overlap you get an ordinary content conflict at the new path —
resolve it there and ignore the old name.
A binary conflict — an image, a PDF, a compiled asset — has no
line structure, so Git will not attempt a merge. It leaves your
version in place and says it cannot merge the file. Open both,
choose one with git restore --ours or --theirs, then
git add. If both sides made real changes, someone has to redo
their work in the chosen file.
A merge commit can contain code that exists in neither parent. That is exactly what your resolutions are, and it is why merge commits are such a comfortable place for bugs to live: the diff looks empty in most review tools, so nobody reads it.
So review it yourself. Before committing, git diff HEAD shows
what the merged result changes relative to your side. After
committing, git show --cc prints a combined diff of only the
hunks that differ from every parent — precisely the lines you
invented.
Run the tests before you finish the merge, not after. A merge that compiles is not a merge that works.
The best conflict handling is having fewer conflicts, and that is
structural rather than a matter of skill. Difficulty scales with
the distance back to the merge base, so merge main into your
branch often — daily on a long feature. Three small conflicts on
three calm afternoons beat thirty on one bad one, and short-lived
branches buy the same thing.
Agree on formatting and enforce it automatically so whitespace never becomes an argument, and resist drive-by reformatting of files your change did not need to touch: a reformatted file conflicts with every open branch that touches it.
You now have a process: find the base, read conflicts with that
base visible, work the git status checklist file by file, take
whole files only when a file belongs to one side, and review what
the merge produced before trusting it.
The natural next step is rewriting history with rebase, which
solves the same problem from the other direction — instead of
joining two lines of history, it moves yours on top of theirs. The
conflicts look identical, with the twist you were warned about:
ours and theirs swap meaning.
Until then, practise deliberately. In a scratch repository, set two branches editing the same file, delete it on one side and edit it on the other, then merge. Conflicts stop being frightening the moment you have caused a few on purpose.
<<<<<<< HEAD
const rate = taxRateFor(country);
logRate(rate);
=======
const rate = TAX_RATES[country] ?? 0;
logRate(rate);
>>>>>>> feature/tax-tables<<<<<<< HEAD
const rate = taxRateFor(country);
logRate(rate);
||||||| 4b0a1f2
const rate = 0.2;
=======
const rate = TAX_RATES[country] ?? 0;
logRate(rate);
>>>>>>> feature/tax-tables<<<<<<< HEAD
const rate = taxRateFor(country);
||||||| 4b0a1f2
const rate = 0.2;
=======
const rate = TAX_RATES[country] ?? 0;
>>>>>>> feature/tax-tables
logRate(rate);git merge feature # fast-forward if possible, else merge
git merge --ff-only feature # refuse unless it fast-forwards
git merge --no-ff feature # always create a merge commitgit config --global merge.ff false # always make a merge commit
git config --global pull.ff only # never auto-merge on pullgit status # the checklist of unmerged paths
git diff # combined diff of conflicts only
git diff --base -- src/tax.js # working file vs the merge base
git diff --ours -- src/tax.js # working file vs your side
git diff --theirs -- src/tax.js # working file vs their sidegit restore --ours src/tax.js # keep your version of the file
git restore --theirs src/tax.js # keep their version of the filegit add src/tax.js # mark this file resolved
git merge --continue # commit the merge (opens the message)
git merge --abort # undo the whole merge attemptgit config --global merge.tool meld # or vscode, kdiff3, nvimdiff
git config --global mergetool.keepBackup false
git mergetool # walk through each conflictgit diff HEAD # what the merge changes on your side
git show --cc HEAD # only the hunks unique to the mergegit merge-base main feature # find the common ancestor
git diff main...feature # what feature added since base
git merge feature # fast-forward or true merge
git merge --ff-only feature # refuse a non-fast-forward
git merge --no-ff feature # force a merge commit
git config --global merge.ff false # always make a merge commit
git config --global pull.ff only # never auto-merge on pull
git config --global merge.conflictStyle zdiff3 # show the base
git status # list unmerged paths
git diff # combined diff of conflicts
git diff --base -- <file> # working file vs merge base
git diff --ours -- <file> # working file vs your side
git diff --theirs -- <file> # working file vs their side
git restore --ours <file> # take your whole file
git restore --theirs <file> # take their whole file
git rm <file> # accept a deletion
git add <file> # mark a file resolved
git merge --continue # commit the merge
git merge --abort # undo the merge attempt
git config --global merge.tool meld # pick a visual merge tool
git mergetool # step through conflicts
git diff HEAD # review before committing
git show --cc HEAD # hunks unique to the merge