checkout/src/git-cache-helper.ts
Michael Wyraz 9ddd3f4b35 feat: implement reference-cache for faster checkouts
- Add `reference-cache` input to action.yml
- Introduce `GitCacheHelper` for bare clone cache management
- Prevent race conditions with `proper-lockfile` and atomic directory renames
- Support iterative submodule caching and robust relative URL resolution
- Append to `info/alternates` preserving existing alternate references
- Add fallback to standard clone on submodule cache failure
- Add unit tests for `GitCacheHelper`

Signed-off-by: Michael Wyraz <mw@brick4u.de>
2026-03-05 15:33:54 +01:00

99 lines
3.8 KiB
TypeScript

import * as core from '@actions/core'
import * as path from 'path'
import * as fs from 'fs'
import * as crypto from 'crypto'
import * as lockfile from 'proper-lockfile'
import {IGitCommandManager} from './git-command-manager'
export class GitCacheHelper {
constructor(private referenceCache: string) {}
/**
* Prepares the reference cache for a given repository URL.
* If the cache does not exist, it performs a bare clone.
* If it exists, it performs a fetch to update it.
* Returns the absolute path to the bare cache repository.
*/
async setupCache(git: IGitCommandManager, repositoryUrl: string): Promise<string> {
const cacheDirName = this.generateCacheDirName(repositoryUrl)
const cachePath = path.join(this.referenceCache, cacheDirName)
// Ensure the base cache directory exists before we try to lock inside it
if (!fs.existsSync(this.referenceCache)) {
await fs.promises.mkdir(this.referenceCache, { recursive: true })
}
// We use a dedicated lock dir specifically for this repository's cache
// since we cannot place a lock *inside* a repository that might not exist yet
const lockfilePath = `${cachePath}.lock`
// Ensure the file we are locking exists
if (!fs.existsSync(lockfilePath)) {
await fs.promises.writeFile(lockfilePath, '')
}
core.debug(`Acquiring lock for ${repositoryUrl} at ${lockfilePath}`)
let releaseLock: () => Promise<void>
try {
// proper-lockfile creates a ".lock" directory next to the target file.
// We configure it to wait up to 10 minutes (600,000 ms) for another process to finish.
// E.g. cloning a very large monorepo might take minutes.
releaseLock = await lockfile.lock(lockfilePath, {
retries: {
retries: 60, // try 60 times
factor: 1, // linear backoff
minTimeout: 10000, // wait 10 seconds between tries
maxTimeout: 10000, // (total max wait time: 600s = 10m)
randomize: true
}
})
core.debug(`Lock acquired.`)
} catch (err) {
throw new Error(`Failed to acquire lock for repository cache ${repositoryUrl}: ${err}`)
}
try {
if (fs.existsSync(path.join(cachePath, 'objects'))) {
core.info(`Reference cache for ${repositoryUrl} exists. Updating...`)
const args = ['-C', cachePath, 'fetch', '--force', '--prune', '--tags', 'origin', '+refs/heads/*:refs/heads/*']
await git.execGit(args)
} else {
core.info(`Reference cache for ${repositoryUrl} does not exist. Cloning --bare...`)
// Use a temporary clone pattern to prevent corrupted repos if process is killed mid-clone
const tmpPath = `${cachePath}.tmp.${crypto.randomUUID()}`
try {
const args = ['-C', this.referenceCache, 'clone', '--bare', repositoryUrl, tmpPath]
await git.execGit(args)
if (fs.existsSync(cachePath)) {
// In rare cases where it somehow exists but objects/ didn't, clean it up
await fs.promises.rm(cachePath, { recursive: true, force: true })
}
await fs.promises.rename(tmpPath, cachePath)
} catch (cloneErr) {
// Cleanup partial clone if an error occurred
await fs.promises.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
throw cloneErr
}
}
} finally {
await releaseLock()
}
return cachePath
}
/**
* Generates a directory name for the cache based on the URL.
* Replaces non-alphanumeric characters with underscores
* and appends a short SHA256 hash of the original URL.
*/
generateCacheDirName(url: string): string {
const cleanUrl = url.replace(/[^a-zA-Z0-9]/g, '_')
const hash = crypto.createHash('sha256').update(url).digest('hex').substring(0, 8)
return `${cleanUrl}_${hash}.git`
}
}