git-command-manager.ts 17 KB

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