Git Internals for Engineers

Merge Conflicts Demystified

A developer hits a merge conflict for the first time. They see <<<<<<< HEAD and >>>>>>> feature and conclude Git is broken. They Google "undo merge conflict" and find instructions for git 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:

  1. Find the merge base — the most recent common ancestor commit of both branches.
  2. For each file, compute diffs: base → branchA and base → branchB.
  3. If both branches made identical changes → merge them (no conflict).
  4. If one branch changed something and the other did not → take the change.
  5. 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."

KEY CONCEPT

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 → branchA diff: what branchA changed.
  • base → branchB diff: what branchB changed.
  • branchA vs branchB direct comparison: differences in the current state.

For each region in the file:

basebranchAbranchBResult
XXXX (no one changed it)
XYXY (only branchA changed)
XXZZ (only branchB changed)
XYYY (both made the same change)
XYZCONFLICT

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
  • <<<<<<< HEAD starts the "ours" version (what the branch you are merging into has).
  • ======= is the divider between the two versions.
  • >>>>>>> feature closes 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

merge basegreet = "hello"last common ancestorbranch A (HEAD)greet = "Hello, World!"formal capitalizationbranch B (feature)greet = "hi there"casual greetingCONFLICTsame line, two different changesGit pauses; you decide

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
PRO TIP

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>
WAR STORY

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 B shows the common ancestor commit.
  • Conflict markers: <<<<<<< ours, =======, >>>>>>> theirs. Edit to the final text, remove markers, git add, continue.
  • merge.conflictstyle = zdiff3 adds the common-ancestor content to markers — much better for decision-making.
  • git mergetool launches a visual 3-way merge editor. VS Code and IntelliJ have excellent built-ins.
  • git rerere auto-replays previously-resolved conflicts. Free productivity win for iterative rebases.
  • Reduce conflicts by rebasing daily, keeping PRs small, automating formatting, and using .gitattributes merge strategies for commonly-appended files.
  • git merge --abort / git rebase --abort cleanly 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 zdiff3 is available. Enable it globally.
  • Leaving conflict markers in committed files. grep -rn '<<<<<<< HEAD' . or your editor's linter should catch it — and Git tools refuse git commit with 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 .gitattributes for 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.

KNOWLEDGE CHECK

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?