Git Custom Merge Drivers — DevOps Workflow¶
When two branches both modify a lockfile, generated config, or binary blob, Git creates a conflict on every merge — even though the file is machine-generated and a deterministic merge is often correct. Custom merge drivers let you define per-file merge logic that runs automatically during git merge, git pull, and git rebase, turning manual conflict resolution into a CI-friendly automated step.
Why DevOps Engineers Care¶
- Lockfile conflicts (
package-lock.json,yarn.lock,pnpm-lock.yaml,Cargo.lock,Pipfile.lock,go.sum) are pure data — a three-way merge of their entries is deterministic. - Generated configs (
.env,openapi.json,terraform.tfstate) can be merged programmatically. - Vendor/asset blobs can use a "theirs-wins" driver for no-fuss resolution.
- Manual resolution in CI is a pipeline blocker; automated drivers keep merges green.
Core Concepts¶
The three-way merge inputs¶
Every merge driver receives exactly three file paths as arguments:
| Arg | Description |
|---|---|
$O (Ancestor) |
Common ancestor version — the shared base |
$A (Ours) |
Current branch version — written here on success |
$B (Theirs) |
Incoming branch version — the change being merged |
The driver writes the resolved output to $A. Exit code 0 = success, 1 = conflict (Git falls back to marking conflict markers).
Two ways to register a driver¶
Option 1 — Global Git config (recommended for personal/team tools):
git config --global merge.<driver_name>.name "Human-readable description"
git config --global merge.<driver_name>.driver "<command> %O %A %B"
Option 2 — Per-repo .gitattributes:
# .gitattributes in the repo root (and checked into source control)
*.lock merge=union # union merge — keep both sets of entries
*.pbxproj merge=union # Xcode project files are also line-based
For a custom driver, define it in config and reference it in .gitattributes:
git config --global merge.lockfile.name "Deterministic lockfile merger"
git config --global merge.lockfile.driver "python3 scripts/merge-lockfile.py %O %A %B"
# .gitattributes
package-lock.json merge=lockfile
yarn.lock merge=lockfile
pnpm-lock.yaml merge=lockfile
Built-in Merge Drivers¶
Before writing a custom driver, know what's already available:
| Driver | Behaviour | Use when |
|---|---|---|
merge=union |
Concatenate all non-conflicting lines from both sides | Line-based files where duplicate entries are harmless (e.g., .gitignore) |
merge=union (via attribute) |
Same as above | |
merge=text |
Replace conflicting hunks with conflict markers | Default for unmarked files |
merge=binary |
Prefer $A (ours) entirely |
Binary files where merge is meaningless |
You can also use union as a strategy for a subset of files:
# In .gitattributes — union-merge all manifest/lock files
Gemfile.lock merge=union
poetry.lock merge=union
requirements.txt merge=union
Warning:
unioncreates duplicates if the same line is modified on both branches. Use it only for additive files (lockfiles, ignore rules, manifest lists).
Custom Merge Drivers — Practical Patterns¶
Pattern 1: Deterministic Lockfile Merger (npm/yarn/pnpm)¶
This is the most common DevOps use case. Package managers produce lockfiles by locking versions — a three-way diff can be merged deterministically.
#!/usr/bin/env python3
"""scripts/merge-lockfile.py
Three-way merge for package lockfiles.
Keeps all unique entries from both branches; if the same package is modified
on both sides, ours wins (deterministic, no conflict).
"""
import sys
def merge_lockfile(base, ours, theirs, output):
base_lines = set(open(base, errors='ignore').read().splitlines()) if base else set()
ours_lines = set(open(ours, errors='ignore').read().splitlines()) if ours else set()
theirs_lines = set(open(theirs, errors='ignore').read().splitlines()) if theirs else set()
# Keep entries that exist in any branch (union)
merged = base_lines | ours_lines | theirs_lines
# Resolve conflicts: same package, different version — ours wins (deterministic)
ours_only = ours_lines - theirs_lines
theirs_only = theirs_lines - ours_lines
# Entries modified on both sides: ours wins
common_modified = (ours_lines & theirs_lines) - base_lines
for line in sorted(common_modified):
# ours version already in ours_lines; skip theirs duplicate
pass
result = sorted(ours_lines | theirs_only)
with open(output, 'w') as f:
f.write('\n'.join(result) + '\n')
if __name__ == '__main__':
if len(sys.argv) != 4:
print("Usage: merge-lockfile.py <base> <ours> <theirs>", file=sys.stderr)
sys.exit(1)
merge_lockfile(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[2])
Pattern 2: JSON Config Merger (OpenAPI, Terraform, docker-compose)¶
For JSON config files, use a library-aware merge:
#!/usr/bin/env python3
"""scripts/merge-json.py
Deep three-way merge for JSON config files.
Uses a simple recursive merge: ours wins on scalar conflicts, lists are unioned.
"""
import json, sys, copy
def deep_merge(base, ours, theirs):
result = copy.deepcopy(base) if base else {}
for k, v in (theirs or {}).items():
if k not in result:
result[k] = copy.deepcopy(v)
elif isinstance(v, dict) and isinstance(result[k], dict):
result[k] = deep_merge(result.get(k), v, {})
elif isinstance(v, list) and isinstance(result[k], list):
# Union of list items (deduplicated)
seen = set(tuple(x.items()) if isinstance(x, dict) else str(x) for x in result[k])
for item in v:
key = tuple(item.items()) if isinstance(item, dict) else str(item)
if key not in seen:
result[k].append(copy.deepcopy(item))
seen.add(key)
else:
# Ours wins (current branch version)
result[k] = copy.deepcopy(v)
return result
if __name__ == '__main__':
base = json.load(open(sys.argv[1])) if sys.argv[1] and __import__('os').path.getsize(sys.argv[1]) > 0 else {}
ours = json.load(open(sys.argv[2])) if sys.argv[2] and __import__('os').path.getsize(sys.argv[2]) > 0 else {}
theirs = json.load(open(sys.argv[3])) if sys.argv[3] and __import__('os').path.getsize(sys.argv[3]) > 0 else {}
result = deep_merge(base, ours, theirs)
with open(sys.argv[2], 'w') as f:
json.dump(result, f, indent=2)
Pattern 3: Simple "Theirs Wins" Driver (vendor blobs)¶
For files that should always take the incoming version:
git config --global merge.theirs-wins.name "Theirs-wins merger"
git config --global merge.theirs-wins.driver "bash %P/merge-theirs.sh %O %A %B"
# .gitattributes
vendor/** merge=theirs-wins
Pattern 4: Trust Timestamp from theirs (Terraform state)¶
#!/usr/bin/env python3
"""scripts/merge-tfstate.py
Prefer theirs for terraform.tfstate files to avoid state corruption.
"""
import sys, shutil
shutil.copy(sys.argv[3], sys.argv[2]) # theirs -> ours
Setting Up in CI Pipelines¶
1. Distribute .gitattributes with the repo¶
# .gitattributes — committed to the repo
# Custom merge drivers
package-lock.json merge=lockfile
yarn.lock merge=lockfile
pnpm-lock.yaml merge=lockfile
Gemfile.lock merge=union
Cargo.lock merge=union
go.sum merge=union
2. Install driver on CI runners¶
For custom Python/perl drivers, ensure the script is available on all runners:
# In CI setup step (before any merge)
git config --global merge.lockfile.driver "python3 $CI_PROJECT_DIR/scripts/merge-lockfile.py %O %A %B"
git config --global merge.lockfile.name "Deterministic lockfile merger"
3. Verify driver is active before merge¶
# In CI: confirm the driver resolves cleanly
git merge --no-commit --no-ff origin/feature-branch
GIT_MERGE_AUTOEDIT=no git commit # fail if unresolved conflicts remain
4. Fallback: detect unresolvable conflicts¶
Driver Registry — Per-Project Setup¶
#!/bin/bash
# scripts/install-merge-drivers.sh
# Run once per machine / CI container setup
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
git config --global merge.lockfile.name "Deterministic lockfile merger"
git config --global merge.lockfile.driver "python3 ${SCRIPT_DIR}/merge-lockfile.py %O %A %B"
git config --global merge.jsoncfg.name "JSON config deep merger"
git config --global merge.jsoncfg.driver "python3 ${SCRIPT_DIR}/merge-json.py %O %A %B"
git config --global merge.theirs-wins.name "Theirs-wins merger"
git config --global merge.theirs-wins.driver "cp %3 %2"
echo "Merge drivers registered."
Verification Checklist¶
- [ ]
.gitattributesis committed and checked into source control - [ ] Driver script is on
$PATHor referenced by absolute path - [ ] CI runner setup includes the
install-merge-drivers.shstep - [ ] Merge tested locally with
git merge --no-commitbefore pushing - [ ] Lockfile still produces a valid file (run
npm install/go mod tidyafter merge to verify) - [ ]
git config --get-all merge.<name>.driverreturns the expected command
Key Benefit for DevOps¶
Custom merge drivers eliminate the "manual conflict resolution in CI" anti-pattern. A DevOps engineer who sets up merge=lockfile on package-lock.json can merge any two branches without touching the lockfile — the driver resolves it deterministically, the CI pipeline stays green, and developers never need to manually handle a lockfile conflict again.