UNPKG

@ts-common/azure-js-dev-tools

Version:

Developer dependencies for TypeScript related projects

1,546 lines (1,405 loc) 55.1 kB
/** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for * license information. */ import { toArray, where } from "./arrays"; import { getLines, replaceAll, StringMap } from "./common"; import { joinPath } from "./path"; import { run, RunOptions, RunResult } from "./run"; import { mkdirSync } from "fs"; import { URLBuilder } from "./url"; /** * A set of interfaces and types that relate to the Git interface. */ export namespace Git { /** * Credentials that will authenticate the current application with a remote endpoint. */ export interface Credentials { /** * The user to authenticate with the remote endpoint. */ username: string; /** * The access token that will authenticate this user to the remote endpoint. */ accessToken: string; } /** * The details of the person who authored the changes of the commit. */ export interface Author { /** * The name of the commit author. */ name?: string; /** * The e-mail address of the commit author. */ email?: string; } /** * Options that can be passed to a Git implementation's constructor. */ export interface ConstructorOptions { /** * The credentials that will be used to interact with remote endpoints. */ credentials?: Credentials; /** * The details of the entity who may make commits. */ author?: Author; } /** * The result of getting the current commit SHA. */ export interface CurrentCommitShaResult { /** * The SHA of the current commit. */ currentCommitSha?: string; } /** * Options that can be passed to "git merge". */ export interface MergeOptions { /** * Commits, usually other branch heads, to merge into our branch. Specifying more than one * commit will create a merge with more than two parents (affectionately called an Octopus * merge). */ refsToMerge?: string | string[]; /** * Produce the working tree and index state as if a real merge happened (except for the merge * information), but do not actually make a commit, move the HEAD, or record $GIT_DIR/MERGE_HEAD * (to cause the next git commit command to create a merge commit). */ squash?: boolean; /** * Invoke an editor before committing successful mechanical merge to further edit the * auto-generated merge message, so that the user can explain and justify the merge. */ edit?: boolean; /** * The options that will be passed to the merge strategy. */ strategyOptions?: string | string[]; /** * Operate quietly. Implies --no-progress. */ quiet?: boolean; /** * Set the commit messages to be used for the merge commit (in case one is created). */ messages?: string | string[]; } /** * Options that can be passed to "git rebase". */ export interface RebaseOptions { /** * Working branch; defaults to HEAD. */ branch?: string; /** * Upstream branch to compare against. May be any valid commit, not just an existing branch * name. Defaults to the configured upstream for the current branch. */ upstream?: string; /** * Starting point at which to create the new commits. If the --onto option is not specified, the * starting point is <upstream>. May be any valid commit, and not just an existing branch name. * As a special case, you may use "A...B" as a shortcut for the merge base of A and B if there * is exactly one merge base. You can leave out at most one of A and B, in which case it * defaults to HEAD. */ newbase?: string; /** * Use the given merge strategy. If there is no -s option git merge-recursive is used instead. * This implies --merge. * Because git rebase replays each commit from the working branch on top of the <upstream> * branch using the given strategy, using the ours strategy simply empties all patches from the * <branch>, which makes little sense. */ strategy?: string; /** * Pass the <strategy-option> through to the merge strategy. This implies --merge and, if no * strategy has been specified, -s recursive. Note the reversal of ours and theirs as noted * above for the -m option. */ strategyOption?: string; /** * Be quiet. Implies --no-stat. */ quiet?: boolean; /** * Be verbose. Implies --stat. */ verbose?: boolean; } /** * Options that can be passed to "git clone". */ export interface CloneOptions { /** * Instead of using the remote name "origin" to keep track of the upstream repository, use this value. */ origin?: string; /** * Instead of pointing the newly created HEAD to the branch pointed to by the cloned repository’s * HEAD, point to this value's branch instead. In a non-bare repository, this is the branch that * will be checked out. This value can also take tags and detaches the HEAD at that commit in the * resulting repository. */ branch?: string; /** * Create a shallow clone with a history truncated to the specified number of commits. Implies * "single-branch"=true unless "no-single-branch"=true is given to fetch the histories near the * tips of all branches. If you want to clone submodules shallowly, also use * "shallow-submodules"=true. */ depth?: number; /** * The name of a new directory to clone into. The "humanish" part of the source repository is used * if no directory is explicitly given (repo for /path/to/repo.git and foo for host.xz:foo/.git). * Cloning into an existing directory is only allowed if the directory is empty. */ directory?: string; } /** * Options that can be passed to "git checkout". */ export interface CheckoutOptions { /** * The name of the remote repository where the branch should be checked out from. */ remote?: string; /** * The name to use for the local branch. */ localBranchName?: string; /** * Checkout and detached */ detach?: boolean; } /** * The options for determining how pushing the current branch will run. */ export interface PushOptions { /** * The name of the branch to push. */ branchName?: string; /** * The credentials that will be used to push the changes. */ credentials?: Credentials; /** * Whether or not to force the remote repository to accept this push's changes. */ force?: boolean; } /** * The options that can be passed to createLocalBranch(). */ export interface CreateLocalBranchOptions { /** * The reference point that the new branch will be created from. */ startPoint?: string; } /** * The options for determining how this command will run. */ export interface DeleteRemoteBranchOptions { /** * The name of the tracked remote repository. Defaults to "origin". */ remoteName?: string; } /** * Options that can be passed to `git diff`. */ export interface DiffOptions { /** * The unique identifier for a commit to compare. If this is specified but commit2 is not * specified, then this commit will be compared against HEAD. */ commit1?: string; /** * The unique identifier for a commit to compare. If this is specified but commit1 is not * specified, then this commit will be compared against HEAD. */ commit2?: string; /** * Show only the names of changed files. */ nameOnly?: boolean; /** * Whether or not to ignore whitespace changes in the diff, and if provided, to what extent should * whitespace changes be ignored. */ ignoreSpace?: "at-eol" | "change" | "all"; /** * Whether or not to show all changes that are staged. */ staged?: boolean; } /** * The result of a "git diff" command. */ export interface DiffResult { /** * The files that are reported as being changed by the diff command. */ filesChanged: string[]; } /** * The result of getting the local branches in the repository. */ export interface LocalBranchesResult { /** * The local branches in the repository. */ localBranches: string[]; /** * The current branch that is checked out. */ currentBranch: string; } /** * The return type of getting a remote repository's branches. */ export interface RemoteBranchesResult { /** * The branches in remote repositories. */ remoteBranches: GitRemoteBranch[]; } /** * The result of getting the status of the current branch. */ export interface StatusResult { /** * The current local branch. */ localBranch?: string; /** * The remote tracking branch for the current local branch. */ remoteBranch?: string; /** * Whether or not the current local branch has uncommitted changes. */ hasUncommittedChanges: boolean; /** * Staged, not staged, and untracked files that have either been modified, added, or deleted. */ modifiedFiles: string[]; /** * Files that have been modified and staged. */ stagedModifiedFiles: string[]; /** * Files that have been deleted and staged. */ stagedDeletedFiles: string[]; /** * Files that have been modified but not staged yet. */ notStagedModifiedFiles: string[]; /** * Files that have been deleted but not staged yet. */ notStagedDeletedFiles: string[]; /** * Files that don't currently exist in the repository. */ untrackedFiles: string[]; } /** * The result of getting a git configuration value. */ export interface GetConfigurationValueResult { /** * The requested configuration value or undefined if the value was not found. */ configurationValue?: string; } /** * The options that can be passed to a `git commit` operation. */ export interface CommitOptions { /** * The details of the person who authored the changes of the commit. */ author?: Author; } /** * The result of listing the remote repositories referenced by this local repository. */ export interface ListRemotesResult { /** * The remote repositories referenced by this local repository. */ remotes: StringMap<string>; } export interface StashOptions { pop?: boolean; keepIndex?: boolean; all?: boolean; } } /** * An interface for interacting with Git repositories. */ export interface Git { /** * Get the SHA of the currently checked out commit. */ currentCommitSha(): Promise<Git.CurrentCommitShaResult>; /** * Download objects and refs from another repository. */ fetch(): Promise<unknown>; /** * Merge The provided references (branches or tags) into the current branch using the provided * options. * @param options Options that can be passed to "git merge". */ merge(options?: Git.MergeOptions): Promise<unknown>; /** * Reapply commits on top of another base tip. * @param options Options that can be passed to "git rebase". */ rebase(options?: Git.RebaseOptions): Promise<unknown>; /** * Clone the repository with the provided URI. * @param gitUri The repository URI to clone. * @param options The options that can be passed to "git clone". */ clone(gitUri: string, options?: Git.CloneOptions): Promise<unknown>; /** * Checkout the provided git reference (branch, tag, or commit ID) in the repository. * @param refId The git reference to checkout. * @param options The options that can be passed to "git checkout". */ checkout(refId: string, options?: Git.CheckoutOptions): Promise<unknown>; /** * Pull the latest changes for the current branch from the registered remote branch. */ pull(): Promise<unknown>; /** * Push the current branch to the remote tracked repository. * @param options The options for determining how this command will run. */ push(options?: Git.PushOptions): Promise<unknown>; /** * Add/stage the provided files. * @param filePaths The paths to the files to stage. */ add(filePaths: string | string[]): Promise<unknown>; /** * Add/stage all of the current unstaged files. * @param options The options that determine how this command will run. */ addAll(): Promise<unknown>; /** * Commit the currently staged/added changes to the current branch. * @param commitMessages The commit messages to apply to this commit. */ commit(commitMessages: string | string[], options?: Git.CommitOptions): Promise<unknown>; /** * Delete a local branch. * @param branchName The name of the local branch to delete. */ deleteLocalBranch(branchName: string): Promise<unknown>; /** * Create a new local branch with the provided name. * @param branchName The name of the new branch. */ createLocalBranch(branchName: string): Promise<unknown>; /** * Remote the provided branch from the provided tracked remote repository. * @param branchName The name of the remote branch to delete. * @param remoteName The name of the tracked remote repository. * @param options The options for determining how this command will run. */ deleteRemoteBranch(branchName: string, options?: Git.DeleteRemoteBranchOptions): Promise<unknown>; /** * Get the diff between two commits. * @options The options for determining how this command will run. */ diff(options?: Git.DiffOptions): Promise<Git.DiffResult>; /** * Get the branches that are local to this repository. */ localBranches(): Promise<Git.LocalBranchesResult>; /** * Get the branch that the repository is currently on. */ currentBranch(): Promise<string>; /** * Get the remote branches that this repository clone is aware of. */ remoteBranches(): Promise<Git.RemoteBranchesResult>; /** * Run "git status". */ status(): Promise<Git.StatusResult>; /** * Get the configuration value for the provided configuration value name. * @param configurationValueName The name of the configuration value to get. */ getConfigurationValue(configurationValueName: string): Promise<Git.GetConfigurationValueResult>; /** * Get the URL of the current repository. * @param options The options that can configure how the command will run. */ getRepositoryUrl(): Promise<string | undefined>; /** * Unstage all staged files. * @param options The options that can configure how the command will run. */ resetAll(): Promise<unknown>; /** * Add the provided remote URL to this local repository's list of remote repositories using the * provided remoteName. * @param remoteName The name/reference that will be used to refer to the remote repository. * @param remoteUrl The URL of the remote repository. */ addRemote(remoteName: string, remoteUrl: string): Promise<unknown>; /** * Get the URL associated with the provided remote repository. * @param remoteName The name of the remote repository. * @returns The URL associated with the provided remote repository or undefined if the remote name * is not found. */ getRemoteUrl(remoteName: string): Promise<string | undefined>; /** * Set the URL associated with the provided remote repository. * @param remoteName The name of the remote repository. * @param remoteUrl The URL associated with the provided remote repository. */ setRemoteUrl(remoteName: string, remoteUrl: string): Promise<unknown>; /** * Get the remote repositories that are referenced in this local repository. */ listRemotes(): Promise<Git.ListRemotesResult>; stash(options: ExecutableGit.StashOptions): Promise<ExecutableGit.Result>; } /** * A set of interfaces and types that relate to the ExecutableGit class. */ export namespace ExecutableGit { /** * A set of optional properties that can be applied to an ExecutableGit operation. */ export interface Options extends RunOptions { /** * Whether or not to use a pager in the output of the "git diff" operation. */ usePager?: boolean; /** * The file path to the git executable to use to run commands. This can be either a rooted path * to the executable, or a relative path that will use the environment's PATH variable to * resolve the executable's location. */ gitFilePath?: string; } /** * A set of optional properties that can be applied to the ExecutableGit constructor. */ export interface ConstructorOptions extends Options { /** * The authentication section that will be inserted into remote repository URLs. This can be * either a string (the authentication token that will be inserted into every remote * repository's URL), or a StringMap<string> where the entry's key is a scope and the entry's * value is the authentication token that will be inserted into remote repository URLs that * match the scope. The scope can be either the repository's owner or the full repository * name (<owner>/<repository-name>). */ authentication?: string | StringMap<string> | ((scope: string) => Promise<string | undefined>); } /** * The result of running a git operation. */ export interface Result extends RunResult { /** * The error that occurred while running the git operation. */ error?: Error; } /** * The result of running `git rev-parse HEAD`. */ export interface CurrentCommitShaResult extends Git.CurrentCommitShaResult, Result { } /** * Options that can be passed to `git fetch`. */ export interface FetchOptions extends Options { /** * Before fetching, remove any remote-tracking references that no longer exist on the remote. Tags * are not subject to pruning if they are fetched only because of the default tag auto-following * or due to a --tags option. However, if tags are fetched due to an explicit refspec (either on * the command line or in the remote configuration, for example if the remote was cloned with the * --mirror option), then they are also subject to pruning. Supplying --prune-tags is a shorthand * for providing the tag refspec. */ prune?: boolean; /** * Whether or not to fetch branch updates about all known remote repositories. */ all?: boolean; /** * Set depth to 1 to use shallow fetch */ depth?: number; /** * Name of the remote repo to fetch */ remoteName?: string; /** * refspec to be fetched */ refSpec?: string; } export interface ResetOptions extends Options { hard?: boolean; soft?: boolean; target?: string; } /** * Options that can be passed to "git merge". */ export interface MergeOptions extends Git.MergeOptions, Options { } /** * Options that can be passed to "git rebase". */ export interface RebaseOptions extends Git.RebaseOptions, Options { } /** * Options that can be passed to "git clone". */ export interface CloneOptions extends Git.CloneOptions, Options { /** * Operate quietly. Progress is not reported to the standard error stream. */ quiet?: boolean; /** * Run verbosely. Does not affect the reporting of progress status to the standard error stream. */ verbose?: boolean; } /** * Options that can be passed to "git checkout". */ export interface CheckoutOptions extends Git.CheckoutOptions, Options { } /** * The result of attempting to run `git checkout`. */ export interface CheckoutResult extends Result { /** * Get the files that would've been overwritten if this checkout operation had taken place. This * property will only be populated in an error scenario. */ filesThatWouldBeOverwritten?: string[]; } /** * The options for determining how pushing the current branch will run. */ export interface PushOptions extends Git.PushOptions, Options { /** * The upstream repository to push to if the current branch doesn't already have an upstream * branch. */ setUpstream?: boolean | string; } /** * Options that modify how a "git commit" operation will run. */ export interface CommitOptions extends Options { /** * Whether or not pre-commit checks will be run. */ noVerify?: boolean; } /** * The options that can be passed to createLocalBranch(). */ export interface CreateLocalBranchOptions extends Git.CreateLocalBranchOptions, Options { } /** * Options that can be passed to `git push <remote> :<branch-name>`. */ export interface DeleteRemoteBranchOptions extends Git.DeleteRemoteBranchOptions, Options { } /** * Options that can be passed to `git diff`. */ export interface DiffOptions extends Git.DiffOptions, Options { } /** * The result of a "git diff" command. */ export interface DiffResult extends Git.DiffResult, Result { } /** * The result of getting the local branches in the repository. */ export interface LocalBranchesResult extends Git.LocalBranchesResult, Result { /** * The current branch that is checked out. */ currentBranch: string; } /** * The return type of getting a remote repository's branches. */ export interface RemoteBranchesResult extends Git.RemoteBranchesResult, Result { } /** * The result of getting the status of the current branch. */ export interface StatusResult extends Git.StatusResult, Result { } /** * The result of getting a git configuration value. */ export interface GetConfigurationValueResult extends Git.GetConfigurationValueResult, Result { } /** * The options that can be passed to a `git commit` operation. */ export interface CommitOptions extends Git.CommitOptions, Options { } /** * The result of listing the remote repositories referenced by this local repository. */ export interface ListRemotesResult extends Git.ListRemotesResult, Result { } export interface StashOptions extends Git.StashOptions, Options { } } /** * An implementation of Git that uses a Git executable to run commands. */ export class ExecutableGit implements Git { private authenticationStrings: Set<string> = new Set(); /** * Create a new ExecutableGit object. * @param gitFilePath The file path to the git executable to use to run commands. This can be * either a rooted path to the executable, or a relative path that will use the environment's PATH * variable to resolve the executable's location. * @param options The optional arguments that will be applied to each operation. */ constructor(private readonly options: ExecutableGit.ConstructorOptions = {}) { if (!options.gitFilePath) { options.gitFilePath = "git"; } } private maskAuthenticationInLog<T extends ExecutableGit.Options>(options: T): T { const authentication = this.options.authentication; const log: undefined | ((text: string) => any) = options.log; if (authentication && log) { options.log = (text: string) => { for (const authenticationString of this.authenticationStrings.values()) { text = replaceAll(text, authenticationString, "<redacted>")!; } return log(text); }; } return options; } private async addAuthenticationToURL(url: string, options: ExecutableGit.Options = {}): Promise<string> { let result: string = url; if (this.options.authentication) { const builder: URLBuilder = URLBuilder.parse(url); let authenticationToAdd: string | undefined; if (typeof this.options.authentication === "string") { authenticationToAdd = this.options.authentication; } else if (typeof this.options.authentication === "function") { authenticationToAdd = await this.options.authentication(url); } else { let urlPath: string | undefined = builder.getPath(); if (urlPath) { urlPath = urlPath.toLowerCase(); let matchingScope = ""; for (let [scope, authentication] of Object.entries(this.options.authentication)) { if (!scope.startsWith("/")) { scope = `/${scope}`; } scope = scope.toLowerCase(); if (urlPath.startsWith(scope) && scope.length > matchingScope.length) { matchingScope = scope; authenticationToAdd = authentication; } } if (options.log) { if (matchingScope) { await Promise.resolve(options.log(`Git URL matches authentication scope "${matchingScope}". Inserting auth token "${authenticationToAdd}".`)); } else { const scopes: string[] = Object.keys(this.options.authentication); await Promise.resolve(options.log(`Git URL didn't match any of the authentication scopes (${scopes.join(",")}). Not inserting an auth token.`)); } } } } if (authenticationToAdd !== undefined) { this.authenticationStrings.add(authenticationToAdd); } builder.setAuth(authenticationToAdd); result = builder.toString(); } return result; } /** * Create a new ExecutableGit object that combines this ExecutableGit's options with the provieded * options. * @param options The options to combine with this ExecutableGit's options. */ public scope(options: ExecutableGit.ConstructorOptions): ExecutableGit { return new ExecutableGit({ ...this.options, ...options, }); } /** * Run an arbitrary Git command. * @param args The arguments to provide to the Git executable. */ public run(args: string[], options: ExecutableGit.Options = {}): Promise<ExecutableGit.Result> { if (options.usePager === true) { args.unshift("--paginate"); } else if (options.usePager === false) { args.unshift("--no-pager"); } return run(options.gitFilePath || this.options.gitFilePath!, args, this.maskAuthenticationInLog({ ...this.options, ...options })); } /** * Get the SHA of the currently checked out commit. */ public async currentCommitSha(options: ExecutableGit.Options = {}): Promise<ExecutableGit.CurrentCommitShaResult> { const runResult: ExecutableGit.Result = await this.run(["rev-parse", "HEAD"], options); const result: ExecutableGit.CurrentCommitShaResult = { ...runResult, currentCommitSha: runResult.stdout, }; return result; } /** * Download objects and refs from another repository. * @param options The options that can be passed to `git fetch`. */ public fetch(options: ExecutableGit.FetchOptions = {}): Promise<ExecutableGit.Result> { const args: string[] = ["fetch"]; if (options.prune) { args.push("--prune"); } if (options.all) { args.push("--all"); } if (options.depth) { args.push("--depth", options.depth.toString()); } if (options.remoteName) { args.push(options.remoteName); } if (options.refSpec) { args.push(options.refSpec); } return this.run(args, options); } public merge(options: ExecutableGit.MergeOptions = {}): Promise<ExecutableGit.Result> { const args: string[] = ["merge"]; if (options.squash != undefined) { if (options.squash) { args.push("--squash"); } else { args.push("--no-squash"); } } if (options.edit != undefined) { if (options.edit) { args.push("--edit"); } else { args.push("--no-edit"); } } if (options.strategyOptions) { options.strategyOptions = toArray(options.strategyOptions); for (const strategyOption of options.strategyOptions) { args.push(`--strategy-option=${strategyOption}`); } } if (options.quiet) { args.push(`--quiet`); } if (options.messages != undefined) { options.messages = toArray(options.messages); for (const message of options.messages) { args.push("-m", message); } } if (options.refsToMerge) { for (const refToMerge of toArray(options.refsToMerge)) { if (refToMerge) { args.push(refToMerge); } } } return this.run(args, options); } public rebase(options: ExecutableGit.RebaseOptions = {}): Promise<ExecutableGit.Result> { const args: string[] = ["rebase"]; if (options.strategy) { args.push(`--strategy=${options.strategy}`); } if (options.strategyOption) { args.push(`--strategy-option=${options.strategyOption}`); } if (options.quiet) { args.push("--quiet"); } if (options.verbose) { args.push("--verbose"); } if (options.newbase) { args.push("--onto", options.newbase); } if (options.upstream) { args.push(options.upstream); } if (options.branch) { args.push(options.branch); } return this.run(args, options); } /** * Clone the repository with the provided URI. * @param gitUri The repository URI to clone. * @param options The options that can be passed to "git clone". */ public async clone(gitUri: string, options: ExecutableGit.CloneOptions = {}): Promise<ExecutableGit.Result> { const args: string[] = getCloneArguments(await this.addAuthenticationToURL(gitUri), options); return await this.run(args, options); } /** * Init git repo. * @param options Options */ public async init(options?: ExecutableGit.Options): Promise<ExecutableGit.Result> { return this.run(["init"], options); } public async symbolicRef(name: string, ref?: string, options: ExecutableGit.Options = {}): Promise<ExecutableGit.Result> { const args: string[] = ["symbolic-ref"]; args.push(name); if (ref) { args.push(ref); } return this.run(args, options); } /** * Checkout the provided git reference (branch, tag, or commit ID) in the repository. * @param refId The git reference to checkout. */ public async checkout(refId: string, options: ExecutableGit.CheckoutOptions = {}): Promise<ExecutableGit.CheckoutResult> { const args: string[] = [`checkout`]; if (!options.remote) { args.push(refId); } else { args.push(`--track`, `${options.remote}/${refId}`); } if (options.localBranchName) { args.push("-b", options.localBranchName); } if (options.detach) { args.push("--detach"); } const runResult: ExecutableGit.Result = await this.run(args, options); let filesThatWouldBeOverwritten: string[] | undefined; if (runResult.stderr) { const stderrLines: string[] = getLines(runResult.stderr); if (stderrLines[0].trim() === "error: The following untracked working tree files would be overwritten by checkout:") { filesThatWouldBeOverwritten = []; let lineIndex = 1; while (lineIndex < stderrLines.length) { const line: string = stderrLines[lineIndex]; if (line.trim() === "Please move or remove them before you switch branches.") { break; } else { filesThatWouldBeOverwritten.push(joinPath(options.executionFolderPath || this.options.executionFolderPath || "", line.trim())); ++lineIndex; } } } } return { ...runResult, filesThatWouldBeOverwritten }; } /** * Pull the latest changes for the current branch from the registered remote branch. */ public pull(options: ExecutableGit.Options = {}): Promise<ExecutableGit.Result> { return this.run([`pull`], options); } /** * Push the current branch to the remote tracked repository. * @param options The options for determining how this command will run. */ public async push(options: ExecutableGit.PushOptions = {}): Promise<ExecutableGit.Result> { const args: string[] = ["push"]; if (options.setUpstream) { const upstream: string = typeof options.setUpstream === "string" ? options.setUpstream : "origin"; const branchName: string = options.branchName || await this.currentBranch(options); args.push(`--set-upstream`, upstream, branchName); } if (options.force) { args.push(`--force`); } return await this.run(args, options); } /** * Add/stage the provided files. * @param filePaths The paths to the files to stage. * @param options The options for determining how this command will run. */ public add(filePaths: string | string[], options: ExecutableGit.Options = {}): Promise<ExecutableGit.Result> { const args: string[] = ["add"]; if (typeof filePaths === "string") { args.push(filePaths); } else { args.push(...filePaths); } return this.run(args, options); } /** * Add/stage all of the current unstaged files. * @param options The options that determine how this command will run. */ public addAll(options: ExecutableGit.Options = {}): Promise<ExecutableGit.Result> { return this.add("*", options); } /** * Commit the currently staged/added changes to the current branch. * @param commitMessages The commit messages to apply to this commit. * @param options The options that determine how this command will run. */ public commit(commitMessages: string | string[], options: ExecutableGit.CommitOptions = {}): Promise<ExecutableGit.Result> { const args: string[] = ["commit"]; if (options.noVerify) { args.push("--no-verify"); } if (typeof commitMessages === "string") { commitMessages = [commitMessages]; } for (const commitMessage of commitMessages) { args.push("-m", commitMessage); } return this.run(args, options); } /** * Delete a local branch. * @param branchName The name of the local branch to delete. */ public deleteLocalBranch(branchName: string, options: ExecutableGit.Options = {}): Promise<ExecutableGit.Result> { return this.run([`branch`, `-D`, branchName], options); } /** * Create a new local branch with the provided name. * @param branchName The name of the new branch. * @param options The options for determining how this command will run. */ public createLocalBranch(branchName: string, options: ExecutableGit.CreateLocalBranchOptions = {}): Promise<ExecutableGit.Result> { const args: string[] = ["checkout", "-b", branchName]; if (options.startPoint) { args.push(options.startPoint); } return this.run(args, options); } /** * Remote the provided branch from the provided tracked remote repository. * @param branchName The name of the remote branch to delete. * @param remoteName The name of the tracked remote repository. * @param options The options for determining how this command will run. */ public deleteRemoteBranch(branchName: string, options: ExecutableGit.DeleteRemoteBranchOptions = {}): Promise<ExecutableGit.Result> { return this.run([`push`, options.remoteName || "origin", `:${branchName}`], options); } public async diff(options: ExecutableGit.DiffOptions = {}): Promise<ExecutableGit.DiffResult> { const args: string[] = ["diff"]; if (options.commit1) { args.push(options.commit1); } if (options.commit2) { args.push(options.commit2); } if (options.staged) { args.push(`--staged`); } if (options.nameOnly) { args.push(`--name-only`); } if (options.ignoreSpace === "all") { args.push(`--ignore-all-space`); } else if (options.ignoreSpace) { args.push(`--ignore-space-${options.ignoreSpace}`); } const commandResult: ExecutableGit.Result = await this.run(args, options); let filesChanged: string[]; const repositoryFolderPath: string | undefined = options.executionFolderPath || this.options.executionFolderPath || process.cwd(); const stdoutLines: string[] = getLines(commandResult.stdout); if (options.nameOnly) { filesChanged = []; for (const fileChanged of getLines(commandResult.stdout)) { if (fileChanged) { filesChanged.push(joinPath(repositoryFolderPath, fileChanged)); } } } else { filesChanged = getFilesChangedFromFullDiff(stdoutLines, repositoryFolderPath); } return { ...commandResult, filesChanged }; } /** * Get the branches that are local to this repository. */ public async localBranches(options: ExecutableGit.Options = {}): Promise<ExecutableGit.LocalBranchesResult> { const commandResult: ExecutableGit.Result = await this.run(["branch"], options); let currentBranch = ""; const localBranches: string[] = []; for (let branch of getLines(commandResult.stdout)) { if (branch) { branch = branch.trim(); if (branch) { if (branch.startsWith("*")) { branch = branch.substring(1).trimLeft(); const detachedHeadMatch: RegExpMatchArray | null = branch.match(branchDetachedHeadRegExp); if (detachedHeadMatch) { branch = detachedHeadMatch[1]; } currentBranch = branch; } localBranches.push(branch); } } } return { ...commandResult, localBranches, currentBranch, }; } /** * Get the branch that the repository is currently on. * @param options The options to run this command with. */ public async currentBranch(options: ExecutableGit.Options = {}): Promise<string> { return (await this.localBranches(options)).currentBranch; } /** * Get the remote branches that this repository clone is aware of. * @param options The options to run this command with. */ public async remoteBranches(options: ExecutableGit.Options = {}): Promise<ExecutableGit.RemoteBranchesResult> { const gitResult: ExecutableGit.Result = await this.run(["branch", "--remotes"], options); const remoteBranches: GitRemoteBranch[] = []; for (let remoteBranchLine of getLines(gitResult.stdout)) { if (remoteBranchLine && remoteBranchLine.indexOf("->") === -1) { remoteBranchLine = remoteBranchLine.trim(); if (remoteBranchLine) { const firstSlashIndex: number = remoteBranchLine.indexOf("/"); const repositoryTrackingName: string = remoteBranchLine.substring(0, firstSlashIndex); const branchName: string = remoteBranchLine.substring(firstSlashIndex + 1); remoteBranches.push({ repositoryTrackingName, branchName }); } } } return { ...gitResult, remoteBranches }; } public async stash(options: ExecutableGit.StashOptions = {}): Promise<ExecutableGit.Result> { const args = ["stash"]; if (options.pop) { args.push("pop"); } if (options.keepIndex) { args.push("--keep-index"); } if (options.all) { args.push("--all"); } return this.run(args, options); } /** * Reset a local git repo, possible with existing git data. * Delete all the branches and all the remotes. * Use this function to reuse existing git repo data to accelerate fetching. */ public async resetRepoFolder(): Promise<void> { const log = this.options.log || console.error; if (this.options.executionFolderPath) { mkdirSync(this.options.executionFolderPath, { recursive: true }); } const initResult = await this.init(); if (initResult.exitCode !== 0) { log(`Failed to init repo at ${this.options.executionFolderPath}`); if (initResult.error) { log(JSON.stringify(initResult.error)); throw initResult; } } try { await this.addAll(); } catch (e) {} await this.resetAll({ hard: true }); await this.run(["clean", "-xdf"]); // Remove all the local branches const localBranchesResult = await this.localBranches(); if (localBranchesResult.currentBranch) { try { await this.checkout(localBranchesResult.currentBranch, { detach: true }); await this.deleteLocalBranch(localBranchesResult.currentBranch); } catch (e) { } } for (const branchName of localBranchesResult.localBranches) { if (branchName && branchName !== localBranchesResult.currentBranch) { try { await this.deleteLocalBranch(branchName); } catch (e) { } } } // Remove all the remotes const listRemotesResult = await this.listRemotes(); for (const remoteName of Object.keys(listRemotesResult.remotes)) { if (remoteName) { await this.removeRemote(remoteName); } } } /** * Run "git status". */ public async status(options: ExecutableGit.Options = {}): Promise<ExecutableGit.StatusResult> { const folderPath: string = options.executionFolderPath || this.options.executionFolderPath || process.cwd(); let parseState: StatusParseState = "CurrentBranch"; let localBranch: string | undefined; let remoteBranch: string | undefined; let hasUncommittedChanges = false; const stagedModifiedFiles: string[] = []; const stagedDeletedFiles: string[] = []; const notStagedModifiedFiles: string[] = []; const notStagedDeletedFiles: string[] = []; const untrackedFiles: string[] = []; const runResult: ExecutableGit.Result = await this.run(["status"], options); const lines: string[] = getLines(runResult.stdout); let lineIndex = 0; while (lineIndex < lines.length) { const line: string = lines[lineIndex].trim(); if (!line) { ++lineIndex; } else { switch (parseState) { case "CurrentBranch": const onBranchMatch: RegExpMatchArray | null = line.match(onBranchRegExp); if (onBranchMatch) { localBranch = onBranchMatch[1]; } else { const detachedHeadMatch: RegExpMatchArray | null = line.match(statusDetachedHeadRegExp); if (detachedHeadMatch) { localBranch = detachedHeadMatch[1]; } } parseState = "RemoteBranch"; ++lineIndex; break; case "RemoteBranch": const remoteBranchMatch: RegExpMatchArray | null = line.match(/.*\'(.*)\'.*/); if (remoteBranchMatch) { remoteBranch = remoteBranchMatch[1]; ++lineIndex; } parseState = "Changes"; break; case "Changes": hasUncommittedChanges = !line.match(/nothing to commit, working tree clean/i); if (hasUncommittedChanges) { if (line.match(/Changes to be committed:/i)) { parseState = "ChangesToBeCommitted"; } if (isChangesNotStagedForCommitHeader(line)) { parseState = "ChangesNotStagedForCommit"; } else if (isUntrackedFilesHeader(line)) { parseState = "UntrackedFiles"; } } ++lineIndex; break; case "ChangesToBeCommitted": if (!line.match(/\(use "git reset HEAD <file>..." to unstage\)/i)) { const modifiedMatch: RegExpMatchArray | null = line.match(/modified:(.*)/i); if (modifiedMatch) { const modifiedFilePath: string = joinPath(folderPath, modifiedMatch[1].trim()); stagedModifiedFiles.push(modifiedFilePath); } else { const deletedMatch: RegExpMatchArray | null = line.match(/deleted:(.*)/i); if (deletedMatch) { const deletedFilePath: string = joinPath(folderPath, deletedMatch[1].trim()); stagedDeletedFiles.push(deletedFilePath); } else if (isChangesNotStagedForCommitHeader(line)) { parseState = "ChangesNotStagedForCommit"; } else if (isUntrackedFilesHeader(line)) { parseState = "UntrackedFiles"; } } } ++lineIndex; break; case "ChangesNotStagedForCommit": if (!line.match(/\(use "git add <file>..." to update what will be committed\)/i) && !line.match(/\(use "git checkout -- <file>..." to discard changes in working directory\)/i)) { const modifiedMatch: RegExpMatchArray | null = line.match(/modified:(.*)/i); if (modifiedMatch) { const modifiedFilePath: string = joinPath(folderPath, modifiedMatch[1].trim()); notStagedModifiedFiles.push(modifiedFilePath); } else { const deletedMatch: RegExpMatchArray | null = line.match(/deleted:(.*)/i); if (deletedMatch) { const deletedFilePath: string = joinPath(folderPath, deletedMatch[1].trim()); notStagedDeletedFiles.push(deletedFilePath); } else if (isUntrackedFilesHeader(line)) { parseState = "UntrackedFiles"; } } } ++lineIndex; break; case "UntrackedFiles": if (!line.match(/\(use "git add <file>..." to include in what will be committed\)/i) && !line.match(/nothing added to commit but untracked files present \(use "git add" to track\)/i) && !line.match(/no changes added to commit \(use \"git add\" and\/or \"git commit -a\"\)/i)) { const resolveUntrackedFilePath: string = joinPath(folderPath, line); untrackedFiles.push(resolveUntrackedFilePath); } ++lineIndex; break; } } } const modifiedFiles: string[] = []; if (hasUncommittedChanges) { modifiedFiles.push( ...stagedModifiedFiles, ...stagedDeletedFiles, ...notStagedModifiedFiles, ...notStagedDeletedFiles, ...untrackedFiles); } return { ...runResult, localBranch, remoteBranch, hasUncommittedChanges, modifiedFiles, stagedModifiedFiles, stagedDeletedFiles, notStagedModifiedFiles, notStagedDeletedFiles, untrackedFiles, }; } /** * Get the configuration value for the provided configuration value name. * @param configurationValueName The name of the configuration value to get. * @param options The options that can configure how the command will run. */ public async getConfigurationValue(configurationValueName: string, options?: ExecutableGit.Options): Promise<ExecutableGit.GetConfigurationValueResult> { const result: ExecutableGit.GetConfigurationValueResult = await this.run(["config", "--get", configurationValueName], options); if (result.exitCode === 0 && result.stdout) { result.configurationValue = result.stdout; } return result; } /** * Get the URL of the current repository. * @param options The options that can configure how the command will run. */ public async getRepositoryUrl(options: ExecutableGit.Options = {}): Promise<string | undefined> { let result: string | undefined = (await this.getConfigurationValue("remote.origin.url", options)).configurationValue; if (result) { result = result.trim(); } return result; } /** * Reset all the file. * @param options The options that can configure how the command will run. */ public resetAll(options: ExecutableGit.ResetOptions = {}): Promise<ExecutableGit.Result> { const args = ["reset"]; if (options.hard) { args.push("--hard"); } if (options.soft) { args.push("--soft"); } if (options.target) { args.push(options.target); } else if (!options.hard && !options.soft) { args.push("*"); } return this.run(args, options); } /** * Add the provided remote URL to this local repository's list of remote repositories using the * provided remoteName. * @param remoteName The name/reference that will be used to refer to the remote repository. * @param remoteUrl The URL of the remote repository. * @param options Options that can be used to modify the way that this operation is run. */ public async addRemote(remoteName: string, remoteUrl: string, options: ExecutableGit.Options = {}): Promise<ExecutableGit.Result> { return await this.run(["remote", "add", remoteName, await this.addAuthenticationToURL(remoteUrl)], options); } /** * Remove git remote repository * @param remoteName The name of the remote repository to be removed * @param options Options */ public async removeRemote(remoteName: string, options?: ExecutableGit.Options) { return this.run(["remote", "remove", remoteName], options); } /** * Get the URL associated with the provided remote repository. * @param remoteName The name of the remote repository. * @returns The URL associated with the provided remote repository or undefined if the remote name * is not found. */ public async getRemoteUrl(remoteName: string, options: ExecutableGit.Options = {}): Promise<string | undefined> { const result: string | undefined = (await this.run(["remote", "get-url", remoteName], options)).stdout; return (result && result.trim()) || undefined; } /** * Set the URL associated with the provided remote repository. * @param remoteName The name of the remote repository. * @param remoteUrl The URL associated with the provided remote repository. */ public async setRemoteUrl(remoteName: stri