@@ -23,6 +23,8 @@ import type { RushConfigurationProject } from '../api/RushConfigurationProject';
2323import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile' ;
2424import { PnpmShrinkwrapFile } from './pnpm/PnpmShrinkwrapFile' ;
2525import { Git } from './Git' ;
26+ import { DependencySpecifier , DependencySpecifierType } from './DependencySpecifier' ;
27+ import type { IPnpmOptionsJson , PnpmOptionsConfiguration } from './pnpm/PnpmOptionsConfiguration' ;
2628import {
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