Use the version value from the primary package.json file rather than checking the branch for the latest semver tag. This allows for us to explictly create changelogs from the previous version to the new version. PR Close #42872
253 lines
10 KiB
TypeScript
253 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 {spawnWithDebugOutput} from '../child-process';
|
|
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(' '))}`);
|
|
}
|
|
}
|
|
|
|
/** 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();
|
|
}
|