Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 1 addition & 1 deletion common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1693,7 +1693,7 @@ export class PnpmOptionsConfiguration extends PackageManagerOptionsConfiguration
readonly autoInstallPeers: boolean | undefined;
readonly globalAllowBuilds: Record<string, boolean> | undefined;
readonly globalAllowedDeprecatedVersions: Record<string, string> | undefined;
readonly globalCatalogs: Record<string, Record<string, string>> | undefined;
get globalCatalogs(): Record<string, Record<string, string>> | undefined;
readonly globalIgnoredOptionalDependencies: string[] | undefined;
readonly globalNeverBuiltDependencies: string[] | undefined;
readonly globalOnlyBuiltDependencies: string[] | undefined;
Expand Down
24 changes: 11 additions & 13 deletions libraries/rush-lib/src/api/Subspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown> = {};
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<string, Record<string, string>> | 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');
}

Expand Down
30 changes: 30 additions & 0 deletions libraries/rush-lib/src/api/test/Subspace.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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, () => {
Expand Down
78 changes: 68 additions & 10 deletions libraries/rush-lib/src/cli/RushPnpmCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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/<subspace>/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<string, Record<string, string>> } = (yamlModule.load(
workspaceYamlContent
) ?? {}) as { catalogs?: Record<string, Record<string, string>> };
const newGlobalCatalogs: Record<string, Record<string, string>> | undefined = workspaceYaml?.catalogs;
const currentGlobalCatalogs: Record<string, Record<string, string>> | 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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -830,3 +864,27 @@ export class RushPnpmCommandLineParser {
}
}
}

/**
* The catalog fields of a pnpm-workspace.yaml file.
*/
interface IPnpmWorkspaceYamlCatalogs {
catalog?: Record<string, string>;
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 } };
}
163 changes: 163 additions & 0 deletions libraries/rush-lib/src/cli/test/RushPnpmCommandLineParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <subspaceName> up" after PNPM wrote the specified pnpm-workspace.yaml.
*/
async function runUpCatalogSyncAsync(
subspaceName: string,
workspaceYamlLines: string[]
): Promise<ICatalogSyncResult> {
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<Record<string, Record<string, string>> | undefined> {
const pnpmConfig: { globalCatalogs?: Record<string, Record<string, string>> } =
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<string, Record<string, string>> = {
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();
Expand Down
Loading
Loading