`pnpm install --force` does not re-import a package whose integrity changed
Last pnpm version that worked
12.4.0
pnpm version
12.4.1 (also 12.4.2 and main)
Code to reproduce the issue
A custom resolver from the pnpmfile resolvers hook resolves [email protected] to a tarball, computing the integrity from the bytes on disk. Between the two installs the tarball is replaced with different bytes at the same name@version, which is the case shouldRefreshResolution exists to handle.
mkdir -p /tmp/pnpm-force-repro && cd /tmp/pnpm-force-repro
build () {
rm -rf stage && mkdir -p stage/package
echo '{"name":"dep-a","version":"1.0.0","main":"index.js"}' > stage/package/package.json
echo "module.exports = '$1'" > stage/package/index.js
( cd stage && COPYFILE_DISABLE=1 tar -czf "../$2" package )
}
build OLD old.tgz
build NEW new.tgz
echo '{"name":"repro","version":"1.0.0","dependencies":{"dep-a":"1.0.0"}}' > package.json
cat > .pnpmfile.cjs <<'EOF'
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const served = path.join(__dirname, 'served.tgz')
module.exports = {
resolvers: [{
canResolve: (wanted) => wanted.alias === 'dep-a',
resolve: async () => ({
id: '[email protected]',
resolution: {
tarball: 'file:served.tgz',
integrity: 'sha512-' + crypto.createHash('sha512')
.update(fs.readFileSync(served)).digest('base64'),
},
manifest: { name: 'dep-a', version: '1.0.0' },
resolvedVia: 'custom',
}),
shouldRefreshResolution: () => true,
}],
}
EOF
cp old.tgz served.tgz
pnpm install
cat node_modules/dep-a/index.js # module.exports = 'OLD'
cp new.tgz served.tgz # republish different bytes at the same name@version
pnpm install --force
cat node_modules/dep-a/index.js
grep -o 'sha512-.\{12\}' pnpm-lock.yamlExpected behavior
After the second install, node_modules/dep-a/index.js contains NEW, matching the integrity that install wrote to pnpm-lock.yaml. That is what 12.4.0 does.
Actual behavior
On 12.4.1 and later the file still contains OLD while pnpm-lock.yaml records the new integrity. The install exits 0 and reports Packages: +1 with no warning, so the next install sees no drift and the tree stays stale.
The fetch is fine. The store grows by the new package's files on every run, so the new content is downloaded and ingested. Only the import into the virtual store is skipped.
Same fixture, same store growth, one variable:
| pnpm | node_modules after install --force |
|---|---|
| 12.2.1 | NEW |
| 12.3.2 | NEW |
| 12.4.0 | NEW |
| 12.4.1 | OLD |
| 12.4.2 | OLD |
--force is the trigger. On 12.4.2, any second install that reaches the resolver without --force writes NEW to both the lockfile and node_modules. So --force, documented as "relink packages an earlier install already materialized", is the only thing preventing the relink.
Reproduced with a file: tarball and with one served over HTTP, so the transport is not a factor.
Additional information
Cause
fc56dc12ec ("refactor(rust): give the install phases a shared context and a typed lockfile view", pnpm/pnpm#14737) moved the frozen path's "--force nulls the current lockfile" rule into LockfileEntries::of_previous_install and applied it to the fresh-resolve path too. At 12.4.0 that path passed the current lockfile unconditionally:
current_snapshots: current_lockfile.and_then(|lockfile| lockfile.snapshots.as_ref()),
current_packages: current_lockfile.and_then(|lockfile| lockfile.packages.as_ref()),The rule is safe where it came from and unsafe where it went, because current_entries feeds two decisions that read --force with opposite polarity:
- The skip,
current_entry_unchangedincreate_virtual_store/snapshot_plan.rs, already begins with!probe.policy.force. It handles--forceon its own, so emptyingcurrent_entrieschanges nothing here. - The re-import,
force_import = package_content_changed(batch.current_packages, batch.packages, snapshot_key)increate_virtual_store/warm.rs, with the same call incold.rs. That helper iscurrent.is_some() && !integrity_equal(...)increate_virtual_store/cache_keys.rs, so an emptycurrentmakes itfalse.
force_import false means SlotImportSource.force is false, and repair_incomplete_dir in import_indexed_dir.rs then returns early because marker_present finds the package.json an earlier install wrote. The previous install's files stay in the slot.
Worth noting for the port: pnpm 11's equivalent, isIntegrityEqual(resolution, undefined), returns false for a missing current entry, so its forceImportPackage is true. The Rust helper returns false for the same input. Two versions, one missing value, opposite conclusions.
Suggested fix
Have the import honor --force directly rather than inferring it from a lockfile comparison: carry the reuse policy's force into WarmLinkBatch and the cold capture, and or it into force_import at both sites, plus the equivalent in link_hoisted_modules/dir_clone.rs. Since the skip already gates on policy.force, the if force { LockfileEntries::default() } branch in of_previous_install can be dropped in the same change.
That also covers a related case I ran into while narrowing this down. On both 11 and 12, pnpm install --force does not restore a virtual store slot whose files changed after the install that created them, because the completion marker is only checked for existence. Threading policy.force to the import makes --force mean what it documents.
Scope
Two nearby behaviors are not part of this regression, in case they come up. On a plain second install with the tree in sync, shouldRefreshResolution is never called on either 11 or 12: the whole-tree up-to-date gate short-circuits before the pnpmfile is loaded, so nothing is re-resolved or fetched. Deleting the store does not change that on either version. Both are the same across versions, but together they do mean the hook cannot currently trigger a refresh on its own, since the one flag that gets past the gate is the one this issue is about. I can open a separate issue for that if it is worth discussing.
Node.js version
24.21.0
Operating System
macOS
Written by an agent (Claude Code, claude-opus-5).
Source: pnpm/pnpm