From 2f15affeb54ef60d185b62eb45f91c9196da824c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 07:03:58 -0600 Subject: [PATCH] fix: discard untracked runtime files before branch switch to prevent checkout conflicts (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com> --- CHANGELOG.md | 3 + src/resources/extensions/gsd/git-service.ts | 29 +++++- .../extensions/gsd/tests/git-service.test.ts | 97 +++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8cfd5899..315e19e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 00e9a4975..de6ed624d 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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 ───────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index fbcbf1f34..ca93c7205 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -726,6 +726,103 @@ async function main(): Promise { 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 ===");