import * as core from '@actions/core' import * as exec from '@actions/exec' import * as fs from 'fs' import * as fshelper from './fs-helper' import * as io from '@actions/io' import * as path from 'path' import * as refHelper from './ref-helper' import * as regexpHelper from './regexp-helper' import * as retryHelper from './retry-helper' import {GitVersion} from './git-version' // Auth header not supported before 2.9 // Wire protocol v2 not supported before 2.18 // sparse-checkout not [well-]supported before 2.28 (see https://github.com/actions/checkout/issues/1386) export const MinimumGitVersion = new GitVersion('2.18') export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28') export interface IGitCommandManager { branchDelete(remote: boolean, branch: string): Promise branchExists(remote: boolean, pattern: string): Promise branchList(remote: boolean): Promise disableSparseCheckout(): Promise sparseCheckout(sparseCheckout: string[]): Promise sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise checkout(ref: string, startPoint: string): Promise checkoutDetach(): Promise config( configKey: string, configValue: string, globalConfig?: boolean, add?: boolean ): Promise configExists(configKey: string, globalConfig?: boolean): Promise fetch( refSpec: string[], options: { filter?: string fetchDepth?: number fetchTags?: boolean showProgress?: boolean } ): Promise getDefaultBranch(repositoryUrl: string): Promise getWorkingDirectory(): string init(): Promise isDetached(): Promise lfsFetch(ref: string): Promise lfsInstall(): Promise log1(format?: string): Promise remoteAdd(remoteName: string, remoteUrl: string): Promise removeEnvironmentVariable(name: string): void revParse(ref: string): Promise setEnvironmentVariable(name: string, value: string): void shaExists(sha: string): Promise submoduleForeach(command: string, recursive: boolean): Promise submoduleSync(recursive: boolean): Promise submoduleUpdate(fetchDepth: number, recursive: boolean): Promise submoduleStatus(): Promise tagExists(pattern: string): Promise tryClean(): Promise tryConfigUnset(configKey: string, globalConfig?: boolean): Promise tryDisableAutomaticGarbageCollection(): Promise tryGetFetchUrl(): Promise tryReset(): Promise version(): Promise } export async function createCommandManager( workingDirectory: string, lfs: boolean, doSparseCheckout: boolean ): Promise { return await GitCommandManager.createCommandManager( workingDirectory, lfs, doSparseCheckout ) } class GitCommandManager { private gitEnv = { GIT_TERMINAL_PROMPT: '0', // Disable git prompt GCM_INTERACTIVE: 'Never' // Disable prompting for git credential manager } private gitPath = '' private lfs = false private doSparseCheckout = false private workingDirectory = '' private gitVersion: GitVersion = new GitVersion() // Private constructor; use createCommandManager() private constructor() {} async branchDelete(remote: boolean, branch: string): Promise { const args = ['branch', '--delete', '--force'] if (remote) { args.push('--remote') } args.push(branch) await this.execGit(args) } async branchExists(remote: boolean, pattern: string): Promise { const args = ['branch', '--list'] if (remote) { args.push('--remote') } args.push(pattern) const output = await this.execGit(args) return !!output.stdout.trim() } async branchList(remote: boolean): Promise { const result: string[] = [] // Note, this implementation uses "rev-parse --symbolic-full-name" because the output from // "branch --list" is more difficult when in a detached HEAD state. // TODO(https://github.com/actions/checkout/issues/786): this implementation uses // "rev-parse --symbolic-full-name" because there is a bug // in Git 2.18 that causes "rev-parse --symbolic" to output symbolic full names. When // 2.18 is no longer supported, we can switch back to --symbolic. const args = ['rev-parse', '--symbolic-full-name'] if (remote) { args.push('--remotes=origin') } else { args.push('--branches') } const stderr: string[] = [] const errline: string[] = [] const stdout: string[] = [] const stdline: string[] = [] const listeners = { stderr: (data: Buffer) => { stderr.push(data.toString()) }, errline: (data: Buffer) => { errline.push(data.toString()) }, stdout: (data: Buffer) => { stdout.push(data.toString()) }, stdline: (data: Buffer) => { stdline.push(data.toString()) } } // Suppress the output in order to avoid flooding annotations with innocuous errors. await this.execGit(args, false, true, listeners) core.debug(`stderr callback is: ${stderr}`) core.debug(`errline callback is: ${errline}`) core.debug(`stdout callback is: ${stdout}`) core.debug(`stdline callback is: ${stdline}`) for (let branch of stdline) { branch = branch.trim() if (!branch) { continue } if (branch.startsWith('refs/heads/')) { branch = branch.substring('refs/heads/'.length) } else if (branch.startsWith('refs/remotes/')) { branch = branch.substring('refs/remotes/'.length) } result.push(branch) } return result } async disableSparseCheckout(): Promise { await this.execGit(['sparse-checkout', 'disable']) } async sparseCheckout(sparseCheckout: string[]): Promise { await this.execGit(['sparse-checkout', 'set', ...sparseCheckout]) } async sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise { await this.execGit(['config', 'core.sparseCheckout', 'true']) const output = await this.execGit([ 'rev-parse', '--git-path', 'info/sparse-checkout' ]) const sparseCheckoutPath = path.join( this.workingDirectory, output.stdout.trimRight() ) await fs.promises.appendFile( sparseCheckoutPath, `\n${sparseCheckout.join('\n')}\n` ) } async checkout(ref: string, startPoint: string): Promise { const args = ['checkout', '--progress', '--force'] if (startPoint) { args.push('-B', ref, startPoint) } else { args.push(ref) } await this.execGit(args) } async checkoutDetach(): Promise { const args = ['checkout', '--detach'] await this.execGit(args) } async config( configKey: string, configValue: string, globalConfig?: boolean, add?: boolean ): Promise { const args: string[] = ['config', globalConfig ? '--global' : '--local'] if (add) { args.push('--add') } args.push(...[configKey, configValue]) await this.execGit(args) } async configExists( configKey: string, globalConfig?: boolean ): Promise { const pattern = regexpHelper.escape(configKey) const output = await this.execGit( [ 'config', globalConfig ? '--global' : '--local', '--name-only', '--get-regexp', pattern ], true ) return output.exitCode === 0 } async fetch( refSpec: string[], options: { filter?: string fetchDepth?: number fetchTags?: boolean showProgress?: boolean } ): Promise { const args = ['-c', 'protocol.version=2', 'fetch'] if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) { args.push('--no-tags') } args.push('--prune', '--no-recurse-submodules') if (options.showProgress) { args.push('--progress') } if (options.filter) { args.push(`--filter=${options.filter}`) } if (options.fetchDepth && options.fetchDepth > 0) { args.push(`--depth=${options.fetchDepth}`) } else if ( fshelper.fileExistsSync( path.join(this.workingDirectory, '.git', 'shallow') ) ) { args.push('--unshallow') } args.push('origin') for (const arg of refSpec) { args.push(arg) } const that = this await retryHelper.execute(async () => { await that.execGit(args) }) } async getDefaultBranch(repositoryUrl: string): Promise { let output: GitOutput | undefined await retryHelper.execute(async () => { output = await this.execGit([ 'ls-remote', '--quiet', '--exit-code', '--symref', repositoryUrl, 'HEAD' ]) }) if (output) { // Satisfy compiler, will always be set for (let line of output.stdout.trim().split('\n')) { line = line.trim() if (line.startsWith('ref:') || line.endsWith('HEAD')) { return line .substr('ref:'.length, line.length - 'ref:'.length - 'HEAD'.length) .trim() } } } throw new Error('Unexpected output when retrieving default branch') } getWorkingDirectory(): string { return this.workingDirectory } async init(): Promise { await this.execGit(['init', this.workingDirectory]) } async isDetached(): Promise { // Note, "branch --show-current" would be simpler but isn't available until Git 2.22 const output = await this.execGit( ['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'], true ) return !output.stdout.trim().startsWith('refs/heads/') } async lfsFetch(ref: string): Promise { const args = ['lfs', 'fetch', 'origin', ref] const that = this await retryHelper.execute(async () => { await that.execGit(args) }) } async lfsInstall(): Promise { await this.execGit(['lfs', 'install', '--local']) } async log1(format?: string): Promise { const args = format ? ['log', '-1', format] : ['log', '-1'] const silent = format ? false : true const output = await this.execGit(args, false, silent) return output.stdout } async remoteAdd(remoteName: string, remoteUrl: string): Promise { await this.execGit(['remote', 'add', remoteName, remoteUrl]) } removeEnvironmentVariable(name: string): void { delete this.gitEnv[name] } /** * Resolves a ref to a SHA. For a branch or lightweight tag, the commit SHA is returned. * For an annotated tag, the tag SHA is returned. * @param {string} ref For example: 'refs/heads/main' or '/refs/tags/v1' * @returns {Promise} */ async revParse(ref: string): Promise { const output = await this.execGit(['rev-parse', ref]) return output.stdout.trim() } setEnvironmentVariable(name: string, value: string): void { this.gitEnv[name] = value } async shaExists(sha: string): Promise { const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`] const output = await this.execGit(args, true) return output.exitCode === 0 } async submoduleForeach(command: string, recursive: boolean): Promise { const args = ['submodule', 'foreach'] if (recursive) { args.push('--recursive') } args.push(command) const output = await this.execGit(args) return output.stdout } async submoduleSync(recursive: boolean): Promise { const args = ['submodule', 'sync'] if (recursive) { args.push('--recursive') } await this.execGit(args) } async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise { const args = ['-c', 'protocol.version=2'] args.push('submodule', 'update', '--init', '--force') if (fetchDepth > 0) { args.push(`--depth=${fetchDepth}`) } if (recursive) { args.push('--recursive') } await this.execGit(args) } async submoduleStatus(): Promise { const output = await this.execGit(['submodule', 'status'], true) core.debug(output.stdout) return output.exitCode === 0 } async tagExists(pattern: string): Promise { const output = await this.execGit(['tag', '--list', pattern]) return !!output.stdout.trim() } async tryClean(): Promise { const output = await this.execGit(['clean', '-ffdx'], true) return output.exitCode === 0 } async tryConfigUnset( configKey: string, globalConfig?: boolean ): Promise { const output = await this.execGit( [ 'config', globalConfig ? '--global' : '--local', '--unset-all', configKey ], true ) return output.exitCode === 0 } async tryDisableAutomaticGarbageCollection(): Promise { const output = await this.execGit( ['config', '--local', 'gc.auto', '0'], true ) return output.exitCode === 0 } async tryGetFetchUrl(): Promise { const output = await this.execGit( ['config', '--local', '--get', 'remote.origin.url'], true ) if (output.exitCode !== 0) { return '' } const stdout = output.stdout.trim() if (stdout.includes('\n')) { return '' } return stdout } async tryReset(): Promise { const output = await this.execGit(['reset', '--hard', 'HEAD'], true) return output.exitCode === 0 } async version(): Promise { return this.gitVersion } static async createCommandManager( workingDirectory: string, lfs: boolean, doSparseCheckout: boolean ): Promise { const result = new GitCommandManager() await result.initializeCommandManager( workingDirectory, lfs, doSparseCheckout ) return result } private async execGit( args: string[], allowAllExitCodes = false, silent = false, customListeners = {} ): Promise { fshelper.directoryExistsSync(this.workingDirectory, true) const result = new GitOutput() const env = {} for (const key of Object.keys(process.env)) { env[key] = process.env[key] } for (const key of Object.keys(this.gitEnv)) { env[key] = this.gitEnv[key] } const defaultListener = { stdout: (data: Buffer) => { stdout.push(data.toString()) } } const mergedListeners = {...defaultListener, ...customListeners} const stdout: string[] = [] const options = { cwd: this.workingDirectory, env, silent, ignoreReturnCode: allowAllExitCodes, listeners: mergedListeners } result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options) result.stdout = stdout.join('') core.debug(result.exitCode.toString()) core.debug(result.stdout) return result } private async initializeCommandManager( workingDirectory: string, lfs: boolean, doSparseCheckout: boolean ): Promise { this.workingDirectory = workingDirectory // Git-lfs will try to pull down assets if any of the local/user/system setting exist. // If the user didn't enable `LFS` in their pipeline definition, disable LFS fetch/checkout. this.lfs = lfs if (!this.lfs) { this.gitEnv['GIT_LFS_SKIP_SMUDGE'] = '1' } this.gitPath = await io.which('git', true) // Git version core.debug('Getting git version') this.gitVersion = new GitVersion() let gitOutput = await this.execGit(['version']) let stdout = gitOutput.stdout.trim() if (!stdout.includes('\n')) { const match = stdout.match(/\d+\.\d+(\.\d+)?/) if (match) { this.gitVersion = new GitVersion(match[0]) } } if (!this.gitVersion.isValid()) { throw new Error('Unable to determine git version') } // Minimum git version if (!this.gitVersion.checkMinimum(MinimumGitVersion)) { throw new Error( `Minimum required git version is ${MinimumGitVersion}. Your git ('${this.gitPath}') is ${this.gitVersion}` ) } if (this.lfs) { // Git-lfs version core.debug('Getting git-lfs version') let gitLfsVersion = new GitVersion() const gitLfsPath = await io.which('git-lfs', true) gitOutput = await this.execGit(['lfs', 'version']) stdout = gitOutput.stdout.trim() if (!stdout.includes('\n')) { const match = stdout.match(/\d+\.\d+(\.\d+)?/) if (match) { gitLfsVersion = new GitVersion(match[0]) } } if (!gitLfsVersion.isValid()) { throw new Error('Unable to determine git-lfs version') } // Minimum git-lfs version // Note: // - Auth header not supported before 2.1 const minimumGitLfsVersion = new GitVersion('2.1') if (!gitLfsVersion.checkMinimum(minimumGitLfsVersion)) { throw new Error( `Minimum required git-lfs version is ${minimumGitLfsVersion}. Your git-lfs ('${gitLfsPath}') is ${gitLfsVersion}` ) } } this.doSparseCheckout = doSparseCheckout if (this.doSparseCheckout) { if (!this.gitVersion.checkMinimum(MinimumGitSparseCheckoutVersion)) { throw new Error( `Minimum Git version required for sparse checkout is ${MinimumGitSparseCheckoutVersion}. Your git ('${this.gitPath}') is ${this.gitVersion}` ) } } // Set the user agent const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)` core.debug(`Set git useragent to: ${gitHttpUserAgent}`) this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent } } class GitOutput { stdout = '' exitCode = 0 }