git-command-manager.ts 17 KB

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