import * as core from '@actions/core' import * as exec from '@actions/exec' import * as fshelper from './fs-helper' import * as io from '@actions/io' import * as path from 'path' import {GitVersion} from './git-version' export interface IGitCommandManager { branchDelete(remote: boolean, branch: string): Promise branchExists(remote: boolean, pattern: string): Promise branchList(remote: boolean): Promise checkout(ref: string, startPoint: string): Promise checkoutDetach(): Promise config(configKey: string, configValue: string): Promise configExists(configKey: string): Promise fetch(fetchDepth: number, refSpec: string[]): Promise getWorkingDirectory(): string init(): Promise isDetached(): Promise lfsFetch(ref: string): Promise lfsInstall(): Promise log1(): Promise remoteAdd(remoteName: string, remoteUrl: string): Promise tagExists(pattern: string): Promise tryClean(): Promise tryConfigUnset(configKey: string): Promise tryDisableAutomaticGarbageCollection(): Promise tryGetFetchUrl(): Promise tryReset(): Promise } export async function CreateCommandManager( workingDirectory: string, lfs: boolean ): Promise { return await GitCommandManager.createCommandManager(workingDirectory, lfs) } 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 workingDirectory = '' // 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" because the output from // "branch --list" is more difficult when in a detached HEAD state. const args = ['rev-parse', '--symbolic'] if (remote) { args.push('--remotes=origin') } else { args.push('--branches') } const output = await this.execGit(args) for (let branch of output.stdout.trim().split('\n')) { branch = branch.trim() if (branch) { result.push(branch) } } return result } 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): Promise { await this.execGit(['config', configKey, configValue]) } async configExists(configKey: string): Promise { const pattern = configKey.replace(/[^a-zA-Z0-9_]/g, x => { return `\\${x}` }) const output = await this.execGit( ['config', '--name-only', '--get-regexp', pattern], true ) return output.exitCode === 0 } async fetch(fetchDepth: number, refSpec: string[]): Promise { const args = [ '-c', 'protocol.version=2', 'fetch', '--no-tags', '--prune', '--progress', '--no-recurse-submodules' ] if (fetchDepth > 0) { args.push(`--depth=${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) } let attempt = 1 const maxAttempts = 3 while (attempt <= maxAttempts) { const allowAllExitCodes = attempt < maxAttempts const output = await this.execGit(args, allowAllExitCodes) if (output.exitCode === 0) { break } const seconds = this.getRandomIntInclusive(1, 10) core.warning( `Git fetch failed with exit code ${output.exitCode}. Waiting ${seconds} seconds before trying again.` ) await this.sleep(seconds * 1000) attempt++ } } getWorkingDirectory(): string { return this.workingDirectory } async init(): Promise { await this.execGit(['init', this.workingDirectory]) } async isDetached(): Promise { // Note, this implementation uses "branch --show-current" because // "rev-parse --symbolic-full-name HEAD" can fail on a new repo // with nothing checked out. const output = await this.execGit(['branch', '--show-current']) return output.stdout.trim() === '' } async lfsFetch(ref: string): Promise { const args = ['lfs', 'fetch', 'origin', ref] let attempt = 1 const maxAttempts = 3 while (attempt <= maxAttempts) { const allowAllExitCodes = attempt < maxAttempts const output = await this.execGit(args, allowAllExitCodes) if (output.exitCode === 0) { break } const seconds = this.getRandomIntInclusive(1, 10) core.warning( `Git lfs fetch failed with exit code ${output.exitCode}. Waiting ${seconds} seconds before trying again.` ) await this.sleep(seconds * 1000) attempt++ } } async lfsInstall(): Promise { await this.execGit(['lfs', 'install', '--local']) } async log1(): Promise { await this.execGit(['log', '-1']) } async remoteAdd(remoteName: string, remoteUrl: string): Promise { await this.execGit(['remote', 'add', remoteName, remoteUrl]) } 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): Promise { const output = await this.execGit( ['config', '--unset-all', configKey], true ) return output.exitCode === 0 } async tryDisableAutomaticGarbageCollection(): Promise { const output = await this.execGit(['config', 'gc.auto', '0'], true) return output.exitCode === 0 } async tryGetFetchUrl(): Promise { const output = await this.execGit( ['config', '--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 } static async createCommandManager( workingDirectory: string, lfs: boolean ): Promise { const result = new GitCommandManager() await result.initializeCommandManager(workingDirectory, lfs) return result } private async execGit( args: string[], allowAllExitCodes = false ): 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 stdout: string[] = [] const options = { cwd: this.workingDirectory, env, ignoreReturnCode: allowAllExitCodes, listeners: { stdout: (data: Buffer) => { stdout.push(data.toString()) } } } result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options) result.stdout = stdout.join('') return result } private async initializeCommandManager( workingDirectory: string, lfs: 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') let 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) { gitVersion = new GitVersion(match[0]) } } if (!gitVersion.isValid()) { throw new Error('Unable to determine git version') } // Minimum git version // Note: // - Auth header not supported before 2.9 // - Wire protocol v2 not supported before 2.18 const minimumGitVersion = new GitVersion('2.18') if (!gitVersion.checkMinimum(minimumGitVersion)) { throw new Error( `Minimum required git version is ${minimumGitVersion}. Your git ('${this.gitPath}') is ${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}` ) } } // Set the user agent const gitHttpUserAgent = `git/${gitVersion} (github-actions-checkout)` core.debug(`Set git useragent to: ${gitHttpUserAgent}`) this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent } private getRandomIntInclusive(minimum: number, maximum: number): number { minimum = Math.floor(minimum) maximum = Math.floor(maximum) return Math.floor(Math.random() * (maximum - minimum + 1)) + minimum } private async sleep(milliseconds): Promise { return new Promise(resolve => setTimeout(resolve, milliseconds)) } } class GitOutput { stdout = '' exitCode = 0 }