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 <z@kochan.io>
This commit is contained in:
Abdullah Alaqeel
2026-06-17 01:04:07 +03:00
committed by GitHub
parent c9db42dcd8
commit 61969fbddf
17 changed files with 665 additions and 71 deletions

View File

@@ -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.<branch>.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()`.

View File

@@ -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",

View File

@@ -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<Config,
| 'injectWorkspacePackages'
| 'linkWorkspacePackages'
| 'lockfileDir'
| 'mergeGitBranchLockfiles'
| 'nodeLinker'
| 'patchedDependencies'
| 'peersSuffixMaxLength'
@@ -242,13 +245,22 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
}
}
const conflictedLockfileDir = findConflictedLockfileDir(getWantedLockfileDirs({
const lockfileDirs = getWantedLockfileDirs({
allProjects,
lockfileDir,
rootProjectManifestDir,
sharedWorkspaceLockfile,
workspaceDir,
}), workspaceState.lastValidatedTimestamp)
})
const wantedLockfileName = await getWantedLockfileName({
useGitBranchLockfile: opts.useGitBranchLockfile,
mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
cwd: workspaceDir ?? lockfileDir ?? rootProjectManifestDir,
})
const { conflictedDir: conflictedLockfileDir, anyModified: lockfilesModified, anyMissing: lockfilesMissing } = scanWantedLockfiles(lockfileDirs, workspaceState.lastValidatedTimestamp, {
wantedLockfileName,
mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
})
if (conflictedLockfileDir != null) {
return {
upToDate: false,
@@ -338,15 +350,21 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
manifestStats.mtime.valueOf() > 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<CheckDepsStatusResult['wantedLockfileToRestore']> {
if (safeStatSync(path.join(lockfileDir, WANTED_LOCKFILE)) != null) return undefined
async function missingWantedLockfileStandIn (lockfileDir: string, wantedLockfileName: string): Promise<CheckDepsStatusResult['wantedLockfileToRestore']> {
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: {

View File

@@ -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<void> {
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<void> {
await fs.writeFile(path.join(lockfileDir, lockfileName), [
"lockfileVersion: '9.0'",
'<<<<<<< HEAD',
'settings:',

View File

@@ -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'],

View File

@@ -176,7 +176,7 @@ export type InstallDepsOptions = Pick<Config,
* subcommand — see `runPacquet.ts`'s `noRuntime` opt.
*/
isInstallCommand?: boolean
} & Partial<Pick<Config, 'dryRun' | 'pnpmHomeDir' | 'strictDepBuilds' | 'useLockfile' | 'useGitBranchLockfile'>>
} & Partial<Pick<Config, 'dryRun' | 'pnpmHomeDir' | 'strictDepBuilds' | 'useLockfile' | 'useGitBranchLockfile' | 'mergeGitBranchLockfiles'>>
export async function installDeps (
opts: InstallDepsOptions,

View File

@@ -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.<branch>.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<string[]> {
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<void> {
@@ -13,7 +21,7 @@ export async function cleanGitBranchLockfiles (lockfileDir: string): Promise<voi
await Promise.all(
gitBranchLockfiles.map(async file => {
const filepath: string = path.join(lockfileDir, file)
await fs.unlink(filepath)
await fsp.unlink(filepath)
})
)
}

View File

@@ -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'

View File

@@ -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<string> {
export async function getWantedLockfileName (opts: GetWantedLockfileNameOptions = {}): Promise<string> {
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`)
}

View File

@@ -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') {

View File

@@ -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 })
}
})

View File

@@ -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' })
})
})

View File

@@ -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<boolean> {
}
export async function getCurrentBranch (opts: GitCwdOptions = {}): Promise<string | null> {
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<b
}
return true
}
/**
* Reads the current branch name from `.git/HEAD` without spawning a git subprocess.
*
* Returns:
* - `string` — the branch name extracted from `ref: refs/heads/<name>`
* - `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: <path>`.
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
}
}

View File

@@ -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)

View File

@@ -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
// <https://github.com/pnpm/pnpm/blob/cc4ff817aa/deps/status/src/checkDepsStatus.ts#L593-L596>).
// 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<i64> {
Some(i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX))
}
/// Whether `<workspace_root>/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.
///

View File

@@ -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

View File

@@ -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')
}