Merging and Conflict Resolution

Why a merge needs three versions rather than two, how to read conflict markers as information, and the resolutions that silently lose code.

intermediate 20 min lesson hands-on task included

A merge conflict is not an error. It is Git declining to guess, which is the correct behaviour — the alternative is a tool that silently picks a side and produces code that compiles and is wrong.


Topic 1: Three Versions, Not Two

A MERGE COMPARES THREE VERSIONS, NOT TWO BASE merge-base M1 M2 F1 F2 MERGE 2 parents main feature THE RULE Changed on ONE side → taken automatically Changed on BOTH, same lines → conflict, you decide WHAT A CONFLICT ACTUALLY LOOKS LIKE <<<<<<< HEAD timeout = 30 ← yours (the branch you are ON) ======= timeout = 60 ← theirs (the branch coming IN) >>>>>>> feature Resolve by editing to the correct result — which is often neither line. DURING A CONFLICT git status — the file list git diff — only the conflicts git merge --abort git checkout --ours FILE --abort always gets you back.
The merge base is the third version, and it is what turns an ambiguous comparison into a decidable one. Without it, Git could not tell 'you added this line' apart from 'they deleted it'.

Given two branches, Git finds their merge base — the most recent commit both share — and then compares three versions of every file: base, ours, theirs.

The rule that follows:

  • Changed on one side only → take that change, automatically.
  • Changed on both sides, in different places → take both, automatically.
  • Changed on both sides, in the same place → conflict. You decide.

This is why the base matters. If Git compared only the two tips, it could not distinguish “I added a line” from “they removed it” — the two-way diff looks identical, and the correct actions are opposite.

git merge-base main feature          # the shared ancestor
git merge-base --all main feature    # sometimes there is more than one

Two ancestors happen after criss-cross merges. Git’s default ort strategy handles it by merging the bases recursively — which is worth knowing exists, because it explains conflicts that appear in files neither branch obviously touched.


Topic 2: Reading a Conflict

<<<<<<< HEAD
    timeout = 30
=======
    timeout = 60
>>>>>>> feature/tune-timeouts

Top is ours — the branch you are on. Bottom is theirs — the branch coming in. During a rebase these are reversed, because a rebase replays your commits onto their branch, making their work “ours”. That inversion causes a lot of wrong resolutions, and it is worth pausing to check which operation you are in whenever a conflict looks backwards.

Turn on a better conflict style, permanently:

git config --global merge.conflictStyle zdiff3
<<<<<<< HEAD
    timeout = 30
||||||| base
    timeout = 10
=======
    timeout = 60
>>>>>>> feature/tune-timeouts

The ||||||| section is the original. Now you can see that both sides raised the value from 10 — which tells you the intent (raise it) and lets you make a real decision instead of picking a number. Without the base, you are guessing between two numbers with no context.

Conflicts you cannot resolve by choosing a side:

ConflictWhat Git reportsWhat to do
Modify / delete”deleted in feature and modified in HEAD”Decide whether the file should exist; git rm or git add
Rename / renameBoth renamed the same file, differentlyPick one name, move the content, delete the other
Rename / modifyOne renamed, one editedKeep the rename, reapply the edit
Add / addBoth created the same pathMerge the two files by hand
SubmoduleTwo different pinned commitsChoose which commit is correct — not which line

Topic 3: Working Through a Conflict

git merge feature
# CONFLICT (content): Merge conflict in src/config.go

git status                       # which files, and which conflict type
git diff                         # shows only the conflicting hunks
git diff --name-only --diff-filter=U   # just the unresolved paths

# Inspect a specific version
git show :1:src/config.go        # base
git show :2:src/config.go        # ours
git show :3:src/config.go        # theirs

# Take a whole file from one side (only when that is genuinely right)
git checkout --ours src/config.go
git checkout --theirs src/config.go

# Mark resolved, then finish
git add src/config.go
git merge --continue

# Or get out entirely — always available, always safe
git merge --abort

