Skip to content

Branch lockfile merge reinstates removed dependencies, which breaks --frozen-lockfile #13966

Description

@rupesh-parab-one-app

Verify latest release

  • I verified that the issue exists in the latest pnpm release

pnpm version

11.22.0, which is the latest stable release at the time I ran this. npm also carries 12.0.0-rc.6 on a prerelease tag, which I did not test. The defect also reproduces on 10.30.0. I ran both on Node 22.22.3.

Which area(s) of pnpm are affected? (leave empty if unsure)

No response

Link to the code that reproduces this issue or a replay of the bug

https://github.com/rupesh-parab-one-app/pnpm-lockfile-merge-repro — a self-contained shell script that needs no git history and no committed lockfile. Run ./repro.sh; note that the exit code is inverted, so exit 1 means the defect reproduced. Step 3 is a control that succeeds, and step 4 differs from it only by --merge-git-branch-lockfiles and fails with ERR_PNPM_OUTDATED_LOCKFILE

Reproduction steps

Warning: this script inverts the usual exit-code convention. It exits 1 when the defect reproduces, because step 4 is expected to fail. It exits 0 only if the defect does not reproduce. Read exit 1 as a successful reproduction.

Steps 1 and 2 are setup. Step 3 is a control that must succeed. Step 4 differs from step 3 only by the --merge-git-branch-lockfiles flag, and it aborts with ERR_PNPM_OUTDATED_LOCKFILE. Each of those two steps removes node_modules first, because a frozen install reports "Already up to date" and skips its own check when a previous install in the same directory left a matching node_modules behind.

Save this as repro.sh, make it executable, and run it from an empty directory with pnpm on the PATH. The script creates a repro/ subdirectory and stops if one already exists. The last line it prints is either REPRODUCED or NOT REPRODUCED; read that line, because a setup step that fails for an unrelated reason also exits non-zero. The same script is at https://github.com/rupesh-parab-one-app/pnpm-lockfile-merge-repro.

#!/usr/bin/env bash
#
# Reproduction: merging git branch lockfiles reinstates a removed dependency,
# which makes --frozen-lockfile fail.
#
# Exit code convention (note that it is inverted):
#   exit 1  -> the defect REPRODUCED (step 4 failed, which is the point)
#   exit 0  -> the defect did NOT reproduce (step 4 unexpectedly succeeded)
#
# Steps 1-3 are setup and a control. They must all succeed. Step 4 is the
# failing case and is expected to abort with ERR_PNPM_OUTDATED_LOCKFILE.

set -e

mkdir repro && cd repro

cat > package.json <<'EOF'
{
  "name": "repro-merge-deletion",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "is-number": "7.0.0",
    "is-positive": "3.1.0"
  }
}
EOF

printf 'packages: []\n' > pnpm-workspace.yaml

# 1. Create a branch lockfile while both dependencies exist.
echo '### step 1: create the branch lockfile (both dependencies present)'
pnpm install --lockfile-only
cp pnpm-lock.yaml pnpm-lock.some-branch.yaml

# 2. Remove is-positive from package.json, then refresh the main lockfile.
echo '### step 2: remove is-positive, refresh the main lockfile'
cat > package.json <<'EOF'
{
  "name": "repro-merge-deletion",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "is-number": "7.0.0"
  }
}
EOF
rm pnpm-lock.yaml
pnpm install --lockfile-only

# A frozen install reports "Already up to date" and skips its own check when a
# previous install in this directory left a matching node_modules behind. Steps
# 3 and 4 each start from no node_modules so that both actually run the check
# and differ only by the --merge-git-branch-lockfiles flag.

# 3. A frozen install succeeds when branch lockfiles are not merged.
echo '### step 3 (control): frozen install without merging -- expected to succeed'
rm -rf node_modules
pnpm install --frozen-lockfile
echo '### step 3 succeeded, as expected'

# 4. The same frozen install fails when branch lockfiles are merged.
echo '### step 4: frozen install with merging -- expected to fail'
rm -rf node_modules
set +e
pnpm install --merge-git-branch-lockfiles --frozen-lockfile
step4=$?
set -e

