perf: content-check modified manifests and fall back to the current lockfile on the repeat-install fast path (pacquet + pnpm) (#12315)

## Why

On [benchmarks.vlt.sh](https://benchmarks.vlt.sh/) (2026-06-10 run, pacquet 0.11.2), pacquet ranked **8th–9th of 10** in every `lockfile+node_modules` variation — slower than pnpm, npm, yarn and vlt — e.g. astro: pacquet 936 ms vs pnpm 502 ms; babylon: pacquet 9.08 s vs pnpm 0.85 s. It also trailed vlt/npm in the `node_modules` and `cache+node_modules` variations (astro 1.5 s / 0.7 s, babylon 8.9 s / 6.4 s).

### Root cause

Tracing the actual runner (a `pacquet` PATH shim logging per-invocation file stats) showed the harness's prepare step rewrites `package.json` with **identical content but a fresh mtime** before every timed run, while `clean_all_cache` wipes `~/.cache/pnpm` (the packument cache and `lockfile-verified.jsonl`), and the `node_modules` variations additionally delete `pnpm-lock.yaml`.

- **pnpm**: `checkDepsStatus`'s modified-manifests branch re-checks the *content* against the lockfile (`assertWantedLockfileUpToDate`, `assertLockfilesEqual`, `linkedPackagesAreUpToDate`) and reports "Already up to date" with zero network — ~0.5 s is just Node startup. Verified locally: with all caches wiped and the network blocked, `pnpm install` still prints "Already up to date" in 228 ms.
- **pacquet**: the optimistic repeat-install check bailed on *any* newer manifest mtime, fell into the full pipeline, and the awaited `minimumReleaseAge` lockfile-verification gate — its verdict cache wiped — re-fetched **one packument per locked package** per run: 0.94 s on astro, 9.1 s on babylon.
- With `pnpm-lock.yaml` deleted, both stacks pay a similar fan-out on the synthesized-from-current lockfile (`tryLockfileVerificationCache` bails before the content-hash index when the lockfile file can't be stat'd), which is why even pnpm needs 2.2–11.6 s there.

## What

**Commit 1 — port the modified-manifests branch of `checkDepsStatus`** (at pnpm/pnpm@cc4ff817aa) into `optimistic_repeat_install`:

- a manifest whose mtime is newer than `lastValidatedTimestamp` is re-checked against the wanted lockfile instead of invalidating the fast path: lockfile-settings drift (`getOutdatedLockfileSetting`), per-importer `satisfiesPackageManifest`, and a port of `linkedPackagesAreUpToDate` for workspace links (`isLocalFileDepUpdated` for `file:` directory specifiers is not ported — those conservatively fall through to the full install);
- `assertLockfilesEqual` runs when the wanted lockfile is newer than the reference (workspace: `lastValidatedTimestamp`; single-project: the current lockfile's mtime, mirroring upstream's branch shapes);
- the workspace branch refreshes `lastValidatedTimestamp` after a passing content check, like upstream's `updateWorkspaceState` call;
- the frozen-dispatch freshness gate is split into reusable pieces (`parse_config_overrides`, `check_lockfile_settings_drift`, `check_importer_satisfies`) shared with the new check, and the per-importer slice is no longer hard-wired to the root importer.

**Commit 2 — treat the current lockfile as the wanted one when `pnpm-lock.yaml` is missing (pacquet)** (requested by @zkochan): when `node_modules` is intact, `<virtual_store_dir>/lock.yaml` — the record of what the previous install materialized — stands in as the wanted lockfile for the same content checks, and `pnpm-lock.yaml` is regenerated from it (byte-identical to what the full install's synthesize-from-current path would write) before the fast path reports "Already up to date". Single-project installs with no lockfile on either side still refuse the fast path; `lockfile: false` skips the regeneration; a manifest that no longer matches (e.g. `pacquet add`) still takes the full resolve.

## Validation

Re-ran the actual vlt.sh harness (same scripts, ubuntu-24.04-arm runner) with the patched binary swapped into the npm-installed pacquet; all hyperfine runs exited 0:

| fixture, variation | pacquet 0.11.2 (official run) | patched | pnpm (same validation run) |
|---|---|---|---|
| astro, `lockfile+node_modules` | 935.6 ms (rank 9/10) | **38–39 ms** | 599–621 ms |
| babylon, `lockfile+node_modules` | 9 084 ms (rank 8/10) | **86.6 ms ± 0.6** | 767.7 ms |
| astro, `node_modules` | 1 501 ms (rank 4/10) | **41.2 ms ± 0.8** | 2 226 ms |
| astro, `cache+node_modules` | 704 ms (rank 5/10) | **42.9 ms ± 0.9** | 2 017 ms |
| babylon, `node_modules` | 8 962 ms (rank 6/10) | **107.8 ms ± 1.0** | 11 566 ms |

After this change only aube (~5 ms) and bun (~8 ms) stay ahead in these five variations.

`cargo nextest run -p pacquet-package-manager` (438 tests), `-p pacquet-cli` install suites, workspace clippy `-D warnings`, dylint, fmt, taplo and `typos pacquet` are clean. New tests cover the touched-but-identical manifest, a manifest that adds a dependency, a diverged wanted-vs-current lockfile, the state-timestamp refresh, linked siblings inside/outside the manifest range, lockfile regeneration (modified and unmodified manifests, workspace state bump), and `lockfile: false`. Two offline e2e tests additionally pin the "zero network, zero pipeline" property through `Install::run`'s real dispatch: a real install, registry dropped, caches wiped, repeat install pointed at a dead port — both verified discriminating by temporarily disabling the content check.

Two existing tests were adjusted: `fresh_install_records_lockfile_verification_for_mtime_bypassed_noop` now disables the optimistic check explicitly so it keeps guarding the verification-cache wiring it was written for, and `optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing` now passes `lockfile: None` (matching the CLI contract for a missing file) and documents that the guard requires *both* lockfiles to be absent.

**Commit `1ee88c5107` — the same fallback in the pnpm CLI** (`@pnpm/deps.status` + `@pnpm/installing.commands`): `checkDepsStatus` lets the current lockfile stand in when `pnpm-lock.yaml` is missing (workspace shared-lockfile branch and single-project branch), runs the same content checks against it, and returns it as `wantedLockfileToRestore`; `installDeps` writes `pnpm-lock.yaml` back from it before reporting "Already up to date". Guard rails: no lockfile on either side still refuses the fast path, `useLockfile: false` skips the restore, a failed restore falls through to the full install, and the stand-in is disabled under `useGitBranchLockfile` (there a missing plain `pnpm-lock.yaml` is the steady state and the branch lockfile may legitimately differ from the current one). Verified with the bundled CLI: install → delete `pnpm-lock.yaml` → `pnpm install --registry=http://127.0.0.1:9/` prints "Already up to date" in 29 ms and restores the lockfile byte-identically. Covered by 5 new `checkDepsStatus` unit tests and an `installing/commands` integration test that runs the repeat install against a dead registry. Changeset bumps `@pnpm/deps.status`, `@pnpm/installing.commands`, and `pnpm` (minor).
This commit is contained in:
Zoltan Kochan
2026-06-10 21:24:47 +02:00
committed by GitHub
parent 5aed1200ea
commit d976edf4ec
10 changed files with 1775 additions and 280 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/deps.status": minor
"@pnpm/installing.commands": minor
"pnpm": minor
---
`pnpm install` completes without re-resolving when `pnpm-lock.yaml` was deleted but `node_modules` is intact: the up-to-date check now treats the current lockfile (`node_modules/.pnpm/lock.yaml`) — the record of what the previous install materialized — as the wanted lockfile, verifies the manifests still match it, restores `pnpm-lock.yaml` from it, and reports "Already up to date". Previously this scenario triggered a full resolution and a re-verification of every locked package against the registry.

View File

@@ -68,12 +68,30 @@ export type CheckDepsStatusOptions = Pick<Config,
ignoreFilteredInstallCache?: boolean
ignoredWorkspaceStateSettings?: Array<keyof WorkspaceStateSettings>
pnpmfile: string[]
/**
* When git-branch lockfiles are enabled, the wanted lockfile lives at
* `pnpm-lock.<branch>.yaml`, so a missing `pnpm-lock.yaml` is the steady
* state — the current-lockfile stand-in must not kick in.
*/
useGitBranchLockfile?: boolean
} & WorkspaceStateSettings
export interface CheckDepsStatusResult {
upToDate: boolean | undefined
issue?: string
workspaceState: WorkspaceState | undefined
/**
* Set when `pnpm-lock.yaml` was missing and the current lockfile
* (`<lockfileDir>/node_modules/.pnpm/lock.yaml`) stood in as the wanted
* lockfile for the up-to-date checks. The current lockfile records
* exactly what the previous install materialized, so the caller can
* restore `pnpm-lock.yaml` from it without resolving — `installDeps`
* does that before reporting "Already up to date".
*/
wantedLockfileToRestore?: {
lockfile: LockfileObject
lockfileDir: string
}
}
export async function checkDepsStatus (opts: CheckDepsStatusOptions): Promise<CheckDepsStatusResult> {
@@ -258,37 +276,59 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
if (modifiedProjects.length === 0) {
logger.debug({ msg: 'No manifest files were modified since the last validation. Exiting check.' })
return { upToDate: true, workspaceState }
const wantedLockfileToRestore = sharedWorkspaceLockfile && !opts.useGitBranchLockfile
? await missingWantedLockfileStandIn(workspaceDir)
: undefined
return { upToDate: true, workspaceState, wantedLockfileToRestore }
}
logger.debug({ msg: 'Some manifest files were modified since the last validation. Continuing check.' })
let wantedLockfileToRestore: CheckDepsStatusResult['wantedLockfileToRestore']
let readWantedLockfileAndDir: (projectDir: string) => Promise<{
wantedLockfile: LockfileObject
wantedLockfileDir: string
}>
if (sharedWorkspaceLockfile) {
let wantedLockfileStats: fs.Stats
let wantedLockfileStats: fs.Stats | undefined
try {
wantedLockfileStats = fs.statSync(path.join(workspaceDir, WANTED_LOCKFILE))
} catch (error) {
if (util.types.isNativeError(error) && 'code' in error && error.code === 'ENOENT') {
return throwLockfileNotFound(workspaceDir)
wantedLockfileStats = undefined
} else {
throw error
}
}
const wantedLockfilePromise = readWantedLockfile(workspaceDir, { ignoreIncompatible: false })
if (wantedLockfileStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp) {
if (wantedLockfileStats == null) {
// `pnpm-lock.yaml` is gone, but the current lockfile records
// exactly what the previous install materialized — let it stand
// in as the wanted lockfile for the checks below, and report it
// back so `installDeps` can restore `pnpm-lock.yaml` from it
// without resolving. There is no second lockfile to compare
// against, so the wanted-vs-current equality assertion doesn't
// apply on this path.
if (opts.useGitBranchLockfile) return throwLockfileNotFound(workspaceDir)
const currentLockfile = await readCurrentLockfile(path.join(workspaceDir, 'node_modules/.pnpm'), { ignoreIncompatible: false })
const wantedLockfile = (await wantedLockfilePromise) ?? throwLockfileNotFound(workspaceDir)
assertLockfilesEqual(currentLockfile, wantedLockfile, workspaceDir)
if (currentLockfile == null) return throwLockfileNotFound(workspaceDir)
wantedLockfileToRestore = { lockfile: currentLockfile, lockfileDir: workspaceDir }
readWantedLockfileAndDir = async () => ({
wantedLockfile: currentLockfile,
wantedLockfileDir: workspaceDir,
})
} else {
const wantedLockfilePromise = readWantedLockfile(workspaceDir, { ignoreIncompatible: false })
if (wantedLockfileStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp) {
const currentLockfile = await readCurrentLockfile(path.join(workspaceDir, 'node_modules/.pnpm'), { ignoreIncompatible: false })
const wantedLockfile = (await wantedLockfilePromise) ?? throwLockfileNotFound(workspaceDir)
assertLockfilesEqual(currentLockfile, wantedLockfile, workspaceDir)
}
readWantedLockfileAndDir = async () => ({
wantedLockfile: (await wantedLockfilePromise) ?? throwLockfileNotFound(workspaceDir),
wantedLockfileDir: workspaceDir,
})
}
readWantedLockfileAndDir = async () => ({
wantedLockfile: (await wantedLockfilePromise) ?? throwLockfileNotFound(workspaceDir),
wantedLockfileDir: workspaceDir,
})
} else {
readWantedLockfileAndDir = async wantedLockfileDir => {
const wantedLockfilePromise = readWantedLockfile(wantedLockfileDir, { ignoreIncompatible: false })
@@ -355,7 +395,7 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
filteredInstall: workspaceState.filteredInstall,
})
return { upToDate: true, workspaceState }
return { upToDate: true, workspaceState, wantedLockfileToRestore }
}
if (!allProjects) {
@@ -389,12 +429,26 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
statManifestFile(rootProjectManifestDir),
])
if (!wantedLockfileStats) return throwLockfileNotFound(rootProjectManifestDir)
if (!wantedLockfileStats && (!currentLockfileStats || opts.useGitBranchLockfile)) return throwLockfileNotFound(rootProjectManifestDir)
// When `pnpm-lock.yaml` is gone but the current lockfile
// (`node_modules/.pnpm/lock.yaml`) survives, the current one stands
// in as the wanted lockfile: it records exactly what the previous
// install materialized, so the checks below run against it and the
// caller can restore `pnpm-lock.yaml` from it without resolving.
// The wanted-vs-current equality assertion doesn't apply on this
// path — the two are the same object.
const wantedLockfileIsMissing = !wantedLockfileStats
const effectiveWantedLockfileStats = (wantedLockfileStats ?? currentLockfileStats)!
const readEffectiveWantedLockfile = async (): Promise<LockfileObject> => {
const lockfile = wantedLockfileIsMissing ? await currentLockfilePromise : await wantedLockfilePromise
return lockfile ?? throwLockfileNotFound(rootProjectManifestDir)
}
const issue = await patchesOrHooksAreModified({
patchedDependencies,
rootDir: rootProjectManifestDir,
lastValidatedTimestamp: wantedLockfileStats.mtime.valueOf(),
lastValidatedTimestamp: effectiveWantedLockfileStats.mtime.valueOf(),
currentPnpmfiles: opts.pnpmfile,
previousPnpmfiles: workspaceState.pnpmfiles,
})
@@ -402,7 +456,7 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
return { upToDate: false, issue, workspaceState }
}
if (currentLockfileStats && wantedLockfileStats.mtime.valueOf() > currentLockfileStats.mtime.valueOf()) {
if (!wantedLockfileIsMissing && currentLockfileStats && wantedLockfileStats.mtime.valueOf() > currentLockfileStats.mtime.valueOf()) {
const currentLockfile = await currentLockfilePromise
const wantedLockfile = (await wantedLockfilePromise) ?? throwLockfileNotFound(rootProjectManifestDir)
assertLockfilesEqual(currentLockfile, wantedLockfile, rootProjectManifestDir)
@@ -413,7 +467,7 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
throw new Error(`Cannot find one of ${MANIFEST_BASE_NAMES.join(', ')} in ${rootProjectManifestDir}`)
}
if (manifestStats.mtime.valueOf() > wantedLockfileStats.mtime.valueOf()) {
if (manifestStats.mtime.valueOf() > effectiveWantedLockfileStats.mtime.valueOf()) {
logger.debug({ msg: 'The manifest is newer than the lockfile. Continuing check.' })
try {
await assertWantedLockfileUpToDate({
@@ -429,7 +483,7 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
projectDir: rootProjectManifestDir,
projectId: '.' as ProjectId,
projectManifest: rootProjectManifest,
wantedLockfile: (await wantedLockfilePromise) ?? throwLockfileNotFound(rootProjectManifestDir),
wantedLockfile: await readEffectiveWantedLockfile(),
wantedLockfileDir: rootProjectManifestDir,
})
} catch (err) {
@@ -450,6 +504,16 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
}
}
if (wantedLockfileIsMissing) {
const currentLockfile = await currentLockfilePromise
if (currentLockfile != null) {
return {
upToDate: true,
workspaceState,
wantedLockfileToRestore: { lockfile: currentLockfile, lockfileDir: rootProjectManifestDir },
}
}
}
return { upToDate: true, workspaceState }
}
@@ -564,6 +628,19 @@ function throwLockfileNotFound (wantedLockfileDir: string): never {
})
}
/**
* When `<lockfileDir>/pnpm-lock.yaml` is missing but the current lockfile
* exists, returns the current lockfile so the caller can restore
* `pnpm-lock.yaml` from it. `undefined` when the wanted lockfile is present
* (nothing to restore) or when there is no current lockfile to restore from.
*/
async function missingWantedLockfileStandIn (lockfileDir: string): Promise<CheckDepsStatusResult['wantedLockfileToRestore']> {
if (safeStatSync(path.join(lockfileDir, WANTED_LOCKFILE)) != null) return undefined
const currentLockfile = await readCurrentLockfile(path.join(lockfileDir, 'node_modules/.pnpm'), { ignoreIncompatible: false })
if (currentLockfile == null) return undefined
return { lockfile: currentLockfile, lockfileDir }
}
function getWantedLockfileDirs (opts: {
allProjects: Project[] | undefined
lockfileDir: string | undefined

View File

@@ -564,3 +564,229 @@ async function writeConflictedLockfile (lockfileDir: string): Promise<void> {
'',
].join('\n'))
}
describe('checkDepsStatus - missing wanted lockfile fallback', () => {
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
const currentLockfile = {
lockfileVersion: '9.0',
importers: { '.': { specifiers: {} } },
} as unknown as LockfileObject
function mockSingleProjectStats (opts: {
wantedLockfileExists: boolean
currentLockfileMtime: number
manifestMtime: number
}): void {
jest.mocked(fsUtils.safeStat).mockImplementation(async (filePath: string) => {
if (filePath === path.join('/project', 'node_modules', '.pnpm', 'lock.yaml')) {
return {
mtime: new Date(opts.currentLockfileMtime),
mtimeMs: opts.currentLockfileMtime,
} as Stats
}
if (filePath === path.join('/project', 'pnpm-lock.yaml') && opts.wantedLockfileExists) {
return {
mtime: new Date(opts.currentLockfileMtime),
mtimeMs: opts.currentLockfileMtime,
} as Stats
}
return undefined
})
jest.mocked(fsUtils.safeStatSync).mockReturnValue(undefined)
jest.mocked(statManifestFileUtils.statManifestFile).mockImplementation(async () => ({
mtime: new Date(opts.manifestMtime),
mtimeMs: opts.manifestMtime,
} as Stats))
jest.mocked(lockfileFs.readCurrentLockfile).mockImplementation(async () => currentLockfile)
jest.mocked(lockfileFs.readWantedLockfile).mockImplementation(async () => null)
}
it('returns the current lockfile to restore when pnpm-lock.yaml is missing in a single project', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
mockSingleProjectStats({
wantedLockfileExists: false,
currentLockfileMtime: lastValidatedTimestamp - 10_000,
manifestMtime: lastValidatedTimestamp - 20_000,
})
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
expect(result.wantedLockfileToRestore).toEqual({
lockfile: currentLockfile,
lockfileDir: '/project',
})
})
it('does not set a lockfile to restore when pnpm-lock.yaml exists', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
mockSingleProjectStats({
wantedLockfileExists: true,
currentLockfileMtime: lastValidatedTimestamp - 10_000,
manifestMtime: lastValidatedTimestamp - 20_000,
})
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
expect(result.wantedLockfileToRestore).toBeUndefined()
})
it('still reports the lockfile as not found when the current lockfile is missing too', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
jest.mocked(fsUtils.safeStat).mockResolvedValue(undefined)
jest.mocked(fsUtils.safeStatSync).mockReturnValue(undefined)
jest.mocked(statManifestFileUtils.statManifestFile).mockResolvedValue(undefined)
jest.mocked(lockfileFs.readCurrentLockfile).mockResolvedValue(null)
jest.mocked(lockfileFs.readWantedLockfile).mockResolvedValue(null)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toMatch(/Cannot find a lockfile/)
expect(result.wantedLockfileToRestore).toBeUndefined()
})
it('does not stand in for the wanted lockfile when git-branch lockfiles are enabled', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
mockSingleProjectStats({
wantedLockfileExists: false,
currentLockfileMtime: lastValidatedTimestamp - 10_000,
manifestMtime: lastValidatedTimestamp - 20_000,
})
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
useGitBranchLockfile: true,
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toMatch(/Cannot find a lockfile/)
expect(result.wantedLockfileToRestore).toBeUndefined()
})
it('returns the current lockfile to restore for a workspace with unmodified manifests', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const projectRootDir = '/workspace' as ProjectRootDir
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {
[projectRootDir]: { name: 'root', version: '1.0.0' },
},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
jest.mocked(fsUtils.safeStat).mockResolvedValue(undefined)
jest.mocked(fsUtils.safeStatSync).mockReturnValue(undefined)
jest.mocked(statManifestFileUtils.statManifestFile).mockImplementation(async () => ({
mtime: new Date(lastValidatedTimestamp - 20_000),
mtimeMs: lastValidatedTimestamp - 20_000,
} as Stats))
jest.mocked(lockfileFs.readCurrentLockfile).mockImplementation(async () => currentLockfile)
jest.mocked(lockfileFs.readWantedLockfile).mockResolvedValue(null)
const opts: CheckDepsStatusOptions = {
allProjects: [
{
rootDir: projectRootDir,
rootDirRealPath: '/workspace' as ProjectRootDirRealPath,
manifest: { name: 'root', version: '1.0.0' },
writeProjectManifest: async () => {},
},
],
workspaceDir: '/workspace',
sharedWorkspaceLockfile: true,
rootProjectManifest: { name: 'root', version: '1.0.0' },
rootProjectManifestDir: '/workspace',
pnpmfile: [],
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(true)
expect(result.wantedLockfileToRestore).toEqual({
lockfile: currentLockfile,
lockfileDir: '/workspace',
})
})
})

