mirror of
https://code.forgejo.org/actions/checkout.git
synced 2026-03-08 05:55:49 +00:00
Merge ed69f3bbdd into 0c366fd6a8
This commit is contained in:
commit
e03315fd5d
17 changed files with 3149 additions and 34 deletions
|
|
@ -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
|
||||
# https://my-ghes-server.example.com
|
||||
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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -1047,8 +1047,10 @@ async function setup(testName: string): Promise<void> {
|
|||
lfsFetch: jest.fn(),
|
||||
lfsInstall: jest.fn(),
|
||||
log1: jest.fn(),
|
||||
referenceAdd: jest.fn(),
|
||||
remoteAdd: jest.fn(),
|
||||
removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]),
|
||||
execGit: jest.fn(),
|
||||
revParse: jest.fn(),
|
||||
setEnvironmentVariable: jest.fn((name: string, value: string) => {
|
||||
git.env[name] = value
|
||||
|
|
@ -1157,6 +1159,7 @@ async function setup(testName: string): Promise<void> {
|
|||
sparseCheckout: [],
|
||||
sparseCheckoutConeMode: true,
|
||||
fetchDepth: 1,
|
||||
fetchDepthExplicit: false,
|
||||
fetchTags: false,
|
||||
showProgress: true,
|
||||
lfs: false,
|
||||
|
|
@ -1173,7 +1176,8 @@ async function setup(testName: string): Promise<void> {
|
|||
sshUser: '',
|
||||
workflowOrganizationId: 123456,
|
||||
setSafeDirectory: true,
|
||||
githubServerUrl: githubServerUrl
|
||||
githubServerUrl: githubServerUrl,
|
||||
referenceCache: ''
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
109
__test__/git-cache-helper.test.ts
Normal file
109
__test__/git-cache-helper.test.ts
Normal 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/*'
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -478,8 +478,10 @@ async function setup(testName: string): Promise<void> {
|
|||
lfsFetch: jest.fn(),
|
||||
lfsInstall: jest.fn(),
|
||||
log1: jest.fn(),
|
||||
referenceAdd: jest.fn(),
|
||||
remoteAdd: jest.fn(),
|
||||
removeEnvironmentVariable: jest.fn(),
|
||||
execGit: jest.fn(),
|
||||
revParse: jest.fn(),
|
||||
setEnvironmentVariable: jest.fn(),
|
||||
shaExists: jest.fn(),
|
||||
|
|
|
|||
88
__test__/git-source-provider.test.ts
Normal file
88
__test__/git-source-provider.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import * as core from '@actions/core'
|
||||
import {adjustFetchDepthForCache} from '../src/git-source-provider'
|
||||
|
||||
// Mock @actions/core
|
||||
jest.mock('@actions/core')
|
||||
|
||||
describe('adjustFetchDepthForCache', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing when referenceCache is not set', () => {
|
||||
const settings = {
|
||||
referenceCache: '',
|
||||
fetchDepth: 1,
|
||||
fetchDepthExplicit: false
|
||||
}
|
||||
adjustFetchDepthForCache(settings)
|
||||
expect(settings.fetchDepth).toBe(1)
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
expect(core.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('overrides fetchDepth to 0 when referenceCache is set and fetchDepth is default', () => {
|
||||
const settings = {
|
||||
referenceCache: '/cache/git-reference-cache',
|
||||
fetchDepth: 1,
|
||||
fetchDepthExplicit: false
|
||||
}
|
||||
adjustFetchDepthForCache(settings)
|
||||
expect(settings.fetchDepth).toBe(0)
|
||||
expect(core.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Overriding fetch-depth from 1 to 0')
|
||||
)
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns but keeps fetchDepth when referenceCache is set and fetchDepth is explicit', () => {
|
||||
const settings = {
|
||||
referenceCache: '/cache/git-reference-cache',
|
||||
fetchDepth: 1,
|
||||
fetchDepthExplicit: true
|
||||
}
|
||||
adjustFetchDepthForCache(settings)
|
||||
expect(settings.fetchDepth).toBe(1)
|
||||
expect(core.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("'fetch-depth: 1' is set with reference-cache enabled")
|
||||
)
|
||||
expect(core.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when referenceCache is set and fetchDepth is already 0 (explicit)', () => {
|
||||
const settings = {
|
||||
referenceCache: '/cache/git-reference-cache',
|
||||
fetchDepth: 0,
|
||||
fetchDepthExplicit: true
|
||||
}
|
||||
adjustFetchDepthForCache(settings)
|
||||
expect(settings.fetchDepth).toBe(0)
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
expect(core.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when referenceCache is set and fetchDepth is already 0 (default)', () => {
|
||||
const settings = {
|
||||
referenceCache: '/cache/git-reference-cache',
|
||||
fetchDepth: 0,
|
||||
fetchDepthExplicit: false
|
||||
}
|
||||
adjustFetchDepthForCache(settings)
|
||||
expect(settings.fetchDepth).toBe(0)
|
||||
expect(core.warning).not.toHaveBeenCalled()
|
||||
expect(core.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns with correct depth value when explicit fetchDepth is > 1', () => {
|
||||
const settings = {
|
||||
referenceCache: '/cache/git-reference-cache',
|
||||
fetchDepth: 42,
|
||||
fetchDepthExplicit: true
|
||||
}
|
||||
adjustFetchDepthForCache(settings)
|
||||
expect(settings.fetchDepth).toBe(42)
|
||||
expect(core.warning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("'fetch-depth: 42' is set with reference-cache enabled")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -91,6 +91,7 @@ describe('input-helper tests', () => {
|
|||
expect(settings.repositoryOwner).toBe('some-owner')
|
||||
expect(settings.repositoryPath).toBe(gitHubWorkspace)
|
||||
expect(settings.setSafeDirectory).toBe(true)
|
||||
expect(settings.referenceCache || '').toBe('')
|
||||
})
|
||||
|
||||
it('qualifies ref', async () => {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ inputs:
|
|||
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
|
||||
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:
|
||||
ref:
|
||||
description: 'The branch, tag or SHA that was checked out'
|
||||
|
|
|
|||
37
adrs/2303-reference-cache.md
Normal file
37
adrs/2303-reference-cache.md
Normal 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).
|
||||
2472
dist/index.js
vendored
2472
dist/index.js
vendored
File diff suppressed because it is too large
Load diff
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
|||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.9.0",
|
||||
"@typescript-eslint/parser": "^7.9.0",
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
"jest-circus": "^29.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
|
@ -1523,6 +1525,23 @@
|
|||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
|
|
@ -5980,6 +5999,18 @@
|
|||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
@ -6135,6 +6166,16 @@
|
|||
"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": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.9.0",
|
||||
"@typescript-eslint/parser": "^7.9.0",
|
||||
|
|
@ -49,6 +50,7 @@
|
|||
"jest-circus": "^29.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface IGitAuthHelper {
|
|||
configureSubmoduleAuth(): Promise<void>
|
||||
configureTempGlobalConfig(): Promise<string>
|
||||
removeAuth(): Promise<void>
|
||||
removeGlobalAuth(): Promise<void>
|
||||
removeGlobalConfig(): Promise<void>
|
||||
}
|
||||
|
||||
|
|
@ -235,6 +236,12 @@ class GitAuthHelper {
|
|||
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> {
|
||||
if (this.temporaryHomePath?.length > 0) {
|
||||
core.debug(`Unsetting HOME override`)
|
||||
|
|
|
|||
98
src/git-cache-helper.ts
Normal file
98
src/git-cache-helper.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,11 @@ import {GitVersion} from './git-version'
|
|||
export const MinimumGitVersion = new GitVersion('2.18')
|
||||
export const MinimumGitSparseCheckoutVersion = new GitVersion('2.28')
|
||||
|
||||
export class GitOutput {
|
||||
stdout = ''
|
||||
exitCode = 0
|
||||
}
|
||||
|
||||
export interface IGitCommandManager {
|
||||
branchDelete(remote: boolean, branch: string): Promise<void>
|
||||
branchExists(remote: boolean, pattern: string): Promise<boolean>
|
||||
|
|
@ -48,6 +53,7 @@ export interface IGitCommandManager {
|
|||
lfsFetch(ref: string): Promise<void>
|
||||
lfsInstall(): Promise<void>
|
||||
log1(format?: string): Promise<string>
|
||||
referenceAdd(referenceObjects: string): Promise<void>
|
||||
remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
|
||||
removeEnvironmentVariable(name: string): void
|
||||
revParse(ref: string): Promise<string>
|
||||
|
|
@ -80,6 +86,12 @@ export interface IGitCommandManager {
|
|||
): Promise<string[]>
|
||||
tryReset(): Promise<boolean>
|
||||
version(): Promise<GitVersion>
|
||||
execGit(
|
||||
args: string[],
|
||||
allowAllExitCodes?: boolean,
|
||||
silent?: boolean,
|
||||
customListeners?: any
|
||||
): Promise<GitOutput>
|
||||
}
|
||||
|
||||
export async function createCommandManager(
|
||||
|
|
@ -401,6 +413,32 @@ class GitCommandManager {
|
|||
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 {
|
||||
delete this.gitEnv[name]
|
||||
}
|
||||
|
|
@ -609,7 +647,7 @@ class GitCommandManager {
|
|||
return result
|
||||
}
|
||||
|
||||
private async execGit(
|
||||
async execGit(
|
||||
args: string[],
|
||||
allowAllExitCodes = false,
|
||||
silent = false,
|
||||
|
|
@ -746,7 +784,3 @@ class GitCommandManager {
|
|||
}
|
||||
}
|
||||
|
||||
class GitOutput {
|
||||
stdout = ''
|
||||
exitCode = 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,156 @@ import {
|
|||
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
|
||||
|
|
@ -105,6 +255,19 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||
// 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'))
|
||||
|
|
@ -113,8 +276,35 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||
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())) {
|
||||
|
|
@ -124,15 +314,6 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||
}
|
||||
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
|
||||
if (!settings.ref && !settings.commit) {
|
||||
core.startGroup('Determining the default branch')
|
||||
|
|
@ -154,6 +335,10 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||
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: {
|
||||
|
|
@ -264,7 +449,21 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||
// Checkout submodules
|
||||
core.startGroup('Fetching submodules')
|
||||
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(
|
||||
'git config --local gc.auto 0',
|
||||
settings.nestedSubmodules
|
||||
|
|
@ -373,3 +572,30 @@ async function getGitCommandManager(
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ export interface IGitSourceSettings {
|
|||
*/
|
||||
fetchDepth: number
|
||||
|
||||
/**
|
||||
* Whether fetch-depth was explicitly set by the user
|
||||
*/
|
||||
fetchDepthExplicit: boolean
|
||||
|
||||
/**
|
||||
* Fetch tags, even if fetchDepth > 0 (default: false)
|
||||
*/
|
||||
|
|
@ -59,6 +64,11 @@ export interface IGitSourceSettings {
|
|||
*/
|
||||
showProgress: boolean
|
||||
|
||||
/**
|
||||
* The path to a local directory used as a reference cache for Git clones
|
||||
*/
|
||||
referenceCache: string
|
||||
|
||||
/**
|
||||
* Indicates whether to fetch LFS objects
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -102,7 +102,9 @@ export async function getInputs(): Promise<IGitSourceSettings> {
|
|||
'TRUE'
|
||||
|
||||
// Fetch depth
|
||||
result.fetchDepth = Math.floor(Number(core.getInput('fetch-depth') || '1'))
|
||||
const fetchDepthInput = core.getInput('fetch-depth')
|
||||
result.fetchDepthExplicit = fetchDepthInput !== ''
|
||||
result.fetchDepth = Math.floor(Number(fetchDepthInput || '1'))
|
||||
if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {
|
||||
result.fetchDepth = 0
|
||||
}
|
||||
|
|
@ -161,5 +163,9 @@ export async function getInputs(): Promise<IGitSourceSettings> {
|
|||
result.githubServerUrl = core.getInput('github-server-url')
|
||||
core.debug(`GitHub Host URL = ${result.githubServerUrl}`)
|
||||
|
||||
// Reference Cache
|
||||
result.referenceCache = core.getInput('reference-cache')
|
||||
core.debug(`Reference Cache = ${result.referenceCache}`)
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue