From 45fe6460eda7ae99de1b51ebce0b7b95733981c6 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Mon, 11 Aug 2025 12:21:31 +0100 Subject: [PATCH 1/8] try preserve local changes option --- README.md | 20 ++++++++++++++++++++ action.yml | 5 ++++- src/git-command-manager.ts | 19 ++++++++++++++++--- src/git-source-provider.ts | 10 +++++++++- src/git-source-settings.ts | 7 ++++++- src/input-helper.ts | 4 ++++ 6 files changed, 59 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8969446..1e94e04 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ 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 + preserveLocalChanges: '' + # Partially clone against a given filter. Overrides sparse-checkout if set. # Default: null filter: '' @@ -332,6 +337,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 + preserveLocalChanges: 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/action.yml b/action.yml index 767c416..26d5a5b 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' + preserveLocalChanges: + 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/src/git-command-manager.ts b/src/git-command-manager.ts index 8e42a38..26e4102 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-source-provider.ts b/src/git-source-provider.ts index 2d35138..7f694a1 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -229,7 +229,15 @@ 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') + // Use --merge to preserve local changes if possible + // This will fail if there are merge conflicts, but that's expected behavior + await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint, ['--merge']) + } 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..833cdcd 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -82,6 +82,10 @@ 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('preserveLocalChanges') || 'false').toUpperCase() === 'TRUE' + core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`) + // Filter const filter = core.getInput('filter') if (filter) { From f04b821901d66e4ffb9af1ac5a5d192000336b3f Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Mon, 11 Aug 2025 12:25:16 +0100 Subject: [PATCH 2/8] kebab case --- README.md | 7 ++++--- action.yml | 2 +- dist/index.js | 28 ++++++++++++++++++++++++---- src/input-helper.ts | 2 +- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1e94e04..6f9e8bb 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,10 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/ 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. + # local files that are not tracked by Git. By default, all files will be + # overwritten. # Default: false - preserveLocalChanges: '' + preserve-local-changes: '' # Partially clone against a given filter. Overrides sparse-checkout if set. # Default: null @@ -349,7 +350,7 @@ steps: uses: actions/checkout@v5 with: clean: false - preserveLocalChanges: true + preserve-local-changes: true ``` # Recommended permissions diff --git a/action.yml b/action.yml index 26d5a5b..0665486 100644 --- a/action.yml +++ b/action.yml @@ -57,7 +57,7 @@ inputs: clean: description: 'Whether to execute `git clean -ffdx && git reset --hard HEAD` before fetching' default: 'true' - preserveLocalChanges: + 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: diff --git a/dist/index.js b/dist/index.js index f3ae6f3..e91b36b 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); } @@ -1329,7 +1337,16 @@ 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'); + // Use --merge to preserve local changes if possible + // This will fail if there are merge conflicts, but that's expected behavior + yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint, ['--merge']); + } + else { + // Use the default behavior with --force + yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint); + } core.endGroup(); // Submodules if (settings.submodules) { @@ -1766,6 +1783,9 @@ 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/input-helper.ts b/src/input-helper.ts index 833cdcd..49eafc7 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -83,7 +83,7 @@ export async function getInputs(): Promise { core.debug(`clean = ${result.clean}`) // Preserve local changes - result.preserveLocalChanges = (core.getInput('preserveLocalChanges') || 'false').toUpperCase() === 'TRUE' + result.preserveLocalChanges = (core.getInput('preserve-local-changes') || 'false').toUpperCase() === 'TRUE' core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`) // Filter From ebd82bae91b035e508904b8737aaee4d07cb9f71 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Mon, 11 Aug 2025 12:33:14 +0100 Subject: [PATCH 3/8] Update logic for preserving local changes in the checkout action --- dist/index.js | 94 ++++++++++++++++++++++++++++++++-- src/git-source-provider.ts | 102 +++++++++++++++++++++++++++++++++++-- 2 files changed, 190 insertions(+), 6 deletions(-) diff --git a/dist/index.js b/dist/index.js index e91b36b..badeccf 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1339,9 +1339,97 @@ function getSource(settings) { core.startGroup('Checking out the ref'); if (settings.preserveLocalChanges) { core.info('Attempting to preserve local changes during checkout'); - // Use --merge to preserve local changes if possible - // This will fail if there are merge conflicts, but that's expected behavior - yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint, ['--merge']); + // 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 diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index 7f694a1..83bf73e 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -231,9 +231,105 @@ export async function getSource(settings: IGitSourceSettings): Promise { core.startGroup('Checking out the ref') if (settings.preserveLocalChanges) { core.info('Attempting to preserve local changes during checkout') - // Use --merge to preserve local changes if possible - // This will fail if there are merge conflicts, but that's expected behavior - await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint, ['--merge']) + + // 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) From 6503dcd44ca07faf086298f68612e2566d36126e Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Mon, 11 Aug 2025 12:39:11 +0100 Subject: [PATCH 4/8] update --- dist/index.js | 24 ++++++++++++++++++++---- src/git-directory-helper.ts | 21 +++++++++++++++++++-- src/git-source-provider.ts | 3 ++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/dist/index.js b/dist/index.js index badeccf..666ef16 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1033,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; @@ -1120,14 +1124,26 @@ 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 }); + } + } }); } @@ -1224,7 +1240,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 diff --git a/src/git-directory-helper.ts b/src/git-directory-helper.ts index 9a0085f..c72f08e 100644 --- a/src/git-directory-helper.ts +++ b/src/git-directory-helper.ts @@ -11,13 +11,19 @@ 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') // 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) { @@ -114,12 +120,23 @@ export async function prepareExistingDirectory( } } - 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 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 83bf73e..3403292 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 ) } From 630cdb38746cb306174af730f36f5d4cb9bc51ba Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Mon, 11 Aug 2025 12:47:29 +0100 Subject: [PATCH 5/8] linting --- src/git-command-manager.ts | 4 ++-- src/git-directory-helper.ts | 18 +++++++++++++----- src/git-source-provider.ts | 32 ++++++++++++++++++-------------- src/input-helper.ts | 4 +++- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 26e4102..580d9f2 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -209,7 +209,7 @@ class GitCommandManager { options: string[] = [] ): Promise { const args = ['checkout', '--progress'] - + // Add custom options (like --merge) if provided if (options.length > 0) { args.push(...options) @@ -217,7 +217,7 @@ class GitCommandManager { // 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 c72f08e..2d255b2 100644 --- a/src/git-directory-helper.ts +++ b/src/git-directory-helper.ts @@ -19,10 +19,12 @@ 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`) + core.info( + `Preserve local changes is enabled, will attempt to keep local files` + ) } // Check whether using git or REST API @@ -132,11 +134,17 @@ export async function prepareExistingDirectory( await io.rmRF(path.join(repositoryPath, file)) } } else if (remove && preserveLocalChanges) { - core.info(`Skipping deletion of directory contents due to preserve-local-changes setting`) + 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 }) + 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 3403292..c484f97 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -232,17 +232,17 @@ export async function getSource(settings: IGitSourceSettings): Promise { core.startGroup('Checking out the ref') if (settings.preserveLocalChanges) { core.info('Attempting to preserve local changes during checkout') - + // List and store local files before checkout const fs = require('fs') const path = require('path') const localFiles = new Map() - + try { // Get all files in the workspace that aren't in the .git directory const workspacePath = process.cwd() core.info(`Current workspace path: ${workspacePath}`) - + // List all files in the current directory using fs const listFilesRecursively = (dir: string): string[] => { let results: string[] = [] @@ -252,7 +252,7 @@ export async function getSource(settings: IGitSourceSettings): Promise { 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 @@ -270,17 +270,17 @@ export async function getSource(settings: IGitSourceSettings): Promise { }) 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 { @@ -290,16 +290,16 @@ export async function getSource(settings: IGitSourceSettings): Promise { 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') + const {exec} = require('@actions/exec') let exitCode = 0 const output = { stdout: '', stderr: '' } - + // Capture output const options = { ...execOptions, @@ -312,14 +312,18 @@ export async function getSource(settings: IGitSourceSettings): Promise { } } } - - exitCode = await exec('git', ['ls-files', '--error-unmatch', filePath], options) - + + 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.mkdirSync(path.dirname(fullPath), {recursive: true}) fs.writeFileSync(fullPath, content) core.info(`Restored local file: ${filePath}`) restoredCount++ diff --git a/src/input-helper.ts b/src/input-helper.ts index 49eafc7..c4f29f1 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -83,7 +83,9 @@ export async function getInputs(): Promise { core.debug(`clean = ${result.clean}`) // Preserve local changes - result.preserveLocalChanges = (core.getInput('preserve-local-changes') || 'false').toUpperCase() === 'TRUE' + result.preserveLocalChanges = + (core.getInput('preserve-local-changes') || 'false').toUpperCase() === + 'TRUE' core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`) // Filter From caa5717450737f8146f25815eeff441b54c166a0 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Mon, 11 Aug 2025 12:48:35 +0100 Subject: [PATCH 6/8] dist --- dist/index.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dist/index.js b/dist/index.js index 666ef16..7902246 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1141,7 +1141,9 @@ function prepareExistingDirectory(git_1, repositoryPath_1, repositoryUrl_1, clea // 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 }); + yield fs.promises.mkdir(path.join(repositoryPath, '.git'), { + recursive: true + }); } } }); @@ -1888,7 +1890,9 @@ function getInputs() { 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'; + result.preserveLocalChanges = + (core.getInput('preserve-local-changes') || 'false').toUpperCase() === + 'TRUE'; core.debug(`preserveLocalChanges = ${result.preserveLocalChanges}`); // Filter const filter = core.getInput('filter'); From 215f9562a10944672795edd7fce875068e4f121a Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Mon, 11 Aug 2025 13:00:43 +0100 Subject: [PATCH 7/8] update tests --- __test__/git-auth-helper.test.ts | 1 + __test__/git-directory-helper.test.ts | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) 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..e7ad155 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -143,12 +143,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() expect(core.warning).toHaveBeenCalled() expect(git.tryReset).not.toHaveBeenCalled() @@ -170,12 +171,13 @@ 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() }) @@ -221,12 +223,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() expect(git.tryReset).toHaveBeenCalled() expect(core.warning).toHaveBeenCalled() @@ -246,12 +249,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 +306,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() }) From 45abae3e9f3df5893d661cb11371ae3124e5bfe1 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Mon, 11 Aug 2025 14:10:22 +0100 Subject: [PATCH 8/8] 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. --- __test__/git-directory-helper.test.ts | 89 +++++++++++++++++++++++++++ src/git-directory-helper.ts | 14 +++++ 2 files changed, 103 insertions(+) diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index e7ad155..5d3fb4f 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -154,6 +154,34 @@ describe('git-directory-helper tests', () => { 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() + }) const removesContentsWhenDifferentRepositoryUrl = 'removes contents when different repository url' @@ -182,6 +210,39 @@ describe('git-directory-helper tests', () => { 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 () => { @@ -235,6 +296,34 @@ describe('git-directory-helper tests', () => { 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() + }) + const removesContentsWhenUndefinedGitCommandManager = 'removes contents when undefined git command manager' it(removesContentsWhenUndefinedGitCommandManager, async () => { diff --git a/src/git-directory-helper.ts b/src/git-directory-helper.ts index 2d255b2..6d57bcf 100644 --- a/src/git-directory-helper.ts +++ b/src/git-directory-helper.ts @@ -122,6 +122,20 @@ export async function prepareExistingDirectory( } } + // Check repository conditions + let isLocalGitRepo = git && fsHelper.directoryExistsSync(path.join(repositoryPath, '.git')); + let repoUrl = isLocalGitRepo ? await git?.tryGetFetchUrl() : ''; + let isSameRepository = repositoryUrl === repoUrl; + let differentRepoUrl = !isSameRepository; + + // Repository URL has changed + if (differentRepoUrl) { + if (preserveLocalChanges) { + core.warning(`Repository URL has changed from '${repoUrl}' to '${repositoryUrl}'. Local changes will be preserved as requested.`); + } + remove = true; // Mark for removal, but actual removal will respect preserveLocalChanges + } + if (remove && !preserveLocalChanges) { // Delete the contents of the directory. Don't delete the directory itself // since it might be the current working directory.