Skip to content

Commit 83dd2f8

Browse files
committed
refactor(@angular/cli): harden git utilities and improve JSDoc
This commit refactors the git utility functions in `@angular/cli` to enhance robustness, security, and code clarity. All `git` commands now use `execFileSync` instead of `execSync` to prevent shell injection vulnerabilities and provide more predictable execution. `checkCleanGit` now utilizes `git status --porcelain -z` for NUL-terminated output, ensuring correct handling of filenames with spaces or special characters, and preventing potential path trimming bugs. An `execGit` helper function was introduced to reduce code duplication and standardize `git` command execution options. `hasChangesToCommit` now gracefully handles non-Git repositories by returning `false` instead of throwing.
1 parent eb48d71 commit 83dd2f8

File tree

5 files changed

+69
-41
lines changed

5 files changed

+69
-41
lines changed

packages/angular/cli/src/commands/update/utilities/git.ts

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,57 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { execSync } from 'node:child_process';
9+
import { execFileSync } from 'node:child_process';
1010
import * as path from 'node:path';
1111

12+
/**
13+
* Execute a git command.
14+
* @param args Arguments to pass to the git command.
15+
* @param input Optional input to pass to the command via stdin.
16+
* @returns The output of the command.
17+
*/
18+
function execGit(args: string[], input?: string): string {
19+
return execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe', input });
20+
}
21+
1222
/**
1323
* Checks if the git repository is clean.
14-
* @param root The root directory of the project.
15-
* @returns True if the repository is clean, false otherwise.
24+
* This function only checks for changes that are within the specified root directory.
25+
* Changes outside the root directory are ignored.
26+
* @param root The root directory of the project to check.
27+
* @returns True if the repository is clean within the root, false otherwise.
1628
*/
1729
export function checkCleanGit(root: string): boolean {
1830
try {
19-
const topLevel = execSync('git rev-parse --show-toplevel', {
20-
encoding: 'utf8',
21-
stdio: 'pipe',
22-
});
23-
const result = execSync('git status --porcelain', { encoding: 'utf8', stdio: 'pipe' });
24-
if (result.trim().length === 0) {
31+
const topLevel = execGit(['rev-parse', '--show-toplevel']);
32+
const result = execGit(['status', '--porcelain', '-z']);
33+
if (result.length === 0) {
2534
return true;
2635
}
2736

28-
// Only files inside the workspace root are relevant
29-
for (const entry of result.split('\n')) {
30-
const relativeEntry = path.relative(
31-
path.resolve(root),
32-
path.resolve(topLevel.trim(), entry.slice(3).trim()),
33-
);
37+
const entries = result.split('\0');
38+
for (let i = 0; i < entries.length; i++) {
39+
const line = entries[i];
40+
if (!line) {
41+
continue;
42+
}
43+
44+
// Status is the first 2 characters.
45+
// If the status is a rename ('R'), the next entry in the split array is the target path.
46+
let filePath = line.slice(3);
47+
const status = line.slice(0, 2);
48+
if (status[0] === 'R') {
49+
// Check the source path (filePath)
50+
if (isPathInsideRoot(filePath, root, topLevel.trim())) {
51+
return false;
52+
}
53+
54+
// The next entry is the target path of the rename.
55+
i++;
56+
filePath = entries[i];
57+
}
3458

35-
if (!relativeEntry.startsWith('..') && !path.isAbsolute(relativeEntry)) {
59+
if (isPathInsideRoot(filePath, root, topLevel.trim())) {
3660
return false;
3761
}
3862
}
@@ -41,15 +65,27 @@ export function checkCleanGit(root: string): boolean {
4165
return true;
4266
}
4367

68+
function isPathInsideRoot(filePath: string, root: string, topLevel: string): boolean {
69+
const relativeEntry = path.relative(
70+
path.resolve(root),
71+
path.resolve(topLevel, filePath),
72+
);
73+
74+
return !relativeEntry.startsWith('..') && !path.isAbsolute(relativeEntry);
75+
}
76+
4477
/**
4578
* Checks if the working directory has pending changes to commit.
46-
* @returns Whether or not the working directory has Git changes to commit.
79+
* @returns Whether or not the working directory has Git changes to commit. Returns false if not in a Git repository.
4780
*/
4881
export function hasChangesToCommit(): boolean {
49-
// List all modified files not covered by .gitignore.
50-
// If any files are returned, then there must be something to commit.
51-
52-
return execSync('git ls-files -m -d -o --exclude-standard').toString() !== '';
82+
try {
83+
// List all modified files not covered by .gitignore.
84+
// If any files are returned, then there must be something to commit.
85+
return execGit(['ls-files', '-m', '-d', '-o', '--exclude-standard']).trim() !== '';
86+
} catch {
87+
return false;
88+
}
5389
}
5490

5591
/**
@@ -58,19 +94,19 @@ export function hasChangesToCommit(): boolean {
5894
*/
5995
export function createCommit(message: string) {
6096
// Stage entire working tree for commit.
61-
execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' });
97+
execGit(['add', '-A']);
6298

6399
// Commit with the message passed via stdin to avoid bash escaping issues.
64-
execSync('git commit --no-verify -F -', { encoding: 'utf8', stdio: 'pipe', input: message });
100+
execGit(['commit', '--no-verify', '-F', '-'], message);
65101
}
66102

67103
/**
68-
* Finds the Git SHA hash of the HEAD commit.
69-
* @returns The Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash.
104+
* Finds the full Git SHA hash of the HEAD commit.
105+
* @returns The full Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash.
70106
*/
71107
export function findCurrentGitSha(): string | null {
72108
try {
73-
return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: 'pipe' }).trim();
109+
return execGit(['rev-parse', 'HEAD']).trim();
74110
} catch {
75111
return null;
76112
}

packages/angular_devkit/architect_cli/BUILD.bazel

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ ts_project(
1919
deps = [
2020
":node_modules/@angular-devkit/architect",
2121
":node_modules/@angular-devkit/core",
22-
":node_modules/ansi-colors",
2322
":node_modules/yargs-parser",
2423
"//:node_modules/@types/node",
2524
"//:node_modules/@types/yargs-parser",

packages/angular_devkit/architect_cli/bin/architect.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import { Architect } from '@angular-devkit/architect';
1111
import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node';
1212
import { JsonValue, json, logging, schema, tags, workspaces } from '@angular-devkit/core';
1313
import { NodeJsSyncHost, createConsoleLogger } from '@angular-devkit/core/node';
14-
import * as ansiColors from 'ansi-colors';
1514
import { existsSync } from 'node:fs';
1615
import * as path from 'node:path';
16+
import { styleText } from 'node:util';
1717
import yargsParser, { camelCase, decamelize } from 'yargs-parser';
1818

1919
function findUp(names: string | string[], from: string) {
@@ -58,9 +58,6 @@ function usage(logger: logging.Logger, exitCode = 0): never {
5858
return process.exit(exitCode);
5959
}
6060

61-
// Create a separate instance to prevent unintended global changes to the color configuration
62-
const colors = ansiColors.create();
63-
6461
async function _executeTarget(
6562
parentLogger: logging.Logger,
6663
workspace: workspaces.WorkspaceDefinition,
@@ -100,9 +97,9 @@ async function _executeTarget(
10097
try {
10198
const result = await run.lastOutput;
10299
if (result.success) {
103-
parentLogger.info(colors.green('SUCCESS'));
100+
parentLogger.info(styleText(['green'], 'SUCCESS'));
104101
} else {
105-
parentLogger.info(colors.red('FAILURE'));
102+
parentLogger.info(styleText(['red'], 'FAILURE'));
106103
}
107104
parentLogger.info('Result: ' + JSON.stringify({ ...result, info: undefined }, null, 4));
108105

@@ -114,7 +111,7 @@ async function _executeTarget(
114111

115112
return result.success ? 0 : 1;
116113
} catch (err) {
117-
parentLogger.info(colors.red('ERROR'));
114+
parentLogger.info(styleText(['red'], 'ERROR'));
118115
parentLogger.info('\nLogs:');
119116
logs.forEach((l) => parentLogger.next(l));
120117

@@ -141,9 +138,9 @@ async function main(args: string[]): Promise<number> {
141138
const logger = createConsoleLogger(argv['verbose'], process.stdout, process.stderr, {
142139
info: (s) => s,
143140
debug: (s) => s,
144-
warn: (s) => colors.bold.yellow(s),
145-
error: (s) => colors.bold.red(s),
146-
fatal: (s) => colors.bold.red(s),
141+
warn: (s) => styleText(['yellow', 'bold'], s),
142+
error: (s) => styleText(['red', 'bold'], s),
143+
fatal: (s) => styleText(['red', 'bold'], s),
147144
});
148145

149146
// Check the target.

packages/angular_devkit/architect_cli/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
"dependencies": {
1717
"@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER",
1818
"@angular-devkit/core": "workspace:0.0.0-PLACEHOLDER",
19-
"ansi-colors": "4.1.3",
2019
"yargs-parser": "22.0.0"
2120
}
2221
}

pnpm-lock.yaml

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)