mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-29 18:35:18 -04:00
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:
10
.changeset/fix-optimistic-repeat-install-lockfile.md
Normal file
10
.changeset/fix-optimistic-repeat-install-lockfile.md
Normal 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()`.
|
||||
@@ -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",
|
||||
|
||||
128
deps/status/src/checkDepsStatus.ts
vendored
128
deps/status/src/checkDepsStatus.ts
vendored
@@ -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: {
|
||||
|
||||
350
deps/status/test/checkDepsStatus.test.ts
vendored
350
deps/status/test/checkDepsStatus.test.ts
vendored
@@ -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:',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user