Reducing lockfile churn in a busy repository Jump to heading
Measure conflicts in almost any active project and the lockfile is first by a wide margin. The cause is structural: any branch that adds, removes or updates a dependency rewrites a file that encodes the entire resolved graph, and two branches doing so in the same week are guaranteed to collide. The conflict is unresolvable by hand in any meaningful sense, and the volume is what makes it worth attacking rather than just automating around. This recipe reduces both, within conflict prevention by design.
When to use this approach Jump to heading
- The lockfile appears in most of your conflict resolutions.
- Dependency update branches conflict with each other routinely.
- Someone has resolved a lockfile by hand and the build behaved oddly afterwards.
- Reviews are dominated by thousands of generated lines.
- If dependencies change once a month, the automation below is fine but the churn is not your problem.
Step 1 — Measure where the rewrites come from Jump to heading
Not all lockfile changes are equal: a dependency bump is unavoidable, an accidental rewrite is not.
# How many commits touched the lockfile, and who by?
git log --since='6 months ago' --format='%an' --name-only -- package-lock.json \
| awk '/^[A-Z]/{a=$0; next} NF{print a}' | sort | uniq -c | sort -rn | head # How many of those changed the manifest too? The rest are accidental rewrites.
git log --since='6 months ago' --format='%H' -- package-lock.json | while read -r sha; do
git show --name-only --format='' "$sha" | grep -q '^package\.json$' \
|| echo "$sha lockfile-only"
done | wc -l # Verification: a lockfile-only commit usually means a tool rewrote it
git show --stat "$(git log --format='%H' -1 -- package-lock.json)" Step 2 — Remove the accidental rewrites Jump to heading
Different package-manager versions produce different lockfile formats, and a machine with an older one rewrites the file on every install.
{
"engines": { "node": ">=20.11", "npm": ">=10.4" },
"packageManager": "[email protected]"
} # Enforce it, so an install with the wrong version fails rather than rewriting
npm config set engine-strict true
corepack enable # for pnpm and yarn, pins the version from packageManager # Verification: the lockfile version field should be stable across machines
jq '.lockfileVersion' package-lock.json
git log -p --since='3 months ago' -- package-lock.json | grep -c '"lockfileVersion"' A changing lockfileVersion in history is the clearest evidence of this problem, and pinning the tool removes an entire category of conflicts without touching anything else.
Step 3 — Make CI the authority on what the lockfile should contain Jump to heading
- run: npm ci --ignore-scripts
- name: Lockfile matches the manifest
run: |
npm install --package-lock-only --ignore-scripts
git diff --exit-code package-lock.json # Locally, the same check before pushing
npm install --package-lock-only && git diff --exit-code package-lock.json \
&& echo "in sync" || echo "commit the regenerated lockfile" # Verification: a hand-edited lockfile fails the check
sed -i 's/"resolved":/"resolvedX":/' package-lock.json && npm install --package-lock-only \
&& git diff --stat package-lock.json; git checkout -- package-lock.json Step 4 — Resolve conflicts by regenerating, never by hand Jump to heading
# The resolution, whatever the conflict looks like
git checkout --theirs package-lock.json
npm install --package-lock-only --ignore-scripts
git add package-lock.json # Automate it with a driver so nobody has to remember
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 --ignore-scripts'
printf 'package-lock.json merge=lockfile linguist-generated=true\n' >> .gitattributes # Verification: an install from the regenerated file is clean
npm ci --dry-run >/dev/null && echo "lockfile and manifest agree" SAFETY WARNING — regeneration resolves against the registry as it is now, so the merged lockfile can pull a newer transitive version than either branch tested. That is usually correct and is never verified by the merge itself. Always let the pipeline run against the merged tree before it lands; a merge queue makes that automatic, as described in merge queues and required checks.
Step 5 — Cut the number of branches that rewrite it Jump to heading
The remaining churn comes from dependency update branches, and grouping them reduces it directly.
# How many open branches currently touch the lockfile?
git for-each-ref --format='%(refname:short)' refs/remotes/origin | while read -r b; do
git diff --name-only "origin/main...$b" 2>/dev/null | grep -q 'package-lock.json' && echo "$b"
done | wc -l {
"packageRules": [
{ "matchDepTypes": ["devDependencies"], "groupName": "dev tooling" },
{ "matchUpdateTypes": ["patch"], "groupName": "runtime patches" }
],
"prConcurrentLimit": 3,
"rebaseWhen": "conflicted"
} # Verification: fewer branches, each with one lockfile rewrite
gh pr list --label dependencies --json number --jq 'length' The grouping configuration is covered in grouping dependency updates to reduce PR noise.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Should the lockfile be committed at all? Jump to heading
Yes for applications — it is what makes an install reproducible, and removing it trades a conflict problem for a correctness problem. For libraries the answer differs: consumers resolve their own graph, so a committed lockfile constrains only your own CI, which is still worth having but matters less.
Does regenerating lose the versions we tested? Jump to heading
It can, and that is the honest cost. The regenerated graph satisfies the merged manifests using whatever the registry offers now, which may not be exactly what either branch tested. Running the pipeline against the merged tree is what converts that from a risk into a checked fact.
What about ecosystems where the lockfile is not JSON? Jump to heading
The same shape applies: pin the tool version, regenerate rather than merge, mark the file generated, and verify in CI. The commands differ — poetry lock --no-update, cargo generate-lockfile, go mod tidy — but the resolution is identical because the reasoning is.
Related Jump to heading
- Conflict Prevention by Design — the parent topic and the four collision shapes.
- Keeping Lockfiles Conflict-Free During Bulk Updates — the update-branch side of the same problem.
- A Custom Merge Driver for Lockfile Conflicts — the driver mechanism in detail.