Skip to content

Commit 3230820

Browse files
[rush-lib] Add pnpm global catalog detection to rush change (#5627)
* add pnpm global catalog detection to rush change * changelog * remove consumers marked as changed * add additional test * cater for subspaces * remove redundant subspace logic * consider namespaced catalog package version changes * Update common/changes/@microsoft/rush/rush-change-catalog-entries_2026-02-17-18-48.json Co-authored-by: Ian Clanton-Thuon <iclanton@users.noreply.github.com> * narrow try catch and account for peerDeps * remove nested for and leverage protoype methods --------- Co-authored-by: Ian Clanton-Thuon <iclanton@users.noreply.github.com>
1 parent 16e3d3a commit 3230820

29 files changed

Lines changed: 945 additions & 15 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"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.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts

Lines changed: 153 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import type { RushConfigurationProject } from '../api/RushConfigurationProject';
2323
import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile';
2424
import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile';
2525
import { Git } from './Git';
26+
import { DependencySpecifier, DependencySpecifierType } from './DependencySpecifier';
27+
import type { IPnpmOptionsJson, PnpmOptionsConfiguration } from './pnpm/PnpmOptionsConfiguration';
2628
import {
2729
type IInputsSnapshotProjectMetadata,
2830
type IInputsSnapshot,
@@ -178,26 +180,42 @@ export class ProjectChangeAnalyzer {
178180
{ concurrency: 10 }
179181
);
180182

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

186-
const subspaces: Iterable<Subspace> = rushConfiguration.subspacesFeatureEnabled
187-
? rushConfiguration.subspaces
188-
: [rushConfiguration.defaultSubspace];
188+
const variantToUse: string | undefined = includeExternalDependencies
189+
? (variant ?? (await this._rushConfiguration.getCurrentlyInstalledVariantAsync()))
190+
: undefined;
189191

190-
const variantToUse: string | undefined =
191-
variant ?? (await this._rushConfiguration.getCurrentlyInstalledVariantAsync());
192+
await Async.forEachAsync(subspaces, async (subspace: Subspace) => {
193+
const subspaceProjects: RushConfigurationProject[] = subspace.getProjects();
194+
195+
// Detect changes to pnpm catalog entries in pnpm-config.json
196+
if (rushConfiguration.isPnpm) {
197+
await this._detectCatalogChangesAsync(
198+
subspace,
199+
rushConfiguration,
200+
changedFiles,
201+
mergeCommit,
202+
repoRoot,
203+
terminal,
204+
changedProjects
205+
);
206+
}
207+
208+
// External dependency changes are not allowed to be filtered, so add these after filtering
209+
if (includeExternalDependencies) {
210+
// Even though changing the installed version of a nested dependency merits a change file,
211+
// ignore lockfile changes for `rush change` for the moment
192212

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

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

202220
if (shrinkwrapStatus) {
203221
if (shrinkwrapStatus.status !== 'M') {
@@ -215,7 +233,7 @@ export class ProjectChangeAnalyzer {
215233
}
216234

217235
if (rushConfiguration.isPnpm) {
218-
const subspaceHasNoProjects: boolean = subspace.getProjects().length === 0;
236+
const subspaceHasNoProjects: boolean = subspaceProjects.length === 0;
219237
const currentShrinkwrap: PnpmShrinkwrapFile | undefined = PnpmShrinkwrapFile.loadFromFile(
220238
fullShrinkwrapPath,
221239
{ subspaceHasNoProjects }
@@ -253,12 +271,12 @@ export class ProjectChangeAnalyzer {
253271
`Lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.`
254272
);
255273
}
256-
subspace.getProjects().forEach((project) => changedProjects.add(project));
274+
subspaceProjects.forEach((project) => changedProjects.add(project));
257275
return;
258276
}
259277
}
260-
});
261-
}
278+
}
279+
});
262280

