fix: handle lockfile conflicts in optimistic install (#11605)

* fix: handle lockfile conflicts in optimistic install

* test: move sharedWorkspaceLockfile out of WorkspaceState.settings

`sharedWorkspaceLockfile` is not in `WORKSPACE_STATE_SETTING_KEYS`, so
placing it inside `WorkspaceState.settings` broke type checking. Pass
it directly on the `CheckDepsStatusOptions` instead.

* chore: add lockfile/fs project reference to installing/commands tsconfig

`@pnpm/lockfile.fs` was added as a runtime dependency but the tsconfig
project references were not updated. meta-updater enforces this in CI.

* perf: restore optimistic-repeat-install fast-path for conflict-free state

The first iteration of the conflict-detection fix unconditionally read
pnpm-lock.yaml on every install - once in installDeps and again inside
checkDepsStatus - defeating the point of optimisticRepeatInstall, which
was to skip reading the lockfile entirely when nothing changed.

Restore the fast path by:

- Dropping the redundant lockfile read from installDeps. checkDepsStatus
  already returns upToDate: false when the lockfile is conflicted, so
  the pre-check was dead weight.
- Gating the conflict check inside checkDepsStatus on the lockfile's
  mtime: if it hasn't been touched since the last successful install,
  it cannot have grown conflict markers, so the read is skipped.

Conflict markers introduced after a successful install (e.g. via
git pull/merge) still update the lockfile mtime, so the correctness
fix is preserved.

* perf: make lockfile-conflict check synchronous

findConflictedLockfileDir awaits its work serially with no concurrent
operations to interleave, so the async overhead (Promise.all microtasks,
event-loop hops) buys nothing. Convert to a plain for-loop with
fs.statSync and a new wantedLockfileHasMergeConflictsSync export.

Also resolves a test-mock issue: the previous version called safeStat
from deps/status, which is jest-mocked across the test file. The mocked
safeStat returned undefined (or stale stats from earlier tests), causing
the conflict check to silently no-op. Switching to fs.statSync bypasses
the mock and gets the real mtime of the temp lockfile the regression
tests write.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Benjamin Staneck
2026-05-14 12:03:32 +02:00
committed by GitHub
parent c2c289094f
commit 180aee9ba5
4 changed files with 261 additions and 12 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/deps.status": patch
"@pnpm/installing.commands": patch
"@pnpm/lockfile.fs": patch
"pnpm": patch
---
Fixed `optimisticRepeatInstall` skipping `pnpm-lock.yaml` merge conflict resolution when the existing `node_modules` state appears up to date.

View File

@@ -13,6 +13,7 @@ import {
type LockfileObject,
readCurrentLockfile,
readWantedLockfile,
wantedLockfileHasMergeConflictsSync,
} from '@pnpm/lockfile.fs'
import {
calcPatchHashes,
@@ -47,6 +48,7 @@ export type CheckDepsStatusOptions = Pick<Config,
| 'excludeLinksFromLockfile'
| 'injectWorkspacePackages'
| 'linkWorkspacePackages'
| 'lockfileDir'
| 'nodeLinker'
| 'patchedDependencies'
| 'peersSuffixMaxLength'
@@ -112,6 +114,7 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
catalogs,
excludeLinksFromLockfile,
linkWorkspacePackages,
lockfileDir,
nodeLinker,
patchedDependencies,
rootProjectManifest,
@@ -166,6 +169,21 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
}
}
const conflictedLockfileDir = findConflictedLockfileDir(getWantedLockfileDirs({
allProjects,
lockfileDir,
rootProjectManifestDir,
sharedWorkspaceLockfile,
workspaceDir,
}), workspaceState.lastValidatedTimestamp)
if (conflictedLockfileDir != null) {
return {
upToDate: false,
issue: `The lockfile in ${conflictedLockfileDir} has merge conflicts`,
workspaceState,
}
}
if (allProjects && workspaceDir) {
if (!equals(
filter(value => value != null, workspaceState.settings.catalogs ?? {}),
@@ -231,16 +249,6 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
}
}
const modifiedProjects = allManifestStats.filter(
({ manifestStats }) =>
manifestStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp
)
if (modifiedProjects.length === 0) {
logger.debug({ msg: 'No manifest files were modified since the last validation. Exiting check.' })
return { upToDate: true, workspaceState }
}
const issue = await patchesOrHooksAreModified({
patchedDependencies,
rootDir: rootProjectManifestDir,
@@ -252,6 +260,16 @@ async function _checkDepsStatus (opts: CheckDepsStatusOptions, workspaceState: W
return { upToDate: false, issue, workspaceState }
}
const modifiedProjects = allManifestStats.filter(
({ manifestStats }) =>
manifestStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp
)
if (modifiedProjects.length === 0) {
logger.debug({ msg: 'No manifest files were modified since the last validation. Exiting check.' })
return { upToDate: true, workspaceState }
}
logger.debug({ msg: 'Some manifest files were modified since the last validation. Continuing check.' })
let readWantedLockfileAndDir: (projectDir: string) => Promise<{
@@ -555,6 +573,36 @@ function throwLockfileNotFound (wantedLockfileDir: string): never {
})
}
function getWantedLockfileDirs (opts: {
allProjects: Project[] | undefined
lockfileDir: string | undefined
rootProjectManifestDir: string
sharedWorkspaceLockfile: boolean | undefined
workspaceDir: string | undefined
}): string[] {
if (opts.allProjects && opts.workspaceDir && opts.sharedWorkspaceLockfile === false) {
return [...new Set(opts.allProjects.map(({ rootDir }) => rootDir))]
}
return [opts.lockfileDir ?? opts.workspaceDir ?? opts.rootProjectManifestDir]
}
function findConflictedLockfileDir (lockfileDirs: string[], lastValidatedTimestamp: number): string | undefined {
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
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
}
async function patchesOrHooksAreModified (opts: {
patchedDependencies?: Record<string, string>
rootDir: string

View File

@@ -1,8 +1,12 @@
import type { Stats } from 'node:fs'
import fs from 'node:fs/promises'
import os from 'node:os'
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 { WorkspaceState } from '@pnpm/workspace.state'
{
@@ -320,4 +324,181 @@ describe('checkDepsStatus - pnpmfile modification', () => {
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('pnpmfile at "modifiedPnpmfile.js" was modified')
})
it('returns upToDate: false when a patch was modified and manifests were not modified', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const beforeLastValidation = lastValidatedTimestamp - 10_000
const afterLastValidation = lastValidatedTimestamp + 1_000
const projectRootDir = '/project' as ProjectRootDir
const projectRootDirRealPath = '/project' as ProjectRootDirRealPath
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {
[projectRootDir]: {
name: 'root',
version: '1.0.0',
},
},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
jest.mocked(fsUtils.safeStat).mockImplementation(async (filePath: string) => {
if (filePath === '/project/patches/foo.patch') {
return {
mtime: new Date(afterLastValidation),
mtimeMs: afterLastValidation,
} as Stats
}
return {
mtime: new Date(beforeLastValidation),
mtimeMs: beforeLastValidation,
} as Stats
})
jest.mocked(statManifestFileUtils.statManifestFile).mockImplementation(async () => ({
mtime: new Date(beforeLastValidation),
mtimeMs: beforeLastValidation,
} as Stats))
const opts: CheckDepsStatusOptions = {
allProjects: [{
rootDir: projectRootDir,
rootDirRealPath: projectRootDirRealPath,
manifest: {
name: 'root',
version: '1.0.0',
dependencies: {
foo: '1.0.0',
},
},
writeProjectManifest: async () => {},
}],
workspaceDir: '/project',
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
patchedDependencies: {
foo: '/project/patches/foo.patch',
},
...mockWorkspaceState.settings,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('Patches were modified')
})
})
describe('checkDepsStatus - lockfile conflicts', () => {
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('returns upToDate: false when the wanted lockfile has merge conflict markers', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-'))
try {
await writeConflictedLockfile(projectDir)
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: [],
...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('returns upToDate: false when a project lockfile has merge conflict markers and sharedWorkspaceLockfile is false', async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pnpm-check-deps-workspace-'))
try {
const projectDir = path.join(workspaceDir, 'packages/project')
await fs.mkdir(projectDir, { recursive: true })
await writeConflictedLockfile(projectDir)
const projectRootDir = projectDir as ProjectRootDir
const projectRootDirRealPath = await fs.realpath(projectDir) as ProjectRootDirRealPath
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp: Date.now() - 10_000,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
},
projects: {
[projectRootDir]: {
name: 'project',
version: '1.0.0',
},
},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
allProjects: [{
rootDir: projectRootDir,
rootDirRealPath: projectRootDirRealPath,
manifest: {
name: 'project',
version: '1.0.0',
},
writeProjectManifest: async () => {},
}],
workspaceDir,
rootProjectManifest: {},
rootProjectManifestDir: workspaceDir,
pnpmfile: [],
sharedWorkspaceLockfile: false,
...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(workspaceDir, { force: true, recursive: true })
}
})
})
async function writeConflictedLockfile (lockfileDir: string): Promise<void> {
await fs.writeFile(path.join(lockfileDir, 'pnpm-lock.yaml'), [
"lockfileVersion: '9.0'",
'<<<<<<< HEAD',
'settings:',
' autoInstallPeers: true',
'=======',
'settings:',
' autoInstallPeers: false',
'>>>>>>> branch',
'',
].join('\n'))
}

View File

@@ -1,4 +1,4 @@
import { promises as fs } from 'node:fs'
import fs, { promises as fsp } from 'node:fs'
import path from 'node:path'
import util from 'node:util'
@@ -64,6 +64,18 @@ export async function readWantedLockfile (
return (await _readWantedLockfile(pkgPath, opts)).lockfile
}
export function wantedLockfileHasMergeConflictsSync (pkgPath: string): boolean {
try {
const lockfileRawContent = stripBom(fs.readFileSync(path.join(pkgPath, WANTED_LOCKFILE), 'utf8'))
return isDiff(extractMainDocument(lockfileRawContent))
} catch (err: unknown) {
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
return false
}
throw err
}
}
async function _read (
lockfilePath: string,
prefix: string, // only for logging
@@ -78,7 +90,7 @@ async function _read (
}> {
let lockfileRawContent
try {
lockfileRawContent = stripBom(await fs.readFile(lockfilePath, 'utf8'))
lockfileRawContent = stripBom(await fsp.readFile(lockfilePath, 'utf8'))
} catch (err: unknown) {
if (!(util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT')) {
throw err