mirror of
https://github.com/actions/checkout.git
synced 2026-06-11 16:43:48 +08:00
Compare commits
9 Commits
v6-beta
...
ed69f3bbdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed69f3bbdd | ||
|
|
9ddd3f4b35 | ||
|
|
0c366fd6a8 | ||
|
|
de0fac2e45 | ||
|
|
064fe7f331 | ||
|
|
8e8c483db8 | ||
|
|
033fa0dc0b | ||
|
|
c2d88d3ecc | ||
|
|
1af3b93b68 |
2
.github/workflows/check-dist.yml
vendored
2
.github/workflows/check-dist.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.1.6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set Node.js 24.x
|
- name: Set Node.js 24.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.1.6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
|
|||||||
2
.github/workflows/licensed.yml
vendored
2
.github/workflows/licensed.yml
vendored
@@ -9,6 +9,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Check licenses
|
name: Check licenses
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.1.6
|
- uses: actions/checkout@v6
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run licensed-check
|
- run: npm run licensed-check
|
||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checking out
|
- name: Checking out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Publish
|
- name: Publish
|
||||||
id: publish
|
id: publish
|
||||||
uses: actions/publish-immutable-action@0.0.3
|
uses: actions/publish-immutable-action@0.0.3
|
||||||
|
|||||||
43
.github/workflows/test.yml
vendored
43
.github/workflows/test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
- uses: actions/checkout@v4.1.6
|
- uses: actions/checkout@v6
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npm run format-check
|
- run: npm run format-check
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Basic checkout
|
# Basic checkout
|
||||||
- name: Checkout basic
|
- name: Checkout basic
|
||||||
@@ -87,6 +87,17 @@ jobs:
|
|||||||
- name: Verify fetch filter
|
- name: Verify fetch filter
|
||||||
run: __test__/verify-fetch-filter.sh
|
run: __test__/verify-fetch-filter.sh
|
||||||
|
|
||||||
|
# Fetch tags
|
||||||
|
- name: Checkout with fetch-tags
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
ref: test-data/v2/basic
|
||||||
|
path: fetch-tags-test
|
||||||
|
fetch-tags: true
|
||||||
|
- name: Verify fetch-tags
|
||||||
|
shell: bash
|
||||||
|
run: __test__/verify-fetch-tags.sh
|
||||||
|
|
||||||
# Sparse checkout
|
# Sparse checkout
|
||||||
- name: Sparse checkout
|
- name: Sparse checkout
|
||||||
uses: ./
|
uses: ./
|
||||||
@@ -165,6 +176,22 @@ jobs:
|
|||||||
- name: Verify submodules recursive
|
- name: Verify submodules recursive
|
||||||
run: __test__/verify-submodules-recursive.sh
|
run: __test__/verify-submodules-recursive.sh
|
||||||
|
|
||||||
|
# Worktree credentials
|
||||||
|
- name: Checkout for worktree test
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
path: worktree-test
|
||||||
|
- name: Verify worktree credentials
|
||||||
|
shell: bash
|
||||||
|
run: __test__/verify-worktree.sh worktree-test worktree-branch
|
||||||
|
|
||||||
|
# Worktree credentials in container step
|
||||||
|
- name: Verify worktree credentials in container step
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: docker://bitnami/git:latest
|
||||||
|
with:
|
||||||
|
args: bash __test__/verify-worktree.sh worktree-test container-worktree-branch
|
||||||
|
|
||||||
# Basic checkout using REST API
|
# Basic checkout using REST API
|
||||||
- name: Remove basic
|
- name: Remove basic
|
||||||
if: runner.os != 'windows'
|
if: runner.os != 'windows'
|
||||||
@@ -202,7 +229,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Basic checkout using git
|
# Basic checkout using git
|
||||||
- name: Checkout basic
|
- name: Checkout basic
|
||||||
@@ -234,7 +261,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Basic checkout using git
|
# Basic checkout using git
|
||||||
- name: Checkout basic
|
- name: Checkout basic
|
||||||
@@ -264,7 +291,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: localClone
|
path: localClone
|
||||||
|
|
||||||
@@ -291,8 +318,8 @@ jobs:
|
|||||||
git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main
|
git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main
|
||||||
|
|
||||||
# needed to make checkout post cleanup succeed
|
# needed to make checkout post cleanup succeed
|
||||||
- name: Fix Checkout v4
|
- name: Fix Checkout v6
|
||||||
uses: actions/checkout@v4.1.6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: localClone
|
path: localClone
|
||||||
|
|
||||||
@@ -301,7 +328,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.1.6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: actions-checkout
|
path: actions-checkout
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/update-main-version.yml
vendored
2
.github/workflows/update-main-version.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
# Note this update workflow can also be used as a rollback tool.
|
# Note this update workflow can also be used as a rollback tool.
|
||||||
# For that reason, it's best to pin `actions/checkout` to a known, stable version
|
# For that reason, it's best to pin `actions/checkout` to a known, stable version
|
||||||
# (typically, about two releases back).
|
# (typically, about two releases back).
|
||||||
- uses: actions/checkout@v4.1.6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Git config
|
- name: Git config
|
||||||
|
|||||||
2
.github/workflows/update-test-ubuntu-git.yml
vendored
2
.github/workflows/update-test-ubuntu-git.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Use `docker/login-action` to log in to GHCR.io.
|
# Use `docker/login-action` to log in to GHCR.io.
|
||||||
# Once published, the packages are scoped to the account defined here.
|
# Once published, the packages are scoped to the account defined here.
|
||||||
|
|||||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,10 +1,25 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## V5.0.0
|
## v6.0.2
|
||||||
|
* Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356
|
||||||
|
|
||||||
|
## v6.0.1
|
||||||
|
* Add worktree support for persist-credentials includeIf by @ericsciple in https://github.com/actions/checkout/pull/2327
|
||||||
|
|
||||||
|
## v6.0.0
|
||||||
|
* Persist creds to a separate file by @ericsciple in https://github.com/actions/checkout/pull/2286
|
||||||
|
* Update README to include Node.js 24 support details and requirements by @salmanmkc in https://github.com/actions/checkout/pull/2248
|
||||||
|
|
||||||
|
## v5.0.1
|
||||||
|
* Port v6 cleanup to v5 by @ericsciple in https://github.com/actions/checkout/pull/2301
|
||||||
|
|
||||||
|
## v5.0.0
|
||||||
* Update actions checkout to use node 24 by @salmanmkc in https://github.com/actions/checkout/pull/2226
|
* Update actions checkout to use node 24 by @salmanmkc in https://github.com/actions/checkout/pull/2226
|
||||||
|
|
||||||
|
## v4.3.1
|
||||||
|
* Port v6 cleanup to v4 by @ericsciple in https://github.com/actions/checkout/pull/2305
|
||||||
|
|
||||||
## V4.3.0
|
## v4.3.0
|
||||||
* docs: update README.md by @motss in https://github.com/actions/checkout/pull/1971
|
* docs: update README.md by @motss in https://github.com/actions/checkout/pull/1971
|
||||||
* Add internal repos for checking out multiple repositories by @mouismail in https://github.com/actions/checkout/pull/1977
|
* Add internal repos for checking out multiple repositories by @mouismail in https://github.com/actions/checkout/pull/1977
|
||||||
* Documentation update - add recommended permissions to Readme by @benwells in https://github.com/actions/checkout/pull/2043
|
* Documentation update - add recommended permissions to Readme by @benwells in https://github.com/actions/checkout/pull/2043
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -1,11 +1,12 @@
|
|||||||
[](https://github.com/actions/checkout/actions/workflows/test.yml)
|
[](https://github.com/actions/checkout/actions/workflows/test.yml)
|
||||||
|
|
||||||
# Checkout v6-beta
|
# Checkout v6
|
||||||
|
|
||||||
## What's new
|
## What's new
|
||||||
|
|
||||||
- Updated `persist-credentials` to store the credentials under `$RUNNER_TEMP` instead of directly in the local git config.
|
- Improved credential security: `persist-credentials` now stores credentials in a separate file under `$RUNNER_TEMP` instead of directly in `.git/config`
|
||||||
- This requires a minimum Actions Runner version of [v2.329.0](https://github.com/actions/runner/releases/tag/v2.329.0) to access the persisted credentials for [Docker container action](https://docs.github.com/en/actions/tutorials/use-containerized-services/create-a-docker-container-action) scenarios.
|
- No workflow changes required — `git fetch`, `git push`, etc. continue to work automatically
|
||||||
|
- Running authenticated git commands from a [Docker container action](https://docs.github.com/actions/sharing-automations/creating-actions/creating-a-docker-container-action) requires Actions Runner [v2.329.0](https://github.com/actions/runner/releases/tag/v2.329.0) or later
|
||||||
|
|
||||||
# Checkout v5
|
# Checkout v5
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
|
|
||||||
<!-- start usage -->
|
<!-- start usage -->
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
# Repository name with owner. For example, actions/checkout
|
# Repository name with owner. For example, actions/checkout
|
||||||
# Default: ${{ github.repository }}
|
# Default: ${{ github.repository }}
|
||||||
@@ -159,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 -->
|
||||||
|
|
||||||
@@ -190,7 +197,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
## Fetch only the root files
|
## Fetch only the root files
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .
|
sparse-checkout: .
|
||||||
```
|
```
|
||||||
@@ -198,7 +205,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
## Fetch only the root files and `.github` and `src` folder
|
## Fetch only the root files and `.github` and `src` folder
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
sparse-checkout: |
|
sparse-checkout: |
|
||||||
.github
|
.github
|
||||||
@@ -208,7 +215,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
## Fetch only a single file
|
## Fetch only a single file
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
sparse-checkout: |
|
sparse-checkout: |
|
||||||
README.md
|
README.md
|
||||||
@@ -218,7 +225,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
## Fetch all history for all tags and branches
|
## Fetch all history for all tags and branches
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
```
|
```
|
||||||
@@ -226,7 +233,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
## Checkout a different branch
|
## Checkout a different branch
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: my-branch
|
ref: my-branch
|
||||||
```
|
```
|
||||||
@@ -234,7 +241,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
## Checkout HEAD^
|
## Checkout HEAD^
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
- run: git checkout HEAD^
|
- run: git checkout HEAD^
|
||||||
@@ -244,12 +251,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: main
|
path: main
|
||||||
|
|
||||||
- name: Checkout tools repo
|
- name: Checkout tools repo
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: my-org/my-tools
|
repository: my-org/my-tools
|
||||||
path: my-tools
|
path: my-tools
|
||||||
@@ -260,10 +267,10 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Checkout tools repo
|
- name: Checkout tools repo
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: my-org/my-tools
|
repository: my-org/my-tools
|
||||||
path: my-tools
|
path: my-tools
|
||||||
@@ -274,12 +281,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: main
|
path: main
|
||||||
|
|
||||||
- name: Checkout private tools
|
- name: Checkout private tools
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: my-org/my-private-tools
|
repository: my-org/my-private-tools
|
||||||
token: ${{ secrets.GH_PAT }} # `GH_PAT` is a secret that contains your PAT
|
token: ${{ secrets.GH_PAT }} # `GH_PAT` is a secret that contains your PAT
|
||||||
@@ -292,7 +299,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
## Checkout pull request HEAD commit instead of merge commit
|
## Checkout pull request HEAD commit instead of merge commit
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
```
|
```
|
||||||
@@ -308,7 +315,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
```
|
```
|
||||||
|
|
||||||
## Push a commit using the built-in token
|
## Push a commit using the built-in token
|
||||||
@@ -319,7 +326,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
- run: |
|
- run: |
|
||||||
date > generated.txt
|
date > generated.txt
|
||||||
# Note: the following account information will not work on GHES
|
# Note: the following account information will not work on GHES
|
||||||
@@ -341,7 +348,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.head_ref }}
|
ref: ${{ github.head_ref }}
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1157,6 +1159,7 @@ async function setup(testName: string): Promise<void> {
|
|||||||
sparseCheckout: [],
|
sparseCheckout: [],
|
||||||
sparseCheckoutConeMode: true,
|
sparseCheckoutConeMode: true,
|
||||||
fetchDepth: 1,
|
fetchDepth: 1,
|
||||||
|
fetchDepthExplicit: false,
|
||||||
fetchTags: false,
|
fetchTags: false,
|
||||||
showProgress: true,
|
showProgress: true,
|
||||||
lfs: false,
|
lfs: false,
|
||||||
@@ -1173,7 +1176,8 @@ async function setup(testName: string): Promise<void> {
|
|||||||
sshUser: '',
|
sshUser: '',
|
||||||
workflowOrganizationId: 123456,
|
workflowOrganizationId: 123456,
|
||||||
setSafeDirectory: true,
|
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/*'
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -108,7 +108,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
jest.restoreAllMocks()
|
jest.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchDepth is 0 and fetchTags is true', async () => {
|
it('should call execGit with the correct arguments when fetchDepth is 0', async () => {
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
const workingDirectory = 'test'
|
const workingDirectory = 'test'
|
||||||
const lfs = false
|
const lfs = false
|
||||||
@@ -122,45 +122,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
const refSpec = ['refspec1', 'refspec2']
|
const refSpec = ['refspec1', 'refspec2']
|
||||||
const options = {
|
const options = {
|
||||||
filter: 'filterValue',
|
filter: 'filterValue',
|
||||||
fetchDepth: 0,
|
fetchDepth: 0
|
||||||
fetchTags: true
|
|
||||||
}
|
|
||||||
|
|
||||||
await git.fetch(refSpec, options)
|
|
||||||
|
|
||||||
expect(mockExec).toHaveBeenCalledWith(
|
|
||||||
expect.any(String),
|
|
||||||
[
|
|
||||||
'-c',
|
|
||||||
'protocol.version=2',
|
|
||||||
'fetch',
|
|
||||||
'--prune',
|
|
||||||
'--no-recurse-submodules',
|
|
||||||
'--filter=filterValue',
|
|
||||||
'origin',
|
|
||||||
'refspec1',
|
|
||||||
'refspec2'
|
|
||||||
],
|
|
||||||
expect.any(Object)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchDepth is 0 and fetchTags is false', async () => {
|
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
|
||||||
|
|
||||||
const workingDirectory = 'test'
|
|
||||||
const lfs = false
|
|
||||||
const doSparseCheckout = false
|
|
||||||
git = await commandManager.createCommandManager(
|
|
||||||
workingDirectory,
|
|
||||||
lfs,
|
|
||||||
doSparseCheckout
|
|
||||||
)
|
|
||||||
const refSpec = ['refspec1', 'refspec2']
|
|
||||||
const options = {
|
|
||||||
filter: 'filterValue',
|
|
||||||
fetchDepth: 0,
|
|
||||||
fetchTags: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.fetch(refSpec, options)
|
await git.fetch(refSpec, options)
|
||||||
@@ -183,7 +145,45 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is false', async () => {
|
it('should call execGit with the correct arguments when fetchDepth is 0 and refSpec includes tags', async () => {
|
||||||
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
|
const workingDirectory = 'test'
|
||||||
|
const lfs = false
|
||||||
|
const doSparseCheckout = false
|
||||||
|
git = await commandManager.createCommandManager(
|
||||||
|
workingDirectory,
|
||||||
|
lfs,
|
||||||
|
doSparseCheckout
|
||||||
|
)
|
||||||
|
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
|
||||||
|
const options = {
|
||||||
|
filter: 'filterValue',
|
||||||
|
fetchDepth: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await git.fetch(refSpec, options)
|
||||||
|
|
||||||
|
expect(mockExec).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.version=2',
|
||||||
|
'fetch',
|
||||||
|
'--no-tags',
|
||||||
|
'--prune',
|
||||||
|
'--no-recurse-submodules',
|
||||||
|
'--filter=filterValue',
|
||||||
|
'origin',
|
||||||
|
'refspec1',
|
||||||
|
'refspec2',
|
||||||
|
'+refs/tags/*:refs/tags/*'
|
||||||
|
],
|
||||||
|
expect.any(Object)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call execGit with the correct arguments when fetchDepth is 1', async () => {
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
const workingDirectory = 'test'
|
const workingDirectory = 'test'
|
||||||
@@ -197,8 +197,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
const refSpec = ['refspec1', 'refspec2']
|
const refSpec = ['refspec1', 'refspec2']
|
||||||
const options = {
|
const options = {
|
||||||
filter: 'filterValue',
|
filter: 'filterValue',
|
||||||
fetchDepth: 1,
|
fetchDepth: 1
|
||||||
fetchTags: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.fetch(refSpec, options)
|
await git.fetch(refSpec, options)
|
||||||
@@ -222,7 +221,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is true', async () => {
|
it('should call execGit with the correct arguments when fetchDepth is 1 and refSpec includes tags', async () => {
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
const workingDirectory = 'test'
|
const workingDirectory = 'test'
|
||||||
@@ -233,11 +232,10 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
lfs,
|
lfs,
|
||||||
doSparseCheckout
|
doSparseCheckout
|
||||||
)
|
)
|
||||||
const refSpec = ['refspec1', 'refspec2']
|
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
|
||||||
const options = {
|
const options = {
|
||||||
filter: 'filterValue',
|
filter: 'filterValue',
|
||||||
fetchDepth: 1,
|
fetchDepth: 1
|
||||||
fetchTags: true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.fetch(refSpec, options)
|
await git.fetch(refSpec, options)
|
||||||
@@ -248,13 +246,15 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
'-c',
|
'-c',
|
||||||
'protocol.version=2',
|
'protocol.version=2',
|
||||||
'fetch',
|
'fetch',
|
||||||
|
'--no-tags',
|
||||||
'--prune',
|
'--prune',
|
||||||
'--no-recurse-submodules',
|
'--no-recurse-submodules',
|
||||||
'--filter=filterValue',
|
'--filter=filterValue',
|
||||||
'--depth=1',
|
'--depth=1',
|
||||||
'origin',
|
'origin',
|
||||||
'refspec1',
|
'refspec1',
|
||||||
'refspec2'
|
'refspec2',
|
||||||
|
'+refs/tags/*:refs/tags/*'
|
||||||
],
|
],
|
||||||
expect.any(Object)
|
expect.any(Object)
|
||||||
)
|
)
|
||||||
@@ -338,7 +338,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchTags is true and showProgress is true', async () => {
|
it('should call execGit with the correct arguments when showProgress is true and refSpec includes tags', async () => {
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
const workingDirectory = 'test'
|
const workingDirectory = 'test'
|
||||||
@@ -349,10 +349,9 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
lfs,
|
lfs,
|
||||||
doSparseCheckout
|
doSparseCheckout
|
||||||
)
|
)
|
||||||
const refSpec = ['refspec1', 'refspec2']
|
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
|
||||||
const options = {
|
const options = {
|
||||||
filter: 'filterValue',
|
filter: 'filterValue',
|
||||||
fetchTags: true,
|
|
||||||
showProgress: true
|
showProgress: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,15 +363,134 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
'-c',
|
'-c',
|
||||||
'protocol.version=2',
|
'protocol.version=2',
|
||||||
'fetch',
|
'fetch',
|
||||||
|
'--no-tags',
|
||||||
'--prune',
|
'--prune',
|
||||||
'--no-recurse-submodules',
|
'--no-recurse-submodules',
|
||||||
'--progress',
|
'--progress',
|
||||||
'--filter=filterValue',
|
'--filter=filterValue',
|
||||||
'origin',
|
'origin',
|
||||||
'refspec1',
|
'refspec1',
|
||||||
'refspec2'
|
'refspec2',
|
||||||
|
'+refs/tags/*:refs/tags/*'
|
||||||
],
|
],
|
||||||
expect.any(Object)
|
expect.any(Object)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('git user-agent with orchestration ID', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
|
||||||
|
jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
// Clean up environment variable to prevent test pollution
|
||||||
|
delete process.env['ACTIONS_ORCHESTRATION_ID']
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include orchestration ID in user-agent when ACTIONS_ORCHESTRATION_ID is set', async () => {
|
||||||
|
const orchId = 'test-orch-id-12345'
|
||||||
|
process.env['ACTIONS_ORCHESTRATION_ID'] = orchId
|
||||||
|
|
||||||
|
let capturedEnv: any = null
|
||||||
|
mockExec.mockImplementation((path, args, options) => {
|
||||||
|
if (args.includes('version')) {
|
||||||
|
options.listeners.stdout(Buffer.from('2.18'))
|
||||||
|
}
|
||||||
|
// Capture env on any command
|
||||||
|
capturedEnv = options.env
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
|
const workingDirectory = 'test'
|
||||||
|
const lfs = false
|
||||||
|
const doSparseCheckout = false
|
||||||
|
git = await commandManager.createCommandManager(
|
||||||
|
workingDirectory,
|
||||||
|
lfs,
|
||||||
|
doSparseCheckout
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call a git command to trigger env capture after user-agent is set
|
||||||
|
await git.init()
|
||||||
|
|
||||||
|
// Verify the user agent includes the orchestration ID
|
||||||
|
expect(git).toBeDefined()
|
||||||
|
expect(capturedEnv).toBeDefined()
|
||||||
|
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
|
||||||
|
`git/2.18 (github-actions-checkout) actions_orchestration_id/${orchId}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sanitize invalid characters in orchestration ID', async () => {
|
||||||
|
const orchId = 'test (with) special/chars'
|
||||||
|
process.env['ACTIONS_ORCHESTRATION_ID'] = orchId
|
||||||
|
|
||||||
|
let capturedEnv: any = null
|
||||||
|
mockExec.mockImplementation((path, args, options) => {
|
||||||
|
if (args.includes('version')) {
|
||||||
|
options.listeners.stdout(Buffer.from('2.18'))
|
||||||
|
}
|
||||||
|
// Capture env on any command
|
||||||
|
capturedEnv = options.env
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
|
const workingDirectory = 'test'
|
||||||
|
const lfs = false
|
||||||
|
const doSparseCheckout = false
|
||||||
|
git = await commandManager.createCommandManager(
|
||||||
|
workingDirectory,
|
||||||
|
lfs,
|
||||||
|
doSparseCheckout
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call a git command to trigger env capture after user-agent is set
|
||||||
|
await git.init()
|
||||||
|
|
||||||
|
// Verify the user agent has sanitized orchestration ID (spaces, parentheses, slash replaced)
|
||||||
|
expect(git).toBeDefined()
|
||||||
|
expect(capturedEnv).toBeDefined()
|
||||||
|
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
|
||||||
|
'git/2.18 (github-actions-checkout) actions_orchestration_id/test__with__special_chars'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not modify user-agent when ACTIONS_ORCHESTRATION_ID is not set', async () => {
|
||||||
|
delete process.env['ACTIONS_ORCHESTRATION_ID']
|
||||||
|
|
||||||
|
let capturedEnv: any = null
|
||||||
|
mockExec.mockImplementation((path, args, options) => {
|
||||||
|
if (args.includes('version')) {
|
||||||
|
options.listeners.stdout(Buffer.from('2.18'))
|
||||||
|
}
|
||||||
|
// Capture env on any command
|
||||||
|
capturedEnv = options.env
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
|
const workingDirectory = 'test'
|
||||||
|
const lfs = false
|
||||||
|
const doSparseCheckout = false
|
||||||
|
git = await commandManager.createCommandManager(
|
||||||
|
workingDirectory,
|
||||||
|
lfs,
|
||||||
|
doSparseCheckout
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call a git command to trigger env capture after user-agent is set
|
||||||
|
await git.init()
|
||||||
|
|
||||||
|
// Verify the user agent does NOT contain orchestration ID
|
||||||
|
expect(git).toBeDefined()
|
||||||
|
expect(capturedEnv).toBeDefined()
|
||||||
|
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
|
||||||
|
'git/2.18 (github-actions-checkout)'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
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.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 () => {
|
||||||
|
|||||||
@@ -152,7 +152,22 @@ describe('ref-helper tests', () => {
|
|||||||
it('getRefSpec sha + refs/tags/', async () => {
|
it('getRefSpec sha + refs/tags/', async () => {
|
||||||
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit)
|
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit)
|
||||||
expect(refSpec.length).toBe(1)
|
expect(refSpec.length).toBe(1)
|
||||||
expect(refSpec[0]).toBe(`+${commit}:refs/tags/my-tag`)
|
expect(refSpec[0]).toBe(`+refs/tags/my-tag:refs/tags/my-tag`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRefSpec sha + refs/tags/ with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, only include tags wildcard (specific tag is redundant)
|
||||||
|
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit, true)
|
||||||
|
expect(refSpec.length).toBe(1)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRefSpec sha + refs/heads/ with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, include both the branch refspec and tags wildcard
|
||||||
|
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', commit, true)
|
||||||
|
expect(refSpec.length).toBe(2)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
expect(refSpec[1]).toBe(`+${commit}:refs/remotes/origin/my/branch`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('getRefSpec sha only', async () => {
|
it('getRefSpec sha only', async () => {
|
||||||
@@ -168,6 +183,14 @@ describe('ref-helper tests', () => {
|
|||||||
expect(refSpec[1]).toBe('+refs/tags/my-ref*:refs/tags/my-ref*')
|
expect(refSpec[1]).toBe('+refs/tags/my-ref*:refs/tags/my-ref*')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('getRefSpec unqualified ref only with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, skip specific tag pattern since wildcard covers all
|
||||||
|
const refSpec = refHelper.getRefSpec('my-ref', '', true)
|
||||||
|
expect(refSpec.length).toBe(2)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
expect(refSpec[1]).toBe('+refs/heads/my-ref*:refs/remotes/origin/my-ref*')
|
||||||
|
})
|
||||||
|
|
||||||
it('getRefSpec refs/heads/ only', async () => {
|
it('getRefSpec refs/heads/ only', async () => {
|
||||||
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '')
|
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '')
|
||||||
expect(refSpec.length).toBe(1)
|
expect(refSpec.length).toBe(1)
|
||||||
@@ -187,4 +210,21 @@ describe('ref-helper tests', () => {
|
|||||||
expect(refSpec.length).toBe(1)
|
expect(refSpec.length).toBe(1)
|
||||||
expect(refSpec[0]).toBe('+refs/tags/my-tag:refs/tags/my-tag')
|
expect(refSpec[0]).toBe('+refs/tags/my-tag:refs/tags/my-tag')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('getRefSpec refs/tags/ only with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, only include tags wildcard (specific tag is redundant)
|
||||||
|
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', '', true)
|
||||||
|
expect(refSpec.length).toBe(1)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRefSpec refs/heads/ only with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, include both the branch refspec and tags wildcard
|
||||||
|
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '', true)
|
||||||
|
expect(refSpec.length).toBe(2)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
expect(refSpec[1]).toBe(
|
||||||
|
'+refs/heads/my/branch:refs/remotes/origin/my/branch'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
9
__test__/verify-fetch-tags.sh
Executable file
9
__test__/verify-fetch-tags.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Verify tags were fetched
|
||||||
|
TAG_COUNT=$(git -C ./fetch-tags-test tag | wc -l)
|
||||||
|
if [ "$TAG_COUNT" -eq 0 ]; then
|
||||||
|
echo "Expected tags to be fetched, but found none"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found $TAG_COUNT tags"
|
||||||
51
__test__/verify-worktree.sh
Executable file
51
__test__/verify-worktree.sh
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Verify worktree credentials
|
||||||
|
# This test verifies that git credentials work in worktrees created after checkout
|
||||||
|
# Usage: verify-worktree.sh <checkout-path> <worktree-name>
|
||||||
|
|
||||||
|
CHECKOUT_PATH="$1"
|
||||||
|
WORKTREE_NAME="$2"
|
||||||
|
|
||||||
|
if [ -z "$CHECKOUT_PATH" ] || [ -z "$WORKTREE_NAME" ]; then
|
||||||
|
echo "Usage: verify-worktree.sh <checkout-path> <worktree-name>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$CHECKOUT_PATH"
|
||||||
|
|
||||||
|
# Add safe directory for container environments
|
||||||
|
git config --global --add safe.directory "*" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Show the includeIf configuration
|
||||||
|
echo "Git config includeIf entries:"
|
||||||
|
git config --list --show-origin | grep -i include || true
|
||||||
|
|
||||||
|
# Create the worktree
|
||||||
|
echo "Creating worktree..."
|
||||||
|
git worktree add "../$WORKTREE_NAME" HEAD --detach
|
||||||
|
|
||||||
|
# Change to worktree directory
|
||||||
|
cd "../$WORKTREE_NAME"
|
||||||
|
|
||||||
|
# Verify we're in a worktree
|
||||||
|
echo "Verifying worktree gitdir:"
|
||||||
|
cat .git
|
||||||
|
|
||||||
|
# Verify credentials are available in worktree by checking extraheader is configured
|
||||||
|
echo "Checking credentials in worktree..."
|
||||||
|
if git config --list --show-origin | grep -q "extraheader"; then
|
||||||
|
echo "Credentials are configured in worktree"
|
||||||
|
else
|
||||||
|
echo "ERROR: Credentials are NOT configured in worktree"
|
||||||
|
echo "Full git config:"
|
||||||
|
git config --list --show-origin
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify fetch works in the worktree
|
||||||
|
echo "Fetching in worktree..."
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
echo "Worktree credentials test passed!"
|
||||||
@@ -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'
|
||||||
|
|||||||
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).
|
||||||
2558
dist/index.js
vendored
2558
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": {
|
"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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -374,6 +381,10 @@ class GitAuthHelper {
|
|||||||
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
|
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
|
||||||
await this.git.config(hostIncludeKey, credentialsConfigPath)
|
await this.git.config(hostIncludeKey, credentialsConfigPath)
|
||||||
|
|
||||||
|
// Configure host includeIf for worktrees
|
||||||
|
const hostWorktreeIncludeKey = `includeIf.gitdir:${gitDir}/worktrees/*.path`
|
||||||
|
await this.git.config(hostWorktreeIncludeKey, credentialsConfigPath)
|
||||||
|
|
||||||
// Container git directory
|
// Container git directory
|
||||||
const workingDirectory = this.git.getWorkingDirectory()
|
const workingDirectory = this.git.getWorkingDirectory()
|
||||||
const githubWorkspace = process.env['GITHUB_WORKSPACE']
|
const githubWorkspace = process.env['GITHUB_WORKSPACE']
|
||||||
@@ -395,6 +406,13 @@ class GitAuthHelper {
|
|||||||
// Configure container includeIf
|
// Configure container includeIf
|
||||||
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
|
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
|
||||||
await this.git.config(containerIncludeKey, containerCredentialsPath)
|
await this.git.config(containerIncludeKey, containerCredentialsPath)
|
||||||
|
|
||||||
|
// Configure container includeIf for worktrees
|
||||||
|
const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path`
|
||||||
|
await this.git.config(
|
||||||
|
containerWorktreeIncludeKey,
|
||||||
|
containerCredentialsPath
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 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>
|
||||||
@@ -37,7 +42,6 @@ export interface IGitCommandManager {
|
|||||||
options: {
|
options: {
|
||||||
filter?: string
|
filter?: string
|
||||||
fetchDepth?: number
|
fetchDepth?: number
|
||||||
fetchTags?: boolean
|
|
||||||
showProgress?: boolean
|
showProgress?: boolean
|
||||||
}
|
}
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
@@ -49,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>
|
||||||
@@ -81,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(
|
||||||
@@ -280,14 +291,13 @@ class GitCommandManager {
|
|||||||
options: {
|
options: {
|
||||||
filter?: string
|
filter?: string
|
||||||
fetchDepth?: number
|
fetchDepth?: number
|
||||||
fetchTags?: boolean
|
|
||||||
showProgress?: boolean
|
showProgress?: boolean
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const args = ['-c', 'protocol.version=2', 'fetch']
|
const args = ['-c', 'protocol.version=2', 'fetch']
|
||||||
if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) {
|
// Always use --no-tags for explicit control over tag fetching
|
||||||
args.push('--no-tags')
|
// Tags are fetched explicitly via refspec when needed
|
||||||
}
|
args.push('--no-tags')
|
||||||
|
|
||||||
args.push('--prune', '--no-recurse-submodules')
|
args.push('--prune', '--no-recurse-submodules')
|
||||||
if (options.showProgress) {
|
if (options.showProgress) {
|
||||||
@@ -403,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]
|
||||||
}
|
}
|
||||||
@@ -611,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,
|
||||||
@@ -730,13 +766,21 @@ class GitCommandManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Set the user agent
|
// Set the user agent
|
||||||
const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
|
let gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
|
||||||
|
|
||||||
|
// Append orchestration ID if set
|
||||||
|
const orchId = process.env['ACTIONS_ORCHESTRATION_ID']
|
||||||
|
if (orchId) {
|
||||||
|
// Sanitize the orchestration ID to ensure it contains only valid characters
|
||||||
|
// Valid characters: 0-9, a-z, _, -, .
|
||||||
|
const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_')
|
||||||
|
if (sanitizedId) {
|
||||||
|
gitHttpUserAgent = `${gitHttpUserAgent} actions_orchestration_id/${sanitizedId}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
|
core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
|
||||||
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
|
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GitOutput {
|
|
||||||
stdout = ''
|
|
||||||
exitCode = 0
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -154,12 +335,15 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||||||
await git.lfsInstall()
|
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
|
// Fetch
|
||||||
core.startGroup('Fetching the repository')
|
core.startGroup('Fetching the repository')
|
||||||
const fetchOptions: {
|
const fetchOptions: {
|
||||||
filter?: string
|
filter?: string
|
||||||
fetchDepth?: number
|
fetchDepth?: number
|
||||||
fetchTags?: boolean
|
|
||||||
showProgress?: boolean
|
showProgress?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
@@ -182,12 +366,35 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||||||
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
||||||
refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
||||||
await git.fetch(refSpec, fetchOptions)
|
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 {
|
} else {
|
||||||
fetchOptions.fetchDepth = settings.fetchDepth
|
fetchOptions.fetchDepth = settings.fetchDepth
|
||||||
fetchOptions.fetchTags = settings.fetchTags
|
const refSpec = refHelper.getRefSpec(
|
||||||
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
settings.ref,
|
||||||
|
settings.commit,
|
||||||
|
settings.fetchTags
|
||||||
|
)
|
||||||
await git.fetch(refSpec, fetchOptions)
|
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()
|
core.endGroup()
|
||||||
|
|
||||||
@@ -242,7 +449,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
|
||||||
@@ -351,3 +572,30 @@ async function getGitCommandManager(
|
|||||||
return undefined
|
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
|
fetchDepth: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether fetch-depth was explicitly set by the user
|
||||||
|
*/
|
||||||
|
fetchDepthExplicit: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch tags, even if fetchDepth > 0 (default: false)
|
* Fetch tags, even if fetchDepth > 0 (default: false)
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +64,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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export async function getInputs(): Promise<IGitSourceSettings> {
|
|||||||
'TRUE'
|
'TRUE'
|
||||||
|
|
||||||
// Fetch depth
|
// 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) {
|
if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {
|
||||||
result.fetchDepth = 0
|
result.fetchDepth = 0
|
||||||
}
|
}
|
||||||
@@ -161,5 +163,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ function updateUsage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateUsage(
|
updateUsage(
|
||||||
'actions/checkout@v5',
|
'actions/checkout@v6',
|
||||||
path.join(__dirname, '..', '..', 'action.yml'),
|
path.join(__dirname, '..', '..', 'action.yml'),
|
||||||
path.join(__dirname, '..', '..', 'README.md')
|
path.join(__dirname, '..', '..', 'README.md')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,55 +76,75 @@ export function getRefSpecForAllHistory(ref: string, commit: string): string[] {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRefSpec(ref: string, commit: string): string[] {
|
export function getRefSpec(
|
||||||
|
ref: string,
|
||||||
|
commit: string,
|
||||||
|
fetchTags?: boolean
|
||||||
|
): string[] {
|
||||||
if (!ref && !commit) {
|
if (!ref && !commit) {
|
||||||
throw new Error('Args ref and commit cannot both be empty')
|
throw new Error('Args ref and commit cannot both be empty')
|
||||||
}
|
}
|
||||||
|
|
||||||
const upperRef = (ref || '').toUpperCase()
|
const upperRef = (ref || '').toUpperCase()
|
||||||
|
const result: string[] = []
|
||||||
|
|
||||||
|
// When fetchTags is true, always include the tags refspec
|
||||||
|
if (fetchTags) {
|
||||||
|
result.push(tagsRefSpec)
|
||||||
|
}
|
||||||
|
|
||||||
// SHA
|
// SHA
|
||||||
if (commit) {
|
if (commit) {
|
||||||
// refs/heads
|
// refs/heads
|
||||||
if (upperRef.startsWith('REFS/HEADS/')) {
|
if (upperRef.startsWith('REFS/HEADS/')) {
|
||||||
const branch = ref.substring('refs/heads/'.length)
|
const branch = ref.substring('refs/heads/'.length)
|
||||||
return [`+${commit}:refs/remotes/origin/${branch}`]
|
result.push(`+${commit}:refs/remotes/origin/${branch}`)
|
||||||
}
|
}
|
||||||
// refs/pull/
|
// refs/pull/
|
||||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||||
const branch = ref.substring('refs/pull/'.length)
|
const branch = ref.substring('refs/pull/'.length)
|
||||||
return [`+${commit}:refs/remotes/pull/${branch}`]
|
result.push(`+${commit}:refs/remotes/pull/${branch}`)
|
||||||
}
|
}
|
||||||
// refs/tags/
|
// refs/tags/
|
||||||
else if (upperRef.startsWith('REFS/TAGS/')) {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
return [`+${commit}:${ref}`]
|
if (!fetchTags) {
|
||||||
|
result.push(`+${ref}:${ref}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Otherwise no destination ref
|
// Otherwise no destination ref
|
||||||
else {
|
else {
|
||||||
return [commit]
|
result.push(commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Unqualified ref, check for a matching branch or tag
|
// Unqualified ref, check for a matching branch or tag
|
||||||
else if (!upperRef.startsWith('REFS/')) {
|
else if (!upperRef.startsWith('REFS/')) {
|
||||||
return [
|
result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`)
|
||||||
`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
|
if (!fetchTags) {
|
||||||
`+refs/tags/${ref}*:refs/tags/${ref}*`
|
result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`)
|
||||||
]
|
}
|
||||||
}
|
}
|
||||||
// refs/heads/
|
// refs/heads/
|
||||||
else if (upperRef.startsWith('REFS/HEADS/')) {
|
else if (upperRef.startsWith('REFS/HEADS/')) {
|
||||||
const branch = ref.substring('refs/heads/'.length)
|
const branch = ref.substring('refs/heads/'.length)
|
||||||
return [`+${ref}:refs/remotes/origin/${branch}`]
|
result.push(`+${ref}:refs/remotes/origin/${branch}`)
|
||||||
}
|
}
|
||||||
// refs/pull/
|
// refs/pull/
|
||||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||||
const branch = ref.substring('refs/pull/'.length)
|
const branch = ref.substring('refs/pull/'.length)
|
||||||
return [`+${ref}:refs/remotes/pull/${branch}`]
|
result.push(`+${ref}:refs/remotes/pull/${branch}`)
|
||||||
}
|
}
|
||||||
// refs/tags/
|
// refs/tags/
|
||||||
else {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
return [`+${ref}:${ref}`]
|
if (!fetchTags) {
|
||||||
|
result.push(`+${ref}:${ref}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Other refs
|
||||||
|
else {
|
||||||
|
result.push(`+${ref}:${ref}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,8 +190,10 @@ export async function testRef(
|
|||||||
// refs/tags/
|
// refs/tags/
|
||||||
else if (upperRef.startsWith('REFS/TAGS/')) {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
const tagName = ref.substring('refs/tags/'.length)
|
const tagName = ref.substring('refs/tags/'.length)
|
||||||
|
// Use ^{commit} to dereference annotated tags to their underlying commit
|
||||||
return (
|
return (
|
||||||
(await git.tagExists(tagName)) && commit === (await git.revParse(ref))
|
(await git.tagExists(tagName)) &&
|
||||||
|
commit === (await git.revParse(`${ref}^{commit}`))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Unexpected
|
// Unexpected
|
||||||
|
|||||||
Reference in New Issue
Block a user