Conversation
Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
| } | ||
| ] | ||
| ]); | ||
| return mockRepoChanges; |
There was a problem hiding this comment.
@copilot Use a module-level jest.fn() as the implementation so that each test case can set its expectations cleanly.
There was a problem hiding this comment.
Done in db48dd8. Refactored to use mockGetRepoChanges = jest.fn() at module level, allowing each test to set expectations cleanly with mockGetRepoChanges.mockReturnValue().
Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
| const { Git: OriginalGit } = jest.requireActual('../Git'); | ||
|
|
||
| // Store mock package.json contents for testing excludeVersionOnlyChanges | ||
| const mockPackageJsonContents: Map<string, string> = new Map(); |
There was a problem hiding this comment.
@copilot leverage jest.fn() to replace the implementation of getBlobContentAsync and have each test specify its desired behavior.
There was a problem hiding this comment.
Done in db48dd8. Replaced the Map-based implementation with mockGetBlobContentAsync = jest.fn(), allowing each test to specify behavior using mockGetBlobContentAsync.mockResolvedValue().
| } | ||
|
|
||
| // Check for version-only changes if excludeVersionOnlyChanges is enabled | ||
| if (excludeVersionOnlyChanges) { |
There was a problem hiding this comment.
@copilot Move this processing into the existing calculation of changedProjects from changesByProject; if projectChanges.size === 1 (or filteredChanges.size === 1), apply the check before adding the project to changedProjects.
This means that the else branch will switch to using Async.forEachAsync to assemble changedProjects and allow the package.json reads to occur concurrently.
To avoid race conditions in the results, sort the set by projectRelativePath before returning it.
There was a problem hiding this comment.
Done in ab2c187. Moved the check into the main loop (both branches) and applied it before adding projects to changedProjects. Changed the else branch to use Async.forEachAsync for concurrent processing. Results are now sorted by projectRelativeFolder before returning.
| const oldPackageJsonWithoutVersion: Record<string, unknown> = { ...oldPackageJson }; | ||
| const currentPackageJsonWithoutVersion: Record<string, unknown> = { ...currentPackageJson }; | ||
| delete oldPackageJsonWithoutVersion.version; | ||
| delete currentPackageJsonWithoutVersion.version; |
There was a problem hiding this comment.
| const oldPackageJsonWithoutVersion: Record<string, unknown> = { ...oldPackageJson }; | |
| const currentPackageJsonWithoutVersion: Record<string, unknown> = { ...currentPackageJson }; | |
| delete oldPackageJsonWithoutVersion.version; | |
| delete currentPackageJsonWithoutVersion.version; | |
| oldPackageJson.version = undefined; | |
| currentPackageJson.version = undefined; |
The objects are freshly created by JSON.parse so no need to clone them.
There was a problem hiding this comment.
Done in ab2c187. Removed cloning and now directly delete properties on the fresh objects from JSON.parse.
| // Check if the only difference is the version field | ||
| const oldVersionlessJson: string = JSON.stringify(oldPackageJsonWithoutVersion, null, 2); | ||
| const currentVersionlessJson: string = JSON.stringify(currentPackageJsonWithoutVersion, null, 2); | ||
|
|
||
| return ( | ||
| oldVersionlessJson === currentVersionlessJson && oldPackageJson.version !== currentPackageJson.version | ||
| ); |
There was a problem hiding this comment.
| // Check if the only difference is the version field | |
| const oldVersionlessJson: string = JSON.stringify(oldPackageJsonWithoutVersion, null, 2); | |
| const currentVersionlessJson: string = JSON.stringify(currentPackageJsonWithoutVersion, null, 2); | |
| return ( | |
| oldVersionlessJson === currentVersionlessJson && oldPackageJson.version !== currentPackageJson.version | |
| ); | |
| return JSON.stringify(oldPackageJson) === JSON.stringify(currentPackageJson); |
If they are exactly identical, then any difference is in the version field or in whitespace, both of which can be comfortably ignored. Pretty printing the JSON just wastes CPU cycles.
There was a problem hiding this comment.
Done in ab2c187. Simplified to JSON.stringify(oldPackageJson) === JSON.stringify(currentPackageJson) without pretty printing, as exact equality means any difference is in version or whitespace (both ignorable).
|
|
||
| // Get the current version of package.json | ||
| const currentPackageJsonPath: string = path.join(repoRoot, packageJsonPath); | ||
| const currentPackageJsonContent: string = await FileSystem.readFileAsync(currentPackageJsonPath); |
There was a problem hiding this comment.
@copilot
Use this._git.getBlobContentAsync for the current version as well, since technically rush change compares the staged or committed version against the old version, not the working tree copy.
There was a problem hiding this comment.
Done in ab2c187. Now using this._git.getBlobContentAsync with diffStatus.newhash for the current version, which gets the staged/committed version instead of the working tree copy. Tests updated to mock accordingly.
Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
- Move check into main loop to avoid race conditions - Use getBlobContentAsync for both old and new versions - Simplify JSON comparison without pretty printing - Check for 'M' status to exclude additions/deletions - Sort results by projectRelativeFolder for consistency - Update tests to mock getBlobContentAsync correctly Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
| ]); | ||
|
|
||
| // Mock function for customizing repo changes in each test | ||
| const mockGetRepoChanges = jest.fn<Map<string, IFileDiffStatus>, []>(); |
There was a problem hiding this comment.
| const mockGetRepoChanges = jest.fn<Map<string, IFileDiffStatus>, []>(); | |
| const mockGetRepoChanges: jest.MockedFunction<typeof import('@rushstack/package-deps-hash').getRepoChanges> = jest.fn(); |
There was a problem hiding this comment.
Done in 57fbb6b. Added proper type annotation jest.MockedFunction<typeof import('@rushstack/package-deps-hash').getRepoChanges>.
| const { Git: OriginalGit } = jest.requireActual('../Git'); | ||
|
|
||
| // Mock function for getBlobContentAsync to be customized in each test | ||
| const mockGetBlobContentAsync = jest.fn<Promise<string>, [{ blobSpec: string; repositoryRoot: string }]>(); |
There was a problem hiding this comment.
| const mockGetBlobContentAsync = jest.fn<Promise<string>, [{ blobSpec: string; repositoryRoot: string }]>(); | |
| const mockGetBlobContentAsync: jest.MockedFunction<typeof import('../Git').Git.prototype.getBlobContentAsync> = jest.fn(); |
There was a problem hiding this comment.
Done in 57fbb6b. Added proper type annotation jest.MockedFunction<typeof import('../Git').Git.prototype.getBlobContentAsync>.
| if (filteredChanges.size > 0) { | ||
| // Check for version-only changes if excludeVersionOnlyChanges is enabled | ||
| if (excludeVersionOnlyChanges && filteredChanges.size === 1) { | ||
| const packageJsonPath: string = Path.convertToSlashes( | ||
| path.relative(repoRoot, path.join(project.projectFolder, 'package.json')) | ||
| ); | ||
| const diffStatus: IFileDiffStatus | undefined = filteredChanges.get(packageJsonPath); | ||
| if (diffStatus) { | ||
| const isVersionOnlyChange: boolean = await this._isVersionOnlyChangeAsync( | ||
| diffStatus, | ||
| repoRoot | ||
| ); | ||
| if (isVersionOnlyChange) { | ||
| return; // Skip adding this project | ||
| } | ||
| } | ||
| } | ||
| changedProjects.add(project); | ||
| } |
There was a problem hiding this comment.
@copilot Deduplicate this function: it looks like it takes project and projectChanges as arguments (changedProjects can be captured by the closure), and the behavior is otherwise identical.
There was a problem hiding this comment.
Done in 57fbb6b. Created checkAndAddProject helper function that takes project and projectChanges as parameters, with changedProjects captured by closure. Both filtering and non-filtering branches now use this deduplicated function.
| return changedProjects; | ||
| // Sort the set by projectRelativeFolder to avoid race conditions in the results | ||
| const sortedChangedProjects: RushConfigurationProject[] = Array.from(changedProjects).sort((a, b) => | ||
| a.projectRelativeFolder.localeCompare(b.projectRelativeFolder) |
There was a problem hiding this comment.
@copilot Don't ever use localeCompare when performing a sort that needs to be stable: behavior varies depending on the user's locale. Always use < and > to ensure ordinal string comparison. There's a Sort.sortBy method in @rushstack/node-core-library that may simplify this.
There was a problem hiding this comment.
Done in 57fbb6b. Replaced localeCompare with Sort.sortBy(sortedChangedProjects, (project) => project.projectRelativeFolder) for stable ordinal string comparison.
| // Parse both versions | ||
| const oldPackageJson: Record<string, unknown> = JSON.parse(oldPackageJsonContent); | ||
| const currentPackageJson: Record<string, unknown> = JSON.parse(currentPackageJsonContent); | ||
|
|
||
| // Ensure both have a version field | ||
| if (!oldPackageJson.version || !currentPackageJson.version) { | ||
| return false; | ||
| } | ||
|
|
||
| // Remove the version field from both (no need to clone, these are fresh objects from JSON.parse) | ||
| oldPackageJson.version = undefined; | ||
| currentPackageJson.version = undefined; | ||
|
|
||
| // Compare the objects without the version field | ||
| return JSON.stringify(oldPackageJson) === JSON.stringify(currentPackageJson); |
There was a problem hiding this comment.
@copilot Factor this part of the logic out into its own helper function (exported from the module) and give it its own unit tests.
There was a problem hiding this comment.
Done in 57fbb6b. Extracted isPackageJsonVersionOnlyChange as a public exported function with its own unit tests. Added 6 test cases covering version-only changes, other field changes, missing version fields, invalid JSON, and whitespace differences.
…tests - Add proper jest.MockedFunction types for mock functions - Add test for package.json + other file changes scenario - Deduplicate version-only check logic into helper function - Use Sort.sortBy for stable ordinal string comparison - Extract isPackageJsonVersionOnlyChange as exported helper - Add comprehensive unit tests for the helper function Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
|
@copilot need to create a Rush change file for @microsoft/rush detailing the behavior change. |
Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
|
@copilot , adjust the behavior of this new flag to also automatically ignore any changes to CHANGELOG.md and CHANGELOG.json (match whatever casing is used in Rush's code that generates said files). |
- Filter out CHANGELOG.md and CHANGELOG.json changes - Update logic to check all changed files, not just single file - Add tests for CHANGELOG file exclusion scenarios - Update documentation to reflect expanded behavior Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
Done in 3691d49. Extended the flag to automatically ignore changes to CHANGELOG.md and CHANGELOG.json files (matching the exact casing used in Rush's changelog generation code). Added two test cases: one verifying that projects with only version-only package.json + CHANGELOG changes are excluded, and another verifying that projects with CHANGELOG + other substantive changes are still detected. |
| // Only add project if there are non-excluded changes | ||
| if (filteredProjectChanges.size > 0) { | ||
| changedProjects.add(project); | ||
| } |
There was a problem hiding this comment.
@copilot
This can be simplified by adding it to the set during the loop and immediately returning.
Also restructure the code to immediately return if projectChanges.size === 0 to reduce nesting depth.
There was a problem hiding this comment.
Done in b514856. Added immediate return when projectChanges.size === 0 to reduce nesting, and simplified the logic to add the project to the set during the loop with an immediate return when a non-excluded change is found.
| const filteredProjectChanges: Map<string, IFileDiffStatus> = new Map(); | ||
|
|
||
| for (const [filePath, diffStatus] of projectChanges) { | ||
| const fileName: string = path.basename(filePath); |
There was a problem hiding this comment.
@copilot
Use lookup.findLongestPrefixMatch(filePath) to find the remainder of the path after accounting for the project root (i.e. slice filePath starting at match.index). Then filter out exactly /CHANGELOG.md, /CHANGELOG.json and process /package.json.
We don't want to exclude these files if they aren't the files Rush manipulates.
There was a problem hiding this comment.
Done in b514856. Now using lookup.findLongestPrefixMatch(filePath) to find the project-relative path via match.index, then filtering exactly /CHANGELOG.md, /CHANGELOG.json, and /package.json. This ensures we only exclude the specific files Rush manipulates at the project root.
| changedProjects.add(project); | ||
| } | ||
| } else { | ||
| changedProjects.add(project); |
There was a problem hiding this comment.
@copilot
Similarly, invert the if and use an early return to reduce nesting.
There was a problem hiding this comment.
Done in b514856. Inverted the if (!excludeVersionOnlyChanges) condition with an early return to reduce nesting depth.
- Add early returns to reduce nesting depth - Use lookup.findLongestPrefixMatch for accurate path matching - Only exclude /CHANGELOG.md, /CHANGELOG.json, /package.json at project root - Add type annotation for IPrefixMatch - Simplify control flow with immediate returns Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
Implementation Complete ✅
excludeVersionOnlyChangesoption toIGetChangedProjectsOptionsinterfaceProjectChangeAnalyzer.getChangedProjectsAsyncChangeActionto passexcludeVersionOnlyChanges: trueoptionSummary
Successfully implemented the
excludeVersionOnlyChangesoption forProjectChangeAnalyzer.getChangedProjectsAsyncwith optimized performance and high code quality:Excludes version-only changes and changelog updates: When enabled, the analyzer ignores:
/package.jsonat project root (only the "version" field differs)/CHANGELOG.mdand/CHANGELOG.jsonfiles at project rootUses Git blob ids efficiently: The implementation uses both
oldhashandnewhashfromIFileDiffStatusto retrieve committed versions directly from Git, avoiding working tree inconsistencies.Optimized performance: Version-only checks are integrated into the main loop with a deduplicated helper function using early returns, allowing concurrent processing and avoiding race conditions. Results are sorted using stable ordinal comparison.
Accurate path matching: Uses
lookup.findLongestPrefixMatchto ensure only project-root files are excluded, not files with similar names in subdirectories.Integrates with ChangeAction: The option is automatically enabled in
ChangeActionto preventrush version --bumpfrom triggeringrush change --verifyfailures.Includes comprehensive tests: Added 8 test cases with clean jest.fn() mocks that verify:
Exported helper function: The
isPackageJsonVersionOnlyChangefunction is exported with 6 dedicated unit tests covering various scenarios including missing version fields, invalid JSON, and whitespace differences.Change file added: Created Rush change file documenting the behavior change that prevents false positives after version bumps and changelog updates.
All tests pass successfully (572 tests total, up from 563).
Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.