fix: discard untracked runtime files before branch switch to prevent checkout conflicts (#346)

* Initial plan

* fix: add discardUntrackedRuntimeFiles to handle STATE.md checkout conflicts

Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TÂCHES <afromanguy@me.com>
Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com>
This commit is contained in:
Copilot 2026-03-14 07:03:58 -06:00 committed by GitHub
parent 31c03b6caf
commit 2f15affeb5
3 changed files with 126 additions and 3 deletions

View file

@ -6,6 +6,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Fixed
- Fixed residual `gsd auto` branch-switch failure: `git checkout -- .gsd/` only reverts *tracked* runtime files; a new `discardUntrackedRuntimeFiles` step (`git clean -fdx`) now also removes untracked runtime files (e.g. `STATE.md`) that would otherwise trigger "The following untracked working tree files would be overwritten by checkout" when the target branch still has them committed
## [2.10.8] - 2026-03-14
### Fixed

View file

@ -524,10 +524,13 @@ export class GitServiceImpl {
this.autoCommit("pre-switch", current, [".gsd/"]);
// Discard uncommitted .gsd/ changes so checkout doesn't fail.
// These are runtime files (metrics, completed-units, STATE) that were
// intentionally excluded from the commit above. If they remain dirty,
// git checkout refuses when the target branch has different versions.
// Two-step approach handles both tracked and untracked runtime files:
// 1. `checkout --` reverts tracked .gsd/ files to their HEAD versions.
// 2. `clean -fdx` removes untracked runtime files that the target branch has
// tracked — e.g., when a prior cleanup commit removed STATE.md from the
// current branch's HEAD but the target branch still has it committed.
this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
this.discardUntrackedRuntimeFiles();
this.git(["checkout", branch]);
return created;
@ -545,11 +548,31 @@ export class GitServiceImpl {
this.autoCommit("pre-switch", current, [".gsd/"]);
// Discard uncommitted .gsd/ changes so checkout doesn't fail.
// Two-step approach handles both tracked and untracked runtime files.
this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
this.discardUntrackedRuntimeFiles();
this.git(["checkout", mainBranch]);
}
/**
* Remove untracked runtime files from the working tree.
*
* Complements `git checkout -- .gsd/` (which only handles tracked files).
* Runtime files can end up untracked after a cleanup commit removes them
* from the current branch's HEAD but the target branch may still have
* them committed. Without this step, `git checkout` fails with:
* "The following untracked working tree files would be overwritten by checkout"
*
* `git clean -fdx` is safe here because:
* - Only removes *untracked* files (tracked files are untouched)
* - Targets only the specific runtime paths listed in RUNTIME_EXCLUSION_PATHS
* - These files are always regenerated by GSD on the next run
*/
private discardUntrackedRuntimeFiles(): void {
this.git(["clean", "-fdx", "--", ...RUNTIME_EXCLUSION_PATHS], { allowFailure: true });
}
// ─── S05 Features ─────────────────────────────────────────────────────
/**

View file

@ -726,6 +726,103 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: tracked STATE.md + dirty (regression: "local changes overwritten") ─
//
// Reproduces: "error: Your local changes to the following files would be overwritten
// by checkout: .gsd/STATE.md" that occurred in gsd auto when STATE.md was historically
// committed to the repo (before it was added to .gitignore).
console.log("\n=== ensureSliceBranch: tracked STATE.md + dirty (checkout conflict regression) ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Simulate historical state: STATE.md was committed before gitignore was configured
createFile(repo, ".gsd/STATE.md", "# State v1");
run("git add -f .gsd/STATE.md", repo);
run("git commit -m 'add state (pre-gitignore)'", repo);
// STATE.md gets modified during runtime (dirty)
createFile(repo, ".gsd/STATE.md", "# State v2 (modified at runtime)");
// ensureSliceBranch must not fail with "local changes would be overwritten"
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "checked out slice branch despite tracked+dirty STATE.md");
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureSliceBranch: untracked STATE.md blocks checkout (regression: cleanup-commit edge case) ─
//
// Reproduces: "The following untracked working tree files would be overwritten by checkout:
// .gsd/STATE.md" when the smartStage cleanup commit removes STATE.md from the current
// branch's HEAD but the target branch was already created from the old HEAD (so it still
// has STATE.md tracked). Without discardUntrackedRuntimeFiles(), the untracked STATE.md
// on disk would block the checkout.
console.log("\n=== ensureSliceBranch: untracked runtime files blocked by target branch (cleanup-commit edge case) ===");
{
const repo = initBranchTestRepo();
// Simulate: STATE.md is tracked in main's HEAD (historical state)
createFile(repo, ".gsd/STATE.md", "# State original");
run("git add -f .gsd/STATE.md", repo);
run("git commit -m 'initial with tracked STATE.md'", repo);
// Simulate what smartStage one-time cleanup does: remove STATE.md from index and commit.
// This leaves STATE.md on disk but removes it from main's HEAD.
run("git rm --cached .gsd/STATE.md", repo);
run("git commit -m 'chore: untrack runtime files'", repo);
// STATE.md exists on disk (modified) but is now untracked in main's HEAD
createFile(repo, ".gsd/STATE.md", "# State modified after cleanup");
// Create slice branch — this is what ensureSliceBranch does internally but we
// simulate a GitServiceImpl that has already done the cleanup commit.
// The slice branch is created from the OLD HEAD (before cleanup commit) so it HAS
// STATE.md tracked. Without discardUntrackedRuntimeFiles(), the checkout would fail.
run("git branch gsd/M001/S01 HEAD~1", repo); // branch from HEAD~1 = the commit that had STATE.md
// Now use GitServiceImpl to switch to the already-existing slice branch
const svc = new GitServiceImpl(repo);
// ensureSliceBranch must succeed despite the untracked STATE.md on disk
// conflicting with the tracked STATE.md in the target branch
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "checked out slice branch (untracked runtime file removed before checkout)");
rmSync(repo, { recursive: true, force: true });
}
// ─── switchToMain: tracked STATE.md + dirty (regression) ─────────────
console.log("\n=== switchToMain: tracked STATE.md + dirty (checkout conflict regression) ===");
{
const repo = initBranchTestRepo();
const svc = new GitServiceImpl(repo);
// Track STATE.md on main (historical pre-gitignore state)
createFile(repo, ".gsd/STATE.md", "# State on main");
run("git add -f .gsd/STATE.md", repo);
run("git commit -m 'add state (pre-gitignore)'", repo);
// Create slice branch (inherits STATE.md from main)
svc.ensureSliceBranch("M001", "S01");
assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "on slice branch before switchToMain");
// Modify STATE.md on slice branch (runtime update)
createFile(repo, ".gsd/STATE.md", "# State updated on slice branch");
// switchToMain must not fail with "local changes would be overwritten"
svc.switchToMain();
assertEq(svc.getCurrentBranch(), svc.getMainBranch(), "back on main after switchToMain despite tracked+dirty STATE.md");
rmSync(repo, { recursive: true, force: true });
}
// ─── switchToMain ─────────────────────────────────────────────────────
console.log("\n=== switchToMain ===");