diff --git a/.changeset/thin-crabs-smoke.md b/.changeset/thin-crabs-smoke.md new file mode 100644 index 0000000000..3131ab1064 --- /dev/null +++ b/.changeset/thin-crabs-smoke.md @@ -0,0 +1,6 @@ +--- +"@pnpm/resolve-dependencies": patch +"pnpm": patch +--- + +Fixed a bug ([#9759](https://github.com/pnpm/pnpm/issues/9759)) where `pnpm add` would incorrectly modify a catalog entry in `pnpm-workspace.yaml` to its exact version. diff --git a/pkg-manager/core/test/catalogs.ts b/pkg-manager/core/test/catalogs.ts index 5296fb4d15..492a3f5089 100644 --- a/pkg-manager/core/test/catalogs.ts +++ b/pkg-manager/core/test/catalogs.ts @@ -1193,6 +1193,58 @@ describe('add', () => { }) }) + // Regression test for https://github.com/pnpm/pnpm/issues/9759 + test('adding new usage of default catalog does not mutate catalog entries', async () => { + const { options, projects, readLockfile } = preparePackagesAndReturnObjects([ + { + name: 'project1', + dependencies: { + '@pnpm.e2e/foo': 'catalog:', + }, + }, + { + name: 'project2', + }, + ]) + + const catalogs = { + default: { '@pnpm.e2e/foo': '^100.0.0' }, + } + + await mutateModules(installProjects(projects), { + ...options, + lockfileOnly: true, + catalogs, + }) + + await addDependenciesToPackage( + projects['project2' as ProjectId], + ['@pnpm.e2e/foo'], + { + ...options, + dir: path.join(options.lockfileDir, 'project2'), + lockfileOnly: true, + allowNew: true, + catalogs, + }) + + const lockfile = readLockfile() + + // This is the specific condition we're regression testing for. The + // specifier used in the original catalog entry should not be modified. + expect(lockfile.catalogs.default['@pnpm.e2e/foo'].specifier).toEqual(catalogs.default['@pnpm.e2e/foo']) + + // Sanity check that the rest of the lockfile has expected contents. + expect(readLockfile()).toMatchObject({ + catalogs: { default: { '@pnpm.e2e/foo': { specifier: '^100.0.0', version: '100.0.0' } } }, + importers: { + project1: { dependencies: { '@pnpm.e2e/foo': { specifier: 'catalog:', version: '100.0.0' } } }, + project2: { dependencies: { '@pnpm.e2e/foo': { specifier: 'catalog:', version: '100.0.0' } } }, + }, + packages: { '@pnpm.e2e/foo@100.0.0': expect.any(Object) }, + }) + }) + test('adding specific version equal to catalog version will use catalog if present', async () => { const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{ name: 'project1', diff --git a/pkg-manager/resolve-dependencies/src/getExactSinglePreferredVersions.ts b/pkg-manager/resolve-dependencies/src/getExactSinglePreferredVersions.ts new file mode 100644 index 0000000000..787cc43675 --- /dev/null +++ b/pkg-manager/resolve-dependencies/src/getExactSinglePreferredVersions.ts @@ -0,0 +1,13 @@ +import { type PreferredVersions } from '@pnpm/resolver-base' +import { type WantedDependency } from './getWantedDependencies.js' +import { unwrapPackageName } from './unwrapPackageName.js' + +/** + * Create a PreferredVersions object with a specific exact version. + */ +export function getExactSinglePreferredVersions (wantedDependency: WantedDependency, version: string): PreferredVersions { + const { pkgName } = unwrapPackageName(wantedDependency.alias, wantedDependency.bareSpecifier) + return { + [pkgName]: { [version]: 'version' }, + } +} diff --git a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts index 3fa0f33698..ad6c3ff553 100644 --- a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts +++ b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts @@ -53,6 +53,7 @@ import pDefer from 'p-defer' import pShare from 'promise-share' import { pickBy, omit, zipWith } from 'ramda' import semver from 'semver' +import { getExactSinglePreferredVersions } from './getExactSinglePreferredVersions.js' import { getNonDevWantedDependencies, type WantedDependency } from './getNonDevWantedDependencies.js' import { safeIntersect } from './mergePeers.js' import { type NodeId, nextNodeId } from './nextNodeId.js' @@ -1306,14 +1307,19 @@ async function resolveDependency ( optional: true, } } + + // Normalize the `preferredVersion` (singular) and `preferredVersions` + // (plural) options. If the singular option is passed through, it'll be used + // instead of the plural option. + const preferredVersions = !options.updateRequested && options.preferredVersion != null + ? getExactSinglePreferredVersions(wantedDependency, options.preferredVersion) + : options.preferredVersions + try { const calcSpecifier = options.currentDepth === 0 if (!options.update && currentPkg.version && currentPkg.pkgId?.endsWith(`@${currentPkg.version}`) && !calcSpecifier) { wantedDependency.bareSpecifier = replaceVersionInBareSpecifier(wantedDependency.bareSpecifier, currentPkg.version) } - if (!options.updateRequested && options.preferredVersion != null) { - wantedDependency.bareSpecifier = replaceVersionInBareSpecifier(wantedDependency.bareSpecifier, options.preferredVersion) - } pkgResponse = await ctx.storeController.requestPackage(wantedDependency, { allowBuild: ctx.allowBuild, alwaysTryWorkspacePackages: ctx.linkWorkspacePackagesDepth >= options.currentDepth, @@ -1333,7 +1339,7 @@ async function resolveDependency ( pickLowestVersion: options.pickLowestVersion, downloadPriority: -options.currentDepth, lockfileDir: ctx.lockfileDir, - preferredVersions: options.preferredVersions, + preferredVersions, preferWorkspacePackages: ctx.preferWorkspacePackages, projectDir: ( options.currentDepth > 0 &&