Compare commits

...

10 Commits

Author SHA1 Message Date
Salman Chishti
c3c8a0719d
Merge 45abae3e9f into 900f2210b1 2026-05-04 21:05:20 -07:00
Yashwanth Anantharaju
900f2210b1
fix: expand merge commit SHA regex and add SHA-256 test cases (#2414)
Some checks failed
Check dist / check-dist (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Licensed / Check licenses (push) Has been cancelled
Build and Test / build (push) Has been cancelled
Build and Test / test (macos-latest) (push) Has been cancelled
Build and Test / test (ubuntu-latest) (push) Has been cancelled
Build and Test / test (windows-latest) (push) Has been cancelled
Build and Test / test-proxy (push) Has been cancelled
Build and Test / test-bypass-proxy (push) Has been cancelled
Build and Test / test-git-container (push) Has been cancelled
Build and Test / test-output (push) Has been cancelled
* fix: expand merge commit SHA regex and add SHA-256 test cases

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: add checkCommitInfo SHA coverage

Add checkCommitInfo tests for SHA-1 and SHA-256 merge messages and reject invalid 50-character hex merge heads.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style: fix Prettier formatting in test and source files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 13:30:55 -04:00
Salman Muin Kayser Chishti
45abae3e9f feat: Honor preserve-local-changes flag when repository URL changes
Makes preserve-local-changes option work consistently in all scenarios,
including when repository URL changes. Updates warning message to
correctly reflect this behavior.
2025-08-11 14:10:22 +01:00
Salman Muin Kayser Chishti
215f9562a1 update tests 2025-08-11 13:00:43 +01:00
Salman Muin Kayser Chishti
caa5717450 dist 2025-08-11 12:48:35 +01:00
Salman Muin Kayser Chishti
630cdb3874 linting 2025-08-11 12:47:29 +01:00
Salman Muin Kayser Chishti
6503dcd44c update 2025-08-11 12:39:11 +01:00
Salman Muin Kayser Chishti
ebd82bae91 Update logic for preserving local changes in the checkout action 2025-08-11 12:33:14 +01:00
Salman Muin Kayser Chishti
f04b821901 kebab case 2025-08-11 12:25:16 +01:00
Salman Muin Kayser Chishti
45fe6460ed try preserve local changes option 2025-08-11 12:21:31 +01:00
13 changed files with 610 additions and 31 deletions

View File

@ -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:

View File

@ -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',

View File

@ -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()
}) })

View File

