git-command-manager.ts 12 KB

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