mirror of
https://github.com/actions/checkout.git
synced 2026-03-07 08:51:46 +08:00
Compare commits
12 Commits
27f4b6ff54
...
6117f65b39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6117f65b39 | ||
|
|
0c366fd6a8 | ||
|
|
de0fac2e45 | ||
|
|
064fe7f331 | ||
|
|
45abae3e9f | ||
|
|
215f9562a1 | ||
|
|
caa5717450 | ||
|
|
630cdb3874 | ||
|
|
6503dcd44c | ||
|
|
ebd82bae91 | ||
|
|
f04b821901 | ||
|
|
45fe6460ed |
11
.github/workflows/test.yml
vendored
11
.github/workflows/test.yml
vendored
@ -87,6 +87,17 @@ jobs:
|
|||||||
- name: Verify fetch filter
|
- name: Verify fetch filter
|
||||||
run: __test__/verify-fetch-filter.sh
|
run: __test__/verify-fetch-filter.sh
|
||||||
|
|
||||||
|
# Fetch tags
|
||||||
|
- name: Checkout with fetch-tags
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
ref: test-data/v2/basic
|
||||||
|
path: fetch-tags-test
|
||||||
|
fetch-tags: true
|
||||||
|
- name: Verify fetch-tags
|
||||||
|
shell: bash
|
||||||
|
run: __test__/verify-fetch-tags.sh
|
||||||
|
|
||||||
# Sparse checkout
|
# Sparse checkout
|
||||||
- name: Sparse checkout
|
- name: Sparse checkout
|
||||||
uses: ./
|
uses: ./
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v6.0.2
|
||||||
|
* Fix tag handling: preserve annotations and explicit fetch-tags by @ericsciple in https://github.com/actions/checkout/pull/2356
|
||||||
|
|
||||||
|
## v6.0.1
|
||||||
|
* Add worktree support for persist-credentials includeIf by @ericsciple in https://github.com/actions/checkout/pull/2327
|
||||||
|
|
||||||
## v6.0.0
|
## v6.0.0
|
||||||
* Persist creds to a separate file by @ericsciple in https://github.com/actions/checkout/pull/2286
|
* Persist creds to a separate file by @ericsciple in https://github.com/actions/checkout/pull/2286
|
||||||
* Update README to include Node.js 24 support details and requirements by @salmanmkc in https://github.com/actions/checkout/pull/2248
|
* Update README to include Node.js 24 support details and requirements by @salmanmkc in https://github.com/actions/checkout/pull/2248
|
||||||
|
|||||||
21
README.md
21
README.md
@ -112,6 +112,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||||||
# Default: true
|
# Default: true
|
||||||
clean: ''
|
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.
|
# Partially clone against a given filter. Overrides sparse-checkout if set.
|
||||||
# Default: null
|
# Default: null
|
||||||
filter: ''
|
filter: ''
|
||||||
@ -357,6 +363,21 @@ 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
|
*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
|
# 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:
|
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:
|
||||||
|
|||||||
@ -1163,6 +1163,7 @@ async function setup(testName: string): Promise<void> {
|
|||||||
submodules: false,
|
submodules: false,
|
||||||
nestedSubmodules: false,
|
nestedSubmodules: false,
|
||||||
persistCredentials: true,
|
persistCredentials: true,
|
||||||
|
preserveLocalChanges: false,
|
||||||
ref: 'refs/heads/main',
|
ref: 'refs/heads/main',
|
||||||
repositoryName: 'my-repo',
|
repositoryName: 'my-repo',
|
||||||
repositoryOwner: 'my-org',
|
repositoryOwner: 'my-org',
|
||||||
|
|||||||
@ -108,7 +108,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
jest.restoreAllMocks()
|
jest.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchDepth is 0 and fetchTags is true', async () => {
|
it('should call execGit with the correct arguments when fetchDepth is 0', async () => {
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
const workingDirectory = 'test'
|
const workingDirectory = 'test'
|
||||||
const lfs = false
|
const lfs = false
|
||||||
@ -122,45 +122,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
const refSpec = ['refspec1', 'refspec2']
|
const refSpec = ['refspec1', 'refspec2']
|
||||||
const options = {
|
const options = {
|
||||||
filter: 'filterValue',
|
filter: 'filterValue',
|
||||||
fetchDepth: 0,
|
fetchDepth: 0
|
||||||
fetchTags: true
|
|
||||||
}
|
|
||||||
|
|
||||||
await git.fetch(refSpec, options)
|
|
||||||
|
|
||||||
expect(mockExec).toHaveBeenCalledWith(
|
|
||||||
expect.any(String),
|
|
||||||
[
|
|
||||||
'-c',
|
|
||||||
'protocol.version=2',
|
|
||||||
'fetch',
|
|
||||||
'--prune',
|
|
||||||
'--no-recurse-submodules',
|
|
||||||
'--filter=filterValue',
|
|
||||||
'origin',
|
|
||||||
'refspec1',
|
|
||||||
'refspec2'
|
|
||||||
],
|
|
||||||
expect.any(Object)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchDepth is 0 and fetchTags is false', async () => {
|
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
|
||||||
|
|
||||||
const workingDirectory = 'test'
|
|
||||||
const lfs = false
|
|
||||||
const doSparseCheckout = false
|
|
||||||
git = await commandManager.createCommandManager(
|
|
||||||
workingDirectory,
|
|
||||||
lfs,
|
|
||||||
doSparseCheckout
|
|
||||||
)
|
|
||||||
const refSpec = ['refspec1', 'refspec2']
|
|
||||||
const options = {
|
|
||||||
filter: 'filterValue',
|
|
||||||
fetchDepth: 0,
|
|
||||||
fetchTags: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.fetch(refSpec, options)
|
await git.fetch(refSpec, options)
|
||||||
@ -183,7 +145,45 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is false', async () => {
|
it('should call execGit with the correct arguments when fetchDepth is 0 and refSpec includes tags', async () => {
|
||||||
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
|
const workingDirectory = 'test'
|
||||||
|
const lfs = false
|
||||||
|
const doSparseCheckout = false
|
||||||
|
git = await commandManager.createCommandManager(
|
||||||
|
workingDirectory,
|
||||||
|
lfs,
|
||||||
|
doSparseCheckout
|
||||||
|
)
|
||||||
|
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
|
||||||
|
const options = {
|
||||||
|
filter: 'filterValue',
|
||||||
|
fetchDepth: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await git.fetch(refSpec, options)
|
||||||
|
|
||||||
|
expect(mockExec).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
[
|
||||||
|
'-c',
|
||||||
|
'protocol.version=2',
|
||||||
|
'fetch',
|
||||||
|
'--no-tags',
|
||||||
|
'--prune',
|
||||||
|
'--no-recurse-submodules',
|
||||||
|
'--filter=filterValue',
|
||||||
|
'origin',
|
||||||
|
'refspec1',
|
||||||
|
'refspec2',
|
||||||
|
'+refs/tags/*:refs/tags/*'
|
||||||
|
],
|
||||||
|
expect.any(Object)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call execGit with the correct arguments when fetchDepth is 1', async () => {
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
const workingDirectory = 'test'
|
const workingDirectory = 'test'
|
||||||
@ -197,8 +197,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
const refSpec = ['refspec1', 'refspec2']
|
const refSpec = ['refspec1', 'refspec2']
|
||||||
const options = {
|
const options = {
|
||||||
filter: 'filterValue',
|
filter: 'filterValue',
|
||||||
fetchDepth: 1,
|
fetchDepth: 1
|
||||||
fetchTags: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.fetch(refSpec, options)
|
await git.fetch(refSpec, options)
|
||||||
@ -222,7 +221,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchDepth is 1 and fetchTags is true', async () => {
|
it('should call execGit with the correct arguments when fetchDepth is 1 and refSpec includes tags', async () => {
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
const workingDirectory = 'test'
|
const workingDirectory = 'test'
|
||||||
@ -233,11 +232,10 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
lfs,
|
lfs,
|
||||||
doSparseCheckout
|
doSparseCheckout
|
||||||
)
|
)
|
||||||
const refSpec = ['refspec1', 'refspec2']
|
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
|
||||||
const options = {
|
const options = {
|
||||||
filter: 'filterValue',
|
filter: 'filterValue',
|
||||||
fetchDepth: 1,
|
fetchDepth: 1
|
||||||
fetchTags: true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.fetch(refSpec, options)
|
await git.fetch(refSpec, options)
|
||||||
@ -248,13 +246,15 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
'-c',
|
'-c',
|
||||||
'protocol.version=2',
|
'protocol.version=2',
|
||||||
'fetch',
|
'fetch',
|
||||||
|
'--no-tags',
|
||||||
'--prune',
|
'--prune',
|
||||||
'--no-recurse-submodules',
|
'--no-recurse-submodules',
|
||||||
'--filter=filterValue',
|
'--filter=filterValue',
|
||||||
'--depth=1',
|
'--depth=1',
|
||||||
'origin',
|
'origin',
|
||||||
'refspec1',
|
'refspec1',
|
||||||
'refspec2'
|
'refspec2',
|
||||||
|
'+refs/tags/*:refs/tags/*'
|
||||||
],
|
],
|
||||||
expect.any(Object)
|
expect.any(Object)
|
||||||
)
|
)
|
||||||
@ -338,7 +338,7 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call execGit with the correct arguments when fetchTags is true and showProgress is true', async () => {
|
it('should call execGit with the correct arguments when showProgress is true and refSpec includes tags', async () => {
|
||||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
const workingDirectory = 'test'
|
const workingDirectory = 'test'
|
||||||
@ -349,10 +349,9 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
lfs,
|
lfs,
|
||||||
doSparseCheckout
|
doSparseCheckout
|
||||||
)
|
)
|
||||||
const refSpec = ['refspec1', 'refspec2']
|
const refSpec = ['refspec1', 'refspec2', '+refs/tags/*:refs/tags/*']
|
||||||
const options = {
|
const options = {
|
||||||
filter: 'filterValue',
|
filter: 'filterValue',
|
||||||
fetchTags: true,
|
|
||||||
showProgress: true
|
showProgress: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -364,15 +363,134 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||||||
'-c',
|
'-c',
|
||||||
'protocol.version=2',
|
'protocol.version=2',
|
||||||
'fetch',
|
'fetch',
|
||||||
|
'--no-tags',
|
||||||
'--prune',
|
'--prune',
|
||||||
'--no-recurse-submodules',
|
'--no-recurse-submodules',
|
||||||
'--progress',
|
'--progress',
|
||||||
'--filter=filterValue',
|
'--filter=filterValue',
|
||||||
'origin',
|
'origin',
|
||||||
'refspec1',
|
'refspec1',
|
||||||
'refspec2'
|
'refspec2',
|
||||||
|
'+refs/tags/*:refs/tags/*'
|
||||||
],
|
],
|
||||||
expect.any(Object)
|
expect.any(Object)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('git user-agent with orchestration ID', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
|
||||||
|
jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
// Clean up environment variable to prevent test pollution
|
||||||
|
delete process.env['ACTIONS_ORCHESTRATION_ID']
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include orchestration ID in user-agent when ACTIONS_ORCHESTRATION_ID is set', async () => {
|
||||||
|
const orchId = 'test-orch-id-12345'
|
||||||
|
process.env['ACTIONS_ORCHESTRATION_ID'] = orchId
|
||||||
|
|
||||||
|
let capturedEnv: any = null
|
||||||
|
mockExec.mockImplementation((path, args, options) => {
|
||||||
|
if (args.includes('version')) {
|
||||||
|
options.listeners.stdout(Buffer.from('2.18'))
|
||||||
|
}
|
||||||
|
// Capture env on any command
|
||||||
|
capturedEnv = options.env
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
|
const workingDirectory = 'test'
|
||||||
|
const lfs = false
|
||||||
|
const doSparseCheckout = false
|
||||||
|
git = await commandManager.createCommandManager(
|
||||||
|
workingDirectory,
|
||||||
|
lfs,
|
||||||
|
doSparseCheckout
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call a git command to trigger env capture after user-agent is set
|
||||||
|
await git.init()
|
||||||
|
|
||||||
|
// Verify the user agent includes the orchestration ID
|
||||||
|
expect(git).toBeDefined()
|
||||||
|
expect(capturedEnv).toBeDefined()
|
||||||
|
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
|
||||||
|
`git/2.18 (github-actions-checkout) actions_orchestration_id/${orchId}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sanitize invalid characters in orchestration ID', async () => {
|
||||||
|
const orchId = 'test (with) special/chars'
|
||||||
|
process.env['ACTIONS_ORCHESTRATION_ID'] = orchId
|
||||||
|
|
||||||
|
let capturedEnv: any = null
|
||||||
|
mockExec.mockImplementation((path, args, options) => {
|
||||||
|
if (args.includes('version')) {
|
||||||
|
options.listeners.stdout(Buffer.from('2.18'))
|
||||||
|
}
|
||||||
|
// Capture env on any command
|
||||||
|
capturedEnv = options.env
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
|
const workingDirectory = 'test'
|
||||||
|
const lfs = false
|
||||||
|
const doSparseCheckout = false
|
||||||
|
git = await commandManager.createCommandManager(
|
||||||
|
workingDirectory,
|
||||||
|
lfs,
|
||||||
|
doSparseCheckout
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call a git command to trigger env capture after user-agent is set
|
||||||
|
await git.init()
|
||||||
|
|
||||||
|
// Verify the user agent has sanitized orchestration ID (spaces, parentheses, slash replaced)
|
||||||
|
expect(git).toBeDefined()
|
||||||
|
expect(capturedEnv).toBeDefined()
|
||||||
|
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
|
||||||
|
'git/2.18 (github-actions-checkout) actions_orchestration_id/test__with__special_chars'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not modify user-agent when ACTIONS_ORCHESTRATION_ID is not set', async () => {
|
||||||
|
delete process.env['ACTIONS_ORCHESTRATION_ID']
|
||||||
|
|
||||||
|
let capturedEnv: any = null
|
||||||
|
mockExec.mockImplementation((path, args, options) => {
|
||||||
|
if (args.includes('version')) {
|
||||||
|
options.listeners.stdout(Buffer.from('2.18'))
|
||||||
|
}
|
||||||
|
// Capture env on any command
|
||||||
|
capturedEnv = options.env
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||||
|
|
||||||
|
const workingDirectory = 'test'
|
||||||
|
const lfs = false
|
||||||
|
const doSparseCheckout = false
|
||||||
|
git = await commandManager.createCommandManager(
|
||||||
|
workingDirectory,
|
||||||
|
lfs,
|
||||||
|
doSparseCheckout
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call a git command to trigger env capture after user-agent is set
|
||||||
|
await git.init()
|
||||||
|
|
||||||
|
// Verify the user agent does NOT contain orchestration ID
|
||||||
|
expect(git).toBeDefined()
|
||||||
|
expect(capturedEnv).toBeDefined()
|
||||||
|
expect(capturedEnv['GIT_HTTP_USER_AGENT']).toBe(
|
||||||
|
'git/2.18 (github-actions-checkout)'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -143,12 +143,41 @@ describe('git-directory-helper tests', () => {
|
|||||||
repositoryPath,
|
repositoryPath,
|
||||||
repositoryUrl,
|
repositoryUrl,
|
||||||
clean,
|
clean,
|
||||||
ref
|
ref,
|
||||||
|
false // preserveLocalChanges = false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const files = await fs.promises.readdir(repositoryPath)
|
const files = await fs.promises.readdir(repositoryPath)
|
||||||
expect(files).toHaveLength(0)
|
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(git.tryClean).toHaveBeenCalled()
|
expect(git.tryClean).toHaveBeenCalled()
|
||||||
expect(core.warning).toHaveBeenCalled()
|
expect(core.warning).toHaveBeenCalled()
|
||||||
expect(git.tryReset).not.toHaveBeenCalled()
|
expect(git.tryReset).not.toHaveBeenCalled()
|
||||||
@ -170,16 +199,50 @@ describe('git-directory-helper tests', () => {
|
|||||||
repositoryPath,
|
repositoryPath,
|
||||||
differentRepositoryUrl,
|
differentRepositoryUrl,
|
||||||
clean,
|
clean,
|
||||||
ref
|
ref,
|
||||||
|
false // preserveLocalChanges = false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const files = await fs.promises.readdir(repositoryPath)
|
const files = await fs.promises.readdir(repositoryPath)
|
||||||
expect(files).toHaveLength(0)
|
expect(files).toEqual(['.git']) // Expect just the .git directory to remain
|
||||||
expect(core.warning).not.toHaveBeenCalled()
|
expect(core.warning).not.toHaveBeenCalled()
|
||||||
expect(git.isDetached).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 =
|
const removesContentsWhenNoGitDirectory =
|
||||||
'removes contents when no git directory'
|
'removes contents when no git directory'
|
||||||
it(removesContentsWhenNoGitDirectory, async () => {
|
it(removesContentsWhenNoGitDirectory, async () => {
|
||||||
@ -221,12 +284,41 @@ describe('git-directory-helper tests', () => {
|
|||||||
repositoryPath,
|
repositoryPath,
|
||||||
repositoryUrl,
|
repositoryUrl,
|
||||||
clean,
|
clean,
|
||||||
ref
|
ref,
|
||||||
|
false // preserveLocalChanges = false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const files = await fs.promises.readdir(repositoryPath)
|
const files = await fs.promises.readdir(repositoryPath)
|
||||||
expect(files).toHaveLength(0)
|
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(git.tryClean).toHaveBeenCalled()
|
expect(git.tryClean).toHaveBeenCalled()
|
||||||
expect(git.tryReset).toHaveBeenCalled()
|
expect(git.tryReset).toHaveBeenCalled()
|
||||||
expect(core.warning).toHaveBeenCalled()
|
expect(core.warning).toHaveBeenCalled()
|
||||||
@ -246,12 +338,13 @@ describe('git-directory-helper tests', () => {
|
|||||||
repositoryPath,
|
repositoryPath,
|
||||||
repositoryUrl,
|
repositoryUrl,
|
||||||
clean,
|
clean,
|
||||||
ref
|
ref,
|
||||||
|
false // preserveLocalChanges = false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const files = await fs.promises.readdir(repositoryPath)
|
const files = await fs.promises.readdir(repositoryPath)
|
||||||
expect(files).toHaveLength(0)
|
expect(files).toEqual(['.git']) // Expect just the .git directory to remain
|
||||||
expect(core.warning).not.toHaveBeenCalled()
|
expect(core.warning).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -302,12 +395,13 @@ describe('git-directory-helper tests', () => {
|
|||||||
repositoryPath,
|
repositoryPath,
|
||||||
repositoryUrl,
|
repositoryUrl,
|
||||||
clean,
|
clean,
|
||||||
ref
|
ref,
|
||||||
|
false // preserveLocalChanges = false
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const files = await fs.promises.readdir(repositoryPath)
|
const files = await fs.promises.readdir(repositoryPath)
|
||||||
expect(files).toHaveLength(0)
|
expect(files).toEqual(['.git']) // Expect just the .git directory to remain
|
||||||
expect(git.tryClean).toHaveBeenCalled()
|
expect(git.tryClean).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -152,7 +152,22 @@ describe('ref-helper tests', () => {
|
|||||||
it('getRefSpec sha + refs/tags/', async () => {
|
it('getRefSpec sha + refs/tags/', async () => {
|
||||||
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit)
|
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit)
|
||||||
expect(refSpec.length).toBe(1)
|
expect(refSpec.length).toBe(1)
|
||||||
expect(refSpec[0]).toBe(`+${commit}:refs/tags/my-tag`)
|
expect(refSpec[0]).toBe(`+refs/tags/my-tag:refs/tags/my-tag`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRefSpec sha + refs/tags/ with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, only include tags wildcard (specific tag is redundant)
|
||||||
|
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', commit, true)
|
||||||
|
expect(refSpec.length).toBe(1)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRefSpec sha + refs/heads/ with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, include both the branch refspec and tags wildcard
|
||||||
|
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', commit, true)
|
||||||
|
expect(refSpec.length).toBe(2)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
expect(refSpec[1]).toBe(`+${commit}:refs/remotes/origin/my/branch`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('getRefSpec sha only', async () => {
|
it('getRefSpec sha only', async () => {
|
||||||
@ -168,6 +183,14 @@ describe('ref-helper tests', () => {
|
|||||||
expect(refSpec[1]).toBe('+refs/tags/my-ref*:refs/tags/my-ref*')
|
expect(refSpec[1]).toBe('+refs/tags/my-ref*:refs/tags/my-ref*')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('getRefSpec unqualified ref only with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, skip specific tag pattern since wildcard covers all
|
||||||
|
const refSpec = refHelper.getRefSpec('my-ref', '', true)
|
||||||
|
expect(refSpec.length).toBe(2)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
expect(refSpec[1]).toBe('+refs/heads/my-ref*:refs/remotes/origin/my-ref*')
|
||||||
|
})
|
||||||
|
|
||||||
it('getRefSpec refs/heads/ only', async () => {
|
it('getRefSpec refs/heads/ only', async () => {
|
||||||
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '')
|
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '')
|
||||||
expect(refSpec.length).toBe(1)
|
expect(refSpec.length).toBe(1)
|
||||||
@ -187,4 +210,21 @@ describe('ref-helper tests', () => {
|
|||||||
expect(refSpec.length).toBe(1)
|
expect(refSpec.length).toBe(1)
|
||||||
expect(refSpec[0]).toBe('+refs/tags/my-tag:refs/tags/my-tag')
|
expect(refSpec[0]).toBe('+refs/tags/my-tag:refs/tags/my-tag')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('getRefSpec refs/tags/ only with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, only include tags wildcard (specific tag is redundant)
|
||||||
|
const refSpec = refHelper.getRefSpec('refs/tags/my-tag', '', true)
|
||||||
|
expect(refSpec.length).toBe(1)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRefSpec refs/heads/ only with fetchTags', async () => {
|
||||||
|
// When fetchTags is true, include both the branch refspec and tags wildcard
|
||||||
|
const refSpec = refHelper.getRefSpec('refs/heads/my/branch', '', true)
|
||||||
|
expect(refSpec.length).toBe(2)
|
||||||
|
expect(refSpec[0]).toBe('+refs/tags/*:refs/tags/*')
|
||||||
|
expect(refSpec[1]).toBe(
|
||||||
|
'+refs/heads/my/branch:refs/remotes/origin/my/branch'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
9
__test__/verify-fetch-tags.sh
Executable file
9
__test__/verify-fetch-tags.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Verify tags were fetched
|
||||||
|
TAG_COUNT=$(git -C ./fetch-tags-test tag | wc -l)
|
||||||
|
if [ "$TAG_COUNT" -eq 0 ]; then
|
||||||
|
echo "Expected tags to be fetched, but found none"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found $TAG_COUNT tags"
|
||||||
@ -56,7 +56,10 @@ inputs:
|
|||||||
description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
|
description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
|
||||||
clean:
|
clean:
|
||||||
description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching'
|
description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching'
|
||||||
default: true
|
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'
|
||||||
filter:
|
filter:
|
||||||
description: >
|
description: >
|
||||||
Partially clone against a given filter.
|
Partially clone against a given filter.
|
||||||
|
|||||||
220
dist/index.js
vendored
220
dist/index.js
vendored
@ -653,7 +653,6 @@ const fs = __importStar(__nccwpck_require__(7147));
|
|||||||
const fshelper = __importStar(__nccwpck_require__(7219));
|
const fshelper = __importStar(__nccwpck_require__(7219));
|
||||||
const io = __importStar(__nccwpck_require__(7436));
|
const io = __importStar(__nccwpck_require__(7436));
|
||||||
const path = __importStar(__nccwpck_require__(1017));
|
const path = __importStar(__nccwpck_require__(1017));
|
||||||
const refHelper = __importStar(__nccwpck_require__(8601));
|
|
||||||
const regexpHelper = __importStar(__nccwpck_require__(3120));
|
const regexpHelper = __importStar(__nccwpck_require__(3120));
|
||||||
const retryHelper = __importStar(__nccwpck_require__(2155));
|
const retryHelper = __importStar(__nccwpck_require__(2155));
|
||||||
const git_version_1 = __nccwpck_require__(3142);
|
const git_version_1 = __nccwpck_require__(3142);
|
||||||
@ -781,9 +780,17 @@ class GitCommandManager {
|
|||||||
yield fs.promises.appendFile(sparseCheckoutPath, `\n${sparseCheckout.join('\n')}\n`);
|
yield fs.promises.appendFile(sparseCheckoutPath, `\n${sparseCheckout.join('\n')}\n`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
checkout(ref, startPoint) {
|
checkout(ref_1, startPoint_1) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, arguments, void 0, function* (ref, startPoint, options = []) {
|
||||||
const args = ['checkout', '--progress', '--force'];
|
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');
|
||||||
|
}
|
||||||
if (startPoint) {
|
if (startPoint) {
|
||||||
args.push('-B', ref, startPoint);
|
args.push('-B', ref, startPoint);
|
||||||
}
|
}
|
||||||
@ -831,9 +838,9 @@ class GitCommandManager {
|
|||||||
fetch(refSpec, options) {
|
fetch(refSpec, options) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, void 0, void 0, function* () {
|
||||||
const args = ['-c', 'protocol.version=2', 'fetch'];
|
const args = ['-c', 'protocol.version=2', 'fetch'];
|
||||||
if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) {
|
// Always use --no-tags for explicit control over tag fetching
|
||||||
|
// Tags are fetched explicitly via refspec when needed
|
||||||
args.push('--no-tags');
|
args.push('--no-tags');
|
||||||
}
|
|
||||||
args.push('--prune', '--no-recurse-submodules');
|
args.push('--prune', '--no-recurse-submodules');
|
||||||
if (options.showProgress) {
|
if (options.showProgress) {
|
||||||
args.push('--progress');
|
args.push('--progress');
|
||||||
@ -1206,7 +1213,17 @@ class GitCommandManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Set the user agent
|
// Set the user agent
|
||||||
const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`;
|
let gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`;
|
||||||
|
// Append orchestration ID if set
|
||||||
|
const orchId = process.env['ACTIONS_ORCHESTRATION_ID'];
|
||||||
|
if (orchId) {
|
||||||
|
// Sanitize the orchestration ID to ensure it contains only valid characters
|
||||||
|
// Valid characters: 0-9, a-z, _, -, .
|
||||||
|
const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_');
|
||||||
|
if (sanitizedId) {
|
||||||
|
gitHttpUserAgent = `${gitHttpUserAgent} actions_orchestration_id/${sanitizedId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
core.debug(`Set git useragent to: ${gitHttpUserAgent}`);
|
core.debug(`Set git useragent to: ${gitHttpUserAgent}`);
|
||||||
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent;
|
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent;
|
||||||
});
|
});
|
||||||
@ -1267,13 +1284,17 @@ const fs = __importStar(__nccwpck_require__(7147));
|
|||||||
const fsHelper = __importStar(__nccwpck_require__(7219));
|
const fsHelper = __importStar(__nccwpck_require__(7219));
|
||||||
const io = __importStar(__nccwpck_require__(7436));
|
const io = __importStar(__nccwpck_require__(7436));
|
||||||
const path = __importStar(__nccwpck_require__(1017));
|
const path = __importStar(__nccwpck_require__(1017));
|
||||||
function prepareExistingDirectory(git, repositoryPath, repositoryUrl, clean, ref) {
|
function prepareExistingDirectory(git_1, repositoryPath_1, repositoryUrl_1, clean_1, ref_1) {
|
||||||
return __awaiter(this, void 0, void 0, function* () {
|
return __awaiter(this, arguments, void 0, function* (git, repositoryPath, repositoryUrl, clean, ref, preserveLocalChanges = false) {
|
||||||
var _a;
|
var _a;
|
||||||
assert.ok(repositoryPath, 'Expected repositoryPath to be defined');
|
assert.ok(repositoryPath, 'Expected repositoryPath to be defined');
|
||||||
assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined');
|
assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined');
|
||||||
// Indicates whether to delete the directory contents
|
// Indicates whether to delete the directory contents
|
||||||
let remove = false;
|
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
|
// Check whether using git or REST API
|
||||||
if (!git) {
|
if (!git) {
|
||||||
remove = true;
|
remove = true;
|
||||||
@ -1354,14 +1375,28 @@ function prepareExistingDirectory(git, repositoryPath, repositoryUrl, clean, ref
|
|||||||
remove = true;
|
remove = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (remove) {
|
if (remove && !preserveLocalChanges) {
|
||||||
// Delete the contents of the directory. Don't delete the directory itself
|
// Delete the contents of the directory. Don't delete the directory itself
|
||||||
// since it might be the current working directory.
|
// since it might be the current working directory.
|
||||||
core.info(`Deleting the contents of '${repositoryPath}'`);
|
core.info(`Deleting the contents of '${repositoryPath}'`);
|
||||||
for (const file of yield fs.promises.readdir(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));
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1458,7 +1493,7 @@ function getSource(settings) {
|
|||||||
}
|
}
|
||||||
// Prepare existing directory, otherwise recreate
|
// Prepare existing directory, otherwise recreate
|
||||||
if (isExisting) {
|
if (isExisting) {
|
||||||
yield gitDirectoryHelper.prepareExistingDirectory(git, settings.repositoryPath, repositoryUrl, settings.clean, settings.ref);
|
yield gitDirectoryHelper.prepareExistingDirectory(git, settings.repositoryPath, repositoryUrl, settings.clean, settings.ref, settings.preserveLocalChanges);
|
||||||
}
|
}
|
||||||
if (!git) {
|
if (!git) {
|
||||||
// Downloading using REST API
|
// Downloading using REST API
|
||||||
@ -1529,13 +1564,26 @@ function getSource(settings) {
|
|||||||
if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) {
|
if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) {
|
||||||
refSpec = refHelper.getRefSpec(settings.ref, settings.commit);
|
refSpec = refHelper.getRefSpec(settings.ref, settings.commit);
|
||||||
yield git.fetch(refSpec, fetchOptions);
|
yield git.fetch(refSpec, fetchOptions);
|
||||||
|
// Verify the ref now matches. For branches, the targeted fetch above brings
|
||||||
|
// in the specific commit. For tags (fetched by ref), this will fail if
|
||||||
|
// the tag was moved after the workflow was triggered.
|
||||||
|
if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) {
|
||||||
|
throw new Error(`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
|
||||||
|
`The ref may have been updated after the workflow was triggered.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
fetchOptions.fetchDepth = settings.fetchDepth;
|
fetchOptions.fetchDepth = settings.fetchDepth;
|
||||||
fetchOptions.fetchTags = settings.fetchTags;
|
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit, settings.fetchTags);
|
||||||
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit);
|
|
||||||
yield git.fetch(refSpec, fetchOptions);
|
yield git.fetch(refSpec, fetchOptions);
|
||||||
|
// For tags, verify the ref still points to the expected commit.
|
||||||
|
// Tags are fetched by ref (not commit), so if a tag was moved after the
|
||||||
|
// workflow was triggered, we would silently check out the wrong commit.
|
||||||
|
if (!(yield refHelper.testRef(git, settings.ref, settings.commit))) {
|
||||||
|
throw new Error(`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
|
||||||
|
`The ref may have been updated after the workflow was triggered.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
core.endGroup();
|
core.endGroup();
|
||||||
// Checkout info
|
// Checkout info
|
||||||
@ -1571,7 +1619,104 @@ function getSource(settings) {
|
|||||||
}
|
}
|
||||||
// Checkout
|
// Checkout
|
||||||
core.startGroup('Checking out the ref');
|
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);
|
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);
|
||||||
|
}
|
||||||
core.endGroup();
|
core.endGroup();
|
||||||
// Submodules
|
// Submodules
|
||||||
if (settings.submodules) {
|
if (settings.submodules) {
|
||||||
@ -2008,6 +2153,11 @@ function getInputs() {
|
|||||||
// Clean
|
// Clean
|
||||||
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE';
|
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE';
|
||||||
core.debug(`clean = ${result.clean}`);
|
core.debug(`clean = ${result.clean}`);
|
||||||
|
// Preserve local changes
|
||||||
|
result.preserveLocalChanges =
|
||||||
|
(core.getInput('preserve-local-changes') || 'false').toUpperCase() ===
|
||||||
|
'TRUE';
|
||||||
|
core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`);
|
||||||
// Filter
|
// Filter
|
||||||
const filter = core.getInput('filter');
|
const filter = core.getInput('filter');
|
||||||
if (filter) {
|
if (filter) {
|
||||||
@ -2274,53 +2424,67 @@ function getRefSpecForAllHistory(ref, commit) {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
function getRefSpec(ref, commit) {
|
function getRefSpec(ref, commit, fetchTags) {
|
||||||
if (!ref && !commit) {
|
if (!ref && !commit) {
|
||||||
throw new Error('Args ref and commit cannot both be empty');
|
throw new Error('Args ref and commit cannot both be empty');
|
||||||
}
|
}
|
||||||
const upperRef = (ref || '').toUpperCase();
|
const upperRef = (ref || '').toUpperCase();
|
||||||
|
const result = [];
|
||||||
|
// When fetchTags is true, always include the tags refspec
|
||||||
|
if (fetchTags) {
|
||||||
|
result.push(exports.tagsRefSpec);
|
||||||
|
}
|
||||||
// SHA
|
// SHA
|
||||||
if (commit) {
|
if (commit) {
|
||||||
// refs/heads
|
// refs/heads
|
||||||
if (upperRef.startsWith('REFS/HEADS/')) {
|
if (upperRef.startsWith('REFS/HEADS/')) {
|
||||||
const branch = ref.substring('refs/heads/'.length);
|
const branch = ref.substring('refs/heads/'.length);
|
||||||
return [`+${commit}:refs/remotes/origin/${branch}`];
|
result.push(`+${commit}:refs/remotes/origin/${branch}`);
|
||||||
}
|
}
|
||||||
// refs/pull/
|
// refs/pull/
|
||||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||||
const branch = ref.substring('refs/pull/'.length);
|
const branch = ref.substring('refs/pull/'.length);
|
||||||
return [`+${commit}:refs/remotes/pull/${branch}`];
|
result.push(`+${commit}:refs/remotes/pull/${branch}`);
|
||||||
}
|
}
|
||||||
// refs/tags/
|
// refs/tags/
|
||||||
else if (upperRef.startsWith('REFS/TAGS/')) {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
return [`+${commit}:${ref}`];
|
if (!fetchTags) {
|
||||||
|
result.push(`+${ref}:${ref}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Otherwise no destination ref
|
// Otherwise no destination ref
|
||||||
else {
|
else {
|
||||||
return [commit];
|
result.push(commit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Unqualified ref, check for a matching branch or tag
|
// Unqualified ref, check for a matching branch or tag
|
||||||
else if (!upperRef.startsWith('REFS/')) {
|
else if (!upperRef.startsWith('REFS/')) {
|
||||||
return [
|
result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`);
|
||||||
`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
|
if (!fetchTags) {
|
||||||
`+refs/tags/${ref}*:refs/tags/${ref}*`
|
result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`);
|
||||||
];
|
}
|
||||||
}
|
}
|
||||||
// refs/heads/
|
// refs/heads/
|
||||||
else if (upperRef.startsWith('REFS/HEADS/')) {
|
else if (upperRef.startsWith('REFS/HEADS/')) {
|
||||||
const branch = ref.substring('refs/heads/'.length);
|
const branch = ref.substring('refs/heads/'.length);
|
||||||
return [`+${ref}:refs/remotes/origin/${branch}`];
|
result.push(`+${ref}:refs/remotes/origin/${branch}`);
|
||||||
}
|
}
|
||||||
// refs/pull/
|
// refs/pull/
|
||||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||||
const branch = ref.substring('refs/pull/'.length);
|
const branch = ref.substring('refs/pull/'.length);
|
||||||
return [`+${ref}:refs/remotes/pull/${branch}`];
|
result.push(`+${ref}:refs/remotes/pull/${branch}`);
|
||||||
}
|
}
|
||||||
// refs/tags/
|
// refs/tags/
|
||||||
else {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
return [`+${ref}:${ref}`];
|
if (!fetchTags) {
|
||||||
|
result.push(`+${ref}:${ref}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Other refs
|
||||||
|
else {
|
||||||
|
result.push(`+${ref}:${ref}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Tests whether the initial fetch created the ref at the expected commit
|
* Tests whether the initial fetch created the ref at the expected commit
|
||||||
@ -2356,7 +2520,9 @@ function testRef(git, ref, commit) {
|
|||||||
// refs/tags/
|
// refs/tags/
|
||||||
else if (upperRef.startsWith('REFS/TAGS/')) {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
const tagName = ref.substring('refs/tags/'.length);
|
const tagName = ref.substring('refs/tags/'.length);
|
||||||
return ((yield git.tagExists(tagName)) && commit === (yield git.revParse(ref)));
|
// Use ^{commit} to dereference annotated tags to their underlying commit
|
||||||
|
return ((yield git.tagExists(tagName)) &&
|
||||||
|
commit === (yield git.revParse(`${ref}^{commit}`)));
|
||||||
}
|
}
|
||||||
// Unexpected
|
// Unexpected
|
||||||
else {
|
else {
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export interface IGitCommandManager {
|
|||||||
disableSparseCheckout(): Promise<void>
|
disableSparseCheckout(): Promise<void>
|
||||||
sparseCheckout(sparseCheckout: string[]): Promise<void>
|
sparseCheckout(sparseCheckout: string[]): Promise<void>
|
||||||
sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void>
|
sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void>
|
||||||
checkout(ref: string, startPoint: string): Promise<void>
|
checkout(ref: string, startPoint: string, options?: string[]): Promise<void>
|
||||||
checkoutDetach(): Promise<void>
|
checkoutDetach(): Promise<void>
|
||||||
config(
|
config(
|
||||||
configKey: string,
|
configKey: string,
|
||||||
@ -37,7 +37,6 @@ export interface IGitCommandManager {
|
|||||||
options: {
|
options: {
|
||||||
filter?: string
|
filter?: string
|
||||||
fetchDepth?: number
|
fetchDepth?: number
|
||||||
fetchTags?: boolean
|
|
||||||
showProgress?: boolean
|
showProgress?: boolean
|
||||||
}
|
}
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
@ -221,8 +220,21 @@ class GitCommandManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkout(ref: string, startPoint: string): Promise<void> {
|
async checkout(
|
||||||
const args = ['checkout', '--progress', '--force']
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
if (startPoint) {
|
if (startPoint) {
|
||||||
args.push('-B', ref, startPoint)
|
args.push('-B', ref, startPoint)
|
||||||
} else {
|
} else {
|
||||||
@ -280,14 +292,13 @@ class GitCommandManager {
|
|||||||
options: {
|
options: {
|
||||||
filter?: string
|
filter?: string
|
||||||
fetchDepth?: number
|
fetchDepth?: number
|
||||||
fetchTags?: boolean
|
|
||||||
showProgress?: boolean
|
showProgress?: boolean
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const args = ['-c', 'protocol.version=2', 'fetch']
|
const args = ['-c', 'protocol.version=2', 'fetch']
|
||||||
if (!refSpec.some(x => x === refHelper.tagsRefSpec) && !options.fetchTags) {
|
// Always use --no-tags for explicit control over tag fetching
|
||||||
|
// Tags are fetched explicitly via refspec when needed
|
||||||
args.push('--no-tags')
|
args.push('--no-tags')
|
||||||
}
|
|
||||||
|
|
||||||
args.push('--prune', '--no-recurse-submodules')
|
args.push('--prune', '--no-recurse-submodules')
|
||||||
if (options.showProgress) {
|
if (options.showProgress) {
|
||||||
@ -730,7 +741,19 @@ class GitCommandManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Set the user agent
|
// Set the user agent
|
||||||
const gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
|
let gitHttpUserAgent = `git/${this.gitVersion} (github-actions-checkout)`
|
||||||
|
|
||||||
|
// Append orchestration ID if set
|
||||||
|
const orchId = process.env['ACTIONS_ORCHESTRATION_ID']
|
||||||
|
if (orchId) {
|
||||||
|
// Sanitize the orchestration ID to ensure it contains only valid characters
|
||||||
|
// Valid characters: 0-9, a-z, _, -, .
|
||||||
|
const sanitizedId = orchId.replace(/[^a-z0-9_.-]/gi, '_')
|
||||||
|
if (sanitizedId) {
|
||||||
|
gitHttpUserAgent = `${gitHttpUserAgent} actions_orchestration_id/${sanitizedId}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
|
core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
|
||||||
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
|
this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,8 @@ export async function prepareExistingDirectory(
|
|||||||
repositoryPath: string,
|
repositoryPath: string,
|
||||||
repositoryUrl: string,
|
repositoryUrl: string,
|
||||||
clean: boolean,
|
clean: boolean,
|
||||||
ref: string
|
ref: string,
|
||||||
|
preserveLocalChanges: boolean = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assert.ok(repositoryPath, 'Expected repositoryPath to be defined')
|
assert.ok(repositoryPath, 'Expected repositoryPath to be defined')
|
||||||
assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined')
|
assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined')
|
||||||
@ -19,6 +20,13 @@ export async function prepareExistingDirectory(
|
|||||||
// Indicates whether to delete the directory contents
|
// Indicates whether to delete the directory contents
|
||||||
let remove = false
|
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
|
// Check whether using git or REST API
|
||||||
if (!git) {
|
if (!git) {
|
||||||
remove = true
|
remove = true
|
||||||
@ -114,12 +122,43 @@ export async function prepareExistingDirectory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remove) {
|
// 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) {
|
||||||
// Delete the contents of the directory. Don't delete the directory itself
|
// Delete the contents of the directory. Don't delete the directory itself
|
||||||
// since it might be the current working directory.
|
// since it might be the current working directory.
|
||||||
core.info(`Deleting the contents of '${repositoryPath}'`)
|
core.info(`Deleting the contents of '${repositoryPath}'`)
|
||||||
for (const file of await fs.promises.readdir(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))
|
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,7 +70,8 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||||||
settings.repositoryPath,
|
settings.repositoryPath,
|
||||||
repositoryUrl,
|
repositoryUrl,
|
||||||
settings.clean,
|
settings.clean,
|
||||||
settings.ref
|
settings.ref,
|
||||||
|
settings.preserveLocalChanges
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,7 +160,6 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||||||
const fetchOptions: {
|
const fetchOptions: {
|
||||||
filter?: string
|
filter?: string
|
||||||
fetchDepth?: number
|
fetchDepth?: number
|
||||||
fetchTags?: boolean
|
|
||||||
showProgress?: boolean
|
showProgress?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
@ -182,12 +182,35 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||||||
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
||||||
refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
||||||
await git.fetch(refSpec, fetchOptions)
|
await git.fetch(refSpec, fetchOptions)
|
||||||
|
|
||||||
|
// Verify the ref now matches. For branches, the targeted fetch above brings
|
||||||
|
// in the specific commit. For tags (fetched by ref), this will fail if
|
||||||
|
// the tag was moved after the workflow was triggered.
|
||||||
|
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
||||||
|
throw new Error(
|
||||||
|
`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
|
||||||
|
`The ref may have been updated after the workflow was triggered.`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fetchOptions.fetchDepth = settings.fetchDepth
|
fetchOptions.fetchDepth = settings.fetchDepth
|
||||||
fetchOptions.fetchTags = settings.fetchTags
|
const refSpec = refHelper.getRefSpec(
|
||||||
const refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
|
settings.ref,
|
||||||
|
settings.commit,
|
||||||
|
settings.fetchTags
|
||||||
|
)
|
||||||
await git.fetch(refSpec, fetchOptions)
|
await git.fetch(refSpec, fetchOptions)
|
||||||
|
|
||||||
|
// For tags, verify the ref still points to the expected commit.
|
||||||
|
// Tags are fetched by ref (not commit), so if a tag was moved after the
|
||||||
|
// workflow was triggered, we would silently check out the wrong commit.
|
||||||
|
if (!(await refHelper.testRef(git, settings.ref, settings.commit))) {
|
||||||
|
throw new Error(
|
||||||
|
`The ref '${settings.ref}' does not point to the expected commit '${settings.commit}'. ` +
|
||||||
|
`The ref may have been updated after the workflow was triggered.`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
core.endGroup()
|
core.endGroup()
|
||||||
|
|
||||||
@ -229,7 +252,115 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||||||
|
|
||||||
// Checkout
|
// Checkout
|
||||||
core.startGroup('Checking out the ref')
|
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)
|
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)
|
||||||
|
}
|
||||||
core.endGroup()
|
core.endGroup()
|
||||||
|
|
||||||
// Submodules
|
// Submodules
|
||||||
|
|||||||
@ -25,10 +25,15 @@ export interface IGitSourceSettings {
|
|||||||
commit: string
|
commit: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates whether to clean the repository
|
* Whether to execute git clean and git reset before fetching
|
||||||
*/
|
*/
|
||||||
clean: boolean
|
clean: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to preserve local changes during checkout
|
||||||
|
*/
|
||||||
|
preserveLocalChanges: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The filter determining which objects to include
|
* The filter determining which objects to include
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -82,6 +82,12 @@ export async function getInputs(): Promise<IGitSourceSettings> {
|
|||||||
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'
|
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'
|
||||||
core.debug(`clean = ${result.clean}`)
|
core.debug(`clean = ${result.clean}`)
|
||||||
|
|
||||||
|
// Preserve local changes
|
||||||
|
result.preserveLocalChanges =
|
||||||
|
(core.getInput('preserve-local-changes') || 'false').toUpperCase() ===
|
||||||
|
'TRUE'
|
||||||
|
core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`)
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
const filter = core.getInput('filter')
|
const filter = core.getInput('filter')
|
||||||
if (filter) {
|
if (filter) {
|
||||||
|
|||||||
@ -76,55 +76,75 @@ export function getRefSpecForAllHistory(ref: string, commit: string): string[] {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRefSpec(ref: string, commit: string): string[] {
|
export function getRefSpec(
|
||||||
|
ref: string,
|
||||||
|
commit: string,
|
||||||
|
fetchTags?: boolean
|
||||||
|
): string[] {
|
||||||
if (!ref && !commit) {
|
if (!ref && !commit) {
|
||||||
throw new Error('Args ref and commit cannot both be empty')
|
throw new Error('Args ref and commit cannot both be empty')
|
||||||
}
|
}
|
||||||
|
|
||||||
const upperRef = (ref || '').toUpperCase()
|
const upperRef = (ref || '').toUpperCase()
|
||||||
|
const result: string[] = []
|
||||||
|
|
||||||
|
// When fetchTags is true, always include the tags refspec
|
||||||
|
if (fetchTags) {
|
||||||
|
result.push(tagsRefSpec)
|
||||||
|
}
|
||||||
|
|
||||||
// SHA
|
// SHA
|
||||||
if (commit) {
|
if (commit) {
|
||||||
// refs/heads
|
// refs/heads
|
||||||
if (upperRef.startsWith('REFS/HEADS/')) {
|
if (upperRef.startsWith('REFS/HEADS/')) {
|
||||||
const branch = ref.substring('refs/heads/'.length)
|
const branch = ref.substring('refs/heads/'.length)
|
||||||
return [`+${commit}:refs/remotes/origin/${branch}`]
|
result.push(`+${commit}:refs/remotes/origin/${branch}`)
|
||||||
}
|
}
|
||||||
// refs/pull/
|
// refs/pull/
|
||||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||||
const branch = ref.substring('refs/pull/'.length)
|
const branch = ref.substring('refs/pull/'.length)
|
||||||
return [`+${commit}:refs/remotes/pull/${branch}`]
|
result.push(`+${commit}:refs/remotes/pull/${branch}`)
|
||||||
}
|
}
|
||||||
// refs/tags/
|
// refs/tags/
|
||||||
else if (upperRef.startsWith('REFS/TAGS/')) {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
return [`+${commit}:${ref}`]
|
if (!fetchTags) {
|
||||||
|
result.push(`+${ref}:${ref}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Otherwise no destination ref
|
// Otherwise no destination ref
|
||||||
else {
|
else {
|
||||||
return [commit]
|
result.push(commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Unqualified ref, check for a matching branch or tag
|
// Unqualified ref, check for a matching branch or tag
|
||||||
else if (!upperRef.startsWith('REFS/')) {
|
else if (!upperRef.startsWith('REFS/')) {
|
||||||
return [
|
result.push(`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`)
|
||||||
`+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
|
if (!fetchTags) {
|
||||||
`+refs/tags/${ref}*:refs/tags/${ref}*`
|
result.push(`+refs/tags/${ref}*:refs/tags/${ref}*`)
|
||||||
]
|
}
|
||||||
}
|
}
|
||||||
// refs/heads/
|
// refs/heads/
|
||||||
else if (upperRef.startsWith('REFS/HEADS/')) {
|
else if (upperRef.startsWith('REFS/HEADS/')) {
|
||||||
const branch = ref.substring('refs/heads/'.length)
|
const branch = ref.substring('refs/heads/'.length)
|
||||||
return [`+${ref}:refs/remotes/origin/${branch}`]
|
result.push(`+${ref}:refs/remotes/origin/${branch}`)
|
||||||
}
|
}
|
||||||
// refs/pull/
|
// refs/pull/
|
||||||
else if (upperRef.startsWith('REFS/PULL/')) {
|
else if (upperRef.startsWith('REFS/PULL/')) {
|
||||||
const branch = ref.substring('refs/pull/'.length)
|
const branch = ref.substring('refs/pull/'.length)
|
||||||
return [`+${ref}:refs/remotes/pull/${branch}`]
|
result.push(`+${ref}:refs/remotes/pull/${branch}`)
|
||||||
}
|
}
|
||||||
// refs/tags/
|
// refs/tags/
|
||||||
else {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
return [`+${ref}:${ref}`]
|
if (!fetchTags) {
|
||||||
|
result.push(`+${ref}:${ref}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Other refs
|
||||||
|
else {
|
||||||
|
result.push(`+${ref}:${ref}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -170,8 +190,10 @@ export async function testRef(
|
|||||||
// refs/tags/
|
// refs/tags/
|
||||||
else if (upperRef.startsWith('REFS/TAGS/')) {
|
else if (upperRef.startsWith('REFS/TAGS/')) {
|
||||||
const tagName = ref.substring('refs/tags/'.length)
|
const tagName = ref.substring('refs/tags/'.length)
|
||||||
|
// Use ^{commit} to dereference annotated tags to their underlying commit
|
||||||
return (
|
return (
|
||||||
(await git.tagExists(tagName)) && commit === (await git.revParse(ref))
|
(await git.tagExists(tagName)) &&
|
||||||
|
commit === (await git.revParse(`${ref}^{commit}`))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Unexpected
|
// Unexpected
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user