echo
if [ "$step4" -ne 0 ]; then
  echo "REPRODUCED: step 4 failed with exit ${step4} (expected ERR_PNPM_OUTDATED_LOCKFILE)."
  exit 1
fi
echo 'NOT REPRODUCED: step 4 succeeded. The defect may be fixed in this pnpm version.'
exit 0

Step 3 succeeds. Step 4 fails with:

ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with <root>/package.json

  Failure reason:
  specifiers in the lockfile don't match specifiers in package.json:
* 1 dependencies were removed: is-positive@3.1.0

Describe the Bug

mergeLockfileChanges merges two lockfiles by taking the union of their keys. It has no way to express a deletion. mergeDict builds its result from Object.keys(ourDict).concat(Object.keys(theirDict)), so a dependency that is present in either input is present in the output.

A branch lockfile is a snapshot of the dependency graph at the time the branch last ran an install. If a dependency is removed on the main branch after that snapshot is taken, the branch lockfile still lists it. The merge cannot tell the difference between these two cases:

  • The main branch removed the dependency, and the branch lockfile is stale.
  • The branch added the dependency, and the main lockfile does not have it yet.

The merge treats both as an addition, so it reinstates the removed dependency. The frozen-lockfile check then compares the merged lockfile against package.json, finds a dependency that package.json does not declare, and fails.

The reproduction uses one stale branch lockfile and one removed dependency. The effect accumulates: each stale branch lockfile that predates a removal reinstates that dependency.

Impact

This defect does not produce an incorrect dependency tree. That limit bounds the severity.

Any install that performs resolution reconciles the importers against package.json and prunes the reinstated dependency. I confirmed this for both --no-frozen-lockfile and --lockfile-only: the dependency does not reach node_modules, and it is not present in the lockfile that pnpm writes. The merged object is repaired before anything is materialised.

So the reinstated dependency is only ever observable in the one path that does not resolve, which is --frozen-lockfile. There, pnpm compares the merged lockfile against package.json, finds the extra entry, and aborts.

The practical effect is therefore narrow: --frozen-lockfile cannot be combined with --merge-git-branch-lockfiles once any branch lockfile predates a dependency removal.

The available workaround, and what it does not restore

Warning: the workaround below stops the pipeline from checking whether the committed lockfile is current. Read the limits stated after the commands before you adopt it.

A project can split the install into two commands:

pnpm install --merge-git-branch-lockfiles --lockfile-only
pnpm install --frozen-lockfile

The first command merges, resolves, repairs the merged object and writes pnpm-lock.yaml. The second command then validates that freshly written file, so it passes. This works, and I mention it so that other readers of this issue are not blocked.

The second command validates the lockfile that the first command generated seconds earlier, not the lockfile that is committed to the repository. If the committed lockfile is stale, the first command silently repairs it and the second command passes. A stale committed lockfile therefore becomes undetectable.

I verified this on a workspace whose committed lockfile was missing an entire project and several dependencies. A single pnpm install --frozen-lockfile rejected it, correctly. The two-command sequence accepted it, because the first command had already rewritten the file.

That is a property of splitting the install, not a separate defect. A project that adopts the split to keep its pipeline green silently gives up the check that its committed lockfile is current, which is why fixing the merge is worth more than working around it.

Expected Behavior

pnpm install --merge-git-branch-lockfiles --frozen-lockfile should succeed when the main lockfile matches package.json and the only difference in a branch lockfile is a dependency that the main branch has since removed.

The merge needs a way to distinguish "added on the branch" from "removed on main". Two fixes would supply that, and they differ in cost:

  1. The cheaper fix. Intersect the merged importer entries with the declared dependencies in each package.json, before the frozen-lockfile check runs.
  2. The more expensive fix. Perform a three-way merge against the common ancestor, which supplies the distinction directly.

Which Node.js version are you using?

22.22.3

Which operating systems have you used?

  • macOS
  • Windows
  • Linux

If your OS is a Linux based, which one it is? (Include the version if relevant)

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions