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 }); }