Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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,11 @@
{
"changes": [
{
"comment": "Added the ability to select projects via path, e.g. 'rush build --to path:./my-project' or 'rush build --only path:/some/absolute/path'",
"type": "minor",
"packageName": "@microsoft/rush"
}
],
"packageName": "@microsoft/rush",
"email": "[email protected]"
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe(RushConfiguration.name, () => {
expect(rushConfiguration.projectFolderMinDepth).toEqual(1);
expect(rushConfiguration.hotfixChangeEnabled).toEqual(true);

expect(rushConfiguration.projects).toHaveLength(3);
expect(rushConfiguration.projects).toHaveLength(5);

// "approvedPackagesPolicy" feature
const approvedPackagesPolicy: ApprovedPackagesPolicy = rushConfiguration.approvedPackagesPolicy;
Expand Down
5 changes: 5 additions & 0 deletions libraries/rush-lib/src/api/test/repo/apps/app1/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "app1",
"version": "1.0.0",
"description": "Test app 1"
}
5 changes: 5 additions & 0 deletions libraries/rush-lib/src/api/test/repo/apps/app2/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "app2",
"version": "1.0.0",
"description": "Test app 2"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[
{
"definitionName": "lockStepVersion",
"policyName": "testPolicy",
"version": "1.0.0",
"nextBump": "minor"
}
]
23 changes: 20 additions & 3 deletions libraries/rush-lib/src/api/test/repo/rush-npm.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,37 @@
{
"packageName": "project1",
"projectFolder": "project1",
"reviewCategory": "third-party"
"reviewCategory": "third-party",
"tags": ["frontend", "ui"],
"versionPolicyName": "testPolicy"
},

{
"packageName": "project2",
"projectFolder": "project2",
"reviewCategory": "third-party",
"skipRushCheck": true
"skipRushCheck": true,
"tags": ["backend"]
},

{
"packageName": "project3",
"projectFolder": "project3",
"reviewCategory": "prototype"
"reviewCategory": "prototype",
"tags": ["frontend"],
"versionPolicyName": "testPolicy"
},

{
"packageName": "app1",
"projectFolder": "apps/app1",
"reviewCategory": "first-party"
},

{
"packageName": "app2",
"projectFolder": "apps/app2",
"reviewCategory": "first-party"
}
]
}
2 changes: 2 additions & 0 deletions libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { NamedProjectSelectorParser } from '../../logic/selectors/NamedProjectSe
import { TagProjectSelectorParser } from '../../logic/selectors/TagProjectSelectorParser';
import { VersionPolicyProjectSelectorParser } from '../../logic/selectors/VersionPolicyProjectSelectorParser';
import { SubspaceSelectorParser } from '../../logic/selectors/SubspaceSelectorParser';
import { PathProjectSelectorParser } from '../../logic/selectors/PathProjectSelectorParser';
import { RushConstants } from '../../logic/RushConstants';
import type { Subspace } from '../../api/Subspace';

