Skip to content

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: union creates 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:

#!/bin/bash
# scripts/merge-theirs.sh — replace ours with theirs
cp "$3" "$2"
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

if git ls-files -u | grep -q '\.lock$'; then
  echo "ERROR: unresolved lockfile conflicts"
  exit 1
fi

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

  • [ ] .gitattributes is committed and checked into source control
  • [ ] Driver script is on $PATH or referenced by absolute path
  • [ ] CI runner setup includes the install-merge-drivers.sh step
  • [ ] Merge tested locally with git merge --no-commit before pushing
  • [ ] Lockfile still produces a valid file (run npm install / go mod tidy after merge to verify)
  • [ ] git config --get-all merge.<name>.driver returns 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.