Skip to content
Merged
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,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": "198982749+Copilot@users.noreply.github.com"
}
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"
}
]
}
7 changes: 7 additions & 0 deletions libraries/rush-lib/src/cli/RushCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ export class RushCommandLineParser extends CommandLineParser {
private readonly _terminal: Terminal;
private readonly _autocreateBuildCommand: boolean;

/**
* The current working directory that was used to find the Rush configuration.
*/
public get cwd(): string {
return this._rushOptions.cwd;
}

public constructor(options?: Partial<IRushCommandLineParserOptions>) {
super({
toolFilename: 'rush',
Expand Down
3 changes: 2 additions & 1 deletion libraries/rush-lib/src/cli/actions/InstallAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export class InstallAction extends BaseInstallAction {
// Disable filtering because rush-project.json is riggable and therefore may not be available
enableFiltering: false
},
includeSubspaceSelector: true
includeSubspaceSelector: true,
cwd: this.parser.cwd
});

this._checkOnlyParameter = this.defineFlagParameter({
Expand Down
3 changes: 2 additions & 1 deletion libraries/rush-lib/src/cli/actions/ListAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ export class ListAction extends BaseRushAction {
// Disable filtering because rush-project.json is riggable and therefore may not be available
enableFiltering: false
},
includeSubspaceSelector: false
includeSubspaceSelector: false,
cwd: this.parser.cwd
});
}

Expand Down
3 changes: 2 additions & 1 deletion libraries/rush-lib/src/cli/actions/UpdateAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export class UpdateAction extends BaseInstallAction {
// Disable filtering because rush-project.json is riggable and therefore may not be available
enableFiltering: false
},
includeSubspaceSelector: true
includeSubspaceSelector: true,
cwd: this.parser.cwd
});
}

Expand Down
68 changes: 35 additions & 33 deletions libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts
Original file line number Diff line number Diff line change
@@ -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 { AlreadyReportedError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library';
import { AlreadyReportedError } from '@rushstack/node-core-library';
import { Colorize, type ITerminal } from '@rushstack/terminal';
import type {
CommandLineParameterProvider,
Expand All @@ -21,14 +21,19 @@ 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 { RushConstants } from '../../logic/RushConstants';
import { PathProjectSelectorParser } from '../../logic/selectors/PathProjectSelectorParser';
import type { Subspace } from '../../api/Subspace';

export const SUBSPACE_LONG_ARG_NAME: '--subspace' = '--subspace';

interface ISelectionParameterSetOptions {
gitOptions: IGitSelectorParserOptions;
includeSubspaceSelector: boolean;
/**
* The working directory used to resolve relative paths.
* This should be the same directory that was used to find the Rush configuration.
*/
cwd: string;
}

/**
Expand Down Expand Up @@ -58,7 +63,7 @@ export class SelectionParameterSet {
action: CommandLineParameterProvider,
options: ISelectionParameterSetOptions
) {
const { gitOptions, includeSubspaceSelector } = options;
const { gitOptions, includeSubspaceSelector, cwd } = options;
this._rushConfiguration = rushConfiguration;

const selectorParsers: Map<string, ISelectorParser<RushConfigurationProject>> = new Map<
Expand All @@ -72,6 +77,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, cwd));

this._selectorParserByScope = selectorParsers;

Expand Down Expand Up @@ -416,40 +422,36 @@ export class SelectionParameterSet {
const selection: Set<RushConfigurationProject> = new Set();

for (const rawSelector of listParameter.values) {
// Handle the special case of "current project" without a scope
if (rawSelector === '.') {
const packageJsonLookup: PackageJsonLookup = PackageJsonLookup.instance;
const packageJson: IPackageJson | undefined = packageJsonLookup.tryLoadPackageJsonFor(process.cwd());
if (packageJson) {
const project: RushConfigurationProject | undefined = this._rushConfiguration.getProjectByName(
packageJson.name
);

if (project) {
selection.add(project);
} else {
terminal.writeErrorLine(
`Rush is not currently running in a project directory specified in ${RushConstants.rushJsonFilename}. ` +
`The "." value for the ${parameterName} parameter is not allowed.`
);
throw new AlreadyReportedError();
}
const scopeIndex: number = rawSelector.indexOf(':');

let scope: string;
let unscopedSelector: string;

if (scopeIndex < 0) {
// No explicit scope - determine if this looks like a path
// Check for relative paths: '.', '..', or those followed by '/' and more
// Check for absolute POSIX paths: starting with '/'
const isRelativePath: boolean =
rawSelector === '.' ||
rawSelector === '..' ||
rawSelector.startsWith('./') ||
rawSelector.startsWith('../');
const isAbsolutePosixPath: boolean = rawSelector.startsWith('/');

if (isRelativePath || isAbsolutePosixPath) {
// Route to path: selector
scope = 'path';
unscopedSelector = rawSelector;
} else {
terminal.writeErrorLine(
'Rush is not currently running in a project directory. ' +
`The "." value for the ${parameterName} parameter is not allowed.`
);
throw new AlreadyReportedError();
// Default to name: selector
scope = 'name';
unscopedSelector = rawSelector;
}

continue;
} else {
scope = rawSelector.slice(0, scopeIndex);
unscopedSelector = rawSelector.slice(scopeIndex + 1);
}

const scopeIndex: number = rawSelector.indexOf(':');

const scope: string = scopeIndex < 0 ? 'name' : rawSelector.slice(0, scopeIndex);
const unscopedSelector: string = scopeIndex < 0 ? rawSelector : rawSelector.slice(scopeIndex + 1);

const handler: ISelectorParser<RushConfigurationProject> | undefined =
this._selectorParserByScope.get(scope);
if (!handler) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
// Enable filtering to reduce evaluation cost
enableFiltering: true
},
includeSubspaceSelector: false
includeSubspaceSelector: false,
cwd: this.parser.cwd
});

this._verboseParameter = this.defineFlagParameter({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// 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, Path } 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);

// Normalize path separators to forward slashes for LookupByPath
const normalizedPath: string = Path.convertToSlashes(relativePath);

// 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(normalizedPath);

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(normalizedPath)) {
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');
});
});
Loading
Loading