fix: detect overrides and other lockfile-affecting setting changes in optimisticRepeatInstall (#10654)

* fix: detect overrides and other lockfile-affecting setting changes in optimisticRepeatInstall

When optimisticRepeatInstall was enabled, changing overrides,
packageExtensions, ignoredOptionalDependencies, patchedDependencies,
or peersSuffixMaxLength would not trigger a reinstall because these
settings were not tracked in the workspace state file.

* refactor: extract WORKSPACE_STATE_SETTING_KEYS to prevent type/runtime drift

The settings key list in createWorkspaceState's pick() call must stay
in sync with the WorkspaceStateSettings type. Extract a shared const
array so both the type and runtime pick are derived from a single
source, preventing the class of bug fixed in the previous commit.
This commit is contained in:
Zoltan Kochan
2026-02-20 14:00:25 +01:00
committed by GitHub
parent 7f979f5fdc
commit 03c502c1a0
6 changed files with 231 additions and 40 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/workspace.state": patch
"pnpm": patch
---
Fixed `optimisticRepeatInstall` skipping install when `overrides`, `packageExtensions`, `ignoredOptionalDependencies`, `patchedDependencies`, or `peersSuffixMaxLength` changed.

View File

@@ -41,6 +41,163 @@ const lockfileFs = await import('@pnpm/lockfile.fs')
const fsUtils = await import('../lib/safeStat.js')
const statManifestFileUtils = await import('../lib/statManifestFile.js')
describe('checkDepsStatus - settings change detection', () => {
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
it('returns upToDate: false when overrides have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
overrides: { foo: '1.0.0' },
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
overrides: { foo: '2.0.0' },
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the overrides setting has changed')
})
it('returns upToDate: false when packageExtensions have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
packageExtensions: { foo: { dependencies: { bar: '1.0.0' } } },
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
packageExtensions: { foo: { dependencies: { bar: '2.0.0' } } },
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the packageExtensions setting has changed')
})
it('returns upToDate: false when ignoredOptionalDependencies have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
ignoredOptionalDependencies: ['foo'],
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
ignoredOptionalDependencies: ['foo', 'bar'],
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the ignoredOptionalDependencies setting has changed')
})
it('returns upToDate: false when patchedDependencies have changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
patchedDependencies: { foo: 'patches/foo.patch' },
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
patchedDependencies: { foo: 'patches/foo-v2.patch' },
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the patchedDependencies setting has changed')
})
it('returns upToDate: false when peersSuffixMaxLength has changed', async () => {
const lastValidatedTimestamp = Date.now() - 10_000
const mockWorkspaceState: WorkspaceState = {
lastValidatedTimestamp,
pnpmfiles: [],
settings: {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
preferWorkspacePackages: true,
peersSuffixMaxLength: 1000,
},
projects: {},
filteredInstall: false,
}
jest.mocked(loadWorkspaceState).mockReturnValue(mockWorkspaceState)
const opts: CheckDepsStatusOptions = {
rootProjectManifest: {},
rootProjectManifestDir: '/project',
pnpmfile: [],
...mockWorkspaceState.settings,
peersSuffixMaxLength: 100,
}
const result = await checkDepsStatus(opts)
expect(result.upToDate).toBe(false)
expect(result.issue).toBe('The value of the peersSuffixMaxLength setting has changed')
})
})
describe('checkDepsStatus - pnpmfile modification', () => {
beforeEach(() => {
jest.resetModules()

View File

@@ -1,6 +1,6 @@
import { pick } from 'ramda'
import { type ConfigDependencies } from '@pnpm/types'
import { type WorkspaceState, type WorkspaceStateSettings, type ProjectsList } from './types.js'
import { WORKSPACE_STATE_SETTING_KEYS, type WorkspaceState, type WorkspaceStateSettings, type ProjectsList } from './types.js'
export interface CreateWorkspaceStateOptions {
allProjects: ProjectsList
@@ -20,25 +20,7 @@ export const createWorkspaceState = (opts: CreateWorkspaceStateOptions): Workspa
},
])),
pnpmfiles: opts.pnpmfiles,
settings: pick([
'autoInstallPeers',
'catalogs',
'dedupeDirectDeps',
'dedupeInjectedDeps',
'dedupePeerDependents',
'dev',
'excludeLinksFromLockfile',
'hoistPattern',
'hoistWorkspacePackages',
'injectWorkspacePackages',
'linkWorkspacePackages',
'nodeLinker',
'optional',
'preferWorkspacePackages',
'production',
'publicHoistPattern',
'workspacePackagePatterns',
], opts.settings),
settings: pick(WORKSPACE_STATE_SETTING_KEYS, opts.settings),
filteredInstall: opts.filteredInstall,
configDependencies: opts.configDependencies,
})

