diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 264738921..47a233407 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -48,7 +48,7 @@ import { validateCompleteBoundary, formatValidationIssues, } from "./observability-validator.js"; -import { ensureGitignore } from "./gitignore.js"; +import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; import { runGSDDoctor, rebuildState } from "./doctor.js"; import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js"; import { @@ -381,6 +381,7 @@ export async function startAuto( // Ensure .gitignore has baseline patterns ensureGitignore(base); + untrackRuntimeFiles(base); // Bootstrap .gsd/ if it doesn't exist const gsdDir = join(base, ".gsd"); diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 2886519f6..02e8ea924 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { execSync } from "node:child_process"; /** * Patterns that are always correct regardless of project type. @@ -106,6 +107,32 @@ export function ensureGitignore(basePath: string): boolean { return true; } +/** + * Remove BASELINE_PATTERNS runtime paths from the git index if they are + * currently tracked. This fixes repos that started tracking these files + * before the .gitignore rule was added — git continues tracking files + * already in the index even after .gitignore is updated. + * + * Only removes from the index (`--cached`), never from disk. Idempotent. + */ +export function untrackRuntimeFiles(basePath: string): void { + // The GSD runtime paths are the first 7 entries in BASELINE_PATTERNS + const runtimePaths = BASELINE_PATTERNS.slice(0, 7); + + for (const pattern of runtimePaths) { + // Use -r for directory patterns (trailing slash), strip the slash for the command + const target = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern; + try { + execSync(`git rm -r --cached ${target}`, { + cwd: basePath, + stdio: ["ignore", "ignore", "ignore"], + }); + } catch { + // File not tracked or doesn't exist — expected, ignore + } + } +} + /** * Ensure basePath/.gsd/PREFERENCES.md exists as an empty template. * Creates the file with frontmatter only if it doesn't exist. diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index a51bc83a6..df7d12b42 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -21,7 +21,7 @@ import { import { join } from "node:path"; import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; -import { ensureGitignore, ensurePreferences } from "./gitignore.js"; +import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { showConfirm } from "../shared/confirm-ui.js"; @@ -457,6 +457,7 @@ export async function showSmartEntry( // ── Ensure .gitignore has baseline patterns ────────────────────────── ensureGitignore(basePath); + untrackRuntimeFiles(basePath); // ── No GSD project OR no milestone → Create first/next milestone ──── if (!existsSync(join(basePath, ".gsd"))) { diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index 994e7e410..2811db1b1 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; @@ -1370,6 +1370,58 @@ async function main(): Promise { assert(true, "PreMergeCheckResult type exported and usable"); } + // ─── untrackRuntimeFiles: removes tracked runtime files from index ─── + + console.log("\n=== untrackRuntimeFiles ==="); + + { + const { untrackRuntimeFiles } = await import("../gitignore.ts"); + const repo = mkdtempSync(join(tmpdir(), "gsd-untrack-")); + run("git init -b main", repo); + run("git config user.email test@test.com", repo); + run("git config user.name Test", repo); + + // Create and track runtime files (simulates pre-.gitignore state) + mkdirSync(join(repo, ".gsd", "activity"), { recursive: true }); + mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true }); + writeFileSync(join(repo, ".gsd", "completed-units.json"), '["u1"]'); + writeFileSync(join(repo, ".gsd", "metrics.json"), '{}'); + writeFileSync(join(repo, ".gsd", "STATE.md"), "# State"); + writeFileSync(join(repo, ".gsd", "activity", "log.jsonl"), "{}"); + writeFileSync(join(repo, ".gsd", "runtime", "data.json"), "{}"); + writeFileSync(join(repo, "src.ts"), "code"); + run("git add -A", repo); + run("git commit -m init", repo); + + // Precondition: runtime files are tracked + const trackedBefore = run("git ls-files .gsd/", repo); + assert(trackedBefore.includes("completed-units.json"), "untrack: precondition — completed-units tracked"); + assert(trackedBefore.includes("metrics.json"), "untrack: precondition — metrics tracked"); + + // Run untrackRuntimeFiles + untrackRuntimeFiles(repo); + + // Runtime files should be removed from the index + const trackedAfter = run("git ls-files .gsd/", repo); + assertEq(trackedAfter, "", "untrack: all runtime files removed from index"); + + // Non-runtime files remain tracked + const srcTracked = run("git ls-files src.ts", repo); + assert(srcTracked.includes("src.ts"), "untrack: non-runtime files remain tracked"); + + // Files still exist on disk + assert(existsSync(join(repo, ".gsd", "completed-units.json")), + "untrack: completed-units.json still on disk"); + assert(existsSync(join(repo, ".gsd", "metrics.json")), + "untrack: metrics.json still on disk"); + + // Idempotent — running again doesn't error + untrackRuntimeFiles(repo); + assert(true, "untrack: second call is idempotent (no error)"); + + rmSync(repo, { recursive: true, force: true }); + } + console.log(`\nResults: ${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); console.log("All tests passed ✓");