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:
parent
31c03b6caf
commit
2f15affeb5
3 changed files with 126 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 ===");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue