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)"
Where lockfile rewrites actually come fromGenuine dependency changes are a minority. Most rewrites come from bulk update branches, from a developer running install with a different package manager version, and from tooling that reorders or reformats the file without changing the resolution.lockfile-touching commits, by causedependency bot branches64genuine dependency changes28different tool version19accidental, no manifest change11the two red bars are pure churn and are removable

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.

Why a hand-resolved lockfile is meaninglessA lockfile encodes a resolved graph. Picking lines from two versions produces a graph that satisfies neither manifest and that no resolver would have produced, and the result installs successfully because nothing checks it against the manifests.Two manifestsmerged cleanlyTwo lockfilesconflicting hunksHand resolutionlines from bothA graph nobody resolvedinstalls fineregeneration replaces the last two boxes with a graph the manifests actually describe

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'
Twelve update branches against three grouped onesEach open update branch holds its own rewritten lockfile, and every pair of them conflicts. Grouping reduces the number of rewrites directly, which reduces conflicts quadratically rather than linearly.Twelve branchesThree groupedlockfile rewrites open123conflicting pairs663pipeline runs123review decisions123conflicts scale with the square of the number of open rewrites

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.