diff --git a/common/changes/@microsoft/rush/watch-changed-only_2025-04-16-21-54.json b/common/changes/@microsoft/rush/watch-changed-only_2025-04-16-21-54.json new file mode 100644 index 00000000000..8c09959bb6a --- /dev/null +++ b/common/changes/@microsoft/rush/watch-changed-only_2025-04-16-21-54.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Support the `--changed-projects-only` flag in watch mode and allow it to be toggled between iterations.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 678053cc27c..e423b7768e8 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -409,6 +409,7 @@ export interface IConfigurationEnvironmentVariable { // @alpha export interface ICreateOperationsContext { readonly buildCacheConfiguration: BuildCacheConfiguration | undefined; + readonly changedProjectsOnly: boolean; readonly cobuildConfiguration: CobuildConfiguration | undefined; readonly customParameters: ReadonlyMap; readonly includePhaseDeps: boolean; @@ -663,6 +664,7 @@ export interface IOperationSettings { dependsOnAdditionalFiles?: string[]; dependsOnEnvVars?: string[]; disableBuildCacheForOperation?: boolean; + ignoreChangedProjectsOnlyFlag?: boolean; operationName: string; outputFolderNames?: string[]; sharding?: IRushPhaseSharding; diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index a1a723280d2..6a1bb5ad3a1 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -141,6 +141,11 @@ export interface IOperationSettings { * If true, this operation can use cobuilds for orchestration without restoring build cache entries. */ allowCobuildWithoutCache?: boolean; + + /** + * If true, this operation will never be skipped by the `--changed-projects-only` flag. + */ + ignoreChangedProjectsOnlyFlag?: boolean; } interface IOldRushProjectJson { diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 4d09afbba02..3f6728b1c9d 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -147,7 +147,7 @@ export class PhasedScriptAction extends BaseScriptAction { private readonly _knownPhases: ReadonlyMap; private readonly _terminal: ITerminal; - private readonly _changedProjectsOnly: CommandLineFlagParameter | undefined; + private readonly _changedProjectsOnlyParameter: CommandLineFlagParameter | undefined; private readonly _selectionParameters: SelectionParameterSet; private readonly _verboseParameter: CommandLineFlagParameter; private readonly _parallelismParameter: CommandLineStringParameter | undefined; @@ -162,6 +162,8 @@ export class PhasedScriptAction extends BaseScriptAction { private readonly _debugBuildCacheIdsParameter: CommandLineFlagParameter; private readonly _includePhaseDeps: CommandLineFlagParameter | undefined; + private _changedProjectsOnly: boolean; + public constructor(options: IPhasedScriptActionOptions) { super(options); this._enableParallelism = options.enableParallelism; @@ -175,6 +177,7 @@ export class PhasedScriptAction extends BaseScriptAction { this._alwaysInstall = options.alwaysInstall; this._runsBeforeInstall = false; this._knownPhases = options.phases; + this._changedProjectsOnly = false; this.hooks = new PhasedCommandHooks(); @@ -243,7 +246,7 @@ export class PhasedScriptAction extends BaseScriptAction { `Using "--impacted-by A --include-phase-deps" avoids that work by performing "_phase:test" only for downstream projects.` }); - this._changedProjectsOnly = this._isIncrementalBuildAllowed + this._changedProjectsOnlyParameter = this._isIncrementalBuildAllowed ? this.defineFlagParameter({ parameterLongName: '--changed-projects-only', parameterShortName: '-c', @@ -427,7 +430,8 @@ export class PhasedScriptAction extends BaseScriptAction { const isQuietMode: boolean = !this._verboseParameter.value; - const changedProjectsOnly: boolean = !!this._changedProjectsOnly?.value; + const changedProjectsOnly: boolean = !!this._changedProjectsOnlyParameter?.value; + this._changedProjectsOnly = changedProjectsOnly; let buildCacheConfiguration: BuildCacheConfiguration | undefined; let cobuildConfiguration: CobuildConfiguration | undefined; @@ -528,6 +532,7 @@ export class PhasedScriptAction extends BaseScriptAction { const initialCreateOperationsContext: ICreateOperationsContext = { buildCacheConfiguration, + changedProjectsOnly, cobuildConfiguration, customParameters: customParametersByName, isIncrementalBuildAllowed: this._isIncrementalBuildAllowed, @@ -658,11 +663,12 @@ export class PhasedScriptAction extends BaseScriptAction { projectWatcher: ProjectWatcher, abortController: AbortController ): void { - const toggleWatcherKey: 'w' = 'w'; const buildOnceKey: 'b' = 'b'; + const changedProjectsOnlyKey: 'c' = 'c'; const invalidateKey: 'i' = 'i'; - const shutdownProcessesKey: 'x' = 'x'; const quitKey: 'q' = 'q'; + const toggleWatcherKey: 'w' = 'w'; + const shutdownProcessesKey: 'x' = 'x'; const terminal: ITerminal = this._terminal; @@ -670,7 +676,8 @@ export class PhasedScriptAction extends BaseScriptAction { const promptLines: string[] = [ ` Press <${quitKey}> to gracefully exit.`, ` Press <${toggleWatcherKey}> to ${isPaused ? 'resume' : 'pause'}.`, - ` Press <${invalidateKey}> to invalidate all projects.` + ` Press <${invalidateKey}> to invalidate all projects.`, + ` Press <${changedProjectsOnlyKey}> to ${this._changedProjectsOnly ? 'disable' : 'enable'} changed-projects-only mode (${this._changedProjectsOnly ? 'ENABLED' : 'DISABLED'}).` ]; if (isPaused) { promptLines.push(` Press <${buildOnceKey}> to build once.`); @@ -713,6 +720,10 @@ export class PhasedScriptAction extends BaseScriptAction { projectWatcher.resume(); } break; + case changedProjectsOnlyKey: + this._changedProjectsOnly = !this._changedProjectsOnly; + projectWatcher.rerenderStatus(); + break; case shutdownProcessesKey: projectWatcher.clearStatus(); terminal.writeLine(`Shutting down long-lived child processes...`); @@ -834,6 +845,7 @@ export class PhasedScriptAction extends BaseScriptAction { // Account for consumer relationships const executeOperationsContext: IExecuteOperationsContext = { ...initialCreateOperationsContext, + changedProjectsOnly: this._changedProjectsOnly, isInitial: false, inputsSnapshot: state, projectsInUnknownState: changedProjects, diff --git a/libraries/rush-lib/src/logic/ProjectWatcher.ts b/libraries/rush-lib/src/logic/ProjectWatcher.ts index af588838404..6f910886fe9 100644 --- a/libraries/rush-lib/src/logic/ProjectWatcher.ts +++ b/libraries/rush-lib/src/logic/ProjectWatcher.ts @@ -69,6 +69,7 @@ export class ProjectWatcher { private _onAbort: undefined | (() => void); private _getPromptLines: undefined | IPromptGeneratorFunction; + private _lastStatus: string | undefined; private _renderedStatusLines: number; public isPaused: boolean = false; @@ -140,6 +141,10 @@ export class ProjectWatcher { this._renderedStatusLines = 0; } + public rerenderStatus(): void { + this._setStatus(this._lastStatus ?? 'Waiting for changes...'); + } + public setPromptGenerator(promptGenerator: IPromptGeneratorFunction): void { this._getPromptLines = promptGenerator; } @@ -427,6 +432,7 @@ export class ProjectWatcher { readline.clearScreenDown(process.stdout); } this._renderedStatusLines = statusLines.length; + this._lastStatus = status; this._terminal.writeLine(Colorize.bold(Colorize.cyan(statusLines.join('\n')))); } diff --git a/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts index 9909046eb08..77d2a479deb 100644 --- a/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts @@ -28,72 +28,19 @@ function createOperations( existingOperations: Set, context: ICreateOperationsContext ): Set { - const { - projectsInUnknownState: changedProjects, - phaseOriginal, - phaseSelection, - projectSelection, - projectConfigurations, - includePhaseDeps, - isInitial - } = context; + const { phaseSelection, projectSelection, projectConfigurations } = context; const operations: Map = new Map(); // Create tasks for selected phases and projects // This also creates the minimal set of dependencies needed - for (const phase of phaseOriginal) { + for (const phase of phaseSelection) { for (const project of projectSelection) { getOrCreateOperation(phase, project); } } - // Grab all operations that were explicitly requested. - const operationsWithWork: Set = new Set(); - for (const operation of existingOperations) { - const { associatedPhase, associatedProject } = operation; - if (!associatedPhase || !associatedProject) { - // Fix this when these are required properties. - continue; - } - - if (phaseSelection.has(associatedPhase) && changedProjects.has(associatedProject)) { - operationsWithWork.add(operation); - } - } - - // Add all operations that are selected that depend on the explicitly requested operations. - // This will mostly be relevant during watch; in initial runs it should not add any new operations. - for (const operation of operationsWithWork) { - for (const consumer of operation.consumers) { - operationsWithWork.add(consumer); - } - } - - if (includePhaseDeps && isInitial) { - // Add all operations that are dependencies of the operations already scheduled. - for (const operation of operationsWithWork) { - for (const dependency of operation.dependencies) { - operationsWithWork.add(dependency); - } - } - } - - for (const operation of existingOperations) { - // Enable exactly the set of operations that are requested. - operation.enabled &&= operationsWithWork.has(operation); - - if (!includePhaseDeps || !isInitial) { - const { associatedPhase, associatedProject } = operation; - if (!associatedPhase || !associatedProject) { - // Fix this when these are required properties. - continue; - } - - // This filter makes the "unsafe" selections happen. - operation.enabled &&= phaseSelection.has(associatedPhase) && projectSelection.has(associatedProject); - } - } + configureOperations(existingOperations, context); return existingOperations; @@ -141,6 +88,69 @@ function createOperations( } } +function configureOperations(operations: ReadonlySet, context: ICreateOperationsContext): void { + const { + changedProjectsOnly, + projectsInUnknownState: changedProjects, + phaseOriginal, + phaseSelection, + projectSelection, + includePhaseDeps, + isInitial + } = context; + + // Grab all operations that were explicitly requested. + const operationsWithWork: Set = new Set(); + for (const operation of operations) { + const { associatedPhase, associatedProject } = operation; + if (phaseOriginal.has(associatedPhase) && changedProjects.has(associatedProject)) { + operationsWithWork.add(operation); + } + } + + if (!isInitial && changedProjectsOnly) { + const potentiallyAffectedOperations: Set = new Set(operationsWithWork); + for (const operation of potentiallyAffectedOperations) { + if (operation.settings?.ignoreChangedProjectsOnlyFlag) { + operationsWithWork.add(operation); + } + + for (const consumer of operation.consumers) { + potentiallyAffectedOperations.add(consumer); + } + } + } else { + // Add all operations that are selected that depend on the explicitly requested operations. + // This will mostly be relevant during watch; in initial runs it should not add any new operations. + for (const operation of operationsWithWork) { + for (const consumer of operation.consumers) { + operationsWithWork.add(consumer); + } + } + } + + if (includePhaseDeps) { + // Add all operations that are dependencies of the operations already scheduled. + for (const operation of operationsWithWork) { + for (const dependency of operation.dependencies) { + operationsWithWork.add(dependency); + } + } + } + + for (const operation of operations) { + // Enable exactly the set of operations that are requested. + operation.enabled &&= operationsWithWork.has(operation); + + if (!includePhaseDeps || !isInitial) { + const { associatedPhase, associatedProject } = operation; + + // This filter makes the "unsafe" selections happen. + operation.enabled &&= phaseSelection.has(associatedPhase) && projectSelection.has(associatedProject); + } + } +} + // Convert the [IPhase, RushConfigurationProject] into a value suitable for use as a Map key function getOperationKey(phase: IPhase, project: RushConfigurationProject): string { return `${project.packageName};${phase.name}`; diff --git a/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts b/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts index 0f5166fc101..4b665b7bdca 100644 --- a/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts +++ b/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts @@ -48,6 +48,12 @@ export interface ICreateOperationsContext { * The configuration for the build cache, if the feature is enabled. */ readonly buildCacheConfiguration: BuildCacheConfiguration | undefined; + /** + * If true, for an incremental build, Rush will only include projects with immediate changes or projects with no consumers. + * @remarks + * This is an optimization that may produce invalid outputs if some of the intervening projects are impacted by the changes. + */ + readonly changedProjectsOnly: boolean; /** * The configuration for the cobuild, if cobuild feature and build cache feature are both enabled. */ diff --git a/libraries/rush-lib/src/schemas/rush-project.schema.json b/libraries/rush-lib/src/schemas/rush-project.schema.json index 6e686581253..52883eaa6c1 100644 --- a/libraries/rush-lib/src/schemas/rush-project.schema.json +++ b/libraries/rush-lib/src/schemas/rush-project.schema.json @@ -106,6 +106,10 @@ "allowCobuildWithoutCache": { "type": "boolean", "description": "If true, this operation will not need to use the build cache to leverage cobuilds" + }, + "ignoreChangedProjectsOnlyFlag": { + "type": "boolean", + "description": "If true, this operation never be skipped by the `--changed-projects-only` flag. This is useful for projects that bundle code from other packages." } } }