Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Add support for pnpm global catalog detection to `rush change`. Now, when a dependencyis changed in the pnpm global catalog, changelogs will be required for affected published packages.",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
168 changes: 153 additions & 15 deletions libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type { RushConfigurationProject } from '../api/RushConfigurationProject';
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 IInputsSnapshotProjectMetadata,
type IInputsSnapshot,
Expand Down Expand Up @@ -178,26 +180,42 @@ export class ProjectChangeAnalyzer {
{ concurrency: 10 }
);

// External dependency changes are not allowed to be filtered, so add these after filtering
if (includeExternalDependencies) {
// Even though changing the installed version of a nested dependency merits a change file,
// ignore lockfile changes for `rush change` for the moment
// Detect per-subspace changes: catalog entries in pnpm-config.json and external dependency lockfiles
const subspaces: Iterable<Subspace> = rushConfiguration.subspacesFeatureEnabled
? rushConfiguration.subspaces
: [rushConfiguration.defaultSubspace];

const subspaces: Iterable<Subspace> = rushConfiguration.subspacesFeatureEnabled
? rushConfiguration.subspaces
: [rushConfiguration.defaultSubspace];
const variantToUse: string | undefined = includeExternalDependencies
? (variant ?? (await this._rushConfiguration.getCurrentlyInstalledVariantAsync()))
: undefined;

const variantToUse: string | undefined =
variant ?? (await this._rushConfiguration.getCurrentlyInstalledVariantAsync());
await Async.forEachAsync(subspaces, async (subspace: Subspace) => {
const subspaceProjects: RushConfigurationProject[] = subspace.getProjects();

// Detect changes to pnpm catalog entries in pnpm-config.json
if (rushConfiguration.isPnpm) {
await this._detectCatalogChangesAsync(
subspace,
rushConfiguration,
changedFiles,
mergeCommit,
repoRoot,
terminal,
changedProjects
);
}

// External dependency changes are not allowed to be filtered, so add these after filtering
if (includeExternalDependencies) {
// Even though changing the installed version of a nested dependency merits a change file,
// ignore lockfile changes for `rush change` for the moment

await Async.forEachAsync(subspaces, async (subspace: Subspace) => {
const fullShrinkwrapPath: string = subspace.getCommittedShrinkwrapFilePath(variantToUse);

const relativeShrinkwrapFilePath: string = Path.convertToSlashes(
path.relative(repoRoot, fullShrinkwrapPath)
);
const shrinkwrapStatus: IFileDiffStatus | undefined = changedFiles.get(relativeShrinkwrapFilePath);
const subspaceProjects: RushConfigurationProject[] = subspace.getProjects();

if (shrinkwrapStatus) {
if (shrinkwrapStatus.status !== 'M') {
Expand All @@ -215,7 +233,7 @@ export class ProjectChangeAnalyzer {
}

if (rushConfiguration.isPnpm) {
const subspaceHasNoProjects: boolean = subspace.getProjects().length === 0;
const subspaceHasNoProjects: boolean = subspaceProjects.length === 0;
const currentShrinkwrap: PnpmShrinkwrapFile | undefined = PnpmShrinkwrapFile.loadFromFile(
fullShrinkwrapPath,
{ subspaceHasNoProjects }
Expand Down Expand Up @@ -253,12 +271,12 @@ export class ProjectChangeAnalyzer {
`Lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.`
);
}
subspace.getProjects().forEach((project) => changedProjects.add(project));
subspaceProjects.forEach((project) => changedProjects.add(project));
return;
}
}
});
}
}
});

// Sort the set by projectRelativeFolder to avoid race conditions in the results
const sortedChangedProjects: RushConfigurationProject[] = Array.from(changedProjects);
Expand Down Expand Up @@ -491,6 +509,126 @@ export class ProjectChangeAnalyzer {
return ignoreMatcher;
}
}

