A custom merge driver for lockfile conflicts Jump to heading

Two branches each add a dependency. Both regenerate the lockfile, both touch thousands of lines, and the merge produces a conflict spanning half the file β€” in a format nobody can resolve by reading, because the resolution is not a choice between two texts but a question of what the resolver would produce from the merged manifest. Git lets you answer that question directly by registering a driver for the path. This recipe builds one, applying the merge machinery explained in 3-Way Merge Fundamentals.

When to use this approach Jump to heading

  • Lockfile conflicts are a routine tax on merging, and resolving them by hand is guesswork.
  • Your ecosystem has a deterministic resolver, so regenerating from the manifest gives a canonical answer.
  • The file is genuinely generated: package-lock.json, yarn.lock, Cargo.lock, poetry.lock, go.sum.
  • Everyone can run the setup step that registers the driver locally, because Git will not do it for them.
  • Do not do this for source files, schema files, or anything a person writes. There, a conflict is information.

Step 1 β€” Understand why the default merge fails here Jump to heading

Why a text merge is the wrong tool for a generated fileFor source code, a three-way merge combines two sets of human edits and a conflict marks a genuine disagreement. For a lockfile, both sides are machine output derived from the manifest, so combining them line by line produces a file the resolver would never generate β€” internally inconsistent even when it merges cleanly.source code β€” text merge worksours: a human edittheirs: a human editcombine the two intentionsa conflict means two people disagreed β€”exactly what you want to be toldlockfile β€” text merge is meaninglessours: resolver outputtheirs: resolver outputa tree the resolver never producedeven a clean merge can be inconsistent β€”the answer must be regenerated, not combined

The right-hand side has a subtler failure than a noisy conflict: a lockfile merge that succeeds without conflict can still be wrong, because two independently-valid halves do not compose into a valid whole. Regeneration is the only operation that produces a file the resolver would actually emit.

Step 2 β€” Write a driver that regenerates rather than merges Jump to heading

Git calls a merge driver with four arguments: the base, ours, theirs, and the conflict marker size. It must write the result into the ours file and exit 0 for success.

#!/bin/sh
# scripts/merge-lockfile.sh
# Args: %O = base   %A = ours (also the output)   %B = theirs   %L = marker size
set -eu

base="$1"; ours="$2"; theirs="$3"

# The manifest has already been merged by the time this driver runs.
if [ ! -f package.json ]; then
  echo "merge-lockfile: package.json missing; cannot regenerate" >&2
  exit 1
fi

# Take theirs as a starting point, then let the resolver produce the truth.
cp "$theirs" package-lock.json

if ! npm install --package-lock-only --no-audit --no-fund >/dev/null 2>&1; then
  echo "merge-lockfile: regeneration failed; resolve package-lock.json by hand" >&2
  exit 1                      # non-zero leaves a normal conflict for a human
fi

cp package-lock.json "$ours"  # the driver's result goes in the "ours" file
: "$base"
exit 0

What changed: the driver ignores the textual conflict entirely and asks the package manager what the lockfile should be, given the manifest that the merge already produced.

chmod +x scripts/merge-lockfile.sh
sh -n scripts/merge-lockfile.sh && echo "syntax OK"

The failure path is as important as the success path. Exiting non-zero tells Git the driver could not resolve it, so the conflict is presented normally and a human decides β€” which is exactly right when the manifest itself is inconsistent.

Step 3 β€” Register the driver and the attribute Jump to heading

Two halves, in two different places, and only one of them is shared.

# Shared: which paths use the driver. Committed to the repository.
printf 'package-lock.json merge=npm-lock\n' >> .gitattributes
git add .gitattributes
git commit -m "chore: use a custom merge driver for package-lock.json"
# Local: what the driver actually runs. Git will NOT take this from a remote.
git config merge.npm-lock.name 'regenerate package-lock.json from the merged manifest'
git config merge.npm-lock.driver 'sh scripts/merge-lockfile.sh %O %A %B %L'

# Verify both halves are in place
git check-attr merge package-lock.json     # expect: merge: npm-lock
git config merge.npm-lock.driver           # expect: the command
The attribute travels; the driver definition does notThe gitattributes entry naming the driver is committed and reaches every clone. The config entry defining the command is deliberately local, because Git will not execute a command supplied by a remote. A clone that has the attribute but not the config silently falls back to the default text merge..gitattributespackage-lock.json merge=npm-lockcommitted β€” reaches everyone.git/configmerge.npm-lock.driver = …local only β€” never sharedboth present?checked at merge timedriver runslockfile is regeneratedsilent fallbackdefault text merge, no warning
# Put the local half in the setup script, and verify it
[ -n "$(git config merge.npm-lock.driver)" ] || {
  echo "run: sh scripts/setup-git.sh β€” the lockfile merge driver is not registered" >&2
  exit 1
}

SAFETY WARNING β€” a merge driver runs a command from your repository during an ordinary git merge. Anyone who can land a change to scripts/merge-lockfile.sh can execute code on every machine that merges afterwards. That is precisely why Git refuses to accept the driver definition from a remote, and why the script’s path should be covered by CODEOWNERS review. Never register a driver pointing at a script you have not read.

Step 4 β€” Verify it on a deliberate conflict Jump to heading

# 1. Two branches, each adding a different dependency
git checkout -b test/dep-a main
npm install --package-lock-only [email protected]
git commit -am "deps: add lodash"

git checkout -b test/dep-b main
npm install --package-lock-only [email protected]
git commit -am "deps: add dayjs"

# 2. Merge them β€” the manifest merges normally, the lockfile is regenerated
git merge test/dep-a
# 3. Confirm the result is what the resolver would produce
git status --short                 # no unmerged paths
npm ci --dry-run >/dev/null && echo "lockfile is consistent with package.json"
grep -c 'lodash\|dayjs' package-lock.json    # both dependencies present
Manifest merges; lockfile regeneratesThe two branches' manifest changes merge cleanly by text because they add different keys. The driver then discards both lockfile versions and asks the resolver to produce a lockfile for the merged manifest, which is the only version guaranteed to be internally consistent.package.jsonours: + lodashpackage.jsontheirs: + dayjsmerged manifestboth dependencies, by textthe resolver runs--package-lock-onlypackage-lock.jsonboth versions discardedthe driver never tries to combine themcanonical lockfileconsistent by construction

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not just gitignore the lockfile? Jump to heading

Because the lockfile is what makes a build reproducible: it pins the exact resolved version of every transitive dependency. Removing it means two developers and CI can resolve different trees from the same manifest, which produces the class of bug that only appears on one machine. The conflict is annoying; the non-determinism is worse.

Which files should never get a custom merge driver? Jump to heading

Anything a human authored. A driver that resolves conflicts without asking is right for generated artefacts whose canonical source is another file, and dangerous for source code, where a conflict is Git telling you two people made incompatible decisions. Restrict the attribute to generated paths and be specific about them.

Does the driver need to be installed on every machine? Jump to heading

Yes. The .gitattributes file naming the driver is committed and shared, but the config entry defining what the driver actually runs is local, because Git will not execute a command a remote supplied. Register it in the project’s setup script and have that script verify it, or the attribute silently falls back to the default merge on machines that skipped setup.