diff --git a/dev-infra/build-worker.js b/dev-infra/build-worker.js index 95491d4d51..1ee07bfd30 100644 --- a/dev-infra/build-worker.js +++ b/dev-infra/build-worker.js @@ -7,7 +7,461 @@ var fs = require('fs'); var path = require('path'); var chalk = _interopDefault(require('chalk')); require('inquirer'); -var shelljs = require('shelljs'); +var child_process = require('child_process'); +var semver = require('semver'); +var graphql = require('@octokit/graphql'); +var Octokit = require('@octokit/rest'); +var typedGraphqlify = require('typed-graphqlify'); +var url = require('url'); + +/** + * @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 + */ +/** Whether the current environment is in dry run mode. */ +function isDryRun() { + return process.env['DRY_RUN'] !== undefined; +} +/** Error to be thrown when a function or method is called in dryRun mode and shouldn't be. */ +var DryRunError = /** @class */ (function (_super) { + tslib.__extends(DryRunError, _super); + function DryRunError() { + var _this = _super.call(this, 'Cannot call this function in dryRun mode.') || this; + // 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, DryRunError.prototype); + return _this; + } + return DryRunError; +}(Error)); + +/** + * @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 + */ +/** Error for failed Github API requests. */ +var GithubApiRequestError = /** @class */ (function (_super) { + tslib.__extends(GithubApiRequestError, _super); + function GithubApiRequestError(status, message) { + var _this = _super.call(this, message) || this; + _this.status = status; + return _this; + } + return GithubApiRequestError; +}(Error)); +/** Error for failed Github API requests. */ +var GithubGraphqlClientError = /** @class */ (function (_super) { + tslib.__extends(GithubGraphqlClientError, _super); + function GithubGraphqlClientError() { + return _super !== null && _super.apply(this, arguments) || this; + } + return GithubGraphqlClientError; +}(Error)); +/** + * A Github client for interacting with the Github APIs. + * + * Additionally, provides convenience methods for actions which require multiple requests, or + * would provide value from memoized style responses. + **/ +var GithubClient = /** @class */ (function (_super) { + tslib.__extends(GithubClient, _super); + /** + * @param token The github authentication token for Github Rest and Graphql API requests. + */ + function GithubClient(token) { + var _this = + // Pass in authentication token to base Octokit class. + _super.call(this, { auth: token }) || this; + _this.token = token; + /** The current user based on checking against the Github API. */ + _this._currentUser = null; + /** The graphql instance with authentication set during construction. */ + _this._graphql = graphql.graphql.defaults({ headers: { authorization: "token " + _this.token } }); + _this.hook.error('request', function (error) { + // Wrap API errors in a known error class. This allows us to + // expect Github API errors better and in a non-ambiguous way. + throw new GithubApiRequestError(error.status, error.message); + }); + // Note: The prototype must be set explictly as Github's Octokit class is a non-standard class + // definition which adjusts the prototype chain. + // See: + // https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work + // https://github.com/octokit/rest.js/blob/7b51cee4a22b6e52adcdca011f93efdffa5df998/lib/constructor.js + Object.setPrototypeOf(_this, GithubClient.prototype); + return _this; + } + /** Perform a query using Github's Graphql API. */ + GithubClient.prototype.graphql = function (queryObject, params) { + if (params === void 0) { params = {}; } + return tslib.__awaiter(this, void 0, void 0, function () { + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + if (this.token === undefined) { + throw new GithubGraphqlClientError('Cannot query via graphql without an authentication token set, use the authenticated ' + + '`GitClient` by calling `GitClient.getAuthenticatedInstance()`.'); + } + return [4 /*yield*/, this._graphql(typedGraphqlify.query(queryObject), params)]; + case 1: return [2 /*return*/, (_a.sent())]; + } + }); + }); + }; + /** Retrieve the login of the current user from Github. */ + GithubClient.prototype.getCurrentUser = function () { + return tslib.__awaiter(this, void 0, void 0, function () { + var result; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: + // If the current user has already been retrieved return the current user value again. + if (this._currentUser !== null) { + return [2 /*return*/, this._currentUser]; + } + return [4 /*yield*/, this.graphql({ + viewer: { + login: typedGraphqlify.types.string, + } + })]; + case 1: + result = _a.sent(); + return [2 /*return*/, this._currentUser = result.viewer.login]; + } + }); + }); + }; + return GithubClient; +}(Octokit)); + +/** + * @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 + */ +/** URL to the Github page where personal access tokens can be managed. */ +var GITHUB_TOKEN_SETTINGS_URL = 'https://github.com/settings/tokens'; +/** URL to the Github page where personal access tokens can be generated. */ +var GITHUB_TOKEN_GENERATE_URL = 'https://github.com/settings/tokens/new'; +/** Adds the provided token to the given Github HTTPs remote url. */ +function addTokenToGitHttpsUrl(githubHttpsUrl, token) { + var url$1 = new url.URL(githubHttpsUrl); + url$1.username = token; + return url$1.href; +} +/** Gets the repository Git URL for the given github config. */ +function getRepositoryGitUrl(config, githubToken) { + if (config.useSsh) { + return "git@github.com:" + config.owner + "/" + config.name + ".git"; + } + var baseHttpUrl = "https://github.com/" + config.owner + "/" + config.name + ".git"; + if (githubToken !== undefined) { + return addTokenToGitHttpsUrl(baseHttpUrl, githubToken); + } + return baseHttpUrl; +} + +/** + * @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 + */ +/** Error for failed Git commands. */ +var GitCommandError = /** @class */ (function (_super) { + tslib.__extends(GitCommandError, _super); + function GitCommandError(client, args) { + var _this = + // 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.call(this, "Command failed: git " + client.omitGithubTokenFromMessage(args.join(' '))) || this; + _this.args = args; + return _this; + } + return GitCommandError; +}(Error)); +/** + * Common client for performing Git interactions with a given remote. + * + * Takes in two optional arguments: + * `githubToken`: the token used for authentication in Github interactions, by default empty + * allowing readonly actions. + * `config`: The dev-infra configuration containing information about the remote. By default + * the dev-infra configuration is loaded with its Github configuration. + **/ +var GitClient = /** @class */ (function () { + /** + * @param githubToken The github token used for authentication, if provided. + * @param _config The configuration, containing the github specific configuration. + * @param baseDir The full path to the root of the repository base. + */ + function GitClient(githubToken, config, baseDir) { + this.githubToken = githubToken; + /** Whether verbose logging of Git actions should be used. */ + this.verboseLogging = true; + /** The OAuth scopes available for the provided Github token. */ + this._cachedOauthScopes = null; + /** + * Regular expression that matches the provided Github token. Used for + * sanitizing the token from Git child process output. + */ + this._githubTokenRegex = null; + /** Instance of the Github octokit API. */ + this.github = new GithubClient(this.githubToken); + this.baseDir = baseDir || this.determineBaseDir(); + this.config = config || getConfig(this.baseDir); + this.remoteConfig = this.config.github; + this.remoteParams = { owner: this.remoteConfig.owner, repo: this.remoteConfig.name }; + // If a token has been specified (and is not empty), pass it to the Octokit API and + // also create a regular expression that can be used for sanitizing Git command output + // so that it does not print the token accidentally. + if (typeof githubToken === 'string') { + this._githubTokenRegex = new RegExp(githubToken, 'g'); + } + } + /** + * Static method to get the singleton instance of the unauthorized GitClient, creating it if it + * has not yet been created. + */ + GitClient.getInstance = function () { + if (!GitClient.unauthenticated) { + GitClient.unauthenticated = new GitClient(undefined); + } + return GitClient.unauthenticated; + }; + /** + * Static method to get the singleton instance of the authenticated GitClient if it has been + * generated. + */ + GitClient.getAuthenticatedInstance = function () { + if (!GitClient.authenticated) { + throw Error('The authenticated GitClient has not yet been generated.'); + } + return GitClient.authenticated; + }; + /** Build the authenticated GitClient instance. */ + GitClient.authenticateWithToken = function (token) { + if (GitClient.authenticated) { + throw Error('Cannot generate new authenticated GitClient after one has already been generated.'); + } + GitClient.authenticated = new GitClient(token); + }; + /** Set the verbose logging state of the GitClient instance. */ + GitClient.prototype.setVerboseLoggingState = function (verbose) { + this.verboseLogging = verbose; + return this; + }; + /** Executes the given git command. Throws if the command fails. */ + GitClient.prototype.run = function (args, options) { + var 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; + }; + /** + * 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. + */ + GitClient.prototype.runGraceful = function (args, options) { + if (options === void 0) { options = {}; } + /** The git command to be run. */ + var 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 to better understand the git actions occuring. Depending on the command being + // executed, this debugging information should be logged at different logging levels. + var printFn = (!this.verboseLogging || options.stdio === 'ignore') ? debug : info; + // Note that we do not want to print the 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.omitGithubTokenFromMessage(args.join(' '))); + var result = child_process.spawnSync('git', args, tslib.__assign(tslib.__assign({ 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.omitGithubTokenFromMessage(result.stderr)); + } + return result; + }; + /** Git URL that resolves to the configured repository. */ + GitClient.prototype.getRepoGitUrl = function () { + return getRepositoryGitUrl(this.remoteConfig, this.githubToken); + }; + /** Whether the given branch contains the specified SHA. */ + GitClient.prototype.hasCommit = function (branchName, sha) { + return this.run(['branch', branchName, '--contains', sha]).stdout !== ''; + }; + /** Gets the currently checked out branch or revision. */ + GitClient.prototype.getCurrentBranchOrRevision = function () { + var 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. */ + GitClient.prototype.hasUncommittedChanges = function () { + return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0; + }; + /** Whether the repo has any local changes. */ + GitClient.prototype.hasLocalChanges = function () { + return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0; + }; + /** Sanitizes a given message by omitting the provided Github token if present. */ + GitClient.prototype.omitGithubTokenFromMessage = function (value) { + // If no token has been defined (i.e. no token regex), we just return the + // value as is. There is no secret value that needs to be omitted. + if (this._githubTokenRegex === null) { + return value; + } + return value.replace(this._githubTokenRegex, ''); + }; + /** + * 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. + */ + GitClient.prototype.checkout = function (branchOrRevision, cleanState) { + 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. */ + GitClient.prototype.getLatestSemverTag = function () { + var semVerOptions = { loose: true }; + var tags = this.runGraceful(['tag', '--sort=-committerdate', '--merged']).stdout.split('\n'); + var latestTag = tags.find(function (tag) { return semver.parse(tag, semVerOptions); }); + if (latestTag === undefined) { + throw new Error("Unable to find a SemVer matching tag on \"" + this.getCurrentBranchOrRevision() + "\""); + } + return new semver.SemVer(latestTag, semVerOptions); + }; + /** Gets the path of the directory for the repository base. */ + GitClient.prototype.getBaseDir = function () { + var previousVerboseLoggingState = this.verboseLogging; + this.setVerboseLoggingState(false); + var _a = this.runGraceful(['rev-parse', '--show-toplevel']), stdout = _a.stdout, stderr = _a.stderr, status = _a.status; + this.setVerboseLoggingState(previousVerboseLoggingState); + 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" + + ("ERROR:\n " + stderr)); + } + return stdout.trim(); + }; + /** Retrieve a list of all files in the repostitory changed since the provided shaOrRef. */ + GitClient.prototype.allChangesFilesSince = function (shaOrRef) { + if (shaOrRef === void 0) { shaOrRef = 'HEAD'; } + return Array.from(new Set(tslib.__spreadArray(tslib.__spreadArray([], tslib.__read(gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=d', shaOrRef])))), tslib.__read(gitOutputAsArray(this.runGraceful(['ls-files', '--others', '--exclude-standard'])))))); + }; + /** Retrieve a list of all files currently staged in the repostitory. */ + GitClient.prototype.allStagedFiles = function () { + return gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=ACM', '--staged'])); + }; + /** Retrieve a list of all files tracked in the repostitory. */ + GitClient.prototype.allFiles = function () { + return gitOutputAsArray(this.runGraceful(['ls-files'])); + }; + /** + * Assert the GitClient instance is using a token with permissions for the all of the + * provided OAuth scopes. + */ + GitClient.prototype.hasOauthScopes = function (testFn) { + return tslib.__awaiter(this, void 0, void 0, function () { + var scopes, missingScopes, error; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, this.getAuthScopesForToken()]; + case 1: + scopes = _a.sent(); + missingScopes = []; + // Test Github OAuth scopes and collect missing ones. + testFn(scopes, missingScopes); + // If no missing scopes are found, return true to indicate all OAuth Scopes are available. + if (missingScopes.length === 0) { + return [2 /*return*/, true]; + } + error = "The provided does not have required permissions due to missing scope(s): " + + (yellow(missingScopes.join(', ')) + "\n\n") + + "Update the token in use at:\n" + + (" " + GITHUB_TOKEN_SETTINGS_URL + "\n\n") + + ("Alternatively, a new token can be created at: " + GITHUB_TOKEN_GENERATE_URL + "\n"); + return [2 /*return*/, { error: error }]; + } + }); + }); + }; + /** + * Retrieve the OAuth scopes for the loaded Github token. + **/ + GitClient.prototype.getAuthScopesForToken = function () { + // If the OAuth scopes have already been loaded, return the Promise containing them. + if (this._cachedOauthScopes !== null) { + return this._cachedOauthScopes; + } + // OAuth scopes are loaded via the /rate_limit endpoint to prevent + // usage of a request against that rate_limit for this lookup. + return this._cachedOauthScopes = this.github.rateLimit.get().then(function (_response) { + var response = _response; + var scopes = response.headers['x-oauth-scopes'] || ''; + return scopes.split(',').map(function (scope) { return scope.trim(); }); + }); + }; + GitClient.prototype.determineBaseDir = function () { + this.setVerboseLoggingState(false); + var _a = this.runGraceful(['rev-parse', '--show-toplevel']), stdout = _a.stdout, stderr = _a.stderr, status = _a.status; + 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" + + ("ERROR:\n " + stderr)); + } + this.setVerboseLoggingState(true); + return stdout.trim(); + }; + return GitClient; +}()); +/** + * Takes the output from `GitClient.run` and `GitClient.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 convience utility + * within `GitClient`'s methods to create outputs as array. + */ +function gitOutputAsArray(gitCommandResult) { + return gitCommandResult.stdout.split('\n').map(function (x) { return x.trim(); }).filter(function (x) { return !!x; }); +} /** * @license @@ -117,21 +571,6 @@ function printToLogFile(logLevel) { var logLevelText = (LOG_LEVELS[logLevel] + ":").padEnd(LOG_LEVEL_COLUMNS); } -/** - * @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 - */ -/** - * Runs an given command as child process. By default, child process - * output will not be printed. - */ -function exec(cmd, opts) { - return shelljs.exec(cmd, tslib.__assign(tslib.__assign({ silent: true }, opts), { async: false })); -} - /** * @license * Copyright Google LLC All Rights Reserved. @@ -164,15 +603,12 @@ function isTsNodeAvailable() { var CONFIG_FILE_PATH = '.ng-dev/config'; /** The configuration for ng-dev. */ var cachedConfig = null; -/** - * Get the configuration from the file system, returning the already loaded - * copy if it is defined. - */ -function getConfig() { +function getConfig(baseDir) { // If the global config is not defined, load it from the file system. if (cachedConfig === null) { + baseDir = baseDir || GitClient.getInstance().getBaseDir(); // The full path to the configuration file. - var configPath = path.join(getRepoBaseDir(), CONFIG_FILE_PATH); + var configPath = path.join(baseDir, CONFIG_FILE_PATH); // Read the configuration and validate it before caching it for the future. cachedConfig = validateCommonConfig(readConfigFile(configPath)); } @@ -253,16 +689,6 @@ function assertNoErrors(errors) { } process.exit(1); } -/** Gets the path of the directory for the repository base. */ -function getRepoBaseDir() { - var baseRepoDir = exec("git rev-parse --show-toplevel"); - if (baseRepoDir.code) { - 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" + - ("ERROR:\n " + baseRepoDir.stderr)); - } - return baseRepoDir.trim(); -} /** * @license diff --git a/dev-infra/caretaker/check/g3.spec.ts b/dev-infra/caretaker/check/g3.spec.ts index 3fc9bec36c..ab7b63c520 100644 --- a/dev-infra/caretaker/check/g3.spec.ts +++ b/dev-infra/caretaker/check/g3.spec.ts @@ -9,7 +9,7 @@ import {SpawnSyncReturns} from 'child_process'; import * as console from '../../utils/console'; -import {GitClient} from '../../utils/git'; +import {GitClient} from '../../utils/git/index'; import {installVirtualGitClientSpies, mockNgDevConfig} from '../../utils/testing'; import {G3Module, G3StatsData} from './g3'; diff --git a/dev-infra/caretaker/check/g3.ts b/dev-infra/caretaker/check/g3.ts index a66dbafcc4..94ec261505 100644 --- a/dev-infra/caretaker/check/g3.ts +++ b/dev-infra/caretaker/check/g3.ts @@ -10,7 +10,6 @@ import {existsSync, readFileSync} from 'fs'; import * as multimatch from 'multimatch'; import {join} from 'path'; import {parse as parseYaml} from 'yaml'; -import {getRepoBaseDir} from '../../utils/config'; import {bold, debug, error, info} from '../../utils/console'; import {BaseModule} from './base'; @@ -121,7 +120,7 @@ export class G3Module extends BaseModule { private getG3FileIncludeAndExcludeLists() { - const angularRobotFilePath = join(getRepoBaseDir(), '.github/angular-robot.yml'); + const angularRobotFilePath = join(this.git.baseDir, '.github/angular-robot.yml'); if (!existsSync(angularRobotFilePath)) { debug('No angular robot configuration file exists, skipping.'); return null; diff --git a/dev-infra/commit-message/validate-file/validate-file.ts b/dev-infra/commit-message/validate-file/validate-file.ts index 965b0c6a5c..5656cc5c16 100644 --- a/dev-infra/commit-message/validate-file/validate-file.ts +++ b/dev-infra/commit-message/validate-file/validate-file.ts @@ -8,15 +8,16 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; -import {getRepoBaseDir} from '../../utils/config'; import {error, green, info, log, red, yellow} from '../../utils/console'; +import {GitClient} from '../../utils/git/index'; import {deleteCommitMessageDraft, saveCommitMessageDraft} from '../restore-commit-message/commit-message-draft'; import {printValidationErrors, validateCommitMessage} from '../validate'; /** Validate commit message at the provided file path. */ export function validateFile(filePath: string, isErrorMode: boolean) { - const commitMessage = readFileSync(resolve(getRepoBaseDir(), filePath), 'utf8'); + const git = GitClient.getInstance(); + const commitMessage = readFileSync(resolve(git.baseDir, filePath), 'utf8'); const {valid, errors} = validateCommitMessage(commitMessage); if (valid) { info(`${green('√')} Valid commit message`); diff --git a/dev-infra/format/cli.ts b/dev-infra/format/cli.ts index 8b3c55297e..cf1e44ee87 100644 --- a/dev-infra/format/cli.ts +++ b/dev-infra/format/cli.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import * as yargs from 'yargs'; - -import {allChangedFilesSince, allFiles, allStagedFiles} from '../utils/repo-files'; +import {GitClient} from '../utils/git/index'; import {checkFiles, formatFiles} from './format'; @@ -25,7 +24,8 @@ export function buildFormatParser(localYargs: yargs.Argv) { 'all', 'Run the formatter on all files in the repository', args => args, ({check}) => { const executionCmd = check ? checkFiles : formatFiles; - executionCmd(allFiles()); + const allFiles = GitClient.getInstance().allFiles(); + executionCmd(allFiles); }) .command( 'changed [shaOrRef]', 'Run the formatter on files changed since the provided sha/ref', @@ -33,13 +33,15 @@ export function buildFormatParser(localYargs: yargs.Argv) { ({shaOrRef, check}) => { const sha = shaOrRef || 'master'; const executionCmd = check ? checkFiles : formatFiles; - executionCmd(allChangedFilesSince(sha)); + const allChangedFilesSince = GitClient.getInstance().allChangesFilesSince(sha); + executionCmd(allChangedFilesSince); }) .command( 'staged', 'Run the formatter on all staged files', args => args, ({check}) => { const executionCmd = check ? checkFiles : formatFiles; - executionCmd(allStagedFiles()); + const allStagedFiles = GitClient.getInstance().allStagedFiles(); + executionCmd(allStagedFiles); }) .command( 'files ', 'Run the formatter on provided files', diff --git a/dev-infra/format/formatters/base-formatter.ts b/dev-infra/format/formatters/base-formatter.ts index 8903032b94..b22eb8c968 100644 --- a/dev-infra/format/formatters/base-formatter.ts +++ b/dev-infra/format/formatters/base-formatter.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {GitClient} from '../../utils/git/index'; import {FormatConfig} from '../config'; // A callback to determine if the formatter run found a failure in formatting. @@ -24,6 +25,7 @@ interface FormatterActionMetadata { * The base class for formatters to run against provided files. */ export abstract class Formatter { + protected git = GitClient.getInstance(); /** * The name of the formatter, this is used for identification in logging and for enabling and * configuring the formatter in the config. diff --git a/dev-infra/format/formatters/buildifier.ts b/dev-infra/format/formatters/buildifier.ts index 9f52e50cd0..823200faa7 100644 --- a/dev-infra/format/formatters/buildifier.ts +++ b/dev-infra/format/formatters/buildifier.ts @@ -8,7 +8,6 @@ import {join} from 'path'; -import {getRepoBaseDir} from '../../utils/config'; import {error} from '../../utils/console'; import {Formatter} from './base-formatter'; @@ -19,7 +18,7 @@ import {Formatter} from './base-formatter'; export class Buildifier extends Formatter { name = 'buildifier'; - binaryFilePath = join(getRepoBaseDir(), 'node_modules/.bin/buildifier'); + binaryFilePath = join(this.git.baseDir, 'node_modules/.bin/buildifier'); defaultFileMatcher = ['**/*.bzl', '**/BUILD.bazel', '**/WORKSPACE', '**/BUILD']; diff --git a/dev-infra/format/formatters/clang-format.ts b/dev-infra/format/formatters/clang-format.ts index fcd098c5f6..971893b336 100644 --- a/dev-infra/format/formatters/clang-format.ts +++ b/dev-infra/format/formatters/clang-format.ts @@ -8,7 +8,6 @@ import {join} from 'path'; -import {getRepoBaseDir} from '../../utils/config'; import {error} from '../../utils/console'; import {Formatter} from './base-formatter'; @@ -19,7 +18,7 @@ import {Formatter} from './base-formatter'; export class ClangFormat extends Formatter { name = 'clang-format'; - binaryFilePath = join(getRepoBaseDir(), 'node_modules/.bin/clang-format'); + binaryFilePath = join(this.git.baseDir, 'node_modules/.bin/clang-format'); defaultFileMatcher = ['**/*.{t,j}s']; diff --git a/dev-infra/ng-dev.js b/dev-infra/ng-dev.js index d20c1495ff..3e13d97362 100755 --- a/dev-infra/ng-dev.js +++ b/dev-infra/ng-dev.js @@ -9,13 +9,12 @@ var chalk = _interopDefault(require('chalk')); var fs = require('fs'); var inquirer = require('inquirer'); var path = require('path'); -var shelljs = require('shelljs'); -var url = require('url'); var child_process = require('child_process'); var semver = require('semver'); var graphql = require('@octokit/graphql'); var Octokit = require('@octokit/rest'); var typedGraphqlify = require('typed-graphqlify'); +var url = require('url'); var fetch = _interopDefault(require('node-fetch')); var multimatch = require('multimatch'); var yaml = require('yaml'); @@ -23,27 +22,13 @@ var conventionalCommitsParser = require('conventional-commits-parser'); var gitCommits_ = require('git-raw-commits'); var cliProgress = require('cli-progress'); var os = require('os'); +var shelljs = require('shelljs'); var minimatch = require('minimatch'); var ora = require('ora'); require('ejs'); var glob = require('glob'); var ts = require('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 - */ -/** - * Runs an given command as child process. By default, child process - * output will not be printed. - */ -function exec(cmd, opts) { - return shelljs.exec(cmd, tslib.__assign(tslib.__assign({ silent: true }, opts), { async: false })); -} - /** * @license * Copyright Google LLC All Rights Reserved. @@ -83,15 +68,12 @@ var cachedConfig = null; var USER_CONFIG_FILE_PATH = '.ng-dev.user'; /** The local user configuration for ng-dev. */ var userConfig = null; -/** - * Get the configuration from the file system, returning the already loaded - * copy if it is defined. - */ -function getConfig() { +function getConfig(baseDir) { // If the global config is not defined, load it from the file system. if (cachedConfig === null) { + baseDir = baseDir || GitClient.getInstance().getBaseDir(); // The full path to the configuration file. - var configPath = path.join(getRepoBaseDir(), CONFIG_FILE_PATH); + var configPath = path.join(baseDir, CONFIG_FILE_PATH); // Read the configuration and validate it before caching it for the future. cachedConfig = validateCommonConfig(readConfigFile(configPath)); } @@ -172,16 +154,6 @@ function assertNoErrors(errors) { } process.exit(1); } -/** Gets the path of the directory for the repository base. */ -function getRepoBaseDir() { - var baseRepoDir = exec("git rev-parse --show-toplevel"); - if (baseRepoDir.code) { - 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" + - ("ERROR:\n " + baseRepoDir.stderr)); - } - return baseRepoDir.trim(); -} /** * Get the local user configuration from the file system, returning the already loaded copy if it is * defined. @@ -192,8 +164,9 @@ function getRepoBaseDir() { function getUserConfig() { // If the global config is not defined, load it from the file system. if (userConfig === null) { + var git = GitClient.getInstance(); // The full path to the configuration file. - var configPath = path.join(getRepoBaseDir(), USER_CONFIG_FILE_PATH); + var configPath = path.join(git.baseDir, USER_CONFIG_FILE_PATH); // Set the global config object. userConfig = readConfigFile(configPath, true); } @@ -202,209 +175,6 @@ function getUserConfig() { return tslib.__assign({}, userConfig); } -/** - * @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 - */ -/** Reexport of chalk colors for convenient access. */ -var red = chalk.red; -var green = chalk.green; -var yellow = chalk.yellow; -var bold = chalk.bold; -var blue = chalk.blue; -/** Prompts the user with a confirmation question and a specified message. */ -function promptConfirm(message, defaultValue) { - if (defaultValue === void 0) { defaultValue = false; } - return tslib.__awaiter(this, void 0, void 0, function () { - return tslib.__generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, inquirer.prompt({ - type: 'confirm', - name: 'result', - message: message, - default: defaultValue, - })]; - case 1: return [2 /*return*/, (_a.sent()) - .result]; - } - }); - }); -} -/** - * Supported levels for logging functions. - * - * Levels are mapped to numbers to represent a hierarchy of logging levels. - */ -var LOG_LEVELS; -(function (LOG_LEVELS) { - LOG_LEVELS[LOG_LEVELS["SILENT"] = 0] = "SILENT"; - LOG_LEVELS[LOG_LEVELS["ERROR"] = 1] = "ERROR"; - LOG_LEVELS[LOG_LEVELS["WARN"] = 2] = "WARN"; - LOG_LEVELS[LOG_LEVELS["LOG"] = 3] = "LOG"; - LOG_LEVELS[LOG_LEVELS["INFO"] = 4] = "INFO"; - LOG_LEVELS[LOG_LEVELS["DEBUG"] = 5] = "DEBUG"; -})(LOG_LEVELS || (LOG_LEVELS = {})); -/** Default log level for the tool. */ -var DEFAULT_LOG_LEVEL = LOG_LEVELS.INFO; -/** Write to the console for at INFO logging level */ -var info = buildLogLevelFunction(function () { return console.info; }, LOG_LEVELS.INFO); -/** Write to the console for at ERROR logging level */ -var error = buildLogLevelFunction(function () { return console.error; }, LOG_LEVELS.ERROR); -/** Write to the console for at DEBUG logging level */ -var debug = buildLogLevelFunction(function () { return console.debug; }, LOG_LEVELS.DEBUG); -/** Write to the console for at LOG logging level */ -// tslint:disable-next-line: no-console -var log = buildLogLevelFunction(function () { return console.log; }, LOG_LEVELS.LOG); -/** Write to the console for at WARN logging level */ -var warn = buildLogLevelFunction(function () { return console.warn; }, LOG_LEVELS.WARN); -/** Build an instance of a logging function for the provided level. */ -function buildLogLevelFunction(loadCommand, level) { - /** Write to stdout for the LOG_LEVEL. */ - var loggingFunction = function () { - var text = []; - for (var _i = 0; _i < arguments.length; _i++) { - text[_i] = arguments[_i]; - } - runConsoleCommand.apply(void 0, tslib.__spreadArray([loadCommand, level], tslib.__read(text))); - }; - /** Start a group at the LOG_LEVEL, optionally starting it as collapsed. */ - loggingFunction.group = function (text, collapsed) { - if (collapsed === void 0) { collapsed = false; } - var command = collapsed ? console.groupCollapsed : console.group; - runConsoleCommand(function () { return command; }, level, text); - }; - /** End the group at the LOG_LEVEL. */ - loggingFunction.groupEnd = function () { - runConsoleCommand(function () { return console.groupEnd; }, level); - }; - return loggingFunction; -} -/** - * Run the console command provided, if the environments logging level greater than the - * provided logging level. - * - * The loadCommand takes in a function which is called to retrieve the console.* function - * to allow for jasmine spies to still work in testing. Without this method of retrieval - * the console.* function, the function is saved into the closure of the created logging - * function before jasmine can spy. - */ -function runConsoleCommand(loadCommand, logLevel) { - var text = []; - for (var _i = 2; _i < arguments.length; _i++) { - text[_i - 2] = arguments[_i]; - } - if (getLogLevel() >= logLevel) { - loadCommand().apply(void 0, tslib.__spreadArray([], tslib.__read(text))); - } - printToLogFile.apply(void 0, tslib.__spreadArray([logLevel], tslib.__read(text))); -} -/** - * Retrieve the log level from environment variables, if the value found - * based on the LOG_LEVEL environment variable is undefined, return the default - * logging level. - */ -function getLogLevel() { - var logLevelEnvValue = (process.env["LOG_LEVEL"] || '').toUpperCase(); - var logLevel = LOG_LEVELS[logLevelEnvValue]; - if (logLevel === undefined) { - return DEFAULT_LOG_LEVEL; - } - return logLevel; -} -/** All text to write to the log file. */ -var LOGGED_TEXT = ''; -/** Whether file logging as been enabled. */ -var FILE_LOGGING_ENABLED = false; -/** - * The number of columns used in the prepended log level information on each line of the logging - * output file. - */ -var LOG_LEVEL_COLUMNS = 7; -/** - * Enable writing the logged outputs to the log file on process exit, sets initial lines from the - * command execution, containing information about the timing and command parameters. - * - * This is expected to be called only once during a command run, and should be called by the - * middleware of yargs to enable the file logging before the rest of the command parsing and - * response is executed. - */ -function captureLogOutputForCommand(argv) { - if (FILE_LOGGING_ENABLED) { - throw Error('`captureLogOutputForCommand` cannot be called multiple times'); - } - /** The date time used for timestamping when the command was invoked. */ - var now = new Date(); - /** Header line to separate command runs in log files. */ - var headerLine = Array(100).fill('#').join(''); - LOGGED_TEXT += headerLine + "\nCommand: " + argv.$0 + " " + argv._.join(' ') + "\nRan at: " + now + "\n"; - // On process exit, write the logged output to the appropriate log files - process.on('exit', function (code) { - LOGGED_TEXT += headerLine + "\n"; - LOGGED_TEXT += "Command ran in " + (new Date().getTime() - now.getTime()) + "ms\n"; - LOGGED_TEXT += "Exit Code: " + code + "\n"; - /** Path to the log file location. */ - var logFilePath = path.join(getRepoBaseDir(), '.ng-dev.log'); - // Strip ANSI escape codes from log outputs. - LOGGED_TEXT = LOGGED_TEXT.replace(/\x1B\[([0-9]{1,3}(;[0-9]{1,2})?)?[mGK]/g, ''); - fs.writeFileSync(logFilePath, LOGGED_TEXT); - // For failure codes greater than 1, the new logged lines should be written to a specific log - // file for the command run failure. - if (code > 1) { - var logFileName = ".ng-dev.err-" + now.getTime() + ".log"; - console.error("Exit code: " + code + ". Writing full log to " + logFileName); - fs.writeFileSync(path.join(getRepoBaseDir(), logFileName), LOGGED_TEXT); - } - }); - // Mark file logging as enabled to prevent the function from executing multiple times. - FILE_LOGGING_ENABLED = true; -} -/** Write the provided text to the log file, prepending each line with the log level. */ -function printToLogFile(logLevel) { - var text = []; - for (var _i = 1; _i < arguments.length; _i++) { - text[_i - 1] = arguments[_i]; - } - var logLevelText = (LOG_LEVELS[logLevel] + ":").padEnd(LOG_LEVEL_COLUMNS); - LOGGED_TEXT += text.join(' ').split('\n').map(function (l) { return logLevelText + " " + l + "\n"; }).join(''); -} - -/** - * @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 - */ -/** URL to the Github page where personal access tokens can be managed. */ -var GITHUB_TOKEN_SETTINGS_URL = 'https://github.com/settings/tokens'; -/** URL to the Github page where personal access tokens can be generated. */ -var GITHUB_TOKEN_GENERATE_URL = 'https://github.com/settings/tokens/new'; -/** Adds the provided token to the given Github HTTPs remote url. */ -function addTokenToGitHttpsUrl(githubHttpsUrl, token) { - var url$1 = new url.URL(githubHttpsUrl); - url$1.username = token; - return url$1.href; -} -/** Gets the repository Git URL for the given github config. */ -function getRepositoryGitUrl(config, githubToken) { - if (config.useSsh) { - return "git@github.com:" + config.owner + "/" + config.name + ".git"; - } - var baseHttpUrl = "https://github.com/" + config.owner + "/" + config.name + ".git"; - if (githubToken !== undefined) { - return addTokenToGitHttpsUrl(baseHttpUrl, githubToken); - } - return baseHttpUrl; -} -/** Gets a Github URL that refers to a list of recent commits within a specified branch. */ -function getListCommitsInBranchUrl(_a, branchName) { - var remoteParams = _a.remoteParams; - return "https://github.com/" + remoteParams.owner + "/" + remoteParams.repo + "/commits/" + branchName; -} - /** * @license * Copyright Google LLC All Rights Reserved. @@ -531,6 +301,40 @@ var GithubClient = /** @class */ (function (_super) { return GithubClient; }(Octokit)); +/** + * @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 + */ +/** URL to the Github page where personal access tokens can be managed. */ +var GITHUB_TOKEN_SETTINGS_URL = 'https://github.com/settings/tokens'; +/** URL to the Github page where personal access tokens can be generated. */ +var GITHUB_TOKEN_GENERATE_URL = 'https://github.com/settings/tokens/new'; +/** Adds the provided token to the given Github HTTPs remote url. */ +function addTokenToGitHttpsUrl(githubHttpsUrl, token) { + var url$1 = new url.URL(githubHttpsUrl); + url$1.username = token; + return url$1.href; +} +/** Gets the repository Git URL for the given github config. */ +function getRepositoryGitUrl(config, githubToken) { + if (config.useSsh) { + return "git@github.com:" + config.owner + "/" + config.name + ".git"; + } + var baseHttpUrl = "https://github.com/" + config.owner + "/" + config.name + ".git"; + if (githubToken !== undefined) { + return addTokenToGitHttpsUrl(baseHttpUrl, githubToken); + } + return baseHttpUrl; +} +/** Gets a Github URL that refers to a list of recent commits within a specified branch. */ +function getListCommitsInBranchUrl(_a, branchName) { + var remoteParams = _a.remoteParams; + return "https://github.com/" + remoteParams.owner + "/" + remoteParams.repo + "/commits/" + branchName; +} + /** * @license * Copyright Google LLC All Rights Reserved. @@ -565,14 +369,10 @@ var GitClient = /** @class */ (function () { /** * @param githubToken The github token used for authentication, if provided. * @param _config The configuration, containing the github specific configuration. - * @param _projectRoot The full path to the root of the repository base. + * @param baseDir The full path to the root of the repository base. */ - function GitClient(githubToken, _config, _projectRoot) { - if (_config === void 0) { _config = getConfig(); } - if (_projectRoot === void 0) { _projectRoot = getRepoBaseDir(); } + function GitClient(githubToken, config, baseDir) { this.githubToken = githubToken; - this._config = _config; - this._projectRoot = _projectRoot; /** Whether verbose logging of Git actions should be used. */ this.verboseLogging = true; /** The OAuth scopes available for the provided Github token. */ @@ -582,12 +382,12 @@ var GitClient = /** @class */ (function () { * sanitizing the token from Git child process output. */ this._githubTokenRegex = null; - /** Short-hand for accessing the default remote configuration. */ - this.remoteConfig = this._config.github; - /** Octokit request parameters object for targeting the configured remote. */ - this.remoteParams = { owner: this.remoteConfig.owner, repo: this.remoteConfig.name }; - /** Instance of the authenticated Github octokit API. */ + /** Instance of the Github octokit API. */ this.github = new GithubClient(this.githubToken); + this.baseDir = baseDir || this.determineBaseDir(); + this.config = config || getConfig(this.baseDir); + this.remoteConfig = this.config.github; + this.remoteParams = { owner: this.remoteConfig.owner, repo: this.remoteConfig.name }; // If a token has been specified (and is not empty), pass it to the Octokit API and // also create a regular expression that can be used for sanitizing Git command output // so that it does not print the token accidentally. @@ -657,7 +457,7 @@ var GitClient = /** @class */ (function () { // Note that we do not want to print the 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.omitGithubTokenFromMessage(args.join(' '))); - var result = child_process.spawnSync('git', args, tslib.__assign(tslib.__assign({ cwd: this._projectRoot, stdio: 'pipe' }, options), { + var result = child_process.spawnSync('git', args, tslib.__assign(tslib.__assign({ 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' })); @@ -733,6 +533,32 @@ var GitClient = /** @class */ (function () { } return new semver.SemVer(latestTag, semVerOptions); }; + /** Gets the path of the directory for the repository base. */ + GitClient.prototype.getBaseDir = function () { + var previousVerboseLoggingState = this.verboseLogging; + this.setVerboseLoggingState(false); + var _a = this.runGraceful(['rev-parse', '--show-toplevel']), stdout = _a.stdout, stderr = _a.stderr, status = _a.status; + this.setVerboseLoggingState(previousVerboseLoggingState); + 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" + + ("ERROR:\n " + stderr)); + } + return stdout.trim(); + }; + /** Retrieve a list of all files in the repostitory changed since the provided shaOrRef. */ + GitClient.prototype.allChangesFilesSince = function (shaOrRef) { + if (shaOrRef === void 0) { shaOrRef = 'HEAD'; } + return Array.from(new Set(tslib.__spreadArray(tslib.__spreadArray([], tslib.__read(gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=d', shaOrRef])))), tslib.__read(gitOutputAsArray(this.runGraceful(['ls-files', '--others', '--exclude-standard'])))))); + }; + /** Retrieve a list of all files currently staged in the repostitory. */ + GitClient.prototype.allStagedFiles = function () { + return gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=ACM', '--staged'])); + }; + /** Retrieve a list of all files tracked in the repostitory. */ + GitClient.prototype.allFiles = function () { + return gitOutputAsArray(this.runGraceful(['ls-files'])); + }; /** * Assert the GitClient instance is using a token with permissions for the all of the * provided OAuth scopes. @@ -778,8 +604,200 @@ var GitClient = /** @class */ (function () { return scopes.split(',').map(function (scope) { return scope.trim(); }); }); }; + GitClient.prototype.determineBaseDir = function () { + this.setVerboseLoggingState(false); + var _a = this.runGraceful(['rev-parse', '--show-toplevel']), stdout = _a.stdout, stderr = _a.stderr, status = _a.status; + 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" + + ("ERROR:\n " + stderr)); + } + this.setVerboseLoggingState(true); + return stdout.trim(); + }; return GitClient; }()); +/** + * Takes the output from `GitClient.run` and `GitClient.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 convience utility + * within `GitClient`'s methods to create outputs as array. + */ +function gitOutputAsArray(gitCommandResult) { + return gitCommandResult.stdout.split('\n').map(function (x) { return x.trim(); }).filter(function (x) { return !!x; }); +} + +/** + * @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 + */ +/** Reexport of chalk colors for convenient access. */ +var red = chalk.red; +var green = chalk.green; +var yellow = chalk.yellow; +var bold = chalk.bold; +var blue = chalk.blue; +/** Prompts the user with a confirmation question and a specified message. */ +function promptConfirm(message, defaultValue) { + if (defaultValue === void 0) { defaultValue = false; } + return tslib.__awaiter(this, void 0, void 0, function () { + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, inquirer.prompt({ + type: 'confirm', + name: 'result', + message: message, + default: defaultValue, + })]; + case 1: return [2 /*return*/, (_a.sent()) + .result]; + } + }); + }); +} +/** + * Supported levels for logging functions. + * + * Levels are mapped to numbers to represent a hierarchy of logging levels. + */ +var LOG_LEVELS; +(function (LOG_LEVELS) { + LOG_LEVELS[LOG_LEVELS["SILENT"] = 0] = "SILENT"; + LOG_LEVELS[LOG_LEVELS["ERROR"] = 1] = "ERROR"; + LOG_LEVELS[LOG_LEVELS["WARN"] = 2] = "WARN"; + LOG_LEVELS[LOG_LEVELS["LOG"] = 3] = "LOG"; + LOG_LEVELS[LOG_LEVELS["INFO"] = 4] = "INFO"; + LOG_LEVELS[LOG_LEVELS["DEBUG"] = 5] = "DEBUG"; +})(LOG_LEVELS || (LOG_LEVELS = {})); +/** Default log level for the tool. */ +var DEFAULT_LOG_LEVEL = LOG_LEVELS.INFO; +/** Write to the console for at INFO logging level */ +var info = buildLogLevelFunction(function () { return console.info; }, LOG_LEVELS.INFO); +/** Write to the console for at ERROR logging level */ +var error = buildLogLevelFunction(function () { return console.error; }, LOG_LEVELS.ERROR); +/** Write to the console for at DEBUG logging level */ +var debug = buildLogLevelFunction(function () { return console.debug; }, LOG_LEVELS.DEBUG); +/** Write to the console for at LOG logging level */ +// tslint:disable-next-line: no-console +var log = buildLogLevelFunction(function () { return console.log; }, LOG_LEVELS.LOG); +/** Write to the console for at WARN logging level */ +var warn = buildLogLevelFunction(function () { return console.warn; }, LOG_LEVELS.WARN); +/** Build an instance of a logging function for the provided level. */ +function buildLogLevelFunction(loadCommand, level) { + /** Write to stdout for the LOG_LEVEL. */ + var loggingFunction = function () { + var text = []; + for (var _i = 0; _i < arguments.length; _i++) { + text[_i] = arguments[_i]; + } + runConsoleCommand.apply(void 0, tslib.__spreadArray([loadCommand, level], tslib.__read(text))); + }; + /** Start a group at the LOG_LEVEL, optionally starting it as collapsed. */ + loggingFunction.group = function (text, collapsed) { + if (collapsed === void 0) { collapsed = false; } + var command = collapsed ? console.groupCollapsed : console.group; + runConsoleCommand(function () { return command; }, level, text); + }; + /** End the group at the LOG_LEVEL. */ + loggingFunction.groupEnd = function () { + runConsoleCommand(function () { return console.groupEnd; }, level); + }; + return loggingFunction; +} +/** + * Run the console command provided, if the environments logging level greater than the + * provided logging level. + * + * The loadCommand takes in a function which is called to retrieve the console.* function + * to allow for jasmine spies to still work in testing. Without this method of retrieval + * the console.* function, the function is saved into the closure of the created logging + * function before jasmine can spy. + */ +function runConsoleCommand(loadCommand, logLevel) { + var text = []; + for (var _i = 2; _i < arguments.length; _i++) { + text[_i - 2] = arguments[_i]; + } + if (getLogLevel() >= logLevel) { + loadCommand().apply(void 0, tslib.__spreadArray([], tslib.__read(text))); + } + printToLogFile.apply(void 0, tslib.__spreadArray([logLevel], tslib.__read(text))); +} +/** + * Retrieve the log level from environment variables, if the value found + * based on the LOG_LEVEL environment variable is undefined, return the default + * logging level. + */ +function getLogLevel() { + var logLevelEnvValue = (process.env["LOG_LEVEL"] || '').toUpperCase(); + var logLevel = LOG_LEVELS[logLevelEnvValue]; + if (logLevel === undefined) { + return DEFAULT_LOG_LEVEL; + } + return logLevel; +} +/** All text to write to the log file. */ +var LOGGED_TEXT = ''; +/** Whether file logging as been enabled. */ +var FILE_LOGGING_ENABLED = false; +/** + * The number of columns used in the prepended log level information on each line of the logging + * output file. + */ +var LOG_LEVEL_COLUMNS = 7; +/** + * Enable writing the logged outputs to the log file on process exit, sets initial lines from the + * command execution, containing information about the timing and command parameters. + * + * This is expected to be called only once during a command run, and should be called by the + * middleware of yargs to enable the file logging before the rest of the command parsing and + * response is executed. + */ +function captureLogOutputForCommand(argv) { + if (FILE_LOGGING_ENABLED) { + throw Error('`captureLogOutputForCommand` cannot be called multiple times'); + } + var git = GitClient.getInstance(); + /** The date time used for timestamping when the command was invoked. */ + var now = new Date(); + /** Header line to separate command runs in log files. */ + var headerLine = Array(100).fill('#').join(''); + LOGGED_TEXT += headerLine + "\nCommand: " + argv.$0 + " " + argv._.join(' ') + "\nRan at: " + now + "\n"; + // On process exit, write the logged output to the appropriate log files + process.on('exit', function (code) { + LOGGED_TEXT += headerLine + "\n"; + LOGGED_TEXT += "Command ran in " + (new Date().getTime() - now.getTime()) + "ms\n"; + LOGGED_TEXT += "Exit Code: " + code + "\n"; + /** Path to the log file location. */ + var logFilePath = path.join(git.baseDir, '.ng-dev.log'); + // Strip ANSI escape codes from log outputs. + LOGGED_TEXT = LOGGED_TEXT.replace(/\x1B\[([0-9]{1,3}(;[0-9]{1,2})?)?[mGK]/g, ''); + fs.writeFileSync(logFilePath, LOGGED_TEXT); + // For failure codes greater than 1, the new logged lines should be written to a specific log + // file for the command run failure. + if (code > 1) { + var logFileName = ".ng-dev.err-" + now.getTime() + ".log"; + console.error("Exit code: " + code + ". Writing full log to " + logFileName); + fs.writeFileSync(path.join(git.baseDir, logFileName), LOGGED_TEXT); + } + }); + // Mark file logging as enabled to prevent the function from executing multiple times. + FILE_LOGGING_ENABLED = true; +} +/** Write the provided text to the log file, prepending each line with the log level. */ +function printToLogFile(logLevel) { + var text = []; + for (var _i = 1; _i < arguments.length; _i++) { + text[_i - 1] = arguments[_i]; + } + var logLevelText = (LOG_LEVELS[logLevel] + ":").padEnd(LOG_LEVEL_COLUMNS); + LOGGED_TEXT += text.join(' ').split('\n').map(function (l) { return logLevelText + " " + l + "\n"; }).join(''); +} /** * @license @@ -1318,7 +1336,7 @@ class G3Module extends BaseModule { } getG3FileIncludeAndExcludeLists() { var _a, _b, _c, _d; - const angularRobotFilePath = path.join(getRepoBaseDir(), '.github/angular-robot.yml'); + const angularRobotFilePath = path.join(this.git.baseDir, '.github/angular-robot.yml'); if (!fs.existsSync(angularRobotFilePath)) { debug('No angular robot configuration file exists, skipping.'); return null; @@ -2059,7 +2077,8 @@ function printValidationErrors(errors, print = error) { */ /** Validate commit message at the provided file path. */ function validateFile(filePath, isErrorMode) { - const commitMessage = fs.readFileSync(path.resolve(getRepoBaseDir(), filePath), 'utf8'); + const git = GitClient.getInstance(); + const commitMessage = fs.readFileSync(path.resolve(git.baseDir, filePath), 'utf8'); const { valid, errors } = validateCommitMessage(commitMessage); if (valid) { info(`${green('√')} Valid commit message`); @@ -2260,47 +2279,6 @@ function buildCommitMessageParser(localYargs) { .command(ValidateRangeModule); } -/** - * @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 - */ -/** - * A list of all files currently in the repo which have been modified since the provided sha. - * - * git diff - * Deleted files (--diff-filter=d) are not included as they are not longer present in the repo - * and can not be checked anymore. - * - * git ls-files - * Untracked files (--others), which are not matched by .gitignore (--exclude-standard) - * as they are expected to become tracked files. - */ -function allChangedFilesSince(sha) { - if (sha === void 0) { sha = 'HEAD'; } - var diffFiles = gitOutputAsArray("git diff --name-only --diff-filter=d " + sha); - var untrackedFiles = gitOutputAsArray("git ls-files --others --exclude-standard"); - // Use a set to deduplicate the list as its possible for a file to show up in both lists. - return Array.from(new Set(tslib.__spreadArray(tslib.__spreadArray([], tslib.__read(diffFiles)), tslib.__read(untrackedFiles)))); -} -/** - * A list of all staged files which have been modified. - * - * Only added, created and modified files are listed as others (deleted, renamed, etc) aren't - * changed or available as content to act upon. - */ -function allStagedFiles() { - return gitOutputAsArray("git diff --staged --name-only --diff-filter=ACM"); -} -function allFiles() { - return gitOutputAsArray("git ls-files"); -} -function gitOutputAsArray(cmd) { - return exec(cmd, { cwd: getRepoBaseDir() }).split('\n').map(function (x) { return x.trim(); }).filter(function (x) { return !!x; }); -} - /** * @license * Copyright Google LLC All Rights Reserved. @@ -2351,6 +2329,7 @@ function checkFormatterConfig(key, config, errors) { class Formatter { constructor(config) { this.config = config; + this.git = GitClient.getInstance(); } /** * Retrieve the command to execute the provided action, including both the binary @@ -2414,7 +2393,7 @@ class Buildifier extends Formatter { constructor() { super(...arguments); this.name = 'buildifier'; - this.binaryFilePath = path.join(getRepoBaseDir(), 'node_modules/.bin/buildifier'); + this.binaryFilePath = path.join(this.git.baseDir, 'node_modules/.bin/buildifier'); this.defaultFileMatcher = ['**/*.bzl', '**/BUILD.bazel', '**/WORKSPACE', '**/BUILD']; this.actions = { check: { @@ -2459,7 +2438,7 @@ class ClangFormat extends Formatter { constructor() { super(...arguments); this.name = 'clang-format'; - this.binaryFilePath = path.join(getRepoBaseDir(), 'node_modules/.bin/clang-format'); + this.binaryFilePath = path.join(this.git.baseDir, 'node_modules/.bin/clang-format'); this.defaultFileMatcher = ['**/*.{t,j}s']; this.actions = { check: { @@ -2674,16 +2653,19 @@ function buildFormatParser(localYargs) { }) .command('all', 'Run the formatter on all files in the repository', args => args, ({ check }) => { const executionCmd = check ? checkFiles : formatFiles; - executionCmd(allFiles()); + const allFiles = GitClient.getInstance().allFiles(); + executionCmd(allFiles); }) .command('changed [shaOrRef]', 'Run the formatter on files changed since the provided sha/ref', args => args.positional('shaOrRef', { type: 'string' }), ({ shaOrRef, check }) => { const sha = shaOrRef || 'master'; const executionCmd = check ? checkFiles : formatFiles; - executionCmd(allChangedFilesSince(sha)); + const allChangedFilesSince = GitClient.getInstance().allChangesFilesSince(sha); + executionCmd(allChangedFilesSince); }) .command('staged', 'Run the formatter on all staged files', args => args, ({ check }) => { const executionCmd = check ? checkFiles : formatFiles; - executionCmd(allStagedFiles()); + const allStagedFiles = GitClient.getInstance().allStagedFiles(); + executionCmd(allStagedFiles); }) .command('files ', 'Run the formatter on provided files', args => args.positional('files', { array: true, type: 'string' }), ({ check, files }) => { const executionCmd = check ? checkFiles : formatFiles; @@ -2699,8 +2681,9 @@ function buildFormatParser(localYargs) { * found in the LICENSE file at https://angular.io/license */ function verify() { + const git = GitClient.getInstance(); /** Full path to NgBot config file */ - const NGBOT_CONFIG_YAML_PATH = path.resolve(getRepoBaseDir(), '.github/angular-robot.yml'); + const NGBOT_CONFIG_YAML_PATH = path.resolve(git.baseDir, '.github/angular-robot.yml'); /** The NgBot config file */ const ngBotYaml = fs.readFileSync(NGBOT_CONFIG_YAML_PATH, 'utf8'); try { @@ -3176,6 +3159,21 @@ const CheckoutCommandModule = { describe: 'Checkout a PR from the upstream repo', }; +/** + * @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 + */ +/** + * Runs an given command as child process. By default, child process + * output will not be printed. + */ +function exec(cmd, opts) { + return shelljs.exec(cmd, tslib.__assign(tslib.__assign({ silent: true }, opts), { async: false })); +} + /** * @license * Copyright Google LLC All Rights Reserved. @@ -4965,10 +4963,11 @@ function getGroupsFromYaml(pullApproveYamlRaw) { * found in the LICENSE file at https://angular.io/license */ function verify$1() { + const git = GitClient.getInstance(); /** Full path to PullApprove config file */ - const PULL_APPROVE_YAML_PATH = path.resolve(getRepoBaseDir(), '.pullapprove.yml'); + const PULL_APPROVE_YAML_PATH = path.resolve(git.baseDir, '.pullapprove.yml'); /** All tracked files in the repository. */ - const REPO_FILES = allFiles(); + const REPO_FILES = git.allFiles(); /** The pull approve config file. */ const pullApproveYamlRaw = fs.readFileSync(PULL_APPROVE_YAML_PATH, 'utf8'); /** All of the groups defined in the pullapprove yaml. */ @@ -6787,9 +6786,10 @@ function builder$8(argv) { /** Yargs command handler for staging a release. */ function handler$8(args) { return tslib.__awaiter(this, void 0, void 0, function* () { + const git = GitClient.getInstance(); const config = getConfig(); const releaseConfig = getReleaseConfig(config); - const projectDir = getRepoBaseDir(); + const projectDir = git.baseDir; const task = new ReleaseTool(releaseConfig, config.github, args.githubToken, projectDir); const result = yield task.run(); switch (result) { @@ -6916,7 +6916,8 @@ function hasLocalChanges() { */ function getSCMVersion(mode) { if (mode === 'release') { - const packageJsonPath = path.join(getRepoBaseDir(), 'package.json'); + const git = GitClient.getInstance(); + const packageJsonPath = path.join(git.baseDir, 'package.json'); const { version } = require(packageJsonPath); return version; } diff --git a/dev-infra/ngbot/verify.ts b/dev-infra/ngbot/verify.ts index a5c2449376..79286e4626 100644 --- a/dev-infra/ngbot/verify.ts +++ b/dev-infra/ngbot/verify.ts @@ -9,12 +9,13 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; import {parse as parseYaml} from 'yaml'; -import {getRepoBaseDir} from '../utils/config'; import {error, green, info, red} from '../utils/console'; +import {GitClient} from '../utils/git/index'; export function verify() { + const git = GitClient.getInstance(); /** Full path to NgBot config file */ - const NGBOT_CONFIG_YAML_PATH = resolve(getRepoBaseDir(), '.github/angular-robot.yml'); + const NGBOT_CONFIG_YAML_PATH = resolve(git.baseDir, '.github/angular-robot.yml'); /** The NgBot config file */ const ngBotYaml = readFileSync(NGBOT_CONFIG_YAML_PATH, 'utf8'); diff --git a/dev-infra/pr/merge/index.ts b/dev-infra/pr/merge/index.ts index d67542205c..40c01b570d 100644 --- a/dev-infra/pr/merge/index.ts +++ b/dev-infra/pr/merge/index.ts @@ -7,7 +7,7 @@ */ -import {getConfig, getRepoBaseDir} from '../../utils/config'; +import {getConfig} from '../../utils/config'; import {error, green, info, promptConfirm, red, yellow} from '../../utils/console'; import {GithubApiRequestError} from '../../utils/git/github'; import {GITHUB_TOKEN_GENERATE_URL} from '../../utils/git/github-urls'; diff --git a/dev-infra/pullapprove/verify.ts b/dev-infra/pullapprove/verify.ts index 1674083383..a78d0c572e 100644 --- a/dev-infra/pullapprove/verify.ts +++ b/dev-infra/pullapprove/verify.ts @@ -8,17 +8,17 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; -import {getRepoBaseDir} from '../utils/config'; import {debug, info} from '../utils/console'; -import {allFiles} from '../utils/repo-files'; +import {GitClient} from '../utils/git/index'; import {logGroup, logHeader} from './logging'; import {getGroupsFromYaml} from './parse-yaml'; export function verify() { + const git = GitClient.getInstance(); /** Full path to PullApprove config file */ - const PULL_APPROVE_YAML_PATH = resolve(getRepoBaseDir(), '.pullapprove.yml'); + const PULL_APPROVE_YAML_PATH = resolve(git.baseDir, '.pullapprove.yml'); /** All tracked files in the repository. */ - const REPO_FILES = allFiles(); + const REPO_FILES = git.allFiles(); /** The pull approve config file. */ const pullApproveYamlRaw = readFileSync(PULL_APPROVE_YAML_PATH, 'utf8'); /** All of the groups defined in the pullapprove yaml. */ diff --git a/dev-infra/release/publish/cli.ts b/dev-infra/release/publish/cli.ts index 068f2cf93e..b156a065be 100644 --- a/dev-infra/release/publish/cli.ts +++ b/dev-infra/release/publish/cli.ts @@ -8,9 +8,10 @@ import {Arguments, Argv, CommandModule} from 'yargs'; -import {getConfig, getRepoBaseDir} from '../../utils/config'; +import {getConfig} from '../../utils/config'; import {error, green, info, red, yellow} from '../../utils/console'; import {addGithubTokenOption} from '../../utils/git/github-yargs'; +import {GitClient} from '../../utils/git/index'; import {getReleaseConfig} from '../config/index'; import {CompletionState, ReleaseTool} from './index'; @@ -27,9 +28,10 @@ function builder(argv: Argv): Argv { /** Yargs command handler for staging a release. */ async function handler(args: Arguments) { + const git = GitClient.getInstance(); const config = getConfig(); const releaseConfig = getReleaseConfig(config); - const projectDir = getRepoBaseDir(); + const projectDir = git.baseDir; const task = new ReleaseTool(releaseConfig, config.github, args.githubToken, projectDir); const result = await task.run(); diff --git a/dev-infra/release/stamping/env-stamp.ts b/dev-infra/release/stamping/env-stamp.ts index 5e6e993be0..695d395711 100644 --- a/dev-infra/release/stamping/env-stamp.ts +++ b/dev-infra/release/stamping/env-stamp.ts @@ -7,8 +7,8 @@ */ import {join} from 'path'; +import {GitClient} from '../../utils/git/index'; -import {getRepoBaseDir} from '../../utils/config'; import {exec as _exec} from '../../utils/shelljs'; export type EnvStampMode = 'snapshot'|'release'; @@ -51,7 +51,8 @@ function hasLocalChanges() { */ function getSCMVersion(mode: EnvStampMode) { if (mode === 'release') { - const packageJsonPath = join(getRepoBaseDir(), 'package.json'); + const git = GitClient.getInstance(); + const packageJsonPath = join(git.baseDir, 'package.json'); const {version} = require(packageJsonPath); return version; } diff --git a/dev-infra/utils/config.ts b/dev-infra/utils/config.ts index 75d8a9fbee..9213fc5fa7 100644 --- a/dev-infra/utils/config.ts +++ b/dev-infra/utils/config.ts @@ -10,7 +10,7 @@ import {existsSync} from 'fs'; import {dirname, join} from 'path'; import {debug, error} from './console'; -import {exec} from './shelljs'; +import {GitClient} from './git/index'; import {isTsNodeAvailable} from './ts-node'; /** Configuration for Git client interactions. */ @@ -64,11 +64,14 @@ let userConfig: {[key: string]: any}|null = null; * Get the configuration from the file system, returning the already loaded * copy if it is defined. */ -export function getConfig(): NgDevConfig { +export function getConfig(): NgDevConfig; +export function getConfig(baseDir?: string): NgDevConfig; +export function getConfig(baseDir?: string): NgDevConfig { // If the global config is not defined, load it from the file system. if (cachedConfig === null) { + baseDir = baseDir || GitClient.getInstance().getBaseDir(); // The full path to the configuration file. - const configPath = join(getRepoBaseDir(), CONFIG_FILE_PATH); + const configPath = join(baseDir, CONFIG_FILE_PATH); // Read the configuration and validate it before caching it for the future. cachedConfig = validateCommonConfig(readConfigFile(configPath)); } @@ -141,18 +144,6 @@ export function assertNoErrors(errors: string[]) { process.exit(1); } -/** Gets the path of the directory for the repository base. */ -export function getRepoBaseDir() { - const baseRepoDir = exec(`git rev-parse --show-toplevel`); - if (baseRepoDir.code) { - 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` + - `ERROR:\n ${baseRepoDir.stderr}`); - } - return baseRepoDir.trim(); -} - /** * Get the local user configuration from the file system, returning the already loaded copy if it is * defined. @@ -163,8 +154,9 @@ export function getRepoBaseDir() { export function getUserConfig() { // If the global config is not defined, load it from the file system. if (userConfig === null) { + const git = GitClient.getInstance(); // The full path to the configuration file. - const configPath = join(getRepoBaseDir(), USER_CONFIG_FILE_PATH); + const configPath = join(git.baseDir, USER_CONFIG_FILE_PATH); // Set the global config object. userConfig = readConfigFile(configPath, true); } diff --git a/dev-infra/utils/console.ts b/dev-infra/utils/console.ts index d07b9e140a..456d580e5b 100644 --- a/dev-infra/utils/console.ts +++ b/dev-infra/utils/console.ts @@ -12,7 +12,7 @@ import {prompt} from 'inquirer'; import {join} from 'path'; import {Arguments} from 'yargs'; -import {getRepoBaseDir} from './config'; +import {GitClient} from './git/index'; /** Reexport of chalk colors for convenient access. */ export const red: typeof chalk = chalk.red; @@ -143,6 +143,8 @@ export function captureLogOutputForCommand(argv: Arguments) { if (FILE_LOGGING_ENABLED) { throw Error('`captureLogOutputForCommand` cannot be called multiple times'); } + + const git = GitClient.getInstance(); /** The date time used for timestamping when the command was invoked. */ const now = new Date(); /** Header line to separate command runs in log files. */ @@ -155,7 +157,7 @@ export function captureLogOutputForCommand(argv: Arguments) { LOGGED_TEXT += `Command ran in ${new Date().getTime() - now.getTime()}ms\n`; LOGGED_TEXT += `Exit Code: ${code}\n`; /** Path to the log file location. */ - const logFilePath = join(getRepoBaseDir(), '.ng-dev.log'); + const logFilePath = join(git.baseDir, '.ng-dev.log'); // Strip ANSI escape codes from log outputs. LOGGED_TEXT = LOGGED_TEXT.replace(/\x1B\[([0-9]{1,3}(;[0-9]{1,2})?)?[mGK]/g, ''); @@ -167,7 +169,7 @@ export function captureLogOutputForCommand(argv: Arguments) { if (code > 1) { const logFileName = `.ng-dev.err-${now.getTime()}.log`; console.error(`Exit code: ${code}. Writing full log to ${logFileName}`); - writeFileSync(join(getRepoBaseDir(), logFileName), LOGGED_TEXT); + writeFileSync(join(git.baseDir, logFileName), LOGGED_TEXT); } }); diff --git a/dev-infra/utils/git/index.ts b/dev-infra/utils/git/index.ts index b1a6e5bbdb..d44c3afaed 100644 --- a/dev-infra/utils/git/index.ts +++ b/dev-infra/utils/git/index.ts @@ -10,7 +10,7 @@ import * as Octokit from '@octokit/rest'; import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; import {Options as SemVerOptions, parse, SemVer} from 'semver'; -import {getConfig, getRepoBaseDir} from '../config'; +import {getConfig, GithubConfig, NgDevConfig} from '../config'; import {debug, info, yellow} from '../console'; import {DryRunError, isDryRun} from '../dry-run'; import {GithubClient} from './github'; @@ -83,7 +83,8 @@ export class GitClient { GitClient.authenticated = new GitClient(token); } - + /** The configuration, containing the github specific configuration. */ + private config: NgDevConfig; /** Whether verbose logging of Git actions should be used. */ private verboseLogging = true; /** The OAuth scopes available for the provided Github token. */ @@ -94,21 +95,27 @@ export class GitClient { */ private _githubTokenRegex: RegExp|null = null; /** Short-hand for accessing the default remote configuration. */ - remoteConfig = this._config.github; + remoteConfig: GithubConfig; /** Octokit request parameters object for targeting the configured remote. */ - remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name}; - /** Instance of the authenticated Github octokit API. */ + remoteParams: {owner: string, repo: string}; + /** Instance of the Github octokit API. */ github = new GithubClient(this.githubToken); + /** The full path to the root of the repository base. */ + baseDir: string; /** * @param githubToken The github token used for authentication, if provided. * @param _config The configuration, containing the github specific configuration. - * @param _projectRoot The full path to the root of the repository base. + * @param baseDir The full path to the root of the repository base. */ - protected constructor(public githubToken: - Authenticated extends true? string: undefined, - private _config = getConfig(), - private _projectRoot = getRepoBaseDir()) { + protected constructor(public githubToken: Authenticated extends true? string: undefined, + config?: NgDevConfig, + baseDir?: string) { + this.baseDir = baseDir || this.determineBaseDir(); + this.config = config || getConfig(this.baseDir); + this.remoteConfig = this.config.github; + this.remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name}; + // If a token has been specified (and is not empty), pass it to the Octokit API and // also create a regular expression that can be used for sanitizing Git command output // so that it does not print the token accidentally. @@ -157,7 +164,7 @@ export class GitClient { printFn('Executing: git', this.omitGithubTokenFromMessage(args.join(' '))); const result = spawnSync('git', args, { - cwd: this._projectRoot, + cwd: this.baseDir, stdio: 'pipe', ...options, // Encoding is always `utf8` and not overridable. This ensures that this method @@ -249,6 +256,40 @@ export class GitClient { return new SemVer(latestTag, semVerOptions); } + /** Gets the path of the directory for the repository base. */ + getBaseDir(): string { + const previousVerboseLoggingState = this.verboseLogging; + this.setVerboseLoggingState(false); + const {stdout, stderr, status} = this.runGraceful(['rev-parse', '--show-toplevel']); + this.setVerboseLoggingState(previousVerboseLoggingState); + 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` + + `ERROR:\n ${stderr}`); + } + return stdout.trim(); + } + + /** Retrieve a list of all files in the repostitory 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 repostitory. */ + allFiles(): string[] { + return gitOutputAsArray(this.runGraceful(['ls-files'])); + } + /** * Assert the GitClient instance is using a token with permissions for the all of the * provided OAuth scopes. @@ -293,4 +334,29 @@ export class GitClient { return scopes.split(',').map(scope => scope.trim()); }); } + + private determineBaseDir() { + this.setVerboseLoggingState(false); + const {stdout, stderr, status} = this.runGraceful(['rev-parse', '--show-toplevel']); + 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` + + `ERROR:\n ${stderr}`); + } + this.setVerboseLoggingState(true); + return stdout.trim(); + } +} + +/** + * Takes the output from `GitClient.run` and `GitClient.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 convience utility + * within `GitClient`'s methods to create outputs as array. + */ +function gitOutputAsArray(gitCommandResult: SpawnSyncReturns): string[] { + return gitCommandResult.stdout.split('\n').map(x => x.trim()).filter(x => !!x); } diff --git a/dev-infra/utils/repo-files.ts b/dev-infra/utils/repo-files.ts deleted file mode 100644 index b509e90fc0..0000000000 --- a/dev-infra/utils/repo-files.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @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 {getRepoBaseDir} from './config'; -import {exec} from './shelljs'; - -/** - * A list of all files currently in the repo which have been modified since the provided sha. - * - * git diff - * Deleted files (--diff-filter=d) are not included as they are not longer present in the repo - * and can not be checked anymore. - * - * git ls-files - * Untracked files (--others), which are not matched by .gitignore (--exclude-standard) - * as they are expected to become tracked files. - */ -export function allChangedFilesSince(sha = 'HEAD') { - const diffFiles = gitOutputAsArray(`git diff --name-only --diff-filter=d ${sha}`); - const untrackedFiles = gitOutputAsArray(`git ls-files --others --exclude-standard`); - // Use a set to deduplicate the list as its possible for a file to show up in both lists. - return Array.from(new Set([...diffFiles, ...untrackedFiles])); -} - -/** - * A list of all staged files which have been modified. - * - * Only added, created and modified files are listed as others (deleted, renamed, etc) aren't - * changed or available as content to act upon. - */ -export function allStagedFiles() { - return gitOutputAsArray(`git diff --staged --name-only --diff-filter=ACM`); -} - - - -export function allFiles() { - return gitOutputAsArray(`git ls-files`); -} - - -function gitOutputAsArray(cmd: string) { - return exec(cmd, {cwd: getRepoBaseDir()}).split('\n').map(x => x.trim()).filter(x => !!x); -}