1
0
mirror of https://github.com/actions/checkout.git synced 2026-07-02 18:33:48 +08:00

Compare commits

..

1 Commits

Author SHA1 Message Date
eric sciple
34e114876b Cleanup actions/checkout@v6 auth style (#2305) 2025-11-13 17:20:02 -06:00
18 changed files with 731 additions and 517 deletions

View File

@@ -24,10 +24,10 @@ jobs:
steps:
- uses: actions/checkout@v4.1.6
- name: Set Node.js 24.x
- name: Set Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.x
- name: Install dependencies
run: npm ci

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.x
- uses: actions/checkout@v4.1.6
- run: npm ci
- run: npm run build

View File

@@ -11,7 +11,6 @@ on:
type: choice
description: The major version to update
options:
- v5
- v4
- v3
- v2

View File

@@ -1,9 +1,5 @@
# Changelog
## V5.0.0
* Update actions checkout to use node 24 by @salmanmkc in https://github.com/actions/checkout/pull/2226
## V4.3.0
* 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

View File

@@ -1,13 +1,5 @@
[![Build and Test](https://github.com/actions/checkout/actions/workflows/test.yml/badge.svg)](https://github.com/actions/checkout/actions/workflows/test.yml)
# Checkout V5
## What's new
- Updated to the node24 runtime
- This requires a minimum Actions Runner version of [v2.327.1](https://github.com/actions/runner/releases/tag/v2.327.1) to run.
# Checkout V4
This action checks-out your repository under `$GITHUB_WORKSPACE`, so your workflow can access it.
@@ -44,7 +36,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
<!-- start usage -->
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
# Repository name with owner. For example, actions/checkout
# Default: ${{ github.repository }}
@@ -104,12 +96,6 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
# Default: true
clean: ''
# Whether to preserve local changes during checkout. If true, tries to preserve
# local files that are not tracked by Git. By default, all files will be
# overwritten.
# Default: false
preserve-local-changes: ''
# Partially clone against a given filter. Overrides sparse-checkout if set.
# Default: null
filter: ''
@@ -163,33 +149,24 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
# Scenarios
- [Checkout V5](#checkout-v5)
- [What's new](#whats-new)
- [Checkout V4](#checkout-v4)
- [Note](#note)
- [What's new](#whats-new-1)
- [Usage](#usage)
- [Scenarios](#scenarios)
- [Fetch only the root files](#fetch-only-the-root-files)
- [Fetch only the root files and `.github` and `src` folder](#fetch-only-the-root-files-and-github-and-src-folder)
- [Fetch only a single file](#fetch-only-a-single-file)
- [Fetch all history for all tags and branches](#fetch-all-history-for-all-tags-and-branches)
- [Checkout a different branch](#checkout-a-different-branch)
- [Checkout HEAD^](#checkout-head)
- [Checkout multiple repos (side by side)](#checkout-multiple-repos-side-by-side)
- [Checkout multiple repos (nested)](#checkout-multiple-repos-nested)
- [Checkout multiple repos (private)](#checkout-multiple-repos-private)
- [Checkout pull request HEAD commit instead of merge commit](#checkout-pull-request-head-commit-instead-of-merge-commit)
- [Checkout pull request on closed event](#checkout-pull-request-on-closed-event)
- [Push a commit using the built-in token](#push-a-commit-using-the-built-in-token)
- [Push a commit to a PR using the built-in token](#push-a-commit-to-a-pr-using-the-built-in-token)
- [Recommended permissions](#recommended-permissions)
- [License](#license)
- [Fetch only the root files](#Fetch-only-the-root-files)
- [Fetch only the root files and `.github` and `src` folder](#Fetch-only-the-root-files-and-github-and-src-folder)
- [Fetch only a single file](#Fetch-only-a-single-file)
- [Fetch all history for all tags and branches](#Fetch-all-history-for-all-tags-and-branches)
- [Checkout a different branch](#Checkout-a-different-branch)
- [Checkout HEAD^](#Checkout-HEAD)
- [Checkout multiple repos (side by side)](#Checkout-multiple-repos-side-by-side)
- [Checkout multiple repos (nested)](#Checkout-multiple-repos-nested)
- [Checkout multiple repos (private)](#Checkout-multiple-repos-private)
- [Checkout pull request HEAD commit instead of merge commit](#Checkout-pull-request-HEAD-commit-instead-of-merge-commit)
- [Checkout pull request on closed event](#Checkout-pull-request-on-closed-event)
- [Push a commit using the built-in token](#Push-a-commit-using-the-built-in-token)
- [Push a commit to a PR using the built-in token](#Push-a-commit-to-a-PR-using-the-built-in-token)
## Fetch only the root files
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
sparse-checkout: .
```
@@ -197,7 +174,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only the root files and `.github` and `src` folder
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
sparse-checkout: |
.github
@@ -207,7 +184,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch only a single file
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
sparse-checkout: |
README.md
@@ -217,7 +194,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Fetch all history for all tags and branches
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 0
```
@@ -225,7 +202,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout a different branch
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
ref: my-branch
```
@@ -233,7 +210,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout HEAD^
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 2
- run: git checkout HEAD^
@@ -243,12 +220,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
path: main
- name: Checkout tools repo
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: my-org/my-tools
path: my-tools
@@ -259,10 +236,10 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Checkout tools repo
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: my-org/my-tools
path: my-tools
@@ -273,12 +250,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
```yaml
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
path: main
- name: Checkout private tools
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: my-org/my-private-tools
token: ${{ secrets.GH_PAT }} # `GH_PAT` is a secret that contains your PAT
@@ -291,7 +268,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
## Checkout pull request HEAD commit instead of merge commit
```yaml
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
```
@@ -307,7 +284,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
```
## Push a commit using the built-in token
@@ -318,7 +295,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- run: |
date > generated.txt
# Note: the following account information will not work on GHES
@@ -340,7 +317,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- run: |
@@ -355,21 +332,6 @@ jobs:
*NOTE:* The user email is `{user.id}+{user.login}@users.noreply.github.com`. See users API: https://api.github.com/users/github-actions%5Bbot%5D
## Preserve local changes during checkout
```yaml
steps:
- name: Create file before checkout
shell: pwsh
run: New-Item -Path . -Name "example.txt" -ItemType "File"
- name: Checkout with preserving local changes
uses: actions/checkout@v5
with:
clean: false
preserve-local-changes: true
```
# Recommended permissions
When using the `checkout` action in your GitHub Actions workflow, it is recommended to set the following `GITHUB_TOKEN` permissions to ensure proper functionality, unless alternative auth is provided via the `token` or `ssh-key` inputs:

View File

@@ -675,6 +675,283 @@ describe('git-auth-helper tests', () => {
expect(gitConfigContent.indexOf('http.')).toBeLessThan(0)
})
const removeAuth_removesV6StyleCredentials =
'removeAuth removes v6 style credentials'
it(removeAuth_removesV6StyleCredentials, async () => {
// Arrange
await setup(removeAuth_removesV6StyleCredentials)
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
// Manually create v6-style credentials that would be left by v6
const credentialsFileName =
'git-credentials-12345678-1234-1234-1234-123456789abc.config'
const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
const basicCredential = Buffer.from(
`x-access-token:${settings.authToken}`,
'utf8'
).toString('base64')
const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
await fs.promises.writeFile(credentialsFilePath, credentialsContent)
// Add includeIf entries to local git config (simulating v6 configuration)
const hostGitDir = path.join(workspace, '.git').replace(/\\/g, '/')
await fs.promises.appendFile(
localGitConfigPath,
`[includeIf "gitdir:${hostGitDir}/"]\n\tpath = ${credentialsFilePath}\n`
)
await fs.promises.appendFile(
localGitConfigPath,
`[includeIf "gitdir:/github/workspace/.git/"]\n\tpath = /github/runner_temp/${credentialsFileName}\n`
)
// Verify v6 style config exists
let gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
expect(
gitConfigContent.indexOf(credentialsFilePath)
).toBeGreaterThanOrEqual(0)
await fs.promises.stat(credentialsFilePath) // Verify file exists
// Mock the git methods to handle v6 cleanup
const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
mockTryGetConfigKeys.mockResolvedValue([
`includeIf.gitdir:${hostGitDir}/.path`,
'includeIf.gitdir:/github/workspace/.git/.path'
])
const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
mockTryGetConfigValues.mockImplementation(async (key: string) => {
if (key === `includeIf.gitdir:${hostGitDir}/.path`) {
return [credentialsFilePath]
}
if (key === 'includeIf.gitdir:/github/workspace/.git/.path') {
return [`/github/runner_temp/${credentialsFileName}`]
}
return []
})
const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
any,
any
>
mockTryConfigUnsetValue.mockImplementation(
async (
key: string,
value: string,
globalConfig?: boolean,
configPath?: string
) => {
const targetPath = configPath || localGitConfigPath
let content = await fs.promises.readFile(targetPath, 'utf8')
// Remove the includeIf section
const lines = content
.split('\n')
.filter(line => !line.includes('includeIf') && !line.includes(value))
await fs.promises.writeFile(targetPath, lines.join('\n'))
return true
}
)
// Act
await authHelper.removeAuth()
// Assert includeIf entries removed from local git config
gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('includeIf')).toBeLessThan(0)
expect(gitConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
// Assert credentials config file deleted
try {
await fs.promises.stat(credentialsFilePath)
throw new Error('Credentials file should have been deleted')
} catch (err) {
if ((err as any)?.code !== 'ENOENT') {
throw err
}
}
})
const removeAuth_removesV6StyleCredentialsFromSubmodules =
'removeAuth removes v6 style credentials from submodules'
it(removeAuth_removesV6StyleCredentialsFromSubmodules, async () => {
// Arrange
await setup(removeAuth_removesV6StyleCredentialsFromSubmodules)
// Create fake submodule config paths
const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1')
const submodule1ConfigPath = path.join(submodule1Dir, 'config')
await fs.promises.mkdir(submodule1Dir, {recursive: true})
await fs.promises.writeFile(submodule1ConfigPath, '')
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
// Create v6-style credentials file
const credentialsFileName =
'git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config'
const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
const basicCredential = Buffer.from(
`x-access-token:${settings.authToken}`,
'utf8'
).toString('base64')
const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n`
await fs.promises.writeFile(credentialsFilePath, credentialsContent)
// Add includeIf entries to submodule config
const submodule1GitDir = submodule1Dir.replace(/\\/g, '/')
await fs.promises.appendFile(
submodule1ConfigPath,
`[includeIf "gitdir:${submodule1GitDir}/"]\n\tpath = ${credentialsFilePath}\n`
)
// Verify submodule config has includeIf entry
let submoduleConfigContent = (
await fs.promises.readFile(submodule1ConfigPath)
).toString()
expect(submoduleConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(
0
)
expect(
submoduleConfigContent.indexOf(credentialsFilePath)
).toBeGreaterThanOrEqual(0)
// Mock getSubmoduleConfigPaths
const mockGetSubmoduleConfigPaths =
git.getSubmoduleConfigPaths as jest.Mock<any, any>
mockGetSubmoduleConfigPaths.mockResolvedValue([submodule1ConfigPath])
// Mock tryGetConfigKeys for submodule
const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
mockTryGetConfigKeys.mockImplementation(
async (pattern: string, globalConfig?: boolean, configPath?: string) => {
if (configPath === submodule1ConfigPath) {
return [`includeIf.gitdir:${submodule1GitDir}/.path`]
}
return []
}
)
// Mock tryGetConfigValues for submodule
const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
mockTryGetConfigValues.mockImplementation(
async (key: string, globalConfig?: boolean, configPath?: string) => {
if (
configPath === submodule1ConfigPath &&
key === `includeIf.gitdir:${submodule1GitDir}/.path`
) {
return [credentialsFilePath]
}
return []
}
)
// Mock tryConfigUnsetValue for submodule
const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock<
any,
any
>
mockTryConfigUnsetValue.mockImplementation(
async (
key: string,
value: string,
globalConfig?: boolean,
configPath?: string
) => {
const targetPath = configPath || localGitConfigPath
let content = await fs.promises.readFile(targetPath, 'utf8')
const lines = content
.split('\n')
.filter(line => !line.includes('includeIf') && !line.includes(value))
await fs.promises.writeFile(targetPath, lines.join('\n'))
return true
}
)
// Act
await authHelper.removeAuth()
// Assert submodule includeIf entries removed
submoduleConfigContent = (
await fs.promises.readFile(submodule1ConfigPath)
).toString()
expect(submoduleConfigContent.indexOf('includeIf')).toBeLessThan(0)
expect(submoduleConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
// Assert credentials file deleted
try {
await fs.promises.stat(credentialsFilePath)
throw new Error('Credentials file should have been deleted')
} catch (err) {
if ((err as any)?.code !== 'ENOENT') {
throw err
}
}
})
const removeAuth_skipsV6CleanupWhenEnvVarSet =
'removeAuth skips v6 cleanup when ACTIONS_CHECKOUT_SKIP_V6_CLEANUP is set'
it(removeAuth_skipsV6CleanupWhenEnvVarSet, async () => {
// Arrange
await setup(removeAuth_skipsV6CleanupWhenEnvVarSet)
// Set the skip environment variable
process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'] = '1'
const authHelper = gitAuthHelper.createAuthHelper(git, settings)
await authHelper.configureAuth()
// Create v6-style credentials file in RUNNER_TEMP
const credentialsFileName = 'git-credentials-test-uuid-1234-5678.config'
const credentialsFilePath = path.join(runnerTemp, credentialsFileName)
const credentialsContent =
'[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic token\n'
await fs.promises.writeFile(credentialsFilePath, credentialsContent)
// Add includeIf section to local git config (separate from http.* config)
const includeIfSection = `\n[includeIf "gitdir:/some/path/.git/"]\n\tpath = ${credentialsFilePath}\n`
await fs.promises.appendFile(localGitConfigPath, includeIfSection)
// Verify v6 style config exists
let gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
await fs.promises.stat(credentialsFilePath) // Verify file exists
// Act
await authHelper.removeAuth()
// Assert v5 cleanup still happened (http.* removed)
gitConfigContent = (
await fs.promises.readFile(localGitConfigPath)
).toString()
expect(
gitConfigContent.indexOf('http.https://github.com/.extraheader')
).toBeLessThan(0)
// Assert v6 cleanup was skipped - includeIf should still be present
expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0)
expect(
gitConfigContent.indexOf(credentialsFilePath)
).toBeGreaterThanOrEqual(0)
// Assert credentials file still exists (wasn't deleted)
await fs.promises.stat(credentialsFilePath) // File should still exist
// Assert debug message was logged
expect(core.debug).toHaveBeenCalledWith(
'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
)
// Cleanup
delete process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
})
const removeGlobalConfig_removesOverride =
'removeGlobalConfig removes override'
it(removeGlobalConfig_removesOverride, async () => {
@@ -796,6 +1073,18 @@ async function setup(testName: string): Promise<void> {
),
tryDisableAutomaticGarbageCollection: jest.fn(),
tryGetFetchUrl: jest.fn(),
getSubmoduleConfigPaths: jest.fn(async () => {
return []
}),
tryConfigUnsetValue: jest.fn(async () => {
return true
}),
tryGetConfigValues: jest.fn(async () => {
return []
}),
tryGetConfigKeys: jest.fn(async () => {
return []
}),
tryReset: jest.fn(),
version: jest.fn()
}
@@ -814,7 +1103,6 @@ async function setup(testName: string): Promise<void> {
submodules: false,
nestedSubmodules: false,
persistCredentials: true,
preserveLocalChanges: false,
ref: 'refs/heads/main',
repositoryName: 'my-repo',
repositoryOwner: 'my-org',

View File

@@ -143,41 +143,12 @@ describe('git-directory-helper tests', () => {
repositoryPath,
repositoryUrl,
clean,
ref,
false // preserveLocalChanges = false
ref
)
// Assert
const files = await fs.promises.readdir(repositoryPath)
expect(files).toEqual(['.git']) // Expect just the .git directory to remain
expect(git.tryClean).toHaveBeenCalled()
expect(core.warning).toHaveBeenCalled()
expect(git.tryReset).not.toHaveBeenCalled()
})
const preservesContentsWhenCleanFailsAndPreserveLocalChanges = 'preserves contents when clean fails and preserve-local-changes is true'
it(preservesContentsWhenCleanFailsAndPreserveLocalChanges, async () => {
// Arrange
await setup(preservesContentsWhenCleanFailsAndPreserveLocalChanges)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
let mockTryClean = git.tryClean as jest.Mock<any, any>
mockTryClean.mockImplementation(async () => {
return false
})
// Act
await gitDirectoryHelper.prepareExistingDirectory(
git,
repositoryPath,
repositoryUrl,
clean,
ref,
true // preserveLocalChanges = true
)
// Assert
const files = await fs.promises.readdir(repositoryPath)
expect(files.sort()).toEqual(['.git', 'my-file']) // Expect both .git and user files to remain
expect(files).toHaveLength(0)
expect(git.tryClean).toHaveBeenCalled()
expect(core.warning).toHaveBeenCalled()
expect(git.tryReset).not.toHaveBeenCalled()
@@ -199,50 +170,16 @@ describe('git-directory-helper tests', () => {
repositoryPath,
differentRepositoryUrl,
clean,
ref,
false // preserveLocalChanges = false
ref
)
// Assert
const files = await fs.promises.readdir(repositoryPath)
expect(files).toEqual(['.git']) // Expect just the .git directory to remain
expect(files).toHaveLength(0)
expect(core.warning).not.toHaveBeenCalled()
expect(git.isDetached).not.toHaveBeenCalled()
})
const keepsContentsWhenDifferentRepositoryUrlAndPreserveLocalChanges =
'keeps contents when different repository url and preserve-local-changes is true'
it(keepsContentsWhenDifferentRepositoryUrlAndPreserveLocalChanges, async () => {
// Arrange
await setup(keepsContentsWhenDifferentRepositoryUrlAndPreserveLocalChanges)
clean = false
// Create a file that we expect to be preserved
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
// Simulate a different repository by simply removing the .git directory
await io.rmRF(path.join(repositoryPath, '.git'))
await fs.promises.mkdir(path.join(repositoryPath, '.git'))
const differentRepositoryUrl = 'https://github.com/my-different-org/my-different-repo'
// Act
await gitDirectoryHelper.prepareExistingDirectory(
git,
repositoryPath,
differentRepositoryUrl, // Use a different URL
clean,
ref,
true // preserveLocalChanges = true
)
// Assert
const files = await fs.promises.readdir(repositoryPath)
console.log('Files after operation:', files)
// When preserveLocalChanges is true, files should be preserved even with different repo URL
expect(files.sort()).toEqual(['.git', 'my-file'].sort())
})
const removesContentsWhenNoGitDirectory =
'removes contents when no git directory'
it(removesContentsWhenNoGitDirectory, async () => {
@@ -284,41 +221,12 @@ describe('git-directory-helper tests', () => {
repositoryPath,
repositoryUrl,
clean,
ref,
false // preserveLocalChanges = false
ref
)
// Assert
const files = await fs.promises.readdir(repositoryPath)
expect(files).toEqual(['.git']) // Expect just the .git directory to remain
expect(git.tryClean).toHaveBeenCalled()
expect(git.tryReset).toHaveBeenCalled()
expect(core.warning).toHaveBeenCalled()
})
const preservesContentsWhenResetFailsAndPreserveLocalChanges = 'preserves contents when reset fails and preserve-local-changes is true'
it(preservesContentsWhenResetFailsAndPreserveLocalChanges, async () => {
// Arrange
await setup(preservesContentsWhenResetFailsAndPreserveLocalChanges)
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
let mockTryReset = git.tryReset as jest.Mock<any, any>
mockTryReset.mockImplementation(async () => {
return false
})
// Act
await gitDirectoryHelper.prepareExistingDirectory(
git,
repositoryPath,
repositoryUrl,
clean,
ref,
true // preserveLocalChanges = true
)
// Assert
const files = await fs.promises.readdir(repositoryPath)
expect(files.sort()).toEqual(['.git', 'my-file']) // Expect both .git and user files to remain
expect(files).toHaveLength(0)
expect(git.tryClean).toHaveBeenCalled()
expect(git.tryReset).toHaveBeenCalled()
expect(core.warning).toHaveBeenCalled()
@@ -338,13 +246,12 @@ describe('git-directory-helper tests', () => {
repositoryPath,
repositoryUrl,
clean,
ref,
false // preserveLocalChanges = false
ref
)
// Assert
const files = await fs.promises.readdir(repositoryPath)
expect(files).toEqual(['.git']) // Expect just the .git directory to remain
expect(files).toHaveLength(0)
expect(core.warning).not.toHaveBeenCalled()
})
@@ -395,13 +302,12 @@ describe('git-directory-helper tests', () => {
repositoryPath,
repositoryUrl,
clean,
ref,
false // preserveLocalChanges = false
ref
)
// Assert
const files = await fs.promises.readdir(repositoryPath)
expect(files).toEqual(['.git']) // Expect just the .git directory to remain
expect(files).toHaveLength(0)
expect(git.tryClean).toHaveBeenCalled()
})
@@ -593,6 +499,18 @@ async function setup(testName: string): Promise<void> {
await fs.promises.stat(path.join(repositoryPath, '.git'))
return repositoryUrl
}),
getSubmoduleConfigPaths: jest.fn(async () => {
return []
}),
tryConfigUnsetValue: jest.fn(async () => {
return true
}),
tryGetConfigValues: jest.fn(async () => {
return []
}),
tryGetConfigKeys: jest.fn(async () => {
return []
}),
tryReset: jest.fn(async () => {
return true
}),

View File

@@ -56,10 +56,7 @@ inputs:
description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
clean:
description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching'
default: 'true'
preserve-local-changes:
description: 'Whether to preserve local changes during checkout. If true, tries to preserve local files that are not tracked by Git. By default, all files will be overwritten.'
default: 'false'
default: true
filter:
description: >
Partially clone against a given filter.
@@ -107,6 +104,6 @@ outputs:
commit:
description: 'The commit SHA that was checked out'
runs:
using: node24
using: node20
main: dist/index.js
post: dist/index.js

295
dist/index.js vendored
View File

@@ -411,8 +411,50 @@ class GitAuthHelper {
}
removeToken() {
return __awaiter(this, void 0, void 0, function* () {
// HTTP extra header
// Remove HTTP extra header from local git config and submodule configs
yield this.removeGitConfig(this.tokenConfigKey);
//
// Cleanup actions/checkout@v6 style credentials
//
const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'];
if (skipV6Cleanup === '1' || (skipV6Cleanup === null || skipV6Cleanup === void 0 ? void 0 : skipV6Cleanup.toLowerCase()) === 'true') {
core.debug('Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP');
return;
}
try {
// Collect credentials config paths that need to be removed
const credentialsPaths = new Set();
// Remove includeIf entries that point to git-credentials-*.config files
const mainCredentialsPaths = yield this.removeIncludeIfCredentials();
mainCredentialsPaths.forEach(path => credentialsPaths.add(path));
// Remove submodule includeIf entries that point to git-credentials-*.config files
try {
const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true);
for (const configPath of submoduleConfigPaths) {
const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath);
submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path));
}
}
catch (err) {
core.debug(`Unable to get submodule config paths: ${err}`);
}
// Remove credentials config files
for (const credentialsPath of credentialsPaths) {
// Only remove credentials config files if they are under RUNNER_TEMP
const runnerTemp = process.env['RUNNER_TEMP'];
if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
try {
yield io.rmRF(credentialsPath);
}
catch (err) {
core.debug(`Failed to remove credentials config '${credentialsPath}': ${err}`);
}
}
}
}
catch (err) {
core.debug(`Failed to cleanup v6 style credentials: ${err}`);
}
});
}
removeGitConfig(configKey_1) {
@@ -430,6 +472,49 @@ class GitAuthHelper {
`sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true);
});
}
/**
* Removes includeIf entries that point to git-credentials-*.config files.
* This handles cleanup of credentials configured by newer versions of the action.
* @param configPath Optional path to a specific git config file to operate on
* @returns Array of unique credentials config file paths that were found and removed
*/
removeIncludeIfCredentials(configPath) {
return __awaiter(this, void 0, void 0, function* () {
const credentialsPaths = new Set();
try {
// Get all includeIf.gitdir keys
const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig?
configPath);
for (const key of keys) {
// Get all values for this key
const values = yield this.git.tryGetConfigValues(key, false, // globalConfig?
configPath);
if (values.length > 0) {
// Remove only values that match git-credentials-<uuid>.config pattern
for (const value of values) {
if (this.testCredentialsConfigPath(value)) {
credentialsPaths.add(value);
yield this.git.tryConfigUnsetValue(key, value, false, configPath);
}
}
}
}
}
catch (err) {
// Ignore errors - this is cleanup code
core.debug(`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`);
}
return Array.from(credentialsPaths);
});
}
/**
* Tests if a path matches the git-credentials-*.config pattern used by newer versions.
* @param path The path to test
* @returns True if the path matches the credentials config pattern
*/
testCredentialsConfigPath(path) {
return /git-credentials-[0-9a-f-]+\.config$/i.test(path);
}
}
@@ -609,17 +694,9 @@ class GitCommandManager {
yield fs.promises.appendFile(sparseCheckoutPath, `\n${sparseCheckout.join('\n')}\n`);
});
}
checkout(ref_1, startPoint_1) {
return __awaiter(this, arguments, void 0, function* (ref, startPoint, options = []) {
const args = ['checkout', '--progress'];
// Add custom options (like --merge) if provided
if (options.length > 0) {
args.push(...options);
}
else {
// Default behavior - use force
args.push('--force');
}
checkout(ref, startPoint) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['checkout', '--progress', '--force'];
if (startPoint) {
args.push('-B', ref, startPoint);
}
@@ -714,6 +791,16 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch');
});
}
getSubmoduleConfigPaths(recursive) {
return __awaiter(this, void 0, void 0, function* () {
// Get submodule config file paths.
// Use `--show-origin` to get the config file path for each submodule.
const output = yield this.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, recursive);
// Extract config file paths from the output (lines starting with "file:").
const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [];
return configPaths;
});
}
getWorkingDirectory() {
return this.workingDirectory;
}
@@ -844,6 +931,20 @@ class GitCommandManager {
return output.exitCode === 0;
});
}
tryConfigUnsetValue(configKey, configValue, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--unset', configKey, configValue);
const output = yield this.execGit(args, true);
return output.exitCode === 0;
});
}
tryDisableAutomaticGarbageCollection() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true);
@@ -863,6 +964,46 @@ class GitCommandManager {
return stdout;
});
}
tryGetConfigValues(configKey, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--get-all', configKey);
const output = yield this.execGit(args, true);
if (output.exitCode !== 0) {
return [];
}
return output.stdout
.trim()
.split('\n')
.filter(value => value.trim());
});
}
tryGetConfigKeys(pattern, globalConfig, configFile) {
return __awaiter(this, void 0, void 0, function* () {
const args = ['config'];
if (configFile) {
args.push('--file', configFile);
}
else {
args.push(globalConfig ? '--global' : '--local');
}
args.push('--name-only', '--get-regexp', pattern);
const output = yield this.execGit(args, true);
if (output.exitCode !== 0) {
return [];
}
return output.stdout
.trim()
.split('\n')
.filter(key => key.trim());
});
}
tryReset() {
return __awaiter(this, void 0, void 0, function* () {
const output = yield this.execGit(['reset', '--hard', 'HEAD'], true);
@@ -1033,17 +1174,13 @@ const fs = __importStar(__nccwpck_require__(7147));
const fsHelper = __importStar(__nccwpck_require__(7219));
const io = __importStar(__nccwpck_require__(7436));
const path = __importStar(__nccwpck_require__(1017));
function prepareExistingDirectory(git_1, repositoryPath_1, repositoryUrl_1, clean_1, ref_1) {
return __awaiter(this, arguments, void 0, function* (git, repositoryPath, repositoryUrl, clean, ref, preserveLocalChanges = false) {
function prepareExistingDirectory(git, repositoryPath, repositoryUrl, clean, ref) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
assert.ok(repositoryPath, 'Expected repositoryPath to be defined');
assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined');
// Indicates whether to delete the directory contents
let remove = false;
// If preserveLocalChanges is true, log it
if (preserveLocalChanges) {
core.info(`Preserve local changes is enabled, will attempt to keep local files`);
}
// Check whether using git or REST API
if (!git) {
remove = true;
@@ -1124,28 +1261,14 @@ function prepareExistingDirectory(git_1, repositoryPath_1, repositoryUrl_1, clea
remove = true;
}
}
if (remove && !preserveLocalChanges) {
if (remove) {
// Delete the contents of the directory. Don't delete the directory itself
// since it might be the current working directory.
core.info(`Deleting the contents of '${repositoryPath}'`);
for (const file of yield fs.promises.readdir(repositoryPath)) {
// Skip .git directory as we need it to determine if a file is tracked
if (file === '.git') {
continue;
}
yield io.rmRF(path.join(repositoryPath, file));
}
}
else if (remove && preserveLocalChanges) {
core.info(`Skipping deletion of directory contents due to preserve-local-changes setting`);
// We still need to make sure we have a git repository to work with
if (!git) {
core.info(`Initializing git repository to prepare for checkout with preserved changes`);
yield fs.promises.mkdir(path.join(repositoryPath, '.git'), {
recursive: true
});
}
}
});
}
@@ -1242,7 +1365,7 @@ function getSource(settings) {
}
// Prepare existing directory, otherwise recreate
if (isExisting) {
yield gitDirectoryHelper.prepareExistingDirectory(git, settings.repositoryPath, repositoryUrl, settings.clean, settings.ref, settings.preserveLocalChanges);
yield gitDirectoryHelper.prepareExistingDirectory(git, settings.repositoryPath, repositoryUrl, settings.clean, settings.ref);
}
if (!git) {
// Downloading using REST API
@@ -1355,104 +1478,7 @@ function getSource(settings) {
}
// Checkout
core.startGroup('Checking out the ref');
if (settings.preserveLocalChanges) {
core.info('Attempting to preserve local changes during checkout');
// List and store local files before checkout
const fs = __nccwpck_require__(7147);
const path = __nccwpck_require__(1017);
const localFiles = new Map();
try {
// Get all files in the workspace that aren't in the .git directory
const workspacePath = process.cwd();
core.info(`Current workspace path: ${workspacePath}`);
// List all files in the current directory using fs
const listFilesRecursively = (dir) => {
let results = [];
const list = fs.readdirSync(dir);
list.forEach((file) => {
const fullPath = path.join(dir, file);
const relativePath = path.relative(workspacePath, fullPath);
// Skip .git directory
if (relativePath.startsWith('.git'))
return;
const stat = fs.statSync(fullPath);
if (stat && stat.isDirectory()) {
// Recursively explore subdirectories
results = results.concat(listFilesRecursively(fullPath));
}
else {
// Store file content in memory
try {
const content = fs.readFileSync(fullPath);
localFiles.set(relativePath, content);
results.push(relativePath);
}
catch (readErr) {
core.warning(`Failed to read file ${relativePath}: ${readErr}`);
}
}
});
return results;
};
const localFilesList = listFilesRecursively(workspacePath);
core.info(`Found ${localFilesList.length} local files to preserve:`);
localFilesList.forEach(file => core.info(` - ${file}`));
}
catch (error) {
core.warning(`Failed to list local files: ${error}`);
}
// Perform normal checkout
yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint);
// Restore local files that were not tracked by git
core.info('Restoring local files after checkout');
try {
let restoredCount = 0;
const execOptions = {
cwd: process.cwd(),
silent: true,
ignoreReturnCode: true
};
for (const [filePath, content] of localFiles.entries()) {
// Check if file exists in git using a child process instead of git.execGit
const { exec } = __nccwpck_require__(1514);
let exitCode = 0;
const output = {
stdout: '',
stderr: ''
};
// Capture output
const options = Object.assign(Object.assign({}, execOptions), { listeners: {
stdout: (data) => {
output.stdout += data.toString();
},
stderr: (data) => {
output.stderr += data.toString();
}
} });
exitCode = yield exec('git', ['ls-files', '--error-unmatch', filePath], options);
if (exitCode !== 0) {
// File is not tracked by git, safe to restore
const fullPath = path.join(process.cwd(), filePath);
// Ensure directory exists
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
core.info(`Restored local file: ${filePath}`);
restoredCount++;
}
else {
core.info(`Skipping ${filePath} as it's tracked by git`);
}
}
core.info(`Successfully restored ${restoredCount} local files`);
}
catch (error) {
core.warning(`Failed to restore local files: ${error}`);
}
}
else {
// Use the default behavior with --force
yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint);
}
yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint);
core.endGroup();
// Submodules
if (settings.submodules) {
@@ -1889,11 +1915,6 @@ function getInputs() {
// Clean
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE';
core.debug(`clean = ${result.clean}`);
// Preserve local changes
result.preserveLocalChanges =
(core.getInput('preserve-local-changes') || 'false').toUpperCase() ===
'TRUE';
core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`);
// Filter
const filter = core.getInput('filter');
if (filter) {

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "checkout",
"version": "5.0.0",
"version": "4.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "checkout",
"version": "5.0.0",
"version": "4.3.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.10.1",
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "^24.1.0",
"@types/node": "^20.12.12",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",
@@ -1515,12 +1515,12 @@
"dev": true
},
"node_modules/@types/node": {
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"dev": true,
"dependencies": {
"undici-types": "~7.8.0"
"undici-types": "~5.26.4"
}
},
"node_modules/@types/stack-utils": {
@@ -6865,9 +6865,9 @@
}
},
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/universal-user-agent": {

View File

@@ -1,6 +1,6 @@
{
"name": "checkout",
"version": "5.0.0",
"version": "4.3.0",
"description": "checkout action",
"main": "lib/main.js",
"scripts": {
@@ -37,7 +37,7 @@
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "^24.1.0",
"@types/node": "^20.12.12",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.9.0",
"@typescript-eslint/parser": "^7.9.0",

View File

@@ -346,8 +346,58 @@ class GitAuthHelper {
}
private async removeToken(): Promise<void> {
// HTTP extra header
// Remove HTTP extra header from local git config and submodule configs
await this.removeGitConfig(this.tokenConfigKey)
//
// Cleanup actions/checkout@v6 style credentials
//
const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']
if (skipV6Cleanup === '1' || skipV6Cleanup?.toLowerCase() === 'true') {
core.debug(
'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'
)
return
}
try {
// Collect credentials config paths that need to be removed
const credentialsPaths = new Set<string>()
// Remove includeIf entries that point to git-credentials-*.config files
const mainCredentialsPaths = await this.removeIncludeIfCredentials()
mainCredentialsPaths.forEach(path => credentialsPaths.add(path))
// Remove submodule includeIf entries that point to git-credentials-*.config files
try {
const submoduleConfigPaths =
await this.git.getSubmoduleConfigPaths(true)
for (const configPath of submoduleConfigPaths) {
const submoduleCredentialsPaths =
await this.removeIncludeIfCredentials(configPath)
submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
}
} catch (err) {
core.debug(`Unable to get submodule config paths: ${err}`)
}
// Remove credentials config files
for (const credentialsPath of credentialsPaths) {
// Only remove credentials config files if they are under RUNNER_TEMP
const runnerTemp = process.env['RUNNER_TEMP']
if (runnerTemp && credentialsPath.startsWith(runnerTemp)) {
try {
await io.rmRF(credentialsPath)
} catch (err) {
core.debug(
`Failed to remove credentials config '${credentialsPath}': ${err}`
)
}
}
}
} catch (err) {
core.debug(`Failed to cleanup v6 style credentials: ${err}`)
}
}
private async removeGitConfig(
@@ -371,4 +421,59 @@ class GitAuthHelper {
true
)
}
/**
* Removes includeIf entries that point to git-credentials-*.config files.
* This handles cleanup of credentials configured by newer versions of the action.
* @param configPath Optional path to a specific git config file to operate on
* @returns Array of unique credentials config file paths that were found and removed
*/
private async removeIncludeIfCredentials(
configPath?: string
): Promise<string[]> {
const credentialsPaths = new Set<string>()
try {
// Get all includeIf.gitdir keys
const keys = await this.git.tryGetConfigKeys(
'^includeIf\\.gitdir:',
false, // globalConfig?
configPath
)
for (const key of keys) {
// Get all values for this key
const values = await this.git.tryGetConfigValues(
key,
false, // globalConfig?
configPath
)
if (values.length > 0) {
// Remove only values that match git-credentials-<uuid>.config pattern
for (const value of values) {
if (this.testCredentialsConfigPath(value)) {
credentialsPaths.add(value)
await this.git.tryConfigUnsetValue(key, value, false, configPath)
}
}
}
}
} catch (err) {
// Ignore errors - this is cleanup code
core.debug(
`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`
)
}
return Array.from(credentialsPaths)
}
/**
* Tests if a path matches the git-credentials-*.config pattern used by newer versions.
* @param path The path to test
* @returns True if the path matches the credentials config pattern
*/
private testCredentialsConfigPath(path: string): boolean {
return /git-credentials-[0-9a-f-]+\.config$/i.test(path)
}
}

View File

@@ -22,7 +22,7 @@ export interface IGitCommandManager {
disableSparseCheckout(): Promise<void>
sparseCheckout(sparseCheckout: string[]): Promise<void>
sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void>
checkout(ref: string, startPoint: string, options?: string[]): Promise<void>
checkout(ref: string, startPoint: string): Promise<void>
checkoutDetach(): Promise<void>
config(
configKey: string,
@@ -41,6 +41,7 @@ export interface IGitCommandManager {
}
): Promise<void>
getDefaultBranch(repositoryUrl: string): Promise<string>
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string
init(): Promise<void>
isDetached(): Promise<boolean>
@@ -59,8 +60,24 @@ export interface IGitCommandManager {
tagExists(pattern: string): Promise<boolean>
tryClean(): Promise<boolean>
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean,
configFile?: string
): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]>
tryGetConfigKeys(
pattern: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]>
tryReset(): Promise<boolean>
version(): Promise<GitVersion>
}
@@ -203,21 +220,8 @@ class GitCommandManager {
)
}
async checkout(
ref: string,
startPoint: string,
options: string[] = []
): Promise<void> {
const args = ['checkout', '--progress']
// Add custom options (like --merge) if provided
if (options.length > 0) {
args.push(...options)
} else {
// Default behavior - use force
args.push('--force')
}
async checkout(ref: string, startPoint: string): Promise<void> {
const args = ['checkout', '--progress', '--force']
if (startPoint) {
args.push('-B', ref, startPoint)
} else {
@@ -336,6 +340,21 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch')
}
async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
// Get submodule config file paths.
// Use `--show-origin` to get the config file path for each submodule.
const output = await this.submoduleForeach(
`git config --local --show-origin --name-only --get-regexp remote.origin.url`,
recursive
)
// Extract config file paths from the output (lines starting with "file:").
const configPaths =
output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
return configPaths
}
getWorkingDirectory(): string {
return this.workingDirectory
}
@@ -468,6 +487,24 @@ class GitCommandManager {
return output.exitCode === 0
}
async tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean,
configFile?: string
): Promise<boolean> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--unset', configKey, configValue)
const output = await this.execGit(args, true)
return output.exitCode === 0
}
async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
const output = await this.execGit(
['config', '--local', 'gc.auto', '0'],
@@ -494,6 +531,56 @@ class GitCommandManager {
return stdout
}
async tryGetConfigValues(
configKey: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--get-all', configKey)
const output = await this.execGit(args, true)
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(value => value.trim())
}
async tryGetConfigKeys(
pattern: string,
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const args = ['config']
if (configFile) {
args.push('--file', configFile)
} else {
args.push(globalConfig ? '--global' : '--local')
}
args.push('--name-only', '--get-regexp', pattern)
const output = await this.execGit(args, true)
if (output.exitCode !== 0) {
return []
}
return output.stdout
.trim()
.split('\n')
.filter(key => key.trim())
}
async tryReset(): Promise<boolean> {
const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
return output.exitCode === 0

View File

@@ -11,8 +11,7 @@ export async function prepareExistingDirectory(
repositoryPath: string,
repositoryUrl: string,
clean: boolean,
ref: string,
preserveLocalChanges: boolean = false
ref: string
): Promise<void> {
assert.ok(repositoryPath, 'Expected repositoryPath to be defined')
assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined')
@@ -20,13 +19,6 @@ export async function prepareExistingDirectory(
// Indicates whether to delete the directory contents
let remove = false
// If preserveLocalChanges is true, log it
if (preserveLocalChanges) {
core.info(
`Preserve local changes is enabled, will attempt to keep local files`
)
}
// Check whether using git or REST API
if (!git) {
remove = true
@@ -122,43 +114,12 @@ export async function prepareExistingDirectory(
}
}
// Check repository conditions
let isLocalGitRepo = git && fsHelper.directoryExistsSync(path.join(repositoryPath, '.git'));
let repoUrl = isLocalGitRepo ? await git?.tryGetFetchUrl() : '';
let isSameRepository = repositoryUrl === repoUrl;
let differentRepoUrl = !isSameRepository;
// Repository URL has changed
if (differentRepoUrl) {
if (preserveLocalChanges) {
core.warning(`Repository URL has changed from '${repoUrl}' to '${repositoryUrl}'. Local changes will be preserved as requested.`);
}
remove = true; // Mark for removal, but actual removal will respect preserveLocalChanges
}
if (remove && !preserveLocalChanges) {
if (remove) {
// Delete the contents of the directory. Don't delete the directory itself
// since it might be the current working directory.
core.info(`Deleting the contents of '${repositoryPath}'`)
for (const file of await fs.promises.readdir(repositoryPath)) {
// Skip .git directory as we need it to determine if a file is tracked
if (file === '.git') {
continue
}
await io.rmRF(path.join(repositoryPath, file))
}
} else if (remove && preserveLocalChanges) {
core.info(
`Skipping deletion of directory contents due to preserve-local-changes setting`
)
// We still need to make sure we have a git repository to work with
if (!git) {
core.info(
`Initializing git repository to prepare for checkout with preserved changes`
)
await fs.promises.mkdir(path.join(repositoryPath, '.git'), {
recursive: true
})
}
}
}

View File

@@ -70,8 +70,7 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
settings.repositoryPath,
repositoryUrl,
settings.clean,
settings.ref,
settings.preserveLocalChanges
settings.ref
)
}
@@ -230,115 +229,7 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
// Checkout
core.startGroup('Checking out the ref')
if (settings.preserveLocalChanges) {
core.info('Attempting to preserve local changes during checkout')
// List and store local files before checkout
const fs = require('fs')
const path = require('path')
const localFiles = new Map()
try {
// Get all files in the workspace that aren't in the .git directory
const workspacePath = process.cwd()
core.info(`Current workspace path: ${workspacePath}`)
// List all files in the current directory using fs
const listFilesRecursively = (dir: string): string[] => {
let results: string[] = []
const list = fs.readdirSync(dir)
list.forEach((file: string) => {
const fullPath = path.join(dir, file)
const relativePath = path.relative(workspacePath, fullPath)
// Skip .git directory
if (relativePath.startsWith('.git')) return
const stat = fs.statSync(fullPath)
if (stat && stat.isDirectory()) {
// Recursively explore subdirectories
results = results.concat(listFilesRecursively(fullPath))
} else {
// Store file content in memory
try {
const content = fs.readFileSync(fullPath)
localFiles.set(relativePath, content)
results.push(relativePath)
} catch (readErr) {
core.warning(`Failed to read file ${relativePath}: ${readErr}`)
}
}
})
return results
}
const localFilesList = listFilesRecursively(workspacePath)
core.info(`Found ${localFilesList.length} local files to preserve:`)
localFilesList.forEach(file => core.info(` - ${file}`))
} catch (error) {
core.warning(`Failed to list local files: ${error}`)
}
// Perform normal checkout
await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint)
// Restore local files that were not tracked by git
core.info('Restoring local files after checkout')
try {
let restoredCount = 0
const execOptions = {
cwd: process.cwd(),
silent: true,
ignoreReturnCode: true
}
for (const [filePath, content] of localFiles.entries()) {
// Check if file exists in git using a child process instead of git.execGit
const {exec} = require('@actions/exec')
let exitCode = 0
const output = {
stdout: '',
stderr: ''
}
// Capture output
const options = {
...execOptions,
listeners: {
stdout: (data: Buffer) => {
output.stdout += data.toString()
},
stderr: (data: Buffer) => {
output.stderr += data.toString()
}
}
}
exitCode = await exec(
'git',
['ls-files', '--error-unmatch', filePath],
options
)
if (exitCode !== 0) {
// File is not tracked by git, safe to restore
const fullPath = path.join(process.cwd(), filePath)
// Ensure directory exists
fs.mkdirSync(path.dirname(fullPath), {recursive: true})
fs.writeFileSync(fullPath, content)
core.info(`Restored local file: ${filePath}`)
restoredCount++
} else {
core.info(`Skipping ${filePath} as it's tracked by git`)
}
}
core.info(`Successfully restored ${restoredCount} local files`)
} catch (error) {
core.warning(`Failed to restore local files: ${error}`)
}
} else {
// Use the default behavior with --force
await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint)
}
await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint)
core.endGroup()
// Submodules

View File

@@ -25,15 +25,10 @@ export interface IGitSourceSettings {
commit: string
/**
* Whether to execute git clean and git reset before fetching
* Indicates whether to clean the repository
*/
clean: boolean
/**
* Whether to preserve local changes during checkout
*/
preserveLocalChanges: boolean
/**
* The filter determining which objects to include
*/

View File

@@ -82,12 +82,6 @@ export async function getInputs(): Promise<IGitSourceSettings> {
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'
core.debug(`clean = ${result.clean}`)
// Preserve local changes
result.preserveLocalChanges =
(core.getInput('preserve-local-changes') || 'false').toUpperCase() ===
'TRUE'
core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`)
// Filter
const filter = core.getInput('filter')
if (filter) {

View File

@@ -120,7 +120,7 @@ function updateUsage(
}
updateUsage(
'actions/checkout@v5',
'actions/checkout@v4',
path.join(__dirname, '..', '..', 'action.yml'),
path.join(__dirname, '..', '..', 'README.md')
)