diff --git a/common/changes/@microsoft/rush/user-danade-ExposeFileChangeAnalysis_2024-10-02-19-12.json b/common/changes/@microsoft/rush/user-danade-ExposeFileChangeAnalysis_2024-10-02-19-12.json new file mode 100644 index 00000000000..d4a1166b634 --- /dev/null +++ b/common/changes/@microsoft/rush/user-danade-ExposeFileChangeAnalysis_2024-10-02-19-12.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose `getChangesByProject` to allow classes that extend ProjectChangeAnalyzer to override file change analysis", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/lookup-by-path/user-danade-ExposeFileChangeAnalysis_2024-10-03-00-11.json b/common/changes/@rushstack/lookup-by-path/user-danade-ExposeFileChangeAnalysis_2024-10-03-00-11.json new file mode 100644 index 00000000000..cf453a09dcc --- /dev/null +++ b/common/changes/@rushstack/lookup-by-path/user-danade-ExposeFileChangeAnalysis_2024-10-03-00-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/lookup-by-path", + "comment": "Allow for a map of file paths to arbitrary info to be grouped by the nearest entry in the LookupByPath trie", + "type": "minor" + } + ], + "packageName": "@rushstack/lookup-by-path" +} \ No newline at end of file diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 212b74313f6..d96170e3a18 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -126,10 +126,10 @@ importers: version: file:../../../apps/heft(@types/node@18.17.15) '@rushstack/heft-lint-plugin': specifier: file:../../heft-plugins/heft-lint-plugin - version: file:../../../heft-plugins/heft-lint-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15) + version: file:../../../heft-plugins/heft-lint-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15) '@rushstack/heft-typescript-plugin': specifier: file:../../heft-plugins/heft-typescript-plugin - version: file:../../../heft-plugins/heft-typescript-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15) + version: file:../../../heft-plugins/heft-typescript-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15) eslint: specifier: ~8.57.0 version: 8.57.0 @@ -6240,7 +6240,7 @@ packages: - typescript dev: true - file:../../../heft-plugins/heft-api-extractor-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15): + file:../../../heft-plugins/heft-api-extractor-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15): resolution: {directory: ../../../heft-plugins/heft-api-extractor-plugin, type: directory} id: file:../../../heft-plugins/heft-api-extractor-plugin name: '@rushstack/heft-api-extractor-plugin' @@ -6255,7 +6255,7 @@ packages: - '@types/node' dev: true - file:../../../heft-plugins/heft-jest-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15)(jest-environment-node@29.5.0): + file:../../../heft-plugins/heft-jest-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15)(jest-environment-node@29.5.0): resolution: {directory: ../../../heft-plugins/heft-jest-plugin, type: directory} id: file:../../../heft-plugins/heft-jest-plugin name: '@rushstack/heft-jest-plugin' @@ -6289,7 +6289,7 @@ packages: - ts-node dev: true - file:../../../heft-plugins/heft-lint-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15): + file:../../../heft-plugins/heft-lint-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15): resolution: {directory: ../../../heft-plugins/heft-lint-plugin, type: directory} id: file:../../../heft-plugins/heft-lint-plugin name: '@rushstack/heft-lint-plugin' @@ -6303,7 +6303,7 @@ packages: - '@types/node' dev: true - file:../../../heft-plugins/heft-typescript-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15): + file:../../../heft-plugins/heft-typescript-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15): resolution: {directory: ../../../heft-plugins/heft-typescript-plugin, type: directory} id: file:../../../heft-plugins/heft-typescript-plugin name: '@rushstack/heft-typescript-plugin' @@ -6479,6 +6479,7 @@ packages: dependencies: '@rushstack/lookup-by-path': file:../../../libraries/lookup-by-path(@types/node@18.17.15) '@rushstack/node-core-library': file:../../../libraries/node-core-library(@types/node@18.17.15) + '@rushstack/package-deps-hash': file:../../../libraries/package-deps-hash(@types/node@18.17.15) '@rushstack/terminal': file:../../../libraries/terminal(@types/node@18.17.15) '@types/node-fetch': 2.6.2 tapable: 2.2.1 @@ -6527,7 +6528,7 @@ packages: transitivePeerDependencies: - '@types/node' - file:../../../rigs/heft-node-rig(@rushstack/heft@0.68.0)(@types/node@18.17.15): + file:../../../rigs/heft-node-rig(@rushstack/heft@0.68.2)(@types/node@18.17.15): resolution: {directory: ../../../rigs/heft-node-rig, type: directory} id: file:../../../rigs/heft-node-rig name: '@rushstack/heft-node-rig' @@ -6537,10 +6538,10 @@ packages: '@microsoft/api-extractor': file:../../../apps/api-extractor(@types/node@18.17.15) '@rushstack/eslint-config': file:../../../eslint/eslint-config(eslint@8.57.0)(typescript@5.4.5) '@rushstack/heft': file:../../../apps/heft(@types/node@18.17.15) - '@rushstack/heft-api-extractor-plugin': file:../../../heft-plugins/heft-api-extractor-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15) - '@rushstack/heft-jest-plugin': file:../../../heft-plugins/heft-jest-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15)(jest-environment-node@29.5.0) - '@rushstack/heft-lint-plugin': file:../../../heft-plugins/heft-lint-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15) - '@rushstack/heft-typescript-plugin': file:../../../heft-plugins/heft-typescript-plugin(@rushstack/heft@0.68.0)(@types/node@18.17.15) + '@rushstack/heft-api-extractor-plugin': file:../../../heft-plugins/heft-api-extractor-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15) + '@rushstack/heft-jest-plugin': file:../../../heft-plugins/heft-jest-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15)(jest-environment-node@29.5.0) + '@rushstack/heft-lint-plugin': file:../../../heft-plugins/heft-lint-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15) + '@rushstack/heft-typescript-plugin': file:../../../heft-plugins/heft-typescript-plugin(@rushstack/heft@0.68.2)(@types/node@18.17.15) '@types/heft-jest': 1.0.1 eslint: 8.57.0 jest-environment-node: 29.5.0 @@ -6560,7 +6561,7 @@ packages: dependencies: '@microsoft/api-extractor': file:../../../apps/api-extractor(@types/node@18.17.15) '@rushstack/heft': file:../../../apps/heft(@types/node@18.17.15) - '@rushstack/heft-node-rig': file:../../../rigs/heft-node-rig(@rushstack/heft@0.68.0)(@types/node@18.17.15) + '@rushstack/heft-node-rig': file:../../../rigs/heft-node-rig(@rushstack/heft@0.68.2)(@types/node@18.17.15) '@types/heft-jest': 1.0.1 '@types/node': 18.17.15 eslint: 8.57.0 diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 7a3f317fa1a..ef88d270727 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "0e569d956f72f98565ea4207cba2d7b359e5cdaa", + "pnpmShrinkwrapHash": "5b75a8ef91af53a8caf52319e5eb0042c4d06852", "preferredVersionsHash": "ce857ea0536b894ec8f346aaea08cfd85a5af648", - "packageJsonInjectedDependenciesHash": "15081ac6b4174f98e6a82a839055fbda1a33680d" + "packageJsonInjectedDependenciesHash": "8927ca4e0147b9436659f98a2ff8ca347107d52f" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 945c4b453a2..c5e1a2046b4 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -3514,6 +3514,9 @@ importers: '@rushstack/node-core-library': specifier: workspace:* version: link:../node-core-library + '@rushstack/package-deps-hash': + specifier: workspace:* + version: link:../package-deps-hash '@rushstack/terminal': specifier: workspace:* version: link:../terminal diff --git a/common/reviews/api/lookup-by-path.api.md b/common/reviews/api/lookup-by-path.api.md index b2c6c1c308f..1d779056878 100644 --- a/common/reviews/api/lookup-by-path.api.md +++ b/common/reviews/api/lookup-by-path.api.md @@ -18,6 +18,7 @@ export class LookupByPath { findChildPath(childPath: string): TItem | undefined; findChildPathFromSegments(childPathSegments: Iterable): TItem | undefined; findLongestPrefixMatch(query: string): IPrefixMatch | undefined; + groupByChild(infoByPath: Map): Map>; static iteratePathSegments(serializedPath: string, delimiter?: string): Iterable; setItem(serializedPath: string, value: TItem): this; setItemFromSegments(pathSegments: Iterable, value: TItem): this; diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 31cb1da9f8a..005423e2727 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -14,6 +14,7 @@ import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; import { HookMap } from 'tapable'; +import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; import { ITerminal } from '@rushstack/terminal'; @@ -1116,6 +1117,8 @@ export class ProjectChangeAnalyzer { // (undocumented) _filterProjectDataAsync(project: RushConfigurationProject, unfilteredProjectData: Map, rootDir: string, terminal: ITerminal): Promise>; getChangedProjectsAsync(options: IGetChangedProjectsOptions): Promise>; + // (undocumented) + protected getChangesByProject(lookup: LookupByPath, changedFiles: Map): Map>; // @internal _tryGetProjectDependenciesAsync(project: RushConfigurationProject, terminal: ITerminal): Promise | undefined>; // @internal diff --git a/libraries/lookup-by-path/src/LookupByPath.ts b/libraries/lookup-by-path/src/LookupByPath.ts index d9fa65fb81d..1f89630af01 100644 --- a/libraries/lookup-by-path/src/LookupByPath.ts +++ b/libraries/lookup-by-path/src/LookupByPath.ts @@ -246,6 +246,33 @@ export class LookupByPath { return best; } + /** + * Groups the provided map of info by the nearest entry in the trie that contains the path. If the path + * is not found in the trie, the info is ignored. + * + * @returns The grouped info, grouped by the nearest entry in the trie that contains the path + * + * @param infoByPath - The info to be grouped, keyed by path + */ + public groupByChild(infoByPath: Map): Map> { + const groupedInfoByChild: Map> = new Map(); + + for (const [path, info] of infoByPath) { + const child: TItem | undefined = this.findChildPath(path); + if (child === undefined) { + continue; + } + let groupedInfo: Map | undefined = groupedInfoByChild.get(child); + if (!groupedInfo) { + groupedInfo = new Map(); + groupedInfoByChild.set(child, groupedInfo); + } + groupedInfo.set(path, info); + } + + return groupedInfoByChild; + } + /** * Iterates through progressively longer prefixes of a given string and returns as soon * as the number of candidate items that match the prefix are 1 or 0. diff --git a/libraries/lookup-by-path/src/LookupByPath.test.ts b/libraries/lookup-by-path/src/test/LookupByPath.test.ts similarity index 68% rename from libraries/lookup-by-path/src/LookupByPath.test.ts rename to libraries/lookup-by-path/src/test/LookupByPath.test.ts index 1086f23c151..3d3aa341080 100644 --- a/libraries/lookup-by-path/src/LookupByPath.test.ts +++ b/libraries/lookup-by-path/src/test/LookupByPath.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { LookupByPath } from './LookupByPath'; +import { LookupByPath } from '../LookupByPath'; describe(LookupByPath.iteratePathSegments.name, () => { it('returns empty for an empty string', () => { @@ -143,3 +143,92 @@ describe(LookupByPath.prototype.findLongestPrefixMatch.name, () => { expect(tree.findLongestPrefixMatch('foo/foo')).toEqual({ value: 1, index: 3 }); }); }); + +describe(LookupByPath.prototype.groupByChild.name, () => { + const lookup: LookupByPath = new LookupByPath([ + ['foo', 'foo'], + ['foo/bar', 'bar'], + ['foo/bar/baz', 'baz'] + ]); + + it('returns empty map for empty input', () => { + expect(lookup.groupByChild(new Map())).toEqual(new Map()); + }); + + it('groups items by the closest group that contains the file path', () => { + const infoByPath: Map = new Map([ + ['foo', 'foo'], + ['foo/bar', 'bar'], + ['foo/bar/baz', 'baz'], + ['foo/bar/baz/qux', 'qux'], + ['foo/bar/baz/qux/quux', 'quux'] + ]); + + const expected: Map> = new Map([ + ['foo', new Map([['foo', 'foo']])], + ['bar', new Map([['foo/bar', 'bar']])], + [ + 'baz', + new Map([ + ['foo/bar/baz', 'baz'], + ['foo/bar/baz/qux', 'qux'], + ['foo/bar/baz/qux/quux', 'quux'] + ]) + ] + ]); + + expect(lookup.groupByChild(infoByPath)).toEqual(expected); + }); + + it('ignores items that do not exist in the lookup', () => { + const infoByPath: Map = new Map([ + ['foo', 'foo'], + ['foo/qux', 'qux'], + ['bar', 'bar'], + ['baz', 'baz'] + ]); + + const expected: Map> = new Map([ + [ + 'foo', + new Map([ + ['foo', 'foo'], + ['foo/qux', 'qux'] + ]) + ] + ]); + + expect(lookup.groupByChild(infoByPath)).toEqual(expected); + }); + + it('ignores items that do not exist in the lookup when the lookup children are possibly falsy', () => { + const falsyLookup: LookupByPath = new LookupByPath([ + ['foo', 'foo'], + ['foo/bar', 'bar'], + ['foo/bar/baz', ''] + ]); + + const infoByPath: Map = new Map([ + ['foo', 'foo'], + ['foo/bar', 'bar'], + ['foo/bar/baz', 'baz'], + ['foo/bar/baz/qux', 'qux'], + ['foo/bar/baz/qux/quux', 'quux'] + ]); + + const expected: Map> = new Map([ + ['foo', new Map([['foo', 'foo']])], + ['bar', new Map([['foo/bar', 'bar']])], + [ + '', + new Map([ + ['foo/bar/baz', 'baz'], + ['foo/bar/baz/qux', 'qux'], + ['foo/bar/baz/qux/quux', 'quux'] + ]) + ] + ]); + + expect(falsyLookup.groupByChild(infoByPath)).toEqual(expected); + }); +}); diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index 9b068940425..acda3b3ccd8 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -225,15 +225,47 @@ export class ProjectChangeAnalyzer { const repoRoot: string = getRepoRoot(rushConfiguration.rushJsonFolder); // if the given targetBranchName is a commit, we assume it is the merge base - const IsTargetBranchACommit: boolean = await this._git.determineIfRefIsACommitAsync(targetBranchName); - const mergeCommit: string = IsTargetBranchACommit + const isTargetBranchACommit: boolean = await this._git.determineIfRefIsACommitAsync(targetBranchName); + const mergeCommit: string = isTargetBranchACommit ? targetBranchName : await this._git.getMergeBaseAsync(targetBranchName, terminal, shouldFetch); - const repoChanges: Map = getRepoChanges(repoRoot, mergeCommit, gitPath); + const changedFiles: Map = getRepoChanges(repoRoot, mergeCommit, gitPath); + const lookup: LookupByPath = + rushConfiguration.getProjectLookupForRoot(repoRoot); + const changesByProject: Map< + RushConfigurationProject, + Map + > = this.getChangesByProject(lookup, changedFiles); const changedProjects: Set = new Set(); + if (enableFiltering) { + // Reading rush-project.json may be problematic if, e.g. rush install has not yet occurred and rigs are in use + await Async.forEachAsync( + changesByProject, + async ([project, projectChanges]) => { + const filteredChanges: Map = await this._filterProjectDataAsync( + project, + projectChanges, + repoRoot, + terminal + ); + if (filteredChanges.size > 0) { + changedProjects.add(project); + } + }, + { concurrency: 10 } + ); + } else { + for (const [project, projectChanges] of changesByProject) { + if (projectChanges.size > 0) { + changedProjects.add(project); + } + } + } + + // 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 @@ -246,7 +278,7 @@ export class ProjectChangeAnalyzer { const relativeShrinkwrapFilePath: string = Path.convertToSlashes( path.relative(repoRoot, fullShrinkwrapPath) ); - const shrinkwrapStatus: IFileDiffStatus | undefined = repoChanges.get(relativeShrinkwrapFilePath); + const shrinkwrapStatus: IFileDiffStatus | undefined = changedFiles.get(relativeShrinkwrapFilePath); if (shrinkwrapStatus) { if (shrinkwrapStatus.status !== 'M') { @@ -289,54 +321,16 @@ export class ProjectChangeAnalyzer { } } - const changesByProject: Map> = new Map(); - const lookup: LookupByPath = - rushConfiguration.getProjectLookupForRoot(repoRoot); - - for (const [file, diffStatus] of repoChanges) { - const project: RushConfigurationProject | undefined = lookup.findChildPath(file); - if (project) { - if (changedProjects.has(project)) { - // Lockfile changes cannot be ignored via rush-project.json - continue; - } - - if (enableFiltering) { - let projectChanges: Map | undefined = changesByProject.get(project); - if (!projectChanges) { - projectChanges = new Map(); - changesByProject.set(project, projectChanges); - } - projectChanges.set(file, diffStatus); - } else { - changedProjects.add(project); - } - } - } - - if (enableFiltering) { - // Reading rush-project.json may be problematic if, e.g. rush install has not yet occurred and rigs are in use - await Async.forEachAsync( - changesByProject, - async ([project, projectChanges]) => { - const filteredChanges: Map = await this._filterProjectDataAsync( - project, - projectChanges, - repoRoot, - terminal - ); - - if (filteredChanges.size > 0) { - changedProjects.add(project); - } - }, - { concurrency: 10 } - ); - } - return changedProjects; } + protected getChangesByProject( + lookup: LookupByPath, + changedFiles: Map + ): Map> { + return lookup.groupByChild(changedFiles); + } + private async _getDataAsync(terminal: ITerminal): Promise { const repoState: IGitState | undefined = await this._getRepoDepsAsync(terminal); if (!repoState) { diff --git a/libraries/rush-sdk/package.json b/libraries/rush-sdk/package.json index 22fdf41ab61..d38a1a4e863 100644 --- a/libraries/rush-sdk/package.json +++ b/libraries/rush-sdk/package.json @@ -40,6 +40,7 @@ "dependencies": { "@rushstack/lookup-by-path": "workspace:*", "@rushstack/node-core-library": "workspace:*", + "@rushstack/package-deps-hash": "workspace:*", "@rushstack/terminal": "workspace:*", "@types/node-fetch": "2.6.2", "tapable": "2.2.1"