checkout/src/git-command-manager.ts
Anatoly Rabkin 3ff67abc5a Fix timeout implementation and address review feedback
- Kill git process on timeout: use child_process.spawn directly for
  timeout-eligible operations so we have a ChildProcess handle to send
  SIGTERM (then SIGKILL after 5s). On Windows, SIGTERM is a forced kill
  so the SIGKILL fallback is effectively a no-op there.

- Fix timeout:0 not working: replace falsy || coalescion with explicit
  empty-string check so that '0' is not replaced by the default '300'.

- Refactor execGit to use an options object instead of 5 positional
  parameters, eliminating error-prone filler args (false, false, {}).

- Pass allowAllExitCodes through to execGitWithTimeout so both code
  paths have consistent behavior for non-zero exit codes.

- Add settled guard to prevent double-reject when both close and error
  events fire on the spawned process.

- Handle null exit code (process killed by signal) as an error rather
  than silently treating it as success.

- Capture stderr in error messages for the timeout path, matching the
  information level of the non-timeout exec path.

- Log SIGKILL failures at debug level instead of empty catch block.

- Warn on customListeners being ignored in the timeout path.

- Emit core.warning() when invalid input values are silently replaced
  with defaults, so users know their configuration was rejected.

- Add input validation in setTimeout (reject negative values).

- Clarify retry-max-attempts semantics: total attempts including the
  initial attempt (3 = 1 initial + 2 retries).

- Remove Kubernetes probe references from descriptions.

- Use non-exhaustive list (e.g.) for network operations in docs to
  avoid staleness if new operations are added.

- Add tests for timeout/retry input parsing (defaults, timeout:0,
  custom values, invalid input with warnings, backoff clamping) and
  command manager configuration (setTimeout, setRetryConfig, fetch).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 23:52:14 +02:00

962 lines
27 KiB
TypeScript

