mirror of
https://code.forgejo.org/actions/checkout.git
synced 2026-03-11 07:03:17 +00:00
When reference-cache is enabled, shallow fetches (fetch-depth > 0) are counterproductive because objects are served from the local cache. Shallow negotiation only adds network latency without saving bandwidth. If fetch-depth was not explicitly set by the user, it is automatically overridden to 0. If explicitly set, a warning is emitted explaining the performance impact. Signed-off-by: Michael Wyraz <mw@brick4u.de>
601 lines
20 KiB
TypeScript
601 lines
20 KiB
TypeScript
import * as core from '@actions/core'
|
|
import * as fsHelper from './fs-helper'
|
|
import * as gitAuthHelper from './git-auth-helper'
|
|
import * as gitCommandManager from './git-command-manager'
|
|
import * as gitDirectoryHelper from './git-directory-helper'
|
|
import * as githubApiHelper from './github-api-helper'
|
|
import * as io from '@actions/io'
|
|
import * as path from 'path'
|
|
import * as refHelper from './ref-helper'
|
|
import * as stateHelper from './state-helper'
|
|
import * as urlHelper from './url-helper'
|
|
import {
|
|
MinimumGitSparseCheckoutVersion,
|
|
IGitCommandManager
|
|
} from './git-command-manager'
|
|
import {IGitSourceSettings} from './git-source-settings'
|
|
import {GitCacheHelper} from './git-cache-helper'
|
|
import * as fs from 'fs'
|
|
|
|
interface SubmoduleInfo {
|
|
name: string
|
|
path: string
|
|
url: string
|
|
}
|
|
|
|
async function iterativeSubmoduleUpdate(
|
|
git: IGitCommandManager,
|
|
cacheHelper: GitCacheHelper,
|
|
repositoryPath: string,
|
|
fetchDepth: number,
|
|
nestedSubmodules: boolean
|
|
): Promise<void> {
|
|
const gitmodulesPath = path.join(repositoryPath, '.gitmodules')
|
|
if (!fs.existsSync(gitmodulesPath)) {
|
|
return
|
|
}
|
|
|
|
const submodules = new Map<string, SubmoduleInfo>()
|
|
|
|
// Get all submodule config keys
|
|
try {
|
|
const output = await git.execGit([
|
|
'-C', repositoryPath,
|
|
'config', '--file', gitmodulesPath, '--get-regexp', 'submodule\\..*'
|
|
], true, true)
|
|
|
|
const lines = output.stdout.split('\n').filter(l => l.trim().length > 0)
|
|
for (const line of lines) {
|
|
const match = line.match(/^submodule\.(.+?)\.(path|url)\s+(.*)$/)
|
|
if (match) {
|
|
const [, name, key, value] = match
|
|
if (!submodules.has(name)) {
|
|
submodules.set(name, { name, path: '', url: '' })
|
|
}
|
|
const info = submodules.get(name)!
|
|
if (key === 'path') info.path = value
|
|
if (key === 'url') info.url = value
|
|
}
|
|
}
|
|
} catch (err) {
|
|
core.warning(`Failed to read .gitmodules: ${err}`)
|
|
return
|
|
}
|
|
|
|
for (const info of submodules.values()) {
|
|
if (!info.path || !info.url) continue
|
|
|
|
core.info(`Processing submodule ${info.name} at ${info.path}`)
|
|
|
|
// Resolve relative URLs or valid URLs
|
|
let subUrl = info.url
|
|
if (subUrl.startsWith('../') || subUrl.startsWith('./')) {
|
|
// In checkout action, relative URLs are handled automatically by git.
|
|
// But for our bare cache clone, we need an absolute URL.
|
|
let originUrl = ''
|
|
try {
|
|
const originOut = await git.execGit(['-C', repositoryPath, 'remote', 'get-url', 'origin'], true, true)
|
|
if (originOut.exitCode === 0) {
|
|
originUrl = originOut.stdout.trim()
|
|
}
|
|
|
|
if (originUrl) {
|
|
try {
|
|
if (originUrl.match(/^https?:\/\//)) {
|
|
// Using Node's URL class to resolve relative paths for HTTP(s)
|
|
const parsedOrigin = new URL(originUrl.replace(/\.git$/, ''))
|
|
const resolvedUrl = new URL(subUrl, parsedOrigin.href + '/')
|
|
subUrl = resolvedUrl.href
|
|
} else {
|
|
// Fallback for SSH URLs which new URL() cannot parse (e.g. git@github.com:org/repo)
|
|
let originParts = originUrl.replace(/\.git$/, '').split('/')
|
|
originParts.pop() // remove current repo
|
|
|
|
// Handle multiple ../
|
|
let subTarget = subUrl
|
|
while (subTarget.startsWith('../')) {
|
|
if (originParts.length === 0) break // Can't go higher
|
|
originParts.pop()
|
|
subTarget = subTarget.substring(3)
|
|
}
|
|
if (subTarget.startsWith('./')) {
|
|
subTarget = subTarget.substring(2)
|
|
}
|
|
|
|
if (originParts.length > 0) {
|
|
subUrl = originParts.join('/') + '/' + subTarget
|
|
}
|
|
}
|
|
} catch {
|
|
// Fallback does not work
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!subUrl || subUrl.startsWith('../') || subUrl.startsWith('./')) {
|
|
core.warning(`Could not resolve absolute URL for submodule ${info.name}. Falling back to standard clone.`)
|
|
await invokeStandardSubmoduleUpdate(git, repositoryPath, fetchDepth, info.path)
|
|
continue
|
|
}
|
|
|
|
try {
|
|
// Prepare cache
|
|
const cachePath = await cacheHelper.setupCache(git, subUrl)
|
|
|
|
// Submodule update for this specific one
|
|
const args = ['-C', repositoryPath, '-c', 'protocol.version=2', 'submodule', 'update', '--init', '--force']
|
|
if (fetchDepth > 0) {
|
|
args.push(`--depth=${fetchDepth}`)
|
|
}
|
|
args.push('--reference', cachePath)
|
|
args.push(info.path)
|
|
|
|
const output = await git.execGit(args, true)
|
|
if (output.exitCode !== 0) {
|
|
throw new Error(`Submodule update failed with exit code ${output.exitCode}`)
|
|
}
|
|
} catch (err) {
|
|
core.warning(`Reference cache failed for submodule ${info.name} (${err}). Falling back to standard clone...`)
|
|
await invokeStandardSubmoduleUpdate(git, repositoryPath, fetchDepth, info.path)
|
|
}
|
|
|
|
// Recursive update inside the submodule
|
|
if (nestedSubmodules) {
|
|
const subRepoPath = path.join(repositoryPath, info.path)
|
|
await iterativeSubmoduleUpdate(
|
|
git,
|
|
cacheHelper,
|
|
subRepoPath,
|
|
fetchDepth,
|
|
nestedSubmodules
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function invokeStandardSubmoduleUpdate(git: IGitCommandManager, repositoryPath: string, fetchDepth: number, submodulePath: string) {
|
|
const args = ['-C', repositoryPath, '-c', 'protocol.version=2', 'submodule', 'update', '--init', '--force']
|
|
if (fetchDepth > 0) {
|
|
args.push(`--depth=${fetchDepth}`)
|
|
}
|
|
args.push(submodulePath)
|
|
await git.execGit(args)
|
|
}
|
|
|
|
export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|
// Repository URL
|
|
core.info(
|
|
`Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`
|
|
)
|
|
const repositoryUrl = urlHelper.getFetchUrl(settings)
|
|
|
|
// Remove conflicting file path
|
|
if (fsHelper.fileExistsSync(settings.repositoryPath)) {
|
|
await io.rmRF(settings.repositoryPath)
|
|
}
|
|
|
|
// Create directory
|
|
let isExisting = true
|
|
if (!fsHelper.directoryExistsSync(settings.repositoryPath)) {
|
|
isExisting = false
|
|
await io.mkdirP(settings.repositoryPath)
|
|
}
|
|
|
|
// Git command manager
|
|
core.startGroup('Getting Git version info')
|
|
const git = await getGitCommandManager(settings)
|
|
core.endGroup()
|
|
|
|
let authHelper: gitAuthHelper.IGitAuthHelper | null = null
|
|
try {
|
|
if (git) {
|
|
authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
|
if (settings.setSafeDirectory) {
|
|
// Setup the repository path as a safe directory, so if we pass this into a container job with a different user it doesn't fail
|
|
// Otherwise all git commands we run in a container fail
|
|
await authHelper.configureTempGlobalConfig()
|
|
core.info(
|
|
`Adding repository directory to the temporary git global config as a safe directory`
|
|
)
|
|
|
|
await git
|
|
.config('safe.directory', settings.repositoryPath, true, true)
|
|
.catch(error => {
|
|
core.info(
|
|
`Failed to initialize safe directory with error: ${error}`
|
|
)
|
|
})
|
|
|
|
stateHelper.setSafeDirectory()
|
|
}
|
|
}
|
|
|
|
// Prepare existing directory, otherwise recreate
|
|
if (isExisting) {
|
|
await gitDirectoryHelper.prepareExistingDirectory(
|
|
git,
|
|
settings.repositoryPath,
|
|
repositoryUrl,
|
|
settings.clean,
|
|
settings.ref
|
|
)
|
|
}
|
|
|
|
if (!git) {
|
|
// Downloading using REST API
|
|
core.info(`The repository will be downloaded using the GitHub REST API`)
|
|
core.info(
|
|
`To create a local Git repository instead, add Git ${gitCommandManager.MinimumGitVersion} or higher to the PATH`
|
|
)
|
|
if (settings.submodules) {
|
|
throw new Error(
|
|
`Input 'submodules' not supported when falling back to download using the GitHub REST API. To create a local Git repository instead, add Git ${gitCommandManager.MinimumGitVersion} or higher to the PATH.`
|
|
)
|
|
} else if (settings.sshKey) {
|
|
throw new Error(
|
|
`Input 'ssh-key' not supported when falling back to download using the GitHub REST API. To create a local Git repository instead, add Git ${gitCommandManager.MinimumGitVersion} or higher to the PATH.`
|
|
)
|
|
}
|
|
|
|
await githubApiHelper.downloadRepository(
|
|
settings.authToken,
|
|
settings.repositoryOwner,
|
|
settings.repositoryName,
|
|
settings.ref,
|
|
settings.commit,
|
|
settings.repositoryPath,
|
|
settings.githubServerUrl
|
|
)
|
|
return
|
|
}
|
|
|
|
// Save state for POST action
|
|
stateHelper.setRepositoryPath(settings.repositoryPath)
|
|
|
|
// If we didn't initialize it above, do it now
|
|
if (!authHelper) {
|
|
authHelper = gitAuthHelper.createAuthHelper(git, settings)
|
|
}
|
|
|
|
// Check if we need global auth setup early for reference cache
|
|
// Global auth does not require a local .git directory
|
|
if (settings.referenceCache) {
|
|
core.startGroup('Setting up global auth for reference cache')
|
|
await authHelper.configureGlobalAuth()
|
|
core.endGroup()
|
|
}
|
|
|
|
// Initialize the repository
|
|
if (
|
|
!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))
|
|
) {
|
|
core.startGroup('Initializing the repository')
|
|
await git.init()
|
|
await git.remoteAdd('origin', repositoryUrl)
|
|
core.endGroup()
|
|
|
|
// Setup reference cache if requested
|
|
if (settings.referenceCache) {
|
|
core.startGroup('Setting up reference repository cache')
|
|
const cacheHelper = new GitCacheHelper(settings.referenceCache)
|
|
const cachePath = await cacheHelper.setupCache(git, repositoryUrl)
|
|
const cacheObjects = path.join(cachePath, 'objects')
|
|
if (fsHelper.directoryExistsSync(cacheObjects, false)) {
|
|
await git.referenceAdd(cacheObjects)
|
|
} else {
|
|
core.warning(`Reference repository cache objects directory ${cacheObjects} does not exist`)
|
|
}
|
|
core.endGroup()
|
|
}
|
|
}
|
|
|
|
// Remove global auth if it was set for reference cache,
|
|
// to avoid duplicate AUTHORIZATION headers during fetch
|
|
if (settings.referenceCache) {
|
|
core.startGroup('Removing global auth after reference cache setup')
|
|
await authHelper.removeGlobalAuth()
|
|
core.endGroup()
|
|
}
|
|
|
|
// Configure auth (must happen after git init so .git exists)
|
|
core.startGroup('Setting up auth')
|
|
await authHelper.configureAuth()
|
|
core.endGroup()
|
|
|
|
// Disable automatic garbage collection
|
|
core.startGroup('Disabling automatic garbage collection')
|
|
if (!(await git.tryDisableAutomaticGarbageCollection())) {
|
|
core.warning(
|
|
`Unable to turn off git automatic garbage collection. The git fetch operation may trigger garbage collection and cause a delay.`
|
|
)
|
|
}
|
|
core.endGroup()
|
|
|
|
// Determine the default branch
|
|
if (!settings.ref && !settings.commit) {
|
|
core.startGroup('Determining the default branch')
|
|
if (settings.sshKey) {
|
|
settings.ref = await git.getDefaultBranch(repositoryUrl)
|
|
} else {
|
|
settings.ref = await githubApiHelper.getDefaultBranch(
|
|
settings.authToken,
|
|
settings.repositoryOwner,
|
|
settings.repositoryName,
|
|
settings.githubServerUrl
|
|
)
|
|
}
|
|
core.endGroup()
|
|
}
|
|
|
|
// LFS install
|
|
if (settings.lfs) {
|
|
await git.lfsInstall()
|
|
}
|
|
|
|
// When using reference cache, fetch-depth > 0 is counterproductive:
|
|
// objects are served from the local cache, so shallow negotiation only adds latency.
|
|
adjustFetchDepthForCache(settings)
|
|
|
|
// Fetch
|
|
core.startGroup('Fetching the repository')
|
|
const fetchOptions: {
|
|
filter?: string
|
|
fetchDepth?: number
|
|
showProgress?: boolean
|
|
} = {}
|
|
|
|
if (settings.filter) {
|
|
fetchOptions.filter = settings.filter
|
|
} else if (settings.sparseCheckout) {
|
|
fetchOptions.filter = 'blob:none'
|
|
}
|
|
|
|
if (settings.fetchDepth <= 0) {
|
|
// Fetch all branches and tags
|
|
let refSpec = refHelper.getRefSpecForAllHistory(
|
|
settings.ref,
|
|
settings.commit
|
|
)
|
|
await git.fetch(refSpec, fetchOptions)
|
|
|
|
// When all history is fetched, the ref we're interested in may have moved to a different
|
|
// commit (push or force push). If so, fetch again with a targeted refspec.
|
|
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
|
refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
|
await git.fetch(refSpec, fetchOptions)
|
|
|
|
// Verify the ref now matches. For branches, the targeted fetch above brings
|
|
// in the specific commit. For tags (fetched by ref), this will fail if
|
|
// the tag was moved after the workflow was triggered.
|
|
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
|
throw new Error(
|
|
`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
|
|
`The ref may have been updated after the workflow was triggered.`
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
fetchOptions.fetchDepth = settings.fetchDepth
|
|
const refSpec = refHelper.getRefSpec(
|
|
settings.ref,
|
|
settings.commit,
|
|
settings.fetchTags
|
|
)
|
|
await git.fetch(refSpec, fetchOptions)
|
|
|
|
// For tags, verify the ref still points to the expected commit.
|
|
// Tags are fetched by ref (not commit), so if a tag was moved after the
|
|
// workflow was triggered, we would silently check out the wrong commit.
|
|
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
|
throw new Error(
|
|
`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
|
|
`The ref may have been updated after the workflow was triggered.`
|
|
)
|
|
}
|
|
}
|
|
core.endGroup()
|
|
|
|
// Checkout info
|
|
core.startGroup('Determining the checkout info')
|
|
const checkoutInfo = await refHelper.getCheckoutInfo(
|
|
git,
|
|
settings.ref,
|
|
settings.commit
|
|
)
|
|
core.endGroup()
|
|
|
|
// LFS fetch
|
|
// Explicit lfs-fetch to avoid slow checkout (fetches one lfs object at a time).
|
|
// Explicit lfs fetch will fetch lfs objects in parallel.
|
|
// For sparse checkouts, let `checkout` fetch the needed objects lazily.
|
|
if (settings.lfs && !settings.sparseCheckout) {
|
|
core.startGroup('Fetching LFS objects')
|
|
await git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref)
|
|
core.endGroup()
|
|
}
|
|
|
|
// Sparse checkout
|
|
if (!settings.sparseCheckout) {
|
|
let gitVersion = await git.version()
|
|
// no need to disable sparse-checkout if the installed git runtime doesn't even support it.
|
|
if (gitVersion.checkMinimum(MinimumGitSparseCheckoutVersion)) {
|
|
await git.disableSparseCheckout()
|
|
}
|
|
} else {
|
|
core.startGroup('Setting up sparse checkout')
|
|
if (settings.sparseCheckoutConeMode) {
|
|
await git.sparseCheckout(settings.sparseCheckout)
|
|
} else {
|
|
await git.sparseCheckoutNonConeMode(settings.sparseCheckout)
|
|
}
|
|
core.endGroup()
|
|
}
|
|
|
|
// Checkout
|
|
core.startGroup('Checking out the ref')
|
|
await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint)
|
|
core.endGroup()
|
|
|
|
// Submodules
|
|
if (settings.submodules) {
|
|
// Temporarily override global config
|
|
core.startGroup('Setting up auth for fetching submodules')
|
|
await authHelper.configureGlobalAuth()
|
|
core.endGroup()
|
|
|
|
// Checkout submodules
|
|
core.startGroup('Fetching submodules')
|
|
await git.submoduleSync(settings.nestedSubmodules)
|
|
|
|
if (settings.referenceCache) {
|
|
core.info('Iterative submodule update using reference cache')
|
|
const cacheHelper = new GitCacheHelper(settings.referenceCache)
|
|
await iterativeSubmoduleUpdate(
|
|
git,
|
|
cacheHelper,
|
|
settings.repositoryPath,
|
|
settings.fetchDepth,
|
|
settings.nestedSubmodules
|
|
)
|
|
} else {
|
|
await git.submoduleUpdate(settings.fetchDepth, settings.nestedSubmodules)
|
|
}
|
|
|
|
await git.submoduleForeach(
|
|
'git config --local gc.auto 0',
|
|
settings.nestedSubmodules
|
|
)
|
|
core.endGroup()
|
|
|
|
// Persist credentials
|
|
if (settings.persistCredentials) {
|
|
core.startGroup('Persisting credentials for submodules')
|
|
await authHelper.configureSubmoduleAuth()
|
|
core.endGroup()
|
|
}
|
|
}
|
|
|
|
// Get commit information
|
|
const commitInfo = await git.log1()
|
|
|
|
// Log commit sha
|
|
const commitSHA = await git.log1('--format=%H')
|
|
core.setOutput('commit', commitSHA.trim())
|
|
|
|
// Check for incorrect pull request merge commit
|
|
await refHelper.checkCommitInfo(
|
|
settings.authToken,
|
|
commitInfo,
|
|
settings.repositoryOwner,
|
|
settings.repositoryName,
|
|
settings.ref,
|
|
settings.commit,
|
|
settings.githubServerUrl
|
|
)
|
|
} finally {
|
|
// Remove auth
|
|
if (authHelper) {
|
|
if (!settings.persistCredentials) {
|
|
core.startGroup('Removing auth')
|
|
await authHelper.removeAuth()
|
|
core.endGroup()
|
|
}
|
|
authHelper.removeGlobalConfig()
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function cleanup(repositoryPath: string): Promise<void> {
|
|
// Repo exists?
|
|
if (
|
|
!repositoryPath ||
|
|
!fsHelper.fileExistsSync(path.join(repositoryPath, '.git', 'config'))
|
|
) {
|
|
return
|
|
}
|
|
|
|
let git: IGitCommandManager
|
|
try {
|
|
git = await gitCommandManager.createCommandManager(
|
|
repositoryPath,
|
|
false,
|
|
false
|
|
)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
// Remove auth
|
|
const authHelper = gitAuthHelper.createAuthHelper(git)
|
|
try {
|
|
if (stateHelper.PostSetSafeDirectory) {
|
|
// Setup the repository path as a safe directory, so if we pass this into a container job with a different user it doesn't fail
|
|
// Otherwise all git commands we run in a container fail
|
|
await authHelper.configureTempGlobalConfig()
|
|
core.info(
|
|
`Adding repository directory to the temporary git global config as a safe directory`
|
|
)
|
|
|
|
await git
|
|
.config('safe.directory', repositoryPath, true, true)
|
|
.catch(error => {
|
|
core.info(`Failed to initialize safe directory with error: ${error}`)
|
|
})
|
|
}
|
|
|
|
await authHelper.removeAuth()
|
|
} finally {
|
|
await authHelper.removeGlobalConfig()
|
|
}
|
|
}
|
|
|
|
async function getGitCommandManager(
|
|
settings: IGitSourceSettings
|
|
): Promise<IGitCommandManager | undefined> {
|
|
core.info(`Working directory is '${settings.repositoryPath}'`)
|
|
try {
|
|
return await gitCommandManager.createCommandManager(
|
|
settings.repositoryPath,
|
|
settings.lfs,
|
|
settings.sparseCheckout != null
|
|
)
|
|
} catch (err) {
|
|
// Git is required for LFS
|
|
if (settings.lfs) {
|
|
throw err
|
|
}
|
|
|
|
// Otherwise fallback to REST API
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adjusts fetchDepth when reference-cache is active.
|
|
* Shallow fetches are counterproductive with a local cache because
|
|
* objects are served from disk, making shallow negotiation pure overhead.
|
|
*/
|
|
export function adjustFetchDepthForCache(
|
|
settings: Pick<
|
|
IGitSourceSettings,
|
|
'referenceCache' | 'fetchDepth' | 'fetchDepthExplicit'
|
|
>
|
|
): void {
|
|
if (settings.referenceCache && settings.fetchDepth > 0) {
|
|
if (settings.fetchDepthExplicit) {
|
|
core.warning(
|
|
`'fetch-depth: ${settings.fetchDepth}' is set with reference-cache enabled. ` +
|
|
`This may slow down checkout because shallow negotiation bypasses the local cache. ` +
|
|
`Consider using 'fetch-depth: 0' for best performance with reference-cache.`
|
|
)
|
|
} else {
|
|
core.info(
|
|
`Overriding fetch-depth from ${settings.fetchDepth} to 0 because reference-cache is enabled`
|
|
)
|
|
settings.fetchDepth = 0
|
|
}
|
|
}
|
|
}
|