View File

@@ -1,3 +1,3 @@
export { loadWorkspaceState } from './loadWorkspaceState.js'
export { type UpdateWorkspaceStateOptions, updateWorkspaceState } from './updateWorkspaceState.js'
export { type WorkspaceState, type WorkspaceStateSettings, type ProjectsList } from './types.js'
export { WORKSPACE_STATE_SETTING_KEYS, type WorkspaceState, type WorkspaceStateSettings, type ProjectsList } from './types.js'

View File

@@ -15,22 +15,29 @@ export interface WorkspaceState {
settings: WorkspaceStateSettings
}
export type WorkspaceStateSettings = Pick<Config,
| 'autoInstallPeers'
| 'catalogs'
| 'dedupeDirectDeps'
| 'dedupeInjectedDeps'
| 'dedupePeerDependents'
| 'dev'
| 'excludeLinksFromLockfile'
| 'hoistPattern'
| 'hoistWorkspacePackages'
| 'injectWorkspacePackages'
| 'linkWorkspacePackages'
| 'nodeLinker'
| 'optional'
| 'preferWorkspacePackages'
| 'production'
| 'publicHoistPattern'
| 'workspacePackagePatterns'
>
export const WORKSPACE_STATE_SETTING_KEYS = [
'autoInstallPeers',
'catalogs',
'dedupeDirectDeps',
'dedupeInjectedDeps',
'dedupePeerDependents',
'dev',
'excludeLinksFromLockfile',
'hoistPattern',
'hoistWorkspacePackages',
'ignoredOptionalDependencies',
'injectWorkspacePackages',
'linkWorkspacePackages',
'nodeLinker',
'optional',
'overrides',
'packageExtensions',
'patchedDependencies',
'peersSuffixMaxLength',
'preferWorkspacePackages',
'production',
'publicHoistPattern',
'workspacePackagePatterns',
] as const satisfies ReadonlyArray<keyof Config>
export type WorkspaceStateSettings = Pick<Config, typeof WORKSPACE_STATE_SETTING_KEYS[number]>

View File

@@ -27,6 +27,45 @@ test('createWorkspaceState() on empty list', () => {
}))
})
test('createWorkspaceState() saves lockfile-affecting settings', () => {
prepareEmpty()
const state = createWorkspaceState({
allProjects: [],
pnpmfiles: [],
filteredInstall: false,
settings: {
autoInstallPeers: true,
dedupeDirectDeps: true,
excludeLinksFromLockfile: false,
preferWorkspacePackages: false,
linkWorkspacePackages: false,
injectWorkspacePackages: false,
overrides: {
foo: '1.0.0',
},
packageExtensions: {
bar: { dependencies: { baz: '2.0.0' } },
},
ignoredOptionalDependencies: ['qux'],
patchedDependencies: {
'some-pkg': 'patches/some-pkg.patch',
},
peersSuffixMaxLength: 100,
},
})
expect(state.settings.overrides).toStrictEqual({ foo: '1.0.0' })
expect(state.settings.packageExtensions).toStrictEqual({
bar: { dependencies: { baz: '2.0.0' } },
})
expect(state.settings.ignoredOptionalDependencies).toStrictEqual(['qux'])
expect(state.settings.patchedDependencies).toStrictEqual({
'some-pkg': 'patches/some-pkg.patch',
})
expect(state.settings.peersSuffixMaxLength).toBe(100)
})
test('createWorkspaceState() on non-empty list', () => {
preparePackages(['a', 'b', 'c', 'd'].map(name => ({
location: `./packages/${name}`,