mirror of
https://github.com/actions/checkout.git
synced 2026-07-02 18:33:48 +08:00
Compare commits
1 Commits
6452e1970f
...
v5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cb6efe18 |
30
README.md
30
README.md
@@ -2,11 +2,7 @@
|
||||
|
||||
# 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 v5 now supports Node.js 24
|
||||
|
||||
# Checkout V4
|
||||
|
||||
@@ -104,12 +100,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: ''
|
||||
@@ -164,10 +154,9 @@ 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)
|
||||
- [What's new](#whats-new)
|
||||
- [Usage](#usage)
|
||||
- [Scenarios](#scenarios)
|
||||
- [Fetch only the root files](#fetch-only-the-root-files)
|
||||
@@ -355,21 +344,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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
295
dist/index.js
vendored
295
dist/index.js
vendored
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user