import * as core from '@actions/core'
import * as exec from '@actions/exec'
import * as fs from 'fs'
import * as fshelper from './fs-helper'
import * as io from '@actions/io'
import * as path from 'path'
import * as refHelper from './ref-helper'
import * as regexpHelper from './regexp-helper'
import * as retryHelper from './retry-helper'
import {spawn} from 'child_process'
import {GitVersion} from './git-version'
// Auth header not supported before 2.9
// Wire protocol v2 not supported before 2.18
// sparse-checkout not [well-]supported before 2.28 (see https://github.com/actions/checkout/issues/1386)
export const MinimumGitVersion = new GitVersion('2.18')
export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28')
export interface IGitCommandManager {
branchDelete(remote: boolean, branch: string): Promise<void>
branchExists(remote: boolean, pattern: string): Promise<boolean>
branchList(remote: boolean): Promise<string[]>
disableSparseCheckout(): Promise<void>
sparseCheckout(sparseCheckout: string[]): Promise<void>
sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void>
checkout(ref: string, startPoint: string): Promise<void>
checkoutDetach(): Promise<void>
config(
configKey: string,
configValue: string,
globalConfig?: boolean,
add?: boolean,
configFile?: string
): Promise<void>
configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
fetch(
refSpec: string[],
options: {
filter?: string
fetchDepth?: number
showProgress?: boolean
}
): Promise<void>
getDefaultBranch(repositoryUrl: string): Promise<string>
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string
init(): Promise<void>
isDetached(): Promise<boolean>
lfsFetch(ref: string): Promise<void>
lfsInstall(): Promise<void>
log1(format?: string): Promise<string>
remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
removeEnvironmentVariable(name: string): void
revParse(ref: string): Promise<string>
setEnvironmentVariable(name: string, value: string): void
shaExists(sha: string): Promise<boolean>
submoduleForeach(command: string, recursive: boolean): Promise<string>
submoduleSync(recursive: boolean): Promise<void>
submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void>
submoduleStatus(): Promise<boolean>
tagExists(pattern: string): Promise<boolean>
tryClean(): Promise<boolean>
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean,
configFile?: string
): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]>
tryGetConfigKeys(
pattern: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]>
tryReset(): Promise<boolean>
version(): Promise<GitVersion>
setTimeout(timeoutSeconds: number): void
setRetryConfig(
maxAttempts: number,
minBackoffSeconds: number,
maxBackoffSeconds: number
): void
}
export async function createCommandManager(
workingDirectory: string,
lfs: boolean,
doSparseCheckout: boolean
): Promise<IGitCommandManager> {
return await GitCommandManager.createCommandManager(
workingDirectory,
lfs,
doSparseCheckout
)
}
class GitCommandManager {
private gitEnv = {
GIT_TERMINAL_PROMPT: '0', // Disable git prompt
GCM_INTERACTIVE: 'Never' // Disable prompting for git credential manager
}
private gitPath = ''
private lfs = false
private doSparseCheckout = false
private workingDirectory = ''
private gitVersion: GitVersion = new GitVersion()
private timeoutMs = 0
private networkRetryHelper = new retryHelper.RetryHelper()
// Private constructor; use createCommandManager()
private constructor() {}
async branchDelete(remote: boolean, branch: string): Promise<void> {
const args = ['branch', '--delete', '--force']
if (remote) {
args.push('--remote')
}
args.push(branch)
await this.execGit(args)
}
async branchExists(remote: boolean, pattern: string): Promise<boolean> {
const args = ['branch', '--list']
if (remote) {
args.push('--remote')
}
args.push(pattern)
const output = await this.execGit(args)
return !!output.stdout.trim()
}
async branchList(remote: boolean): Promise<string[]> {
const result: string[] = []
// Note, this implementation uses "rev-parse --symbolic-full-name" because the output from
// "branch --list" is more difficult when in a detached HEAD state.
// TODO(https://github.com/actions/checkout/issues/786): this implementation uses
// "rev-parse --symbolic-full-name" because there is a bug
// in Git 2.18 that causes "rev-parse --symbolic" to output symbolic full names. When
// 2.18 is no longer supported, we can switch back to --symbolic.
const args = ['rev-parse', '--symbolic-full-name']
if (remote) {
args.push('--remotes=origin')
} else {
args.push('--branches')
}
const stderr: string[] = []
const errline: string[] = []
const stdout: string[] = []
const stdline: string[] = []
const listeners = {
stderr: (data: Buffer) => {
stderr.push(data.toString())
},
errline: (data: Buffer) => {
errline.push(data.toString())
},
stdout: (data: Buffer) => {
stdout.push(data.toString())
},
stdline: (data: Buffer) => {
stdline.push(data.toString())
}
}
// Suppress the output in order to avoid flooding annotations with innocuous errors.
await this.execGit(args, {silent: true, customListeners: listeners})
core.debug(`stderr callback is: ${stderr}`)
core.debug(`errline callback is: ${errline}`)
core.debug(`stdout callback is: ${stdout}`)
core.debug(`stdline callback is: ${stdline}`)
for (let branch of stdline) {
branch = branch.trim()
if (!branch) {
continue
}
if (branch.startsWith('refs/heads/')) {
branch = branch.substring('refs/heads/'.length)
} else if (branch.startsWith('refs/remotes/')) {
branch = branch.substring('refs/remotes/'.length)
}
result.push(branch)
}
return result
}
async disableSparseCheckout(): Promise<void> {
await this.execGit(['sparse-checkout', 'disable'])
// Disabling 'sparse-checkout` leaves behind an undesirable side-effect in config (even in a pristine environment).
await this.tryConfigUnset('extensions.worktreeConfig', false)
}
async sparseCheckout(sparseCheckout: string[]): Promise<void> {
await this.execGit(['sparse-checkout', 'set', ...sparseCheckout])
}
async sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void> {
await this.execGit(['config', 'core.sparseCheckout', 'true'])
const output = await this.execGit([
'rev-parse',
'--git-path',
'info/sparse-checkout'
])
const sparseCheckoutPath = path.join(
this.workingDirectory,
output.stdout.trimRight()
)
await fs.promises.appendFile(
sparseCheckoutPath,
`\n${sparseCheckout.join('\n')}\n`
)
}
async checkout(ref: string, startPoint: string): Promise<void> {
const args = ['checkout', '--progress', '--force']
if (startPoint) {
args.push('-B', ref, startPoint)
} else {
args.push(ref)
}
await this.execGit(args)
}
async checkoutDetach(): Promise<void> {
const args = ['checkout', '--detach']
await this.execGit(args)
}
async config(
configKey: string,
configValue: string,
globalConfig?: boolean,
add?: boolean,
configFile?: string
): Promise<void> {
const args: string[] = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
if (add) {
args.push('--add')
}
args.push(...[configKey, configValue])
await this.execGit(args)
}
async configExists(
configKey: string,
globalConfig?: boolean
): Promise<boolean> {
const pattern = regexpHelper.escape(configKey)
const output = await this.execGit(
[
'config',
globalConfig ? '--global' : '--local',
'--name-only',
'--get-regexp',
pattern
],
{allowAllExitCodes: true}
)
return output.exitCode === 0
}
async fetch(
refSpec: string[],
options: {
filter?: string
fetchDepth?: number
showProgress?: boolean
}
): Promise<void> {
const args = ['-c', 'protocol.version=2', 'fetch']
// Always use --no-tags for explicit control over tag fetching
// Tags are fetched explicitly via refspec when needed
args.push('--no-tags')
args.push('--prune', '--no-recurse-submodules')
if (options.showProgress) {
args.push('--progress')
}
if (options.filter) {
args.push(`--filter=${options.filter}`)
}
if (options.fetchDepth && options.fetchDepth > 0) {
args.push(`--depth=${options.fetchDepth}`)
} else if (
fshelper.fileExistsSync(
path.join(this.workingDirectory, '.git', 'shallow')
)
) {
args.push('--unshallow')
}
args.push('origin')
for (const arg of refSpec) {
args.push(arg)
}
const that = this
await this.networkRetryHelper.execute(async () => {
await that.execGit(args, {timeoutMs: that.timeoutMs})
})
}
async getDefaultBranch(repositoryUrl: string): Promise<string> {
let output: GitOutput | undefined
await this.networkRetryHelper.execute(async () => {
output = await this.execGit(
[
'ls-remote',
'--quiet',
'--exit-code',
'--symref',
repositoryUrl,
'HEAD'
],
{timeoutMs: this.timeoutMs}
)
})
if (output) {
// Satisfy compiler, will always be set
for (let line of output.stdout.trim().split('\n')) {
line = line.trim()
if (line.startsWith('ref:') || line.endsWith('HEAD')) {
return line
.substr('ref:'.length, line.length - 'ref:'.length - 'HEAD'.length)
.trim()
}
}
}
throw new Error('Unexpected output when retrieving default branch')
}
async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
// Get submodule config file paths.
// Use `--show-origin` to get the config file path for each submodule.
const output = await this.submoduleForeach(
`git config --local --show-origin --name-only --get-regexp remote.origin.url`,
recursive
)
// Extract config file paths from the output (lines starting with "file:").
const configPaths =
output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
return configPaths
}
getWorkingDirectory(): string {
return this.workingDirectory
}
async init(): Promise<void> {
await this.execGit(['init', this.workingDirectory])
}
async isDetached(): Promise<boolean> {
// Note, "branch --show-current" would be simpler but isn't available until Git 2.22
const output = await this.execGit(
['rev-parse', '--symbolic-full-name', '--verify', '--quiet', 'HEAD'],
{allowAllExitCodes: true}
)
return !output.stdout.trim().startsWith('refs/heads/')
}
async lfsFetch(ref: string): Promise<void> {
const args = ['lfs', 'fetch', 'origin', ref]
const that = this
await this.networkRetryHelper.execute(async () => {
await that.execGit(args, {timeoutMs: that.timeoutMs})
})
}
async lfsInstall(): Promise<void> {
await this.execGit(['lfs', 'install', '--local'])
}
async log1(format?: string): Promise<string> {
const args = format ? ['log', '-1', format] : ['log', '-1']
const silent = !format
const output = await this.execGit(args, {silent})
return output.stdout
}
async remoteAdd(remoteName: string, remoteUrl: string): Promise<void> {
await this.execGit(['remote', 'add', remoteName, remoteUrl])
}
removeEnvironmentVariable(name: string): void {
delete this.gitEnv[name]
}
/**
* Resolves a ref to a SHA. For a branch or lightweight tag, the commit SHA is returned.
* For an annotated tag, the tag SHA is returned.
* @param {string} ref For example: 'refs/heads/main' or '/refs/tags/v1'
* @returns {Promise<string>}
*/
async revParse(ref: string): Promise<string> {
const output = await this.execGit(['rev-parse', ref])
return output.stdout.trim()
}
setEnvironmentVariable(name: string, value: string): void {
this.gitEnv[name] = value
}
async shaExists(sha: string): Promise<boolean> {
const args = ['rev-parse', '--verify', '--quiet', `${sha}^{object}`]
const output = await this.execGit(args, {allowAllExitCodes: true})
return output.exitCode === 0
}
async submoduleForeach(command: string, recursive: boolean): Promise<string> {
const args = ['submodule', 'foreach']
if (recursive) {
args.push('--recursive')
}
args.push(command)
const output = await this.execGit(args)
return output.stdout
}
async submoduleSync(recursive: boolean): Promise<void> {
const args = ['submodule', 'sync']
if (recursive) {
args.push('--recursive')
}
const that = this
await this.networkRetryHelper.execute(async () => {
await that.execGit(args, {timeoutMs: that.timeoutMs})
})
}
async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise<void> {
const args = ['-c', 'protocol.version=2']
args.push('submodule', 'update', '--init', '--force')
if (fetchDepth > 0) {
args.push(`--depth=${fetchDepth}`)
}
if (recursive) {
args.push('--recursive')
}
const that = this
await this.networkRetryHelper.execute(async () => {
await that.execGit(args, {timeoutMs: that.timeoutMs})
})
}
async submoduleStatus(): Promise<boolean> {
const output = await this.execGit(['submodule', 'status'], {allowAllExitCodes: true})
core.debug(output.stdout)
return output.exitCode === 0
}
async tagExists(pattern: string): Promise<boolean> {
const output = await this.execGit(['tag', '--list', pattern])
return !!output.stdout.trim()
}
async tryClean(): Promise<boolean> {
const output = await this.execGit(['clean', '-ffdx'], {allowAllExitCodes: true})
return output.exitCode === 0
}
async tryConfigUnset(
configKey: string,
globalConfig?: boolean
): Promise<boolean> {
const output = await this.execGit(
[
'config',
globalConfig ? '--global' : '--local',
'--unset-all',
configKey
],
{allowAllExitCodes: true}
)
return output.exitCode === 0
}
async tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean,
configFile?: string
): Promise<boolean> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--unset', configKey, configValue)
const output = await this.execGit(args, {allowAllExitCodes: true})
return output.exitCode === 0
}
async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
const output = await this.execGit(
['config', '--local', 'gc.auto', '0'],
{allowAllExitCodes: true}
)
return output.exitCode === 0
}
async tryGetFetchUrl(): Promise<string> {
const output = await this.execGit(
['config', '--local', '--get', 'remote.origin.url'],
{allowAllExitCodes: true}
)
if (output.exitCode !== 0) {
return ''
}
const stdout = output.stdout.trim()
if (stdout.includes('\n')) {
return ''
}
return stdout
}
async tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--get-all', configKey)
const output = await this.execGit(args, {allowAllExitCodes: true})
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(value => value.trim())
}
async tryGetConfigKeys(
pattern: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--name-only', '--get-regexp', pattern)
const output = await this.execGit(args, {allowAllExitCodes: true})
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(key => key.trim())
}
async tryReset(): Promise<boolean> {
const output = await this.execGit(['reset', '--hard', 'HEAD'], {allowAllExitCodes: true})
return output.exitCode === 0
}
async version(): Promise<GitVersion> {
return this.gitVersion
}
/**
* Sets the timeout for network git operations.
* @param timeoutSeconds Timeout in seconds. 0 disables the timeout.
*/
setTimeout(timeoutSeconds: number): void {
if (timeoutSeconds < 0) {
throw new Error(`Timeout must be non-negative, got ${timeoutSeconds}`)
}
this.timeoutMs = timeoutSeconds * 1000
}
/**
* Configures retry behavior for network git operations.
* @param maxAttempts Total attempts including the initial one. Must be >= 1.
* @param minBackoffSeconds Minimum backoff between retries. Must be <= maxBackoffSeconds.
* @param maxBackoffSeconds Maximum backoff between retries.
*/
setRetryConfig(
maxAttempts: number,
minBackoffSeconds: number,
maxBackoffSeconds: number
): void {
this.networkRetryHelper = new retryHelper.RetryHelper(
maxAttempts,
minBackoffSeconds,
maxBackoffSeconds
)
}
static async createCommandManager(
workingDirectory: string,
lfs: boolean,
doSparseCheckout: boolean
): Promise<GitCommandManager> {
const result = new GitCommandManager()
await result.initializeCommandManager(
workingDirectory,
lfs,
doSparseCheckout
)
return result
}
private async execGit(
args: string[],
options: {
allowAllExitCodes?: boolean
silent?: boolean
customListeners?: {}
timeoutMs?: number
} = {}
): Promise<GitOutput> {
const {
allowAllExitCodes = false,
silent = false,
customListeners = {},
timeoutMs = 0
} = options
fshelper.directoryExistsSync(this.workingDirectory, true)
// Use child_process.spawn directly when timeout is set,
// so we can kill the process on timeout and avoid orphaned git processes.
// Note: customListeners are not supported in the timeout path.
if (timeoutMs > 0) {
if (
customListeners &&
Object.keys(customListeners).length > 0
) {
core.debug(
'customListeners are not supported with timeoutMs and will be ignored'
)
}
return await this.execGitWithTimeout(
args,
timeoutMs,
silent,
allowAllExitCodes
)
}
const result = new GitOutput()
const env = {}
for (const key of Object.keys(process.env)) {
env[key] = process.env[key]
}
for (const key of Object.keys(this.gitEnv)) {
env[key] = this.gitEnv[key]
}
const defaultListener = {
stdout: (data: Buffer) => {
stdout.push(data.toString())
}
}
const mergedListeners = {...defaultListener, ...customListeners}
const stdout: string[] = []
const execOptions = {
cwd: this.workingDirectory,
env,
silent,
ignoreReturnCode: allowAllExitCodes,
listeners: mergedListeners
}
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, execOptions)
result.stdout = stdout.join('')
core.debug(result.exitCode.toString())
core.debug(result.stdout)
return result
}
/**
* Executes a git command with a timeout. Uses child_process.spawn directly
* (instead of @actions/exec) so we can kill the process on timeout and
* terminate it cleanly. Does not support customListeners.
*/
private async execGitWithTimeout(
args: string[],
timeoutMs: number,
silent: boolean,
allowAllExitCodes: boolean
): Promise<GitOutput> {
const result = new GitOutput()
const env: {[key: string]: string} = {}
for (const key of Object.keys(process.env)) {
env[key] = process.env[key] as string
}
for (const key of Object.keys(this.gitEnv)) {
env[key] = this.gitEnv[key]
}
const stdout: string[] = []
const stderr: string[] = []
return new Promise<GitOutput>((resolve, reject) => {
const child = spawn(this.gitPath, args, {
cwd: this.workingDirectory,
env,
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout?.on('data', (data: Buffer) => {
stdout.push(data.toString())
})
if (child.stderr) {
child.stderr.on('data', (data: Buffer) => {
stderr.push(data.toString())
if (!silent) {
process.stderr.write(data)
}
})
}
let settled = false
let timedOut = false
let forceKillTimer: ReturnType<typeof setTimeout> | undefined
const cleanup = (): void => {
clearTimeout(timer)
if (forceKillTimer) {
clearTimeout(forceKillTimer)
}
}
const timer = global.setTimeout(() => {
timedOut = true
// SIGTERM first, then force SIGKILL after 5 seconds.
// On Windows, SIGTERM is equivalent to a forced kill, so
// the SIGKILL fallback is effectively a no-op there.
child.kill('SIGTERM')
forceKillTimer = global.setTimeout(() => {
try {
child.kill('SIGKILL')
} catch (killErr) {
core.debug(
`Failed to SIGKILL git process: ${killErr}`
)
}
}, 5000)
if (forceKillTimer.unref) {
forceKillTimer.unref()
}
}, timeoutMs)
if (timer.unref) {
timer.unref()
}
child.on('close', (code: number | null) => {
if (settled) return
settled = true
cleanup()
if (timedOut) {
reject(
new Error(
`Git operation timed out after ${timeoutMs / 1000} seconds: git ${args.slice(0, 5).join(' ')}...`
)
)
return
}
// null code means killed by signal (e.g. OOM killer, external SIGTERM)
if (code === null) {
const stderrText = stderr.join('').trim()
reject(
new Error(
`The process 'git' was killed by a signal` +
(stderrText ? `\n${stderrText}` : '')
)
)
return
}
if (code !== 0 && !allowAllExitCodes) {
const stderrText = stderr.join('').trim()
reject(
new Error(
`The process 'git' failed with exit code ${code}` +
(stderrText ? `\n${stderrText}` : '')
)
)
return
}
result.exitCode = code
result.stdout = stdout.join('')
core.debug(result.exitCode.toString())
core.debug(result.stdout)
resolve(result)
})
child.on('error', (err: Error) => {
if (settled) return
settled = true
cleanup()
reject(err)
})
})
}
private async initializeCommandManager(
workingDirectory: string,
lfs: boolean,
doSparseCheckout: boolean
): Promise<void> {
this.workingDirectory = workingDirectory
// Git-lfs will try to pull down assets if any of the local/user/system setting exist.
// If the user didn't enable `LFS` in their pipeline definition, disable LFS fetch/checkout.
this.lfs = lfs
if (!this.lfs) {
this.gitEnv['GIT_LFS_SKIP_SMUDGE'] = '1'
}
this.gitPath = await io.which('git', true)
// Git version
core.debug('Getting git version')
this.gitVersion = new GitVersion()
let gitOutput = await this.execGit(['version'])
let stdout = gitOutput.stdout.trim()
if (!stdout.includes('\n')) {
const match = stdout.match(/\d+\.\d+(\.\d+)?/)
if (match) {
this.gitVersion = new GitVersion(match[0])
}
}
if (!this.gitVersion.isValid()) {
throw new Error('Unable to determine git version')
}
// Minimum git version
if (!this.gitVersion.checkMinimum(MinimumGitVersion)) {
throw new Error(
`Minimum required git version is ${MinimumGitVersion}. Your git ('${this.gitPath}') is ${this.gitVersion}`
)
}
if (this.lfs) {
// Git-lfs version
core.debug('Getting git-lfs version')
let gitLfsVersion = new GitVersion()
const gitLfsPath = await io.which('git-lfs', true)
gitOutput = await this.execGit(['lfs', 'version'])
stdout = gitOutput.stdout.trim()
if (!stdout.includes('\n')) {
const match = stdout.match(/\d+\.\d+(\.\d+)?/)
if (match) {
gitLfsVersion = new GitVersion(match[0])
}
}
if (!gitLfsVersion.isValid()) {
throw new Error('Unable to determine git-lfs version')
}
// Minimum git-lfs version
// Note:
// - Auth header not supported before 2.1
const minimumGitLfsVersion = new GitVersion('2.1')
if (!gitLfsVersion.checkMinimum(minimumGitLfsVersion)) {
throw new Error(
`Minimum required git-lfs version is ${minimumGitLfsVersion}. Your git-lfs ('${gitLfsPath}') is ${gitLfsVersion}`
)
}
}
this.doSparseCheckout = doSparseCheckout
if (this.doSparseCheckout) {
if (!this.gitVersion.checkMinimum(MinimumGitSparseCheckoutVersion)) {
throw new Error(
`Minimum Git version required for sparse checkout is ${MinimumGitSparseCheckoutVersion}. Your git ('${this.gitPath}') is ${this.gitVersion}`
)
}
}
// Set the user agent
let gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
// Append orchestration ID if set
const orchId = process.env['ACTIONS_ORCHESTRATION_ID']
if (orchId) {
// Sanitize the orchestration ID to ensure it contains only valid characters
// Valid characters: 0-9, a-z, _, -, .
const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_')
if (sanitizedId) {
gitHttpUserAgent = `${gitHttpUserAgent} actions_orchestration_id/${sanitizedId}`
}
}
core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
}
}
class GitOutput {
stdout = ''
exitCode = 0
}