mirror of https://github.com/actions/checkout
Compare commits
10 Commits
c3c8a0719d
...
9d20889f09
| Author | SHA1 | Date |
|---|---|---|
|
|
9d20889f09 | |
|
|
1cce3390c2 | |
|
|
45abae3e9f | |
|
|
215f9562a1 | |
|
|
caa5717450 | |
|
|
630cdb3874 | |
|
|
6503dcd44c | |
|
|
ebd82bae91 | |
|
|
f04b821901 | |
|
|
45fe6460ed |
21
README.md
21
README.md
|
|
@ -112,6 +112,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/
|
|||
# Default: true
|
||||
clean: ''
|
||||
|
||||
# Whether to preserve local changes during checkout. If true, tries to preserve
|
||||
# local files that are not tracked by Git. By default, all files will be
|
||||
# overwritten.
|
||||
# Default: false
|
||||
preserve-local-changes: ''
|
||||
|
||||
# Partially clone against a given filter. Overrides sparse-checkout if set.
|
||||
# Default: null
|
||||
filter: ''
|
||||
|
|
@ -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
|
||||
|
||||
## Preserve local changes during checkout
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Create file before checkout
|
||||
shell: pwsh
|
||||
run: New-Item -Path . -Name "example.txt" -ItemType "File"
|
||||
|
||||
- name: Checkout with preserving local changes
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
clean: false
|
||||
preserve-local-changes: true
|
||||
```
|
||||
|
||||
# Recommended permissions
|
||||
|
||||
When using the `checkout` action in your GitHub Actions workflow, it is recommended to set the following `GITHUB_TOKEN` permissions to ensure proper functionality, unless alternative auth is provided via the `token` or `ssh-key` inputs:
|
||||
|
|
|
|||
|
|
@ -1163,6 +1163,7 @@ async function setup(testName: string): Promise<void> {
|
|||
submodules: false,
|
||||
nestedSubmodules: false,
|
||||
persistCredentials: true,
|
||||
preserveLocalChanges: false,
|
||||
ref: 'refs/heads/main',
|
||||
repositoryName: 'my-repo',
|
||||
repositoryOwner: 'my-org',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,59 @@ describe('Test fetchDepth and fetchTags options', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('repository initialization object format', () => {
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
|
||||
jest.spyOn(fshelper, 'directoryExistsSync').mockImplementation(jest.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('initializes SHA-256 repositories with the matching object format', async () => {
|
||||
mockExec.mockImplementation((path, args, options) => {
|
||||
if (args.includes('version')) {
|
||||
options.listeners.stdout(Buffer.from('git version 2.50.1'))
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||
|
||||
git = await commandManager.createCommandManager('test', false, false)
|
||||
|
||||
await git.init('sha256')
|
||||
|
||||
expect(mockExec).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
['init', '--object-format=sha256', 'test'],
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('initializes SHA-1 repositories with existing default arguments', async () => {
|
||||
mockExec.mockImplementation((path, args, options) => {
|
||||
if (args.includes('version')) {
|
||||
options.listeners.stdout(Buffer.from('git version 2.50.1'))
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
jest.spyOn(exec, 'exec').mockImplementation(mockExec)
|
||||
|
||||
git = await commandManager.createCommandManager('test', false, false)
|
||||
|
||||
await git.init('sha1')
|
||||
|
||||
expect(mockExec).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
['init', 'test'],
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('git user-agent with orchestration ID', () => {
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(fshelper, 'fileExistsSync').mockImplementation(jest.fn())
|
||||
|
|
|
|||
|
|
@ -143,12 +143,41 @@ describe('git-directory-helper tests', () => {
|
|||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
ref,
|
||||
false // preserveLocalChanges = false
|
||||
)
|
||||
|
||||
// Assert
|
||||
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(core.warning).toHaveBeenCalled()
|
||||
expect(git.tryReset).not.toHaveBeenCalled()
|
||||
|
|
@ -170,16 +199,50 @@ describe('git-directory-helper tests', () => {
|
|||
repositoryPath,
|
||||
differentRepositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
ref,
|
||||
false // preserveLocalChanges = false
|
||||
)
|
||||
|
||||
// Assert
|
||||
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(git.isDetached).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const keepsContentsWhenDifferentRepositoryUrlAndPreserveLocalChanges =
|
||||
'keeps contents when different repository url and preserve-local-changes is true'
|
||||
it(keepsContentsWhenDifferentRepositoryUrlAndPreserveLocalChanges, async () => {
|
||||
// Arrange
|
||||
await setup(keepsContentsWhenDifferentRepositoryUrlAndPreserveLocalChanges)
|
||||
clean = false
|
||||
|
||||
// Create a file that we expect to be preserved
|
||||
await fs.promises.writeFile(path.join(repositoryPath, 'my-file'), '')
|
||||
|
||||
// Simulate a different repository by simply removing the .git directory
|
||||
await io.rmRF(path.join(repositoryPath, '.git'))
|
||||
await fs.promises.mkdir(path.join(repositoryPath, '.git'))
|
||||
|
||||
const differentRepositoryUrl = 'https://github.com/my-different-org/my-different-repo'
|
||||
|
||||
// Act
|
||||
await gitDirectoryHelper.prepareExistingDirectory(
|
||||
git,
|
||||
repositoryPath,
|
||||
differentRepositoryUrl, // Use a different URL
|
||||
clean,
|
||||
ref,
|
||||
true // preserveLocalChanges = true
|
||||
)
|
||||
|
||||
// Assert
|
||||
const files = await fs.promises.readdir(repositoryPath)
|
||||
console.log('Files after operation:', files)
|
||||
// When preserveLocalChanges is true, files should be preserved even with different repo URL
|
||||
expect(files.sort()).toEqual(['.git', 'my-file'].sort())
|
||||
})
|
||||
|
||||
const removesContentsWhenNoGitDirectory =
|
||||
'removes contents when no git directory'
|
||||
it(removesContentsWhenNoGitDirectory, async () => {
|
||||
|
|
@ -221,12 +284,41 @@ describe('git-directory-helper tests', () => {
|
|||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
ref,
|
||||
false // preserveLocalChanges = false
|
||||
)
|
||||
|
||||
// Assert
|
||||
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.tryReset).toHaveBeenCalled()
|
||||
expect(core.warning).toHaveBeenCalled()
|
||||
|
|
@ -246,12 +338,13 @@ describe('git-directory-helper tests', () => {
|
|||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
ref,
|
||||
false // preserveLocalChanges = false
|
||||
)
|
||||
|
||||
// Assert
|
||||
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()
|
||||
})
|
||||
|
||||
|
|
@ -302,12 +395,13 @@ describe('git-directory-helper tests', () => {
|
|||
repositoryPath,
|
||||
repositoryUrl,
|
||||
clean,
|
||||
ref
|
||||
ref,
|
||||
false // preserveLocalChanges = false
|
||||
)
|
||||
|
||||
// Assert
|
||||
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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import * as githubApiHelper from '../lib/github-api-helper'
|
||||
|
||||
describe('github-api-helper object format', () => {
|
||||
let getOctokitSpy: jest.SpyInstance
|
||||
let debugSpy: jest.SpyInstance
|
||||
let request: jest.Mock
|
||||
|
||||
function mockHashAlgorithmApi(hashAlgorithm: string): void {
|
||||
request = jest.fn(async () => ({
|
||||
data: {
|
||||
hash_algorithm: hashAlgorithm
|
||||
}
|
||||
}))
|
||||
getOctokitSpy = jest.spyOn(github, 'getOctokit').mockReturnValue({
|
||||
request
|
||||
} as any)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
debugSpy = jest.spyOn(core, 'debug').mockImplementation(jest.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('detects SHA-256 from the repository hash algorithm endpoint', async () => {
|
||||
mockHashAlgorithmApi('sha256')
|
||||
|
||||
await expect(
|
||||
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
|
||||
).resolves.toEqual({format: 'sha256', succeeded: true})
|
||||
|
||||
expect(getOctokitSpy).toHaveBeenCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({baseUrl: 'https://api.github.com'})
|
||||
)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'GET /repos/{owner}/{repo}/hash-algorithm',
|
||||
{owner: 'owner', repo: 'repo'}
|
||||
)
|
||||
})
|
||||
|
||||
it('detects SHA-1 from the repository hash algorithm endpoint', async () => {
|
||||
mockHashAlgorithmApi('sha1')
|
||||
|
||||
await expect(
|
||||
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
|
||||
).resolves.toEqual({format: 'sha1', succeeded: true})
|
||||
})
|
||||
|
||||
it('detects object format from an existing commit without API calls', async () => {
|
||||
const commitSha =
|
||||
'9422233ca7ee1b17f1e905d0e141faf0c401556c41cdc6acd71c6bd685da2e92'
|
||||
getOctokitSpy = jest.spyOn(github, 'getOctokit')
|
||||
|
||||
await expect(
|
||||
githubApiHelper.tryGetRepositoryObjectFormat(
|
||||
'token',
|
||||
'owner',
|
||||
'repo',
|
||||
undefined,
|
||||
commitSha
|
||||
)
|
||||
).resolves.toEqual({format: 'sha256', succeeded: true})
|
||||
|
||||
expect(getOctokitSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unsuccessful when the hash algorithm endpoint value is not recognized', async () => {
|
||||
mockHashAlgorithmApi('unknown')
|
||||
|
||||
await expect(
|
||||
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
|
||||
).resolves.toEqual({format: '', succeeded: false})
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
'Unable to determine repository object format from hash-algorithm endpoint'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns unsuccessful when the hash algorithm API lookup fails', async () => {
|
||||
request = jest.fn(async () => {
|
||||
throw new Error('not found')
|
||||
})
|
||||
jest.spyOn(github, 'getOctokit').mockReturnValue({
|
||||
request
|
||||
} as any)
|
||||
|
||||
await expect(
|
||||
githubApiHelper.tryGetRepositoryObjectFormat('token', 'owner', 'repo')
|
||||
).resolves.toEqual({format: '', succeeded: false})
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
'Unable to determine repository object format from hash-algorithm endpoint: not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -56,7 +56,10 @@ inputs:
|
|||
description: 'Relative path under $GITHUB_WORKSPACE to place the repository'
|
||||
clean:
|
||||
description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching'
|
||||
default: true
|
||||
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:
|
||||
description: >
|
||||
Partially clone against a given filter.
|
||||
|
|
|
|||
|
|
@ -780,9 +780,17 @@ class GitCommandManager {
|
|||
yield fs.promises.appendFile(sparseCheckoutPath, `\n${sparseCheckout.join('\n')}\n`);
|
||||
});
|
||||
}
|
||||
checkout(ref, startPoint) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const args = ['checkout', '--progress', '--force'];
|
||||
checkout(ref_1, startPoint_1) {
|
||||
return __awaiter(this, arguments, void 0, function* (ref, startPoint, options = []) {
|
||||
const args = ['checkout', '--progress'];
|
||||
// Add custom options (like --merge) if provided
|
||||
if (options.length > 0) {
|
||||
args.push(...options);
|
||||
}
|
||||
else {
|
||||
// Default behavior - use force
|
||||
args.push('--force');
|
||||
}
|
||||
if (startPoint) {
|
||||
args.push('-B', ref, startPoint);
|
||||
}
|
||||
|
|
@ -896,9 +904,14 @@ class GitCommandManager {
|
|||
getWorkingDirectory() {
|
||||
return this.workingDirectory;
|
||||
}
|
||||
init() {
|
||||
init(objectFormat) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
yield this.execGit(['init', this.workingDirectory]);
|
||||
const args = ['init'];
|
||||
if (objectFormat === 'sha256') {
|
||||
args.push('--object-format=sha256');
|
||||
}
|
||||
args.push(this.workingDirectory);
|
||||
yield this.execGit(args);
|
||||
});
|
||||
}
|
||||
isDetached() {
|
||||
|
|
@ -1276,13 +1289,17 @@ const fs = __importStar(__nccwpck_require__(7147));
|
|||
const fsHelper = __importStar(__nccwpck_require__(7219));
|
||||
const io = __importStar(__nccwpck_require__(7436));
|
||||
const path = __importStar(__nccwpck_require__(1017));
|
||||
function prepareExistingDirectory(git, repositoryPath, repositoryUrl, clean, ref) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
function prepareExistingDirectory(git_1, repositoryPath_1, repositoryUrl_1, clean_1, ref_1) {
|
||||
return __awaiter(this, arguments, void 0, function* (git, repositoryPath, repositoryUrl, clean, ref, preserveLocalChanges = false) {
|
||||
var _a;
|
||||
assert.ok(repositoryPath, 'Expected repositoryPath to be defined');
|
||||
assert.ok(repositoryUrl, 'Expected repositoryUrl to be defined');
|
||||
// Indicates whether to delete the directory contents
|
||||
let remove = false;
|
||||
// If preserveLocalChanges is true, log it
|
||||
if (preserveLocalChanges) {
|
||||
core.info(`Preserve local changes is enabled, will attempt to keep local files`);
|
||||
}
|
||||
// Check whether using git or REST API
|
||||
if (!git) {
|
||||
remove = true;
|
||||
|
|
@ -1363,14 +1380,28 @@ function prepareExistingDirectory(git, repositoryPath, repositoryUrl, clean, ref
|
|||
remove = true;
|
||||
}
|
||||
}
|
||||
if (remove) {
|
||||
if (remove && !preserveLocalChanges) {
|
||||
// Delete the contents of the directory. Don't delete the directory itself
|
||||
// since it might be the current working directory.
|
||||
core.info(`Deleting the contents of '${repositoryPath}'`);
|
||||
for (const file of yield fs.promises.readdir(repositoryPath)) {
|
||||
// Skip .git directory as we need it to determine if a file is tracked
|
||||
if (file === '.git') {
|
||||
continue;
|
||||
}
|
||||
yield io.rmRF(path.join(repositoryPath, file));
|
||||
}
|
||||
}
|
||||
else if (remove && preserveLocalChanges) {
|
||||
core.info(`Skipping deletion of directory contents due to preserve-local-changes setting`);
|
||||
// We still need to make sure we have a git repository to work with
|
||||
if (!git) {
|
||||
core.info(`Initializing git repository to prepare for checkout with preserved changes`);
|
||||
yield fs.promises.mkdir(path.join(repositoryPath, '.git'), {
|
||||
recursive: true
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1467,7 +1498,7 @@ function getSource(settings) {
|
|||
}
|
||||
// Prepare existing directory, otherwise recreate
|
||||
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) {
|
||||
// Downloading using REST API
|
||||
|
|
@ -1486,8 +1517,17 @@ function getSource(settings) {
|
|||
stateHelper.setRepositoryPath(settings.repositoryPath);
|
||||
// Initialize the repository
|
||||
if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) {
|
||||
core.startGroup('Determining repository object format');
|
||||
const objectFormatResult = yield githubApiHelper.tryGetRepositoryObjectFormat(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.githubServerUrl, settings.commit);
|
||||
const objectFormat = objectFormatResult.succeeded
|
||||
? objectFormatResult.format
|
||||
: '';
|
||||
if (objectFormat === 'sha256') {
|
||||
core.info('Detected SHA-256 repository object format');
|
||||
}
|
||||
core.endGroup();
|
||||
core.startGroup('Initializing the repository');
|
||||
yield git.init();
|
||||
yield git.init(objectFormat);
|
||||
yield git.remoteAdd('origin', repositoryUrl);
|
||||
core.endGroup();
|
||||
}
|
||||
|
|
@ -1593,7 +1633,104 @@ function getSource(settings) {
|
|||
}
|
||||
// Checkout
|
||||
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();
|
||||
// Submodules
|
||||
if (settings.submodules) {
|
||||
|
|
@ -1810,6 +1947,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||
exports.downloadRepository = downloadRepository;
|
||||
exports.getDefaultBranch = getDefaultBranch;
|
||||
exports.tryGetRepositoryObjectFormat = tryGetRepositoryObjectFormat;
|
||||
const assert = __importStar(__nccwpck_require__(9491));
|
||||
const core = __importStar(__nccwpck_require__(2186));
|
||||
const fs = __importStar(__nccwpck_require__(7147));
|
||||
|
|
@ -1911,6 +2049,40 @@ function getDefaultBranch(authToken, owner, repo, baseUrl) {
|
|||
}));
|
||||
});
|
||||
}
|
||||
function tryGetRepositoryObjectFormat(authToken, owner, repo, baseUrl, commit) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a;
|
||||
const commitFormat = getObjectFormat(commit);
|
||||
if (commitFormat) {
|
||||
return { format: commitFormat, succeeded: true };
|
||||
}
|
||||
try {
|
||||
const octokit = github.getOctokit(authToken, {
|
||||
baseUrl: (0, url_helper_1.getServerApiUrl)(baseUrl)
|
||||
});
|
||||
const response = yield octokit.request('GET /repos/{owner}/{repo}/hash-algorithm', { owner, repo });
|
||||
const hashAlgorithm = response.data.hash_algorithm;
|
||||
if (hashAlgorithm === 'sha256' || hashAlgorithm === 'sha1') {
|
||||
return { format: hashAlgorithm, succeeded: true };
|
||||
}
|
||||
core.debug('Unable to determine repository object format from hash-algorithm endpoint');
|
||||
return { format: '', succeeded: false };
|
||||
}
|
||||
catch (err) {
|
||||
core.debug(`Unable to determine repository object format from hash-algorithm endpoint: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
||||
return { format: '', succeeded: false };
|
||||
}
|
||||
});
|
||||
}
|
||||
function getObjectFormat(sha) {
|
||||
if (/^[0-9a-fA-F]{64}$/.test(sha || '')) {
|
||||
return 'sha256';
|
||||
}
|
||||
if (/^[0-9a-fA-F]{40}$/.test(sha || '')) {
|
||||
return 'sha1';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function downloadArchive(authToken, owner, repo, ref, commit, baseUrl) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const octokit = github.getOctokit(authToken, {
|
||||
|
|
@ -2030,6 +2202,11 @@ function getInputs() {
|
|||
// Clean
|
||||
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE';
|
||||
core.debug(`clean = ${result.clean}`);
|
||||
// Preserve local changes
|
||||
result.preserveLocalChanges =
|
||||
(core.getInput('preserve-local-changes') || 'false').toUpperCase() ===
|
||||
'TRUE';
|
||||
core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`);
|
||||
// Filter
|
||||
const filter = core.getInput('filter');
|
||||
if (filter) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export interface IGitCommandManager {
|
|||
disableSparseCheckout(): Promise<void>
|
||||
sparseCheckout(sparseCheckout: string[]): Promise<void>
|
||||
sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise<void>
|
||||
checkout(ref: string, startPoint: string): Promise<void>
|
||||
checkout(ref: string, startPoint: string, options?: string[]): Promise<void>
|
||||
checkoutDetach(): Promise<void>
|
||||
config(
|
||||
configKey: string,
|
||||
|
|
@ -43,7 +43,7 @@ export interface IGitCommandManager {
|
|||
getDefaultBranch(repositoryUrl: string): Promise<string>
|
||||
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
|
||||
getWorkingDirectory(): string
|
||||
init(): Promise<void>
|
||||
init(objectFormat?: string): Promise<void>
|
||||
isDetached(): Promise<boolean>
|
||||
lfsFetch(ref: string): Promise<void>
|
||||
lfsInstall(): Promise<void>
|
||||
|
|
@ -220,8 +220,21 @@ class GitCommandManager {
|
|||
)
|
||||
}
|
||||
|
||||
async checkout(ref: string, startPoint: string): Promise<void> {
|
||||
const args = ['checkout', '--progress', '--force']
|
||||
async checkout(
|
||||
ref: string,
|
||||
startPoint: string,
|
||||
options: string[] = []
|
||||
): Promise<void> {
|
||||
const args = ['checkout', '--progress']
|
||||
|
||||
// Add custom options (like --merge) if provided
|
||||
if (options.length > 0) {
|
||||
args.push(...options)
|
||||
} else {
|
||||
// Default behavior - use force
|
||||
args.push('--force')
|
||||
}
|
||||
|
||||
if (startPoint) {
|
||||
args.push('-B', ref, startPoint)
|
||||
} else {
|
||||
|
|
@ -364,8 +377,14 @@ class GitCommandManager {
|
|||
return this.workingDirectory
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.execGit(['init', this.workingDirectory])
|
||||
async init(objectFormat?: string): Promise<void> {
|
||||
const args = ['init']
|
||||
if (objectFormat === 'sha256') {
|
||||
args.push('--object-format=sha256')
|
||||
}
|
||||
args.push(this.workingDirectory)
|
||||
|
||||
await this.execGit(args)
|
||||
}
|
||||
|
||||
async isDetached(): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ export async function prepareExistingDirectory(
|
|||
repositoryPath: string,
|
||||
repositoryUrl: string,
|
||||
clean: boolean,
|
||||
ref: string
|
||||
ref: string,
|
||||
preserveLocalChanges: boolean = false
|
||||
): Promise<void> {
|
||||
assert.ok(repositoryPath, 'Expected repositoryPath 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
|
||||
let remove = false
|
||||
|
||||
// If preserveLocalChanges is true, log it
|
||||
if (preserveLocalChanges) {
|
||||
core.info(
|
||||
`Preserve local changes is enabled, will attempt to keep local files`
|
||||
)
|
||||
}
|
||||
|
||||
// Check whether using git or REST API
|
||||
if (!git) {
|
||||
remove = true
|
||||
|
|
@ -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
|
||||
// since it might be the current working directory.
|
||||
core.info(`Deleting the contents of '${repositoryPath}'`)
|
||||
for (const file of await fs.promises.readdir(repositoryPath)) {
|
||||
// Skip .git directory as we need it to determine if a file is tracked
|
||||
if (file === '.git') {
|
||||
continue
|
||||
}
|
||||
await io.rmRF(path.join(repositoryPath, file))
|
||||
}
|
||||
} else if (remove && preserveLocalChanges) {
|
||||
core.info(
|
||||
`Skipping deletion of directory contents due to preserve-local-changes setting`
|
||||
)
|
||||
// We still need to make sure we have a git repository to work with
|
||||
if (!git) {
|
||||
core.info(
|
||||
`Initializing git repository to prepare for checkout with preserved changes`
|
||||
)
|
||||
await fs.promises.mkdir(path.join(repositoryPath, '.git'), {
|
||||
recursive: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||
settings.repositoryPath,
|
||||
repositoryUrl,
|
||||
settings.clean,
|
||||
settings.ref
|
||||
settings.ref,
|
||||
settings.preserveLocalChanges
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -109,8 +110,25 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||
if (
|
||||
!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))
|
||||
) {
|
||||
core.startGroup('Determining repository object format')
|
||||
const objectFormatResult =
|
||||
await githubApiHelper.tryGetRepositoryObjectFormat(
|
||||
settings.authToken,
|
||||
settings.repositoryOwner,
|
||||
settings.repositoryName,
|
||||
settings.githubServerUrl,
|
||||
settings.commit
|
||||
)
|
||||
const objectFormat = objectFormatResult.succeeded
|
||||
? objectFormatResult.format
|
||||
: ''
|
||||
if (objectFormat === 'sha256') {
|
||||
core.info('Detected SHA-256 repository object format')
|
||||
}
|
||||
core.endGroup()
|
||||
|
||||
core.startGroup('Initializing the repository')
|
||||
await git.init()
|
||||
await git.init(objectFormat)
|
||||
await git.remoteAdd('origin', repositoryUrl)
|
||||
core.endGroup()
|
||||
}
|
||||
|
|
@ -251,7 +269,115 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> {
|
|||
|
||||
// Checkout
|
||||
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()
|
||||
|
||||
// Submodules
|
||||
|
|
|
|||
|
|
@ -25,10 +25,15 @@ export interface IGitSourceSettings {
|
|||
commit: string
|
||||
|
||||
/**
|
||||
* Indicates whether to clean the repository
|
||||
* Whether to execute git clean and git reset before fetching
|
||||
*/
|
||||
clean: boolean
|
||||
|
||||
/**
|
||||
* Whether to preserve local changes during checkout
|
||||
*/
|
||||
preserveLocalChanges: boolean
|
||||
|
||||
/**
|
||||
* The filter determining which objects to include
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import {getServerApiUrl} from './url-helper'
|
|||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
export interface RepositoryObjectFormatResult {
|
||||
format: string
|
||||
succeeded: boolean
|
||||
}
|
||||
|
||||
export async function downloadRepository(
|
||||
authToken: string,
|
||||
owner: string,
|
||||
|
|
@ -122,6 +127,53 @@ export async function getDefaultBranch(
|
|||
})
|
||||
}
|
||||
|
||||
export async function tryGetRepositoryObjectFormat(
|
||||
authToken: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
baseUrl?: string,
|
||||
commit?: string
|
||||
): Promise<RepositoryObjectFormatResult> {
|
||||
const commitFormat = getObjectFormat(commit)
|
||||
if (commitFormat) {
|
||||
return {format: commitFormat, succeeded: true}
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = github.getOctokit(authToken, {
|
||||
baseUrl: getServerApiUrl(baseUrl)
|
||||
})
|
||||
const response = await octokit.request(
|
||||
'GET /repos/{owner}/{repo}/hash-algorithm',
|
||||
{owner, repo}
|
||||
)
|
||||
const hashAlgorithm = response.data.hash_algorithm
|
||||
if (hashAlgorithm === 'sha256' || hashAlgorithm === 'sha1') {
|
||||
return {format: hashAlgorithm, succeeded: true}
|
||||
}
|
||||
|
||||
core.debug(
|
||||
'Unable to determine repository object format from hash-algorithm endpoint'
|
||||
)
|
||||
return {format: '', succeeded: false}
|
||||
} catch (err) {
|
||||
core.debug(
|
||||
`Unable to determine repository object format from hash-algorithm endpoint: ${(err as any)?.message ?? err}`
|
||||
)
|
||||
return {format: '', succeeded: false}
|
||||
}
|
||||
}
|
||||
|
||||
function getObjectFormat(sha?: string): string {
|
||||
if (/^[0-9a-fA-F]{64}$/.test(sha || '')) {
|
||||
return 'sha256'
|
||||
}
|
||||
if (/^[0-9a-fA-F]{40}$/.test(sha || '')) {
|
||||
return 'sha1'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function downloadArchive(
|
||||
authToken: string,
|
||||
owner: string,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,12 @@ export async function getInputs(): Promise<IGitSourceSettings> {
|
|||
result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'
|
||||
core.debug(`clean = ${result.clean}`)
|
||||
|
||||
// Preserve local changes
|
||||
result.preserveLocalChanges =
|
||||
(core.getInput('preserve-local-changes') || 'false').toUpperCase() ===
|
||||
'TRUE'
|
||||
core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`)
|
||||
|
||||
// Filter
|
||||
const filter = core.getInput('filter')
|
||||
if (filter) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue