3-Way Merge Fundamentals Jump to heading

The 3-way merge algorithm is the engine behind every non-trivial git merge. It sits at the heart of Conflict Resolution & Safe Merge Operations — understanding its mechanics is what separates teams that resolve conflicts quickly from those that repeatedly corrupt merges or lose work. This page covers the algorithm itself, conflict style configuration, binary file routing, CI pre-flight validation, and commit hygiene patterns that keep merge history auditable.

Prerequisites Jump to heading

Before working through the configuration steps below, confirm the following:


Git 3-Way Merge: merge base, two diffs, and conflict detectionA flow diagram. At the top, a common ancestor commit splits into two branches. Each branch shows one commit. Arrows from both branches point down to a merge result node. Where both diffs touch the same lines, the result is labeled CONFLICT; where they touch different lines, the result is labeled AUTO-MERGED.Merge Basecommon ancestor commitHEAD (current)diff: ancestor → HEADfeature-branchdiff: ancestor → featureAUTO-MERGEDdiffs touch different linesCONFLICTdiffs overlap the same lines

Step 1 — Understand How the Algorithm Works Jump to heading

Git begins by locating the merge base — the most recent common ancestor of the two branches being merged. You can inspect it directly:

# Print the SHA of the merge base between HEAD and feature-branch
git merge-base HEAD feature-branch

With the base identified, Git generates two diffs and applies them to the base tree:

  1. Ancestor → current branch (what you changed)
  2. Ancestor → incoming branch (what they changed)

Where the two diffs touch different lines, Git applies both automatically. Where they touch the same lines, Git halts and marks the conflict. The important implication: if one side deleted a function and the other side modified it, Git detects the overlap and requires a human decision.

Verify: After any merge that produces conflicts, run git status --porcelain and look for UU (both-modified) prefixes to see exactly which files need resolution.

Git’s default merge strategy since v2.33 is ort (Optimized Recursive Trees). It replaced the older recursive strategy and avoids the quadratic cost of ancestor discovery in repositories with deep, tangled histories. You do not need to configure this; ort is the default on any Git v2.33+ installation.

Step 2 — Enable diff3 Conflict Style Jump to heading

Standard conflict markers show only the two sides:

<<<<<<< HEAD
current change
=======
incoming change
>>>>>>> feature-branch

This is often insufficient. Enabling diff3 inserts the common ancestor’s content as a third section, giving you the context to understand why each side differs:

# Apply globally — the most impactful single setting for reducing resolution time
git config --global merge.conflictstyle diff3

With diff3 active, the same conflict looks like this:

<<<<<<< HEAD
current change
||||||| merged common ancestors
original line from the ancestor
=======
incoming change
>>>>>>> feature-branch

Verify: Trigger a deliberate conflict on a test branch and confirm the ||||||| ancestor section appears in the conflict markers.

SAFETY WARNING: merge.conflictstyle diff3 changes how conflict markers are written but does not affect which lines are treated as conflicted. Existing conflict-marker parsers or IDE integrations that do not understand the ||||||| section may misbehave. Test your team’s tooling before rolling this out globally.

Step 3 — Classify Binary Files with .gitattributes Jump to heading

Git’s line-level diff engine cannot process binary files. When two branches modify the same binary independently, Git marks it conflicted but writes no conflict markers — the working tree simply contains one side’s blob. Teams must define explicit resolution policies before this happens.

Configure .gitattributes to classify files and assign merge drivers:

# Images and PDFs cannot be line-merged; treat as opaque blobs
*.png   binary
*.jpg   binary
*.pdf   binary

# Generated artifacts: always accept the current branch's version
*.dll   merge=ours
*.pyc   merge=ours

# Design source files: route to a custom driver (defined below)
*.psd   merge=lfs-ours
*.sketch merge=lfs-ours

Verify: After adding .gitattributes, run git check-attr merge -- path/to/file.png to confirm the attribute is applied correctly.

SAFETY WARNING: merge=ours silently accepts the current-branch version for every conflict without warning. Use it exclusively for generated artifacts where your branch is always authoritative. Never apply it to hand-authored source files — incoming changes will be silently discarded.

Step 4 — Register Custom Merge Drivers Jump to heading

Named merge drivers let you go beyond the built-in ours/theirs policies. Register a driver in .git/config (project-level) or ~/.gitconfig (global):

# The driver receives three temporary file paths:
#   %O  ancestor blob
#   %A  current-branch blob (must contain the result when the driver exits 0)
#   %B  incoming blob
#
# This driver unconditionally keeps the current-branch version:
git config merge.lfs-ours.name "Keep current branch for LFS-tracked design files"
git config merge.lfs-ours.driver "true"

The driver command is executed with %O, %A, and %B substituted. Exiting 0 signals a clean merge; exiting non-zero signals a conflict requiring manual resolution. The shell built-in true always exits 0 without modifying %A, making it the simplest possible “keep ours” driver.

