Merge Strategies & gitattributes Jump to heading
Most conflict pain is treated as an unavoidable cost of collaboration, and a surprising amount of it is configuration. Gitβs merge machinery is pluggable at three levels β the strategy that decides the overall approach, the options that tune it, and per-path drivers that replace it entirely for specific files β and almost nobody touches any of them. The result is teams hand-resolving conflicts in files that have a deterministic correct resolution, and arguing about whitespace changes Git can be told to ignore. This part of Conflict Resolution & Safe Merge Operations covers all three levels.
Prerequisites Jump to heading
The Three Levels of Control Jump to heading
Knowing which level a problem belongs to is most of the work. A conflict caused by a whitespace change is a strategy-option problem; a conflict in a lockfile is a driver problem; a conflict in genuinely overlapping logic is neither, and no configuration will help.
Step 1 β Know What the Default Strategy Does Jump to heading
Since Git 2.33 the default is ort, a reimplementation of the old recursive strategy. It produces better results on renames and is substantially faster on large repositories, and it occasionally resolves something recursive would have conflicted on.
# Which strategy is in use?
git -c merge.verbosity=5 merge --no-commit --no-ff other-branch 2>&1 | head -5
git merge --abort # Compare the two explicitly on a merge you already know conflicts
git merge -s ort --no-commit other-branch 2>&1 | tail -3; git merge --abort
git merge -s recursive --no-commit other-branch 2>&1 | tail -3; git merge --abort # Verification: your client's default
git --version
git config --get pull.twohead || echo "(unset β ort on 2.33+)" The practical advice is to leave the strategy alone. The comparison in choosing between the ort and recursive strategies covers the rare cases where the older one behaves differently enough to matter.
Step 2 β Use Strategy Options Where They Are Honest Jump to heading
-X options change how a merge resolves, and two of them are genuinely useful while one is frequently misused.
# Legitimate: a reformatting commit on one side created whitespace-only conflicts
git merge -X ignore-all-space other-branch
# Legitimate: a generated file where one side is authoritative for this merge
git merge -X ours other-branch # prefer our side ONLY in conflicting hunks # Verification: what did the option actually change?
git diff HEAD@{1} HEAD --stat
git log -1 --format='%H %s' SAFETY WARNING β
-X oursresolves conflicting hunks in favour of your side without telling you what it discarded, and it is not the same as-s ours, which discards the other branchβs changes entirely. Both silently drop work, and a merge commit produced by either looks exactly like a normal one in history. Use them only where the discarded content is genuinely regenerable, and record in the commit message that you did.
Step 3 β Let Attributes Decide Before a Conflict Exists Jump to heading
The most effective control is per path, declared once, applying to everyone automatically.
cat >> .gitattributes <<'ATTR'
# Generated: regenerate rather than merge
package-lock.json merge=lockfile linguist-generated=true
schema.graphql merge=generated
# Append-only: keep both sides
CHANGELOG.md merge=union
docs/decisions/*.md merge=union
# Binary: never attempt a textual merge
*.png binary
*.xlsx binary -merge
ATTR # Verification: what will Git do with each path on a conflict?
git check-attr merge -- package-lock.json CHANGELOG.md assets/logo.png merge=union keeps both sidesβ lines for append-only files, which turns a guaranteed conflict in a changelog into no conflict at all β the detail is in union merge for append-only files.
Step 4 β Define Drivers for Files With a Deterministic Resolution Jump to heading
A driver is a command Git runs instead of merging. For generated files the correct resolution is always βregenerateβ, and a driver expresses that.
git config merge.lockfile.name 'regenerate the lockfile from the manifest'
git config merge.lockfile.driver 'cp %B %A && npm install --package-lock-only --silent'
git config merge.generated.name 'regenerate from source'
git config merge.generated.driver 'npm run codegen --silent && cp %A %A' # Verification: force a conflict and confirm it resolves without intervention
git merge other-branch 2>&1 | grep -i 'package-lock' || echo "lockfile resolved automatically" Drivers live in local configuration rather than in the repository, because a driver is a command and honouring commands from a cloned repository would be a security problem. Distribute them through the include mechanism in shipping a team gitconfig with includeIf.
Step 5 β Make Binary Files Fail Fast Jump to heading
A binary file with no merge tool produces a conflict nobody can resolve textually, and the worst outcome is somebody resolving it anyway.
# Mark it binary so Git refuses rather than producing a corrupt file
printf '*.xlsx binary\n*.psd binary\n' >> .gitattributes # And define what to do instead, per format
git config merge.keepboth.name 'keep both versions side by side'
git config merge.keepboth.driver 'cp %A "%A.ours" && cp %B "%A.theirs" && exit 1' # Verification: the conflict names both files for a human to reconcile
git check-attr merge -- report.xlsx Failing loudly with both versions on disk is better than any automatic resolution, because a person with the two files and the right application can produce the correct answer, and Git cannot. The approach is covered further in resolving complex binary conflicts in git.
Choosing the Right Level for a Recurring Conflict Jump to heading
When the same file conflicts repeatedly, work down the levels rather than up. The first question is whether the file is source at all β if a command reproduces it, the answer is a driver and the conflict disappears permanently. The second is whether the file is append-only, in which case union merge removes the conflict without any command. The third is whether the conflicts are caused by formatting rather than content, which is a strategy-option question and usually also a signal that a formatter should run in a hook rather than in occasional sweeps.
Only after all three should a recurring conflict be treated as a genuine design problem β two areas of responsibility colliding in one file β which is the subject of conflict prevention by design. Getting the order wrong is common and expensive: teams restructure a module to avoid conflicts that a three-line attribute would have removed, or configure an attribute to paper over a genuine coordination problem that then surfaces somewhere less visible.
It is also worth being precise about what these mechanisms can and cannot promise. Attributes and drivers make merges deterministic; they do not make them correct. A lockfile regenerated from two merged manifests is internally consistent and may resolve a dependency to a version neither branch tested. A union-merged changelog contains both sidesβ entries in an order nobody chose. In both cases the automation is doing the right thing and the result still needs the pipeline to run against the merged tree β which is the argument for a merge queue, covered in merge queues and required checks.
Finally, document the attributes as you would any other rule. A merge=union on a file that later stops being append-only produces duplicated content nobody can explain, and a driver that regenerates a file whose generator has been removed fails in a way that looks like Git being broken. A comment per line, and an occasional review of what the file still claims, keeps the mechanism trustworthy.
Configuration Reference Jump to heading
| Attribute or setting | Effect | When to use |
|---|---|---|
merge=union | Keeps both sidesβ lines | Append-only files: changelogs, decision logs |
merge=<driver> | Runs a command instead of merging | Generated files with a deterministic resolution |
binary | No textual merge or diff | Images, documents, archives |
-merge | Conflict always, never auto-resolve | Files where a wrong merge is worse than a conflict |
-X ignore-all-space | Ignores whitespace differences | After a reformatting commit on one side |
-X ours / -X theirs | Picks a side in conflicting hunks only | Regenerable content, recorded in the message |
-s ours | Discards the other branchβs changes entirely | Almost never β it records a merge that merged nothing |
merge.conflictStyle=zdiff3 | Shows the common ancestor | Always; it turns guessing into deciding |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Lockfile conflicts on every merge | Treated as source | Add a merge driver that regenerates it |
| Changelog conflicts on every release | Two branches appended at the same place | merge=union |
| Whole file conflicts after a reformat | Whitespace changes on one side | -X ignore-all-space for that merge only |
| A driver does nothing | Driver defined in the repository, not locally | Distribute it via the config include |
| Binary file merged into corruption | Not marked binary | Mark it, and define a keep-both driver |
| Merge silently dropped a change | -X ours or -s ours was used | Check the merge commit; prefer explicit resolution |
Frequently Asked Questions Jump to heading
Should we change the default merge strategy? Jump to heading
No. ort is better than its predecessor on essentially every axis, and the cases where the old behaviour differed are rare enough that discovering one is more likely to indicate a genuine conflict than a strategy problem. Spend the effort on attributes, which apply automatically and to everyone.
Is merge=union safe? Jump to heading
For genuinely append-only files, yes β it produces both sidesβ lines with no conflict. For anything else it is dangerous, because it will happily interleave two versions of a function into something that neither compiles nor resembles either side. Apply it per path, never as a default.
Why can drivers not be committed to the repository? Jump to heading
Because a driver is a shell command, and honouring commands from a cloned repository would mean cloning could execute code. Git therefore reads driver definitions only from local configuration. The attributes that reference a driver are committed; the definition has to be installed, which is what the configuration include is for.
Do these settings apply to rebases and cherry-picks as well as merges? Jump to heading
Yes β all of them use the same merge machinery, so attributes, drivers and the conflict style apply identically. Strategy options can be passed to rebase and cherry-pick with -X as well, which is useful when replaying a branch across a reformatting commit.
Can attributes make a merge wrong? Jump to heading
They can make it wrong quietly, which is the risk worth naming. A union merge on a file that stopped being append-only, a driver whose generator has been removed, a binary mark on a file people expect to diff β each produces a result that looks fine and is not, and none of them raises a conflict to alert anyone. The mitigation is a periodic review of the attributes file alongside a check in the pipeline that the files it governs still behave the way the attribute assumes: generated output still regenerates identically, append-only files still have no in-place edits, binary files still have no textual representation anyone relies on.
Related Jump to heading
- Choosing Between the ort and recursive Strategies β what actually changed, and when it matters.
- Resolving Whitespace Conflicts With Strategy Options β surviving a reformatting commit on one side.
- Union Merge for Append-Only Files β removing changelog conflicts permanently.
- Keeping Generated Files Out of Merge Conflicts β drivers that regenerate rather than merge.
- Diffing Binary Formats With textconv β making binary changes readable in review.