git-command-manager.ts 17 KB

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