Expand Down Expand Up @@ -72,6 +73,7 @@ export class SelectionParameterSet {
selectorParsers.set('tag', new TagProjectSelectorParser(rushConfiguration));
selectorParsers.set('version-policy', new VersionPolicyProjectSelectorParser(rushConfiguration));
selectorParsers.set('subspace', new SubspaceSelectorParser(rushConfiguration));
selectorParsers.set('path', new PathProjectSelectorParser(rushConfiguration, process.cwd()));

this._selectorParserByScope = selectorParsers;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as nodePath from 'node:path';

import { AlreadyReportedError } from '@rushstack/node-core-library';
import type { LookupByPath } from '@rushstack/lookup-by-path';

import type { RushConfiguration } from '../../api/RushConfiguration';
import type { RushConfigurationProject } from '../../api/RushConfigurationProject';
import type { IEvaluateSelectorOptions, ISelectorParser } from './ISelectorParser';
import { RushConstants } from '../RushConstants';

export class PathProjectSelectorParser implements ISelectorParser<RushConfigurationProject> {
private readonly _rushConfiguration: RushConfiguration;
private readonly _workingDirectory: string;

public constructor(rushConfiguration: RushConfiguration, workingDirectory: string) {
this._rushConfiguration = rushConfiguration;
this._workingDirectory = workingDirectory;
}

public async evaluateSelectorAsync({
unscopedSelector,
terminal,
parameterName
}: IEvaluateSelectorOptions): Promise<Iterable<RushConfigurationProject>> {
// Resolve the input path against the working directory
const absolutePath: string = nodePath.resolve(this._workingDirectory, unscopedSelector);

// Relativize it to the rushJsonFolder
const relativePath: string = nodePath.relative(this._rushConfiguration.rushJsonFolder, absolutePath);

// Get the LookupByPath instance for the Rush root
const lookupByPath: LookupByPath<RushConfigurationProject> =
this._rushConfiguration.getProjectLookupForRoot(this._rushConfiguration.rushJsonFolder);

// Check if this path is within a project or matches a project exactly
const containingProject: RushConfigurationProject | undefined = lookupByPath.findChildPath(relativePath);

if (containingProject) {
return [containingProject];
}

// Check if there are any projects under this path (i.e., it's a directory containing projects)
const projectsUnderPath: Set<RushConfigurationProject> = new Set();
for (const [, project] of lookupByPath.entries(relativePath)) {
projectsUnderPath.add(project);
}

if (projectsUnderPath.size > 0) {
return projectsUnderPath;
}

// No projects found
terminal.writeErrorLine(
`The path "${unscopedSelector}" passed to "${parameterName}" does not match any project in ` +
`${RushConstants.rushJsonFilename}. The resolved path relative to the Rush root is "${relativePath}".`
);
throw new AlreadyReportedError();
}

public getCompletions(): Iterable<string> {
// Return empty completions as path completions are typically handled by the shell
return [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'node:path';

import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';

import { RushConfiguration } from '../../../api/RushConfiguration';
import { NamedProjectSelectorParser } from '../NamedProjectSelectorParser';

describe(NamedProjectSelectorParser.name, () => {
let rushConfiguration: RushConfiguration;
let terminal: Terminal;
let terminalProvider: StringBufferTerminalProvider;
let parser: NamedProjectSelectorParser;

beforeEach(() => {
const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json');
rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile);
terminalProvider = new StringBufferTerminalProvider();
terminal = new Terminal(terminalProvider);
parser = new NamedProjectSelectorParser(rushConfiguration);
});

it('should select a project by exact package name', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: 'project1',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project1');
});

it('should throw error for non-existent project', async () => {
await expect(
parser.evaluateSelectorAsync({
unscopedSelector: 'nonexistent',
terminal,
parameterName: '--only'
})
).rejects.toThrow();
});

it('should provide completions for all projects', () => {
const completions = Array.from(parser.getCompletions());
expect(completions).toContain('project1');
expect(completions).toContain('project2');
expect(completions).toContain('project3');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'node:path';

import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';

import { RushConfiguration } from '../../../api/RushConfiguration';
import { PathProjectSelectorParser } from '../PathProjectSelectorParser';

describe(PathProjectSelectorParser.name, () => {
let rushConfiguration: RushConfiguration;
let terminal: Terminal;
let terminalProvider: StringBufferTerminalProvider;
let parser: PathProjectSelectorParser;

beforeEach(() => {
const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json');
rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile);
terminalProvider = new StringBufferTerminalProvider();
terminal = new Terminal(terminalProvider);
parser = new PathProjectSelectorParser(rushConfiguration, rushConfiguration.rushJsonFolder);
});

it('should select a project by exact path', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: 'project1',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project1');
});

it('should select a project by path within the project', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: 'project1/src/index.ts',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project1');
});

it('should select multiple projects from a parent directory', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: '.',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects.length).toBeGreaterThan(0);
// Should include all projects in the test repo
const packageNames = projects.map((p) => p.packageName).sort();
expect(packageNames).toContain('project1');
expect(packageNames).toContain('project2');
expect(packageNames).toContain('project3');
});

it('should select multiple projects from a shared subfolder', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: 'apps',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(2);
const packageNames = projects.map((p) => p.packageName).sort();
expect(packageNames).toEqual(['app1', 'app2']);
});

it('should select project from specified directory', async () => {
const project1Path = path.join(rushConfiguration.rushJsonFolder, 'project1');
const parserWithCustomCwd = new PathProjectSelectorParser(rushConfiguration, project1Path);

const result = await parserWithCustomCwd.evaluateSelectorAsync({
unscopedSelector: '.',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project1');
});

it('should handle absolute paths', async () => {
const absolutePath = path.join(rushConfiguration.rushJsonFolder, 'project2');

const result = await parser.evaluateSelectorAsync({
unscopedSelector: absolutePath,
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
expect(projects).toHaveLength(1);
expect(projects[0].packageName).toBe('project2');
});

it('should throw error for paths that do not match any project', async () => {
await expect(
parser.evaluateSelectorAsync({
unscopedSelector: 'nonexistent/path',
terminal,
parameterName: '--only'
})
).rejects.toThrow();
});

it('should handle paths outside workspace', async () => {
// Paths outside the workspace should not match any project and throw
await expect(
parser.evaluateSelectorAsync({
unscopedSelector: '../outside',
terminal,
parameterName: '--only'
})
).rejects.toThrow();
});

it('should return empty completions', () => {
const completions = Array.from(parser.getCompletions());
expect(completions).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'node:path';

import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal';

import { RushConfiguration } from '../../../api/RushConfiguration';
import { SubspaceSelectorParser } from '../SubspaceSelectorParser';

describe(SubspaceSelectorParser.name, () => {
let rushConfiguration: RushConfiguration;
let terminal: Terminal;
let terminalProvider: StringBufferTerminalProvider;
let parser: SubspaceSelectorParser;

beforeEach(() => {
const rushJsonFile: string = path.resolve(__dirname, '../../../api/test/repo/rush-npm.json');
rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile);
terminalProvider = new StringBufferTerminalProvider();
terminal = new Terminal(terminalProvider);
parser = new SubspaceSelectorParser(rushConfiguration);
});

it('should return completions based on configuration', () => {
const completions = Array.from(parser.getCompletions());
// The test fixture doesn't have subspaces configured, so completions may be empty
expect(Array.isArray(completions)).toBe(true);
});

it('should select projects from default subspace', async () => {
const result = await parser.evaluateSelectorAsync({
unscopedSelector: 'default',
terminal,
parameterName: '--only'
});

const projects = Array.from(result);
// Should get projects from the default subspace
expect(projects.length).toBeGreaterThan(0);
});
});
Loading
Loading