257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.io/license
|
|
*/
|
|
|
|
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
|
|
import {Options as SemVerOptions, parse, SemVer} from 'semver';
|
|
|
|
import {getConfig, GithubConfig, NgDevConfig} from '../config';
|
|
import {debug, info} from '../console';
|
|
import {DryRunError, isDryRun} from '../dry-run';
|
|
|
|
import {GithubClient} from './github';
|
|
import {getRepositoryGitUrl} from './github-urls';
|
|
|
|
/** Error for failed Git commands. */
|
|
export class GitCommandError extends Error {
|
|
constructor(client: GitClient, public args: string[]) {
|
|
// Errors are not guaranteed to be caught. To ensure that we don't
|
|
// 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(' '))}`);
|
|
|
|
// Set the prototype explicitly because in ES5, the prototype is accidentally lost due to
|
|
// a limitation in down-leveling.
|
|
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work.
|
|
Object.setPrototypeOf(this, GitCommandError.prototype);
|
|
}
|
|
}
|
|
|
|
/** The options available for the `GitClient``run` and `runGraceful` methods. */
|
|
type GitCommandRunOptions = SpawnSyncOptions&{
|
|
verboseLogging?: boolean;
|
|
};
|
|
|
|
/** Class that can be used to perform Git interactions with a given remote. **/
|
|
export class GitClient {
|
|
/** Short-hand for accessing the default remote configuration. */
|
|
readonly remoteConfig: GithubConfig = this.config.github;
|
|
|
|
/** Octokit request parameters object for targeting the configured remote. */
|
|
readonly remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name};
|
|
|
|
/** Instance of the Github client. */
|
|
readonly github = new GithubClient();
|
|
|
|
constructor(
|
|
/** The full path to the root of the repository base. */
|
|
readonly baseDir = determineRepoBaseDirFromCwd(),
|
|
/** The configuration, containing the github specific configuration. */
|
|
readonly config = getConfig(baseDir)) {}
|
|
|
|
/** Executes the given git command. Throws if the command fails. */
|
|
run(args: string[], options?: GitCommandRunOptions): Omit<SpawnSyncReturns<string>, 'status'> {
|
|
const result = this.runGraceful(args, options);
|
|
if (result.status !== 0) {
|
|
throw new GitCommandError(this, args);
|
|
}
|
|
// Omit `status` from the type so that it's obvious that the status is never
|
|
// non-zero as explained in the method description.
|
|
return result as Omit<SpawnSyncReturns<string>, 'status'>;
|
|
}
|
|
|
|
/**
|
|
* Spawns a given Git command process. Does not throw if the command fails. Additionally,
|
|
* if there is any stderr output, the output will be printed. This makes it easier to
|
|
* info failed commands.
|
|
*/
|
|
runGraceful(args: string[], options: GitCommandRunOptions = {}): SpawnSyncReturns<string> {
|
|
/** The git command to be run. */
|
|
const gitCommand = args[0];
|
|
|
|
if (isDryRun() && gitCommand === 'push') {
|
|
debug(`"git push" is not able to be run in dryRun mode.`);
|
|
throw new DryRunError();
|
|
}
|
|
|
|
// To improve the debugging experience in case something fails, we print all executed Git
|
|
// commands at the DEBUG level to better understand the git actions occurring. Verbose logging,
|
|
// always logging at the INFO level, can be enabled either by setting the verboseLogging
|
|
// property on the GitClient class or the options object provided to the method.
|
|
const printFn = (GitClient.verboseLogging || options.verboseLogging) ? info : debug;
|
|
// Note that we sanitize the command before printing it to the console. We do not want to
|
|
// print an access token if it is contained in the command. It's common to share errors with
|
|
// others if the tool failed, and we do not want to leak tokens.
|
|
printFn('Executing: git', this.sanitizeConsoleOutput(args.join(' ')));
|
|
|
|
const result = spawnSync('git', args, {
|
|
cwd: this.baseDir,
|
|
stdio: 'pipe',
|
|
...options,
|
|
// Encoding is always `utf8` and not overridable. This ensures that this method
|
|
// always returns `string` as output instead of buffers.
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
if (result.stderr !== null) {
|
|
// Git sometimes prints the command if it failed. This means that it could
|
|
// potentially leak the Github token used for accessing the remote. To avoid
|
|
// printing a token, we sanitize the string before printing the stderr output.
|
|
process.stderr.write(this.sanitizeConsoleOutput(result.stderr));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Git URL that resolves to the configured repository. */
|
|
getRepoGitUrl() {
|
|
return getRepositoryGitUrl(this.remoteConfig);
|
|
}
|
|
|
|
/** Whether the given branch contains the specified SHA. */
|
|
hasCommit(branchName: string, sha: string): boolean {
|
|
return this.run(['branch', branchName, '--contains', sha]).stdout !== '';
|
|
}
|
|
|
|
/** Gets the currently checked out branch or revision. */
|
|
getCurrentBranchOrRevision(): string {
|
|
const branchName = this.run(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
|
|
// If no branch name could be resolved. i.e. `HEAD` has been returned, then Git
|
|
// is currently in a detached state. In those cases, we just want to return the
|
|
// currently checked out revision/SHA.
|
|
if (branchName === 'HEAD') {
|
|
return this.run(['rev-parse', 'HEAD']).stdout.trim();
|
|
}
|
|
return branchName;
|
|
}
|
|
|
|
/** Gets whether the current Git repository has uncommitted changes. */
|
|
hasUncommittedChanges(): boolean {
|
|
return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0;
|
|
}
|
|
|
|
/**
|
|
* Checks out a requested branch or revision, optionally cleaning the state of the repository
|
|
* before attempting the checking. Returns a boolean indicating whether the branch or revision
|
|
* was cleanly checked out.
|
|
*/
|
|
checkout(branchOrRevision: string, cleanState: boolean): boolean {
|
|
if (cleanState) {
|
|
// Abort any outstanding ams.
|
|
this.runGraceful(['am', '--abort'], {stdio: 'ignore'});
|
|
// Abort any outstanding cherry-picks.
|
|
this.runGraceful(['cherry-pick', '--abort'], {stdio: 'ignore'});
|
|
// Abort any outstanding rebases.
|
|
this.runGraceful(['rebase', '--abort'], {stdio: 'ignore'});
|
|
// Clear any changes in the current repo.
|
|
this.runGraceful(['reset', '--hard'], {stdio: 'ignore'});
|
|
}
|
|
return this.runGraceful(['checkout', branchOrRevision], {stdio: 'ignore'}).status === 0;
|
|
}
|
|
|
|
/** Gets the latest git tag on the current branch that matches SemVer. */
|
|
getLatestSemverTag(): SemVer {
|
|
const semVerOptions: SemVerOptions = {loose: true};
|
|
const tags = this.runGraceful(['tag', '--sort=-committerdate', '--merged']).stdout.split('\n');
|
|
const latestTag = tags.find((tag: string) => parse(tag, semVerOptions));
|
|
|
|
if (latestTag === undefined) {
|
|
throw new Error(
|
|
`Unable to find a SemVer matching tag on "${this.getCurrentBranchOrRevision()}"`);
|
|
}
|
|
return new SemVer(latestTag, semVerOptions);
|
|
}
|
|
|
|
/** Retrieves the git tag matching the provided SemVer, if it exists. */
|
|
getMatchingTagForSemver(semver: SemVer): string {
|
|
const semVerOptions: SemVerOptions = {loose: true};
|
|
const tags = this.runGraceful(['tag', '--sort=-committerdate', '--merged']).stdout.split('\n');
|
|
const matchingTag =
|
|
tags.find((tag: string) => parse(tag, semVerOptions)?.compare(semver) === 0);
|
|
|
|
if (matchingTag === undefined) {
|
|
throw new Error(`Unable to find a tag for the version: "${semver.format()}"`);
|
|
}
|
|
return matchingTag;
|
|
}
|
|
|
|
/** Retrieve a list of all files in the repository changed since the provided shaOrRef. */
|
|
allChangesFilesSince(shaOrRef = 'HEAD'): string[] {
|
|
return Array.from(new Set([
|
|
...gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=d', shaOrRef])),
|
|
...gitOutputAsArray(this.runGraceful(['ls-files', '--others', '--exclude-standard'])),
|
|
]));
|
|
}
|
|
|
|
/** Retrieve a list of all files currently staged in the repostitory. */
|
|
allStagedFiles(): string[] {
|
|
return gitOutputAsArray(
|
|
this.runGraceful(['diff', '--name-only', '--diff-filter=ACM', '--staged']));
|
|
}
|
|
|
|
/** Retrieve a list of all files tracked in the repository. */
|
|
allFiles(): string[] {
|
|
return gitOutputAsArray(this.runGraceful(['ls-files']));
|
|
}
|
|
|
|
/**
|
|
* Sanitizes the given console message. This method can be overridden by
|
|
* derived classes. e.g. to sanitize access tokens from Git commands.
|
|
*/
|
|
sanitizeConsoleOutput(value: string) {
|
|
return value;
|
|
}
|
|
|
|
/** Whether verbose logging of Git actions should be used. */
|
|
private static verboseLogging = false;
|
|
|
|
/** The singleton instance of the unauthenticated `GitClient`. */
|
|
private static _unauthenticatedInstance: GitClient;
|
|
|
|
/** Set the verbose logging state of all git client instances. */
|
|
static setVerboseLoggingState(verbose: boolean) {
|
|
GitClient.verboseLogging = verbose;
|
|
}
|
|
|
|
/**
|
|
* Static method to get the singleton instance of the `GitClient`, creating it
|
|
* if it has not yet been created.
|
|
*/
|
|
static get(): GitClient {
|
|
if (!this._unauthenticatedInstance) {
|
|
GitClient._unauthenticatedInstance = new GitClient();
|
|
}
|
|
return GitClient._unauthenticatedInstance;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Takes the output from `run` and `runGraceful` and returns an array of strings for each
|
|
* new line. Git commands typically return multiple output values for a command a set of
|
|
* strings separated by new lines.
|
|
*
|
|
* Note: This is specifically created as a locally available function for usage as convenience
|
|
* utility within `GitClient`'s methods to create outputs as array.
|
|
*/
|
|
function gitOutputAsArray(gitCommandResult: SpawnSyncReturns<string>): string[] {
|
|
return gitCommandResult.stdout.split('\n').map(x => x.trim()).filter(x => !!x);
|
|
}
|
|
|
|
/** Determines the repository base directory from the current working directory. */
|
|
function determineRepoBaseDirFromCwd() {
|
|
// TODO(devversion): Replace with common spawn sync utility once available.
|
|
const {stdout, stderr, status} = spawnSync(
|
|
'git', ['rev-parse --show-toplevel'], {shell: true, stdio: 'pipe', encoding: 'utf8'});
|
|
if (status !== 0) {
|
|
throw Error(
|
|
`Unable to find the path to the base directory of the repository.\n` +
|
|
`Was the command run from inside of the repo?\n\n` +
|
|
`${stderr}`);
|
|
}
|
|
return stdout.trim();
|
|
}
|