git-command-manager.ts 14 KB

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