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:
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:
- Ancestor → current branch (what you changed)
- 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 diff3changes 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=ourssilently 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 --abortafter 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
| Symptom | Likely Cause | Fix |
|---|---|---|
Conflict markers appear without ||||||| ancestor section | merge.conflictstyle is not diff3 | Run git config --global merge.conflictstyle diff3 and retry the merge |
| Binary file shows as conflicted but has no markers | Git cannot line-diff the binary | Add a merge=ours or custom driver in .gitattributes and re-run the merge |
git merge-tree exits 0 but the PR shows conflicts | Using the old one-argument form without --write-tree | Upgrade to git merge-tree --write-tree main feature-branch (requires Git v2.38+) |
| Mid-merge state persists after CI job | git merge --abort was not called after a failed dry-run | Add git merge --abort to the finally block of your CI script |
merge=ours silently drops incoming changes on source files | .gitattributes pattern is too broad | Narrow the glob to only generated files; use git check-attr merge -- <file> to audit |
| Merge base resolution fails in repos with grafts or shallow clones | History is incomplete | Run 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.
Related Jump to heading
- Resolving Complex Binary Conflicts in Git — step-by-step recipes for custom merge drivers, LFS locking, and attribute-based resolution of binary file conflicts
- Squash & Fixup Strategies — how to consolidate post-merge commit noise with
--squashand--fixupbefore landing on the main branch - Interactive Rebase Workflows — reordering and cleaning commits before merge to shrink conflict surface area
- Cherry-Pick & Backporting — applying the same 3-way algorithm to individual commits across release branches
- Conflict Resolution & Safe Merge Operations — the parent section covering all merge safety patterns for distributed engineering teams