mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-29 18:35:18 -04:00
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:
9
.changeset/reuse-current-lockfile-when-wanted-missing.md
Normal file
9
.changeset/reuse-current-lockfile-when-wanted-missing.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>.
|
||||
|
||||
Reference in New Issue
Block a user