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/index.ts b/installing/context/src/index.ts index 10d110d4e1..747b63b2cd 100644 --- a/installing/context/src/index.ts +++ b/installing/context/src/index.ts @@ -36,6 +36,7 @@ export interface PnpmContext { existsCurrentLockfile: boolean existsWantedLockfile: boolean existsNonEmptyWantedLockfile: boolean + hasUsableLockfile: boolean extraBinPaths: string[] /** Affected by existing modules directory, if it exists. */ extraNodePaths: string[] @@ -207,6 +208,7 @@ export interface PnpmSingleContext { existsCurrentLockfile: boolean existsWantedLockfile: boolean existsNonEmptyWantedLockfile: boolean + hasUsableLockfile: boolean /** Affected by existing modules directory, if it exists. */ extraBinPaths: string[] extraNodePaths: string[] diff --git a/installing/context/src/readLockfiles.ts b/installing/context/src/readLockfiles.ts index f298f2103d..cb43d88df8 100644 --- a/installing/context/src/readLockfiles.ts +++ b/installing/context/src/readLockfiles.ts @@ -20,6 +20,7 @@ export interface PnpmContext { existsCurrentLockfile: boolean existsWantedLockfile: boolean existsNonEmptyWantedLockfile: boolean + hasUsableLockfile: boolean wantedLockfile: LockfileObject } @@ -48,6 +49,7 @@ export async function readLockfiles ( existsCurrentLockfile: boolean existsWantedLockfile: boolean existsNonEmptyWantedLockfile: boolean + hasUsableLockfile: boolean wantedLockfile: LockfileObject wantedLockfileIsModified: boolean lockfileHadConflicts: boolean @@ -122,10 +124,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,13 +140,13 @@ 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), + hasUsableLockfile: !isEmptyLockfile(wantedLockfile), wantedLockfile, wantedLockfileIsModified, lockfileHadConflicts, diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index a4c592438c..7c0420dbb7 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -909,7 +909,7 @@ export async function mutateModules ( !needsFullResolution && opts.preferFrozenLockfile && (!opts.pruneLockfileImporters || Object.keys(ctx.wantedLockfile.importers).length === Object.keys(ctx.projects).length) && - ctx.existsNonEmptyWantedLockfile && + ctx.hasUsableLockfile && ctx.wantedLockfile.lockfileVersion === LOCKFILE_VERSION && await allProjectsAreUpToDate(Object.values(ctx.projects), { catalogs: opts.catalogs, @@ -939,6 +939,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 +983,7 @@ Note that in CI environments, this setting is enabled by default.`, ignoredBuilds: undefined, } } - if (!ctx.existsNonEmptyWantedLockfile) { + if (!ctx.hasUsableLockfile) { 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([ {