1
0
Fork 0
mirror of https://code.forgejo.org/actions/checkout.git synced 2026-03-08 05:55:49 +00:00

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>
This commit is contained in:
Michael Wyraz 2026-03-05 11:54:42 +01:00
parent 0c366fd6a8
commit 9ddd3f4b35
16 changed files with 2996 additions and 32 deletions

View file

@ -160,6 +160,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
# running from unless specified. Example URLs are https://github.com or # running from unless specified. Example URLs are https://github.com or
# https://my-ghes-server.example.com # https://my-ghes-server.example.com
github-server-url: '' github-server-url: ''
# Path to a local directory used as a reference cache for Git clones. Over time,
# this directory will contain bare clones of the checked-out repositories (and
# their submodules). Using this significantly reduces network bandwidth and speeds
# up clones.
reference-cache: ''
``` ```
<!-- end usage --> <!-- end usage -->

View file

@ -1047,8 +1047,10 @@ async function setup(testName: string): Promise<void> {
lfsFetch: jest.fn(), lfsFetch: jest.fn(),
lfsInstall: jest.fn(), lfsInstall: jest.fn(),
log1: jest.fn(), log1: jest.fn(),
referenceAdd: jest.fn(),
remoteAdd: jest.fn(), remoteAdd: jest.fn(),
removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]), removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]),
execGit: jest.fn(),
revParse: jest.fn(), revParse: jest.fn(),
setEnvironmentVariable: jest.fn((name: string, value: string) => { setEnvironmentVariable: jest.fn((name: string, value: string) => {
git.env[name] = value git.env[name] = value
@ -1173,7 +1175,8 @@ async function setup(testName: string): Promise<void> {
sshUser: '', sshUser: '',
workflowOrganizationId: 123456, workflowOrganizationId: 123456,
setSafeDirectory: true, setSafeDirectory: true,
githubServerUrl: githubServerUrl githubServerUrl: githubServerUrl,
referenceCache: ''
} }
} }

View file

@ -0,0 +1,109 @@
import * as path from 'path'
import * as fs from 'fs'
import * as io from '@actions/io'
import { GitCacheHelper } from '../src/git-cache-helper'
import { IGitCommandManager } from '../src/git-command-manager'
describe('GitCacheHelper', () => {
let cacheHelper: GitCacheHelper
let mockGit: jest.Mocked<IGitCommandManager>
const cacheDir = path.join(__dirname, 'test-cache')
beforeEach(async () => {
cacheHelper = new GitCacheHelper(cacheDir)
mockGit = {
execGit: jest.fn().mockImplementation(async (args) => {
// If git clone is called, simulate creating the destination dir
if (args && args.includes('clone')) {
const dest = args.find((a: string) => a.includes('.tmp.'));
if (dest) {
await io.mkdirP(dest);
} else {
console.log('No .tmp. found in args:', args);
}
}
return { exitCode: 0, stdout: '', stderr: '' };
}),
gitEnv: {}
} as any
await io.mkdirP(cacheDir)
})
afterEach(async () => {
await io.rmRF(cacheDir)
})
it('generates a consistent, short, and safe cache directory name', () => {
const url1 = 'https://github.com/mwyraz/forgejo-actions-checkout.git'
const name1 = (cacheHelper as any).generateCacheDirName(url1)
// Check structure: safe string + hash
expect(name1).toMatch(/^https___github_com_mwyraz_forgejo_actions_checkout_git_[0-9a-f]{8}\.git$/)
// Same URL should produce the same directory name
const url1_duplicate = 'https://github.com/mwyraz/forgejo-actions-checkout.git'
expect((cacheHelper as any).generateCacheDirName(url1_duplicate)).toBe(name1)
// Different URL should produce a different directory name
const url2 = 'https://github.com/mwyraz/forgejo-actions-checkout-other.git'
expect((cacheHelper as any).generateCacheDirName(url2)).not.toBe(name1)
// SSH URL
const url3 = 'git@github.com:auth/repo.git'
const name3 = (cacheHelper as any).generateCacheDirName(url3)
expect(name3).toMatch(/^git_github_com_auth_repo_git_[0-9a-f]{8}\.git$/)
// Unclean URLs
const url4 = 'https://github.com/foo/bar.git?v=1'
const name4 = (cacheHelper as any).generateCacheDirName(url4)
expect(name4).toMatch(/^https___github_com_foo_bar_git_v_1_[0-9a-f]{8}\.git$/)
})
it('sets up a cache directory if it does not exist', async () => {
const repositoryUrl = 'https://github.com/mwyraz/test-repo.git'
const resultPath = await cacheHelper.setupCache(mockGit, repositoryUrl)
const expectedName = (cacheHelper as any).generateCacheDirName(repositoryUrl)
expect(resultPath).toBe(path.join(cacheDir, expectedName))
// It should have executed git clone --bare
expect(mockGit.execGit).toHaveBeenCalledWith(
expect.arrayContaining([
'-C',
cacheDir,
'clone',
'--bare',
repositoryUrl,
expect.stringContaining(`${expectedName}.tmp`) // should use tmp dir
])
)
})
it('fetches updates if the cache directory already exists', async () => {
const repositoryUrl = 'https://github.com/mwyraz/existing-repo.git'
const expectedName = (cacheHelper as any).generateCacheDirName(repositoryUrl)
const fixedPath = path.join(cacheDir, expectedName)
// Fake existing directory
await io.mkdirP(path.join(fixedPath, 'objects'))
const resultPath = await cacheHelper.setupCache(mockGit, repositoryUrl)
expect(resultPath).toBe(fixedPath)
// It should have executed git fetch
expect(mockGit.execGit).toHaveBeenCalledWith(
expect.arrayContaining([
'-C',
fixedPath,
'fetch',
'--force',
'--prune',
'--tags',
'origin',
'+refs/heads/*:refs/heads/*'
])
)
})
})

View file

@ -478,8 +478,10 @@ async function setup(testName: string): Promise<void> {
lfsFetch: jest.fn(), lfsFetch: jest.fn(),
lfsInstall: jest.fn(), lfsInstall: jest.fn(),
log1: jest.fn(), log1: jest.fn(),
referenceAdd: jest.fn(),
remoteAdd: jest.fn(), remoteAdd: jest.fn(),
removeEnvironmentVariable: jest.fn(), removeEnvironmentVariable: jest.fn(),
execGit: jest.fn(),
revParse: jest.fn(), revParse: jest.fn(),
setEnvironmentVariable: jest.fn(), setEnvironmentVariable: jest.fn(),
shaExists: jest.fn(), shaExists: jest.fn(),

View file

@ -91,6 +91,7 @@ describe('input-helper tests', () => {
expect(settings.repositoryOwner).toBe('some-owner') expect(settings.repositoryOwner).toBe('some-owner')
expect(settings.repositoryPath).toBe(gitHubWorkspace) expect(settings.repositoryPath).toBe(gitHubWorkspace)
expect(settings.setSafeDirectory).toBe(true) expect(settings.setSafeDirectory).toBe(true)
expect(settings.referenceCache || '').toBe('')
}) })
it('qualifies ref', async () => { it('qualifies ref', async () => {

View file

@ -98,6 +98,12 @@ inputs:
github-server-url: github-server-url:
description: The base URL for the GitHub instance that you are trying to clone from, will use environment defaults to fetch from the same instance that the workflow is running from unless specified. Example URLs are https://github.com or https://my-ghes-server.example.com description: The base URL for the GitHub instance that you are trying to clone from, will use environment defaults to fetch from the same instance that the workflow is running from unless specified. Example URLs are https://github.com or https://my-ghes-server.example.com
required: false required: false
reference-cache:
description: >
Path to a local directory used as a reference cache for Git clones. Over time,
this directory will contain bare clones of the checked-out repositories (and their submodules).
Using this significantly reduces network bandwidth and speeds up clones.
required: false
outputs: outputs:
ref: ref:
description: 'The branch, tag or SHA that was checked out' description: 'The branch, tag or SHA that was checked out'

View file

@ -0,0 +1,37 @@
# Reference Cache für schnelle Checkouts
## Zusammenfassung
Einführung eines lokal verwalteten Git-Referenz-Caches für Haupt-Repositories und Submodule, um Netzwerk-Traffic und Checkout-Zeiten auf persistenten Runnern (z.B. Self-Hosted) massiv zu reduzieren.
## Implementierungsplan
1. **Inputs:**
- In `action.yml` einen neuen Input `reference-cache` (Pfad zum Cache-Verzeichnis) hinzufügen. Default ist leer.
- In `src/git-source-settings.ts` und `src/input-helper.ts` den Input auslesen und bereitstellen (`settings.referenceCache`).
2. **Cache Manager (`src/git-cache-helper.ts`):**
- Eine neue Klasse/Helper-Logik, die das Erstellen (`git clone --bare`) und Aktualisieren (`git fetch --force`) von Bare Cache-Repos übernimmt.
- **Namenskonvention Cache-Verzeichnis:** Damit Admin-Lesbarkeit und Kollisionsfreiheit gewährleistet sind, wird das Cache-Verzeichnis aus der Repository-URL gebildet:
- Alle Sonderzeichen in der URL durch `_` ersetzen.
- Ein kurzer Hash (z. B. erste 8 Zeichen des SHA256) der echten URL zur Eindeutigkeit anhängen.
- Beispiel: `<reference-cache>/https___github_com_actions_checkout_8f9b1c2a.git`
3. **Haupt-Repo Checkout (`src/git-source-provider.ts`):**
- Vor dem Setup des Checkouts prüfen, ob `reference-cache` gesetzt ist.
- Wenn ja: den Cache-Ordner für die Haupt-URL aktualisieren/anlegen.
- Nach dem initialen `git.init()` den Pfad in `.git/objects/info/alternates` schreiben, der auf das `objects`-Verzeichnis des Cache-Ordners zeigt.
4. **Submodule Checkouts (Iterativ statt monolithisch):**
- Der aktuelle Befehl `git submodule update --recursive` funktioniert nicht out-of-the-box mit `reference`, wenn jedes Submodul seinen individuellen Referenz-Cache benötigt.
- Wenn `reference-cache` aktiv ist und Submodule initialisiert werden sollen:
- Lese `.gitmodules` aus (alle Sub-URLs ermitteln).
- Für jedes Submodul den Cache (genauso wie in Step 2) anlegen oder aktualisieren.
- Submodul einzeln auschecken per `git submodule update --init --reference <cache-pfad/.git> <pfad>`.
- Bei der Einstellung `recursive`: In jedes Submodul-Verzeichnis wechseln und den Vorgang für `.gitmodules` rekursiv auf Skript-Ebene durchführen (anstatt Git's `--recursive` Flag einfach weiterzugeben).
## Akzeptanzkriterien
1. **Neue Option konfigurierbar**: Der Input `reference-cache` kann übergeben werden, der Code reagiert darauf.
2. **Ordnerstruktur korrekt**: Der Cache-Ordner für das Hauptrepo und Submodule erhält Namen nach der "URL_Sonderzeichen_Ersetzt+SHA_Cut"-Logik.
3. **Bandbreite gespart / Alternates genutzt**: Beim Hauptcheckout wird eine `.git/objects/info/alternates`-Datei mit Pfad zum lokalen Cache erzeugt. Danach ausgeführte `git fetch`-Befehle sind signifikant schneller bzw. laden deutlich weniger Bytes herunter.
4. **Submodule erhalten Caches**: Auch tiefe (rekursive) Submodule profitieren für deren jeweilige Remote-URL vom Cache, da pro Submodul ein passender `--reference` Punkt dynamisch berechnet und übergeben wird.
5. **Kein --dissociate**: Aus Performance-Gründen bleibt der Arbeitsordner an den Cache gebunden (`git repack` ist zeitaufwändig). Fällt der Cache weg, muss der Workspace erst einmal neu erzeugt werden (was bei Action Runnern die Norm ist, falls es nicht ohnehin "single-use" Runner sind).

2446
dist/index.js vendored

File diff suppressed because it is too large Load diff

41
package-lock.json generated
View file

@ -19,6 +19,7 @@
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^24.1.0", "@types/node": "^24.1.0",
"@types/proper-lockfile": "^4.1.4",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0", "@typescript-eslint/parser": "^7.9.0",
@ -30,6 +31,7 @@
"jest-circus": "^29.7.0", "jest-circus": "^29.7.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"proper-lockfile": "^4.1.2",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
@ -1523,6 +1525,23 @@
"undici-types": "~7.8.0" "undici-types": "~7.8.0"
} }
}, },
"node_modules/@types/proper-lockfile": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/retry": "*"
}
},
"node_modules/@types/retry": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/stack-utils": { "node_modules/@types/stack-utils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@ -5980,6 +5999,18 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -6135,6 +6166,16 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",