/**
* Detects changes to pnpm catalog entries in a subspace's pnpm-config.json and marks
* affected projects as changed.
*/
private async _detectCatalogChangesAsync(
subspace: Subspace,
rushConfiguration: RushConfiguration,
changedFiles: Map<string, IFileDiffStatus>,
mergeCommit: string,
repoRoot: string,
terminal: ITerminal,
changedProjects: Set<RushConfigurationProject>
): Promise<void> {
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<string, Record<string, string>> = pnpmOptions?.globalCatalogs ?? {};

const pnpmConfigRelativePath: string = Path.convertToSlashes(
path.relative(repoRoot, subspace.getPnpmConfigFilePath())
);

if (!changedFiles.has(pnpmConfigRelativePath)) {
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 = JSON.parse(oldPnpmConfigText);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CarltonHowell I think this path is currently parsing pnpm-config.json with JSON.parse, which does not allow comments. In many Rush repos this file is JSONC-style (with comments), so this branch may incorrectly fall into the “created or unparseable” path and report all projects as affected.

Also, should this message use writeWarningLine instead of writeLine? Right now it goes to stdout, which can break consumers expecting pure JSON output from list --json.

oldCatalogs = oldPnpmConfig.globalCatalogs ?? {};
} catch {
// Old file didn't exist or was unparseable — treat all packages in all current catalogs as changed
if (rushConfiguration.subspacesFeatureEnabled) {
terminal.writeLine(
`"${subspace.subspaceName}" subspace pnpm-config.json was created or unparseable. Assuming all projects are affected.`
);
} else {
terminal.writeLine(
`pnpm-config.json was created or unparseable. Assuming all projects are affected.`
);
}
}

const changedCatalogPackages: Map<string, Set<string>> = new Map();
const currentCatalogEntries: Map<string, Record<string, string>> = new Map(
Object.entries(currentCatalogs)
);

if (oldCatalogs === undefined) {
// Could not load old catalogs — treat all packages in all current catalogs as changed
for (const [catalogName, packages] of currentCatalogEntries) {
changedCatalogPackages.set(catalogName, new Set(Object.keys(packages)));
}
} else {
// Check current catalogs for new or modified package entries
for (const [catalogName, packages] of currentCatalogEntries) {
const oldPackages: Record<string, string> | undefined = oldCatalogs[catalogName];
if (!oldPackages) {
// Entire catalog is new — all packages in it are changed
changedCatalogPackages.set(catalogName, new Set(Object.keys(packages)));
continue;
}
const changedPackages: Set<string> = new Set();
for (const [pkgName, version] of Object.entries(packages)) {
if (oldPackages[pkgName] !== version) {
changedPackages.add(pkgName);
}
}
// Check for packages that were removed from this catalog
for (const pkgName of Object.keys(oldPackages)) {
if (!Object.prototype.hasOwnProperty.call(packages, pkgName)) {
changedPackages.add(pkgName);
}
}
if (changedPackages.size > 0) {
changedCatalogPackages.set(catalogName, changedPackages);
}
}

// Check for catalogs that were entirely removed
for (const [catalogName, oldPackages] of Object.entries(oldCatalogs)) {
if (!Object.prototype.hasOwnProperty.call(currentCatalogs, catalogName)) {
changedCatalogPackages.set(catalogName, new Set(Object.keys(oldPackages)));
}
}
}

if (changedCatalogPackages.size > 0) {
// Check each project in the subspace to see if it depends on a changed catalog package
const subspaceProjects: RushConfigurationProject[] = subspace.getProjects();
subspaceProjects.forEach((project) => {
const { dependencies, devDependencies, optionalDependencies, peerDependencies } =
project.packageJson;
const allDependencies: Set<[string, string]> = new Set(
[dependencies, devDependencies, optionalDependencies, peerDependencies].flatMap((deps) =>
Object.entries(deps ?? {})
)
);

for (const [depName, depVersion] of allDependencies) {
const specifier: DependencySpecifier = DependencySpecifier.parseWithCache(depName, depVersion);
if (specifier.specifierType === DependencySpecifierType.Catalog) {
// versionSpecifier holds the catalog name (empty string for "catalog:")
const catalogName: string = specifier.versionSpecifier || 'default';
const changedPkgs: Set<string> | undefined = changedCatalogPackages.get(catalogName);
if (changedPkgs?.has(depName)) {
changedProjects.add(project);
return;
}
}
}
});
}
}
}

/**
Expand Down
Loading