Merge pull request #188 from gsd-build/worktree-agent-a3ed52d1

fix: untrack runtime files already in git index to prevent merge conflicts
This commit is contained in:
TÂCHES 2026-03-13 09:54:30 -06:00 committed by GitHub
commit 83af235d86
4 changed files with 84 additions and 3 deletions

View file

@ -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");

View file

@ -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.

View file

@ -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"))) {

View file

@ -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<void> {
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 ✓");