diff --git a/README.md b/README.md index 9a32e9a..eab815e 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,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: '' @@ -349,6 +355,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: diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 7633704..5c8eec2 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -814,6 +814,7 @@ async function setup(testName: string): Promise { submodules: false, nestedSubmodules: false, persistCredentials: true, + preserveLocalChanges: false, ref: 'refs/heads/main', repositoryName: 'my-repo', repositoryOwner: 'my-org', diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index 22e9ae6..5d3fb4f 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -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 + 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 + 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() }) diff --git a/action.yml b/action.yml index 767c416..0665486 100644 --- a/action.yml +++ b/action.yml @@ -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. diff --git a/dist/index.js b/dist/index.js index f3ae6f3..7902246 100644 --- a/dist/index.js +++ b/dist/index.js @@ -609,9 +609,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); } @@ -1025,13 +1033,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; @@ -1112,14 +1124,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 + }); + } + } }); } @@ -1216,7 +1242,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 @@ -1329,7 +1355,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) { @@ -1766,6 +1889,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) { diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 8e42a38..580d9f2 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -22,7 +22,7 @@ export interface IGitCommandManager { disableSparseCheckout(): Promise sparseCheckout(sparseCheckout: string[]): Promise sparseCheckoutNonConeMode(sparseCheckout: string[]): Promise - checkout(ref: string, startPoint: string): Promise + checkout(ref: string, startPoint: string, options?: string[]): Promise checkoutDetach(): Promise config( configKey: string, @@ -203,8 +203,21 @@ class GitCommandManager { ) } - async checkout(ref: string, startPoint: string): Promise { - const args = ['checkout', '--progress', '--force'] + async checkout( + ref: string, + startPoint: string, + options: string[] = [] + ): Promise { + 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 { diff --git a/src/git-directory-helper.ts b/src/git-directory-helper.ts index 9a0085f..6d57bcf 100644 --- a/src/git-directory-helper.ts +++ b/src/git-directory-helper.ts @@ -11,7 +11,8 @@ export async function prepareExistingDirectory( repositoryPath: string, repositoryUrl: string, clean: boolean, - ref: string + ref: string, + preserveLocalChanges: boolean = false ): Promise { 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 + }) + } } } diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index 2d35138..c484f97 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -70,7 +70,8 @@ export async function getSource(settings: IGitSourceSettings): Promise { settings.repositoryPath, repositoryUrl, settings.clean, - settings.ref + settings.ref, + settings.preserveLocalChanges ) } @@ -229,7 +230,115 @@ export async function getSource(settings: IGitSourceSettings): Promise { // 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 diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts index 4e41ac3..d6b2f44 100644 --- a/src/git-source-settings.ts +++ b/src/git-source-settings.ts @@ -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 */ diff --git a/src/input-helper.ts b/src/input-helper.ts index 059232f..c4f29f1 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -82,6 +82,12 @@ export async function getInputs(): Promise { 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) {