Skip to content
Merged
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 repo-state.json, change detection, and \"rush-pnpm update\" ignoring the PNPM catalogs of subspaces that use common/config/rush/pnpm-config.json.",
"type": "patch"
}
],
"packageName": "@microsoft/rush",
"email": "[email protected]"
}
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
23 changes: 9 additions & 14 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 @@ -411,27 +412,21 @@ export class Subspace {
}

/**
* Computes a hash of the PNPM catalog definitions for this subspace.
* Computes a hash of the PNPM catalogs that install uses for this subspace, which come from
* common/config/rush/pnpm-config.json if the subspace has no pnpm-config.json of its own.
* Returns undefined if no catalogs are defined.
*/
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
63 changes: 55 additions & 8 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 @@ -752,20 +758,32 @@ export class RushPnpmCommandLineParser {
// 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) {
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 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);

// This subspace's workspace can't show which entries other subspaces sharing the file use,
// so never remove any
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 +793,18 @@ export class RushPnpmCommandLineParser {
`Rush refreshed the ${RushConstants.pnpmConfigFilename} and shrinkwrap file.\n` +
' Please commit this change to Git.'
);

if (subspacesSharingPnpmConfig.length > 0) {
// Their lockfiles still have the old catalogs, so their next "rush install" would fail
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 +860,20 @@ export class RushPnpmCommandLineParser {
}
}
}

interface IPnpmWorkspaceYamlCatalogs {
catalog?: Record<string, string>;
catalogs?: PnpmCatalogs;
}

/**
* Rush writes "catalogs", but PNPM may add default catalog entries 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 } };
}
113 changes: 113 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,119 @@ 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`;
// Only "own-config-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`;

async function runUpCatalogSyncAsync(
subspaceName: string,
workspaceYamlLines: string[]
): Promise<{ rushConfiguration: RushConfiguration; warningOutput: string }> {
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();
await RushPnpmCommandLineParser._postExecuteForTestingAsync({
commandName: 'up',
doRushUpdateAsync: async () => {},
rushConfiguration,
subspace,
terminal: new Terminal(terminalProvider)
});

return { rushConfiguration, 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 to the common pnpm-config.json and warns about the other subspaces that use it', async () => {
const ownPnpmConfigContent: string = await FileSystem.readFileAsync(OWN_PNPM_CONFIG_FILENAME);

const { rushConfiguration, 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' }
};
expect(await loadGlobalCatalogsAsync(COMMON_PNPM_CONFIG_FILENAME)).toEqual(expectedCatalogs);
expect(rushConfiguration.pnpmOptions.globalCatalogs).toEqual(expectedCatalogs);
expect(await FileSystem.readFileAsync(OWN_PNPM_CONFIG_FILENAME)).toEqual(ownPnpmConfigContent);
expect(warningOutput).toContain('rush update --subspace other-subspace');
expect(warningOutput).not.toContain('own-config-subspace');
});

it('never removes catalog entries from a pnpm-config.json that is shared by several subspaces', async () => {
await runUpCatalogSyncAsync('other-subspace', [
'packages:',
' - ../../../c',
'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' }
});
});

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 { 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(await FileSystem.readFileAsync(COMMON_PNPM_CONFIG_FILENAME)).toEqual(commonPnpmConfigContent);
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
63 changes: 47 additions & 16 deletions libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -620,7 +625,7 @@ export class ProjectChangeAnalyzer {
}

/**
* Detects changes to pnpm catalog entries in a subspace's pnpm-config.json and marks
* Detects changes to the pnpm catalog entries that install uses for a subspace and marks
* affected projects as changed.
*/
async #detectCatalogChangesAsync(
Expand All @@ -632,29 +637,55 @@ export class ProjectChangeAnalyzer {
terminal: ITerminal,
changedProjects: Set<RushConfigurationProject>
): Promise<void> {
const pnpmOptions: PnpmOptionsConfiguration | undefined = subspace.getPnpmOptions();
const effectivePnpmOptions: IEffectivePnpmOptions = getEffectivePnpmOptions(subspace, rushConfiguration);
// Default to an empty object if no global catalogs are configured, handle case of globalCatalogs being deleted
const currentCatalogs: Record<string, Record<string, string>> = pnpmOptions?.globalCatalogs ?? {};

const pnpmConfigRelativePath: string = Path.convertToSlashes(
path.relative(repoRoot, subspace.getPnpmConfigFilePath())
const currentCatalogs: Record<string, Record<string, string>> = effectivePnpmOptions.pnpmOptions
.globalCatalogs ?? {};

const toRepoRelativePath = (filePath: string): string =>
Path.convertToSlashes(path.relative(repoRoot, filePath));
const subspacePnpmConfigRelativePath: string = toRepoRelativePath(subspace.getPnpmConfigFilePath());
const commonPnpmConfigRelativePath: string = toRepoRelativePath(
getCommonPnpmConfigFilePath(rushConfiguration)
);

if (!changedFiles.has(pnpmConfigRelativePath)) {
// Check the subspace's own file even if deleted, since deleting it switches the subspace to the common file
if (
!changedFiles.has(subspacePnpmConfigRelativePath) &&
!(effectivePnpmOptions.isFallback && changedFiles.has(commonPnpmConfigRelativePath))
) {
return;
}

// Determine which specific packages changed within each catalog namespace
// Maps catalogNamespace (e.g. "default", "react17") → Set of changed package names
let oldCatalogs: Record<string, Record<string, string>> | undefined;
try {
const oldPnpmConfigText: string = await this.#git.getBlobContentAsync({
blobSpec: `${mergeCommit}:${pnpmConfigRelativePath}`,
repositoryRoot: repoRoot
});
const oldPnpmConfig: IPnpmOptionsJson = JsonFile.parseString(oldPnpmConfigText);
oldCatalogs = oldPnpmConfig.globalCatalogs ?? {};
} catch {
// Use whichever file was in effect at the merge commit
const oldPnpmConfigCandidatePaths: Set<string> = new Set([
subspacePnpmConfigRelativePath,
commonPnpmConfigRelativePath
]);
for (const oldPnpmConfigRelativePath of oldPnpmConfigCandidatePaths) {
let oldPnpmConfigText: string;
try {
oldPnpmConfigText = await this.#git.getBlobContentAsync({
blobSpec: `${mergeCommit}:${oldPnpmConfigRelativePath}`,
repositoryRoot: repoRoot
});
} catch {
continue;
}

try {
const oldPnpmConfig: IPnpmOptionsJson = JsonFile.parseString(oldPnpmConfigText);
oldCatalogs = oldPnpmConfig.globalCatalogs ?? {};
} catch {
// Unparseable: 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(
Expand Down
5 changes: 4 additions & 1 deletion libraries/rush-lib/src/logic/base/BaseInstallManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -427,7 +428,9 @@ export abstract class BaseInstallManager {
potentiallyChangedFiles.push(subspace.getCommonVersionsFilePath(variant));

// Add pnpm-config.json file to the potentially changed files list.
potentiallyChangedFiles.push(subspace.getPnpmConfigFilePath());
potentiallyChangedFiles.push(
getEffectivePnpmOptions(subspace, this.rushConfiguration).pnpmConfigFilePath
);

if (this.rushConfiguration.isPnpm) {
// If the repo is using pnpmfile.js, consider that also
Expand Down
Loading
Loading