From a33c4bfcb0ffee1acb9495bbebc4a66a9164f0b1 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 28 May 2026 00:47:45 +0200 Subject: [PATCH] perf: skip resolution when only pnpm-lock.yaml is missing (pnpm + pacquet) (#12004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ...se-current-lockfile-when-wanted-missing.md | 9 +++ installing/context/src/readLockfiles.ts | 9 ++- .../deps-installer/src/install/index.ts | 16 +++- .../test/install/frozenLockfile.ts | 73 +++++++++++++++++ pacquet/crates/cli/tests/install.rs | 80 +++++++++++++++++++ pacquet/crates/package-manager/src/install.rs | 52 ++++++++++++ 6 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 .changeset/reuse-current-lockfile-when-wanted-missing.md diff --git a/.changeset/reuse-current-lockfile-when-wanted-missing.md b/.changeset/reuse-current-lockfile-when-wanted-missing.md new file mode 100644 index 0000000000..a93361a34a --- /dev/null +++ b/.changeset/reuse-current-lockfile-when-wanted-missing.md @@ -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. diff --git a/installing/context/src/readLockfiles.ts b/installing/context/src/readLockfiles.ts index f298f2103d..d491e02091 100644 --- a/installing/context/src/readLockfiles.ts +++ b/installing/context/src/readLockfiles.ts @@ -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, diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index a4c592438c..be63a2576b 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -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`) } diff --git a/installing/deps-installer/test/install/frozenLockfile.ts b/installing/deps-installer/test/install/frozenLockfile.ts index 718dcedd73..c6315630ee 100644 --- a/installing/deps-installer/test/install/frozenLockfile.ts +++ b/installing/deps-installer/test/install/frozenLockfile.ts @@ -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([ { diff --git a/pacquet/crates/cli/tests/install.rs b/pacquet/crates/cli/tests/install.rs index eee6477bef..95be1be8d0 100644 --- a/pacquet/crates/cli/tests/install.rs +++ b/pacquet/crates/cli/tests/install.rs @@ -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 +/// . +#[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::(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 diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index a558021aa8..f1950eb627 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -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 `/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 `/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 + // . + let synthesized_lockfile: Option = + 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 + // `/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 // .