git-command-manager.ts 18 KB

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