git-command-manager.ts 17 KB

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