git-command-manager.ts 15 KB

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