Merge Conflicts Demystified
A developer hits a merge conflict for the first time. They see
<<<<<<< HEADand>>>>>>> featureand conclude Git is broken. They Google "undo merge conflict" and find instructions forgit merge --abort, which they use to back out. Over the next year, they avoid merging or rebasing whenever possible, accumulating branches that eventually become unmergeable. Eventually a senior pairs with them for 30 minutes and shows: merge conflict markers are exactly three copies of disagreeing text, you pick the right one (or combine them), remove the markers, stage the file, continue. That is the whole mechanism. In ten minutes they resolve a two-year backlog.Merge conflicts feel scary because the three-way-merge algorithm and the conflict markers look cryptic. They are neither. This lesson demystifies what a conflict actually is (two changes to the same region, with no way to tell which one the author wants), how Git figures it out (the three-way merge with a common ancestor), and the tools that make resolution fast.
What a Conflict Actually Is
A merge conflict is simply: the same region of the same file was changed in both branches, in ways Git cannot automatically reconcile.
Git uses a three-way merge algorithm:
- Find the merge base — the most recent common ancestor commit of both branches.
- For each file, compute diffs:
base → branchAandbase → branchB. - If both branches made identical changes → merge them (no conflict).
- If one branch changed something and the other did not → take the change.
- If both branches changed the same region in different ways → conflict.
Conflicts are not Git failing. They are Git being honest: "two humans made incompatible edits; one of them has to decide what to do."
A merge conflict is Git saying "I cannot decide for you." It is not a software failure. The resolution is: read what both sides wrote, decide what the merged code should be, stage it, move on. The tools Git provides make this easier, but the core skill is reading code — no amount of better tooling replaces that.
The Three-Way Merge
base
/ \
/ \
branchA branchB
Git compares each file three ways:
base → branchAdiff: what branchA changed.base → branchBdiff: what branchB changed.branchA vs branchBdirect comparison: differences in the current state.
For each region in the file:
| base | branchA | branchB | Result |
|---|---|---|---|
| X | X | X | X (no one changed it) |
| X | Y | X | Y (only branchA changed) |
| X | X | Z | Z (only branchB changed) |
| X | Y | Y | Y (both made the same change) |
| X | Y | Z | CONFLICT |
The conflict is that box in the corner: two disagreeing changes to text that was previously the same. Git cannot guess the right answer.
See the merge base
git merge-base main feature
# a1b2c3d4... (the commit where they last agreed)
git show $(git merge-base main feature)
# Shows the contents at that common-ancestor point
git log --oneline $(git merge-base main feature)..HEAD
# Commits on main since the branch
git log --oneline $(git merge-base main feature)..feature
# Commits on feature since the branch
Understanding the merge base makes "why is this conflicting?" clear: the conflict is about changes both branches made after the merge base.
Conflict Markers
When a three-way merge hits a conflict in a file, Git writes both conflicting versions into the file, marked with conflict markers:
normal content above
<<<<<<< HEAD
this is what is currently on main
=======
this is what the feature branch has
>>>>>>> feature
normal content below
<<<<<<< HEADstarts the "ours" version (what the branch you are merging into has).=======is the divider between the two versions.>>>>>>> featurecloses the "theirs" version (what the branch you are merging from has).
Your job: edit the file to remove the markers and leave the content you actually want.
Resolving a conflict
# 1. Git told you there's a conflict and paused the merge
git status
# Unmerged paths:
# (use "git add <file>..." to mark resolution)
# both modified: src/auth.py
# 2. Open the file, find the markers, decide what the merged text should be
vim src/auth.py
# ...remove markers, write the correct content...
# 3. Stage the resolved file
git add src/auth.py
# 4. Continue the merge
git commit # or `git merge --continue` / `git rebase --continue`
Possible outcomes when deciding:
- Keep HEAD's version. Delete the markers and feature's version.
- Keep feature's version. Delete the markers and HEAD's version.
- Combine them. Keep relevant pieces from both, delete the rest.
- Write new text. Sometimes the right answer is neither — rewrite the region.
Git does not care which you choose. It just needs to see the markers gone and the file staged.
Three-Way Merge Visualized
A Worked Example
Start with a common base:
# Base: a file with one line
echo 'greet = "hello"' > app.py
git add app.py && git commit -m "initial"
# Branch A: formal greeting
git checkout -b formal
sed -i '' 's/hello/Hello, World!/' app.py
git add app.py && git commit -m "formal greeting"
# Branch B: casual greeting (from the same base)
git checkout main
git checkout -b casual main~1 # wait, let me redo
Actually let me restart with a cleaner example:
mkdir /tmp/conflict-demo && cd /tmp/conflict-demo
git init
echo 'greet = "hello"' > app.py
git add app.py && git commit -m "initial"
# Branch A: formal
git checkout -b formal
sed -i '' 's/hello/Hello, World!/' app.py
git commit -am "formal greeting"
# Branch B: casual (from the original commit, not from formal)
git checkout -b casual main
# error: pathspec 'main' did not match any file(s) known to git
# (depends on default branch name; might be master)
# Let's check the branch name
git branch
# formal
# * master (or main — varies by git version)
# Use whichever your default is
git checkout -b casual master # or main
sed -i '' 's/hello/hi there/' app.py
git commit -am "casual greeting"
# Now try to merge
git checkout master # or main
git merge formal # fast-forwards (no conflict yet; only one diverging branch was merged)
git merge casual # BOOM — formal and casual both changed the same line
# Auto-merging app.py
# CONFLICT (content): Merge conflict in app.py
# Automatic merge failed; fix conflicts and then commit the result.
cat app.py
# <<<<<<< HEAD
# greet = "Hello, World!"
# =======
# greet = "hi there"
# >>>>>>> casual
Resolve:
# Option 1: keep formal
echo 'greet = "Hello, World!"' > app.py
# Option 2: keep casual
# echo 'greet = "hi there"' > app.py
# Option 3: combine
# echo 'greet = "Hello, hi there!"' > app.py
# Stage and commit
git add app.py
git commit # opens editor with default merge message; usually keep it
Helpful Flags and Commands
Show all unmerged files
git status
# Unmerged paths:
# both modified: app.py
git diff --name-only --diff-filter=U
# app.py
See the versions of an unmerged file
The index has three "stages" during a conflict:
- Stage 1: the merge base (common ancestor).
- Stage 2: HEAD's version ("ours").
- Stage 3: the merging branch's version ("theirs").
Extract them:
git show :1:app.py # base version
git show :2:app.py # HEAD version (ours)
git show :3:app.py # theirs (the branch being merged)
# Also:
git checkout --conflict=diff3 app.py # adds a "||||||| merged common ancestors" section showing the base
# More context for resolution.
diff3-style markers are often more useful than the default 2-way markers:
<<<<<<< HEAD
greet = "Hello, World!"
||||||| merged common ancestors
greet = "hello"
=======
greet = "hi there"
>>>>>>> casual
Now you can see what the original was, which helps decide. Enable globally:
git config --global merge.conflictstyle diff3
# Or zdiff3 (Git 2.35+) for cleaner output
git config --global merge.conflictstyle zdiff3
Turn on merge.conflictstyle = zdiff3 (or diff3) globally. Seeing the original common-ancestor version in the conflict markers makes resolution dramatically easier. You are no longer guessing what the base was; you can see it.
git mergetool
Command-line editing works fine for small conflicts. For larger or more complex ones, a visual 3-way merge tool is a better experience:
git mergetool
# Launches your configured mergetool (vimdiff by default)
# Configure once:
git config --global merge.tool meld # or kdiff3, vscode, etc.
git config --global mergetool.keepBackup false
Popular options:
- meld (Linux / Mac via Homebrew) — clean 3-pane view.
- kdiff3 — cross-platform, feature-rich.
- Beyond Compare — commercial but excellent.
- VS Code — built-in 3-way merge UI in recent versions (
code --wait). - IntelliJ / JetBrains IDEs — excellent built-in merge tool.
Most engineers settle on their IDE's built-in tool. VS Code's 3-way merge editor (accept current, accept incoming, combine, resolve manually) is notable.
Strategies for Reducing Conflicts
Most merge conflicts are self-inflicted. Several habits dramatically reduce them:
1. Rebase often
If you keep your feature branch rebased onto the latest main every day or two, conflicts happen one commit at a time — small, easy to resolve. If you rebase once at PR time after a month of divergence, you get every conflict at once.
# Daily habit on your feature branch
git fetch origin
git rebase origin/main
# resolve conflicts per commit
git push --force-with-lease
2. Small, focused PRs
A 50-line PR rarely conflicts. A 5000-line PR routinely does. If your PR is huge, it has often been in progress so long that main has moved significantly.
3. Communicate about hot files
If two people are editing the same critical file, talk. "Rebase your branch when I merge mine" prevents double-editing.
4. Strategic whitespace and formatting
Autoformatters (prettier, black, rustfmt) that run on every commit drastically reduce whitespace-only conflicts. Entire lines of "fixed trailing whitespace" are a conflict magnet.
5. .gitattributes merge strategies
For specific files, you can override the default three-way merge:
# .gitattributes
CHANGELOG.md merge=union # both additions; no conflict
package-lock.json merge=theirs # always take theirs
*.generated.* merge=binary # cannot merge; take one or the other
merge=union is great for files where appends from both sides are almost always fine (changelogs, README additions).
Renaming Conflicts
When both branches rename the same file, or one branch modifies while the other renames, Git's rename detection kicks in — sometimes surprisingly.
# I renamed src/auth.py → src/authentication.py
# You modified src/auth.py on another branch
git merge other-branch
# CONFLICT (modify/delete): src/auth.py deleted in HEAD and modified in other-branch.
# Or:
# Renamed src/auth.py → src/authentication.py in HEAD
# Added changes to src/auth.py in other-branch
# (Git tries to apply the modifications to the renamed file)
Git usually does the right thing. For complex rename conflicts, git mergetool or just manual inspection is fastest.
Common Conflict Patterns
"Both modified" (the basic case)
Two branches changed the same region. Resolve as above.
"Deleted / modified"
One branch deleted a file; the other modified it. Git asks what you want:
# CONFLICT (modify/delete): README.md deleted in HEAD and modified in other.
# Version other of README.md left in tree.
# Either: keep the modification (undo the delete)
git add README.md
git commit
# Or: keep the delete (discard the modifications)
git rm README.md
git commit
Whitespace-only conflicts
Happens when two branches reformatted the same file differently. Usually safe:
git merge other-branch --strategy-option=ignore-all-space
# Re-run the merge ignoring whitespace; usually makes conflicts disappear
Binary files
No meaningful line-level diff. Git asks you which version to keep:
git checkout --ours logo.png
git add logo.png
# Or:
git checkout --theirs logo.png
git add logo.png
git rerere — "Reuse Recorded Resolution"
If you resolve the same conflict multiple times (common during a long rebase with many conflicts), Git can remember and auto-reapply:
git config --global rerere.enabled true
git config --global rerere.autoUpdate true
# From now on, when you resolve a conflict, Git records it
# Next time the same conflict appears (e.g., when rebasing again), Git auto-resolves
This is magical for iterative rebases on a long-lived branch. Enable it once and forget.
Aborting a Merge or Rebase
If you realize you should not have started the merge:
git merge --abort # during a merge
git rebase --abort # during a rebase
git cherry-pick --abort # during a cherry-pick
git revert --abort # during a revert (single-commit)
All return you to the state before the operation started. Safe to use.
If you have partially resolved conflicts and want to keep your resolution but re-start from scratch:
# Save your current resolution as a backup
git stash push -u -m "partial merge resolution"
git merge --abort
# ... now you can redo
# Or rely on the reflog:
git reflog
git reset --hard <pre-merge-entry>
A developer spent 90 minutes manually resolving conflicts in a painful rebase. Halfway through, they noticed Git had dropped some changes and panicked. They git rebase --abort-ed to start over — and discovered rerere had NOT been enabled, so they had to resolve everything again from scratch. Enabling rerere is a one-time five-second task that pays off the first time a conflict repeats. Modern Git tutorials should mention it earlier; unfortunately many do not.
Key Concepts Summary
- A conflict is two changes to the same region after a common ancestor, with no way to auto-reconcile.
- Three-way merge compares: base → branchA, base → branchB, branchA vs branchB. Conflict when both changed the same region differently.
git merge-base A Bshows the common ancestor commit.- Conflict markers:
<<<<<<< ours,=======,>>>>>>> theirs. Edit to the final text, remove markers,git add, continue. merge.conflictstyle = zdiff3adds the common-ancestor content to markers — much better for decision-making.git mergetoollaunches a visual 3-way merge editor. VS Code and IntelliJ have excellent built-ins.git rerereauto-replays previously-resolved conflicts. Free productivity win for iterative rebases.- Reduce conflicts by rebasing daily, keeping PRs small, automating formatting, and using
.gitattributesmerge strategies for commonly-appended files. git merge --abort/git rebase --abortcleanly undo — safe to use.
Common Mistakes
- Panicking at the first conflict markers and aborting. Read them, decide, resolve. They are not a Git bug.
- Using default 2-way conflict markers when
zdiff3is available. Enable it globally. - Leaving conflict markers in committed files.
grep -rn '<<<<<<< HEAD' .or your editor's linter should catch it — and Git tools refusegit commitwith unresolved markers. - Not enabling
rerere. Iterative rebases make you resolve the same conflict many times without it. - Big-bang merges after months of divergence. Rebase frequently.
- Resolving conflicts by randomly picking "ours" without reading the code. Sometimes "ours" is correct; often it is not. Read both sides.
- Thinking merge strategies (
-X theirs,-X ours) resolve logical conflicts. They resolve textual conflicts; logic may still be wrong after. - Relying on autoformatters without consistent config across team. Per-developer formatters create conflicts; team-wide ones prevent them.
- Not using
.gitattributesfor files that "always" can be merged one way (CHANGELOG.md, generated files, lockfiles). - Force-pushing over conflicted branches to "skip" conflicts. You just moved the conflict to whoever pulls next.
You are rebasing a long-lived feature branch onto main for the fifth time this week. Each rebase encounters the same conflict in src/config.py where both sides changed the default timeout value — you always resolve it to keep your teams setting. What Git feature will auto-apply this resolution next time without you re-doing it?