View File

@@ -314,6 +314,7 @@ export type InstallCommandOptions = Pick<Config,
| 'linkWorkspacePackages'
| 'lockfileDir'
| 'lockfileOnly'
| 'optimisticRepeatInstall'
| 'modulesDir'
| 'nodeLinker'
| 'patchedDependencies'

View File

@@ -17,6 +17,7 @@ import {
type UpdateMatchingFunction,
type WorkspacePackages,
} from '@pnpm/installing.deps-installer'
import { writeWantedLockfile } from '@pnpm/lockfile.fs'
import type { LockfileObject } from '@pnpm/lockfile.types'
import { globalInfo, logger } from '@pnpm/logger'
import { applyRuntimeOnFailOverride, filterDependenciesByType } from '@pnpm/pkg-manifest.utils'
@@ -171,18 +172,18 @@ export type InstallDepsOptions = Pick<Config,
* subcommand — see `runPacquet.ts`'s `noRuntime` opt.
*/
isInstallCommand?: boolean
} & Partial<Pick<Config, 'pnpmHomeDir' | 'strictDepBuilds'>>
} & Partial<Pick<Config, 'pnpmHomeDir' | 'strictDepBuilds' | 'useLockfile' | 'useGitBranchLockfile'>>
export async function installDeps (
opts: InstallDepsOptions,
params: string[]
): Promise<void> {
if (!opts.update && !opts.dedupe && params.length === 0 && opts.optimisticRepeatInstall) {
const { upToDate } = await checkDepsStatus({
const { upToDate, wantedLockfileToRestore } = await checkDepsStatus({
...opts,
ignoreFilteredInstallCache: true,
})
if (upToDate) {
if (upToDate && await restoreWantedLockfileIfMissing(wantedLockfileToRestore, opts)) {
if (opts.hooks?.customResolvers?.some(r => r.shouldRefreshResolution)) {
logger.warn({
message: 'shouldRefreshResolution hooks were skipped because optimisticRepeatInstall is enabled.',
@@ -592,3 +593,27 @@ function preferNonvulnerablePackageVersions (packageVulnerabilityAudit: PackageV
}
return preferredVersions
}
/**
* Restore a missing `pnpm-lock.yaml` from the current lockfile before the
* optimistic repeat-install short-circuit reports "Already up to date", so
* the fast path leaves the same on-disk contract a full install would.
* Returns `true` when the short-circuit may proceed: nothing to restore,
* lockfile writing is disabled (`useLockfile: false`), or the restore
* succeeded. A failed write returns `false` so the caller falls through to
* the full install instead of reporting up to date while `pnpm-lock.yaml`
* stays missing.
*/
async function restoreWantedLockfileIfMissing (
wantedLockfileToRestore: { lockfile: LockfileObject, lockfileDir: string } | undefined,
opts: Pick<InstallDepsOptions, 'useLockfile'>
): Promise<boolean> {
if (wantedLockfileToRestore == null || opts.useLockfile === false) return true
try {
await writeWantedLockfile(wantedLockfileToRestore.lockfileDir, wantedLockfileToRestore.lockfile)
return true
} catch (error) {
logger.debug({ msg: 'Failed to restore pnpm-lock.yaml from the current lockfile', error })
return false
}
}

View File

@@ -175,3 +175,27 @@ test('do not install Node.js when devEngines runtime is not set to onFail=downlo
const lockfile = project.readLockfile()
expect(lockfile.importers['.'].devDependencies).toBeUndefined()
})
test('install restores a deleted pnpm-lock.yaml from the current lockfile without resolution', async () => {
prepareEmpty()
await add.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
}, ['is-positive@1.0.0'])
const originalLockfile = fs.readFileSync('pnpm-lock.yaml', 'utf8')
rimrafSync('pnpm-lock.yaml')
// The dead registry proves the repeat install neither resolves nor
// verifies: the current lockfile (node_modules/.pnpm/lock.yaml) stands in
// as the wanted lockfile and pnpm-lock.yaml is restored from it.
await install.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
optimisticRepeatInstall: true,
registries: { default: 'http://127.0.0.1:9/' },
})
expect(fs.readFileSync('pnpm-lock.yaml', 'utf8')).toBe(originalLockfile)
})

View File

@@ -1,7 +1,8 @@
use crate::{
BuildVerifiersError, HoistedDependencies, InstallFrozenLockfile, InstallFrozenLockfileError,
InstallWithFreshLockfile, InstallWithFreshLockfileError, ResolvedPackages, UpdateSeedPolicy,
build_resolution_verifiers, check_optimistic_repeat_install,
InstallWithFreshLockfile, InstallWithFreshLockfileError, OptimisticRepeatInstallCheck,
ResolvedPackages, UpdateSeedPolicy, build_resolution_verifiers,
check_optimistic_repeat_install,
optimistic_repeat_install::Decision as OptimisticRepeatInstallDecision,
};
use derive_more::{Display, Error};
@@ -493,14 +494,17 @@ where
// `KeepAll` keeps `install` / `add` on the fast path.
if matches!(update_seed_policy, UpdateSeedPolicy::KeepAll)
&& !frozen_lockfile
&& let OptimisticRepeatInstallDecision::UpToDate = check_optimistic_repeat_install(
&workspace_root,
config,
node_linker,
included,
&project_manifests,
workspace_manifest.is_some(),
)
&& let OptimisticRepeatInstallDecision::UpToDate =
check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: &workspace_root,
config,
node_linker,
included,
project_manifests: &project_manifests,
is_workspace_install: workspace_manifest.is_some(),
lockfile,
catalogs: &catalogs,
})
{
Reporter::emit(&LogEvent::Pnpm(PnpmLog {
level: LogLevel::Info,
@@ -1199,39 +1203,60 @@ fn check_lockfile_freshness(
catalogs: &Catalogs,
ignore_manifest_check: bool,
) -> Result<(), FreshnessCheckError> {
let parsed_overrides_opt = parse_config_overrides(config, catalogs)?;
check_lockfile_settings_drift(lockfile, config, parsed_overrides_opt.as_deref())?;
if ignore_manifest_check {
return Ok(());
}
// Pacquet has only one importer today (<https://github.com/pnpm/pacquet/issues/431> tracks workspaces),
// so the root project is the only thing to verify; once
// workspaces land this becomes a per-project loop over
// `lockfile.importers`.
let importer = lockfile.importers.get(Lockfile::ROOT_IMPORTER_KEY).ok_or_else(|| {
FreshnessCheckError::NoImporter { importer_id: Lockfile::ROOT_IMPORTER_KEY.to_string() }
})?;
check_importer_satisfies(
lockfile,
manifest,
Lockfile::ROOT_IMPORTER_KEY,
config,
parsed_overrides_opt.as_deref(),
)
}
// `pnpm.overrides` values can use the `catalog:` protocol, which
// pnpm resolves against the workspace's catalogs *before* writing
// them to `pnpm-lock.yaml#overrides`. Mirror that here so an
// override declared as `"foo": "catalog:"` compares equal to the
// lockfile's already-resolved `"foo": "<concrete>"`. Mirrors
// upstream's
// <https://github.com/pnpm/pnpm/blob/4a36b9a110/config/parse-overrides/src/index.ts#L20-L44>
// → `createOverridesMapFromParsed` pipeline.
let parsed_overrides_opt = match config.overrides.as_ref() {
Some(map) if !map.is_empty() => Some(
/// Parse `pnpm.overrides` from the config. Values can use the
/// `catalog:` protocol, which pnpm resolves against the workspace's
/// catalogs *before* writing them to `pnpm-lock.yaml#overrides`
/// resolving here keeps an override declared as `"foo": "catalog:"`
/// comparable to the lockfile's already-resolved `"foo": "<concrete>"`.
/// Mirrors upstream's
/// <https://github.com/pnpm/pnpm/blob/4a36b9a110/config/parse-overrides/src/index.ts#L20-L44>
/// → `createOverridesMapFromParsed` pipeline.
pub(crate) fn parse_config_overrides(
config: &Config,
catalogs: &Catalogs,
) -> Result<Option<Vec<pacquet_config_parse_overrides::VersionOverride>>, FreshnessCheckError> {
match config.overrides.as_ref() {
Some(map) if !map.is_empty() => Ok(Some(
pacquet_config_parse_overrides::parse_overrides_iter(map.iter(), catalogs)
.map_err(FreshnessCheckError::InvalidOverrides)?,
),
_ => None,
};
let overrides_map: Option<std::collections::HashMap<String, String>> = parsed_overrides_opt
.as_deref()
.map(pacquet_config_parse_overrides::create_overrides_map_from_parsed);
)),
_ => Ok(None),
}
}
// Outdated-settings gate (umbrella <https://github.com/pnpm/pacquet/issues/434> slice 7): check
// `ignoredOptionalDependencies` + `overrides` +
// `packageExtensionsChecksum` drift between the lockfile-recorded
// values and the current config before the per-importer specifier
// check. Mirrors upstream's
// [`getOutdatedLockfileSetting`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts).
/// Outdated-settings gate (umbrella <https://github.com/pnpm/pacquet/issues/434> slice 7): check
/// `ignoredOptionalDependencies` + `overrides` +
/// `packageExtensionsChecksum` drift between the lockfile-recorded
/// values and the current config before the per-importer specifier
/// check. Mirrors upstream's
/// [`getOutdatedLockfileSetting`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts).
pub(crate) fn check_lockfile_settings_drift(
lockfile: &Lockfile,
config: &Config,
parsed_overrides: Option<&[pacquet_config_parse_overrides::VersionOverride]>,
) -> Result<(), FreshnessCheckError> {
let overrides_map: Option<std::collections::HashMap<String, String>> =
parsed_overrides.map(pacquet_config_parse_overrides::create_overrides_map_from_parsed);
let package_extensions_checksum = config
.package_extensions
.as_ref()
@@ -1254,11 +1279,26 @@ fn check_lockfile_freshness(
config.inject_workspace_packages,
config.peers_suffix_max_length,
)
.map_err(FreshnessCheckError::Stale)?;
.map_err(FreshnessCheckError::Stale)
}
if ignore_manifest_check {
return Ok(());
}
/// Per-importer slice of the freshness gate: the manifest of the
/// project at `importer_id` must still be satisfied by the lockfile's
/// importer snapshot. Mirrors upstream's `satisfiesPackageManifest`
/// call inside
/// [`allProjectsAreUpToDate`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/verification/src/allProjectsAreUpToDate.ts)
/// / `assertWantedLockfileUpToDate`.
pub(crate) fn check_importer_satisfies(
lockfile: &Lockfile,
manifest: &PackageManifest,
importer_id: &str,
config: &Config,
parsed_overrides: Option<&[pacquet_config_parse_overrides::VersionOverride]>,
) -> Result<(), FreshnessCheckError> {
let importer = lockfile
.importers
.get(importer_id)
.ok_or_else(|| FreshnessCheckError::NoImporter { importer_id: importer_id.to_string() })?;
// Apply `pnpm.overrides` to a *cloned* manifest before the
// per-importer specifier check so the lockfile's specifiers —
@@ -1268,19 +1308,18 @@ fn check_lockfile_freshness(
// from the perspective of every consumer downstream of the
// resolver.
let overrider_manifest_holder;
let manifest_for_freshness: &PackageManifest =
if let Some(parsed) = parsed_overrides_opt.as_deref() {
let root_dir = manifest.path().parent().unwrap_or_else(|| Path::new("."));
let overrider = crate::VersionsOverrider::new(parsed, root_dir);
overrider_manifest_holder = {
let mut cloned: PackageManifest = manifest.clone();
overrider.apply(&mut cloned, Some(root_dir));
cloned
};
&overrider_manifest_holder
} else {
manifest
let manifest_for_freshness: &PackageManifest = if let Some(parsed) = parsed_overrides {
let root_dir = manifest.path().parent().unwrap_or_else(|| Path::new("."));
let overrider = crate::VersionsOverrider::new(parsed, root_dir);
overrider_manifest_holder = {
let mut cloned: PackageManifest = manifest.clone();
overrider.apply(&mut cloned, Some(root_dir));
cloned
};
&overrider_manifest_holder
} else {
manifest
};
// Build the `ignoredOptionalDependencies` filter set. Mirrors
// upstream's
@@ -1307,13 +1346,8 @@ fn check_lockfile_freshness(
.unwrap_or_default();
let is_ignored_optional: &dyn Fn(&str) -> bool = &|name: &str| ignored_set.contains(name);
satisfies_package_manifest(
importer,
manifest_for_freshness,
Lockfile::ROOT_IMPORTER_KEY,
is_ignored_optional,
)
.map_err(FreshnessCheckError::Stale)
satisfies_package_manifest(importer, manifest_for_freshness, importer_id, is_ignored_optional)
.map_err(FreshnessCheckError::Stale)
}
/// Outcome of [`check_lockfile_freshness`]. Splits "user
@@ -1321,7 +1355,7 @@ fn check_lockfile_freshness(
/// (fatal for `--frozen-lockfile`, fall-through to the fresh-resolve
/// path under `preferFrozenLockfile: true`).
#[derive(Debug, Display, Error, Diagnostic)]
enum FreshnessCheckError {
pub(crate) enum FreshnessCheckError {
/// The lockfile has no entry for the root importer.
#[display(
r#"Cannot install with "frozen-lockfile" because pnpm-lock.yaml has no `importers["{importer_id}"]` entry. Regenerate the lockfile with `pnpm install --lockfile-only`."#
@@ -1662,7 +1696,7 @@ fn build_projects_map(
/// are omitted; pnpm's `checkDepsStatus`
/// only iterates fields present in the serialized object, so an
/// absent key is silently skipped rather than treated as a drift.
fn build_workspace_state(
pub(crate) fn build_workspace_state(
config: &Config,
node_linker: NodeLinker,
included: IncludedDependencies,

View File

@@ -5671,15 +5671,18 @@ async fn frozen_lockfile_disables_optimistic_short_circuit() {
// absent so the polarity of the gate is clear.
}
/// Regression: a single-project install where `node_modules` is
/// still on disk (so the workspace-state file survives) but
/// `pnpm-lock.yaml` is gone must NOT short-circuit. This is the
/// `cache+node_modules` and `node_modules`-only benchmark scenario
/// pnpm finishes in ~5 s and pacquet was silently completing in
/// ~35 ms before the single-project lockfile gate landed. Mirrors
/// pnpm's [`throwLockfileNotFound`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L396-L401)
/// converting into `upToDate: false`. Companion to the workspace-
/// mode tolerance proved by
/// Regression: a single-project install with NO lockfile anywhere —
/// `pnpm-lock.yaml` is gone and the virtual store has no current
/// `lock.yaml` to stand in for it — must NOT short-circuit, even when
/// `node_modules` and the workspace-state file survive. There is
/// nothing to content-check the manifests against and nothing to
/// regenerate `pnpm-lock.yaml` from, so the full install must run.
/// Mirrors pnpm's [`throwLockfileNotFound`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L396-L401)
/// converting into `upToDate: false`. When the current lockfile IS
/// present, the fast path instead treats it as the wanted lockfile —
/// see `regenerates_missing_wanted_lockfile_from_current_when_manifests_unchanged`
/// in the `optimistic_repeat_install` tests. Companion to the
/// workspace-mode tolerance proved by
/// [`returns_up_to_date_in_workspace_mode_without_lockfile`](crate::optimistic_repeat_install::tests::returns_up_to_date_in_workspace_mode_without_lockfile).
#[tokio::test]
async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing() {
@@ -5706,8 +5709,9 @@ async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing(
manifest.add_dependency("sibling", "link:../sibling", DependencyGroup::Prod).unwrap();
manifest.save().unwrap();
// Deliberately do NOT write `pnpm-lock.yaml` — that's the
// scenario under test.
// Deliberately do NOT write `pnpm-lock.yaml` and do NOT seed a
// current `lock.yaml` in the virtual store — that's the scenario
// under test.
let mut config = Config::new();
config.lockfile = false;
@@ -5716,19 +5720,6 @@ async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing(
config.virtual_store_dir = virtual_store_dir;
let config = config.leak();
let lockfile: Lockfile = serde_saphyr::from_str(text_block! {
"lockfileVersion: '9.0'"
"importers:"
" .:"
" dependencies:"
" sibling:"
" specifier: link:../sibling"
" version: link:../sibling"
"packages: {}"
"snapshots: {}"
})
.expect("parse lockfile");
let included = pacquet_modules_yaml::IncludedDependencies {
dependencies: true,
dev_dependencies: false,
@@ -5788,7 +5779,7 @@ async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing(
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: false,
@@ -5816,7 +5807,7 @@ async fn optimistic_repeat_install_does_not_short_circuit_when_lockfile_missing(
LogEvent::Pnpm(log) if log.message == "Already up to date"
)),
"the optimistic 'Already up to date' log MUST NOT fire when \
`pnpm-lock.yaml` is missing in a single-project install; got events: {captured:#?}",
no lockfile exists in a single-project install; got events: {captured:#?}",
);
}
@@ -5977,6 +5968,11 @@ async fn optimistic_repeat_install_round_trips_on_single_project_install() {
drop((dir, mock_instance));
}
/// A fresh install records its lockfile-verification verdict, so a
/// repeat install that reaches the full path (the optimistic fast
/// path is disabled here — it would otherwise absorb the touched
/// manifest via the content re-check) hits the cache and never fans
/// out to the registry.
#[tokio::test]
async fn fresh_install_records_lockfile_verification_for_mtime_bypassed_noop() {
let mock_instance = TestRegistry::start();
@@ -6065,6 +6061,7 @@ async fn fresh_install_records_lockfile_verification_for_mtime_bypassed_noop() {
second_config.modules_dir = modules_dir;
second_config.virtual_store_dir = virtual_store_dir;
second_config.registry = "http://127.0.0.1:9/".to_string();
second_config.optimistic_repeat_install = false;
let second_config = second_config.leak();
Install {
@@ -6112,6 +6109,269 @@ async fn fresh_install_records_lockfile_verification_for_mtime_bypassed_noop() {
drop(dir);
}
/// Shared setup for the offline repeat-install regression tests below:
/// a real install against the mock registry, after which the registry
/// is dropped and the packument cache is wiped. Any code path that
/// falls off the optimistic fast path — the resolver, the
/// lockfile-verification fan-out, a tarball fetch — would have to
/// reach the dead `127.0.0.1:9` registry and fail the install, so the
/// `expect` on the second run is the regression tripwire for the
/// repeat-install optimizations (the benchmarks don't run in CI; these
/// tests are what pins the "zero network, zero pipeline" property).
async fn install_then_go_offline() -> (tempfile::TempDir, &'static Config, PackageManifest) {
let mock_instance = TestRegistry::start();
let dir = tempdir().unwrap();
let cache_dir = dir.path().join("cache");
let store_dir = dir.path().join("pacquet-store");
let project_root = dir.path().join("project");
let modules_dir = project_root.join("node_modules");
let virtual_store_dir = modules_dir.join(".pacquet");
std::fs::create_dir_all(&project_root).expect("create project root");
let manifest_path = project_root.join("package.json");
let mut manifest = PackageManifest::create_if_needed(manifest_path.clone()).unwrap();
manifest
.add_dependency("@pnpm.e2e/hello-world-js-bin", "1.0.0", DependencyGroup::Prod)
.unwrap();
manifest.save().unwrap();
let mut config = Config::new();
config.cache_dir = cache_dir.clone();
config.store_dir = store_dir.clone().into();
config.modules_dir = modules_dir.clone();
config.virtual_store_dir = virtual_store_dir.clone();
config.registry = mock_instance.url();
let config = config.leak();
Install {
tarball_mem_cache: Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: false,
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
update_checksums: false,
is_full_install: true,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
lockfile_only: false,
resolved_packages: &Default::default(),
update_seed_policy: crate::UpdateSeedPolicy::KeepAll,
auth_override: None,
resolution_observer: None,
}
.run::<SilentReporter>()
.await
.expect("first install must succeed");
drop(mock_instance);
// The benchmark harness wipes `~/.cache/pnpm` (packument cache +
// `lockfile-verified.jsonl`) before every run; do the same so a
// regression can't hide behind a cache hit.
std::fs::remove_dir_all(&cache_dir).expect("wipe the cache dir");
let mut offline_config = Config::new();
offline_config.cache_dir = cache_dir;
offline_config.store_dir = store_dir.into();
offline_config.modules_dir = modules_dir;
offline_config.virtual_store_dir = virtual_store_dir;
offline_config.registry = "http://127.0.0.1:9/".to_string();
let offline_config = offline_config.leak();
(dir, offline_config, manifest)
}
/// Rewrite `package.json` with identical content but a strictly newer
/// mtime — the shape the vlt.sh benchmark prepare step (`npm pkg
/// delete`, `touch`) produces before every timed run.
fn touch_manifest(manifest: &PackageManifest) -> PackageManifest {
let manifest_path = manifest.path().to_path_buf();
let manifest_text = std::fs::read_to_string(&manifest_path).expect("read package.json");
std::fs::write(&manifest_path, manifest_text).expect("refresh package.json mtime");
let forced_mtime = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
std::fs::OpenOptions::new()
.write(true)
.open(&manifest_path)
.expect("open package.json")
.set_times(std::fs::FileTimes::new().set_modified(forced_mtime))
.expect("force package.json mtime");
PackageManifest::from_path(manifest_path).expect("reload manifest")
}
/// A repeat install whose manifest was rewritten with identical
/// content (newer mtime) must short-circuit offline: no resolver, no
/// lockfile-verification fan-out, no install pipeline. Guards the
/// modified-manifests content re-check end-to-end through
/// `Install::run`'s dispatch ordering — the fast path has to run
/// *before* the verification gate for this to pass with a dead
/// registry and an empty packument/verdict cache.
#[tokio::test]
async fn optimistic_repeat_install_short_circuits_offline_when_touched_manifest_is_unchanged() {
let (dir, offline_config, manifest) = install_then_go_offline().await;
let project_root = manifest.path().parent().unwrap().to_path_buf();
let touched_manifest = touch_manifest(&manifest);
let lockfile_path = project_root.join(Lockfile::FILE_NAME);
let wanted_lockfile =
Lockfile::load_wanted_from_dir(&project_root).expect("load wanted lockfile").unwrap();
static EVENTS: Mutex<Vec<LogEvent>> = Mutex::new(Vec::new());
EVENTS.lock().unwrap().clear();
struct RecordingReporter;
impl Reporter for RecordingReporter {
fn emit(event: &LogEvent) {
EVENTS.lock().unwrap().push(event.clone());
}
}
Install {
tarball_mem_cache: Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config: offline_config,
manifest: &touched_manifest,
lockfile: Some(&wanted_lockfile),
lockfile_path: Some(&lockfile_path),
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: false,
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
update_checksums: false,
is_full_install: true,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
lockfile_only: false,
resolved_packages: &Default::default(),
update_seed_policy: crate::UpdateSeedPolicy::KeepAll,
auth_override: None,
resolution_observer: None,
}
.run::<RecordingReporter>()
.await
.expect("repeat install with an unchanged-content manifest must not need the registry");
let captured = EVENTS.lock().unwrap();
assert!(
captured.iter().any(|event| matches!(
event,
LogEvent::Pnpm(log) if log.message == "Already up to date"
)),
"the touched-but-unchanged manifest must take the fast path; got {captured:#?}",
);
let pipeline_emits = captured
.iter()
.filter(|event| {
matches!(
event,
LogEvent::Context(_) | LogEvent::Stage(_) | LogEvent::LockfileVerification(_),
)
})
.count();
assert_eq!(
pipeline_emits, 0,
"the fast path must not run any install-setup step; got {captured:#?}",
);
drop(dir);
}
/// A repeat install with `pnpm-lock.yaml` deleted but `node_modules`
/// intact must short-circuit offline by treating the current lockfile
/// (`<virtual_store_dir>/lock.yaml`) as the wanted one, and must
/// restore `pnpm-lock.yaml` byte-identically. Guards the
/// current-as-wanted fallback end-to-end: a regression into the full
/// pipeline (resolution or the verification fan-out against an empty
/// cache) fails on the dead registry.
#[tokio::test]
async fn optimistic_repeat_install_restores_missing_lockfile_offline() {
let (dir, offline_config, manifest) = install_then_go_offline().await;
let project_root = manifest.path().parent().unwrap().to_path_buf();
let lockfile_path = project_root.join(Lockfile::FILE_NAME);
let original_lockfile_bytes =
std::fs::read(&lockfile_path).expect("read pnpm-lock.yaml written by the first install");
std::fs::remove_file(&lockfile_path).expect("delete pnpm-lock.yaml");
let touched_manifest = touch_manifest(&manifest);
static EVENTS: Mutex<Vec<LogEvent>> = Mutex::new(Vec::new());
EVENTS.lock().unwrap().clear();
struct RecordingReporter;
impl Reporter for RecordingReporter {
fn emit(event: &LogEvent) {
EVENTS.lock().unwrap().push(event.clone());
}
}
Install {
tarball_mem_cache: Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config: offline_config,
manifest: &touched_manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: false,
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
update_checksums: false,
is_full_install: true,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
lockfile_only: false,
resolved_packages: &Default::default(),
update_seed_policy: crate::UpdateSeedPolicy::KeepAll,
auth_override: None,
resolution_observer: None,
}
.run::<RecordingReporter>()
.await
.expect("repeat install with a deleted pnpm-lock.yaml must not need the registry");
let captured = EVENTS.lock().unwrap();
assert!(
captured.iter().any(|event| matches!(
event,
LogEvent::Pnpm(log) if log.message == "Already up to date"
)),
"the deleted-lockfile repeat install must take the fast path; got {captured:#?}",
);
let pipeline_emits = captured
.iter()
.filter(|event| {
matches!(
event,
LogEvent::Context(_) | LogEvent::Stage(_) | LogEvent::LockfileVerification(_),
)
})
.count();
assert_eq!(
pipeline_emits, 0,
"the fast path must not run any install-setup step; got {captured:#?}",
);
let regenerated_bytes =
std::fs::read(&lockfile_path).expect("pnpm-lock.yaml must be regenerated");
assert_eq!(
regenerated_bytes, original_lockfile_bytes,
"the regenerated pnpm-lock.yaml must be byte-identical to the one the install wrote",
);
drop(dir);
}
#[tokio::test]
async fn fresh_lockfile_applies_overrides_to_direct_dependencies() {
let (_dir, lockfile) = fresh_lockfile_only_with_overrides(

View File

@@ -16,19 +16,24 @@
//! — the underlying check.
//!
//! Scope: the mtime-vs-`lastValidatedTimestamp` branch (upstream's
//! `modifiedProjects.length === 0` exit at lines 263-271), plus the
//! `modifiedProjects.length === 0` exit at lines 263-271), the
//! patch-file branch of upstream's
//! [`patchesOrHooksAreModified`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L597-L612):
//! a configured patch file whose mtime is newer than
//! [`patchesOrHooksAreModified`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L597-L612)
//! (a configured patch file whose mtime is newer than
//! `lastValidatedTimestamp` invalidates the fast path even when its
//! `patchedDependencies` config entry is unchanged (a content edit the
//! key→path settings comparison can't see). The pnpmfile branch of
//! `patchesOrHooksAreModified` and the `assertWantedLockfileUpToDate`
//! re-verification are NOT ported here. When this function returns
//! `Decision::Skipped` the caller proceeds with the full install path,
//! which still has its own freshness guards (`check_lockfile_freshness`,
//! the no-op short-circuit). Remaining work tracked at
//! pnpm/pnpm#11940 (this issue).
//! `patchedDependencies` config entry is unchanged a content edit the
//! key→path settings comparison can't see), and the modified-manifests
//! content re-check: when a manifest's mtime is newer but its
//! dependency-relevant content still matches the lockfile, upstream's
//! [`assertWantedLockfileUpToDate`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L483-L561)
//! still reports up-to-date (a `touch package.json`, a `scripts` edit, or
//! an `npm pkg set/delete` rewrite must not trigger a full install). The
//! pnpmfile branch of `patchesOrHooksAreModified` and the
//! `isLocalFileDepUpdated` branch of `linkedPackagesAreUpToDate` are NOT
//! ported here. When this function returns `Decision::Skipped` the caller
//! proceeds with the full install path, which still has its own freshness
//! guards (`check_lockfile_freshness`, the no-op short-circuit). Remaining
//! work tracked at pnpm/pnpm#11940 (this issue).
//!
//! [`checkDepsStatus`]: https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts
//!
@@ -46,13 +51,14 @@ use std::{
time::SystemTime,
};
use pacquet_catalogs_types::Catalogs;
use pacquet_config::{Config, LinkWorkspacePackages, NodeLinker};
use pacquet_lockfile::Lockfile;
use pacquet_lockfile::{ImporterDepVersion, Lockfile, ProjectSnapshot};
use pacquet_modules_yaml::IncludedDependencies;
use pacquet_package_manifest::PackageManifest;
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
use pacquet_workspace_state::{
NodeLinker as WorkspaceStateNodeLinker, WorkspaceState, WorkspaceStateSettings,
load_workspace_state,
load_workspace_state, update_workspace_state,
};
/// Outcome of [`check_optimistic_repeat_install`].
@@ -70,40 +76,63 @@ pub enum Decision {
Skipped { reason: &'static str },
}
/// Inputs to [`check_optimistic_repeat_install`].
pub struct OptimisticRepeatInstallCheck<'a> {
/// The directory containing `pnpm-workspace.yaml` (or the project
/// root when no workspace manifest exists — same fallback as
/// [`Install::run`](crate::Install::run)).
pub workspace_root: &'a Path,
pub config: &'a Config,
pub node_linker: NodeLinker,
pub included: IncludedDependencies,
/// Every importer's `(root_dir, manifest)` pair. For a
/// single-project install it's just the root manifest; for a
/// workspace install it's every project the resolver would
/// otherwise walk. The caller passes this in (rather than this
/// function rediscovering it) so the same walk seeds the regular
/// install path on the fall-through.
pub project_manifests: &'a [(PathBuf, &'a PackageManifest)],
/// `true` when a `pnpm-workspace.yaml` drives the install — that
/// selects pnpm's
/// [`allProjects && workspaceDir` branch](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L187)
/// which keys the manifest and lockfile comparisons off
/// `lastValidatedTimestamp`. `false` (no workspace manifest)
/// selects the
/// [single-project branch](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L387-L462)
/// which additionally requires `pnpm-lock.yaml` to exist on disk —
/// pnpm throws `RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND` otherwise, which
/// the outer `try` converts into `upToDate: false` — and keys its
/// comparisons off the lockfile mtimes instead.
pub is_workspace_install: bool,
/// The wanted lockfile as loaded by the CLI (`None` when
/// `pnpm-lock.yaml` is absent or empty). Consulted only by the
/// modified-manifests content re-check; the pure-mtime fast path
/// never reads it. When `None` and `<virtual_store_dir>/lock.yaml`
/// exists, the current lockfile stands in as the wanted one — it
/// records exactly what the previous install materialized — and
/// `pnpm-lock.yaml` is regenerated from it before the check
/// reports up-to-date.
pub lockfile: Option<&'a Lockfile>,
/// Workspace catalogs, for resolving `catalog:` values inside
/// `pnpm.overrides` before the lockfile settings comparison.
pub catalogs: &'a Catalogs,
}
/// Run the workspace-state freshness fast path. Returns
/// [`Decision::UpToDate`] when the install can short-circuit.
///
/// `workspace_root` is the directory containing `pnpm-workspace.yaml`
/// (or the project root when no workspace manifest exists — same
/// fallback as [`Install::run`](crate::Install::run)).
///
/// `project_manifests` lists every importer's `(root_dir, manifest)`
/// pair. For a single-project install it's just the root manifest;
/// for a workspace install it's every project the resolver would
/// otherwise walk. The caller passes this in (rather than this
/// function rediscovering it) so the same walk seeds the regular
/// install path on the fall-through.
///
/// `is_workspace_install` is `true` when a `pnpm-workspace.yaml`
/// drives the install — that selects pnpm's
/// [`allProjects && workspaceDir` branch](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L187)
/// which exits purely on the per-manifest mtime check. `false` (no
/// workspace manifest) selects the
/// [single-project branch](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L387-L462)
/// which additionally requires `pnpm-lock.yaml` to exist on disk —
/// pnpm throws `RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND` otherwise, which
/// the outer `try` converts into `upToDate: false`.
///
/// Always returns `Decision::Skipped` when
/// `config.optimistic_repeat_install` is `false`.
pub fn check_optimistic_repeat_install(
workspace_root: &Path,
config: &Config,
node_linker: NodeLinker,
included: IncludedDependencies,
project_manifests: &[(PathBuf, &PackageManifest)],
is_workspace_install: bool,
) -> Decision {
pub fn check_optimistic_repeat_install(check: &OptimisticRepeatInstallCheck<'_>) -> Decision {
let &OptimisticRepeatInstallCheck {
workspace_root,
config,
node_linker,
included,
project_manifests,
is_workspace_install,
..
} = check;
if !config.optimistic_repeat_install {
return Decision::Skipped { reason: "optimistic_repeat_install disabled" };
}
@@ -140,17 +169,27 @@ pub fn check_optimistic_repeat_install(
};
}
// Single-project installs require `pnpm-lock.yaml` on disk to
// even attempt the fast path. Upstream's single-project branch
// at <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L396-L401>
// Single-project installs require a lockfile to even attempt the
// fast path. Upstream's single-project branch at
// <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L396-L401>
// throws `RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND` when
// `wantedLockfileStats` is absent, which the outer `try`
// converts into `upToDate: false`. Workspace installs skip this
// gate — pnpm's workspace branch returns `upToDate: true` purely
// off the manifest-mtime check (its only lockfile probe,
// `findConflictedLockfileDir`, silently `continue`s on ENOENT at
// converts into `upToDate: false`. Pacquet additionally accepts
// the *current* lockfile (`<virtual_store_dir>/lock.yaml`) as a
// stand-in when `pnpm-lock.yaml` is missing: it records exactly
// what the previous install materialized, so the content checks
// can run against it and `pnpm-lock.yaml` is regenerated from it
// on success — the same substitution the full install path makes
// when it synthesizes the wanted lockfile from the current one.
// Workspace installs skip this gate — pnpm's workspace branch
// returns `upToDate: true` purely off the manifest-mtime check
// (its only lockfile probe, `findConflictedLockfileDir`, silently
// `continue`s on ENOENT at
// <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L593-L596>).
if !is_workspace_install && !workspace_root.join(Lockfile::FILE_NAME).exists() {
if !is_workspace_install
&& !workspace_root.join(Lockfile::FILE_NAME).exists()
&& !config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME).exists()
{
return Decision::Skipped { reason: "wanted lockfile missing" };
}
@@ -168,12 +207,470 @@ pub fn check_optimistic_repeat_install(
// returns `upToDate: true` when none have an mtime newer than
// `workspaceState.lastValidatedTimestamp`. The walk has to
// succeed (read errors mean we can't *prove* freshness, so fall
// through), and any newer mtime invalidates.
if !manifests_unchanged_since(state.last_validated_timestamp, project_manifests) {
return Decision::Skipped { reason: "a manifest is newer than the last validation" };
// through).
let Some(manifest_stats) = stat_manifests(project_manifests) else {
return Decision::Skipped { reason: "failed to stat a project manifest" };
};
let modified: Vec<&ManifestStat<'_>> = manifest_stats
.iter()
.filter(|stat| stat.mtime_ms > state.last_validated_timestamp)
.collect();
if modified.is_empty() {
return match regenerate_wanted_lockfile_if_missing(check, None) {
Ok(()) => Decision::UpToDate,
Err(reason) => Decision::Skipped { reason },
};
}
Decision::UpToDate
// A newer mtime alone doesn't invalidate: upstream's
// modified-manifests branch re-checks the *content* against the
// wanted lockfile (`assertWantedLockfileUpToDate`) so a rewrite
// that left the dependency fields intact — `touch`, a `scripts`
// edit, `npm pkg set/delete` — still reports up to date.
match modified_manifests_match_lockfile(check, &state, &modified) {
Ok(loaded_current) => {
if let Err(reason) = regenerate_wanted_lockfile_if_missing(check, loaded_current) {
return Decision::Skipped { reason };
}
// "update lastValidatedTimestamp to prevent pointless
// repeat" — upstream's workspace branch rewrites the
// state after the content checks pass at
// <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L349-L357>.
// The single-project branch keys its comparisons off the
// lockfile mtimes instead and leaves the state alone. A
// failed write only costs the next run a repeat of the
// content check, so it degrades rather than fails.
if is_workspace_install {
let new_state = crate::install::build_workspace_state(
config,
node_linker,
included,
project_manifests,
);
if let Err(error) = update_workspace_state(workspace_root, &new_state) {
tracing::warn!(
target: "pacquet::install",
?error,
"Failed to refresh the workspace state after the repeat-install content check",
);
}
}
Decision::UpToDate
}
Err(reason) => Decision::Skipped { reason },
}
}
/// Restore a missing `pnpm-lock.yaml` from the current lockfile before
/// the fast path reports "Already up to date", so the short-circuit
/// leaves the same on-disk contract a full install would (the full
/// path synthesizes the wanted lockfile from the current one and
/// rewrites it). No-op when `pnpm-lock.yaml` was loaded, when lockfile
/// writing is disabled (`lockfile: false`), or when there is no
/// current lockfile to restore from (a dependency-less project).
/// A write failure falls through to the full install path rather than
/// reporting up-to-date while leaving the lockfile missing.
fn regenerate_wanted_lockfile_if_missing(
check: &OptimisticRepeatInstallCheck<'_>,
loaded_current: Option<Lockfile>,
) -> Result<(), &'static str> {
if check.lockfile.is_some() || !check.config.lockfile {
return Ok(());
}
let current = match loaded_current {
Some(current) => Some(current),
None => Lockfile::load_current_from_virtual_store_dir(&check.config.virtual_store_dir)
.map_err(|_| "the current lockfile cannot be loaded")?,
};
let Some(current) = current else {
return Ok(());
};
current
.save_to_path(&check.workspace_root.join(Lockfile::FILE_NAME))
.map_err(|_| "failed to regenerate pnpm-lock.yaml from the current lockfile")
}
/// One project manifest's stat outcome, paired with the inputs the
/// content re-check needs.
struct ManifestStat<'a> {
root_dir: &'a Path,
manifest: &'a PackageManifest,
mtime_ms: i64,
}
/// Port of upstream's modified-manifests branch: the lockfile-equality
/// assertion ([`assertLockfilesEqual`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/assertLockfilesEqual.ts))
/// plus [`assertWantedLockfileUpToDate`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L483-L561)
/// (settings drift, per-importer specifier match, linked-package
/// freshness) for every project whose manifest is newer than the last
/// validation. `Err` carries the `Decision::Skipped` reason; upstream
/// converts the equivalent throws into `upToDate: false`.
///
/// When `pnpm-lock.yaml` is absent, the current lockfile stands in as
/// the wanted one (see the lockfile gate in
/// [`check_optimistic_repeat_install`]); `Ok(Some(_))` then carries the
/// loaded current lockfile so the caller can regenerate
/// `pnpm-lock.yaml` from it without a second read.
fn modified_manifests_match_lockfile(
check: &OptimisticRepeatInstallCheck<'_>,
state: &WorkspaceState,
modified: &[&ManifestStat<'_>],
) -> Result<Option<Lockfile>, &'static str> {
let &OptimisticRepeatInstallCheck {
workspace_root,
config,
project_manifests,
is_workspace_install,
lockfile,
catalogs,
..
} = check;
let mut loaded_current: Option<Lockfile> = None;
let mut wanted_is_current = false;
let (wanted, wanted_mtime_ms): (&Lockfile, i64) = match lockfile {
Some(wanted) => {
let Some(mtime) = mtime_ms(&workspace_root.join(Lockfile::FILE_NAME)) else {
return Err(
"a manifest is newer than the last validation and pnpm-lock.yaml cannot be stat'd",
);
};
(wanted, mtime)
}
None => {
let current_path = config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME);
let Some(mtime) = mtime_ms(&current_path) else {
return Err(
"a manifest is newer than the last validation and no lockfile is loaded",
);
};
let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir)
.map_err(|_| "the current lockfile cannot be loaded")?
.ok_or("a manifest is newer than the last validation and no lockfile is loaded")?;
wanted_is_current = true;
(&*loaded_current.insert(current), mtime)
}
};
// Decide which modified projects need the full content check, and
// whether the wanted lockfile must be compared against the current
// one (`<virtual_store_dir>/lock.yaml`).
let to_check: &[&ManifestStat<'_>] = if wanted_is_current {
// The wanted lockfile IS the current one — there's no second
// lockfile to assert equality against, and the mtime
// short-circuits below compare the two lockfile files, so they
// don't apply. Every modified project gets the content check.
modified
} else if is_workspace_install {
// Workspace branch: a wanted lockfile newer than the last
// validation must equal what the previous install materialized.
// Mirrors <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L283-L289>.
if wanted_mtime_ms > state.last_validated_timestamp {
assert_wanted_lockfile_equals_current(wanted, config)?;
}
modified
} else {
// Single-project branch keys off the lockfile mtimes instead of
// `lastValidatedTimestamp`. Mirrors
// <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L407-L462>.
let current_mtime_ms =
mtime_ms(&config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME));
if let Some(current_mtime_ms) = current_mtime_ms
&& wanted_mtime_ms > current_mtime_ms
{
assert_wanted_lockfile_equals_current(wanted, config)?;
}
let root = modified.first().expect("modified-manifests branch requires a modified project");
if root.mtime_ms > wanted_mtime_ms {
modified
} else if current_mtime_ms.is_some() {
// "The manifest file is not newer than the lockfile.
// Exiting check."
&[]
} else if wanted.packages.as_ref().is_some_and(|packages| !packages.is_empty()) {
// RUN_CHECK_DEPS_NO_DEPS: the lockfile requires
// dependencies but nothing was ever installed.
return Err("the lockfile requires dependencies but none were installed");
} else {
&[]
}
};
if to_check.is_empty() {
return Ok(loaded_current);
}
let parsed_overrides = crate::install::parse_config_overrides(config, catalogs)
.map_err(|_| "pnpm.overrides cannot be parsed")?;
if let Err(error) =
crate::install::check_lockfile_settings_drift(wanted, config, parsed_overrides.as_deref())
{
tracing::debug!(target: "pacquet::install", %error, "repeat-install content check: lockfile settings drift");
return Err("a lockfile setting drifted from the current configuration");
}
let linked_ctx = LinkedPackagesContext::new(config, project_manifests);
for project in to_check {
let importer_id =
pacquet_workspace::importer_id_from_root_dir(workspace_root, project.root_dir);
if let Err(error) = crate::install::check_importer_satisfies(
wanted,
project.manifest,
&importer_id,
config,
parsed_overrides.as_deref(),
) {
tracing::debug!(target: "pacquet::install", %error, importer_id, "repeat-install content check: manifest no longer satisfied");
return Err("a modified manifest is no longer satisfied by the lockfile");
}
let Some(importer) = wanted.importers.get(&importer_id) else {
return Err("a modified project has no importer entry in the lockfile");
};
if !linked_packages_are_up_to_date(
&linked_ctx,
project.root_dir,
project.manifest,
importer,
) {
return Err("a linked package is out of date");
}
}
Ok(loaded_current)
}
/// Port of upstream's
/// [`assertLockfilesEqual`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/assertLockfilesEqual.ts):
/// with no current lockfile every importer of the wanted one must be
/// dependency-free (`RUN_CHECK_DEPS_NO_DEPS`); otherwise the two parsed
/// lockfiles must be equal (`RUN_CHECK_DEPS_OUTDATED_DEPS`).
fn assert_wanted_lockfile_equals_current(
wanted: &Lockfile,
config: &Config,
) -> Result<(), &'static str> {
let current = Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir)
.map_err(|_| "the current lockfile cannot be loaded")?;
match current {
None => {
let any_deps = wanted.importers.values().any(|snapshot| {
snapshot
.dependencies_by_groups([
DependencyGroup::Prod,
DependencyGroup::Dev,
DependencyGroup::Optional,
])
.next()
.is_some()
});
if any_deps {
Err("the lockfile requires dependencies but none were installed")
} else {
Ok(())
}
}
Some(current) => {
if &current == wanted {
Ok(())
} else {
Err("the installed dependencies are not up to date with the lockfile")
}
}
}
}
/// Shared lookups for [`linked_packages_are_up_to_date`], built once
/// per content check. Mirrors the `bind(null, {...})` context upstream
/// creates in
/// [`checkDepsStatus`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L317-L329).
struct LinkedPackagesContext<'a> {
link_workspace_packages: bool,
manifests_by_dir: std::collections::HashMap<&'a Path, &'a PackageManifest>,
/// `name → version → root_dir` over the workspace's projects —
/// the same index upstream's `workspacePackages` map carries.
workspace_packages:
std::collections::HashMap<String, std::collections::HashMap<String, &'a Path>>,
}
impl<'a> LinkedPackagesContext<'a> {
fn new(config: &Config, project_manifests: &'a [(PathBuf, &'a PackageManifest)]) -> Self {
let mut manifests_by_dir = std::collections::HashMap::new();
let mut workspace_packages: std::collections::HashMap<
String,
std::collections::HashMap<String, &'a Path>,
> = std::collections::HashMap::new();
for (root_dir, manifest) in project_manifests {
manifests_by_dir.insert(root_dir.as_path(), *manifest);
if let (Some(name), Some(version)) = (
manifest_string_field(manifest, "name"),
manifest_string_field(manifest, "version"),
) {
workspace_packages.entry(name).or_default().insert(version, root_dir.as_path());
}
}
LinkedPackagesContext {
link_workspace_packages: config.link_workspace_packages != LinkWorkspacePackages::Off,
manifests_by_dir,
workspace_packages,
}
}
/// The version of the package manifest at `dir`, preferring the
/// already-loaded workspace manifests over a disk read. Mirrors
/// upstream's `manifestsByDir[linkedDir] ?? safeReadPackageJsonFromDir`.
fn linked_version(&self, dir: &Path) -> Option<String> {
if let Some(manifest) = self.manifests_by_dir.get(dir) {
return manifest_string_field(manifest, "version");
}
pacquet_package_manifest::safe_read_package_json_from_dir(dir)
.ok()
.flatten()
.and_then(|value| value.get("version").and_then(|v| v.as_str()).map(str::to_string))
}
}
/// Port of upstream's
/// [`linkedPackagesAreUpToDate`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/lockfile/verification/src/linkedPackagesAreUpToDate.ts):
/// every importer dependency that resolved to a workspace link must
/// still link under today's manifest spec, and every one that resolved
/// to the registry must not have become linkable. The
/// `isLocalFileDepUpdated` branch (a `file:` directory specifier) is
/// not ported — those entries conservatively report "not up to date" so
/// the full install path re-evaluates them.
fn linked_packages_are_up_to_date(
ctx: &LinkedPackagesContext<'_>,
project_dir: &Path,
manifest: &PackageManifest,
snapshot: &ProjectSnapshot,
) -> bool {
const GROUPS: [(DependencyGroup, &str); 3] = [
(DependencyGroup::Optional, "optionalDependencies"),
(DependencyGroup::Prod, "dependencies"),
(DependencyGroup::Dev, "devDependencies"),
];
for (group, manifest_field) in GROUPS {
let Some(lockfile_deps) = snapshot.get_map_by_group(group) else {
continue;
};
let Some(manifest_deps) =
manifest.value().get(manifest_field).and_then(|value| value.as_object())
else {
continue;
};
for (dep_name, dep) in lockfile_deps {
let dep_name = dep_name.to_string();
let Some(current_spec) = manifest_deps.get(&dep_name).and_then(|v| v.as_str()) else {
continue;
};
if ref_is_local_directory(&dep.specifier) {
// A `file:` specifier that resolved to `link:` (e.g. an
// injected self-reference) is a local link with no
// `packages:` entry — up to date by construction.
if matches!(dep.version, ImporterDepVersion::Link(_)) {
continue;
}
return false;
}
let link_target = dep.version.as_link_target();
let is_linked = link_target.is_some();
if is_linked
&& (current_spec.starts_with("link:")
|| current_spec.starts_with("file:")
|| current_spec.starts_with("workspace:."))
{
continue;
}
// A linked dependency whose spec is a distribution tag is
// considered up to date to skip full resolution
// (<https://github.com/pnpm/pnpm/issues/6592>).
if is_linked && spec_is_distribution_tag(current_spec) {
continue;
}
let linked_dir: Option<std::borrow::Cow<'_, Path>> = match link_target {
Some(target) => Some(std::borrow::Cow::Owned(project_dir.join(target))),
None => dep
.version
.as_regular()
.map(|ver| ver.to_string())
.and_then(|version| ctx.workspace_packages.get(&dep_name)?.get(&version))
.map(|dir| std::borrow::Cow::Borrowed(*dir)),
};
let Some(linked_dir) = linked_dir else {
continue;
};
if !ctx.link_workspace_packages && !current_spec.starts_with("workspace:") {
// A linkable dir exists, but nothing requests linking it.
continue;
}
let available_range = version_range_of_spec(current_spec);
let local_package_satisfies_range = matches!(available_range, "*" | "^" | "~")
|| ctx
.linked_version(&linked_dir)
.is_some_and(|version| semver_satisfies_loosely(&version, available_range));
if is_linked != local_package_satisfies_range {
return false;
}
}
}
true
}
/// Port of upstream's
/// [`refIsLocalDirectory`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/lockfile/utils/src/refIsLocalTarball.ts):
/// a `file:` specifier that is not a tarball.
fn ref_is_local_directory(specifier: &str) -> bool {
specifier.starts_with("file:")
&& !(specifier.ends_with(".tgz")
|| specifier.ends_with(".tar.gz")
|| specifier.ends_with(".tar"))
}
/// Whether a bare specifier is an npm distribution tag (`latest`,
/// `beta`, ...). Approximates upstream's
/// `getVersionSelectorType(spec)?.type === 'tag'` — anything that
/// doesn't parse as a semver range and contains only characters a tag
/// name may carry. Protocol-ish specs (`workspace:^1.0.0`,
/// `npm:foo@1`) contain `:`/`@`/`/` and therefore never match, same as
/// `version-selector-type` rejecting them.
fn spec_is_distribution_tag(spec: &str) -> bool {
!spec.is_empty()
&& spec.parse::<node_semver::Range>().is_err()
&& spec.chars().all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.'))
}
/// Port of upstream's `getVersionRange`: strips the `workspace:` /
/// `npm:` envelope so the remainder can be compared as a semver range.
fn version_range_of_spec(spec: &str) -> &str {
if let Some(rest) = spec.strip_prefix("workspace:") {
return rest;
}
if let Some(rest) = spec.strip_prefix("npm:") {
// `npm:<alias>@<range>` — the `@` search starts at index 1 so a
// leading scope `@` isn't mistaken for the separator.
return match rest.get(1..).and_then(|tail| tail.find('@')) {
Some(at) => {
let range = &rest[at + 2..];
if range.is_empty() { "*" } else { range }
}
None => "*",
};
}
spec
}
/// `semver.satisfies(version, range, { loose: true })` — a version or
/// range that doesn't parse fails the match.
fn semver_satisfies_loosely(version: &str, range: &str) -> bool {
let Ok(version) = version.parse::<node_semver::Version>() else { return false };
let Ok(range) = range.parse::<node_semver::Range>() else { return false };
range.satisfies(&version)
}
/// Millisecond mtime of `path`, `None` when it can't be stat'd.
/// Converts wall-clock to ms-since-epoch the same way
/// `pacquet_workspace_state::now_millis` does on the write side, so
/// comparisons against `lastValidatedTimestamp` are apples-to-apples.
fn mtime_ms(path: &Path) -> Option<i64> {
let modified = fs::metadata(path).and_then(|metadata| metadata.modified()).ok()?;
let elapsed = modified.duration_since(SystemTime::UNIX_EPOCH).ok()?;
Some(i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX))
}
/// Compare today's settings against what the previous install
@@ -449,12 +946,6 @@ fn manifest_string_field(manifest: &PackageManifest, key: &str) -> Option<String
manifest.value().get(key).and_then(|v| v.as_str()).map(ToString::to_string)
}
/// Stat every project's `package.json` and check that no mtime is
/// newer than `cutoff_ms`. Any stat failure is treated as "can't
/// prove freshness, fall through" — matching pnpm's
/// [`statManifestFile`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/statManifestFile.ts)
/// behavior on missing files (it throws, which `checkDepsStatus`
/// catches via the outer `try`).
/// Whether any configured patch file's mtime is newer than the last
/// validation. Mirrors the patch branch of upstream's
/// [`patchesOrHooksAreModified`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L604-L613):
@@ -487,26 +978,24 @@ fn patches_modified_since(workspace_root: &Path, config: &Config, cutoff_ms: i64
})
}
fn manifests_unchanged_since(
cutoff_ms: i64,
project_manifests: &[(PathBuf, &PackageManifest)],
) -> bool {
project_manifests.iter().all(|(_, manifest)| {
let Ok(metadata) = fs::metadata(manifest.path()) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
// Convert wall-clock to ms-since-epoch the same way
// `pacquet_workspace_state::now_millis` does on the write
// side, so a `> cutoff` comparison is apples-to-apples.
let Ok(elapsed) = modified.duration_since(SystemTime::UNIX_EPOCH) else {
return false;
};
let modified_ms = i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX);
modified_ms <= cutoff_ms
})
/// Stat every project's `package.json`. `None` on any stat failure —
/// "can't prove freshness, fall through" — matching pnpm's
/// [`statManifestFile`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/statManifestFile.ts)
/// behavior on missing files (it throws, which `checkDepsStatus`
/// catches via the outer `try`).
fn stat_manifests<'a>(
project_manifests: &'a [(PathBuf, &'a PackageManifest)],
) -> Option<Vec<ManifestStat<'a>>> {
project_manifests
.iter()
.map(|(root_dir, manifest)| {
mtime_ms(manifest.path()).map(|mtime_ms| ManifestStat {
root_dir: root_dir.as_path(),
manifest,
mtime_ms,
})
})
.collect()
}
#[cfg(test)]

View File

@@ -1,4 +1,6 @@
use super::{Decision, check_optimistic_repeat_install, current_settings};
use super::{
Decision, OptimisticRepeatInstallCheck, check_optimistic_repeat_install, current_settings,
};
use pacquet_config::Config;
use pacquet_lockfile::Lockfile;
use pacquet_modules_yaml::IncludedDependencies;
@@ -13,6 +15,27 @@ fn isolated_included() -> IncludedDependencies {
IncludedDependencies { dependencies: true, dev_dependencies: true, optional_dependencies: true }
}
/// Run the fast-path check in single-project mode with no loaded
/// lockfile and no catalogs — the shape every pre-content-check test
/// exercises.
fn check(
workspace_root: &std::path::Path,
config: &Config,
node_linker: pacquet_config::NodeLinker,
project_manifests: &[(std::path::PathBuf, &PackageManifest)],
) -> Decision {
check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root,
config,
node_linker,
included: isolated_included(),
project_manifests,
is_workspace_install: false,
lockfile: None,
catalogs: &Default::default(),
})
}
/// Write an empty `pnpm-lock.yaml` to satisfy the single-project
/// branch's lockfile-existence gate. The fast path only checks
/// existence, not contents.
@@ -101,13 +124,11 @@ fn returns_up_to_date_when_state_and_manifests_agree() {
let (dir, config, manifest) =
setup_fresh_install(pacquet_config::NodeLinker::Isolated, "root", "1.0.0", "");
let decision = check_optimistic_repeat_install(
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(dir.path().to_path_buf(), &manifest)],
false,
);
assert_eq!(decision, Decision::UpToDate);
}
@@ -129,13 +150,11 @@ fn returns_skipped_when_config_disabled() {
// Even though the state file is missing (would also skip), the
// disabled-config branch is checked first — that's the reason
// string we assert on.
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("disabled")));
}
@@ -154,13 +173,11 @@ fn returns_skipped_when_no_state_file() {
config.modules_dir = workspace_root.join("node_modules");
let config = config.leak();
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("no workspace state")),
@@ -180,13 +197,11 @@ fn returns_skipped_when_manifest_is_newer_than_validation() {
fs::write(&manifest_path, r#"{"name":"root","version":"1.0.0"}"#).unwrap();
let refreshed_manifest = PackageManifest::from_path(manifest_path).unwrap();
let decision = check_optimistic_repeat_install(
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(dir.path().to_path_buf(), &refreshed_manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("newer")));
}
@@ -199,13 +214,11 @@ fn returns_skipped_when_node_linker_drifts() {
let (dir, config, manifest) =
setup_fresh_install(pacquet_config::NodeLinker::Hoisted, "root", "1.0.0", "");
let decision = check_optimistic_repeat_install(
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(dir.path().to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -234,13 +247,11 @@ fn returns_skipped_when_workspace_project_set_changes() {
// fire — we want to prove the project-list branch fires.
write_state(dir.path(), now_millis() + 60_000, settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(dir.path().to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("project list")));
}
@@ -284,13 +295,11 @@ fn returns_skipped_when_overrides_drift() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -327,13 +336,11 @@ fn returns_skipped_when_inject_workspace_packages_drifts() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -370,13 +377,11 @@ fn returns_skipped_when_enable_global_virtual_store_drifts() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -402,13 +407,11 @@ fn returns_up_to_date_when_recorded_global_virtual_store_is_explicit_off() {
);
write_state(dir.path(), now_millis(), settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(dir.path().to_path_buf(), &manifest)],
false,
);
assert_eq!(decision, Decision::UpToDate);
}
@@ -444,13 +447,11 @@ fn returns_skipped_when_exclude_links_from_lockfile_drifts() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -485,13 +486,11 @@ fn returns_skipped_when_minimum_release_age_drifts() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -525,13 +524,11 @@ fn returns_skipped_when_minimum_release_age_ignore_missing_time_drifts() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -568,13 +565,11 @@ fn returns_skipped_when_ignored_optional_dependencies_drift() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -614,13 +609,11 @@ fn returns_skipped_when_patched_dependencies_drift() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -662,13 +655,11 @@ fn returns_skipped_when_patch_file_modified_after_validation() {
sleep(Duration::from_millis(20));
fs::write(&patch_path, "--- a\n+++ b\n+edited\n").unwrap();
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("patch")));
}
@@ -708,13 +699,11 @@ fn returns_up_to_date_when_patch_file_unchanged() {
sleep(Duration::from_millis(20));
write_state(workspace_root, now_millis(), settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert_eq!(decision, Decision::UpToDate);
}
@@ -749,13 +738,11 @@ fn returns_skipped_when_dedupe_peers_drift() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -791,13 +778,11 @@ fn returns_skipped_when_prefer_workspace_packages_drift() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -833,13 +818,11 @@ fn returns_skipped_when_peers_suffix_max_length_drift() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -889,13 +872,11 @@ fn returns_skipped_when_package_extensions_drift() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -931,13 +912,11 @@ fn returns_skipped_when_allow_builds_drift() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -972,13 +951,11 @@ fn returns_skipped_when_dedupe_direct_deps_drifts() {
);
write_state(workspace_root, now_millis() + 60_000, stale_settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("settings")));
}
@@ -1030,13 +1007,11 @@ fn returns_up_to_date_when_state_carries_unported_pnpm_settings() {
);
write_state(workspace_root, now_millis() + 60_000, settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert_eq!(decision, Decision::UpToDate);
}
@@ -1076,13 +1051,11 @@ fn returns_up_to_date_when_state_has_empty_allow_builds_and_current_has_none() {
);
write_state(workspace_root, now_millis() + 60_000, settings, projects);
let decision = check_optimistic_repeat_install(
let decision = check(
workspace_root,
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(workspace_root.to_path_buf(), &manifest)],
false,
);
assert_eq!(decision, Decision::UpToDate);
}
@@ -1129,14 +1102,19 @@ fn returns_skipped_when_sibling_node_modules_missing_for_project_with_deps() {
);
write_state(dir.path(), now_millis() + 60_000, settings, projects);
let decision = check_optimistic_repeat_install(
dir.path(),
let decision = check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(dir.path().to_path_buf(), &root_manifest), (sibling_dir, &sibling_manifest)],
true,
);
node_linker: pacquet_config::NodeLinker::Isolated,
included: isolated_included(),
project_manifests: &[
(dir.path().to_path_buf(), &root_manifest),
(sibling_dir, &sibling_manifest),
],
is_workspace_install: true,
lockfile: None,
catalogs: &Default::default(),
});
assert!(matches!(decision, Decision::Skipped { reason } if reason.contains("node_modules")));
}
@@ -1159,13 +1137,11 @@ fn returns_skipped_when_lockfile_missing_in_single_project_mode() {
// lockfile branch.
fs::remove_file(dir.path().join(Lockfile::FILE_NAME)).expect("remove seeded lockfile");
let decision = check_optimistic_repeat_install(
let decision = check(
dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(dir.path().to_path_buf(), &manifest)],
false,
);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("wanted lockfile")),
@@ -1191,13 +1167,389 @@ fn returns_up_to_date_in_workspace_mode_without_lockfile() {
// wiped first — the workspace branch shouldn't care.
fs::remove_file(dir.path().join(Lockfile::FILE_NAME)).expect("remove seeded lockfile");
let decision = check_optimistic_repeat_install(
dir.path(),
let decision = check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
pacquet_config::NodeLinker::Isolated,
isolated_included(),
&[(dir.path().to_path_buf(), &manifest)],
true,
);
node_linker: pacquet_config::NodeLinker::Isolated,
included: isolated_included(),
project_manifests: &[(dir.path().to_path_buf(), &manifest)],
is_workspace_install: true,
lockfile: None,
catalogs: &Default::default(),
});
assert_eq!(decision, Decision::UpToDate);
}
/// Minimal valid lockfile matching a manifest with
/// `"dependencies": {"foo": "^1.0.0"}`.
const FOO_LOCKFILE: &str = "lockfileVersion: '9.0'
importers:
.:
dependencies:
foo:
specifier: ^1.0.0
version: 1.0.0
packages:
foo@1.0.0:
resolution: {integrity: sha512-aaa}
snapshots:
foo@1.0.0: {}
";
const FOO_MANIFEST: &str = r#"{"name":"root","version":"1.0.0","dependencies":{"foo":"^1.0.0"}}"#;
/// Build a single project whose manifest, wanted lockfile, and current
/// lockfile all agree, with the workspace state stamped after every
/// file write. Content-check tests then touch or rewrite individual
/// files and assert the decision.
fn setup_content_check_project() -> (tempfile::TempDir, &'static Config) {
let dir = tempdir().unwrap();
let workspace_root = dir.path();
fs::write(workspace_root.join("package.json"), FOO_MANIFEST).unwrap();
fs::write(workspace_root.join(Lockfile::FILE_NAME), FOO_LOCKFILE).unwrap();
let mut config = Config::new();
config.modules_dir = workspace_root.join("node_modules");
config.virtual_store_dir = workspace_root.join("node_modules/.pnpm");
fs::create_dir_all(&config.virtual_store_dir).unwrap();
fs::write(config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME), FOO_LOCKFILE).unwrap();
let config = config.leak();
sleep(Duration::from_millis(20));
let settings =
current_settings(config, pacquet_config::NodeLinker::Isolated, isolated_included());
let mut projects = BTreeMap::new();
projects.insert(
workspace_root.to_string_lossy().into_owned(),
ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) },
);
write_state(workspace_root, now_millis(), settings, projects);
sleep(Duration::from_millis(20));
(dir, config)
}
fn content_check_decision(
dir: &tempfile::TempDir,
config: &'static Config,
is_workspace_install: bool,
project_manifests: &[(std::path::PathBuf, &PackageManifest)],
) -> Decision {
let lockfile = Lockfile::load_wanted_from_dir(dir.path()).expect("parse pnpm-lock.yaml");
check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root: dir.path(),
config,
node_linker: pacquet_config::NodeLinker::Isolated,
included: isolated_included(),
project_manifests,
is_workspace_install,
lockfile: lockfile.as_ref(),
catalogs: &Default::default(),
})
}
/// A manifest rewrite that leaves the dependency fields intact — the
/// shape `touch package.json` / `npm pkg set/delete` produce — must
/// still short-circuit. Ports the contract behind upstream's
/// `assertWantedLockfileUpToDate` pass at
/// <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L420-L447>.
#[test]
fn returns_up_to_date_when_touched_manifest_still_satisfies_lockfile() {
let (dir, config) = setup_content_check_project();
fs::write(dir.path().join("package.json"), FOO_MANIFEST).unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, false, &[(dir.path().to_path_buf(), &manifest)]);
assert_eq!(decision, Decision::UpToDate);
}
/// A manifest rewrite that *changes* the dependency fields falls
/// through to the full install.
#[test]
fn returns_skipped_when_touched_manifest_adds_a_dependency() {
let (dir, config) = setup_content_check_project();
fs::write(
dir.path().join("package.json"),
r#"{"name":"root","version":"1.0.0","dependencies":{"foo":"^1.0.0","bar":"^2.0.0"}}"#,
)
.unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, false, &[(dir.path().to_path_buf(), &manifest)]);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("satisfied")),
"expected Skipped(no longer satisfied), got {decision:?}",
);
}
/// A wanted lockfile rewritten after the last install (newer than the
/// current lockfile, different content) cannot short-circuit: the
/// modules directory no longer reflects it. Ports upstream's
/// [`assertLockfilesEqual`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/assertLockfilesEqual.ts)
/// `RUN_CHECK_DEPS_OUTDATED_DEPS` outcome.
#[test]
fn returns_skipped_when_wanted_lockfile_diverged_from_current() {
let (dir, config) = setup_content_check_project();
fs::write(dir.path().join("package.json"), FOO_MANIFEST).unwrap();
fs::write(dir.path().join(Lockfile::FILE_NAME), FOO_LOCKFILE.replace("1.0.0", "1.0.1"))
.unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, false, &[(dir.path().to_path_buf(), &manifest)]);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("not up to date")),
"expected Skipped(outdated deps), got {decision:?}",
);
}
/// Workspace branch: a passing content check refreshes
/// `lastValidatedTimestamp` so the next run exits on the pure-mtime
/// path. Mirrors upstream's `updateWorkspaceState` call at
/// <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L349-L357>.
#[test]
fn workspace_content_check_refreshes_last_validated_timestamp() {
let (dir, config) = setup_content_check_project();
let before = pacquet_workspace_state::load_workspace_state(dir.path())
.unwrap()
.unwrap()
.last_validated_timestamp;
fs::write(dir.path().join("package.json"), FOO_MANIFEST).unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, true, &[(dir.path().to_path_buf(), &manifest)]);
assert_eq!(decision, Decision::UpToDate);
let after = pacquet_workspace_state::load_workspace_state(dir.path())
.unwrap()
.unwrap()
.last_validated_timestamp;
assert!(after > before, "expected the state timestamp to advance ({before} -> {after})");
}
/// Workspace lockfile whose root importer links a sibling: the link
/// stays valid while the sibling's version satisfies the manifest
/// range, and a bump outside the range falls through to the full
/// install. Ports upstream's
/// [`linkedPackagesAreUpToDate`](https://github.com/pnpm/pnpm/blob/cc4ff817aa/lockfile/verification/src/linkedPackagesAreUpToDate.ts).
fn linked_sibling_decision(sibling_version: &str) -> Decision {
let dir = tempdir().unwrap();
let workspace_root = dir.path();
fs::write(
workspace_root.join("package.json"),
r#"{"name":"root","version":"1.0.0","devDependencies":{"pkg-a":"^1.0.0"}}"#,
)
.unwrap();
let sibling_dir = workspace_root.join("pkg-a");
fs::create_dir_all(&sibling_dir).unwrap();
fs::write(
sibling_dir.join("package.json"),
format!(r#"{{"name":"pkg-a","version":"{sibling_version}"}}"#),
)
.unwrap();
fs::write(
workspace_root.join(Lockfile::FILE_NAME),
"lockfileVersion: '9.0'
importers:
.:
devDependencies:
pkg-a:
specifier: ^1.0.0
version: link:pkg-a
pkg-a: {}
",
)
.unwrap();
let mut config = Config::new();
config.modules_dir = workspace_root.join("node_modules");
config.virtual_store_dir = workspace_root.join("node_modules/.pnpm");
config.link_workspace_packages = pacquet_config::LinkWorkspacePackages::DirectOnly;
fs::create_dir_all(&config.modules_dir).unwrap();
let config = config.leak();
let root_manifest = PackageManifest::from_path(workspace_root.join("package.json")).unwrap();
let sibling_manifest = PackageManifest::from_path(sibling_dir.join("package.json")).unwrap();
sleep(Duration::from_millis(20));
let settings =
current_settings(config, pacquet_config::NodeLinker::Isolated, isolated_included());
let mut projects = BTreeMap::new();
projects.insert(
workspace_root.to_string_lossy().into_owned(),
ProjectEntry { name: Some("root".into()), version: Some("1.0.0".into()) },
);
projects.insert(
sibling_dir.to_string_lossy().into_owned(),
ProjectEntry { name: Some("pkg-a".into()), version: Some(sibling_version.into()) },
);
write_state(workspace_root, now_millis(), settings, projects);
sleep(Duration::from_millis(20));
// Touch the root manifest so the content re-check runs.
fs::write(
workspace_root.join("package.json"),
r#"{"name":"root","version":"1.0.0","devDependencies":{"pkg-a":"^1.0.0"}}"#,
)
.unwrap();
let root_manifest_touched =
PackageManifest::from_path(workspace_root.join("package.json")).unwrap();
let _ = root_manifest;
let lockfile = Lockfile::load_wanted_from_dir(workspace_root).expect("parse pnpm-lock.yaml");
check_optimistic_repeat_install(&OptimisticRepeatInstallCheck {
workspace_root,
config,
node_linker: pacquet_config::NodeLinker::Isolated,
included: isolated_included(),
project_manifests: &[
(workspace_root.to_path_buf(), &root_manifest_touched),
(sibling_dir, &sibling_manifest),
],
is_workspace_install: true,
lockfile: lockfile.as_ref(),
catalogs: &Default::default(),
})
}
#[test]
fn returns_up_to_date_when_linked_sibling_still_satisfies_range() {
assert_eq!(linked_sibling_decision("1.5.0"), Decision::UpToDate);
}
#[test]
fn returns_skipped_when_linked_sibling_no_longer_satisfies_range() {
let decision = linked_sibling_decision("2.0.0");
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("linked")),
"expected Skipped(linked package out of date), got {decision:?}",
);
}
/// `pnpm-lock.yaml` deleted while `node_modules` (and its current
/// lockfile) is intact: the current lockfile stands in as the wanted
/// one, and the fast path regenerates `pnpm-lock.yaml` from it instead
/// of falling into the full install pipeline.
#[test]
fn regenerates_missing_wanted_lockfile_from_current_when_manifests_unchanged() {
let (dir, config) = setup_content_check_project();
fs::remove_file(dir.path().join(Lockfile::FILE_NAME)).unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, false, &[(dir.path().to_path_buf(), &manifest)]);
assert_eq!(decision, Decision::UpToDate);
let regenerated = Lockfile::load_wanted_from_dir(dir.path())
.expect("parse regenerated pnpm-lock.yaml")
.expect("pnpm-lock.yaml must be regenerated from the current lockfile");
let current =
Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir).unwrap().unwrap();
assert_eq!(regenerated, current);
}
/// Same as above with a touched (content-identical) manifest — the
/// content re-check runs against the current lockfile.
#[test]
fn regenerates_missing_wanted_lockfile_when_touched_manifest_satisfies_current() {
let (dir, config) = setup_content_check_project();
fs::remove_file(dir.path().join(Lockfile::FILE_NAME)).unwrap();
fs::write(dir.path().join("package.json"), FOO_MANIFEST).unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, false, &[(dir.path().to_path_buf(), &manifest)]);
assert_eq!(decision, Decision::UpToDate);
assert!(dir.path().join(Lockfile::FILE_NAME).exists(), "pnpm-lock.yaml must be regenerated");
}
/// A manifest that no longer matches the current lockfile cannot ride
/// the current-as-wanted fallback — the full install must resolve.
#[test]
fn returns_skipped_when_missing_wanted_lockfile_and_manifest_adds_a_dependency() {
let (dir, config) = setup_content_check_project();
fs::remove_file(dir.path().join(Lockfile::FILE_NAME)).unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"name":"root","version":"1.0.0","dependencies":{"foo":"^1.0.0","bar":"^2.0.0"}}"#,
)
.unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, false, &[(dir.path().to_path_buf(), &manifest)]);
assert!(
matches!(decision, Decision::Skipped { reason } if reason.contains("satisfied")),
"expected Skipped(no longer satisfied), got {decision:?}",
);
assert!(
!dir.path().join(Lockfile::FILE_NAME).exists(),
"must not regenerate on a failed check",
);
}
/// Workspace mode: deleted `pnpm-lock.yaml` + touched manifest takes
/// the current-as-wanted fallback, regenerates the lockfile, and
/// refreshes the state timestamp.
#[test]
fn workspace_regenerates_missing_wanted_lockfile_and_bumps_state() {
let (dir, config) = setup_content_check_project();
let before = pacquet_workspace_state::load_workspace_state(dir.path())
.unwrap()
.unwrap()
.last_validated_timestamp;
fs::remove_file(dir.path().join(Lockfile::FILE_NAME)).unwrap();
fs::write(dir.path().join("package.json"), FOO_MANIFEST).unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision =
content_check_decision(&dir, config, true, &[(dir.path().to_path_buf(), &manifest)]);
assert_eq!(decision, Decision::UpToDate);
assert!(dir.path().join(Lockfile::FILE_NAME).exists(), "pnpm-lock.yaml must be regenerated");
let after = pacquet_workspace_state::load_workspace_state(dir.path())
.unwrap()
.unwrap()
.last_validated_timestamp;
assert!(after > before, "expected the state timestamp to advance ({before} -> {after})");
}
/// `lockfile: false` (pnpm's `useLockfile: false`) disables the
/// regeneration but keeps the fast path.
#[test]
fn does_not_regenerate_wanted_lockfile_when_lockfile_writing_disabled() {
let (dir, config) = setup_content_check_project();
// `Config` is leaked per test; build a second one with `lockfile`
// off instead of mutating the shared reference.
let mut no_lockfile_config = Config::new();
no_lockfile_config.modules_dir = config.modules_dir.clone();
no_lockfile_config.virtual_store_dir = config.virtual_store_dir.clone();
no_lockfile_config.lockfile = false;
let no_lockfile_config = no_lockfile_config.leak();
fs::remove_file(dir.path().join(Lockfile::FILE_NAME)).unwrap();
let manifest = PackageManifest::from_path(dir.path().join("package.json")).unwrap();
let decision = content_check_decision(
&dir,
no_lockfile_config,
false,
&[(dir.path().to_path_buf(), &manifest)],
);
assert_eq!(decision, Decision::UpToDate);
assert!(!dir.path().join(Lockfile::FILE_NAME).exists(), "lockfile: false must skip the write");
}