GitGroundworkGit
8 / 18 written chapters

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.

12 min read2 diagrams11 sections
11On this page
  1. Fast-forward: when there is nothing to merge
  2. Three-way merge and the merge base
  3. Choosing how the result is recorded
  4. Strategies and strategy options
  5. Reading a conflict
  6. Resolving
  7. Tools: mergetool and rerere
  8. Semantic conflicts: the ones Git cannot see
  9. Reviewing what a merge really did
  10. Undoing a merge
  11. How to have fewer conflicts
Fresher Mid Senior
In one minute

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.

BEFORE main feature 7d21e0 e2c81d AFTER git merge feature main slides forward main, feature e2c81d Updating 7d21e0..e2c81d Fast-forward
A fast-forward only moves a label. The history is identical before and after, and nothing records that a branch ever existed.

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.

merge base ours (HEAD) M: two parents theirs: feature main ← HEAD diff base → ours diff base → theirs same hunk changed twice: conflict
The hollow commit on the left is the merge base; the hollow one on the right is the merge commit. Its first parent is the old main tip, its second parent is the feature tip.

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

CommandResulting historyGood for
git merge featureFast-forward if possible, otherwise a merge commitLocal syncing
git merge --no-ff featureAlways a merge commit, so the branch stays visible as a unitFeature branches you may want to revert as a whole
git merge --ff-only featureFast-forward, or refuse and change nothingUpdating main from origin/main; scripts
git merge --squash featureStages the combined changes; you then commit one ordinary commit with one parentNoisy branches with 40 "wip" commits
git merge --no-commit featureDoes the merge but stops before committing, so you can inspect or adjustMerges 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.

StrategyWhat it does
ortThe 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.
recursiveThe 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.
octopusThe 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.
oursRecords 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.
resolveAn older, simpler two-head algorithm. Rarely needed.
subtreeMerges 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 typeWhat happenedHow it looks in status
contentBoth sides edited the same linesboth modified
modify/deleteOne side edited a file, the other deleted itdeleted by us or deleted by them
add/addBoth sides created a file at the same pathboth added
rename/renameBoth sides renamed the same file to different namesboth deleted, added by us, added by them
binaryBoth sides changed an image or other binary fileboth 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() to cartTotal() 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.

Two settings worth turning on today

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 main daily, 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.