fix: catalog snapshots removed on filtered install with --fix-lockfile (#9152)

close #8639
This commit is contained in:
Brandon Cheng
2025-02-23 20:41:27 -05:00
committed by GitHub
parent e32b1a29e9
commit 41dada429b
3 changed files with 110 additions and 66 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/core": patch
pnpm: patch
---
Fix a bug causing catalog snapshots to be removed from the `pnpm-lock.yaml` file when using `--fix-lockfile` and `--filter`. [#8639](https://github.com/pnpm/pnpm/issues/8639)

View File

@@ -1433,74 +1433,56 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => {
const allProjectsLocatedInsideWorkspace = Object.values(ctx.projects)
.filter((project) => isPathInsideWorkspace(project.rootDirRealPath ?? project.rootDir))
if (allProjectsLocatedInsideWorkspace.length > projects.length) {
if (
allMutationsAreInstalls(projects) &&
await allProjectsAreUpToDate(allProjectsLocatedInsideWorkspace, {
catalogs: opts.catalogs,
autoInstallPeers: opts.autoInstallPeers,
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
linkWorkspacePackages: opts.linkWorkspacePackagesDepth >= 0,
wantedLockfile: ctx.wantedLockfile,
workspacePackages: ctx.workspacePackages,
lockfileDir: opts.lockfileDir,
})
) {
return installInContext(projects, ctx, {
...opts,
frozenLockfile: true,
})
} else {
const newProjects = [...projects]
const getWantedDepsOpts = {
autoInstallPeers: opts.autoInstallPeers,
includeDirect: opts.includeDirect,
updateWorkspaceDependencies: false,
nodeExecPath: opts.nodeExecPath,
injectWorkspacePackages: opts.injectWorkspacePackages,
}
const _isWantedDepPrefSame = isWantedDepPrefSame.bind(null, ctx.wantedLockfile.catalogs, opts.catalogs)
for (const project of allProjectsLocatedInsideWorkspace) {
if (!newProjects.some(({ rootDir }) => rootDir === project.rootDir)) {
// This code block mirrors the installCase() function in
// mutateModules(). Consider a refactor that combines this logic
// to deduplicate code.
const wantedDependencies = getWantedDependencies(project.manifest, getWantedDepsOpts)
.map((wantedDependency) => ({ ...wantedDependency, updateSpec: true, preserveNonSemverVersionSpec: true }))
forgetResolutionsOfPrevWantedDeps(ctx.wantedLockfile.importers[project.id], wantedDependencies, _isWantedDepPrefSame)
newProjects.push({
mutation: 'install',
...project,
wantedDependencies,
pruneDirectDependencies: false,
updatePackageManifest: false,
})
}
}
const result = await installInContext(newProjects, ctx, {
...opts,
lockfileOnly: true,
})
const { stats, ignoredBuilds } = await headlessInstall({
...ctx,
...opts,
currentEngine: {
nodeVersion: opts.nodeVersion,
pnpmVersion: opts.packageManager.name === 'pnpm' ? opts.packageManager.version : '',
},
currentHoistedLocations: ctx.modulesFile?.hoistedLocations,
selectedProjectDirs: projects.map((project) => project.rootDir),
allProjects: ctx.projects,
prunedAt: ctx.modulesFile?.prunedAt,
wantedLockfile: result.newLockfile,
useLockfile: opts.useLockfile && ctx.wantedLockfileIsModified,
hoistWorkspacePackages: opts.hoistWorkspacePackages,
})
return {
...result,
stats,
ignoredBuilds,
const newProjects = [...projects]
const getWantedDepsOpts = {
autoInstallPeers: opts.autoInstallPeers,
includeDirect: opts.includeDirect,
updateWorkspaceDependencies: false,
nodeExecPath: opts.nodeExecPath,
injectWorkspacePackages: opts.injectWorkspacePackages,
}
const _isWantedDepPrefSame = isWantedDepPrefSame.bind(null, ctx.wantedLockfile.catalogs, opts.catalogs)
for (const project of allProjectsLocatedInsideWorkspace) {
if (!newProjects.some(({ rootDir }) => rootDir === project.rootDir)) {
// This code block mirrors the installCase() function in
// mutateModules(). Consider a refactor that combines this logic to
// deduplicate code.
const wantedDependencies = getWantedDependencies(project.manifest, getWantedDepsOpts)
.map((wantedDependency) => ({ ...wantedDependency, updateSpec: true, preserveNonSemverVersionSpec: true }))
forgetResolutionsOfPrevWantedDeps(ctx.wantedLockfile.importers[project.id], wantedDependencies, _isWantedDepPrefSame)
newProjects.push({
mutation: 'install',
...project,
wantedDependencies,
pruneDirectDependencies: false,
updatePackageManifest: false,
})
}
}
const result = await installInContext(newProjects, ctx, {
...opts,
lockfileOnly: true,
})
const { stats, ignoredBuilds } = await headlessInstall({
...ctx,
...opts,
currentEngine: {
nodeVersion: opts.nodeVersion,
pnpmVersion: opts.packageManager.name === 'pnpm' ? opts.packageManager.version : '',
},
currentHoistedLocations: ctx.modulesFile?.hoistedLocations,
selectedProjectDirs: projects.map((project) => project.rootDir),
allProjects: ctx.projects,
prunedAt: ctx.modulesFile?.prunedAt,
wantedLockfile: result.newLockfile,
useLockfile: opts.useLockfile && ctx.wantedLockfileIsModified,
hoistWorkspacePackages: opts.hoistWorkspacePackages,
})
return {
...result,
stats,
ignoredBuilds,
}
}
}
if (opts.nodeLinker === 'hoisted' && !opts.lockfileOnly) {

View File

@@ -3,6 +3,7 @@ import { type ProjectRootDir, type ProjectId, type ProjectManifest } from '@pnpm
import { prepareEmpty } from '@pnpm/prepare'
import { addDistTag } from '@pnpm/registry-mock'
import { type MutatedProject, mutateModules, type ProjectOptions, type MutateModulesOptions, addDependenciesToPackage } from '@pnpm/core'
import { type CatalogSnapshots } from '@pnpm/lockfile.types'
import { sync as loadJsonFile } from 'load-json-file'
import path from 'path'
import { testDefaults } from './utils'
@@ -379,6 +380,61 @@ test('lockfile catalog snapshots do not contain stale references on --filter', a
expect(loadJsonFile<ProjectManifest>(pathToIsPositivePkgJson)?.version).toBe('3.1.0')
})
// Regression test for https://github.com/pnpm/pnpm/issues/8639
test('--fix-lockfile with --filter does not erase catalog snapshots', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
'is-negative': 'catalog:',
},
},
{
name: 'project2',
dependencies: {
'is-positive': 'catalog:',
},
},
])
const catalogs = {
default: {
'is-positive': '^1.0.0',
'is-negative': '^1.0.0',
},
}
const expectedCatalogsSnapshot: CatalogSnapshots = {
default: {
'is-negative': { specifier: '^1.0.0', version: '1.0.0' },
'is-positive': { specifier: '^1.0.0', version: '1.0.0' },
},
}
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs,
})
// Sanity check this test is set up correctly.
expect(readLockfile().catalogs).toStrictEqual(expectedCatalogsSnapshot)
// The catalogs snapshot should still be the same after performing a filtered
// install with --fix-lockfile.
const onlyProject1 = installProjects(projects).slice(0, 1)
expect(onlyProject1).toMatchObject([{ id: 'project1' }])
await mutateModules(onlyProject1, {
...options,
lockfileOnly: true,
fixLockfile: true,
catalogs,
})
expect(readLockfile().catalogs).toStrictEqual(expectedCatalogsSnapshot)
})
test('external dependency using catalog protocol errors', async () => {
const { options, projects } = preparePackagesAndReturnObjects([
{