Keeping lockfiles conflict-free during bulk updates Jump to heading

A lockfile conflict has no correct manual resolution. The file is a serialised resolution of a dependency graph; picking lines from two versions of it produces a graph that neither branch tested and that may not even be internally consistent. Yet the default merge machinery cheerfully offers exactly that, and hours disappear into conflict markers in a file nobody wrote. The fix is to stop treating it as source, which is a three-part change to automated dependency updates.

When to use this approach Jump to heading

  • Several dependency branches are open at once and conflict with each other.
  • Someone has resolved a lockfile by hand and the build broke afterwards.
  • Reviews are dominated by thousands of generated lines nobody reads.
  • You maintain a monorepo where one lockfile serves many manifests.
  • If you have a single update branch at a time, a plain rebase is enough and the tooling below is overhead.

Step 1 β€” Declare the file generated Jump to heading

Two attributes change how Git and the review interface treat it.

# .gitattributes
cat >> .gitattributes <<'ATTR'
package-lock.json   -diff linguist-generated=true
pnpm-lock.yaml      -diff linguist-generated=true
poetry.lock         -diff linguist-generated=true
Cargo.lock          -diff linguist-generated=true
go.sum              -diff linguist-generated=true
ATTR
# Verification: Git now reports it as binary in diffs, which is the point
git check-attr -a package-lock.json
git diff --stat HEAD~1 -- package-lock.json

What changed: the file no longer produces a line-by-line diff, so nobody is invited to resolve it by hand, and reviews collapse it automatically. The same reasoning applies to other build artefacts β€” see keeping generated files out of review diffs.

Treating a lockfile as source against treating it as outputAs source, a lockfile produces enormous diffs, invites hand resolution, and yields graphs nobody tested. As output, it is regenerated from the manifests after every merge, so the resolution is always deterministic and always correct.Treated as sourceTreated as outputconflict resolutionby hand, line by lineregenerateresult correctnessuntested graphexactly the manifestsreview burdenthousands of linescollapsedCI enforcementnone possibleone commandnothing about the file changes β€” only what you are allowed to do to it

Step 2 β€” Resolve by regenerating, never by merging Jump to heading

The resolution procedure is the same for every ecosystem: take one side wholesale, then rebuild from the manifests.

# During a rebase or merge conflict on the lockfile
git checkout --theirs package-lock.json     # or --ours; the content is discarded anyway
npm install --package-lock-only             # rebuild from package.json
git add package-lock.json
git rebase --continue                       # or git merge --continue
# The equivalents, for reference
pnpm install --lockfile-only
poetry lock --no-update
cargo generate-lockfile
go mod tidy
# Verification: an install from the rebuilt lockfile is clean and reproducible
npm ci --dry-run >/dev/null && echo "lockfile agrees with the manifest"

Step 3 β€” Automate the resolution with a merge driver Jump to heading

Doing the above by hand every time is reliable and tedious. A custom merge driver does it automatically, and Git will call it whenever the file conflicts.

# Define the driver: take one side, then regenerate
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 && true'

# Point the file at it
echo 'package-lock.json merge=lockfile' >> .gitattributes
# Verification: force a conflict in a scratch branch and confirm it resolves itself
git switch -c /tmp-lock-test && npm install --no-save --package-lock-only [email protected]
git commit -am "test: bump lodash" && git switch - && git merge /tmp-lock-test
git branch -D /tmp-lock-test

Because the driver is defined in local configuration rather than in the repository, every clone needs it β€” ship it through the mechanism in shipping a team gitconfig with includeIf. The general pattern is covered in a custom merge driver for lockfile conflicts.

What the merge driver does in place of a three-way mergeInstead of attempting a textual three-way merge, the driver copies one side into place and runs the package manager to rebuild the lockfile from the merged manifests. The result is a graph that matches the manifests exactly.Conflictboth sides rewrote itDriver runscopy one sidePackage manager--package-lock-onlyreads merged manifestsDeterministic resultmatches the manifeststhe merged manifests are the input; the lockfile is only ever output

Step 4 β€” Verify in CI that the lockfile matches the manifest Jump to heading

A regenerated lockfile can still be wrong if someone edits the manifest without rebuilding. One job catches it.

# .github/workflows/lockfile.yml
name: lockfile
on: [pull_request]
jobs:
  consistent:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm install --package-lock-only --ignore-scripts
      - name: The lockfile must already match the manifest
        run: git diff --exit-code package-lock.json
# Verification locally, before pushing
npm install --package-lock-only && git diff --exit-code package-lock.json \
  && echo "in sync" || echo "commit the regenerated lockfile"

SAFETY WARNING β€” regeneration resolves the version graph from your manifests and the registry as it is right now, which can pull in a newer transitive version than either branch tested. That is usually what you want, but it means the merged result is not identical to either side. Always let the pipeline run on the merged tree; never regenerate and merge without a test run.

How a lockfile ages between full refreshesDirect dependency bumps update only the packages they name. The rest of the tree keeps whatever versions it resolved to when it was first installed, so without a periodic full refresh the transitive graph drifts years behind the direct one.Fresh installwhole tree currentmonth 0Direct bumps onlytransitives untouchedmonth 6Silent driftknown advisories below the surfacemonth 18Full refreshwhole tree resolved againmonth 19lockfile maintenance is the only thing that touches the packages nobody names

Step 5 β€” Schedule a full refresh so transitive versions do not rot Jump to heading

Direct bumps only touch the packages named in them. Everything else in the graph stays at whatever version it resolved to years ago.

{
  "lockFileMaintenance": {
    "enabled": true,
    "schedule": ["before 5am on the first day of the month"],
    "commitMessageAction": "refresh",
    "automerge": false
  }
}
# Verification: how old is the oldest thing in the tree?
npm ls --all --json 2>/dev/null | jq -r '.. | .version? // empty' | sort -u | head

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is merge=ours on a lockfile safe? Jump to heading

Only as one half of a procedure whose other half is regeneration. On its own it silently discards the incoming branch’s resolution while keeping its manifest changes, so the lockfile and the manifest disagree and the next clean install resolves something untested. Pair it with a driver or a pipeline step that rebuilds.

Why not just commit no lockfile at all? Jump to heading

Because then every install resolves differently and nothing is reproducible β€” the exact problem lockfiles exist to solve. The answer is not to remove the file but to stop treating it as something humans edit.

What about ecosystems where the lockfile contains hashes? Jump to heading

Regeneration recomputes them, which is the point: hand-merged hashes are how you end up with an integrity failure at install time in a branch that looked fine in review. Let the tool write them.