git-command-manager.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  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. // Note, this implementation uses "rev-parse --symbolic-full-name" because there is a bug
  87. // in Git 2.18 that causes "rev-parse --symbolic" to output symbolic full names.
  88. const args = ['rev-parse', '--symbolic-full-name']
  89. if (remote) {
  90. args.push('--remotes=origin')
  91. } else {
  92. args.push('--branches')
  93. }
  94. const output = await this.execGit(args)
  95. for (let branch of output.stdout.trim().split('\n')) {
  96. branch = branch.trim()
  97. if (branch) {
  98. if (branch.startsWith('refs/heads/')) {
  99. branch = branch.substr('refs/heads/'.length)
  100. } else if (branch.startsWith('refs/remotes/')) {
  101. branch = branch.substr('refs/remotes/'.length)
  102. }
  103. result.push(branch)
  104. }
  105. }
  106. return result
  107. }
  108. async checkout(ref: string, startPoint: string): Promise<void> {
  109. const args = ['checkout', '--progress', '--force']
  110. if (startPoint) {
  111. args.push('-B', ref, startPoint)
  112. } else {
  113. args.push(ref)
  114. }
  115. await this.execGit(args)
  116. }
  117. async checkoutDetach(): Promise<void> {
  118. const args = ['checkout', '--detach']
  119. await this.execGit(args)
  120. }
  121. async config(
  122. configKey: string,
  123. configValue: string,
  124. globalConfig?: boolean,
  125. add?: boolean
  126. ): Promise<void> {
  127. const args: string[] = ['config', globalConfig ? '--global' : '--local']
  128. if (add) {
  129. args.push('--add')
  130. }
  131. args.push(...[configKey, configValue])
  132. await this.execGit(args)
  133. }
  134. async configExists(
  135. configKey: string,
  136. globalConfig?: boolean
  137. ): Promise<boolean> {
  138. const pattern = regexpHelper.escape(configKey)
  139. const output = await this.execGit(
  140. [
  141. 'config',
  142. globalConfig ? '--global' : '--local',
  143. '--name-only',
  144. '--get-regexp',
  145. pattern
  146. ],
  147. true
  148. )
  149. return output.exitCode === 0
  150. }
  151. async fetch(refSpec: string[], fetchDepth?: number): Promise<void> {
  152. const args = ['-c', 'protocol.version=2', 'fetch']
  153. if (!refSpec.some(x => x === refHelper.tagsRefSpec)) {
  154. args.push('--no-tags')
  155. }
  156. args.push('--prune', '--progress', '--no-recurse-submodules')
  157. if (fetchDepth && fetchDepth > 0) {
  158. args.push(`--depth=${fetchDepth}`)
  159. } else if (
  160. fshelper.fileExistsSync(
  161. path.join(this.workingDirectory, '.git', 'shallow')
  162. )
  163. ) {
  164. args.push('--unshallow')
  165. }
  166. args.push('origin')
  167. for (const arg of refSpec) {
  168. args.push(arg)
  169. }
  170. const that = this
  171. await retryHelper.execute(async () => {
  172. await that.execGit(args)
  173. })
  174. }
  175. async getDefaultBranch(repositoryUrl: string): Promise<string> {
  176. let output: GitOutput | undefined
  177. await retryHelper.execute(async () => {
  178. output = await this.execGit([
  179. 'ls-remote',
  180. '--quiet',
  181. '--exit-code',
  182. '--symref',
  183. repositoryUrl,
  184. 'HEAD'
  185. ])
  186. })
  187. if (output) {
  188. // Satisfy compiler, will always be set
  189. for (let line of output.stdout.trim().split('\n')) {
  190. line = line.trim()
  191. if (line.startsWith('ref:') || line.endsWith('HEAD')) {
  192. return line
  193. .substr('ref:'.length, line.length - 'ref:'.length - 'HEAD'.length)
  194. .trim()
  195. }
  196. }
  197. }
  198. throw new Error('Unexpected output when retrieving default branch')
  199. }
  200. getWorkingDirectory(): string {
  201. return this.workingDirectory
  202. }
  203. async init(): Promise<void> {
  204. await this.execGit(['init', this.workingDirectory])
  205. }
  206. async isDetached(): Promise<boolean> {
  207. // Note, "branch --show-current" would be simpler but isn't available until Git 2.22
  208. const output = await this.execGit(
  209. ['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'],
  210. true
  211. )
  212. return !output.stdout.trim().startsWith('refs/heads/')
  213. }
  214. async lfsFetch(ref: string): Promise<void> {
  215. const args = ['lfs', 'fetch', 'origin', ref]
  216. const that = this
  217. await retryHelper.execute(async () => {
  218. await that.execGit(args)
  219. })
  220. }
  221. async lfsInstall(): Promise<void> {
  222. await this.execGit(['lfs', 'install', '--local'])
  223. }
  224. async log1(format?: string): Promise<string> {
  225. var args = format ? ['log', '-1', format] : ['log', '-1']
  226. var silent = format ? false : true
  227. const output = await this.execGit(args, false, silent)
  228. return output.stdout
  229. }
  230. async remoteAdd(remoteName: string, remoteUrl: string): Promise<void> {
  231. await this.execGit(['remote', 'add', remoteName, remoteUrl])
  232. }
  233. removeEnvironmentVariable(name: string): void {
  234. delete this.gitEnv[name]
  235. }
  236. /**
  237. * Resolves a ref to a SHA. For a branch or lightweight tag, the commit SHA is returned.
  238. * For an annotated tag, the tag SHA is returned.
  239. * @param {string} ref For example: 'refs/heads/main' or '/refs/tags/v1'
  240. * @returns {Promise<string>}
  241. */
  242. async revParse(ref: string): Promise<string> {
  243. const output = await this.execGit(['rev-parse', ref])
  244. return output.stdout.trim()
  245. }
  246. setEnvironmentVariable(name: string, value: string): void {
  247. this.gitEnv[name] = value
  248. }
  249. async shaExists(sha: string): Promise<boolean> {
  250. const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`]
  251. const output = await this.execGit(args, true)
  252. return output.exitCode === 0
  253. }
  254. async submoduleForeach(command: string, recursive: boolean): Promise<string> {
  255. const args = ['submodule', 'foreach']
  256. if (recursive) {
  257. args.push('--recursive')
  258. }
  259. args.push(command)
  260. const output = await this.execGit(args)
  261. return output.stdout
  262. }
  263. async submoduleSync(recursive: boolean): Promise<void> {
  264. const args = ['submodule', 'sync']
  265. if (recursive) {
  266. args.push('--recursive')
  267. }
  268. await this.execGit(args)
  269. }
  270. async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void> {
  271. const args = ['-c', 'protocol.version=2']
  272. args.push('submodule', 'update', '--init', '--force')
  273. if (fetchDepth > 0) {
  274. args.push(`--depth=${fetchDepth}`)
  275. }
  276. if (recursive) {
  277. args.push('--recursive')
  278. }
  279. await this.execGit(args)
  280. }
  281. async tagExists(pattern: string): Promise<boolean> {
  282. const output = await this.execGit(['tag', '--list', pattern])
  283. return !!output.stdout.trim()
  284. }
  285. async tryClean(): Promise<boolean> {
  286. const output = await this.execGit(['clean', '-ffdx'], true)
  287. return output.exitCode === 0
  288. }
  289. async tryConfigUnset(
  290. configKey: string,
  291. globalConfig?: boolean
  292. ): Promise<boolean> {
  293. const output = await this.execGit(
  294. [
  295. 'config',
  296. globalConfig ? '--global' : '--local',
  297. '--unset-all',
  298. configKey
  299. ],
  300. true
  301. )
  302. return output.exitCode === 0
  303. }
  304. async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
  305. const output = await this.execGit(
  306. ['config', '--local', 'gc.auto', '0'],
  307. true
  308. )
  309. return output.exitCode === 0
  310. }
  311. async tryGetFetchUrl(): Promise<string> {
  312. const output = await this.execGit(
  313. ['config', '--local', '--get', 'remote.origin.url'],
  314. true
  315. )
  316. if (output.exitCode !== 0) {
  317. return ''
  318. }
  319. const stdout = output.stdout.trim()
  320. if (stdout.includes('\n')) {
  321. return ''
  322. }
  323. return stdout
  324. }
  325. async tryReset(): Promise<boolean> {
  326. const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
  327. return output.exitCode === 0
  328. }
  329. static async createCommandManager(
  330. workingDirectory: string,
  331. lfs: boolean
  332. ): Promise<GitCommandManager> {
  333. const result = new GitCommandManager()
  334. await result.initializeCommandManager(workingDirectory, lfs)
  335. return result
  336. }
  337. private async execGit(
  338. args: string[],
  339. allowAllExitCodes = false,
  340. silent = false
  341. ): Promise<GitOutput> {
  342. fshelper.directoryExistsSync(this.workingDirectory, true)
  343. const result = new GitOutput()
  344. const env = {}
  345. for (const key of Object.keys(process.env)) {
  346. env[key] = process.env[key]
  347. }
  348. for (const key of Object.keys(this.gitEnv)) {
  349. env[key] = this.gitEnv[key]
  350. }
  351. const stdout: string[] = []
  352. const options = {
  353. cwd: this.workingDirectory,
  354. env,
  355. silent,
  356. ignoreReturnCode: allowAllExitCodes,
  357. listeners: {
  358. stdout: (data: Buffer) => {
  359. stdout.push(data.toString())
  360. }
  361. }
  362. }
  363. result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
  364. result.stdout = stdout.join('')
  365. return result
  366. }
  367. private async initializeCommandManager(
  368. workingDirectory: string,
  369. lfs: boolean
  370. ): Promise<void> {
  371. this.workingDirectory = workingDirectory
  372. // Git-lfs will try to pull down assets if any of the local/user/system setting exist.
  373. // If the user didn't enable `LFS` in their pipeline definition, disable LFS fetch/checkout.
  374. this.lfs = lfs
  375. if (!this.lfs) {
  376. this.gitEnv['GIT_LFS_SKIP_SMUDGE'] = '1'
  377. }
  378. this.gitPath = await io.which('git', true)
  379. // Git version
  380. core.debug('Getting git version')
  381. let gitVersion = new GitVersion()
  382. let gitOutput = await this.execGit(['version'])
  383. let stdout = gitOutput.stdout.trim()
  384. if (!stdout.includes('\n')) {
  385. const match = stdout.match(/\d+\.\d+(\.\d+)?/)
  386. if (match) {
  387. gitVersion = new GitVersion(match[0])
  388. }
  389. }
  390. if (!gitVersion.isValid()) {
  391. throw new Error('Unable to determine git version')
  392. }
  393. // Minimum git version
  394. if (!gitVersion.checkMinimum(MinimumGitVersion)) {
  395. throw new Error(
  396. `Minimum required git version is ${MinimumGitVersion}. Your git ('${this.gitPath}') is ${gitVersion}`
  397. )
  398. }
  399. if (this.lfs) {
  400. // Git-lfs version
  401. core.debug('Getting git-lfs version')
  402. let gitLfsVersion = new GitVersion()
  403. const gitLfsPath = await io.which('git-lfs', true)
  404. gitOutput = await this.execGit(['lfs', 'version'])
  405. stdout = gitOutput.stdout.trim()
  406. if (!stdout.includes('\n')) {
  407. const match = stdout.match(/\d+\.\d+(\.\d+)?/)
  408. if (match) {
  409. gitLfsVersion = new GitVersion(match[0])
  410. }
  411. }
  412. if (!gitLfsVersion.isValid()) {
  413. throw new Error('Unable to determine git-lfs version')
  414. }
  415. // Minimum git-lfs version
  416. // Note:
  417. // - Auth header not supported before 2.1
  418. const minimumGitLfsVersion = new GitVersion('2.1')
  419. if (!gitLfsVersion.checkMinimum(minimumGitLfsVersion)) {
  420. throw new Error(
  421. `Minimum required git-lfs version is ${minimumGitLfsVersion}. Your git-lfs ('${gitLfsPath}') is ${gitLfsVersion}`
  422. )
  423. }
  424. }
  425. // Set the user agent
  426. const gitHttpUserAgent = `git/${gitVersion} (github-actions-checkout)`
  427. core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
  428. this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
  429. }
  430. }
  431. class GitOutput {
  432. stdout = ''
  433. exitCode = 0
  434. }