Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,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: ''
Expand Down Expand Up @@ -332,6 +338,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:
Expand Down
5 changes: 4 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
140 changes: 132 additions & 8 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1112,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 });
}
}
});
}

Expand Down Expand Up @@ -1216,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
Expand Down Expand Up @@ -1329,7 +1353,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) {
Expand Down Expand Up @@ -1766,6 +1887,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) {
Expand Down
19 changes: 16 additions & 3 deletions src/git-command-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -203,8 +203,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 {
Expand Down
21 changes: 19 additions & 2 deletions src/git-directory-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ 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')

// 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) {
Expand Down Expand Up @@ -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 })
Copy link

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating only the .git directory is insufficient to initialize a proper Git repository. This should use git init command or proper Git initialization to create all necessary Git metadata files.

Suggested change
await fs.promises.mkdir(path.join(repositoryPath, '.git'), { recursive: true })
await new Promise<void>((resolve, reject) => {
execFile('git', ['init'], { cwd: repositoryPath }, (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})

Copilot uses AI. Check for mistakes.
}
}
}
Loading
Loading