mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-30 19:46:44 -04:00
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.
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.
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user