View file

@ -38,6 +38,7 @@
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^24.1.0", "@types/node": "^24.1.0",
"@types/proper-lockfile": "^4.1.4",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0", "@typescript-eslint/parser": "^7.9.0",
@ -49,6 +50,7 @@
"jest-circus": "^29.7.0", "jest-circus": "^29.7.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"proper-lockfile": "^4.1.2",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }

View file

@ -21,6 +21,7 @@ export interface IGitAuthHelper {
configureSubmoduleAuth(): Promise<void> configureSubmoduleAuth(): Promise<void>
configureTempGlobalConfig(): Promise<string> configureTempGlobalConfig(): Promise<string>
removeAuth(): Promise<void> removeAuth(): Promise<void>
removeGlobalAuth(): Promise<void>
removeGlobalConfig(): Promise<void> removeGlobalConfig(): Promise<void>
} }
@ -235,6 +236,12 @@ class GitAuthHelper {
await this.removeToken() await this.removeToken()
} }
async removeGlobalAuth(): Promise<void> {
core.debug('Removing global auth entries')
await this.git.tryConfigUnset('include.path', true)
await this.git.tryConfigUnset(this.insteadOfKey, true)
}
async removeGlobalConfig(): Promise<void> { async removeGlobalConfig(): Promise<void> {
if (this.temporaryHomePath?.length > 0) { if (this.temporaryHomePath?.length > 0) {
core.debug(`Unsetting HOME override`) core.debug(`Unsetting HOME override`)

98
src/git-cache-helper.ts Normal file
View file

@ -0,0 +1,98 @@
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`
}
}

View file

@ -15,6 +15,11 @@ import {GitVersion} from './git-version'
export const MinimumGitVersion = new GitVersion('2.18') export const MinimumGitVersion = new GitVersion('2.18')
export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28') export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28')
export class GitOutput {
stdout = ''
exitCode = 0
}
export interface IGitCommandManager { export interface IGitCommandManager {
branchDelete(remote: boolean, branch: string): Promise<void> branchDelete(remote: boolean, branch: string): Promise<void>
branchExists(remote: boolean, pattern: string): Promise<boolean> branchExists(remote: boolean, pattern: string): Promise<boolean>
@ -48,6 +53,7 @@ export interface IGitCommandManager {
lfsFetch(ref: string): Promise<void> lfsFetch(ref: string): Promise<void>
lfsInstall(): Promise<void> lfsInstall(): Promise<void>
log1(format?: string): Promise<string> log1(format?: string): Promise<string>
referenceAdd(referenceObjects: string): Promise<void>
remoteAdd(remoteName: string, remoteUrl: string): Promise<void> remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
removeEnvironmentVariable(name: string): void removeEnvironmentVariable(name: string): void
revParse(ref: string): Promise<string> revParse(ref: string): Promise<string>
@ -80,6 +86,12 @@ export interface IGitCommandManager {
): Promise<string[]> ): Promise<string[]>
tryReset(): Promise<boolean> tryReset(): Promise<boolean>
version(): Promise<GitVersion> version(): Promise<GitVersion>
execGit(
args: string[],
allowAllExitCodes?: boolean,
silent?: boolean,
customListeners?: any
): Promise<GitOutput>
} }
export async function createCommandManager( export async function createCommandManager(
@ -401,6 +413,32 @@ class GitCommandManager {
await this.execGit(['remote', 'add', remoteName, remoteUrl]) await this.execGit(['remote', 'add', remoteName, remoteUrl])
} }
async referenceAdd(referenceObjects: string): Promise<void> {
const alternatesPath = path.join(
this.workingDirectory,
'.git',
'objects',
'info',
'alternates'
)
core.info(`Configuring git alternate to reference objects at ${referenceObjects}`)
const infoDir = path.dirname(alternatesPath)
if (!fs.existsSync(infoDir)) {
await fs.promises.mkdir(infoDir, { recursive: true })
}
let existing = ''
if (fs.existsSync(alternatesPath)) {
existing = (await fs.promises.readFile(alternatesPath, 'utf8')).trim()
}
const lines = existing ? existing.split('\n') : []
if (!lines.includes(referenceObjects)) {
lines.push(referenceObjects)
await fs.promises.writeFile(alternatesPath, lines.join('\n') + '\n')
}
}
removeEnvironmentVariable(name: string): void { removeEnvironmentVariable(name: string): void {
delete this.gitEnv[name] delete this.gitEnv[name]
} }
@ -609,7 +647,7 @@ class GitCommandManager {
return result return result
} }
private async execGit( async execGit(
args: string[], args: string[],
allowAllExitCodes = false, allowAllExitCodes = false,
silent = false, silent = false,
@ -746,7 +784,3 @@ class GitCommandManager {
} }
} }
class GitOutput {
stdout = ''
exitCode = 0
}

View file

@ -14,6 +14,156 @@ import {
IGitCommandManager IGitCommandManager
} from './git-command-manager' } from './git-command-manager'
import {IGitSourceSettings} from './git-source-settings' 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> { export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Repository URL // Repository URL
@ -105,6 +255,19 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Save state for POST action // Save state for POST action
stateHelper.setRepositoryPath(settings.repositoryPath) 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 // Initialize the repository
if ( if (
!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) !fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))
@ -113,8 +276,35 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
await git.init() await git.init()
await git.remoteAdd('origin', repositoryUrl) await git.remoteAdd('origin', repositoryUrl)
core.endGroup() 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 // Disable automatic garbage collection
core.startGroup('Disabling automatic garbage collection') core.startGroup('Disabling automatic garbage collection')
if (!(await git.tryDisableAutomaticGarbageCollection())) { if (!(await git.tryDisableAutomaticGarbageCollection())) {
@ -124,15 +314,6 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
} }
core.endGroup() core.endGroup()
// If we didn't initialize it above, do it now
if (!authHelper) {
authHelper = gitAuthHelper.createAuthHelper(git, settings)
}
// Configure auth
core.startGroup('Setting up auth')
await authHelper.configureAuth()
core.endGroup()
// Determine the default branch // Determine the default branch
if (!settings.ref && !settings.commit) { if (!settings.ref && !settings.commit) {
core.startGroup('Determining the default branch') core.startGroup('Determining the default branch')
@ -264,7 +445,21 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Checkout submodules // Checkout submodules
core.startGroup('Fetching submodules') core.startGroup('Fetching submodules')
await git.submoduleSync(settings.nestedSubmodules) await git.submoduleSync(settings.nestedSubmodules)
await git.submoduleUpdate(settings.fetchDepth, 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( await git.submoduleForeach(
'git config --local gc.auto 0', 'git config --local gc.auto 0',
settings.nestedSubmodules settings.nestedSubmodules

View file

@ -59,6 +59,11 @@ export interface IGitSourceSettings {
*/ */
showProgress: boolean showProgress: boolean
/**
* The path to a local directory used as a reference cache for Git clones
*/
referenceCache: string
/** /**
* Indicates whether to fetch LFS objects * Indicates whether to fetch LFS objects
*/ */

View file

@ -161,5 +161,9 @@ export async function getInputs(): Promise<IGitSourceSettings> {
result.githubServerUrl = core.getInput('github-server-url') result.githubServerUrl = core.getInput('github-server-url')
core.debug(`GitHub Host URL = ${result.githubServerUrl}`) core.debug(`GitHub Host URL = ${result.githubServerUrl}`)
// Reference Cache
result.referenceCache = core.getInput('reference-cache')
core.debug(`Reference Cache = ${result.referenceCache}`)
return result return result
} }