Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Fix an issue where ProjectChangeAnalyzer checked the pnpm-lock.yaml file in the default subspace only, when it should consider all subspaces.",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
98 changes: 61 additions & 37 deletions libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import type { ITerminal } from '@rushstack/terminal';

import type { RushConfiguration } from '../api/RushConfiguration';
import type { Subspace } from '../api/Subspace';
import { RushProjectConfiguration } from '../api/RushProjectConfiguration';
import type { RushConfigurationProject } from '../api/RushConfigurationProject';
import { BaseProjectShrinkwrapFile } from './base/BaseProjectShrinkwrapFile';
Expand Down Expand Up @@ -134,53 +135,76 @@ export class ProjectChangeAnalyzer {
// Even though changing the installed version of a nested dependency merits a change file,
// ignore lockfile changes for `rush change` for the moment

const subspaces: Iterable<Subspace> = rushConfiguration.subspacesFeatureEnabled
? rushConfiguration.subspaces
: [rushConfiguration.defaultSubspace];

const variantToUse: string | undefined =
variant ?? (await this._rushConfiguration.getCurrentlyInstalledVariantAsync());
const fullShrinkwrapPath: string =
rushConfiguration.defaultSubspace.getCommittedShrinkwrapFilePath(variantToUse);

const relativeShrinkwrapFilePath: string = Path.convertToSlashes(
path.relative(repoRoot, fullShrinkwrapPath)
);
const shrinkwrapStatus: IFileDiffStatus | undefined = changedFiles.get(relativeShrinkwrapFilePath);
await Async.forEachAsync(subspaces, async (subspace: Subspace) => {
const fullShrinkwrapPath: string = subspace.getCommittedShrinkwrapFilePath(variantToUse);

if (shrinkwrapStatus) {
if (shrinkwrapStatus.status !== 'M') {
terminal.writeLine(`Lockfile was created or deleted. Assuming all projects are affected.`);
return new Set(rushConfiguration.projects);
}

if (rushConfiguration.isPnpm) {
const currentShrinkwrap: PnpmShrinkwrapFile | undefined =
PnpmShrinkwrapFile.loadFromFile(fullShrinkwrapPath);
const relativeShrinkwrapFilePath: string = Path.convertToSlashes(
path.relative(repoRoot, fullShrinkwrapPath)
);
const shrinkwrapStatus: IFileDiffStatus | undefined = changedFiles.get(relativeShrinkwrapFilePath);
const subspaceProjects: RushConfigurationProject[] = subspace.getProjects();

if (!currentShrinkwrap) {
throw new Error(`Unable to obtain current shrinkwrap file.`);
if (shrinkwrapStatus) {
if (shrinkwrapStatus.status !== 'M') {
if (rushConfiguration.subspacesFeatureEnabled) {
terminal.writeLine(
`"${subspace.subspaceName}" subspace lockfile was created or deleted. Assuming all projects are affected.`
);
} else {
terminal.writeLine(`Lockfile was created or deleted. Assuming all projects are affected.`);
}
for (const project of subspaceProjects) {
changedProjects.add(project);
}
return;
}

const oldShrinkwrapText: string = await this._git.getBlobContentAsync({
// <ref>:<path> syntax: https://git-scm.com/docs/gitrevisions
blobSpec: `${mergeCommit}:${relativeShrinkwrapFilePath}`,
repositoryRoot: repoRoot
});
const oldShrinkWrap: PnpmShrinkwrapFile = PnpmShrinkwrapFile.loadFromString(oldShrinkwrapText);

for (const project of rushConfiguration.projects) {
if (
currentShrinkwrap
.getProjectShrinkwrap(project)
.hasChanges(oldShrinkWrap.getProjectShrinkwrap(project))
) {
changedProjects.add(project);
if (rushConfiguration.isPnpm) {
const currentShrinkwrap: PnpmShrinkwrapFile | undefined =
PnpmShrinkwrapFile.loadFromFile(fullShrinkwrapPath);

if (!currentShrinkwrap) {
throw new Error(`Unable to obtain current shrinkwrap file.`);
}

const oldShrinkwrapText: string = await this._git.getBlobContentAsync({
// <ref>:<path> syntax: https://git-scm.com/docs/gitrevisions
blobSpec: `${mergeCommit}:${relativeShrinkwrapFilePath}`,
repositoryRoot: repoRoot
});
const oldShrinkWrap: PnpmShrinkwrapFile = PnpmShrinkwrapFile.loadFromString(oldShrinkwrapText);

for (const project of subspaceProjects) {
if (
currentShrinkwrap
.getProjectShrinkwrap(project)
.hasChanges(oldShrinkWrap.getProjectShrinkwrap(project))
) {
changedProjects.add(project);
}
}
} else {
if (rushConfiguration.subspacesFeatureEnabled) {
terminal.writeLine(
`"${subspace.subspaceName}" subspace lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.`
);
} else {
terminal.writeLine(
`Lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.`
);
}
subspace.getProjects().forEach((project) => changedProjects.add(project));
return;
}
} else {
terminal.writeLine(
`Lockfile has changed and lockfile content comparison is only supported for pnpm. Assuming all projects are affected.`
);
return new Set(rushConfiguration.projects);
}
}
});
}

return changedProjects;
Expand Down
136 changes: 135 additions & 1 deletion libraries/rush-lib/src/logic/test/ProjectChangeAnalyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,67 @@ jest.mock(`@rushstack/package-deps-hash`, () => {
},
hashFilesAsync(rootDirectory: string, filePaths: Iterable<string>): ReadonlyMap<string, string> {
return new Map(Array.from(filePaths, (filePath: string) => [filePath, filePath]));
},
getRepoChanges(): Map<string, IFileDiffStatus> {
return new Map<string, IFileDiffStatus>([
[
// Test subspace lockfile change detection
'common/config/subspaces/project-change-analyzer-test-subspace/pnpm-lock.yaml',
{
mode: 'modified',
newhash: 'newhash',
oldhash: 'oldhash',
status: 'M'
}
],
[
// Test lockfile deletion detection
'common/config/subspaces/default/pnpm-lock.yaml',
{
mode: 'deleted',
newhash: '',
oldhash: 'oldhash',
status: 'D'
}
]
]);
}
};
});

const { Git: OriginalGit } = jest.requireActual('../Git');
/** Mock Git to test `getChangedProjectsAsync` */
jest.mock('../Git', () => {
return {
Git: class MockGit extends OriginalGit {
public async determineIfRefIsACommitAsync(ref: string): Promise<boolean> {
return true;
}
public async getMergeBaseAsync(ref1: string, ref2: string): Promise<string> {
return 'merge-base-sha';
}
public async getBlobContentAsync(opts: { blobSpec: string; repositoryRoot: string }): Promise<string> {
return '';
}
}
};
});

const OriginalPnpmShrinkwrapFile = jest.requireActual('../pnpm/PnpmShrinkwrapFile').PnpmShrinkwrapFile;
jest.mock('../pnpm/PnpmShrinkwrapFile', () => {
return {
PnpmShrinkwrapFile: {
loadFromFile: (fullShrinkwrapPath: string): PnpmShrinkwrapFile => {
return OriginalPnpmShrinkwrapFile.loadFromString(_getMockedPnpmShrinkwrapFile());
},
loadFromString: (text: string): PnpmShrinkwrapFile => {
return OriginalPnpmShrinkwrapFile.loadFromString(
_getMockedPnpmShrinkwrapFile()
// Change dependencies version
.replace(/1\.0\.1/g, '1.0.0')
.replace(/foo_1_0_1/g, 'foo_1_0_0')
);
}
}
};
});
Expand All @@ -55,7 +116,7 @@ jest.mock('../incremental/InputsSnapshot', () => {

import { resolve } from 'node:path';

import type { IDetailedRepoState } from '@rushstack/package-deps-hash';
import type { IDetailedRepoState, IFileDiffStatus } from '@rushstack/package-deps-hash';
import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';

import { ProjectChangeAnalyzer } from '../ProjectChangeAnalyzer';
Expand All @@ -65,6 +126,7 @@ import type {
GetInputsSnapshotAsyncFn,
IInputsSnapshotParameters
} from '../incremental/InputsSnapshot';
import type { PnpmShrinkwrapFile } from '../pnpm/PnpmShrinkwrapFile';

describe(ProjectChangeAnalyzer.name, () => {
beforeEach(() => {
Expand Down Expand Up @@ -101,4 +163,76 @@ describe(ProjectChangeAnalyzer.name, () => {
expect(mockInput.additionalHashes).toEqual(new Map());
});
});

describe(ProjectChangeAnalyzer.prototype.getChangedProjectsAsync.name, () => {
it('Subspaces detects external changes', async () => {
const rootDir: string = resolve(__dirname, 'repoWithSubspaces');
const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(
resolve(rootDir, 'rush.json')
);
const projectChangeAnalyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(rushConfiguration);

const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true);
const terminal: Terminal = new Terminal(terminalProvider);

const changedProjects = await projectChangeAnalyzer.getChangedProjectsAsync({
enableFiltering: false,
includeExternalDependencies: true,
targetBranchName: 'main',
terminal
});

// a,b,c is included because of change modifier is not modified
// d is included because its dependency foo version changed in the subspace lockfile
['a', 'b', 'c', 'd'].forEach((projectName) => {
expect(changedProjects.has(rushConfiguration.getProjectByName(projectName)!)).toBe(true);
});

// e depends on d via workspace:*, but its calculated lockfile (e.g. "e/.rush/temp/shrinkwrap-deps.json") didn't change.
// So it's not included. e will be included by `expandConsumers` if needed.
['e', 'f'].forEach((projectName) => {
expect(changedProjects.has(rushConfiguration.getProjectByName(projectName)!)).toBe(false);
});
});
});
});

/**
* Create a fake pnpm-lock.yaml content matches "libraries/rush-lib/src/logic/test/repoWithSubspaces" test repo
*/
function _getMockedPnpmShrinkwrapFile(): string {
return `lockfileVersion: '9.0'

settings:
autoInstallPeers: false
excludeLinksFromLockfile: false

importers:

.: {}

../../../d:
dependencies:
foo:
specifier: ~1.0.0
version: 1.0.1

../../../e:
dependencies:
d:
specifier: workspace:*
version: link:../../../d

../../../f:
dependencies:

packages:

foo@1.0.1:
resolution: {integrity: 'foo_1_0_1'}

snapshots:

foo@1.0.1: {}
`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "a",
"version": "1.0.0",
"description": "Test package a"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "b",
"version": "2.0.0",
"description": "Test package b",
"dependencies": {
"foo": "~1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "c",
"version": "3.1.1",
"description": "Test package c",
"dependencies": {
"b": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"exemptDecoupledDependenciesBetweenSubspaces": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/pnpm-config.schema.json",
"useWorkspaces": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/subspaces.schema.json",
"subspacesEnabled": true,
"subspaceNames": ["project-change-analyzer-test-subspace"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use strict';

module.exports = {
hooks: {
readPackage(pkgJson) {
return pkgJson;
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* This configuration file specifies NPM dependency version selections that affect all projects
* in a Rush repo. More documentation is available on the Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json",
"ensureConsistentVersions": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use strict';

module.exports = {
hooks: {
readPackage(pkgJson) {
return pkgJson;
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* This configuration file specifies NPM dependency version selections that affect all projects
* in a Rush repo. More documentation is available on the Rush website: https://rushjs.io
*/
{
"$schema": "https://developer.microsoft.com/json-schemas/rush/v5/common-versions.schema.json",
"ensureConsistentVersions": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "d",
"version": "4.1.1",
"description": "Test package d",
"dependencies": {
"foo": "~1.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "e",
"version": "10.10.0",
"description": "Test package e",
"dependencies": {
"d": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "f",
"version": "10.10.0",
"description": "Test package f"
}
Loading