@ -133,6 +133,16 @@ describe('input-helper tests', () => {
expect(settings.commit).toBe('1111111111222222222233333333334444444444') expect(settings.commit).toBe('1111111111222222222233333333334444444444')
}) })
it('sets ref to empty when explicit sha-256', async () => {
inputs.ref =
'1111111111222222222233333333334444444444555555555566666666667777'
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.ref).toBeFalsy()
expect(settings.commit).toBe(
'1111111111222222222233333333334444444444555555555566666666667777'
)
})
it('sets sha to empty when explicit ref', async () => { it('sets sha to empty when explicit ref', async () => {
inputs.ref = 'refs/heads/some-other-ref' inputs.ref = 'refs/heads/some-other-ref'
const settings: IGitSourceSettings = await inputHelper.getInputs() const settings: IGitSourceSettings = await inputHelper.getInputs()

View File

@ -1,8 +1,12 @@
import * as assert from 'assert' import * as assert from 'assert'
import * as core from '@actions/core'
import * as github from '@actions/github'
import * as refHelper from '../lib/ref-helper' import * as refHelper from '../lib/ref-helper'
import {IGitCommandManager} from '../lib/git-command-manager' import {IGitCommandManager} from '../lib/git-command-manager'
const commit = '1234567890123456789012345678901234567890' const commit = '1234567890123456789012345678901234567890'
const sha256Commit =
'1234567890123456789012345678901234567890123456789012345678901234'
let git: IGitCommandManager let git: IGitCommandManager
describe('ref-helper tests', () => { describe('ref-helper tests', () => {
@ -37,6 +41,12 @@ describe('ref-helper tests', () => {
expect(checkoutInfo.startPoint).toBeFalsy() expect(checkoutInfo.startPoint).toBeFalsy()
}) })
it('getCheckoutInfo sha-256 only', async () => {
const checkoutInfo = await refHelper.getCheckoutInfo(git, '', sha256Commit)
expect(checkoutInfo.ref).toBe(sha256Commit)
expect(checkoutInfo.startPoint).toBeFalsy()
})
it('getCheckoutInfo refs/heads/', async () => { it('getCheckoutInfo refs/heads/', async () => {
const checkoutInfo = await refHelper.getCheckoutInfo( const checkoutInfo = await refHelper.getCheckoutInfo(
git, git,
@ -227,4 +237,142 @@ describe('ref-helper tests', () => {
'+refs/heads/my/branch:refs/remotes/origin/my/branch' '+refs/heads/my/branch:refs/remotes/origin/my/branch'
) )
}) })
describe('checkCommitInfo', () => {
const repositoryOwner = 'some-owner'
const repositoryName = 'some-repo'
const ref = 'refs/pull/123/merge'
const sha1Head = '1111111111222222222233333333334444444444'
const sha1Base = 'aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd'
const sha256Head =
'1111111111222222222233333333334444444444555555555566666666667777'
const sha256Base =
'aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffff0000'
let debugSpy: jest.SpyInstance
let getOctokitSpy: jest.SpyInstance
let repoGetSpy: jest.Mock
let originalEventName: string
let originalPayload: unknown
let originalRef: string
let originalSha: string
function setPullRequestContext(
expectedHeadSha: string,
expectedBaseSha: string,
mergeCommit: string
): void {
;(github.context as any).eventName = 'pull_request'
github.context.ref = ref
github.context.sha = mergeCommit
;(github.context as any).payload = {
action: 'synchronize',
after: expectedHeadSha,
number: 123,
pull_request: {
base: {
sha: expectedBaseSha
}
},
repository: {
private: false
}
}
}
beforeEach(() => {
originalEventName = github.context.eventName
originalPayload = github.context.payload
originalRef = github.context.ref
originalSha = github.context.sha
jest.spyOn(github.context, 'repo', 'get').mockReturnValue({
owner: repositoryOwner,
repo: repositoryName
})
debugSpy = jest.spyOn(core, 'debug').mockImplementation(jest.fn())
repoGetSpy = jest.fn(async () => ({}))
getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({
rest: {
repos: {
get: repoGetSpy
}
}
} as any)
})
afterEach(() => {
;(github.context as any).eventName = originalEventName
;(github.context as any).payload = originalPayload
github.context.ref = originalRef
github.context.sha = originalSha
jest.restoreAllMocks()
})
it('returns early for SHA-1 merge commit', async () => {
setPullRequestContext(sha1Head, sha1Base, commit)
await refHelper.checkCommitInfo(
'token',
`Merge ${sha1Head} into ${sha1Base}`,
repositoryOwner,
repositoryName,
ref,
commit
)
expect(getOctokitSpy).not.toHaveBeenCalled()
expect(repoGetSpy).not.toHaveBeenCalled()
})
it('matches SHA-256 merge commit info', async () => {
const actualHeadSha =
'9999999999888888888877777777776666666666555555555544444444443333'
setPullRequestContext(sha256Head, sha256Base, sha256Commit)
await refHelper.checkCommitInfo(
'token',
`Merge ${actualHeadSha} into ${sha256Base}`,
repositoryOwner,
repositoryName,
ref,
sha256Commit
)
expect(getOctokitSpy).toHaveBeenCalledWith(
'token',
expect.objectContaining({
userAgent: expect.stringContaining(
`expected_head_sha=${sha256Head};actual_head_sha=${actualHeadSha}`
)
})
)
expect(repoGetSpy).toHaveBeenCalledWith({
owner: repositoryOwner,
repo: repositoryName
})
expect(debugSpy).toHaveBeenCalledWith(
`Expected head sha ${sha256Head}; actual head sha ${actualHeadSha}`
)
expect(debugSpy).not.toHaveBeenCalledWith('Unexpected message format')
})
it('does not match 50-char hex as a valid merge', async () => {
const invalidHeadSha =
'99999999998888888888777777777766666666665555555555'
setPullRequestContext(sha1Head, sha1Base, commit)
await refHelper.checkCommitInfo(
'token',
`Merge ${invalidHeadSha} into ${sha1Base}`,
repositoryOwner,
repositoryName,
ref,
commit
)
expect(getOctokitSpy).not.toHaveBeenCalled()
expect(repoGetSpy).not.toHaveBeenCalled()
expect(debugSpy).toHaveBeenCalledWith('Unexpected message format')
})
})
}) })

View File

@ -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.

148
dist/index.js vendored
View File

@ -780,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);
} }
@ -1276,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;
@ -1363,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
});
}
}
}); });
} }
@ -1467,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
@ -1593,7 +1619,104 @@ function getSource(settings) {
} }
// Checkout // Checkout
core.startGroup('Checking out the ref'); core.startGroup('Checking out the ref');
yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint); 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);
}
core.endGroup(); core.endGroup();
// Submodules // Submodules
if (settings.submodules) { if (settings.submodules) {
@ -2021,7 +2144,7 @@ function getInputs() {
} }
} }
// SHA? // SHA?
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) { else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) {
result.commit = result.ref; result.commit = result.ref;
result.ref = ''; result.ref = '';
} }
@ -2030,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) {
@ -2444,7 +2572,7 @@ function checkCommitInfo(token, commitInfo, repositoryOwner, repositoryName, ref
return; return;
} }
// Extract details from message // Extract details from message
const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/); const match = commitInfo.match(/Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/);
if (!match) { if (!match) {
core.debug('Unexpected message format'); core.debug('Unexpected message format');
return; return;

View File

@ -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,
@ -220,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 {

View File

@ -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
})
}
} }
} }

View File

@ -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
) )
} }
@ -251,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')
await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint) 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)
}
core.endGroup() core.endGroup()
// Submodules // Submodules

View File

@ -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
*/ */

View File

@ -71,7 +71,7 @@ export async function getInputs(): Promise<IGitSourceSettings> {
} }
} }
// SHA? // SHA?
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) { else if (result.ref.match(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/)) {
result.commit = result.ref result.commit = result.ref
result.ref = '' result.ref = ''
} }
@ -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) {

View File

@ -258,7 +258,9 @@ export async function checkCommitInfo(
} }
// Extract details from message // Extract details from message
const match = commitInfo.match(/Merge ([0-9a-f]{40}) into ([0-9a-f]{40})/) const match = commitInfo.match(
/Merge ([0-9a-f]{40}|[0-9a-f]{64}) into ([0-9a-f]{40}|[0-9a-f]{64})/
)
if (!match) { if (!match) {
core.debug('Unexpected message format') core.debug('Unexpected message format')
return return