For exclusive locking of binary files (preventing concurrent edits entirely), enable Git LFS file locking on the relevant paths — this is the safest approach for large binary assets where concurrent modification is never desirable.

Verify: Introduce a binary conflict on a test branch and confirm the driver resolves it automatically:

git merge test-branch-with-binary-conflict
# Should complete without conflict markers
echo "Exit: $?"

Step 5 — Validate Merges in CI with git merge-tree Jump to heading

Before a branch lands on the mainline, validate that the merge is clean without touching the target branch’s index or working tree. The modern approach uses git merge-tree (Git v2.38+):

# Simulate the merge of feature-branch into main — no index or worktree changes
git merge-tree --write-tree main feature-branch
echo "Exit code: $?"
# Exit 0 = clean merge; non-zero = conflicts exist

Parsing git status --porcelain for UU (both-modified) status codes lets scripts identify exactly which files are conflicted and create automated tickets or draft PR comments.

For repositories on older Git versions, the fallback approach works but requires careful cleanup:

git fetch origin main

# Attempt the merge without committing
git merge --no-commit --no-ff origin/main
if [ $? -ne 0 ]; then
  echo "CONFLICT_DETECTED"
  git merge --abort   # Always abort — never leave a partial merge
  exit 1
fi
git merge --abort
echo "MERGE_SAFE"

SAFETY WARNING: Always call git merge --abort after a dry-run check. Leaving the working tree in a mid-merge state causes every subsequent Git command to complain about an unfinished merge and can corrupt CI workspace state.

Verify: Run the merge-tree check against a branch you know has a conflict and confirm the non-zero exit code triggers the expected CI failure path.

Integration with Adjacent Workflows Jump to heading

Squash & Fixup Strategies Jump to heading

After a clean merge is confirmed, apply Squash & Fixup Strategies when merging short-lived feature branches to keep the main branch history linear. 3-way merge fundamentals govern whether a merge is possible; squash/fixup strategies govern how the resulting commits are shaped. The boundary: resolve conflicts first using the techniques on this page, then choose the merge mode that fits the branch’s history.

Interactive Rebase Workflows Jump to heading

Interactive rebase workflows let you reorder, combine, and clean up commits before the merge, reducing the surface area for conflicts. Running git rebase -i to squash noise commits before a merge means fewer hunks for the 3-way algorithm to reconcile. Use rebase to clean history on feature branches; use 3-way merge to land them.

Cherry-Pick & Backporting Jump to heading

Cherry-pick & backporting applies individual commits across branches using the same 3-way algorithm under the hood — each cherry-pick computes the diff of the picked commit against its parent and attempts to apply it to the target. Understanding the merge base concept from this page is a prerequisite for diagnosing cherry-pick conflicts.

Troubleshooting Jump to heading

SymptomLikely CauseFix
Conflict markers appear without ||||||| ancestor sectionmerge.conflictstyle is not diff3Run git config --global merge.conflictstyle diff3 and retry the merge
Binary file shows as conflicted but has no markersGit cannot line-diff the binaryAdd a merge=ours or custom driver in .gitattributes and re-run the merge
git merge-tree exits 0 but the PR shows conflictsUsing the old one-argument form without --write-treeUpgrade to git merge-tree --write-tree main feature-branch (requires Git v2.38+)
Mid-merge state persists after CI jobgit merge --abort was not called after a failed dry-runAdd git merge --abort to the finally block of your CI script
merge=ours silently drops incoming changes on source files.gitattributes pattern is too broadNarrow the glob to only generated files; use git check-attr merge -- <file> to audit
Merge base resolution fails in repos with grafts or shallow clonesHistory is incompleteRun git fetch --unshallow before merging in CI environments that use shallow clones

Frequently Asked Questions Jump to heading

What is the merge base in a 3-way merge? Jump to heading

The merge base is the most recent common ancestor commit shared by the two branches. Git computes two independent diffs (ancestor → current, ancestor → incoming) and applies both. Conflicts arise only where both diffs modify the same lines.

What is the difference between diff3 and the default conflict style? Jump to heading

The default style shows only the two conflicting sides. diff3 inserts a third ||||||| section containing the ancestor’s original lines, giving you the context to understand why each side diverged rather than just what each side contains now.

When should I use merge=ours in .gitattributes? Jump to heading

Only for generated artifacts where your branch is always authoritative — compiled outputs, lock files regenerated during CI, or vendored binaries. Never apply it to hand-authored source files; it silently discards all incoming changes without any warning.

How do I check for merge conflicts in CI without modifying the working tree? Jump to heading

Use git merge-tree --write-tree main feature-branch (Git v2.38+). It outputs the merged tree SHA to stdout and exits non-zero when conflicts exist, leaving the index and working tree completely untouched.

What replaced the recursive merge strategy in Git? Jump to heading

The ort (Optimized Recursive Trees) strategy became the default in Git v2.33. It avoids the quadratic cost of ancestor discovery in complex histories and is significantly faster on large repositories with deep branch graphs.