From 61969fbddf8f827586e8455ad6c2cfecec6e685c Mon Sep 17 00:00:00 2001 From: Abdullah Alaqeel Date: Wed, 17 Jun 2026 01:04:07 +0300 Subject: [PATCH] fix(deps-status): detect lockfile-only changes (#12106) ## Summary Fixes `pnpm install` with `optimisticRepeatInstall` incorrectly returning `Already up to date` when `pnpm-lock.yaml` changed but project manifests did not. Fixes #12100. ## Root Cause `checkDepsStatus` used modified manifest mtimes as the only signal for whether it needed to validate dependency status. If no manifest was newer than `workspaceState.lastValidatedTimestamp`, it returned `upToDate: true` before checking whether the wanted lockfile had changed. That skipped lockfile validation for workflows like: - `git checkout HEAD~1 -- pnpm-lock.yaml` - restoring only `pnpm-lock.yaml` from a stash - external tools rewriting the lockfile without touching manifests ## Changes - Check wanted lockfile mtimes before taking the optimistic fast path. - If any wanted lockfile is missing or newer than the workspace state timestamp, validate all projects instead of only modified manifests. - Add a regression test proving a lockfile-only change does not skip wanted-lockfile validation. - Add a patch changeset for `@pnpm/deps.status` and `pnpm`. --------- Co-authored-by: Zoltan Kochan --- .../fix-optimistic-repeat-install-lockfile.md | 10 + cspell.json | 4 +- deps/status/src/checkDepsStatus.ts | 128 +++++-- deps/status/test/checkDepsStatus.test.ts | 350 +++++++++++++++++- eslint.config.mjs | 2 +- installing/commands/src/installDeps.ts | 2 +- lockfile/fs/src/gitBranchLockfile.ts | 20 +- lockfile/fs/src/index.ts | 2 +- lockfile/fs/src/lockfileName.ts | 5 +- lockfile/fs/src/read.ts | 4 +- lockfile/fs/test/gitBranchLockfile.test.ts | 31 +- lockfile/fs/test/lockfileName.test.ts | 6 + network/git-utils/src/index.ts | 47 +++ network/git-utils/test/index.test.ts | 29 ++ .../src/optimistic_repeat_install.rs | 42 ++- .../src/optimistic_repeat_install/tests.rs | 22 ++ .../multiProjectWorkspace.ts | 32 +- 17 files changed, 665 insertions(+), 71 deletions(-) create mode 100644 .changeset/fix-optimistic-repeat-install-lockfile.md diff --git a/.changeset/fix-optimistic-repeat-install-lockfile.md b/.changeset/fix-optimistic-repeat-install-lockfile.md new file mode 100644 index 0000000000..d3fabadc0d --- /dev/null +++ b/.changeset/fix-optimistic-repeat-install-lockfile.md @@ -0,0 +1,10 @@ +--- +"@pnpm/deps.status": patch +"@pnpm/lockfile.fs": patch +"@pnpm/network.git-utils": patch +"pnpm": patch +--- + +Fix `pnpm install` with `optimisticRepeatInstall` incorrectly reporting `Already up to date` when `pnpm-lock.yaml` changed but project manifests did not. This affected workflows such as checking out or restoring only the lockfile [#12100](https://github.com/pnpm/pnpm/issues/12100). + +Also fixes `checkDepsStatus` to use the correct lockfile path when `useGitBranchLockfile` is enabled, so the optimistic fast-path and lockfile modification detection work with `pnpm-lock..yaml` files instead of always stat'ing `pnpm-lock.yaml`. Merge-conflict detection now reads the resolved lockfile name as well, and with `mergeGitBranchLockfiles` enabled every `pnpm-lock.*.yaml` is scanned for modifications and conflicts. The git branch is now resolved by reading `.git/HEAD` directly (no process spawn) and uses the workspace directory rather than `process.cwd()`. diff --git a/cspell.json b/cspell.json index f7f276437b..94db4a2063 100644 --- a/cspell.json +++ b/cspell.json @@ -2,7 +2,8 @@ "ignorePaths": [ "**/nodeReleaseKeys.ts", "**/nodeReleaseKeys.d.ts", - "**/node_release_keys.rs" + "**/node_release_keys.rs", + "bench-work-env/**" ], "words": [ "adduser", @@ -123,6 +124,7 @@ "ghes", "ghsa", "ghsas", + "gitdir", "gitea", "globalconfig", "globstar", diff --git a/deps/status/src/checkDepsStatus.ts b/deps/status/src/checkDepsStatus.ts index 37b6a59d3f..0f73a83ae6 100644 --- a/deps/status/src/checkDepsStatus.ts +++ b/deps/status/src/checkDepsStatus.ts @@ -6,12 +6,14 @@ import { resolveFromCatalog } from '@pnpm/catalogs.resolver' import type { Catalogs } from '@pnpm/catalogs.types' import { parseOverrides } from '@pnpm/config.parse-overrides' import type { Config, ConfigContext } from '@pnpm/config.reader' -import { MANIFEST_BASE_NAMES, WANTED_LOCKFILE } from '@pnpm/constants' +import { MANIFEST_BASE_NAMES } from '@pnpm/constants' import { hashObjectNullableWithPrefix } from '@pnpm/crypto.object-hasher' import { PnpmError } from '@pnpm/error' import { arrayOfWorkspacePackagesToMap } from '@pnpm/installing.context' import { + getGitBranchLockfileNamesSync, getLockfileImporterId, + getWantedLockfileName, type LockfileObject, readCurrentLockfile, readWantedLockfile, @@ -53,6 +55,7 @@ export type CheckDepsStatusOptions = Pick workspaceState.lastValidatedTimestamp ) - if (modifiedProjects.length === 0) { - logger.debug({ msg: 'No manifest files were modified since the last validation. Exiting check.' }) - const wantedLockfileToRestore = sharedWorkspaceLockfile && !opts.useGitBranchLockfile - ? await missingWantedLockfileStandIn(workspaceDir) + if ((modifiedProjects.length === 0) && !lockfilesModified) { + const wantedLockfileToRestore = lockfilesMissing && sharedWorkspaceLockfile && !opts.useGitBranchLockfile + ? await missingWantedLockfileStandIn(workspaceDir, wantedLockfileName) : undefined - return { upToDate: true, workspaceState, wantedLockfileToRestore } + // A missing wanted lockfile only skips the full check when the current + // lockfile can stand in for it. Otherwise fall through so the checks + // below throw RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND instead of silently + // reporting "up to date". + if (!lockfilesMissing || wantedLockfileToRestore != null) { + logger.debug({ msg: 'No manifest files or lockfiles were modified since the last validation. Exiting check.' }) + return { upToDate: true, workspaceState, wantedLockfileToRestore } + } } - logger.debug({ msg: 'Some manifest files were modified since the last validation. Continuing check.' }) + logger.debug({ msg: 'Some manifest files or lockfiles were modified since the last validation. Continuing check.' }) let wantedLockfileToRestore: CheckDepsStatusResult['wantedLockfileToRestore'] let readWantedLockfileAndDir: (projectDir: string) => Promise<{ @@ -356,7 +374,7 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W if (sharedWorkspaceLockfile) { let wantedLockfileStats: fs.Stats | undefined try { - wantedLockfileStats = fs.statSync(path.join(workspaceDir, WANTED_LOCKFILE)) + wantedLockfileStats = fs.statSync(path.join(workspaceDir, wantedLockfileName)) } catch (error) { if (util.types.isNativeError(error) && 'code' in error && error.code === 'ENOENT') { wantedLockfileStats = undefined @@ -382,7 +400,11 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W wantedLockfileDir: workspaceDir, }) } else { - const wantedLockfilePromise = readWantedLockfile(workspaceDir, { ignoreIncompatible: false }) + const wantedLockfilePromise = readWantedLockfile(workspaceDir, { + ignoreIncompatible: false, + useGitBranchLockfile: opts.useGitBranchLockfile, + mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles, + }) if (wantedLockfileStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp) { const currentLockfile = await readCurrentLockfile(path.join(workspaceDir, 'node_modules/.pnpm'), { ignoreIncompatible: false }) const wantedLockfile = (await wantedLockfilePromise) ?? throwLockfileNotFound(workspaceDir) @@ -395,8 +417,12 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W } } else { readWantedLockfileAndDir = async wantedLockfileDir => { - const wantedLockfilePromise = readWantedLockfile(wantedLockfileDir, { ignoreIncompatible: false }) - const wantedLockfileStats = await safeStat(path.join(wantedLockfileDir, WANTED_LOCKFILE)) + const wantedLockfilePromise = readWantedLockfile(wantedLockfileDir, { + ignoreIncompatible: false, + useGitBranchLockfile: opts.useGitBranchLockfile, + mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles, + }) + const wantedLockfileStats = await safeStat(path.join(wantedLockfileDir, wantedLockfileName)) if (!wantedLockfileStats) return throwLockfileNotFound(wantedLockfileDir) if (wantedLockfileStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp) { @@ -432,7 +458,8 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W } try { - await Promise.all(modifiedProjects.map(async ({ project }) => { + const projectsToCheck = lockfilesModified ? allManifestStats : modifiedProjects + await Promise.all(projectsToCheck.map(async ({ project }) => { const { wantedLockfile, wantedLockfileDir } = await readWantedLockfileAndDir(project.rootDir) await assertWantedLockfileUpToDate(assertCtx, { projectDir: project.rootDir, @@ -482,14 +509,18 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W if (rootProjectManifest && rootProjectManifestDir) { const internalPnpmDir = path.join(rootProjectManifestDir, 'node_modules', '.pnpm') const currentLockfilePromise = readCurrentLockfile(internalPnpmDir, { ignoreIncompatible: false }) - const wantedLockfilePromise = readWantedLockfile(rootProjectManifestDir, { ignoreIncompatible: false }) + const wantedLockfilePromise = readWantedLockfile(rootProjectManifestDir, { + ignoreIncompatible: false, + useGitBranchLockfile: opts.useGitBranchLockfile, + mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles, + }) const [ currentLockfileStats, wantedLockfileStats, manifestStats, ] = await Promise.all([ safeStat(path.join(internalPnpmDir, 'lock.yaml')), - safeStat(path.join(rootProjectManifestDir, WANTED_LOCKFILE)), + safeStat(path.join(rootProjectManifestDir, wantedLockfileName)), statManifestFile(rootProjectManifestDir), ]) @@ -809,8 +840,8 @@ function throwLockfileNotFound (wantedLockfileDir: string): never { * `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 { - if (safeStatSync(path.join(lockfileDir, WANTED_LOCKFILE)) != null) return undefined +async function missingWantedLockfileStandIn (lockfileDir: string, wantedLockfileName: string): Promise { + if (safeStatSync(path.join(lockfileDir, wantedLockfileName)) != null) return undefined const currentLockfile = await readCurrentLockfile(path.join(lockfileDir, 'node_modules/.pnpm'), { ignoreIncompatible: false }) if (currentLockfile == null) return undefined return { lockfile: currentLockfile, lockfileDir } @@ -829,21 +860,60 @@ function getWantedLockfileDirs (opts: { return [opts.lockfileDir ?? opts.workspaceDir ?? opts.rootProjectManifestDir] } -function findConflictedLockfileDir (lockfileDirs: string[], lastValidatedTimestamp: number): string | undefined { +function scanWantedLockfiles (lockfileDirs: string[], lastValidatedTimestamp: number, opts: { + wantedLockfileName: string + mergeGitBranchLockfiles?: boolean +}): { + conflictedDir: string | undefined + anyModified: boolean + anyMissing: boolean +} { + let conflictedDir: string | undefined + let anyModified = false + let anyMissing = false for (const lockfileDir of lockfileDirs) { - let mtime: number - try { - mtime = fs.statSync(path.join(lockfileDir, WANTED_LOCKFILE)).mtime.valueOf() - } catch (err: unknown) { - if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') continue + // With `mergeGitBranchLockfiles`, `readWantedLockfile` merges every + // `pnpm-lock.*.yaml`, so a change in any of them changes the wanted + // lockfile and must be detected here. + const lockfileNames = opts.mergeGitBranchLockfiles + ? gitBranchLockfileNames(lockfileDir, opts.wantedLockfileName) + : [opts.wantedLockfileName] + let foundInDir = false + for (const lockfileName of lockfileNames) { + let mtime: number + try { + mtime = fs.statSync(path.join(lockfileDir, lockfileName)).mtime.valueOf() + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') continue + throw err + } + foundInDir = true + if (mtime <= lastValidatedTimestamp) continue + anyModified = true + if (wantedLockfileHasMergeConflictsSync(lockfileDir, lockfileName)) { + conflictedDir = lockfileDir + return { conflictedDir, anyModified, anyMissing } + } + } + if (!foundInDir) anyMissing = true + } + return { conflictedDir, anyModified, anyMissing } +} + +function gitBranchLockfileNames (lockfileDir: string, wantedLockfileName: string): string[] { + let branchLockfileNames: string[] + try { + branchLockfileNames = getGitBranchLockfileNamesSync(lockfileDir) + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { + branchLockfileNames = [] + } else { throw err } - // If the lockfile hasn't been modified since the last successful install, it can't have - // grown conflict markers — skip the read to preserve the optimistic fast-path. - if (mtime <= lastValidatedTimestamp) continue - if (wantedLockfileHasMergeConflictsSync(lockfileDir)) return lockfileDir } - return undefined + return branchLockfileNames.includes(wantedLockfileName) + ? branchLockfileNames + : [wantedLockfileName, ...branchLockfileNames] } async function patchesOrHooksAreModified (opts: { diff --git a/deps/status/test/checkDepsStatus.test.ts b/deps/status/test/checkDepsStatus.test.ts index d9dec4995b..a0e64b3ba8 100644 --- a/deps/status/test/checkDepsStatus.test.ts +++ b/deps/status/test/checkDepsStatus.test.ts @@ -6,7 +6,7 @@ import path from 'node:path' import { beforeEach, describe, expect, it, jest } from '@jest/globals' import type { CheckDepsStatusOptions } from '@pnpm/deps.status' import type { LockfileObject } from '@pnpm/lockfile.fs' -import type { ProjectRootDir, ProjectRootDirRealPath } from '@pnpm/types' +import type { ProjectId, ProjectRootDir, ProjectRootDirRealPath } from '@pnpm/types' import type { WorkspaceState } from '@pnpm/workspace.state' { @@ -35,6 +35,7 @@ import type { WorkspaceState } from '@pnpm/workspace.state' const original = await import('@pnpm/lockfile.fs') jest.unstable_mockModule('@pnpm/lockfile.fs', () => ({ ...original, + getWantedLockfileName: jest.fn(original.getWantedLockfileName), readCurrentLockfile: jest.fn(), readWantedLockfile: jest.fn(), })) @@ -549,10 +550,353 @@ describe('checkDepsStatus - lockfile conflicts', () => { await fs.rm(workspaceDir, { force: true, recursive: true }) } }) + + it('detects merge conflicts in the git-branch lockfile when useGitBranchLockfile is enabled', async () => { + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-git-branch-conflict-')) + try { + const branchLockfileName = 'pnpm-lock.main.yaml' + await writeConflictedLockfile(projectDir, branchLockfileName) + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp: Date.now() - 10_000, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + jest.mocked(lockfileFs.getWantedLockfileName).mockResolvedValueOnce(branchLockfileName) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: projectDir, + pnpmfile: [], + useGitBranchLockfile: true, + ...mockWorkspaceState.settings, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe(`The lockfile in ${projectDir} has merge conflicts`) + } finally { + await fs.rm(projectDir, { force: true, recursive: true }) + } + }) + + it('detects merge conflicts in a branch lockfile when mergeGitBranchLockfiles is enabled', async () => { + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-merge-branch-conflict-')) + try { + // The merged wanted lockfile is `pnpm-lock.yaml` + every `pnpm-lock.*.yaml`. + // Leave `pnpm-lock.yaml` unmodified, but introduce a conflict in a branch + // lockfile and assert it is still detected. + const unmodifiedMtime = (Date.now() - 20_000) / 1000 + await fs.writeFile(path.join(projectDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n") + await fs.utimes(path.join(projectDir, 'pnpm-lock.yaml'), unmodifiedMtime, unmodifiedMtime) + await writeConflictedLockfile(projectDir, 'pnpm-lock.feature.yaml') + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp: Date.now() - 10_000, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + }, + projects: {}, + filteredInstall: false, + } + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + + const opts: CheckDepsStatusOptions = { + rootProjectManifest: {}, + rootProjectManifestDir: projectDir, + pnpmfile: [], + useGitBranchLockfile: true, + mergeGitBranchLockfiles: true, + ...mockWorkspaceState.settings, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe(`The lockfile in ${projectDir} has merge conflicts`) + } finally { + await fs.rm(projectDir, { force: true, recursive: true }) + } + }) }) -async function writeConflictedLockfile (lockfileDir: string): Promise { - await fs.writeFile(path.join(lockfileDir, 'pnpm-lock.yaml'), [ +describe('checkDepsStatus - lockfile modification', () => { + beforeEach(() => { + jest.resetModules() + jest.clearAllMocks() + }) + + it('does not skip the wanted lockfile check when only the lockfile changed since the last validation', async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-lockfile-')) + try { + const lastValidatedTimestamp = Date.now() - 10_000 + const beforeLastValidation = lastValidatedTimestamp - 10_000 + const afterLastValidation = lastValidatedTimestamp + 1_000 + const projectRootDir = workspaceDir as ProjectRootDir + const projectRootDirRealPath = await fs.realpath(workspaceDir) as ProjectRootDirRealPath + const lockfile: LockfileObject = { + lockfileVersion: '9.0', + importers: { + ['.' as ProjectId]: { + specifiers: {}, + }, + }, + } + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + }, + projects: { + [projectRootDir]: { + name: 'project', + version: '1.0.0', + }, + }, + filteredInstall: false, + } + + await fs.writeFile(path.join(workspaceDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n") + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + jest.mocked(fsUtils.safeStatSync).mockImplementation((filePath: string) => { + if (filePath === path.join(workspaceDir, 'pnpm-lock.yaml')) { + return { + mtime: new Date(afterLastValidation), + mtimeMs: afterLastValidation, + } as Stats + } + return undefined + }) + jest.mocked(fsUtils.safeStat).mockImplementation(async (filePath: string) => { + if (filePath.endsWith('pnpm-lock.yaml')) { + return { + mtime: new Date(afterLastValidation), + mtimeMs: afterLastValidation, + } as Stats + } + return undefined + }) + jest.mocked(statManifestFileUtils.statManifestFile).mockResolvedValue({ + mtime: new Date(beforeLastValidation), + mtimeMs: beforeLastValidation, + } as Stats) + const wantedLockfile: LockfileObject = { + lockfileVersion: '9.0', + importers: { + ['.' as ProjectId]: { + specifiers: { foo: '1.0.0' }, + }, + }, + } + jest.mocked(lockfileFs.readCurrentLockfile).mockResolvedValue(lockfile) + jest.mocked(lockfileFs.readWantedLockfile).mockResolvedValue(wantedLockfile) + + const opts: CheckDepsStatusOptions = { + allProjects: [{ + rootDir: projectRootDir, + rootDirRealPath: projectRootDirRealPath, + manifest: { + name: 'project', + version: '1.0.0', + }, + writeProjectManifest: async () => {}, + }], + workspaceDir, + rootProjectManifest: {}, + rootProjectManifestDir: workspaceDir, + pnpmfile: [], + ...mockWorkspaceState.settings, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe(`The installed dependencies in the modules directory is not up-to-date with the lockfile in ${workspaceDir}.`) + } finally { + await fs.rm(workspaceDir, { force: true, recursive: true }) + } + }) + + it('does not throw when pnpm-lock.yaml is absent but a git-branch lockfile exists', async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-git-branch-')) + try { + const lastValidatedTimestamp = Date.now() - 10_000 + const beforeLastValidation = lastValidatedTimestamp - 10_000 + const projectRootDir = workspaceDir as ProjectRootDir + const projectRootDirRealPath = await fs.realpath(workspaceDir) as ProjectRootDirRealPath + const branchLockfileName = 'pnpm-lock.main.yaml' + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + }, + projects: { + [projectRootDir]: { + name: 'project', + version: '1.0.0', + }, + }, + filteredInstall: false, + } + + await fs.writeFile(path.join(workspaceDir, branchLockfileName), "lockfileVersion: '9.0'\n") + const branchLockfilePath = path.join(workspaceDir, branchLockfileName) + await fs.utimes(branchLockfilePath, beforeLastValidation / 1000, beforeLastValidation / 1000) + + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + jest.mocked(lockfileFs.getWantedLockfileName).mockResolvedValueOnce(branchLockfileName) + jest.mocked(fsUtils.safeStatSync).mockReturnValue(undefined) + jest.mocked(fsUtils.safeStat).mockResolvedValue(undefined) + jest.mocked(statManifestFileUtils.statManifestFile).mockResolvedValue({ + mtime: new Date(beforeLastValidation), + mtimeMs: beforeLastValidation, + } as Stats) + + const opts: CheckDepsStatusOptions = { + allProjects: [{ + rootDir: projectRootDir, + rootDirRealPath: projectRootDirRealPath, + manifest: { + name: 'project', + version: '1.0.0', + }, + writeProjectManifest: async () => {}, + }], + workspaceDir, + sharedWorkspaceLockfile: true, + useGitBranchLockfile: true, + rootProjectManifest: {}, + rootProjectManifestDir: workspaceDir, + pnpmfile: [], + ...mockWorkspaceState.settings, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(true) + expect(result.issue).toBeUndefined() + } finally { + await fs.rm(workspaceDir, { force: true, recursive: true }) + } + }) + + it('does not take the optimistic fast-path when the git-branch lockfile is missing', async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-git-branch-missing-')) + try { + const lastValidatedTimestamp = Date.now() - 10_000 + const beforeLastValidation = lastValidatedTimestamp - 10_000 + const projectRootDir = workspaceDir as ProjectRootDir + const projectRootDirRealPath = await fs.realpath(workspaceDir) as ProjectRootDirRealPath + const mockWorkspaceState: WorkspaceState = { + lastValidatedTimestamp, + pnpmfiles: [], + settings: { + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + }, + projects: { + [projectRootDir]: { + name: 'project', + version: '1.0.0', + }, + }, + filteredInstall: false, + } + + // No lockfile is written: `pnpm-lock.main.yaml` is missing on disk. + jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState) + jest.mocked(lockfileFs.getWantedLockfileName).mockResolvedValueOnce('pnpm-lock.main.yaml') + jest.mocked(fsUtils.safeStatSync).mockReturnValue(undefined) + jest.mocked(fsUtils.safeStat).mockResolvedValue(undefined) + jest.mocked(statManifestFileUtils.statManifestFile).mockResolvedValue({ + mtime: new Date(beforeLastValidation), + mtimeMs: beforeLastValidation, + } as Stats) + + const opts: CheckDepsStatusOptions = { + allProjects: [{ + rootDir: projectRootDir, + rootDirRealPath: projectRootDirRealPath, + manifest: { + name: 'project', + version: '1.0.0', + }, + writeProjectManifest: async () => {}, + }], + workspaceDir, + sharedWorkspaceLockfile: true, + useGitBranchLockfile: true, + rootProjectManifest: {}, + rootProjectManifestDir: workspaceDir, + pnpmfile: [], + ...mockWorkspaceState.settings, + } + const result = await checkDepsStatus(opts) + + expect(result.upToDate).toBe(false) + expect(result.issue).toBe(`Cannot find a lockfile in ${workspaceDir}`) + } finally { + await fs.rm(workspaceDir, { force: true, recursive: true }) + } + }) + + it('passes the workspace dir as cwd to getWantedLockfileName so git branch is resolved in the correct repo', async () => { + jest.mocked(lockfileFs.getWantedLockfileName).mockResolvedValueOnce('pnpm-lock.main.yaml') + jest.mocked(loadWorkspaceState).mockReturnValue({ + lastValidatedTimestamp: Date.now() - 10_000, + pnpmfiles: [], + settings: { excludeLinksFromLockfile: false, linkWorkspacePackages: true, preferWorkspacePackages: true }, + projects: {}, + filteredInstall: false, + }) + jest.mocked(fsUtils.safeStatSync).mockReturnValue(undefined) + jest.mocked(fsUtils.safeStat).mockResolvedValue(undefined) + jest.mocked(statManifestFileUtils.statManifestFile).mockResolvedValue(undefined) + + const opts: CheckDepsStatusOptions = { + allProjects: [{ + rootDir: '/workspace/pkg' as ProjectRootDir, + rootDirRealPath: '/workspace/pkg' as ProjectRootDirRealPath, + manifest: { name: 'pkg', version: '1.0.0' }, + writeProjectManifest: async () => {}, + }], + workspaceDir: '/workspace', + sharedWorkspaceLockfile: true, + useGitBranchLockfile: true, + rootProjectManifest: {}, + rootProjectManifestDir: '/workspace', + pnpmfile: [], + excludeLinksFromLockfile: false, + linkWorkspacePackages: true, + preferWorkspacePackages: true, + } + await checkDepsStatus(opts) + + expect(jest.mocked(lockfileFs.getWantedLockfileName)).toHaveBeenCalledWith({ + useGitBranchLockfile: true, + mergeGitBranchLockfiles: undefined, + cwd: '/workspace', + }) + }) +}) + +async function writeConflictedLockfile (lockfileDir: string, lockfileName: string = 'pnpm-lock.yaml'): Promise { + await fs.writeFile(path.join(lockfileDir, lockfileName), [ "lockfileVersion: '9.0'", '<<<<<<< HEAD', 'settings:', diff --git a/eslint.config.mjs b/eslint.config.mjs index 10cdb37fe6..8150866ce0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import * as regexpPlugin from "eslint-plugin-regexp"; export default [ { - ignores: ["**/fixtures", "**/__fixtures__", "**/node_modules", "**/lib", ".claude/**"], + ignores: ["**/fixtures", "**/__fixtures__", "**/node_modules", "**/lib", ".claude/**", "bench-work-env/**"], }, ...eslintConfig, regexpPlugin.configs['flat/recommended'], diff --git a/installing/commands/src/installDeps.ts b/installing/commands/src/installDeps.ts index 0ab5433d3d..0dea29ce91 100644 --- a/installing/commands/src/installDeps.ts +++ b/installing/commands/src/installDeps.ts @@ -176,7 +176,7 @@ export type InstallDepsOptions = Pick> +} & Partial> export async function installDeps ( opts: InstallDepsOptions, diff --git a/lockfile/fs/src/gitBranchLockfile.ts b/lockfile/fs/src/gitBranchLockfile.ts index 79813f1d87..a3ea21e358 100644 --- a/lockfile/fs/src/gitBranchLockfile.ts +++ b/lockfile/fs/src/gitBranchLockfile.ts @@ -1,11 +1,19 @@ -import { promises as fs } from 'node:fs' +import fs, { promises as fsp } from 'node:fs' import path from 'node:path' +// Branch lockfiles are written as `pnpm-lock..yaml` with literal +// dots and a non-empty branch segment. Escaping the dots keeps unrelated +// files out of the matches that feed scanning and `cleanGitBranchLockfiles`. +const GIT_BRANCH_LOCKFILE_NAME = /^pnpm-lock\..+\.yaml$/ + export async function getGitBranchLockfileNames (lockfileDir: string): Promise { - const files = await fs.readdir(lockfileDir) - // eslint-disable-next-line regexp/no-useless-non-capturing-group - const gitBranchLockfileNames: string[] = files.filter(file => file.match(/^pnpm-lock.(?:.*).yaml$/)) - return gitBranchLockfileNames + const files = await fsp.readdir(lockfileDir) + return files.filter(file => GIT_BRANCH_LOCKFILE_NAME.test(file)) +} + +export function getGitBranchLockfileNamesSync (lockfileDir: string): string[] { + const files = fs.readdirSync(lockfileDir) + return files.filter(file => GIT_BRANCH_LOCKFILE_NAME.test(file)) } export async function cleanGitBranchLockfiles (lockfileDir: string): Promise { @@ -13,7 +21,7 @@ export async function cleanGitBranchLockfiles (lockfileDir: string): Promise { const filepath: string = path.join(lockfileDir, file) - await fs.unlink(filepath) + await fsp.unlink(filepath) }) ) } diff --git a/lockfile/fs/src/index.ts b/lockfile/fs/src/index.ts index 910f216103..d37916a4d1 100644 --- a/lockfile/fs/src/index.ts +++ b/lockfile/fs/src/index.ts @@ -1,7 +1,7 @@ export { createEnvLockfile, readEnvLockfile, writeEnvLockfile } from './envLockfile.js' export { existsNonEmptyWantedLockfile } from './existsWantedLockfile.js' export { getLockfileImporterId } from './getLockfileImporterId.js' -export { cleanGitBranchLockfiles } from './gitBranchLockfile.js' +export { cleanGitBranchLockfiles, getGitBranchLockfileNamesSync } from './gitBranchLockfile.js' export { convertToLockfileFile, convertToLockfileObject } from './lockfileFormatConverters.js' export { getWantedLockfileName } from './lockfileName.js' export * from './read.js' diff --git a/lockfile/fs/src/lockfileName.ts b/lockfile/fs/src/lockfileName.ts index c0d92ed9a4..23dfbb3f01 100644 --- a/lockfile/fs/src/lockfileName.ts +++ b/lockfile/fs/src/lockfileName.ts @@ -4,11 +4,12 @@ import { getCurrentBranch } from '@pnpm/network.git-utils' export interface GetWantedLockfileNameOptions { useGitBranchLockfile?: boolean mergeGitBranchLockfiles?: boolean + cwd?: string } -export async function getWantedLockfileName (opts: GetWantedLockfileNameOptions = { useGitBranchLockfile: false, mergeGitBranchLockfiles: false }): Promise { +export async function getWantedLockfileName (opts: GetWantedLockfileNameOptions = {}): Promise { if (opts.useGitBranchLockfile && !opts.mergeGitBranchLockfiles) { - const currentBranchName = await getCurrentBranch() + const currentBranchName = await getCurrentBranch({ cwd: opts.cwd }) if (currentBranchName) { return WANTED_LOCKFILE.replace('.yaml', `.${stringifyBranchName(currentBranchName)}.yaml`) } diff --git a/lockfile/fs/src/read.ts b/lockfile/fs/src/read.ts index 151036cfa4..b5d08121c4 100644 --- a/lockfile/fs/src/read.ts +++ b/lockfile/fs/src/read.ts @@ -84,9 +84,9 @@ export async function readWantedLockfileFile ( return (await _readWantedLockfile(pkgPath, opts)).lockfileFile } -export function wantedLockfileHasMergeConflictsSync (pkgPath: string): boolean { +export function wantedLockfileHasMergeConflictsSync (pkgPath: string, lockfileName: string = WANTED_LOCKFILE): boolean { try { - const lockfileRawContent = stripBom(fs.readFileSync(path.join(pkgPath, WANTED_LOCKFILE), 'utf8')) + const lockfileRawContent = stripBom(fs.readFileSync(path.join(pkgPath, lockfileName), 'utf8')) return isDiff(extractMainDocument(lockfileRawContent)) } catch (err: unknown) { if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') { diff --git a/lockfile/fs/test/gitBranchLockfile.test.ts b/lockfile/fs/test/gitBranchLockfile.test.ts index 2d598c1bee..603b2bd8e7 100644 --- a/lockfile/fs/test/gitBranchLockfile.test.ts +++ b/lockfile/fs/test/gitBranchLockfile.test.ts @@ -1,8 +1,10 @@ +import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { expect, test } from '@jest/globals' -import { getGitBranchLockfileNames } from '../lib/gitBranchLockfile.js' +import { getGitBranchLockfileNames, getGitBranchLockfileNamesSync } from '../lib/gitBranchLockfile.js' process.chdir(import.meta.dirname) @@ -11,3 +13,30 @@ test('getGitBranchLockfileNames()', async () => { const gitBranchLockfileNames = await getGitBranchLockfileNames(lockfileDir) expect(gitBranchLockfileNames).toEqual(['pnpm-lock.branch.yaml']) }) + +test('getGitBranchLockfileNamesSync()', () => { + const lockfileDir: string = path.join('fixtures', '6') + expect(getGitBranchLockfileNamesSync(lockfileDir)).toEqual(['pnpm-lock.branch.yaml']) +}) + +test('git-branch lockfile matcher requires literal dots and a branch segment', () => { + const lockfileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-git-branch-lockfile-')) + try { + for (const name of [ + 'pnpm-lock.main.yaml', // branch lockfile + 'pnpm-lock.feature.x.yaml', // branch name containing a dot + 'pnpm-lock.yaml', // base lockfile, not a branch lockfile + 'pnpm-lock-main-yaml', // no literal dots + 'my-pnpm-lock.main.yaml', // does not start at the beginning + 'README.md', + ]) { + fs.writeFileSync(path.join(lockfileDir, name), '') + } + expect(getGitBranchLockfileNamesSync(lockfileDir).sort()).toEqual([ + 'pnpm-lock.feature.x.yaml', + 'pnpm-lock.main.yaml', + ]) + } finally { + fs.rmSync(lockfileDir, { force: true, recursive: true }) + } +}) diff --git a/lockfile/fs/test/lockfileName.test.ts b/lockfile/fs/test/lockfileName.test.ts index 9f3a5a46d7..0fbc9901b0 100644 --- a/lockfile/fs/test/lockfileName.test.ts +++ b/lockfile/fs/test/lockfileName.test.ts @@ -29,4 +29,10 @@ describe('lockfileName', () => { jest.mocked(getCurrentBranch).mockReturnValue(Promise.resolve('aBc')) await expect(getWantedLockfileName({ useGitBranchLockfile: true })).resolves.toBe('pnpm-lock.abc.yaml') }) + + test('passes cwd to getCurrentBranch', async () => { + jest.mocked(getCurrentBranch).mockReturnValue(Promise.resolve('main')) + await getWantedLockfileName({ useGitBranchLockfile: true, cwd: '/some/workspace' }) + expect(jest.mocked(getCurrentBranch)).toHaveBeenCalledWith({ cwd: '/some/workspace' }) + }) }) diff --git a/network/git-utils/src/index.ts b/network/git-utils/src/index.ts index 336e019f41..fa5c701f41 100644 --- a/network/git-utils/src/index.ts +++ b/network/git-utils/src/index.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs' +import path from 'node:path' + import { safeExeca as execa } from 'execa' // git checks logic is from https://github.com/sindresorhus/np/blob/master/source/git-tasks.js @@ -16,6 +19,8 @@ export async function isGitRepo (opts: GitCwdOptions = {}): Promise { } export async function getCurrentBranch (opts: GitCwdOptions = {}): Promise { + const branch = readBranchFromHeadFile(opts.cwd) + if (branch !== undefined) return branch try { const { stdout } = await execa('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: opts.cwd }) return stdout as string @@ -50,3 +55,45 @@ export async function isRemoteHistoryClean (opts: GitCwdOptions = {}): Promise` + * - `null` — HEAD is detached (a raw commit SHA, not a symbolic ref) + * - `undefined` — `.git/HEAD` could not be read (not a git repo, worktree + * layout not recognized, permissions error, etc.); caller should fall + * back to `git symbolic-ref`. + */ +function readBranchFromHeadFile (cwd?: string): string | null | undefined { + const baseDir = cwd ?? process.cwd() + const dotGitPath = path.join(baseDir, '.git') + let gitDir: string + try { + const stat = fs.statSync(dotGitPath) + if (stat.isDirectory()) { + gitDir = dotGitPath + } else if (stat.isFile()) { + // `.git` is a file — worktree or submodule. It contains `gitdir: `. + const content = fs.readFileSync(dotGitPath, 'utf8').trim() + const match = content.match(/^gitdir:\s*(.+)/) + if (!match) return undefined + gitDir = path.isAbsolute(match[1]!) ? match[1]! : path.resolve(baseDir, match[1]!) + } else { + // `.git` is neither a directory nor a regular file (e.g. a FIFO or + // device); don't read it. Fall back to `git symbolic-ref`. + return undefined + } + } catch { + return undefined + } + try { + const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim() + const match = head.match(/^ref:\s*refs\/heads\/(.+)/) + if (match) return match[1]! + return null + } catch { + return undefined + } +} diff --git a/network/git-utils/test/index.test.ts b/network/git-utils/test/index.test.ts index 8bf6bc8211..59a6511267 100644 --- a/network/git-utils/test/index.test.ts +++ b/network/git-utils/test/index.test.ts @@ -27,6 +27,35 @@ test('getCurrentBranch', async () => { await expect(getCurrentBranch()).resolves.toBe('foo') }) +test('getCurrentBranch reads branch from .git/HEAD without spawning git', async () => { + const tempDir = temporaryDirectory() + + await execa('git', ['init'], { cwd: tempDir }) + await execa('git', ['checkout', '-b', 'bar'], { cwd: tempDir }) + + await expect(getCurrentBranch({ cwd: tempDir })).resolves.toBe('bar') +}) + +test('getCurrentBranch returns null for detached HEAD', async () => { + const tempDir = temporaryDirectory() + + await execa('git', ['init'], { cwd: tempDir }) + await execa('git', ['checkout', '-b', 'main'], { cwd: tempDir }) + await execa('git', ['config', 'user.email', 'test@test.com'], { cwd: tempDir }) + await execa('git', ['config', 'user.name', 'test'], { cwd: tempDir }) + await execa('git', ['config', 'commit.gpgsign', 'false'], { cwd: tempDir }) + await execa('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tempDir }) + await execa('git', ['checkout', '--detach', 'HEAD'], { cwd: tempDir }) + + await expect(getCurrentBranch({ cwd: tempDir })).resolves.toBeNull() +}) + +test('getCurrentBranch returns null outside a git repo', async () => { + const tempDir = temporaryDirectory() + + await expect(getCurrentBranch({ cwd: tempDir })).resolves.toBeNull() +}) + test('isWorkingTreeClean', async () => { const tempDir = temporaryDirectory() process.chdir(tempDir) diff --git a/pacquet/crates/package-manager/src/optimistic_repeat_install.rs b/pacquet/crates/package-manager/src/optimistic_repeat_install.rs index 0c5dd7f3f1..b16eb29b6d 100644 --- a/pacquet/crates/package-manager/src/optimistic_repeat_install.rs +++ b/pacquet/crates/package-manager/src/optimistic_repeat_install.rs @@ -222,11 +222,11 @@ pub fn check_optimistic_repeat_install(check: &OptimisticRepeatInstallCheck<'_>) // 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 - // ). + // Workspace installs skip this existence gate — pnpm's workspace + // branch tolerates a missing `pnpm-lock.yaml` (its `scanWantedLockfiles` + // probe `continue`s on ENOENT, and the missing lockfile is restored + // from the current one rather than throwing). The mtime side of that + // probe is handled by `wanted_lockfile_modified` below. if !is_workspace_install && !workspace_root.join(Lockfile::FILE_NAME).exists() && !config.virtual_store_dir.join(Lockfile::CURRENT_FILE_NAME).exists() @@ -265,7 +265,17 @@ pub fn check_optimistic_repeat_install(check: &OptimisticRepeatInstallCheck<'_>) .iter() .filter(|stat| stat.mtime_ms > state.last_validated_timestamp) .collect(); - if modified.is_empty() { + + // A lockfile-only change — `git checkout`/stash-restore of just + // `pnpm-lock.yaml`, or an external rewrite — leaves every manifest + // untouched but still invalidates the install. Probe the wanted + // lockfile's mtime before the manifest-mtime exit, mirroring + // upstream's `scanWantedLockfiles` + `!lockfilesModified` early-return + // guard (pnpm/pnpm#12100). + let lockfile_modified = + wanted_lockfile_modified(workspace_root, state.last_validated_timestamp); + + if modified.is_empty() && !lockfile_modified { return match regenerate_wanted_lockfile_if_missing(check, None) { Ok(()) => Decision::UpToDate, Err(reason) => Decision::Skipped { reason }, @@ -276,8 +286,13 @@ pub fn check_optimistic_repeat_install(check: &OptimisticRepeatInstallCheck<'_>) // 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) { + // edit, `npm pkg set/delete` — still reports up to date. When only + // the lockfile changed, upstream validates every project rather than + // just the modified ones (`projectsToCheck = lockfilesModified ? + // allManifestStats : modifiedProjects`). + let projects_to_check: Vec<&ManifestStat<'_>> = + if lockfile_modified { manifest_stats.iter().collect() } else { modified }; + match modified_manifests_match_lockfile(check, &state, &projects_to_check) { Ok(loaded_current) => { if let Err(reason) = regenerate_wanted_lockfile_if_missing(check, loaded_current) { return Decision::Skipped { reason }; @@ -900,6 +915,17 @@ fn mtime_ms(path: &Path) -> Option { Some(i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)) } +/// Whether `/pnpm-lock.yaml` has an mtime newer than the +/// last validation. Mirrors upstream's `scanWantedLockfiles` modification +/// probe: a lockfile-only change leaves every manifest untouched but must +/// still defeat the manifest-mtime fast path (pnpm/pnpm#12100). A missing +/// lockfile reports `false` here — it is handled by the existence and +/// stand-in gates, not treated as a modification. +fn wanted_lockfile_modified(workspace_root: &Path, last_validated_timestamp: i64) -> bool { + mtime_ms(&workspace_root.join(Lockfile::FILE_NAME)) + .is_some_and(|mtime| mtime > last_validated_timestamp) +} + /// Compare today's settings against what the previous install /// recorded. /// diff --git a/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs b/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs index b1e9544735..cb1d6b9029 100644 --- a/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs +++ b/pacquet/crates/package-manager/src/optimistic_repeat_install/tests.rs @@ -1940,6 +1940,28 @@ fn returns_skipped_when_wanted_lockfile_diverged_from_current() { ); } +/// Only the wanted lockfile changed (a `git checkout` / stash-restore of +/// just `pnpm-lock.yaml`), with every manifest left untouched. The +/// manifest-mtime fast path must not skip the lockfile change. Regression +/// for pnpm/pnpm#12100. +#[test] +fn returns_skipped_when_only_the_lockfile_changed() { + let (dir, config) = setup_content_check_project(); + + // Rewrite only the wanted lockfile; package.json keeps its original + // (pre-state) mtime, so `modifiedProjects` is empty. + 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 diff --git a/pnpm/test/verifyDepsBeforeRun/multiProjectWorkspace.ts b/pnpm/test/verifyDepsBeforeRun/multiProjectWorkspace.ts index 7b57f4193d..0a9185e483 100644 --- a/pnpm/test/verifyDepsBeforeRun/multiProjectWorkspace.ts +++ b/pnpm/test/verifyDepsBeforeRun/multiProjectWorkspace.ts @@ -111,8 +111,8 @@ test('single dependency', async () => { { const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true }) expect(stdout.toString()).toContain('hello from root') - expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.') - expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.') + expect(stdout.toString()).toContain('No manifest files or lockfiles were modified since the last validation. Exiting check.') + expect(stdout.toString()).not.toContain('Some manifest files or lockfiles were modified since the last validation. Continuing check.') } // should be able to execute a script in a workspace package after dependencies have been installed { @@ -140,16 +140,16 @@ test('single dependency', async () => { { const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true }) expect(stdout.toString()).toContain('hello from root') - expect(stdout.toString()).not.toContain('No manifest files were modified since the last validation. Exiting check.') - expect(stdout.toString()).toContain('Some manifest files were modified since the last validation. Continuing check.') + expect(stdout.toString()).not.toContain('No manifest files or lockfiles were modified since the last validation. Exiting check.') + expect(stdout.toString()).toContain('Some manifest files or lockfiles were modified since the last validation. Continuing check.') expect(stdout.toString()).toContain('updating workspace state') } // should skip check after pnpm has updated the packages list { const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true }) expect(stdout.toString()).toContain('hello from root') - expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.') - expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.') + expect(stdout.toString()).toContain('No manifest files or lockfiles were modified since the last validation. Exiting check.') + expect(stdout.toString()).not.toContain('Some manifest files or lockfiles were modified since the last validation. Continuing check.') expect(stdout.toString()).not.toContain('updating workspace state') } @@ -167,8 +167,8 @@ test('single dependency', async () => { expect(status).not.toBe(0) expect(stdout.toString()).toContain('ERR_PNPM_VERIFY_DEPS_BEFORE_RUN') expect(stdout.toString()).toContain('project of id foo') - expect(stdout.toString()).not.toContain('No manifest files were modified since the last validation. Exiting check.') - expect(stdout.toString()).toContain('Some manifest files were modified since the last validation. Continuing check.') + expect(stdout.toString()).not.toContain('No manifest files or lockfiles were modified since the last validation. Exiting check.') + expect(stdout.toString()).toContain('Some manifest files or lockfiles were modified since the last validation. Continuing check.') } // attempting to execute a script in any workspace package without updating dependencies should fail { @@ -208,8 +208,8 @@ test('single dependency', async () => { { const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true }) expect(stdout.toString()).toContain('hello from root') - expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.') - expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.') + expect(stdout.toString()).toContain('No manifest files or lockfiles were modified since the last validation. Exiting check.') + expect(stdout.toString()).not.toContain('Some manifest files or lockfiles were modified since the last validation. Continuing check.') } // should be able to execute a script in any workspace package after dependencies have been updated { @@ -402,8 +402,8 @@ test('multiple lockfiles', async () => { { const { stdout } = execPnpmSync([...config, '--reporter=ndjson', 'start'], { expectSuccess: true }) expect(stdout.toString()).toContain('hello from root') - expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.') - expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.') + expect(stdout.toString()).toContain('No manifest files or lockfiles were modified since the last validation. Exiting check.') + expect(stdout.toString()).not.toContain('Some manifest files or lockfiles were modified since the last validation. Continuing check.') } // should be able to execute a script in a workspace package after dependencies have been installed { @@ -431,16 +431,16 @@ test('multiple lockfiles', async () => { { const { stdout } = execPnpmSync([...config, '--reporter=ndjson', 'start'], { expectSuccess: true }) expect(stdout.toString()).toContain('hello from root') - expect(stdout.toString()).not.toContain('No manifest files were modified since the last validation. Exiting check.') - expect(stdout.toString()).toContain('Some manifest files were modified since the last validation. Continuing check.') + expect(stdout.toString()).not.toContain('No manifest files or lockfiles were modified since the last validation. Exiting check.') + expect(stdout.toString()).toContain('Some manifest files or lockfiles were modified since the last validation. Continuing check.') expect(stdout.toString()).toContain('updating workspace state') } // should skip check after pnpm has updated the packages list { const { stdout } = execPnpmSync([...config, '--reporter=ndjson', 'start'], { expectSuccess: true }) expect(stdout.toString()).toContain('hello from root') - expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.') - expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.') + expect(stdout.toString()).toContain('No manifest files or lockfiles were modified since the last validation. Exiting check.') + expect(stdout.toString()).not.toContain('Some manifest files or lockfiles were modified since the last validation. Continuing check.') expect(stdout.toString()).not.toContain('updating workspace state') }