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