From 951f191d31e27c4b310bda5eae377ca6e8270fca Mon Sep 17 00:00:00 2001 From: Bharat Middha <5100938+bmiddha@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:12:25 -0700 Subject: [PATCH] fix(rush-lib): use pnpm-config.json fallback for catalogs Subspaces without their own pnpm-config.json install with the catalogs from common/config/rush/pnpm-config.json, but the repo-state.json catalog hash, catalog change detection, and the rush-pnpm update catalog sync only read the subspace's own file. Route all of them through a new internal getEffectivePnpmOptions() helper so they match install. When syncing catalogs into a pnpm-config.json that several subspaces share, only add or update entries and tell the user which other subspaces need "rush update". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...alog-config-fallback_2026-10-13-00-00.json | 11 + common/reviews/api/rush-lib.api.md | 2 +- libraries/rush-lib/src/api/Subspace.ts | 24 +- .../rush-lib/src/api/test/Subspace.test.ts | 30 +++ .../src/cli/RushPnpmCommandLineParser.ts | 78 +++++- .../test/RushPnpmCommandLineParser.test.ts | 163 +++++++++++++ .../src/logic/ProjectChangeAnalyzer.ts | 74 ++++-- .../src/logic/base/BaseInstallManager.ts | 8 +- .../logic/installManager/InstallHelpers.ts | 3 +- .../src/logic/pnpm/EffectivePnpmOptions.ts | 160 ++++++++++++ .../logic/pnpm/PnpmOptionsConfiguration.ts | 12 +- .../src/logic/pnpm/PnpmShrinkwrapFile.ts | 13 +- .../pnpm/SubspacePnpmfileConfiguration.ts | 9 +- .../pnpm/test/EffectivePnpmOptions.test.ts | 227 ++++++++++++++++++ .../src/logic/test/InstallHelpers.test.ts | 35 +++ .../logic/test/ProjectChangeAnalyzer.test.ts | 165 ++++++++++++- .../a/package.json | 8 + .../b/package.json | 8 + .../c/package.json | 8 + .../common/config/rush/experiments.json | 3 + .../common/config/rush/pnpm-config.json | 13 + .../common/config/rush/subspaces.json | 5 + .../common/config/rush/version-policies.json | 1 + .../subspaces/default/common-versions.json | 8 + .../other-subspace/common-versions.json | 8 + .../own-config-subspace/common-versions.json | 8 + .../own-config-subspace/pnpm-config.json | 8 + .../d/package.json | 8 + .../e/package.json | 8 + .../repoWithSubspacesCommonCatalogs/rush.json | 30 +++ 30 files changed, 1079 insertions(+), 59 deletions(-) create mode 100644 common/changes/@microsoft/rush/bmiddha-catalog-config-fallback_2026-10-13-00-00.json create mode 100644 libraries/rush-lib/src/logic/pnpm/EffectivePnpmOptions.ts create mode 100644 libraries/rush-lib/src/logic/pnpm/test/EffectivePnpmOptions.test.ts create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/a/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/b/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/c/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/experiments.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/subspaces.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/version-policies.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/default/common-versions.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/other-subspace/common-versions.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/own-config-subspace/common-versions.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/own-config-subspace/pnpm-config.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/d/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/e/package.json create mode 100644 libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/rush.json diff --git a/common/changes/@microsoft/rush/bmiddha-catalog-config-fallback_2026-10-13-00-00.json b/common/changes/@microsoft/rush/bmiddha-catalog-config-fallback_2026-10-13-00-00.json new file mode 100644 index 00000000000..0007288af03 --- /dev/null +++ b/common/changes/@microsoft/rush/bmiddha-catalog-config-fallback_2026-10-13-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fix PNPM catalog handling for subspaces that do not have their own pnpm-config.json. The repo-state.json catalog hash, catalog change detection, and the \"rush-pnpm update\" catalog sync now use common/config/rush/pnpm-config.json as a fallback, the same way \"rush install\" does. When the catalogs in a pnpm-config.json shared by several subspaces are synced, entries are only added or updated, and Rush lists the other subspaces that need \"rush update\".", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index a85fdb820e4..914ae2604f6 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -1693,7 +1693,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly autoInstallPeers: boolean | undefined; readonly globalAllowBuilds: Record | undefined; readonly globalAllowedDeprecatedVersions: Record | undefined; - readonly globalCatalogs: Record> | undefined; + get globalCatalogs(): Record> | undefined; readonly globalIgnoredOptionalDependencies: string[] | undefined; readonly globalNeverBuiltDependencies: string[] | undefined; readonly globalOnlyBuiltDependencies: string[] | undefined; diff --git a/libraries/rush-lib/src/api/Subspace.ts b/libraries/rush-lib/src/api/Subspace.ts index 0f9583f9f7f..1017d7ac042 100644 --- a/libraries/rush-lib/src/api/Subspace.ts +++ b/libraries/rush-lib/src/api/Subspace.ts @@ -15,6 +15,7 @@ import { CommonVersionsConfiguration } from './CommonVersionsConfiguration'; import { RepoStateFile } from '../logic/RepoStateFile'; import type { PnpmPackageManager } from './packageManager/PnpmPackageManager'; import { PnpmOptionsConfiguration } from '../logic/pnpm/PnpmOptionsConfiguration'; +import { getEffectivePnpmCatalogs } from '../logic/pnpm/EffectivePnpmOptions'; import { SubspacePnpmfileConfiguration } from '../logic/pnpm/SubspacePnpmfileConfiguration'; import type { ISubspacePnpmfileShimSettings } from '../logic/pnpm/IPnpmfile'; @@ -413,25 +414,22 @@ export class Subspace { /** * Computes a hash of the PNPM catalog definitions for this subspace. * Returns undefined if no catalogs are defined. + * + * @remarks + * The catalogs are read from the pnpm-config.json that install uses for this subspace: the subspace's + * own pnpm-config.json if it exists, otherwise `common/config/rush/pnpm-config.json`. */ public getPnpmCatalogsHash(): string | undefined { - const pnpmOptions: PnpmOptionsConfiguration | undefined = this.getPnpmOptions(); - if (!pnpmOptions) { - return undefined; - } - - const catalogData: Record = {}; - if (pnpmOptions.globalCatalogs && Object.keys(pnpmOptions.globalCatalogs).length !== 0) { - Object.assign(catalogData, pnpmOptions.globalCatalogs); - } - - // If no catalogs are defined, return undefined - if (Object.keys(catalogData).length === 0) { + const catalogs: Record> | undefined = getEffectivePnpmCatalogs( + this, + this.#rushConfiguration + ); + if (!catalogs) { return undefined; } const hash: crypto.Hash = crypto.createHash('sha1'); - hash.update(JSON.stringify(catalogData)); + hash.update(JSON.stringify(catalogs)); return hash.digest('hex'); } diff --git a/libraries/rush-lib/src/api/test/Subspace.test.ts b/libraries/rush-lib/src/api/test/Subspace.test.ts index 4087b2b9716..b0da41b9888 100644 --- a/libraries/rush-lib/src/api/test/Subspace.test.ts +++ b/libraries/rush-lib/src/api/test/Subspace.test.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import * as crypto from 'node:crypto'; import * as path from 'node:path'; import { RushConfiguration } from '../RushConfiguration'; @@ -73,6 +74,35 @@ describe(Subspace.name, () => { expect(hashWithoutCatalogs).toBeUndefined(); expect(hashWithCatalogs).toBeDefined(); }); + + it('hashes the common catalogs when a subspace has no pnpm-config.json of its own', () => { + const rushJsonFilename: string = path.resolve( + __dirname, + '../../logic/test/repoWithSubspacesCommonCatalogs/rush.json' + ); + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(rushJsonFilename); + + // This must match the catalogs that install writes to the generated pnpm-workspace.yaml + const expectedHash: string = crypto + .createHash('sha1') + .update(JSON.stringify(rushConfiguration.pnpmOptions.globalCatalogs)) + .digest('hex'); + + expect(rushConfiguration.getSubspace('default').getPnpmCatalogsHash()).toEqual(expectedHash); + expect(rushConfiguration.getSubspace('other-subspace').getPnpmCatalogsHash()).toEqual(expectedHash); + + // A subspace with its own pnpm-config.json uses its own catalogs + const ownConfigHash: string | undefined = rushConfiguration + .getSubspace('own-config-subspace') + .getPnpmCatalogsHash(); + expect(ownConfigHash).toEqual( + crypto + .createHash('sha1') + .update(JSON.stringify({ default: { react: '^17.0.0' } })) + .digest('hex') + ); + }); }); describe(Subspace.prototype.getPackageJsonInjectedDependenciesHash.name, () => { diff --git a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts index 82502d67419..1b18eb13d75 100644 --- a/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts @@ -34,6 +34,12 @@ import { getNpmrcEnvironmentVariables } from '../utilities/npmrcUtilities'; import { InstallHelpers } from '../logic/installManager/InstallHelpers'; import type { Subspace } from '../api/Subspace'; import type { PnpmOptionsConfiguration } from '../logic/pnpm/PnpmOptionsConfiguration'; +import { + getEffectivePnpmOptions, + getOtherSubspacesSharingPnpmConfig, + mergePnpmCatalogs, + type PnpmCatalogs +} from '../logic/pnpm/EffectivePnpmOptions'; import { PnpmWorkspaceFile } from '../logic/pnpm/PnpmWorkspaceFile'; import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; import { initializeDotEnv } from '../logic/dotenv'; @@ -751,21 +757,36 @@ export class RushPnpmCommandLineParser { // When "pnpm up" / "pnpm update" runs, PNPM writes any updated catalog versions to the // generated "catalogs" section of common/temp//pnpm-workspace.yaml. That file is // regenerated on every install, so the updated versions must be synced back to the - // "globalCatalogs" field of pnpm-config.json for the change to be persisted. - const pnpmOptions: PnpmOptionsConfiguration | undefined = this.#subspace.getPnpmOptions(); - if (pnpmOptions === undefined) { + // "globalCatalogs" field of the pnpm-config.json that install reads the catalogs from. + // If the subspace does not have a pnpm-config.json of its own, that is the common + // pnpm-config.json (see getEffectivePnpmOptions()). + const { pnpmOptions, pnpmConfigFilePath } = getEffectivePnpmOptions( + this.#subspace, + this.#rushConfiguration + ); + if (pnpmOptions.jsonFilename === undefined) { + // The legacy "pnpmOptions" field in rush.json does not support catalogs break; } - const workspaceYamlFilename: string = `${subspaceTempFolder}/pnpm-workspace.yaml`; + const workspaceYamlFilename: string = `${subspaceTempFolder}/${RushConstants.pnpmWorkspaceFileName}`; const yamlModule: typeof import('js-yaml') = await import('js-yaml'); const workspaceYamlContent: string = await FileSystem.readFileAsync(workspaceYamlFilename); - const workspaceYaml: { catalogs?: Record> } = (yamlModule.load( - workspaceYamlContent - ) ?? {}) as { catalogs?: Record> }; - const newGlobalCatalogs: Record> | undefined = workspaceYaml?.catalogs; - const currentGlobalCatalogs: Record> | undefined = - pnpmOptions.globalCatalogs; + const workspaceYaml: IPnpmWorkspaceYamlCatalogs = (yamlModule.load(workspaceYamlContent) ?? + {}) as IPnpmWorkspaceYamlCatalogs; + const currentGlobalCatalogs: PnpmCatalogs | undefined = pnpmOptions.globalCatalogs; + let newGlobalCatalogs: PnpmCatalogs | undefined = getCatalogsFromWorkspaceYaml(workspaceYaml); + + // If other subspaces also read their catalogs from this pnpm-config.json, this subspace's + // workspace cannot tell which catalog entries those subspaces use. Only add or update entries, + // and never remove an entry or a catalog that another subspace might depend on. + const subspacesSharingPnpmConfig: Subspace[] = getOtherSubspacesSharingPnpmConfig( + this.#subspace, + this.#rushConfiguration + ); + if (subspacesSharingPnpmConfig.length > 0) { + newGlobalCatalogs = mergePnpmCatalogs(currentGlobalCatalogs, newGlobalCatalogs); + } if (!Objects.areDeepEqual(currentGlobalCatalogs, newGlobalCatalogs)) { await pnpmOptions.updateGlobalCatalogsAsync(newGlobalCatalogs); @@ -775,6 +796,19 @@ export class RushPnpmCommandLineParser { `Rush refreshed the ${RushConstants.pnpmConfigFilename} and shrinkwrap file.\n` + ' Please commit this change to Git.' ); + + if (subspacesSharingPnpmConfig.length > 0) { + // The other subspaces' lockfiles and repo-state.json files still reflect the old catalogs. + // Their next "rush install" would fail the PNPM lockfile catalog check, so they must be updated too. + const updateCommands: string[] = subspacesSharingPnpmConfig.map( + (subspace: Subspace) => ` rush update --subspace ${subspace.subspaceName}` + ); + this.#terminal.writeWarningLine( + `The catalogs were updated in ${pnpmConfigFilePath}, which is also used by other subspaces.\n` + + ' Run these commands to update their shrinkwrap files, then commit those changes to Git:\n' + + updateCommands.join('\n') + ); + } } break; } @@ -830,3 +864,27 @@ export class RushPnpmCommandLineParser { } } } + +/** + * The catalog fields of a pnpm-workspace.yaml file. + */ +interface IPnpmWorkspaceYamlCatalogs { + catalog?: Record; + catalogs?: PnpmCatalogs; +} + +/** + * Reads the catalogs from a parsed pnpm-workspace.yaml file. + * + * @remarks + * PNPM accepts the default catalog either as the top-level "catalog" field or as "catalogs.default". + * Rush always writes "catalogs", but PNPM writes a new default catalog to the top-level "catalog" field. + */ +function getCatalogsFromWorkspaceYaml(workspaceYaml: IPnpmWorkspaceYamlCatalogs): PnpmCatalogs | undefined { + const { catalog, catalogs } = workspaceYaml; + if (!catalog) { + return catalogs; + } + + return { ...catalogs, default: { ...catalogs?.default, ...catalog } }; +} diff --git a/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts index 3d9073e2825..d251956b891 100644 --- a/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts @@ -236,6 +236,169 @@ describe(`${RushPnpmCommandLineParser.name} catalog sync`, () => { }); }); +describe(`${RushPnpmCommandLineParser.name} catalog sync with subspaces`, () => { + const PACKAGE_ROOT: string = path.resolve(__dirname, '../../..'); + const TEST_TEMP_FOLDER: string = `${PACKAGE_ROOT}/temp/rush-pnpm-catalog-sync-subspaces-test`; + // The "default" and "other-subspace" subspaces don't have a pnpm-config.json of their own, so they use + // common/config/rush/pnpm-config.json. The "own-config-subspace" subspace has its own pnpm-config.json. + const FIXTURE_FOLDER: string = path.resolve(__dirname, '../../logic/test/repoWithSubspacesCommonCatalogs'); + const COMMON_PNPM_CONFIG_FILENAME: string = `${TEST_TEMP_FOLDER}/common/config/rush/pnpm-config.json`; + const OWN_PNPM_CONFIG_FILENAME: string = `${TEST_TEMP_FOLDER}/common/config/subspaces/own-config-subspace/pnpm-config.json`; + + interface ICatalogSyncResult { + rushConfiguration: RushConfiguration; + doRushUpdateAsync: jest.Mock; + warningOutput: string; + } + + /** + * Simulates "rush-pnpm --subspace up" after PNPM wrote the specified pnpm-workspace.yaml. + */ + async function runUpCatalogSyncAsync( + subspaceName: string, + workspaceYamlLines: string[] + ): Promise { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${TEST_TEMP_FOLDER}/rush.json` + ); + const subspace: Subspace = rushConfiguration.getSubspace(subspaceName); + await FileSystem.writeFileAsync( + `${subspace.getSubspaceTempFolderPath()}/pnpm-workspace.yaml`, + [...workspaceYamlLines, ''].join('\n'), + { ensureFolderExists: true } + ); + + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const doRushUpdateAsync: jest.Mock = jest.fn().mockResolvedValue(undefined); + await RushPnpmCommandLineParser._postExecuteForTestingAsync({ + commandName: 'up', + doRushUpdateAsync, + rushConfiguration, + subspace, + terminal: new Terminal(terminalProvider) + }); + + return { rushConfiguration, doRushUpdateAsync, warningOutput: terminalProvider.getWarningOutput() }; + } + + async function loadGlobalCatalogsAsync( + pnpmConfigFilename: string + ): Promise> | undefined> { + const pnpmConfig: { globalCatalogs?: Record> } = + await JsonFile.loadAsync(pnpmConfigFilename); + return pnpmConfig.globalCatalogs; + } + + beforeEach(async () => { + await FileSystem.deleteFolderAsync(TEST_TEMP_FOLDER); + await FileSystem.copyFilesAsync({ + sourcePath: FIXTURE_FOLDER, + destinationPath: TEST_TEMP_FOLDER + }); + }); + + afterEach(async () => { + await FileSystem.deleteFolderAsync(TEST_TEMP_FOLDER); + }); + + it('writes updated catalogs back to common/config/rush/pnpm-config.json for a subspace that uses it', async () => { + const ownPnpmConfigContent: string = await FileSystem.readFileAsync(OWN_PNPM_CONFIG_FILENAME); + + const { rushConfiguration, doRushUpdateAsync, warningOutput } = await runUpCatalogSyncAsync('default', [ + 'packages:', + ' - ../../../a', + ' - ../../../b', + 'catalogs:', + ' default:', + ' react: ^18.3.0', + ' foo: ~2.0.0', + ' tools:', + ' typescript: ~5.3.0' + ]); + + const expectedCatalogs: Record> = { + default: { react: '^18.3.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' } + }; + const commonPnpmConfig: { useWorkspaces?: boolean } = + await JsonFile.loadAsync(COMMON_PNPM_CONFIG_FILENAME); + expect(commonPnpmConfig.useWorkspaces).toBe(true); + expect(await loadGlobalCatalogsAsync(COMMON_PNPM_CONFIG_FILENAME)).toEqual(expectedCatalogs); + expect(await FileSystem.readFileAsync(OWN_PNPM_CONFIG_FILENAME)).toEqual(ownPnpmConfigContent); + expect(doRushUpdateAsync).toHaveBeenCalledTimes(1); + + // The in-memory options are updated too, so the "rush update" that follows (and the repo-state.json + // catalog hash of every subspace that shares the file) sees the new catalogs + expect(rushConfiguration.pnpmOptions.globalCatalogs).toEqual(expectedCatalogs); + expect(rushConfiguration.getSubspace('other-subspace').getPnpmCatalogsHash()).toEqual( + rushConfiguration.defaultSubspace.getPnpmCatalogsHash() + ); + + // The other subspace that uses the common pnpm-config.json must be updated too + expect(warningOutput).toContain('rush update --subspace other-subspace'); + expect(warningOutput).not.toContain('rush update --subspace default'); + expect(warningOutput).not.toContain('own-config-subspace'); + }); + + it('never removes catalog entries from a pnpm-config.json that is shared by several subspaces', async () => { + // This subspace's workspace only has the entries that PNPM kept; entries that other subspaces + // might use are missing + const { doRushUpdateAsync } = await runUpCatalogSyncAsync('other-subspace', [ + 'packages:', + ' - ../../../c', + ' - ../../../d', + 'catalogs:', + ' default:', + ' foo: ~2.1.0' + ]); + + expect(await loadGlobalCatalogsAsync(COMMON_PNPM_CONFIG_FILENAME)).toEqual({ + default: { react: '^18.0.0', foo: '~2.1.0' }, + tools: { typescript: '~5.3.0' } + }); + expect(doRushUpdateAsync).toHaveBeenCalledTimes(1); + }); + + it('does not modify a shared pnpm-config.json when the catalogs are unchanged', async () => { + const commonPnpmConfigContent: string = await FileSystem.readFileAsync(COMMON_PNPM_CONFIG_FILENAME); + + const { doRushUpdateAsync, warningOutput } = await runUpCatalogSyncAsync('default', [ + 'packages:', + ' - ../../../a', + ' - ../../../b', + 'catalogs:', + ' default:', + ' react: ^18.0.0', + ' foo: ~2.0.0', + ' tools:', + ' typescript: ~5.3.0' + ]); + + expect(await FileSystem.readFileAsync(COMMON_PNPM_CONFIG_FILENAME)).toEqual(commonPnpmConfigContent); + expect(doRushUpdateAsync).not.toHaveBeenCalled(); + expect(warningOutput).toEqual(''); + }); + + it("writes to the subspace's own pnpm-config.json and reads PNPM's top-level default catalog", async () => { + const commonPnpmConfigContent: string = await FileSystem.readFileAsync(COMMON_PNPM_CONFIG_FILENAME); + + const { rushConfiguration, doRushUpdateAsync, warningOutput } = await runUpCatalogSyncAsync( + 'own-config-subspace', + ['packages:', ' - ../../../e', 'catalog:', ' react: ^17.0.2'] + ); + + expect(await loadGlobalCatalogsAsync(OWN_PNPM_CONFIG_FILENAME)).toEqual({ + default: { react: '^17.0.2' } + }); + expect(rushConfiguration.getSubspace('own-config-subspace').getPnpmOptions()?.globalCatalogs).toEqual({ + default: { react: '^17.0.2' } + }); + expect(await FileSystem.readFileAsync(COMMON_PNPM_CONFIG_FILENAME)).toEqual(commonPnpmConfigContent); + expect(doRushUpdateAsync).toHaveBeenCalledTimes(1); + expect(warningOutput).not.toContain('rush update --subspace'); + }); +}); + describe(`${RushPnpmCommandLineParser.name} patch-commit patchedDependencies sync`, () => { it('reads patchedDependencies from pnpm-workspace.yaml for pnpm >= 11', async () => { const updateGlobalPatchedDependencies: jest.Mock = jest.fn(); diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index 2720d5504c5..ef8cbe0868f 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -35,7 +35,12 @@ import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile'; import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile'; import { Git } from './Git'; import { DependencySpecifier, DependencySpecifierType } from './DependencySpecifier'; -import type { IPnpmOptionsJson, PnpmOptionsConfiguration } from './pnpm/PnpmOptionsConfiguration'; +import type { IPnpmOptionsJson } from './pnpm/PnpmOptionsConfiguration'; +import { + getCommonPnpmConfigFilePath, + getEffectivePnpmOptions, + type IEffectivePnpmOptions +} from './pnpm/EffectivePnpmOptions'; import { type IInputsSnapshotProjectMetadata, type IInputsSnapshot, @@ -620,8 +625,12 @@ export class ProjectChangeAnalyzer { } /** - * Detects changes to pnpm catalog entries in a subspace's pnpm-config.json and marks - * affected projects as changed. + * Detects changes to pnpm catalog entries in the pnpm-config.json that is in effect for a subspace + * and marks affected projects as changed. + * + * @remarks + * If the subspace has no pnpm-config.json of its own, install uses `common/config/rush/pnpm-config.json`, + * so that file is diffed instead. See {@link getEffectivePnpmOptions}. */ async #detectCatalogChangesAsync( subspace: Subspace, @@ -632,29 +641,60 @@ export class ProjectChangeAnalyzer { terminal: ITerminal, changedProjects: Set ): Promise { - const pnpmOptions: PnpmOptionsConfiguration | undefined = subspace.getPnpmOptions(); - // Default to an empty object if no global catalogs are configured, handle case of globalCatalogs being deleted - const currentCatalogs: Record> = pnpmOptions?.globalCatalogs ?? {}; + const toRepoRelativePath = (filePath: string): string => + Path.convertToSlashes(path.relative(repoRoot, filePath)); - const pnpmConfigRelativePath: string = Path.convertToSlashes( - path.relative(repoRoot, subspace.getPnpmConfigFilePath()) + const effectivePnpmOptions: IEffectivePnpmOptions = getEffectivePnpmOptions(subspace, rushConfiguration); + const subspacePnpmConfigRelativePath: string = toRepoRelativePath(subspace.getPnpmConfigFilePath()); + const commonPnpmConfigRelativePath: string = toRepoRelativePath( + getCommonPnpmConfigFilePath(rushConfiguration) ); - if (!changedFiles.has(pnpmConfigRelativePath)) { + // The catalogs for this subspace can only have changed if the subspace's own pnpm-config.json was + // added, modified, or deleted, or if the subspace falls back to the common pnpm-config.json and that + // file changed. (When subspaces are disabled, both paths are the same file.) + if ( + !changedFiles.has(subspacePnpmConfigRelativePath) && + !(effectivePnpmOptions.isFallback && changedFiles.has(commonPnpmConfigRelativePath)) + ) { return; } + // Default to an empty object if no global catalogs are configured, handle case of globalCatalogs being deleted + const currentCatalogs: Record> = effectivePnpmOptions.pnpmOptions + .globalCatalogs ?? {}; + // Determine which specific packages changed within each catalog namespace // Maps catalogNamespace (e.g. "default", "react17") → Set of changed package names let oldCatalogs: Record> | undefined; - try { - const oldPnpmConfigText: string = await this.#git.getBlobContentAsync({ - blobSpec: `${mergeCommit}:${pnpmConfigRelativePath}`, - repositoryRoot: repoRoot - }); - const oldPnpmConfig: IPnpmOptionsJson = JsonFile.parseString(oldPnpmConfigText); - oldCatalogs = oldPnpmConfig.globalCatalogs ?? {}; - } catch { + // Apply the same fallback to the merge commit: the subspace's own pnpm-config.json if it existed, + // otherwise the common pnpm-config.json. + const oldPnpmConfigCandidatePaths: Set = new Set([ + subspacePnpmConfigRelativePath, + commonPnpmConfigRelativePath + ]); + for (const oldPnpmConfigRelativePath of oldPnpmConfigCandidatePaths) { + let oldPnpmConfigText: string; + try { + oldPnpmConfigText = await this.#git.getBlobContentAsync({ + blobSpec: `${mergeCommit}:${oldPnpmConfigRelativePath}`, + repositoryRoot: repoRoot + }); + } catch { + // The file didn't exist in the merge commit; try the next candidate + continue; + } + + try { + const oldPnpmConfig: IPnpmOptionsJson = JsonFile.parseString(oldPnpmConfigText); + oldCatalogs = oldPnpmConfig.globalCatalogs ?? {}; + } catch { + // Unparseable; fall through and treat all packages as changed + } + break; + } + + if (oldCatalogs === undefined) { // Old file didn't exist or was unparseable — treat all packages in all current catalogs as changed if (rushConfiguration.subspacesFeatureEnabled) { terminal.writeWarningLine( diff --git a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts index 74ce8865548..adac7984e2f 100644 --- a/libraries/rush-lib/src/logic/base/BaseInstallManager.ts +++ b/libraries/rush-lib/src/logic/base/BaseInstallManager.ts @@ -55,6 +55,7 @@ import type { IInstallManagerOptions } from './BaseInstallManagerTypes'; import { isVariableSetInNpmrcFile } from '../../utilities/npmrcUtilities'; import type { PnpmResolutionMode } from '../pnpm/PnpmOptionsConfiguration'; import { SubspacePnpmfileConfiguration } from '../pnpm/SubspacePnpmfileConfiguration'; +import { getEffectivePnpmOptions } from '../pnpm/EffectivePnpmOptions'; import type { Subspace } from '../../api/Subspace'; import { ProjectImpactGraphGenerator } from '../ProjectImpactGraphGenerator'; import { FlagFile } from '../../api/FlagFile'; @@ -426,8 +427,11 @@ export abstract class BaseInstallManager { // Add common-versions.json file to the potentially changed files list. potentiallyChangedFiles.push(subspace.getCommonVersionsFilePath(variant)); - // Add pnpm-config.json file to the potentially changed files list. - potentiallyChangedFiles.push(subspace.getPnpmConfigFilePath()); + // Add the pnpm-config.json file that is in effect for this subspace to the potentially changed files list. + // If the subspace has no pnpm-config.json of its own, this is common/config/rush/pnpm-config.json. + potentiallyChangedFiles.push( + getEffectivePnpmOptions(subspace, this.rushConfiguration).pnpmConfigFilePath + ); if (this.rushConfiguration.isPnpm) { // If the repo is using pnpmfile.js, consider that also diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 1d1eb22c5a6..6fcbb1bf649 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -20,6 +20,7 @@ import { Utilities } from '../../utilities/Utilities'; import type { IConfigurationEnvironment } from '../base/BasePackageManagerOptionsConfiguration'; import type { PnpmOptionsConfiguration } from '../pnpm/PnpmOptionsConfiguration'; import { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; +import { getEffectivePnpmOptions } from '../pnpm/EffectivePnpmOptions'; import { merge } from '../../utilities/objectUtilities'; import { getNpmrcEnvironmentVariables } from '../../utilities/npmrcUtilities'; import type { Subspace } from '../../api/Subspace'; @@ -156,7 +157,7 @@ export class InstallHelpers { // NOTE: the pnpm setting is `trustPolicyIgnoreAfter`, but the rush pnpm setting is `trustPolicyIgnoreAfterMinutes` trustPolicyIgnoreAfterMinutes: trustPolicyIgnoreAfter, unsupportedPackageJsonSettings - } = subspace.getPnpmOptions() || rushConfiguration.pnpmOptions; + } = getEffectivePnpmOptions(subspace, rushConfiguration).pnpmOptions; const pnpmVersion: string = rushConfiguration.packageManagerToolVersion; const isPnpm11: boolean = semver.gte(pnpmVersion, '11.0.0'); diff --git a/libraries/rush-lib/src/logic/pnpm/EffectivePnpmOptions.ts b/libraries/rush-lib/src/logic/pnpm/EffectivePnpmOptions.ts new file mode 100644 index 00000000000..0a8ea3b37fe --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/EffectivePnpmOptions.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RushConfiguration } from '../../api/RushConfiguration'; +import type { Subspace } from '../../api/Subspace'; +import { RushConstants } from '../RushConstants'; +import type { PnpmOptionsConfiguration } from './PnpmOptionsConfiguration'; + +/** + * PNPM catalog definitions, keyed by catalog name and then by package name. + * The default catalog (referenced as `catalog:` or `catalog:default`) uses the key `default`. + */ +export type PnpmCatalogs = Record>; + +/** + * The pnpm-config.json settings that are in effect for a subspace. + */ +export interface IEffectivePnpmOptions { + /** + * The PNPM options that Rush uses when installing the subspace. + */ + readonly pnpmOptions: PnpmOptionsConfiguration; + + /** + * The absolute path of the pnpm-config.json file that `pnpmOptions` was loaded from. + * + * @remarks + * When `isFallback` is true, this is `common/config/rush/pnpm-config.json`. That file might not exist + * if the repo still uses the legacy `pnpmOptions` field in rush.json. + */ + readonly pnpmConfigFilePath: string; + + /** + * True if the subspace does not have a pnpm-config.json of its own, so the options come from + * `common/config/rush/pnpm-config.json` (or the legacy `pnpmOptions` field in rush.json). + * + * @remarks + * When the subspaces feature is disabled, the subspace's own pnpm-config.json path is + * `common/config/rush/pnpm-config.json`, so this is only true if that file does not exist. + */ + readonly isFallback: boolean; +} + +/** + * Returns the pnpm-config.json settings that are in effect for the specified subspace. + * + * @remarks + * This mirrors the fallback that install uses: the subspace's own pnpm-config.json + * (see {@link Subspace.getPnpmConfigFilePath}) if it exists, otherwise + * {@link RushConfiguration.pnpmOptions}. Every consumer of the PNPM settings for a subspace + * (install, the repo-state.json catalog hash, change detection, and `rush-pnpm` sync-back) + * must use this function so that they cannot disagree. + */ +export function getEffectivePnpmOptions( + subspace: Subspace, + rushConfiguration: RushConfiguration +): IEffectivePnpmOptions { + const subspacePnpmOptions: PnpmOptionsConfiguration | undefined = subspace.getPnpmOptions(); + if (subspacePnpmOptions) { + return { + pnpmOptions: subspacePnpmOptions, + pnpmConfigFilePath: subspace.getPnpmConfigFilePath(), + isFallback: false + }; + } + + return { + pnpmOptions: rushConfiguration.pnpmOptions, + pnpmConfigFilePath: getCommonPnpmConfigFilePath(rushConfiguration), + isFallback: true + }; +} + +/** + * Returns the PNPM catalogs that Rush writes to the generated pnpm-workspace.yaml for the + * specified subspace, or `undefined` if no catalogs are defined or the package manager is not PNPM. + */ +export function getEffectivePnpmCatalogs( + subspace: Subspace, + rushConfiguration: RushConfiguration +): PnpmCatalogs | undefined { + if (!rushConfiguration.isPnpm) { + return undefined; + } + + const { globalCatalogs } = getEffectivePnpmOptions(subspace, rushConfiguration).pnpmOptions; + if (!globalCatalogs || Object.keys(globalCatalogs).length === 0) { + return undefined; + } + + return globalCatalogs; +} + +/** + * Returns the other subspaces whose effective pnpm-config.json is the same file as the + * specified subspace's effective pnpm-config.json. The specified subspace is not included. + * + * @remarks + * This can only return a non-empty array when the subspaces feature is enabled and the specified + * subspace falls back to `common/config/rush/pnpm-config.json`. Editing the catalogs in that file + * affects every returned subspace, so each of them needs `rush update` to refresh its lockfile. + */ +export function getOtherSubspacesSharingPnpmConfig( + subspace: Subspace, + rushConfiguration: RushConfiguration +): Subspace[] { + const { pnpmConfigFilePath } = getEffectivePnpmOptions(subspace, rushConfiguration); + const result: Subspace[] = []; + if (!rushConfiguration.subspacesFeatureEnabled) { + return result; + } + + for (const otherSubspace of rushConfiguration.subspaces) { + if ( + otherSubspace !== subspace && + getEffectivePnpmOptions(otherSubspace, rushConfiguration).pnpmConfigFilePath === pnpmConfigFilePath + ) { + result.push(otherSubspace); + } + } + + return result; +} + +/** + * Returns the absolute path of `common/config/rush/pnpm-config.json`. + */ +export function getCommonPnpmConfigFilePath(rushConfiguration: RushConfiguration): string { + return `${rushConfiguration.commonRushConfigFolder}/${RushConstants.pnpmConfigFilename}`; +} + +/** + * Adds the entries of `catalogsToAdd` to `existingCatalogs` without removing any existing catalog or entry. + * If a package is in both, the version from `catalogsToAdd` wins. + * + * @remarks + * Use this instead of replacing the catalogs when the pnpm-config.json is shared by several subspaces + * (see {@link getOtherSubspacesSharingPnpmConfig}), because a subspace's workspace cannot tell which + * entries the other subspaces use. The inputs are not modified. + * + * @returns A new plain object (without the configuration file annotations that loaded catalogs have), + * or `existingCatalogs` if the result would have no catalogs. + */ +export function mergePnpmCatalogs( + existingCatalogs: PnpmCatalogs | undefined, + catalogsToAdd: PnpmCatalogs | undefined +): PnpmCatalogs | undefined { + const result: PnpmCatalogs = {}; + for (const catalogs of [existingCatalogs, catalogsToAdd]) { + for (const [catalogName, packages] of Object.entries(catalogs ?? {})) { + const mergedPackages: Record = (result[catalogName] ??= {}); + // Copy only the string keys; object spread would also copy annotation symbols + for (const [packageName, version] of Object.entries(packages)) { + mergedPackages[packageName] = version; + } + } + } + + return Object.keys(result).length > 0 ? result : existingCatalogs; +} diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts index 898699e671c..6d96f6d5959 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmOptionsConfiguration.ts @@ -215,6 +215,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration readonly #json: JsonObject; readonly #commonTempFolder: string; #globalPatchedDependencies: Record | undefined; + #globalCatalogs: Record> | undefined; /** * The method used to resolve the store used by PNPM. @@ -542,7 +543,9 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration * * PNPM documentation: https://pnpm.io/catalogs */ - public readonly globalCatalogs: Record> | undefined; + public get globalCatalogs(): Record> | undefined { + return this.#globalCatalogs; + } /** * (GENERATED BY RUSH-PNPM PATCH-COMMIT) When modifying this property, make sure you know what you are doing. @@ -608,7 +611,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration this.alwaysInjectDependenciesFromOtherSubspaces = json.alwaysInjectDependenciesFromOtherSubspaces; this.alwaysFullInstall = json.alwaysFullInstall; this.pnpmLockfilePolicies = json.pnpmLockfilePolicies; - this.globalCatalogs = json.globalCatalogs; + this.#globalCatalogs = json.globalCatalogs; } /** @internal */ @@ -706,10 +709,15 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration /** * Updates globalCatalogs field of the PNPM options in the common/config/rush/pnpm-config.json file. + * + * @remarks + * The in-memory {@link PnpmOptionsConfiguration.globalCatalogs} value is also updated, so that a + * subsequent install in the same process writes the new catalogs to pnpm-workspace.yaml. */ public async updateGlobalCatalogsAsync( catalogs: Record> | undefined ): Promise { + this.#globalCatalogs = catalogs; this.#json.globalCatalogs = catalogs; await JsonFile.saveAsync(this.#json, this.#getJsonFilenameOrThrow(), { updateExistingFile: true, diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts index 6945bde6d69..19fe965e400 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts @@ -38,6 +38,7 @@ import { PnpmfileConfiguration } from './PnpmfileConfiguration'; import { PnpmProjectShrinkwrapFile } from './PnpmProjectShrinkwrapFile'; import type { PackageManagerOptionsConfigurationBase } from '../base/BasePackageManagerOptionsConfiguration'; import { PnpmOptionsConfiguration } from './PnpmOptionsConfiguration'; +import { getEffectivePnpmOptions } from './EffectivePnpmOptions'; import type { IPnpmfile, IPnpmfileContext } from './IPnpmfile'; import type { Subspace } from '../../api/Subspace'; import { CustomTipId, type CustomTipsConfiguration } from '../../api/CustomTipsConfiguration'; @@ -514,7 +515,10 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile { subspace: Subspace, terminal: ITerminal ): void { - const pnpmOptions: PnpmOptionsConfiguration = subspace.getPnpmOptions() || rushConfiguration.pnpmOptions; + const pnpmOptions: PnpmOptionsConfiguration = getEffectivePnpmOptions( + subspace, + rushConfiguration + ).pnpmOptions; const { pnpmLockfilePolicies } = pnpmOptions; let invalidPoliciesCount: number = 0; @@ -1294,8 +1298,7 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile { // In a package snapshot, link: paths are resolved relative to the pnpm workspace root, // which means they are already direct keys into the importer map. const targetKey: string = normalizedVersion.slice('link:'.length); - const linkedIntegrities: Map | undefined = - this.getIntegrityForImporter(targetKey); + const linkedIntegrities: Map | undefined = this.getIntegrityForImporter(targetKey); if (linkedIntegrities) { for (const [dep, integrity] of linkedIntegrities) { integrityMap.set(dep, integrity); @@ -1319,9 +1322,7 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile { /** * Gets the package description for a tempProject from the shrinkwrap file. */ - #getPackageDescription( - tempProjectDependencyKey: string - ): IPnpmShrinkwrapDependencyYaml | undefined { + #getPackageDescription(tempProjectDependencyKey: string): IPnpmShrinkwrapDependencyYaml | undefined { const packageDescription: IPnpmShrinkwrapDependencyYaml | undefined = this.packages.get(tempProjectDependencyKey); diff --git a/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts b/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts index b190671a0d0..17bf18a5a57 100644 --- a/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts +++ b/libraries/rush-lib/src/logic/pnpm/SubspacePnpmfileConfiguration.ts @@ -13,6 +13,7 @@ import type { PnpmPackageManager } from '../../api/packageManager/PnpmPackageMan import { RushConstants } from '../RushConstants'; import type { Subspace } from '../../api/Subspace'; import type { PnpmOptionsConfiguration } from './PnpmOptionsConfiguration'; +import { getEffectivePnpmOptions } from './EffectivePnpmOptions'; /** * Loads PNPM's pnpmfile.js configuration, and invokes it to preprocess package.json files, @@ -142,9 +143,11 @@ function _getProjectNameToInjectedDependenciesMap( // and the dependency is not injected yet // and the dependency is in another subspace // then, make this dependency as injected dependency - const pnpmOptions: PnpmOptionsConfiguration | undefined = - subspace.getPnpmOptions() || rushConfiguration.pnpmOptions; - if (pnpmOptions && pnpmOptions.alwaysInjectDependenciesFromOtherSubspaces) { + const pnpmOptions: PnpmOptionsConfiguration = getEffectivePnpmOptions( + subspace, + rushConfiguration + ).pnpmOptions; + if (pnpmOptions.alwaysInjectDependenciesFromOtherSubspaces) { const dependencyProjects: ReadonlySet = subspaceProject.dependencyProjects; for (const dependencyProject of dependencyProjects) { const dependencyName: string = dependencyProject.packageName; diff --git a/libraries/rush-lib/src/logic/pnpm/test/EffectivePnpmOptions.test.ts b/libraries/rush-lib/src/logic/pnpm/test/EffectivePnpmOptions.test.ts new file mode 100644 index 00000000000..5c2621445b2 --- /dev/null +++ b/libraries/rush-lib/src/logic/pnpm/test/EffectivePnpmOptions.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { TestUtilities } from '@rushstack/heft-config-file'; + +import { RushConfiguration } from '../../../api/RushConfiguration'; +import type { Subspace } from '../../../api/Subspace'; +import { + getCommonPnpmConfigFilePath, + getEffectivePnpmCatalogs, + getEffectivePnpmOptions, + getOtherSubspacesSharingPnpmConfig, + type IEffectivePnpmOptions, + mergePnpmCatalogs, + type PnpmCatalogs +} from '../EffectivePnpmOptions'; + +const COMMON_CATALOGS_REPO_RUSH_JSON: string = path.resolve( + __dirname, + '../../test/repoWithSubspacesCommonCatalogs/rush.json' +); +const SUBSPACE_CATALOGS_REPO_RUSH_JSON: string = path.resolve( + __dirname, + '../../test/repoWithSubspacesCatalogs/rush.json' +); +const NO_SUBSPACES_CATALOGS_REPO_RUSH_JSON: string = path.resolve( + __dirname, + '../../../api/test/repoCatalogs/rush.json' +); +const NO_CATALOGS_REPO_RUSH_JSON: string = path.resolve(__dirname, '../../../api/test/repo/rush-pnpm.json'); +const NPM_REPO_RUSH_JSON: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json'); + +function getSubspaceNames(subspaces: readonly Subspace[]): string[] { + return subspaces.map((subspace: Subspace) => subspace.subspaceName).sort(); +} + +describe('EffectivePnpmOptions', () => { + describe(getEffectivePnpmOptions.name, () => { + it('uses common/config/rush/pnpm-config.json when subspaces are disabled', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + NO_SUBSPACES_CATALOGS_REPO_RUSH_JSON + ); + const subspace: Subspace = rushConfiguration.defaultSubspace; + + const effective: IEffectivePnpmOptions = getEffectivePnpmOptions(subspace, rushConfiguration); + expect(effective.isFallback).toBe(false); + expect(effective.pnpmOptions).toBe(subspace.getPnpmOptions()); + expect(effective.pnpmConfigFilePath).toEqual(subspace.getPnpmConfigFilePath()); + expect(effective.pnpmConfigFilePath).toEqual(getCommonPnpmConfigFilePath(rushConfiguration)); + expect(effective.pnpmOptions.globalCatalogs).toEqual(rushConfiguration.pnpmOptions.globalCatalogs); + }); + + it("uses the subspace's own pnpm-config.json when it exists", () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + SUBSPACE_CATALOGS_REPO_RUSH_JSON + ); + const subspace: Subspace = rushConfiguration.getSubspace('project-change-analyzer-test-subspace'); + + const effective: IEffectivePnpmOptions = getEffectivePnpmOptions(subspace, rushConfiguration); + expect(effective.isFallback).toBe(false); + expect(effective.pnpmOptions).toBe(subspace.getPnpmOptions()); + expect(effective.pnpmConfigFilePath).toEqual(subspace.getPnpmConfigFilePath()); + expect(effective.pnpmConfigFilePath).not.toEqual(getCommonPnpmConfigFilePath(rushConfiguration)); + expect(TestUtilities.stripAnnotations(effective.pnpmOptions.globalCatalogs)).toEqual({ + default: { foo: '~2.0.0', bar: '^3.0.0' }, + tools: { typescript: '~5.3.0', eslint: '^8.50.0' } + }); + }); + + it('falls back to common/config/rush/pnpm-config.json for a subspace without its own pnpm-config.json', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + COMMON_CATALOGS_REPO_RUSH_JSON + ); + + for (const subspaceName of ['default', 'other-subspace']) { + const subspace: Subspace = rushConfiguration.getSubspace(subspaceName); + expect(subspace.getPnpmOptions()).toBeUndefined(); + + const effective: IEffectivePnpmOptions = getEffectivePnpmOptions(subspace, rushConfiguration); + expect(effective.isFallback).toBe(true); + expect(effective.pnpmOptions).toBe(rushConfiguration.pnpmOptions); + expect(effective.pnpmConfigFilePath).toEqual(getCommonPnpmConfigFilePath(rushConfiguration)); + expect(effective.pnpmConfigFilePath).not.toEqual(subspace.getPnpmConfigFilePath()); + } + + const ownConfigSubspace: Subspace = rushConfiguration.getSubspace('own-config-subspace'); + const ownEffective: IEffectivePnpmOptions = getEffectivePnpmOptions( + ownConfigSubspace, + rushConfiguration + ); + expect(ownEffective.isFallback).toBe(false); + expect(ownEffective.pnpmOptions).toBe(ownConfigSubspace.getPnpmOptions()); + expect(ownEffective.pnpmConfigFilePath).toEqual(ownConfigSubspace.getPnpmConfigFilePath()); + }); + }); + + describe(getEffectivePnpmCatalogs.name, () => { + it('returns the common catalogs for subspaces without their own pnpm-config.json', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + COMMON_CATALOGS_REPO_RUSH_JSON + ); + const expectedCatalogs: PnpmCatalogs = { + default: { react: '^18.0.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' } + }; + + const getCatalogs = (subspaceName: string): PnpmCatalogs | undefined => + TestUtilities.stripAnnotations( + getEffectivePnpmCatalogs(rushConfiguration.getSubspace(subspaceName), rushConfiguration) + ); + + expect(getCatalogs('default')).toEqual(expectedCatalogs); + expect(getCatalogs('other-subspace')).toEqual(expectedCatalogs); + expect(getCatalogs('own-config-subspace')).toEqual({ default: { react: '^17.0.0' } }); + }); + + it('returns undefined when no catalogs are defined', () => { + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(NO_CATALOGS_REPO_RUSH_JSON); + expect(getEffectivePnpmCatalogs(rushConfiguration.defaultSubspace, rushConfiguration)).toBeUndefined(); + }); + + it('returns undefined when the package manager is not PNPM', () => { + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(NPM_REPO_RUSH_JSON); + expect(getEffectivePnpmCatalogs(rushConfiguration.defaultSubspace, rushConfiguration)).toBeUndefined(); + }); + }); + + describe(getOtherSubspacesSharingPnpmConfig.name, () => { + it('returns the other subspaces that fall back to the common pnpm-config.json', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + COMMON_CATALOGS_REPO_RUSH_JSON + ); + + expect( + getSubspaceNames( + getOtherSubspacesSharingPnpmConfig(rushConfiguration.getSubspace('default'), rushConfiguration) + ) + ).toEqual(['other-subspace']); + expect( + getSubspaceNames( + getOtherSubspacesSharingPnpmConfig( + rushConfiguration.getSubspace('other-subspace'), + rushConfiguration + ) + ) + ).toEqual(['default']); + expect( + getOtherSubspacesSharingPnpmConfig( + rushConfiguration.getSubspace('own-config-subspace'), + rushConfiguration + ) + ).toEqual([]); + }); + + it('returns an empty array when every subspace has its own pnpm-config.json', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + SUBSPACE_CATALOGS_REPO_RUSH_JSON + ); + for (const subspace of rushConfiguration.subspaces) { + expect(getOtherSubspacesSharingPnpmConfig(subspace, rushConfiguration)).toEqual([]); + } + }); + + it('returns an empty array when subspaces are disabled', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + NO_SUBSPACES_CATALOGS_REPO_RUSH_JSON + ); + expect( + getOtherSubspacesSharingPnpmConfig(rushConfiguration.defaultSubspace, rushConfiguration) + ).toEqual([]); + }); + }); + + describe(mergePnpmCatalogs.name, () => { + it('adds and updates entries without removing any', () => { + const existingCatalogs: PnpmCatalogs = { + default: { react: '^18.0.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' } + }; + const catalogsToAdd: PnpmCatalogs = { + default: { react: '^18.3.0' }, + lint: { eslint: '^9.0.0' } + }; + + expect(mergePnpmCatalogs(existingCatalogs, catalogsToAdd)).toEqual({ + default: { react: '^18.3.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' }, + lint: { eslint: '^9.0.0' } + }); + + // The inputs are not modified + expect(existingCatalogs).toEqual({ + default: { react: '^18.0.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' } + }); + expect(catalogsToAdd).toEqual({ default: { react: '^18.3.0' }, lint: { eslint: '^9.0.0' } }); + }); + + it('handles undefined inputs', () => { + const catalogs: PnpmCatalogs = { default: { react: '^18.0.0' } }; + expect(mergePnpmCatalogs(undefined, undefined)).toBeUndefined(); + expect(mergePnpmCatalogs(undefined, {})).toBeUndefined(); + expect(mergePnpmCatalogs(catalogs, undefined)).toEqual(catalogs); + expect(mergePnpmCatalogs(undefined, catalogs)).toEqual(catalogs); + }); + + it('does not copy the configuration file annotations of loaded catalogs', () => { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + COMMON_CATALOGS_REPO_RUSH_JSON + ); + const loadedCatalogs: PnpmCatalogs | undefined = rushConfiguration.pnpmOptions.globalCatalogs; + expect(Object.getOwnPropertySymbols(loadedCatalogs!.default)).not.toHaveLength(0); + + const mergedCatalogs: PnpmCatalogs | undefined = mergePnpmCatalogs(loadedCatalogs, { + default: { react: '^18.3.0' } + }); + expect(mergedCatalogs).toEqual({ + default: { react: '^18.3.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' } + }); + }); + }); +}); diff --git a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts index 9f6eda0d499..faf0ed0b2dd 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { execFileSync } from 'node:child_process'; +import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -15,6 +16,8 @@ import { RushConfiguration } from '../../api/RushConfiguration'; import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; import { Utilities } from '../../utilities/Utilities'; import type { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; +import type { Subspace } from '../../api/Subspace'; +import { getEffectivePnpmCatalogs } from '../pnpm/EffectivePnpmOptions'; describe(InstallHelpers.name, () => { describe(InstallHelpers.shouldProvideNpmrcCredentialsViaEnvironment.name, () => { @@ -390,4 +393,36 @@ describe(InstallHelpers.name, () => { expect(workspaceFile?.globalPnpmfile).toBeUndefined(); }); }); + + describe(InstallHelpers.resolvePnpmSettings.name, () => { + it('writes the same catalogs that the repo-state.json catalog hash is computed from', () => { + const RUSH_JSON_FILENAME: string = `${__dirname}/repoWithSubspacesCommonCatalogs/rush.json`; + const rushConfiguration: RushConfiguration = + RushConfiguration.loadFromConfigurationFile(RUSH_JSON_FILENAME); + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider()); + + const expectedCatalogsBySubspaceName: Map = new Map([ + // These subspaces don't have a pnpm-config.json, so install uses common/config/rush/pnpm-config.json + ['default', TestUtilities.stripAnnotations(rushConfiguration.pnpmOptions.globalCatalogs)], + ['other-subspace', TestUtilities.stripAnnotations(rushConfiguration.pnpmOptions.globalCatalogs)], + ['own-config-subspace', { default: { react: '^17.0.0' } }] + ]); + + for (const [subspaceName, expectedCatalogs] of expectedCatalogsBySubspaceName) { + const subspace: Subspace = rushConfiguration.getSubspace(subspaceName); + const pnpmSettings = InstallHelpers.resolvePnpmSettings(rushConfiguration, subspace, terminal); + const catalogs: Record> | undefined = + TestUtilities.stripAnnotations(pnpmSettings)?.workspaceFile.catalogs; + + expect(catalogs).toBeDefined(); + expect(catalogs).toEqual(expectedCatalogs); + expect(catalogs).toEqual( + TestUtilities.stripAnnotations(getEffectivePnpmCatalogs(subspace, rushConfiguration)) + ); + expect(subspace.getPnpmCatalogsHash()).toEqual( + crypto.createHash('sha1').update(JSON.stringify(catalogs)).digest('hex') + ); + } + }); + }); }); diff --git a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts index 618bd4c6948..7c678010442 100644 --- a/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts +++ b/libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts @@ -715,10 +715,7 @@ describe(ProjectChangeAnalyzer.name, () => { mockGetRepoChanges.mockReturnValue( new Map([ - [ - 'b/package.json', - { mode: 'modified', newhash: 'newhash-b', oldhash: 'oldhash-b', status: 'M' } - ] + ['b/package.json', { mode: 'modified', newhash: 'newhash-b', oldhash: 'oldhash-b', status: 'M' }] ]) ); const packageJsonByHash: Record = { @@ -1501,6 +1498,166 @@ describe(ProjectChangeAnalyzer.name, () => { expect(changedProjects.has(rushConfiguration.getProjectByName('f')!)).toBe(false); }); }); + + describe('catalog change detection for subspaces that use common/config/rush/pnpm-config.json', () => { + const COMMON_PNPM_CONFIG_PATH: string = 'common/config/rush/pnpm-config.json'; + const OWN_CONFIG_SUBSPACE_PNPM_CONFIG_PATH: string = + 'common/config/subspaces/own-config-subspace/pnpm-config.json'; + + function getCommonCatalogRushConfiguration(): RushConfiguration { + return RushConfiguration.loadFromConfigurationFile( + resolve(__dirname, 'repoWithSubspacesCommonCatalogs', 'rush.json') + ); + } + + /** + * Mocks the merge-base contents of the specified files. Any other file did not exist in the merge-base. + */ + function mockMergeBaseFiles(filesByPath: Record): void { + mockGetBlobContentAsync.mockImplementation((opts: { blobSpec: string; repositoryRoot: string }) => { + const filePath: string = opts.blobSpec.slice(opts.blobSpec.indexOf(':') + 1); + const file: object | undefined = filesByPath[filePath]; + return file + ? Promise.resolve(JSON.stringify(file)) + : Promise.reject(new Error(`Path '${filePath}' does not exist`)); + }); + } + + async function getChangedProjectNamesAsync( + rushConfiguration: RushConfiguration, + terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true) + ): Promise { + const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration); + const changedProjects: Set = + await projectChangeAnalyzer.getChangedProjectsAsync({ + enableFiltering: false, + includeExternalDependencies: false, + targetBranchName: 'main', + terminal: new Terminal(terminalProvider) + }); + return Array.from(changedProjects, (project: RushConfigurationProject) => project.packageName).sort(); + } + + it('detects changes to common/config/rush/pnpm-config.json for subspaces without their own pnpm-config.json', async () => { + const rushConfiguration: RushConfiguration = getCommonCatalogRushConfiguration(); + + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + COMMON_PNPM_CONFIG_PATH, + { mode: 'modified', newhash: 'newhash', oldhash: 'oldhash', status: 'M' } + ] + ]) + ); + + // react was bumped in the common default catalog; foo and the tools catalog are unchanged + mockMergeBaseFiles({ + [COMMON_PNPM_CONFIG_PATH]: { + useWorkspaces: true, + globalCatalogs: { + default: { react: '^17.0.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' } + } + } + }); + + // 'a' (default subspace) uses "catalog:" for react. 'e' also uses "catalog:" for react, but + // own-config-subspace reads its catalogs from its own pnpm-config.json, so it is not affected. + expect(await getChangedProjectNamesAsync(rushConfiguration)).toEqual(['a']); + + // The mocked Git treats "main" as a commit, so it is used as the merge commit + const requestedBlobSpecs: string[] = mockGetBlobContentAsync.mock.calls.map( + ([opts]: [{ blobSpec: string }]) => opts.blobSpec + ); + expect(requestedBlobSpecs).toContain(`main:${COMMON_PNPM_CONFIG_PATH}`); + // own-config-subspace's pnpm-config.json did not change, so its catalogs are not diffed + expect(requestedBlobSpecs).not.toContain(`main:${OWN_CONFIG_SUBSPACE_PNPM_CONFIG_PATH}`); + }); + + it('detects changes to the common catalogs in every subspace that uses them', async () => { + const rushConfiguration: RushConfiguration = getCommonCatalogRushConfiguration(); + + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + COMMON_PNPM_CONFIG_PATH, + { mode: 'modified', newhash: 'newhash', oldhash: 'oldhash', status: 'M' } + ] + ]) + ); + + // foo (default catalog) and typescript (tools catalog) were bumped; react is unchanged + mockMergeBaseFiles({ + [COMMON_PNPM_CONFIG_PATH]: { + useWorkspaces: true, + globalCatalogs: { + default: { react: '^18.0.0', foo: '~1.0.0' }, + tools: { typescript: '~5.2.0' } + } + } + }); + + // 'b' (default subspace) uses "catalog:tools" for typescript; 'c' (other-subspace) uses "catalog:" for foo + expect(await getChangedProjectNamesAsync(rushConfiguration)).toEqual(['b', 'c']); + }); + + it("compares against the common pnpm-config.json when a subspace's own pnpm-config.json was added", async () => { + const rushConfiguration: RushConfiguration = getCommonCatalogRushConfiguration(); + + mockGetRepoChanges.mockReturnValue( + new Map([ + [ + OWN_CONFIG_SUBSPACE_PNPM_CONFIG_PATH, + { mode: 'added', newhash: 'newhash', oldhash: '', status: 'A' } + ] + ]) + ); + + // Before own-config-subspace had a pnpm-config.json, it used the common catalogs, where react + // was already "^17.0.0". The new file only drops entries that 'e' does not use. + mockMergeBaseFiles({ + [COMMON_PNPM_CONFIG_PATH]: { + useWorkspaces: true, + globalCatalogs: { + default: { react: '^17.0.0', foo: '~2.0.0' }, + tools: { typescript: '~5.3.0' } + } + } + }); + + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + expect(await getChangedProjectNamesAsync(rushConfiguration, terminalProvider)).toEqual([]); + expect(terminalProvider.getWarningOutput()).not.toContain('was created or unparseable'); + }); + + it('assumes all catalog dependencies changed when common/config/rush/pnpm-config.json was added', async () => { + const rushConfiguration: RushConfiguration = getCommonCatalogRushConfiguration(); + + mockGetRepoChanges.mockReturnValue( + new Map([ + [COMMON_PNPM_CONFIG_PATH, { mode: 'added', newhash: 'newhash', oldhash: '', status: 'A' }] + ]) + ); + + // No pnpm-config.json existed in the merge-base + mockMergeBaseFiles({}); + + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + // 'a', 'b', and 'c' use catalogs from the common pnpm-config.json; 'd' has no catalog deps; + // 'e' uses the catalogs from own-config-subspace's pnpm-config.json, which did not change. + expect(await getChangedProjectNamesAsync(rushConfiguration, terminalProvider)).toEqual([ + 'a', + 'b', + 'c' + ]); + const warningOutput: string = terminalProvider.getWarningOutput(); + expect(warningOutput).toContain('"default" subspace pnpm-config.json was created or unparseable'); + expect(warningOutput).toContain( + '"other-subspace" subspace pnpm-config.json was created or unparseable' + ); + expect(warningOutput).not.toContain('"own-config-subspace"'); + }); + }); }); describe('isPackageJsonVersionOnlyChange', () => { diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/a/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/a/package.json new file mode 100644 index 00000000000..5c102c007ba --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/a/package.json @@ -0,0 +1,8 @@ +{ + "name": "a", + "version": "1.0.0", + "description": "Test package a", + "dependencies": { + "react": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/b/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/b/package.json new file mode 100644 index 00000000000..f662bd13865 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/b/package.json @@ -0,0 +1,8 @@ +{ + "name": "b", + "version": "1.0.0", + "description": "Test package b", + "devDependencies": { + "typescript": "catalog:tools" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/c/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/c/package.json new file mode 100644 index 00000000000..03d6d9935e7 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/c/package.json @@ -0,0 +1,8 @@ +{ + "name": "c", + "version": "1.0.0", + "description": "Test package c", + "dependencies": { + "foo": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/experiments.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/experiments.json new file mode 100644 index 00000000000..a20c6d24388 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/experiments.json @@ -0,0 +1,3 @@ +{ + "exemptDecoupledDependenciesBetweenSubspaces": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/pnpm-config.json new file mode 100644 index 00000000000..cede4f94767 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/pnpm-config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "useWorkspaces": true, + "globalCatalogs": { + "default": { + "react": "^18.0.0", + "foo": "~2.0.0" + }, + "tools": { + "typescript": "~5.3.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/subspaces.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/subspaces.json new file mode 100644 index 00000000000..9b41e2bfe21 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/subspaces.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/subspaces.schema.json", + "subspacesEnabled": true, + "subspaceNames": ["other-subspace", "own-config-subspace"] +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/version-policies.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/version-policies.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/rush/version-policies.json @@ -0,0 +1 @@ +[] diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/default/common-versions.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/default/common-versions.json new file mode 100644 index 00000000000..9280fe7b96d --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/default/common-versions.json @@ -0,0 +1,8 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "ensureConsistentVersions": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/other-subspace/common-versions.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/other-subspace/common-versions.json new file mode 100644 index 00000000000..9280fe7b96d --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/other-subspace/common-versions.json @@ -0,0 +1,8 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "ensureConsistentVersions": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/own-config-subspace/common-versions.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/own-config-subspace/common-versions.json new file mode 100644 index 00000000000..9280fe7b96d --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/own-config-subspace/common-versions.json @@ -0,0 +1,8 @@ +/** + * This configuration file specifies NPM dependency version selections that affect all projects + * in a Rush repo. More documentation is available on the Rush website: https://rushjs.io + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json", + "ensureConsistentVersions": true +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/own-config-subspace/pnpm-config.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/own-config-subspace/pnpm-config.json new file mode 100644 index 00000000000..949c6c7eab3 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/common/config/subspaces/own-config-subspace/pnpm-config.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json", + "globalCatalogs": { + "default": { + "react": "^17.0.0" + } + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/d/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/d/package.json new file mode 100644 index 00000000000..16150472ae8 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/d/package.json @@ -0,0 +1,8 @@ +{ + "name": "d", + "version": "1.0.0", + "description": "Test package d", + "dependencies": { + "lodash": "^4.17.0" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/e/package.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/e/package.json new file mode 100644 index 00000000000..55df4b0048a --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/e/package.json @@ -0,0 +1,8 @@ +{ + "name": "e", + "version": "1.0.0", + "description": "Test package e", + "dependencies": { + "react": "catalog:" + } +} diff --git a/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/rush.json b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/rush.json new file mode 100644 index 00000000000..67dfb61bef7 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/repoWithSubspacesCommonCatalogs/rush.json @@ -0,0 +1,30 @@ +{ + "rushVersion": "1.0.5", + "pnpmVersion": "9.15.0", + + "projects": [ + { + "packageName": "a", + "projectFolder": "a" + }, + { + "packageName": "b", + "projectFolder": "b" + }, + { + "packageName": "c", + "projectFolder": "c", + "subspaceName": "other-subspace" + }, + { + "packageName": "d", + "projectFolder": "d", + "subspaceName": "other-subspace" + }, + { + "packageName": "e", + "projectFolder": "e", + "subspaceName": "own-config-subspace" + } + ] +}