mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-01 12:41:16 -04:00
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:
8
.changeset/fix-optimistic-lockfile-conflict.md
Normal file
8
.changeset/fix-optimistic-lockfile-conflict.md
Normal 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.
|
||||
68
deps/status/src/checkDepsStatus.ts
vendored
68
deps/status/src/checkDepsStatus.ts
vendored
@@ -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
|
||||
|
||||
181
deps/status/test/checkDepsStatus.test.ts
vendored
181
deps/status/test/checkDepsStatus.test.ts
vendored
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user