refactor(dev-infra): add spawnSync to child process utils, normalize naming of child-process utils (#42394)

Create a `spawnSync` command for common usage, additionally update naming to use `spawn` instead of
`spawnWithDebugOutput`

PR Close #42394
This commit is contained in:
Joey Perrott 2021-06-03 10:54:36 -07:00 committed by Andrew Kushnir
parent e6593ad94a
commit 08444c6679
8 changed files with 147 additions and 79 deletions

View File

@ -144,6 +144,7 @@ var GitCommandError = /** @class */ (function (_super) {
// we sanitize the command that will be part of the error message.
_super.call(this, "Command failed: git " + client.sanitizeConsoleOutput(args.join(' '))) || this;
_this.args = args;
Object.setPrototypeOf(_this, GitCommandError.prototype);
return _this;
}
return GitCommandError;

View File

@ -316,6 +316,7 @@ var GitCommandError = /** @class */ (function (_super) {
// we sanitize the command that will be part of the error message.
_super.call(this, "Command failed: git " + client.sanitizeConsoleOutput(args.join(' '))) || this;
_this.args = args;
Object.setPrototypeOf(_this, GitCommandError.prototype);
return _this;
}
return GitCommandError;
@ -3309,30 +3310,40 @@ function discoverNewConflictsForPr(newPrNumber, updatedAfter) {
info(`Retrieved ${allPendingPRs.length} total pending PRs`);
info(`Checking ${pendingPrs.length} PRs for conflicts after a merge of #${newPrNumber}`);
// Fetch and checkout the PR being checked.
exec(`git fetch ${requestedPr.headRef.repository.url} ${requestedPr.headRef.name}`);
exec(`git checkout -B ${tempWorkingBranch} FETCH_HEAD`);
git.run(['fetch', '-q', requestedPr.headRef.repository.url, requestedPr.headRef.name]);
git.run(['checkout', '-q', '-B', tempWorkingBranch, 'FETCH_HEAD']);
// Rebase the PR against the PRs target branch.
exec(`git fetch ${requestedPr.baseRef.repository.url} ${requestedPr.baseRef.name}`);
const result = exec(`git rebase FETCH_HEAD`);
if (result.code) {
git.run(['fetch', '-q', requestedPr.baseRef.repository.url, requestedPr.baseRef.name]);
try {
git.run(['rebase', 'FETCH_HEAD'], { stdio: 'ignore' });
}
catch (err) {
if (err instanceof GitCommandError) {
error('The requested PR currently has conflicts');
cleanUpGitState(previousBranchOrRevision);
git.checkout(previousBranchOrRevision, true);
process.exit(1);
}
throw err;
}
// Start the progress bar
progressBar.start(pendingPrs.length, 0);
// Check each PR to determine if it can merge cleanly into the repo after the target PR.
for (const pr of pendingPrs) {
// Fetch and checkout the next PR
exec(`git fetch ${pr.headRef.repository.url} ${pr.headRef.name}`);
exec(`git checkout --detach FETCH_HEAD`);
git.run(['fetch', '-q', pr.headRef.repository.url, pr.headRef.name]);
git.run(['checkout', '-q', '--detach', 'FETCH_HEAD']);
// Check if the PR cleanly rebases into the repo after the target PR.
const result = exec(`git rebase ${tempWorkingBranch}`);
if (result.code !== 0) {
try {
git.run(['rebase', tempWorkingBranch], { stdio: 'ignore' });
}
catch (err) {
if (err instanceof GitCommandError) {
conflicts.push(pr);
}
throw err;
}
// Abort any outstanding rebase attempt.
exec(`git rebase --abort`);
git.runGraceful(['rebase', '--abort'], { stdio: 'ignore' });
progressBar.increment(1);
}
// End the progress bar as all PRs have been processed.
@ -5824,7 +5835,7 @@ const ReleaseNotesCommandModule = {
*
* @returns a Promise resolving on success, and rejecting on command failure with the status code.
*/
function spawnInteractiveCommand(command, args, options) {
function spawnInteractive(command, args, options) {
if (options === void 0) { options = {}; }
return new Promise(function (resolve, reject) {
var commandText = command + " " + args.join(' ');
@ -5839,9 +5850,9 @@ function spawnInteractiveCommand(command, args, options) {
* output mode, stdout/stderr output is also printed to the console, or only on error.
*
* @returns a Promise resolving with captured stdout and stderr on success. The promise
* rejects on command failure.
* rejects on command failure
*/
function spawnWithDebugOutput(command, args, options) {
function spawn(command, args, options) {
if (options === void 0) { options = {}; }
return new Promise(function (resolve, reject) {
var commandText = command + " " + args.join(' ');
@ -5871,15 +5882,16 @@ function spawnWithDebugOutput(command, args, options) {
process.stderr.write(message);
}
});
childProcess.on('exit', function (status, signal) {
var exitDescription = status !== null ? "exit code \"" + status + "\"" : "signal \"" + signal + "\"";
childProcess.on('exit', function (exitCode, signal) {
var exitDescription = exitCode !== null ? "exit code \"" + exitCode + "\"" : "signal \"" + signal + "\"";
var printFn = outputMode === 'on-error' ? error : debug;
var status = statusFromExitCodeAndSignal(exitCode, signal);
printFn("Command \"" + commandText + "\" completed with " + exitDescription + ".");
printFn("Process output: \n" + logOutput);
// On success, resolve the promise. Otherwise reject with the captured stderr
// and stdout log output if the output mode was set to `silent`.
if (status === 0) {
resolve({ stdout: stdout, stderr: stderr });
if (status === 0 || options.suppressErrorOnFailingExitCode) {
resolve({ stdout: stdout, stderr: stderr, status: status });
}
else {
reject(outputMode === 'silent' ? logOutput : undefined);
@ -5887,6 +5899,10 @@ function spawnWithDebugOutput(command, args, options) {
});
});
}
/** Convert the provided exitCode and signal to a single status code. */
function statusFromExitCodeAndSignal(exitCode, signal) {
return exitCode !== null ? exitCode : signal !== null ? signal : -1;
}
/**
* @license
@ -5906,7 +5922,7 @@ function runNpmPublish(packagePath, distTag, registryUrl) {
if (registryUrl !== undefined) {
args.push('--registry', registryUrl);
}
yield spawnWithDebugOutput('npm', args, { cwd: packagePath, mode: 'silent' });
yield spawn('npm', args, { cwd: packagePath, mode: 'silent' });
});
}
/**
@ -5920,7 +5936,7 @@ function setNpmTagForPackage(packageName, distTag, version, registryUrl) {
if (registryUrl !== undefined) {
args.push('--registry', registryUrl);
}
yield spawnWithDebugOutput('npm', args, { mode: 'silent' });
yield spawn('npm', args, { mode: 'silent' });
});
}
/**
@ -5935,7 +5951,7 @@ function npmIsLoggedIn(registryUrl) {
args.push('--registry', registryUrl);
}
try {
yield spawnWithDebugOutput('npm', args, { mode: 'silent' });
yield spawn('npm', args, { mode: 'silent' });
}
catch (e) {
return false;
@ -5958,7 +5974,7 @@ function npmLogin(registryUrl) {
}
// The login command prompts for username, password and other profile information. Hence
// the process needs to be interactive (i.e. respecting current TTYs stdin).
yield spawnInteractiveCommand('npm', args);
yield spawnInteractive('npm', args);
});
}
/**
@ -5975,7 +5991,7 @@ function npmLogout(registryUrl) {
args.splice(1, 0, '--registry', registryUrl);
}
try {
yield spawnWithDebugOutput('npm', args, { mode: 'silent' });
yield spawn('npm', args, { mode: 'silent' });
}
finally {
return npmIsLoggedIn(registryUrl);
@ -6097,7 +6113,7 @@ function invokeSetNpmDistCommand(npmDistTag, version) {
return tslib.__awaiter(this, void 0, void 0, function* () {
try {
// Note: No progress indicator needed as that is the responsibility of the command.
yield spawnWithDebugOutput('yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]);
yield spawn('yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]);
info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`));
}
catch (e) {
@ -6117,7 +6133,7 @@ function invokeReleaseBuildCommand() {
try {
// Since we expect JSON to be printed from the `ng-dev release build` command,
// we spawn the process in silent mode. We have set up an Ora progress spinner.
const { stdout } = yield spawnWithDebugOutput('yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], { mode: 'silent' });
const { stdout } = yield spawn('yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], { mode: 'silent' });
spinner.stop();
info(green(' ✓ Built release output for all packages.'));
// The `ng-dev release build` command prints a JSON array to stdout
@ -6141,7 +6157,7 @@ function invokeYarnInstallCommand(projectDir) {
try {
// Note: No progress indicator needed as that is the responsibility of the command.
// TODO: Consider using an Ora spinner instead to ensure minimal console output.
yield spawnWithDebugOutput('yarn', ['install', '--frozen-lockfile', '--non-interactive'], { cwd: projectDir });
yield spawn('yarn', ['install', '--frozen-lockfile', '--non-interactive'], { cwd: projectDir });
info(green(' ✓ Installed project dependencies.'));
}
catch (e) {
@ -7279,7 +7295,7 @@ class ReleaseTool {
try {
// Note: We do not rely on `/usr/bin/env` but rather access the `env` binary directly as it
// should be part of the shell's `$PATH`. This is necessary for compatibility with Windows.
const pyVersion = yield spawnWithDebugOutput('env', ['python', '--version'], { mode: 'silent' });
const pyVersion = yield spawn('env', ['python', '--version'], { mode: 'silent' });
const version = pyVersion.stdout.trim() || pyVersion.stderr.trim();
if (version.startsWith('Python 3.')) {
debug(`Local python version: ${version}`);

View File

@ -11,7 +11,7 @@ import {types as graphqlTypes} from 'typed-graphqlify';
import {error, info} from '../../utils/console';
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
import {GitClient} from '../../utils/git/git-client';
import {GitCommandError} from '../../utils/git/git-client';
import {getPendingPrs} from '../../utils/github';
import {exec} from '../../utils/shelljs';
@ -95,17 +95,21 @@ export async function discoverNewConflictsForPr(newPrNumber: number, updatedAfte
info(`Checking ${pendingPrs.length} PRs for conflicts after a merge of #${newPrNumber}`);
// Fetch and checkout the PR being checked.
exec(`git fetch ${requestedPr.headRef.repository.url} ${requestedPr.headRef.name}`);
exec(`git checkout -B ${tempWorkingBranch} FETCH_HEAD`);
git.run(['fetch', '-q', requestedPr.headRef.repository.url, requestedPr.headRef.name]);
git.run(['checkout', '-q', '-B', tempWorkingBranch, 'FETCH_HEAD']);
// Rebase the PR against the PRs target branch.
exec(`git fetch ${requestedPr.baseRef.repository.url} ${requestedPr.baseRef.name}`);
const result = exec(`git rebase FETCH_HEAD`);
if (result.code) {
git.run(['fetch', '-q', requestedPr.baseRef.repository.url, requestedPr.baseRef.name]);
try {
git.run(['rebase', 'FETCH_HEAD'], {stdio: 'ignore'});
} catch (err) {
if (err instanceof GitCommandError) {
error('The requested PR currently has conflicts');
cleanUpGitState(previousBranchOrRevision);
git.checkout(previousBranchOrRevision, true);
process.exit(1);
}
throw err;
}
// Start the progress bar
progressBar.start(pendingPrs.length, 0);
@ -113,15 +117,19 @@ export async function discoverNewConflictsForPr(newPrNumber: number, updatedAfte
// Check each PR to determine if it can merge cleanly into the repo after the target PR.
for (const pr of pendingPrs) {
// Fetch and checkout the next PR
exec(`git fetch ${pr.headRef.repository.url} ${pr.headRef.name}`);
exec(`git checkout --detach FETCH_HEAD`);
git.run(['fetch', '-q', pr.headRef.repository.url, pr.headRef.name]);
git.run(['checkout', '-q', '--detach', 'FETCH_HEAD']);
// Check if the PR cleanly rebases into the repo after the target PR.
const result = exec(`git rebase ${tempWorkingBranch}`);
if (result.code !== 0) {
try {
git.run(['rebase', tempWorkingBranch], {stdio: 'ignore'});
} catch (err) {
if (err instanceof GitCommandError) {
conflicts.push(pr);
}
throw err;
}
// Abort any outstanding rebase attempt.
exec(`git rebase --abort`);
git.runGraceful(['rebase', '--abort'], {stdio: 'ignore'});
progressBar.increment(1);
}

View File

@ -9,7 +9,7 @@
import * as ora from 'ora';
import * as semver from 'semver';
import {spawnWithDebugOutput} from '../../utils/child-process';
import {spawn} from '../../utils/child-process';
import {error, green, info, red} from '../../utils/console';
import {BuiltPackage} from '../config/index';
import {NpmDistTag} from '../versioning';
@ -40,7 +40,7 @@ import {FatalReleaseActionError} from './actions-error';
export async function invokeSetNpmDistCommand(npmDistTag: NpmDistTag, version: semver.SemVer) {
try {
// Note: No progress indicator needed as that is the responsibility of the command.
await spawnWithDebugOutput(
await spawn(
'yarn', ['--silent', 'ng-dev', 'release', 'set-dist-tag', npmDistTag, version.format()]);
info(green(` ✓ Set "${npmDistTag}" NPM dist tag for all packages to v${version}.`));
} catch (e) {
@ -59,8 +59,8 @@ export async function invokeReleaseBuildCommand(): Promise<BuiltPackage[]> {
try {
// Since we expect JSON to be printed from the `ng-dev release build` command,
// we spawn the process in silent mode. We have set up an Ora progress spinner.
const {stdout} = await spawnWithDebugOutput(
'yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], {mode: 'silent'});
const {stdout} =
await spawn('yarn', ['--silent', 'ng-dev', 'release', 'build', '--json'], {mode: 'silent'});
spinner.stop();
info(green(' ✓ Built release output for all packages.'));
// The `ng-dev release build` command prints a JSON array to stdout
@ -82,8 +82,7 @@ export async function invokeYarnInstallCommand(projectDir: string): Promise<void
try {
// Note: No progress indicator needed as that is the responsibility of the command.
// TODO: Consider using an Ora spinner instead to ensure minimal console output.
await spawnWithDebugOutput(
'yarn', ['install', '--frozen-lockfile', '--non-interactive'], {cwd: projectDir});
await spawn('yarn', ['install', '--frozen-lockfile', '--non-interactive'], {cwd: projectDir});
info(green(' ✓ Installed project dependencies.'));
} catch (e) {
error(e);

View File

@ -8,7 +8,7 @@
import {ListChoiceOptions, prompt} from 'inquirer';
import {spawnWithDebugOutput} from '../../utils/child-process';
import {spawn} from '../../utils/child-process';
import {GithubConfig} from '../../utils/config';
import {debug, error, info, log, promptConfirm, red, yellow} from '../../utils/console';
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
@ -138,8 +138,7 @@ export class ReleaseTool {
try {
// Note: We do not rely on `/usr/bin/env` but rather access the `env` binary directly as it
// should be part of the shell's `$PATH`. This is necessary for compatibility with Windows.
const pyVersion =
await spawnWithDebugOutput('env', ['python', '--version'], {mode: 'silent'});
const pyVersion = await spawn('env', ['python', '--version'], {mode: 'silent'});
const version = pyVersion.stdout.trim() || pyVersion.stderr.trim();
if (version.startsWith('Python 3.')) {
debug(`Local python version: ${version}`);

View File

@ -7,7 +7,9 @@
*/
import * as semver from 'semver';
import {spawnInteractiveCommand, spawnWithDebugOutput} from '../../utils/child-process';
import {spawn, spawnInteractive} from '../../utils/child-process';
import {NpmDistTag} from './npm-registry';
/**
@ -21,7 +23,7 @@ export async function runNpmPublish(
if (registryUrl !== undefined) {
args.push('--registry', registryUrl);
}
await spawnWithDebugOutput('npm', args, {cwd: packagePath, mode: 'silent'});
await spawn('npm', args, {cwd: packagePath, mode: 'silent'});
}
/**
@ -35,7 +37,7 @@ export async function setNpmTagForPackage(
if (registryUrl !== undefined) {
args.push('--registry', registryUrl);
}
await spawnWithDebugOutput('npm', args, {mode: 'silent'});
await spawn('npm', args, {mode: 'silent'});
}
/**
@ -49,7 +51,7 @@ export async function npmIsLoggedIn(registryUrl: string|undefined): Promise<bool
args.push('--registry', registryUrl);
}
try {
await spawnWithDebugOutput('npm', args, {mode: 'silent'});
await spawn('npm', args, {mode: 'silent'});
} catch (e) {
return false;
}
@ -70,7 +72,7 @@ export async function npmLogin(registryUrl: string|undefined) {
}
// The login command prompts for username, password and other profile information. Hence
// the process needs to be interactive (i.e. respecting current TTYs stdin).
await spawnInteractiveCommand('npm', args);
await spawnInteractive('npm', args);
}
/**
@ -86,7 +88,7 @@ export async function npmLogout(registryUrl: string|undefined): Promise<boolean>
args.splice(1, 0, '--registry', registryUrl);
}
try {
await spawnWithDebugOutput('npm', args, {mode: 'silent'});
await spawn('npm', args, {mode: 'silent'});
} finally {
return npmIsLoggedIn(registryUrl);
}

View File

@ -6,21 +6,34 @@
* found in the LICENSE file at https://angular.io/license
*/
import {spawn, SpawnOptions} from 'child_process';
import {spawn as _spawn, SpawnOptions as _SpawnOptions, spawnSync as _spawnSync, SpawnSyncOptions as _SpawnSyncOptions} from 'child_process';
import {debug, error} from './console';
/** Interface describing the options for spawning a process. */
export interface SpawnedProcessOptions extends Omit<SpawnOptions, 'stdio'> {
/** Console output mode. Defaults to "enabled". */
mode?: 'enabled'|'silent'|'on-error';
export interface SpawnSyncOptions extends Omit<_SpawnSyncOptions, 'shell'|'stdio'> {
/** Whether to prevent exit codes being treated as failures. */
suppressErrorOnFailingExitCode?: boolean;
}
/** Interface describing the options for spawning a process. */
export interface SpawnOptions extends Omit<_SpawnOptions, 'shell'|'stdio'> {
/** Console output mode. Defaults to "enabled". */
mode?: 'enabled'|'silent'|'on-error';
/** Whether to prevent exit codes being treated as failures. */
suppressErrorOnFailingExitCode?: boolean;
}
/** Interface describing the options for spawning an interactive process. */
export type SpawnInteractiveCommandOptions = Omit<_SpawnOptions, 'shell'|'stdio'>;
/** Interface describing the result of a spawned process. */
export interface SpawnedProcessResult {
export interface SpawnResult {
/** Captured stdout in string format. */
stdout: string;
/** Captured stderr in string format. */
stderr: string;
/** The exit code or signal of the process. */
status: number|NodeJS.Signals;
}
/**
@ -29,12 +42,12 @@ export interface SpawnedProcessResult {
*
* @returns a Promise resolving on success, and rejecting on command failure with the status code.
*/
export function spawnInteractiveCommand(
command: string, args: string[], options: Omit<SpawnOptions, 'stdio'> = {}) {
export function spawnInteractive(
command: string, args: string[], options: SpawnInteractiveCommandOptions = {}) {
return new Promise<void>((resolve, reject) => {
const commandText = `${command} ${args.join(' ')}`;
debug(`Executing command: ${commandText}`);
const childProcess = spawn(command, args, {...options, shell: true, stdio: 'inherit'});
const childProcess = _spawn(command, args, {...options, shell: true, stdio: 'inherit'});
childProcess.on('exit', status => status === 0 ? resolve() : reject(status));
});
}
@ -45,18 +58,17 @@ export function spawnInteractiveCommand(
* output mode, stdout/stderr output is also printed to the console, or only on error.
*
* @returns a Promise resolving with captured stdout and stderr on success. The promise
* rejects on command failure.
* rejects on command failure
*/
export function spawnWithDebugOutput(
command: string, args: string[],
options: SpawnedProcessOptions = {}): Promise<SpawnedProcessResult> {
export function spawn(
command: string, args: string[], options: SpawnOptions = {}): Promise<SpawnResult> {
return new Promise((resolve, reject) => {
const commandText = `${command} ${args.join(' ')}`;
const outputMode = options.mode;
debug(`Executing command: ${commandText}`);
const childProcess = spawn(command, args, {...options, shell: true, stdio: 'pipe'});
const childProcess = _spawn(command, args, {...options, shell: true, stdio: 'pipe'});
let logOutput = '';
let stdout = '';
let stderr = '';
@ -83,20 +95,51 @@ export function spawnWithDebugOutput(
}
});
childProcess.on('exit', (status, signal) => {
const exitDescription = status !== null ? `exit code "${status}"` : `signal "${signal}"`;
childProcess.on('exit', (exitCode, signal) => {
const exitDescription = exitCode !== null ? `exit code "${exitCode}"` : `signal "${signal}"`;
const printFn = outputMode === 'on-error' ? error : debug;
const status = statusFromExitCodeAndSignal(exitCode, signal);
printFn(`Command "${commandText}" completed with ${exitDescription}.`);
printFn(`Process output: \n${logOutput}`);
// On success, resolve the promise. Otherwise reject with the captured stderr
// and stdout log output if the output mode was set to `silent`.
if (status === 0) {
resolve({stdout, stderr});
if (status === 0 || options.suppressErrorOnFailingExitCode) {
resolve({stdout, stderr, status});
} else {
reject(outputMode === 'silent' ? logOutput : undefined);
}
});
});
}
/**
* Spawns a given command with the specified arguments inside a shell syncronously.
*
* @returns The command's stdout and stderr.
*/
export function spawnSync(
command: string, args: string[], options: SpawnOptions = {}): SpawnResult {
const commandText = `${command} ${args.join(' ')}`;
debug(`Executing command: ${commandText}`);
const {status: exitCode, signal, stdout, stderr} =
_spawnSync(command, args, {...options, encoding: 'utf8', shell: true, stdio: 'pipe'});
/** The status of the spawn result. */
const status = statusFromExitCodeAndSignal(exitCode, signal);
if (status === 0 || options.suppressErrorOnFailingExitCode) {
return {status, stdout, stderr};
}
throw new Error(stderr);
}
/** Convert the provided exitCode and signal to a single status code. */
function statusFromExitCodeAndSignal(exitCode: number|null, signal: NodeJS.Signals|null) {
return exitCode !== null ? exitCode : signal !== null ? signal : -1;
}

View File

@ -9,7 +9,6 @@
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {Options as SemVerOptions, parse, SemVer} from 'semver';
import {spawnWithDebugOutput} from '../child-process';
import {getConfig, GithubConfig, NgDevConfig} from '../config';
import {debug, info} from '../console';
import {DryRunError, isDryRun} from '../dry-run';
@ -24,6 +23,7 @@ export class GitCommandError extends Error {
// accidentally leak the Github token that might be used in a command,
// we sanitize the command that will be part of the error message.
super(`Command failed: git ${client.sanitizeConsoleOutput(args.join(' '))}`);
Object.setPrototypeOf(this, GitCommandError.prototype);
}
}