perf: skip resolution when only pnpm-lock.yaml is missing (pnpm + pacquet) (#12004)

* perf: skip resolution when only pnpm-lock.yaml is missing

When pnpm-lock.yaml is absent but node_modules/.pnpm/lock.yaml exists and still
satisfies the manifest, reuse the materialized snapshot to regenerate the
wanted lockfile instead of walking the registry to rebuild it. Closes the
cache+node_modules variation gap in the vlt.sh benchmarks for the pnpm CLI
side; the pacquet port is tracked separately at #11993.

`--frozen-lockfile` still fails when pnpm-lock.yaml is absent: the regenerated
file must be committed, so failing loudly is the correct behavior for CI.

* perf(pacquet): port the cache+node_modules shortcut

When `pnpm-lock.yaml` is absent but `node_modules/.pnpm/lock.yaml` exists
and still satisfies the manifest, synthesize the wanted lockfile from the
materialized snapshot and take the frozen-install path. The install skips
resolution and regenerates `pnpm-lock.yaml` from the synthesized object.

Mirrors the pnpm-side change at 8a2146b7be (#12004). The synthesis path
preserves CI semantics: `--frozen-lockfile` still errors with
`NoLockfile` when `pnpm-lock.yaml` is missing, because the regenerated
file must be committed.

For workspace installs (where `pnpm-workspace.yaml` is present),
`optimistic_repeat_install` pre-empts the install with "Already up to
date" before the synthesis can fire — pnpm's `checkDepsStatus` has the
same gap. That's a separate parity fix; the integration test removes the
workspace-state file to exercise the dispatch path the synthesis lives
in. Real-world single-project installs hit the
`wanted lockfile missing` gate at `optimistic_repeat_install.rs:149`
directly and reach the synthesis without extra setup.

* style(pacquet): apply rustfmt

* refactor: inline lockfile-emptiness check instead of adding a derived flag
This commit is contained in:
Zoltan Kochan
2026-05-28 00:47:45 +02:00
committed by GitHub
parent c94b4f89c7
commit a33c4bfcb0
6 changed files with 234 additions and 5 deletions

View File

@@ -0,0 +1,9 @@
---
"@pnpm/installing.deps-installer": patch
"@pnpm/installing.context": patch
"pnpm": patch
---
Skip dependency re-resolution when `pnpm-lock.yaml` is missing but `node_modules/.pnpm/lock.yaml` exists and still satisfies the manifest. `pnpm install` now reuses the materialized snapshot to regenerate `pnpm-lock.yaml` instead of walking the registry to rebuild it from scratch, turning the cache+node_modules variation into a near-no-op for users who deleted the lockfile but kept the install [#11993](https://github.com/pnpm/pnpm/issues/11993).
`--frozen-lockfile` still refuses to proceed when `pnpm-lock.yaml` is absent — the regenerated lockfile must be committed, so failing loudly is the correct behavior for CI.

View File

@@ -122,10 +122,14 @@ export async function readLockfiles (
}
}
}
const existsWantedLockfile = files[0] != null
const existsCurrentLockfile = files[1] != null
const wantedLockfile = files[0] ??
(currentLockfile && clone(currentLockfile)) ??
createLockfileObject(importerIds, sopts)
let wantedLockfileIsModified = false
// Cloning the current lockfile means the disk copy of the wanted lockfile is
// stale, so flag it for rewriting after the install completes.
let wantedLockfileIsModified = !existsWantedLockfile && existsCurrentLockfile
for (const importerId of importerIds) {
if (!wantedLockfile.importers[importerId]) {
wantedLockfileIsModified = true
@@ -134,11 +138,10 @@ export async function readLockfiles (
}
}
}
const existsWantedLockfile = files[0] != null
return {
currentLockfile,
currentLockfileIsUpToDate: equals(currentLockfile, wantedLockfile),
existsCurrentLockfile: files[1] != null,
existsCurrentLockfile,
existsWantedLockfile,
existsNonEmptyWantedLockfile: existsWantedLockfile && !isEmptyLockfile(wantedLockfile),
wantedLockfile,

View File

@@ -43,6 +43,7 @@ import {
type CatalogSnapshots,
cleanGitBranchLockfiles,
getWantedLockfileName,
isEmptyLockfile,
type LockfileObject,
type ProjectSnapshot,
readWantedLockfile,
@@ -909,7 +910,7 @@ export async function mutateModules (
!needsFullResolution &&
opts.preferFrozenLockfile &&
(!opts.pruneLockfileImporters || Object.keys(ctx.wantedLockfile.importers).length === Object.keys(ctx.projects).length) &&
ctx.existsNonEmptyWantedLockfile &&
!isEmptyLockfile(ctx.wantedLockfile) &&
ctx.wantedLockfile.lockfileVersion === LOCKFILE_VERSION &&
await allProjectsAreUpToDate(Object.values(ctx.projects), {
catalogs: opts.catalogs,
@@ -939,6 +940,17 @@ Note that in CI environments, this setting is enabled by default.`,
)
}
if (!opts.ignorePackageManifest) {
// `--frozen-lockfile` (the CI default) means "fail if pnpm-lock.yaml is
// out of sync." Treat its absence as a sync failure even when the
// synthesized snapshot from node_modules/.pnpm/lock.yaml would satisfy
// the manifest — the developer needs to commit the regenerated file.
if (frozenLockfile && !ctx.existsWantedLockfile &&
Object.values(ctx.projects).some((project) => pkgHasDependencies(project.manifest))) {
throw new PnpmError('NO_LOCKFILE',
`Cannot install with "frozen-lockfile" because ${WANTED_LOCKFILE} is absent`, {
hint: 'Note that in CI environments this setting is true by default. If you still need to run install in such cases, use "pnpm install --no-frozen-lockfile"',
})
}
const _satisfiesPackageManifest = satisfiesPackageManifest.bind(null, {
autoInstallPeers: opts.autoInstallPeers,
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
@@ -972,7 +984,7 @@ Note that in CI environments, this setting is enabled by default.`,
ignoredBuilds: undefined,
}
}
if (!ctx.existsNonEmptyWantedLockfile) {
if (isEmptyLockfile(ctx.wantedLockfile)) {
if (Object.values(ctx.projects).some((project) => pkgHasDependencies(project.manifest))) {
throw new Error(`Headless installation requires a ${WANTED_LOCKFILE} file`)
}

View File

@@ -1,3 +1,4 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, jest, test } from '@jest/globals'
@@ -220,6 +221,78 @@ test(`prefer-frozen-lockfile+hoistPattern: should prefer headless installation w
project.has('.pnpm/node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep')
})
test(`prefer-frozen-lockfile: should reuse node_modules/.pnpm/lock.yaml when ${WANTED_LOCKFILE} is missing and the snapshot satisfies package.json`, async () => {
const project = prepareEmpty()
const { updatedManifest: manifest } = await install({
dependencies: {
'is-positive': '^3.0.0',
},
}, testDefaults())
project.has('is-positive')
const wantedLockfilePath = path.resolve(WANTED_LOCKFILE)
const lockfileBefore = fs.readFileSync(wantedLockfilePath, 'utf8')
fs.rmSync(wantedLockfilePath)
const reporter = jest.fn()
await install(manifest, testDefaults({ reporter, preferFrozenLockfile: true }))
expect(reporter).toHaveBeenCalledWith(expect.objectContaining({
level: 'info',
message: 'Lockfile is up to date, resolution step is skipped',
name: 'pnpm',
}))
expect(fs.existsSync(wantedLockfilePath)).toBe(true)
expect(fs.readFileSync(wantedLockfilePath, 'utf8')).toBe(lockfileBefore)
project.has('is-positive')
})
test(`prefer-frozen-lockfile: should re-resolve when ${WANTED_LOCKFILE} is missing and node_modules/.pnpm/lock.yaml does not satisfy package.json`, async () => {
const project = prepareEmpty()
await install({
dependencies: {
'is-positive': '^3.0.0',
},
}, testDefaults())
fs.rmSync(path.resolve(WANTED_LOCKFILE))
const reporter = jest.fn()
await install({
dependencies: {
'is-negative': '1.0.0',
},
}, testDefaults({ reporter, preferFrozenLockfile: true }))
expect(reporter).not.toHaveBeenCalledWith(expect.objectContaining({
level: 'info',
message: 'Lockfile is up to date, resolution step is skipped',
name: 'pnpm',
}))
project.has('is-negative')
})
test(`frozen-lockfile: should fail if ${WANTED_LOCKFILE} is missing even when node_modules/.pnpm/lock.yaml satisfies package.json`, async () => {
prepareEmpty()
const { updatedManifest: manifest } = await install({
dependencies: {
'is-positive': '^3.0.0',
},
}, testDefaults())
fs.rmSync(path.resolve(WANTED_LOCKFILE))
await expect(
install(manifest, testDefaults({ frozenLockfile: true }))
).rejects.toThrow(`Cannot install with "frozen-lockfile" because ${WANTED_LOCKFILE} is absent`)
})
test('prefer-frozen-lockfile: should prefer frozen-lockfile when package has linked dependency', async () => {
const projects = preparePackages([
{

View File

@@ -505,6 +505,86 @@ fn fresh_install_honors_enable_global_virtual_store() {
drop((root, mock_instance)); // cleanup
}
/// End-to-end coverage for the `cache+node_modules` shortcut. After a
/// successful install, deleting `pnpm-lock.yaml` but keeping `node_modules`
/// (and the materialized `node_modules/.pnpm/lock.yaml`) should let the
/// next `pacquet install` skip resolution and regenerate the lockfile
/// from the on-disk snapshot. Mirrors the pnpm-side fix at
/// <https://github.com/pnpm/pnpm/commit/8a2146b7be>.
#[test]
fn install_regenerates_lockfile_from_node_modules_when_wanted_is_missing() {
use std::process::Command;
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
eprintln!("Creating package.json...");
let manifest_path = workspace.join("package.json");
let package_json = serde_json::json!({
"dependencies": {
"@pnpm.e2e/hello-world-js-bin-parent": "1.0.0",
},
});
fs::write(&manifest_path, package_json.to_string()).expect("write to package.json");
eprintln!("Priming with the first install...");
pacquet.with_arg("install").assert().success();
let lockfile_path = workspace.join("pnpm-lock.yaml");
assert!(lockfile_path.exists(), "first install must produce pnpm-lock.yaml");
eprintln!("Removing pnpm-lock.yaml; node_modules/.pnpm/lock.yaml stays intact...");
fs::remove_file(&lockfile_path).expect("remove pnpm-lock.yaml");
// The test helper writes a `pnpm-workspace.yaml` for storeDir/cacheDir
// config, which makes `optimistic_repeat_install` treat this as a
// workspace install and skip the missing-wanted-lockfile invalidator.
// Drop the workspace state file so the freshness fast path falls
// through to the regular install dispatch where the synthesis logic
// lives. Real-world single-project installs (no pnpm-workspace.yaml)
// hit the `wanted lockfile missing` gate at
// `optimistic_repeat_install.rs:149` directly.
fs::remove_file(workspace.join("node_modules/.pnpm-workspace-state-v1.json"))
.expect("remove .pnpm-workspace-state-v1.json");
eprintln!("Re-running install with --reporter=ndjson...");
let pacquet_rerun = Command::cargo_bin("pacquet")
.expect("find the pacquet binary")
.with_current_dir(&workspace);
let output = pacquet_rerun
.with_args(["--reporter=ndjson", "install"])
.output()
.expect("run pacquet install");
assert!(
output.status.success(),
"second install must succeed: stderr={}",
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8(output.stderr).expect("stderr is utf-8");
let up_to_date = stderr
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|record| {
record.get("name").and_then(|v| v.as_str()) == Some("pnpm")
&& record.get("level").and_then(|v| v.as_str()) == Some("info")
&& record.get("message").and_then(|v| v.as_str())
== Some("Lockfile is up to date, resolution step is skipped")
});
assert!(
up_to_date.is_some(),
"expected `name: \"pnpm\" / level: \"info\"` up-to-date log in NDJSON stderr; got:\n{stderr}",
);
let regenerated = fs::read_to_string(&lockfile_path).expect("pnpm-lock.yaml was regenerated");
assert!(
regenerated.contains("@pnpm.e2e/hello-world-js-bin-parent")
&& regenerated.contains("@pnpm.e2e/hello-world-js-bin"),
"regenerated pnpm-lock.yaml must list the installed packages:\n{regenerated}",
);
drop((root, mock_instance)); // cleanup
}
/// End-to-end coverage for the no-op short-circuit. After a successful
/// install, a second `pacquet install --frozen-lockfile` against an
/// untouched workspace must skip materialization and emit pnpm's

View File

@@ -199,6 +199,12 @@ pub enum InstallError {
#[diagnostic(transparent)]
SaveCurrentLockfile(#[error(source)] SaveLockfileError),
/// Surfaces a failure to persist `pnpm-lock.yaml` after the
/// `cache+node_modules` shortcut regenerated it from the
/// materialized snapshot at `<virtual_store_dir>/lock.yaml`.
#[diagnostic(transparent)]
SaveWantedLockfile(#[error(source)] SaveLockfileError),
/// `pnpm-lock.yaml` doesn't match the on-disk `package.json` for
/// the project being installed. Mirrors upstream's
/// `ERR_PNPM_OUTDATED_LOCKFILE` thrown from
@@ -497,6 +503,32 @@ where
Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir)
.map_err(InstallError::LoadCurrentLockfile)?;
// Synthesize the wanted lockfile from `<virtual_store_dir>/lock.yaml`
// when `pnpm-lock.yaml` is absent and the materialized snapshot still
// satisfies the manifest. The install then skips resolution and
// regenerates `pnpm-lock.yaml` from the synthesized object. Mirrors
// pnpm's `installing/context/src/readLockfiles.ts` clone of
// `currentLockfile` into the wanted slot at
// <https://github.com/pnpm/pnpm/blob/8a2146b7be/installing/context/src/readLockfiles.ts#L125-L138>.
let synthesized_lockfile: Option<Lockfile> =
if lockfile.is_none() && !frozen_lockfile && prefer_frozen_lockfile {
current_lockfile.as_ref().and_then(|current| {
check_lockfile_freshness(
current,
manifest,
config,
&catalogs,
ignore_manifest_check,
)
.ok()
.map(|()| current.clone())
})
} else {
None
};
let lockfile_synthesized_from_current = synthesized_lockfile.is_some();
let lockfile = lockfile.or(synthesized_lockfile.as_ref());
// Lockfile-verification gate: re-apply `minimumReleaseAge` /
// `trustPolicy='no-downgrade'` to every entry in the loaded
// `pnpm-lock.yaml` before any resolver or fetcher runs.
@@ -684,6 +716,11 @@ where
prefix: prefix.clone(),
stage: Stage::ImportingDone,
}));
if lockfile_synthesized_from_current && config.lockfile {
wanted_lockfile
.save_to_path(&workspace_root.join(Lockfile::FILE_NAME))
.map_err(InstallError::SaveWantedLockfile)?;
}
update_workspace_state(
&workspace_root,
&build_workspace_state(config, node_linker, included, &project_manifests),
@@ -917,6 +954,21 @@ where
.map_err(InstallError::SaveCurrentLockfile)?;
}
// Regenerate `pnpm-lock.yaml` from the synthesized snapshot when
// the wanted lockfile was reconstructed from
// `<virtual_store_dir>/lock.yaml`. The no-op short-circuit above
// handles the common case; this branch covers the rare path where
// `.modules.yaml` was wiped or inconsistent and the frozen install
// had to relink.
if lockfile_synthesized_from_current
&& config.lockfile
&& let Some(synthesized) = synthesized_lockfile.as_ref()
{
synthesized
.save_to_path(&workspace_root.join(Lockfile::FILE_NAME))
.map_err(InstallError::SaveWantedLockfile)?;
}
// Write `node_modules/.pnpm-workspace-state-v1.json`. Mirrors
// upstream's `updateWorkspaceState` call at
// <https://github.com/pnpm/pnpm/blob/7ff112bac6/installing/commands/src/installDeps.ts#L447-L454>.