git merge --abort restores the pre-merge state exactly. Knowing it exists changes how a conflict feels: nothing you do during a merge is irreversible until you commit it.

A three-step discipline that prevents most bad merges:

  1. Understand both changes before editing. git log --merge -p src/config.go shows only the commits touching this file from both sides. Read why each side changed it.
  2. Resolve for correctness, not for conflict removal. The result is often neither side — two people raising a timeout for different reasons may need the higher value and a comment explaining both.
  3. Build and test before committing the merge. A merge that resolves cleanly at the text level can still be semantically broken: one side renamed a function, the other added a caller. Git sees no conflict; the compiler does.

That last case — a semantic conflict — is the one to fear, because it is invisible to Git and to the diff view in a pull request. It is also the strongest argument for running CI on the merge result rather than on the branch tip.


Topic 4: rerere — Never Resolve the Same Conflict Twice

git config --global rerere.enabled true

Reuse Recorded Resolution records the conflict and your resolution. The next time an identical conflict appears, Git applies your previous answer automatically and tells you it did.

Where it pays for itself:

  • A long-lived branch rebased repeatedly against a moving main.
  • A merge you abort, investigate, and redo.
  • Release branches that repeatedly take the same cherry-picks.
git rerere status     # what it is tracking right now
git rerere diff       # the resolution it would apply
git rerere forget <path>   # you resolved it wrongly — drop the memory

The one caution: rerere replays a resolution that was correct last time. If the surrounding code has since changed, the replayed resolution can be wrong and it will not tell you. Always review what it did — git diff after a rerere-assisted merge takes ten seconds.


Topic 5: Merge Strategies and Options

The default strategy is ort (“ostensibly recursive’s twin”), which replaced recursive and is faster and better at renames. You rarely choose a strategy; you occasionally choose an option:

git merge -X ours feature        # on conflict, prefer our side
git merge -X theirs feature      # on conflict, prefer theirs
git merge -X ignore-space-change feature
git merge -s ours feature        # DIFFERENT: discard their changes entirely

-X ours and -s ours are not variations of the same thing, and confusing them destroys work:

  • -X ours — merge normally; only conflicting hunks resolve to our side.
  • -s ours — produce a merge commit whose content is entirely ours. Every change from the other branch is discarded, while the history claims it was merged.

-s ours has legitimate uses (recording that a branch is obsolete, or joining unrelated histories) and is a footgun everywhere else, because the graph shows a merge that did not merge anything.

Squash merge is a third thing again:

git merge --squash feature       # stages the combined change; you commit it

This produces one commit with no ancestry link to the branch. main gets a clean single commit; Git no longer knows those commits were merged, which is why git branch -d afterwards complains about unmerged work.


Topic 6: Preventing Conflicts, Not Just Resolving Them

Most conflict pain is upstream of the merge:

  • Merge from main daily. Conflicts grow superlinearly with divergence; small, frequent resolutions are far cheaper than one big one.
  • Keep branches short. Two days beats two weeks by more than a factor of seven.
  • Agree on formatting and enforce it automatically. A formatter run in CI removes an entire class of whitespace conflicts. If you introduce one, do it in a single commit and add it to .git-blame-ignore-revs so blame stays useful:
git config blame.ignoreRevsFile .git-blame-ignore-revs
  • Split files people fight over. A 3,000-line routes file that every feature touches is a conflict generator; the fix is architectural.
  • Use .gitattributes for files that should never merge textually — merge=union for changelogs, or a custom driver for lock files.
  • Communicate about large refactors. No tool resolves two people restructuring the same module in one week.

Try it yourself: create a conflict, then run git checkout --conflict=diff3 <file> to re-expand it with the base included. Seeing the original alongside both sides is the moment conflict resolution stops being guesswork.

Common mistake: resolving a conflict by deleting the markers and keeping whichever side compiles. It always compiles — that is the trap. The other side’s change is now silently gone, the tests may still pass, and the feature that vanished is discovered in production a week later by the person who wrote it.