263281
// Sort the set by projectRelativeFolder to avoid race conditions in the results
264282
const sortedChangedProjects: RushConfigurationProject[] = Array.from(changedProjects);
@@ -491,6 +509,126 @@ export class ProjectChangeAnalyzer {
491509
return ignoreMatcher;
492510
}
493511
}
512+
513+
/**
514+
* Detects changes to pnpm catalog entries in a subspace's pnpm-config.json and marks
515+
* affected projects as changed.
516+
*/
517+
private async _detectCatalogChangesAsync(
518+
subspace: Subspace,
519+
rushConfiguration: RushConfiguration,
520+
changedFiles: Map<string, IFileDiffStatus>,
521+
mergeCommit: string,
522+
repoRoot: string,
523+
terminal: ITerminal,
524+
changedProjects: Set<RushConfigurationProject>
525+
): Promise<void> {
526+
const pnpmOptions: PnpmOptionsConfiguration | undefined = subspace.getPnpmOptions();
527+
// Default to an empty object if no global catalogs are configured, handle case of globalCatalogs being deleted
528+
const currentCatalogs: Record<string, Record<string, string>> = pnpmOptions?.globalCatalogs ?? {};
529+
530+
const pnpmConfigRelativePath: string = Path.convertToSlashes(
531+
path.relative(repoRoot, subspace.getPnpmConfigFilePath())
532+
);
533+
534+
if (!changedFiles.has(pnpmConfigRelativePath)) {
535+
return;
536+
}
537+
538+
// Determine which specific packages changed within each catalog namespace
539+
// Maps catalogNamespace (e.g. "default", "react17") → Set of changed package names
540+
let oldCatalogs: Record<string, Record<string, string>> | undefined;
541+
try {
542+
const oldPnpmConfigText: string = await this._git.getBlobContentAsync({
543+
blobSpec: `${mergeCommit}:${pnpmConfigRelativePath}`,
544+
repositoryRoot: repoRoot
545+
});
546+
const oldPnpmConfig: IPnpmOptionsJson = JSON.parse(oldPnpmConfigText);
547+
oldCatalogs = oldPnpmConfig.globalCatalogs ?? {};
548+
} catch {
549+
// Old file didn't exist or was unparseable — treat all packages in all current catalogs as changed
550+
if (rushConfiguration.subspacesFeatureEnabled) {
551+
terminal.writeLine(
552+
`"${subspace.subspaceName}" subspace pnpm-config.json was created or unparseable. Assuming all projects are affected.`
553+
);
554+
} else {
555+
terminal.writeLine(
556+
`pnpm-config.json was created or unparseable. Assuming all projects are affected.`
557+
);
558+
}
559+
}
560+
561+
const changedCatalogPackages: Map<string, Set<string>> = new Map();
562+
const currentCatalogEntries: Map<string, Record<string, string>> = new Map(
563+
Object.entries(currentCatalogs)
564+
);
565+
566+
if (oldCatalogs === undefined) {
567+
// Could not load old catalogs — treat all packages in all current catalogs as changed
568+
for (const [catalogName, packages] of currentCatalogEntries) {
569+
changedCatalogPackages.set(catalogName, new Set(Object.keys(packages)));
570+
}
571+
} else {
572+
// Check current catalogs for new or modified package entries
573+
for (const [catalogName, packages] of currentCatalogEntries) {
574+
const oldPackages: Record<string, string> | undefined = oldCatalogs[catalogName];
575+
if (!oldPackages) {
576+
// Entire catalog is new — all packages in it are changed
577+
changedCatalogPackages.set(catalogName, new Set(Object.keys(packages)));
578+
continue;
579+
}
580+
const changedPackages: Set<string> = new Set();
581+
for (const [pkgName, version] of Object.entries(packages)) {
582+
if (oldPackages[pkgName] !== version) {
583+
changedPackages.add(pkgName);
584+
}
585+
}
586+
// Check for packages that were removed from this catalog
587+
for (const pkgName of Object.keys(oldPackages)) {
588+
if (!Object.prototype.hasOwnProperty.call(packages, pkgName)) {
589+
changedPackages.add(pkgName);
590+
}
591+
}
592+
if (changedPackages.size > 0) {
593+
changedCatalogPackages.set(catalogName, changedPackages);
594+
}
595+
}
596+
597+
// Check for catalogs that were entirely removed
598+
for (const [catalogName, oldPackages] of Object.entries(oldCatalogs)) {
599+
if (!Object.prototype.hasOwnProperty.call(currentCatalogs, catalogName)) {
600+
changedCatalogPackages.set(catalogName, new Set(Object.keys(oldPackages)));
601+
}
602+
}
603+
}
604+
605+
if (changedCatalogPackages.size > 0) {
606+
// Check each project in the subspace to see if it depends on a changed catalog package
607+
const subspaceProjects: RushConfigurationProject[] = subspace.getProjects();
608+
subspaceProjects.forEach((project) => {
609+
const { dependencies, devDependencies, optionalDependencies, peerDependencies } =
610+
project.packageJson;
611+
const allDependencies: Set<[string, string]> = new Set(
612+
[dependencies, devDependencies, optionalDependencies, peerDependencies].flatMap((deps) =>
613+
Object.entries(deps ?? {})
614+
)
615+
);
616+
617+
for (const [depName, depVersion] of allDependencies) {
618+
const specifier: DependencySpecifier = DependencySpecifier.parseWithCache(depName, depVersion);
619+
if (specifier.specifierType === DependencySpecifierType.Catalog) {
620+
// versionSpecifier holds the catalog name (empty string for "catalog:")
621+
const catalogName: string = specifier.versionSpecifier || 'default';
622+
const changedPkgs: Set<string> | undefined = changedCatalogPackages.get(catalogName);
623+
if (changedPkgs?.has(depName)) {
624+
changedProjects.add(project);
625+
return;
626+
}
627+
}
628+
}
629+
});
630+
}
631+
}
494632
}
495633

496634
/**

0 commit comments

Comments
 (0)