From d121c8e3b2f18d8184c7902d43ea5b0c137d03b4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 19 Mar 2026 00:06:41 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20stop=20excluding=20all=20.gsd/=20from=20?= =?UTF-8?q?commits=20=E2=80=94=20only=20exclude=20runtime=20files=20(#1326?= =?UTF-8?q?)=20(#1328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smartStage() was excluding the entire .gsd/ directory from git staging, which is correct when .gsd/ is symlinked to external state. But on Windows (junction links) or projects where .gsd/ is git-tracked (not gitignored), this caused a mid-milestone behavioral discontinuity: 1. One-time cleanup removes runtime files from the index 2. After cleanup, nativeAddAll() + nativeResetPaths('.gsd/') causes ALL .gsd/ files to be unstaged — including milestone artifacts 3. autoCommit returns null (nothing staged) for the rest of the milestone 4. Work continues silently with no commits, no errors, no warnings 5. Worktree teardown loses all uncommitted .gsd/ artifacts Fix: replace the blanket '.gsd/' exclusion with targeted RUNTIME_EXCLUSION_PATHS. Milestone artifacts (.gsd/milestones/, preferences.md, DECISIONS.md, etc.) are now committed normally when they're tracked. When .gsd/ is in .gitignore (the default), git add -A already skips it — the reset is a harmless no-op. Updated git-service.test.ts to verify the new behavior: runtime files excluded, milestone artifacts committed. Fixes #1326 --- src/resources/extensions/gsd/git-service.ts | 28 +++++++++++-------- .../extensions/gsd/tests/git-service.test.ts | 23 ++++++++++----- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index ad2d6f3c3..a3d3ddf97 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -336,13 +336,17 @@ export class GitServiceImpl { * @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS. */ private smartStage(extraExclusions: readonly string[] = []): void { - // Always exclude .gsd/ — state is managed externally (symlinked to ~/.gsd/projects//) - const allExclusions = [".gsd/", ...extraExclusions]; - // One-time cleanup: if runtime files are already tracked in the index // (from older versions where the fallback bug staged them), untrack them // in a dedicated commit. This must happen as a separate commit because // the git reset HEAD step below would otherwise undo the rm --cached. + // + // SAFETY: Only untrack the specific RUNTIME paths (activity/, runtime/, + // auto.lock, etc.) — NOT all of .gsd/. If .gsd/milestones/ files were + // previously tracked, they stay tracked until the milestone completes + // and the worktree is torn down. This prevents a mid-execution behavioral + // discontinuity where the first half of a milestone has .gsd/ artifacts + // committed but the second half doesn't (#1326). if (!this._runtimeFilesCleanedUp) { let cleaned = false; for (const exclusion of RUNTIME_EXCLUSION_PATHS) { @@ -357,17 +361,19 @@ export class GitServiceImpl { // Stage everything, then unstage excluded paths. // - // Previous approach used pathspec excludes (:(exclude)...) with git add -A, - // but that fails when .gsd/ is in .gitignore — git exits non-zero before - // evaluating the excludes. The catch fallback ran plain `git add -A`, - // staging all tracked runtime files unconditionally and defeating the - // exclusion list entirely. + // Exclude only RUNTIME paths from staging — not the entire .gsd/ directory. + // When .gsd/milestones/ files are already tracked in the index (projects + // where .gsd/ is not gitignored, or Windows junctions that git sees as + // real directories), they should continue to be committed. Excluding the + // entire .gsd/ directory mid-milestone causes silent commit failure where + // the second half of a milestone's artifacts are never committed (#1326). // - // git reset HEAD silently succeeds when the path isn't staged, so no - // error handling is needed per-path. + // If .gsd/ IS in .gitignore (the default for external state projects), + // git add -A already skips it and the reset is a harmless no-op. nativeAddAll(this.basePath); - for (const exclusion of allExclusions) { + const runtimeExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions]; + for (const exclusion of runtimeExclusions) { try { nativeResetPaths(this.basePath, [exclusion]); } catch { /* path not staged — ignore */ } } } diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index fed685602..3a67f6604 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -1086,9 +1086,9 @@ async function main(): Promise { rmSync(repo, { recursive: true, force: true }); } - // ─── smartStage always excludes .gsd/ ────────────────────────────── + // ─── smartStage excludes runtime files but allows milestone artifacts ── - console.log("\n=== smartStage always excludes .gsd/ ==="); + console.log("\n=== smartStage excludes runtime files, allows milestone artifacts ==="); { const repo = mkdtempSync(join(tmpdir(), "gsd-smart-stage-excludes-")); @@ -1098,21 +1098,30 @@ async function main(): Promise { writeFileSync(join(repo, "README.md"), "init"); run("git add -A && git commit -m init", repo); - // Create .gsd/ planning files + a normal source file + // Create .gsd/ runtime files + milestone artifacts + a normal source file mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true }); + mkdirSync(join(repo, ".gsd", "activity"), { recursive: true }); writeFileSync(join(repo, ".gsd", "milestones", "M001", "ROADMAP.md"), "# Roadmap"); writeFileSync(join(repo, ".gsd", "preferences.md"), "---\nversion: 1\n---"); + writeFileSync(join(repo, ".gsd", "STATE.md"), "# State"); + writeFileSync(join(repo, ".gsd", "runtime", "units.json"), "{}"); + writeFileSync(join(repo, ".gsd", "activity", "log.jsonl"), "{}"); writeFileSync(join(repo, "src.ts"), "const x = 1;"); - // smartStage always excludes .gsd/ — state is managed externally + // smartStage excludes only runtime paths, not all of .gsd/ (#1326) const svc = new GitServiceImpl(repo); const msg = svc.commit({ message: "test commit" }); - assertTrue(msg !== null, "smartStage: commit succeeds with non-.gsd files"); + assertTrue(msg !== null, "smartStage: commit succeeds"); - // .gsd/ files should NOT be in the commit const committed = run("git show --name-only HEAD", repo); - assertTrue(!committed.includes(".gsd/"), "smartStage: .gsd/ files not in commit"); assertTrue(committed.includes("src.ts"), "smartStage: source files ARE in commit"); + // Runtime files should NOT be committed + assertTrue(!committed.includes(".gsd/STATE.md"), "smartStage: STATE.md excluded (runtime)"); + assertTrue(!committed.includes(".gsd/runtime/"), "smartStage: runtime/ excluded"); + assertTrue(!committed.includes(".gsd/activity/"), "smartStage: activity/ excluded"); + // Milestone artifacts SHOULD be committed when not gitignored (#1326) + assertTrue(committed.includes(".gsd/milestones/"), "smartStage: milestone artifacts ARE committed"); rmSync(repo, { recursive: true, force: true }); }