/ src / git-command-manager.ts
git-command-manager.ts
  1  import * as core from '@actions/core'
  2  import * as exec from '@actions/exec'
  3  import * as fshelper from './fs-helper'
  4  import * as io from '@actions/io'
  5  import * as path from 'path'
  6  import * as refHelper from './ref-helper'
  7  import * as regexpHelper from './regexp-helper'
  8  import * as retryHelper from './retry-helper'
  9  import {GitVersion} from './git-version'
 10  
 11  // Auth header not supported before 2.9
 12  // Wire protocol v2 not supported before 2.18
 13  export const MinimumGitVersion = new GitVersion('2.18')
 14  
 15  export interface IGitCommandManager {
 16    branchDelete(remote: boolean, branch: string): Promise<void>
 17    branchExists(remote: boolean, pattern: string): Promise<boolean>
 18    branchList(remote: boolean): Promise<string[]>
 19    checkout(ref: string, startPoint: string): Promise<void>
 20    checkoutDetach(): Promise<void>
 21    config(
 22      configKey: string,
 23      configValue: string,
 24      globalConfig?: boolean
 25    ): Promise<void>
 26    configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
 27    fetch(refSpec: string[], fetchDepth?: number): Promise<void>
 28    getDefaultBranch(repositoryUrl: string): Promise<string>
 29    getWorkingDirectory(): string
 30    init(): Promise<void>
 31    isDetached(): Promise<boolean>
 32    lfsFetch(ref: string): Promise<void>
 33    lfsInstall(): Promise<void>
 34    log1(format?: string): Promise<string>
 35    remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
 36    removeEnvironmentVariable(name: string): void
 37    revParse(ref: string): Promise<string>
 38    setEnvironmentVariable(name: string, value: string): void
 39    shaExists(sha: string): Promise<boolean>
 40    submoduleForeach(command: string, recursive: boolean): Promise<string>
 41    submoduleSync(recursive: boolean): Promise<void>
 42    submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void>
 43    tagExists(pattern: string): Promise<boolean>
 44    tryClean(): Promise<boolean>
 45    tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
 46    tryDisableAutomaticGarbageCollection(): Promise<boolean>
 47    tryGetFetchUrl(): Promise<string>
 48    tryReset(): Promise<boolean>
 49  }
 50  
 51  export async function createCommandManager(
 52    workingDirectory: string,
 53    lfs: boolean
 54  ): Promise<IGitCommandManager> {
 55    return await GitCommandManager.createCommandManager(workingDirectory, lfs)
 56  }
 57  
 58  class GitCommandManager {
 59    private gitEnv = {
 60      GIT_TERMINAL_PROMPT: '0', // Disable git prompt
 61      GCM_INTERACTIVE: 'Never' // Disable prompting for git credential manager
 62    }
 63    private gitPath = ''
 64    private lfs = false
 65    private workingDirectory = ''
 66  
 67    // Private constructor; use createCommandManager()
 68    private constructor() {}
 69  
 70    async branchDelete(remote: boolean, branch: string): Promise<void> {
 71      const args = ['branch', '--delete', '--force']
 72      if (remote) {
 73        args.push('--remote')
 74      }
 75      args.push(branch)
 76  
 77      await this.execGit(args)
 78    }
 79  
 80    async branchExists(remote: boolean, pattern: string): Promise<boolean> {
 81      const args = ['branch', '--list']
 82      if (remote) {
 83        args.push('--remote')
 84      }
 85      args.push(pattern)
 86  
 87      const output = await this.execGit(args)
 88      return !!output.stdout.trim()
 89    }
 90  
 91    async branchList(remote: boolean): Promise<string[]> {
 92      const result: string[] = []
 93  
 94      // Note, this implementation uses "rev-parse --symbolic-full-name" because the output from
 95      // "branch --list" is more difficult when in a detached HEAD state.
 96      // Note, this implementation uses "rev-parse --symbolic-full-name" because there is a bug
 97      // in Git 2.18 that causes "rev-parse --symbolic" to output symbolic full names.
 98  
 99      const args = ['rev-parse', '--symbolic-full-name']
100      if (remote) {
101        args.push('--remotes=origin')
102      } else {
103        args.push('--branches')
104      }
105  
106      const output = await this.execGit(args)
107  
108      for (let branch of output.stdout.trim().split('\n')) {
109        branch = branch.trim()
110        if (branch) {
111          if (branch.startsWith('refs/heads/')) {
112            branch = branch.substr('refs/heads/'.length)
113          } else if (branch.startsWith('refs/remotes/')) {
114            branch = branch.substr('refs/remotes/'.length)
115          }
116  
117          result.push(branch)
118        }
119      }
120  
121      return result
122    }
123  
124    async checkout(ref: string, startPoint: string): Promise<void> {
125      const args = ['checkout', '--progress', '--force']
126      if (startPoint) {
127        args.push('-B', ref, startPoint)
128      } else {
129        args.push(ref)
130      }
131  
132      await this.execGit(args)
133    }
134  
135    async checkoutDetach(): Promise<void> {
136      const args = ['checkout', '--detach']
137      await this.execGit(args)
138    }
139  
140    async config(
141      configKey: string,
142      configValue: string,
143      globalConfig?: boolean
144    ): Promise<void> {
145      await this.execGit([
146        'config',
147        globalConfig ? '--global' : '--local',
148        configKey,
149        configValue
150      ])
151    }
152  
153    async configExists(
154      configKey: string,
155      globalConfig?: boolean
156    ): Promise<boolean> {
157      const pattern = regexpHelper.escape(configKey)
158      const output = await this.execGit(
159        [
160          'config',
161          globalConfig ? '--global' : '--local',
162          '--name-only',
163          '--get-regexp',
164          pattern
165        ],
166        true
167      )
168      return output.exitCode === 0
169    }
170  
171    async fetch(refSpec: string[], fetchDepth?: number): Promise<void> {
172      const args = ['-c', 'protocol.version=2', 'fetch']
173      if (!refSpec.some(x => x === refHelper.tagsRefSpec)) {
174        args.push('--no-tags')
175      }
176  
177      args.push('--prune', '--progress', '--no-recurse-submodules')
178      if (fetchDepth && fetchDepth > 0) {
179        args.push(`--depth=${fetchDepth}`)
180      } else if (
181        fshelper.fileExistsSync(
182          path.join(this.workingDirectory, '.git', 'shallow')
183        )
184      ) {
185        args.push('--unshallow')
186      }
187  
188      args.push('origin')
189      for (const arg of refSpec) {
190        args.push(arg)
191      }
192  
193      const that = this
194      await retryHelper.execute(async () => {
195        await that.execGit(args)
196      })
197    }
198  
199    async getDefaultBranch(repositoryUrl: string): Promise<string> {
200      let output: GitOutput | undefined
201      await retryHelper.execute(async () => {
202        output = await this.execGit([
203          'ls-remote',
204          '--quiet',
205          '--exit-code',
206          '--symref',
207          repositoryUrl,
208          'HEAD'
209        ])
210      })
211  
212      if (output) {
213        // Satisfy compiler, will always be set
214        for (let line of output.stdout.trim().split('\n')) {
215          line = line.trim()
216          if (line.startsWith('ref:') || line.endsWith('HEAD')) {
217            return line
218              .substr('ref:'.length, line.length - 'ref:'.length - 'HEAD'.length)
219              .trim()
220          }
221        }
222      }
223  
224      throw new Error('Unexpected output when retrieving default branch')
225    }
226  
227    getWorkingDirectory(): string {
228      return this.workingDirectory
229    }
230  
231    async init(): Promise<void> {
232      await this.execGit(['init', this.workingDirectory])
233    }
234  
235    async isDetached(): Promise<boolean> {
236      // Note, "branch --show-current" would be simpler but isn't available until Git 2.22
237      const output = await this.execGit(
238        ['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'],
239        true
240      )
241      return !output.stdout.trim().startsWith('refs/heads/')
242    }
243  
244    async lfsFetch(ref: string): Promise<void> {
245      const args = ['lfs', 'fetch', 'origin', ref]
246  
247      const that = this
248      await retryHelper.execute(async () => {
249        await that.execGit(args)
250      })
251    }
252  
253    async lfsInstall(): Promise<void> {
254      await this.execGit(['lfs', 'install', '--local'])
255    }
256  
257    async log1(format?: string): Promise<string> {
258      var args = format ? ['log', '-1', format] : ['log', '-1']
259      var silent = format ? false : true
260      const output = await this.execGit(args, false, silent)
261      return output.stdout
262    }
263  
264    async remoteAdd(remoteName: string, remoteUrl: string): Promise<void> {
265      await this.execGit(['remote', 'add', remoteName, remoteUrl])
266    }
267  
268    removeEnvironmentVariable(name: string): void {
269      delete this.gitEnv[name]
270    }
271  
272    /**
273     * Resolves a ref to a SHA. For a branch or lightweight tag, the commit SHA is returned.
274     * For an annotated tag, the tag SHA is returned.
275     * @param {string} ref  For example: 'refs/heads/main' or '/refs/tags/v1'
276     * @returns {Promise<string>}
277     */
278    async revParse(ref: string): Promise<string> {
279      const output = await this.execGit(['rev-parse', ref])
280      return output.stdout.trim()
281    }
282  
283    setEnvironmentVariable(name: string, value: string): void {
284      this.gitEnv[name] = value
285    }
286  
287    async shaExists(sha: string): Promise<boolean> {
288      const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`]
289      const output = await this.execGit(args, true)
290      return output.exitCode === 0
291    }
292  
293    async submoduleForeach(command: string, recursive: boolean): Promise<string> {
294      const args = ['submodule', 'foreach']
295      if (recursive) {
296        args.push('--recursive')
297      }
298      args.push(command)
299  
300      const output = await this.execGit(args)
301      return output.stdout
302    }
303  
304    async submoduleSync(recursive: boolean): Promise<void> {
305      const args = ['submodule', 'sync']
306      if (recursive) {
307        args.push('--recursive')
308      }
309  
310      await this.execGit(args)
311    }
312  
313    async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void> {
314      const args = ['-c', 'protocol.version=2']
315      args.push('submodule', 'update', '--init', '--force')
316      if (fetchDepth > 0) {
317        args.push(`--depth=${fetchDepth}`)
318      }
319  
320      if (recursive) {
321        args.push('--recursive')
322      }
323  
324      await this.execGit(args)
325    }
326  
327    async tagExists(pattern: string): Promise<boolean> {
328      const output = await this.execGit(['tag', '--list', pattern])
329      return !!output.stdout.trim()
330    }
331  
332    async tryClean(): Promise<boolean> {
333      const output = await this.execGit(['clean', '-ffdx'], true)
334      return output.exitCode === 0
335    }
336  
337    async tryConfigUnset(
338      configKey: string,
339      globalConfig?: boolean
340    ): Promise<boolean> {
341      const output = await this.execGit(
342        [
343          'config',
344          globalConfig ? '--global' : '--local',
345          '--unset-all',
346          configKey
347        ],
348        true
349      )
350      return output.exitCode === 0
351    }
352  
353    async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
354      const output = await this.execGit(
355        ['config', '--local', 'gc.auto', '0'],
356        true
357      )
358      return output.exitCode === 0
359    }
360  
361    async tryGetFetchUrl(): Promise<string> {
362      const output = await this.execGit(
363        ['config', '--local', '--get', 'remote.origin.url'],
364        true
365      )
366  
367      if (output.exitCode !== 0) {
368        return ''
369      }
370  
371      const stdout = output.stdout.trim()
372      if (stdout.includes('\n')) {
373        return ''
374      }
375  
376      return stdout
377    }
378  
379    async tryReset(): Promise<boolean> {
380      const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
381      return output.exitCode === 0
382    }
383  
384    static async createCommandManager(
385      workingDirectory: string,
386      lfs: boolean
387    ): Promise<GitCommandManager> {
388      const result = new GitCommandManager()
389      await result.initializeCommandManager(workingDirectory, lfs)
390      return result
391    }
392  
393    private async execGit(
394      args: string[],
395      allowAllExitCodes = false,
396      silent = false
397    ): Promise<GitOutput> {
398      fshelper.directoryExistsSync(this.workingDirectory, true)
399  
400      const result = new GitOutput()
401  
402      const env = {}
403      for (const key of Object.keys(process.env)) {
404        env[key] = process.env[key]
405      }
406      for (const key of Object.keys(this.gitEnv)) {
407        env[key] = this.gitEnv[key]
408      }
409  
410      const stdout: string[] = []
411  
412      const options = {
413        cwd: this.workingDirectory,
414        env,
415        silent,
416        ignoreReturnCode: allowAllExitCodes,
417        listeners: {
418          stdout: (data: Buffer) => {
419            stdout.push(data.toString())
420          }
421        }
422      }
423  
424      result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
425      result.stdout = stdout.join('')
426      return result
427    }
428  
429    private async initializeCommandManager(
430      workingDirectory: string,
431      lfs: boolean
432    ): Promise<void> {
433      this.workingDirectory = workingDirectory
434  
435      // Git-lfs will try to pull down assets if any of the local/user/system setting exist.
436      // If the user didn't enable `LFS` in their pipeline definition, disable LFS fetch/checkout.
437      this.lfs = lfs
438      if (!this.lfs) {
439        this.gitEnv['GIT_LFS_SKIP_SMUDGE'] = '1'
440      }
441  
442      this.gitPath = await io.which('git', true)
443  
444      // Git version
445      core.debug('Getting git version')
446      let gitVersion = new GitVersion()
447      let gitOutput = await this.execGit(['version'])
448      let stdout = gitOutput.stdout.trim()
449      if (!stdout.includes('\n')) {
450        const match = stdout.match(/\d+\.\d+(\.\d+)?/)
451        if (match) {
452          gitVersion = new GitVersion(match[0])
453        }
454      }
455      if (!gitVersion.isValid()) {
456        throw new Error('Unable to determine git version')
457      }
458  
459      // Minimum git version
460      if (!gitVersion.checkMinimum(MinimumGitVersion)) {
461        throw new Error(
462          `Minimum required git version is ${MinimumGitVersion}. Your git ('${this.gitPath}') is ${gitVersion}`
463        )
464      }
465  
466      if (this.lfs) {
467        // Git-lfs version
468        core.debug('Getting git-lfs version')
469        let gitLfsVersion = new GitVersion()
470        const gitLfsPath = await io.which('git-lfs', true)
471        gitOutput = await this.execGit(['lfs', 'version'])
472        stdout = gitOutput.stdout.trim()
473        if (!stdout.includes('\n')) {
474          const match = stdout.match(/\d+\.\d+(\.\d+)?/)
475          if (match) {
476            gitLfsVersion = new GitVersion(match[0])
477          }
478        }
479        if (!gitLfsVersion.isValid()) {
480          throw new Error('Unable to determine git-lfs version')
481        }
482  
483        // Minimum git-lfs version
484        // Note:
485        // - Auth header not supported before 2.1
486        const minimumGitLfsVersion = new GitVersion('2.1')
487        if (!gitLfsVersion.checkMinimum(minimumGitLfsVersion)) {
488          throw new Error(
489            `Minimum required git-lfs version is ${minimumGitLfsVersion}. Your git-lfs ('${gitLfsPath}') is ${gitLfsVersion}`
490          )
491        }
492      }
493  
494      // Set the user agent
495      const gitHttpUserAgent = `git/${gitVersion} (github-actions-checkout)`
496      core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
497      this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
498    }
499  }
500  
501  class GitOutput {
502    stdout = ''
503    exitCode = 0
504  }