G8 · Part 2 · Working together
Merging and conflicts
Every merge compares three snapshots: yours, theirs, and the commit where they split. Conflicts are what is left when that comparison has no single answer.
11On this page
- Fast-forward: when there is nothing to merge
- Three-way merge and the merge base
- Choosing how the result is recorded
- Strategies and strategy options
- Reading a conflict
- Resolving
- Tools: mergetool and rerere
- Semantic conflicts: the ones Git cannot see
- Reviewing what a merge really did
- Undoing a merge
- How to have fewer conflicts
git merge feature, run while you are on main,
brings feature's work into main. If main has not moved, Git just
slides main forward. If both have moved, Git combines the two and
records a merge commit. When both sides changed the same lines,
Git stops and marks the file with <<<<<<<
and >>>>>>>. You edit the file
to the right result, git add it, and
git commit. If you panic, git merge --abort
puts everything back.
Fast-forward: when there is nothing to merge
If the branch you are on has not moved since the other branch split from it, your branch is an ancestor of theirs. Git does not need to combine anything. It slides your branch label forward to their tip. That is a fast-forward, and it creates no new commit.
Three-way merge and the merge base
If both branches have new commits, Git has real work to do. It finds the merge base: the best common ancestor, the last commit both branches share. Then it compares three snapshots: the base, your tip (ours), and their tip (theirs). For each region of each file:
- changed on neither side: keep it;
- changed on one side only: take that side's version;
- changed identically on both sides: take it once;
- changed differently on both sides: conflict.
The base is what makes this work. With only two versions, Git
could not tell "you added this line" from "they deleted this
line". With the base, it knows who changed what. The result is
a merge commit with two parents: your old tip
first, their tip second. That order matters later for
git log --first-parent and for reverting a merge.
looking at the base yourself
$ git merge-base main feature/checkout
7d21e0a4c19b2f6e8d3a5c7b9e1f0d2c4a6b8e0f
$ git diff main...feature/checkout --stat
src/cart.js | 14 ++++++++++----
src/coupon.js | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 62 insertions(+), 4 deletions(-)
$ git log --oneline main..feature/checkout
e2c81d0 Apply coupon before tax
b41f7a3 Add coupon model
5a90c2e Add coupon field to cart form
The three dots in git diff A...B mean "diff from
the merge base of A and B to B", which is exactly what a pull
request page shows: what the branch changed, ignoring what main
did since. git diff --merge-base A B is the same
comparison spelled out. The two dots in git log A..B
mean "commits reachable from B but not from A".
Sometimes there is more than one best common ancestor, usually
after two branches have merged each other back and forth (a
criss-cross merge). git merge-base --all
lists them. Git's default strategy handles it by first merging
the bases together into a temporary virtual base, then merging
against that.
Choosing how the result is recorded
| Command | Resulting history | Good for |
|---|---|---|
git merge feature | Fast-forward if possible, otherwise a merge commit | Local syncing |
git merge --no-ff feature | Always a merge commit, so the branch stays visible as a unit | Feature branches you may want to revert as a whole |
git merge --ff-only feature | Fast-forward, or refuse and change nothing | Updating main from origin/main; scripts |
git merge --squash feature | Stages the combined changes; you then commit one ordinary commit with one parent | Noisy branches with 40 "wip" commits |
git merge --no-commit feature | Does the merge but stops before committing, so you can inspect or adjust | Merges you want to test first |
A squash merge has a cost worth knowing. The new commit has no
link to the branch's commits, so Git does not consider the
branch merged: git branch -d refuses, and merging
the same branch again later will try to apply its changes a
second time and may conflict with itself.
Strategies and strategy options
A strategy (-s) is the algorithm
that performs the merge. A strategy option
(-X) tunes that algorithm. They are easy to confuse
and do very different things.
| Strategy | What it does |
|---|---|
ort | The default since Git 2.34 for merging two branches. A rewrite of recursive that is much faster and handles renames better. Recursively merges multiple merge bases. |
recursive | The default from 2005 until 2.33. Since Git 2.50 it is only another name for ort: the old code is gone, and -s recursive in old scripts runs ort. |
octopus | The default when you merge three or more branches at once, git merge a b c. Makes one commit with many parents, and refuses if any conflict needs a manual fix. |
ours | Records a merge but keeps your tree exactly as it was. Their changes are discarded completely. Used to mark an old branch as "merged" without taking anything from it. |
resolve | An older, simpler two-head algorithm. Rarely needed. |
subtree | Merges a project that lives in a subdirectory of yours. |
Compare -s ours with -X ours. The
strategy throws away everything from the other side.
The option runs a normal merge, takes all their non-conflicting
changes, and only resolves conflicting hunks in your
favour. -X theirs is the mirror image; there is no
-s theirs. Both options are blunt: they resolve
conflicts without anyone reading them, so use them only when you
know one side is right, such as regenerated lock files.
other strategy options you will meet
$ git merge -X ignore-space-change feature
$ git merge -X diff-algorithm=histogram feature
$ git merge -X renormalize feature
$ git merge -X find-renames=40% feature
ignore-space-change stops an indentation change on
one side from conflicting with a real change on the other.
histogram often lines up changed blocks better
than the default diff. renormalize helps when one
branch changed line endings. find-renames lowers
the similarity needed for Git to treat a deleted file and a new
file as a rename (the default is 50%).
Reading a conflict
what Git prints
$ git merge feature/checkout
Auto-merging src/tax.js
Auto-merging src/cart.js
CONFLICT (content): Merge conflict in src/cart.js
Automatic merge failed; fix conflicts and then commit the result.
$ git status
On branch main
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)
Changes to be committed:
modified: src/tax.js
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: src/cart.js
Everything that merged cleanly is already staged. Only the conflicted file needs you. Inside it, Git writes both versions between markers:
src/cart.js, default "merge" style
<<<<<<< HEAD
const total = subtotal + tax + shipping;
=======
const total = applyCoupon(subtotal) + tax;
>>>>>>> feature/checkout
Above ======= is ours (the branch you are on),
below is theirs (the branch being merged in). This view hides
the most useful fact: what the line looked like before either
side touched it. Turn on the zdiff3 style and Git
shows the base too:
the same conflict with merge.conflictStyle=zdiff3
<<<<<<< HEAD
const total = subtotal + tax + shipping;
||||||| 7d21e0a
const total = subtotal + tax;
=======
const total = applyCoupon(subtotal) + tax;
>>>>>>> feature/checkout
Now the story is clear: main added shipping, the feature added
the coupon, and the right answer keeps both:
applyCoupon(subtotal) + tax + shipping. The older
diff3 style shows the same base section; zdiff3
(Git 2.35 and later) additionally moves lines that are identical
on both sides out of the conflict, so the marked region is
smaller.
| Conflict type | What happened | How it looks in status |
|---|---|---|
| content | Both sides edited the same lines | both modified |
| modify/delete | One side edited a file, the other deleted it | deleted by us or deleted by them |
| add/add | Both sides created a file at the same path | both added |
| rename/rename | Both sides renamed the same file to different names | both deleted, added by us, added by them |
| binary | Both sides changed an image or other binary file | both modified, with no markers inside |
Resolving
During a conflict the index holds up to three versions of each conflicted file, called stages: 1 is the base, 2 is ours, 3 is theirs. You can read any of them directly, which is the only sane way to deal with binary files and lock files.
working with the three stages
$ git ls-files -u
100644 8a1f3c0e5d7b9a2c4e6f8b0d1a3c5e7f9b1d3a5c 1 src/cart.js
100644 c29e7b4a1f3d5c7e9a0b2d4f6a8c0e1b3d5f7a9c 2 src/cart.js
100644 5f0d2b8e4a6c1e3f5a7b9d0c2e4f6a8b0c1d3e5f 3 src/cart.js
$ git show :1:src/cart.js
$ git show :2:src/cart.js
$ git show :3:src/cart.js
$ git checkout --theirs package-lock.json
$ git checkout --ours assets/logo.png
$ git checkout -m src/cart.js
$ git diff --name-only --diff-filter=U
src/cart.js
$ git add src/cart.js
$ git commit --no-edit
--ours and --theirs take a whole file
from one side (git restore --ours does the same).
checkout -m throws away your half-finished edit and
puts the conflict markers back so you can start again.
git add is how you tell Git a file is resolved: it
collapses the three stages into one. Git does not check for
leftover markers, so run git diff --check before
committing; it flags them.
For a lock file, the right move is usually neither side: take
either version, then regenerate it with
npm install so it matches the merged
package.json.
getting out
$ git merge --abort
$ git log --merge --oneline -- src/cart.js
e2c81d0 Apply coupon before tax
a90f4d1 Add shipping to cart total
--abort returns you to the moment before
git merge. It tries to keep uncommitted edits you
had when you started, but cannot always, which is one reason to
commit or stash before merging. git log --merge lists
only the commits, on either side, that touched the conflicted
files, which is how you find out why both sides
changed that line.
Tools: mergetool and rerere
git mergetool opens each conflicted file in a
three- or four-pane tool that shows base, ours, theirs and the
result side by side. Configure it once with
git config --global merge.tool vscode (plus a
mergetool.vscode.cmd entry) or
vimdiff, meld, kdiff3.
Set mergetool.keepBackup false or it leaves
.orig files everywhere.
rerere ("reuse recorded resolution") records the conflict and how you resolved it. The next time the same conflict appears, typically when you rebase or re-merge a long-lived branch, it applies your resolution for you:
rerere at work
$ git config --global rerere.enabled true
$ git merge feature/checkout
CONFLICT (content): Merge conflict in src/cart.js
Recorded preimage for 'src/cart.js'
Automatic merge failed; fix conflicts and then commit the result.
$ git add src/cart.js && git commit --no-edit
Recorded resolution for 'src/cart.js'.
$ git merge feature/checkout
CONFLICT (content): Merge conflict in src/cart.js
Resolved 'src/cart.js' using previous resolution.
Automatic merge failed; fix conflicts and then commit the result.
Note that Git still stops. rerere fills in the file but leaves
it unstaged so you can check it (set
rerere.autoUpdate true to stage it too). If it
recorded a bad resolution, git rerere forget src/cart.js
removes it.
Semantic conflicts: the ones Git cannot see
Git merges text, not meaning. A merge can complete with zero conflicts and still produce broken code:
- Branch A renames
getTotal()tocartTotal()and updates every caller. - Branch B, written at the same time, adds a new file that calls
getTotal(). - The two branches touch different lines, so Git merges them cleanly. The result does not compile.
Other versions of the same problem: one branch changes a function's default, another starts relying on the old default; two branches add database migrations with the same number; one removes a config key another reads. Only tests catch these, and only if they run on the merged result rather than on each branch alone. That is the reason for "require branches to be up to date before merging" settings and for merge queues, which test the exact commit that will land on main.
You can see a merge's result without touching your working tree:
git merge-tree --write-tree main feature (Git 2.38
and later) performs the merge in memory and prints the
resulting tree ID, plus any conflicts. It is the kind of
check a hosting service runs to show "This branch has no
conflicts".
Reviewing what a merge really did
A merge commit is easy to hide changes in. git show
on a merge prints a combined diff that leaves out every
hunk where the result matches one of the parents. A resolution
that quietly threw away one side's change therefore shows nothing
at all. Git 2.36 added a better
view: --remerge-diff redoes the merge in memory,
conflicts and all, and diffs that automatic result against the
commit that was actually recorded. What you see is exactly what a
human changed during the merge.
git show --remerge-diff on a merge with a conflict
$ git show --remerge-diff --format='%h %s' 7ce6145
7ce6145 Merge branch 'feature/checkout'
diff --git a/src/cart.js b/src/cart.js
remerge CONFLICT (content): Merge conflict in src/cart.js
index 5f0d2b8..9a24f70 100644
--- a/src/cart.js
+++ b/src/cart.js
@@ -1,7 +1,3 @@
export function cartTotal(subtotal, tax, shipping) {
-<<<<<<< a90f4d1 (Add shipping to cart total)
- const total = subtotal + tax + shipping;
-=======
- const total = applyCoupon(subtotal) + tax;
->>>>>>> e2c81d0 (Apply coupon before tax)
+ const total = applyCoupon(subtotal) + tax + shipping;
return total;
For a clean merge it prints only the commit line, which is the
point: no diff means nobody changed anything by hand.
git log --remerge-diff --merges runs the same check
over a whole history, which is a strong review habit for release
branches and for anyone auditing "what landed with that merge".
Undoing a merge
If the merge is only local, git reset --hard ORIG_HEAD
puts the branch back where it was before the merge. If it is
already pushed, revert it:
reverting a pushed merge
$ git revert -m 1 4c9e2b7
[main 8d0a1f5] Revert "Merge branch 'feature/checkout'"
2 files changed, 4 insertions(+), 62 deletions(-)
-m 1 says "parent 1, main, was the mainline; undo
what the other parent brought in". The trap comes later: the
feature's commits are still in main's history, so merging the
fixed branch again brings in only the new commits, not
the ones you reverted. To bring the feature back, first revert
the revert (git revert 8d0a1f5), then merge the
fixes.
git config --global merge.conflictStyle zdiff3
so every conflict shows the base.
git config --global rerere.enabled true so you
never resolve the same conflict twice.
How to have fewer conflicts
- Merge or rebase from
maindaily, not the day before you open the PR. - Keep branches small and short-lived. A three-day branch rarely conflicts; a three-week one always does.
- Agree on formatting and enforce it with a formatter in a pre-commit hook, so whitespace never causes conflicts.
- Do not mix a big refactor and a behaviour change in the same branch. Land the refactor first, on its own.
- Append to lists in a stable order, such as sorted imports, so two additions do not fight over the last line.
Finished reading?
0 of 18 chapters read in this series