All other .gsd/ state files use uppercase naming (DECISIONS.md, REQUIREMENTS.md, PROJECT.md, etc). This renames the canonical preferences file to PREFERENCES.md while keeping a migration fallback — the loader checks PREFERENCES.md first, then falls back to lowercase preferences.md for existing installations. Closes #2700 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
653 lines
29 KiB
TypeScript
653 lines
29 KiB
TypeScript
import { describe, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
/**
|
|
* doctor-git.test.ts — Integration tests for doctor git health checks.
|
|
*
|
|
* Creates real temp git repos with deliberate broken state, runs runGSDDoctor,
|
|
* and asserts correct detection and fixing of git issue codes:
|
|
* orphaned_auto_worktree, stale_milestone_branch,
|
|
* corrupt_merge_state, tracked_runtime_files,
|
|
* integration_branch_missing, worktree_directory_orphaned
|
|
*/
|
|
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync, readFileSync, symlinkSync, renameSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { execSync } from "node:child_process";
|
|
|
|
import { runGSDDoctor } from "../doctor.ts";
|
|
function run(cmd: string, cwd: string): string {
|
|
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
|
}
|
|
|
|
/** Create a temp git repo with a completed milestone M001 in roadmap. */
|
|
function createRepoWithCompletedMilestone(): string {
|
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
|
|
run("git init", dir);
|
|
run("git config user.email test@test.com", dir);
|
|
run("git config user.name Test", dir);
|
|
|
|
// Initial commit
|
|
writeFileSync(join(dir, "README.md"), "# test\n");
|
|
run("git add .", dir);
|
|
run("git commit -m init", dir);
|
|
run("git branch -M main", dir);
|
|
|
|
// Create .gsd structure with milestone M001 — all slices done → complete
|
|
const msDir = join(dir, ".gsd", "milestones", "M001");
|
|
mkdirSync(msDir, { recursive: true });
|
|
writeFileSync(join(msDir, "ROADMAP.md"), `---
|
|
id: M001
|
|
title: "Test Milestone"
|
|
---
|
|
|
|
# M001: Test Milestone
|
|
|
|
## Vision
|
|
Test
|
|
|
|
## Success Criteria
|
|
- Done
|
|
|
|
## Slices
|
|
- [x] **S01: Test slice** \`risk:low\` \`depends:[]\`
|
|
> After this: done
|
|
|
|
## Boundary Map
|
|
_None_
|
|
`);
|
|
|
|
// Commit .gsd files
|
|
run("git add -A", dir);
|
|
run("git commit -m \"add milestone\"", dir);
|
|
|
|
return dir;
|
|
}
|
|
|
|
/** Write a .gsd/PREFERENCES.md with the given git isolation mode. */
|
|
function writePreferencesFile(dir: string, isolation: "none" | "worktree" | "branch"): void {
|
|
const gsdDir = join(dir, ".gsd");
|
|
mkdirSync(gsdDir, { recursive: true });
|
|
writeFileSync(join(gsdDir, "PREFERENCES.md"), `---\ngit:\n isolation: "${isolation}"\n---\n`);
|
|
}
|
|
|
|
/** Create a repo with an in-progress milestone. */
|
|
function createRepoWithActiveMilestone(): string {
|
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
|
|
run("git init", dir);
|
|
run("git config user.email test@test.com", dir);
|
|
run("git config user.name Test", dir);
|
|
|
|
writeFileSync(join(dir, "README.md"), "# test\n");
|
|
run("git add .", dir);
|
|
run("git commit -m init", dir);
|
|
run("git branch -M main", dir);
|
|
|
|
const msDir = join(dir, ".gsd", "milestones", "M001");
|
|
mkdirSync(msDir, { recursive: true });
|
|
writeFileSync(join(msDir, "ROADMAP.md"), `---
|
|
id: M001
|
|
title: "Active Milestone"
|
|
---
|
|
|
|
# M001: Active Milestone
|
|
|
|
## Vision
|
|
Test
|
|
|
|
## Success Criteria
|
|
- Done
|
|
|
|
## Slices
|
|
- [ ] **S01: Test slice** \`risk:low\` \`depends:[]\`
|
|
> After this: done
|
|
|
|
## Boundary Map
|
|
_None_
|
|
`);
|
|
|
|
run("git add -A", dir);
|
|
run("git commit -m \"add milestone\"", dir);
|
|
|
|
return dir;
|
|
}
|
|
|
|
describe('doctor-git', async () => {
|
|
const cleanups: string[] = [];
|
|
|
|
try {
|
|
// ─── Test 1: Orphaned worktree detection & fix ─────────────────────
|
|
// Skip on Windows: git worktree path resolution on Windows temp dirs
|
|
// uses UNC/8.3 forms that don't survive path normalization. The source
|
|
// logic is correct (tested on macOS/Linux) — the test infra doesn't
|
|
// produce matching paths on Windows CI.
|
|
if (process.platform !== "win32") {
|
|
test('orphaned_auto_worktree', async () => {
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create worktree with milestone/M001 branch under .gsd/worktrees/
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
|
|
|
|
const detect = await runGSDDoctor(dir, { isolationMode: "worktree" });
|
|
const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
|
|
assert.ok(orphanIssues.length > 0, "detects orphaned worktree");
|
|
assert.deepStrictEqual(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001");
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" });
|
|
assert.ok(fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), "fix removes orphaned worktree");
|
|
|
|
// Verify worktree is gone
|
|
const wtList = run("git worktree list", dir);
|
|
assert.ok(!wtList.includes("milestone/M001"), "worktree no longer listed after fix");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test 1b: Orphaned worktree fix when cwd is inside worktree (#1946) ──
|
|
// Reproduces the deadlock: if process.cwd() is inside the orphaned worktree,
|
|
// the doctor must chdir out before removing it — not skip the removal.
|
|
if (process.platform !== "win32") {
|
|
console.log("\n=== orphaned_auto_worktree (cwd inside worktree) ===");
|
|
{
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create worktree with milestone/M001 branch under .gsd/worktrees/
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
|
|
|
|
const wtPath = realpathSync(join(dir, ".gsd", "worktrees", "M001"));
|
|
|
|
// Simulate the deadlock: set cwd inside the orphaned worktree
|
|
const previousCwd = process.cwd();
|
|
process.chdir(wtPath);
|
|
try {
|
|
const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" });
|
|
|
|
// The fix must NOT skip removal — it should chdir out and remove
|
|
assert.ok(
|
|
!fixed.fixesApplied.some(f => f.includes("skipped removing worktree")),
|
|
"does NOT skip removal when cwd is inside worktree",
|
|
);
|
|
assert.ok(
|
|
fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")),
|
|
"removes orphaned worktree even when cwd was inside it",
|
|
);
|
|
|
|
// Verify worktree is gone
|
|
const wtList = run("git worktree list", dir);
|
|
assert.ok(!wtList.includes("milestone/M001"), "worktree removed after fix with cwd inside");
|
|
|
|
// Verify cwd was moved out (should be basePath, not still inside worktree)
|
|
const newCwd = process.cwd();
|
|
assert.ok(
|
|
!newCwd.startsWith(wtPath),
|
|
"cwd moved out of worktree after fix",
|
|
);
|
|
} finally {
|
|
// Restore cwd — the worktree dir may be gone, so chdir to previousCwd
|
|
try { process.chdir(previousCwd); } catch { process.chdir(dir); }
|
|
}
|
|
}
|
|
} else {
|
|
console.log("\n=== orphaned_auto_worktree (cwd inside worktree — skipped on Windows) ===");
|
|
}
|
|
|
|
// ─── Test 2: Stale milestone branch detection & fix ────────────────
|
|
// Skip on Windows: git branch glob matching and path resolution
|
|
// behave differently in Windows temp dirs.
|
|
if (process.platform !== "win32") {
|
|
test('stale_milestone_branch', async () => {
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create a milestone/M001 branch (no worktree)
|
|
run("git branch milestone/M001", dir);
|
|
|
|
const detect = await runGSDDoctor(dir, { isolationMode: "worktree" });
|
|
const staleIssues = detect.issues.filter(i => i.code === "stale_milestone_branch");
|
|
assert.ok(staleIssues.length > 0, "detects stale milestone branch");
|
|
assert.deepStrictEqual(staleIssues[0]?.unitId, "M001", "stale branch unitId is M001");
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true, isolationMode: "worktree" });
|
|
assert.ok(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch");
|
|
|
|
// Verify branch is gone
|
|
const branches = run("git branch --list milestone/*", dir);
|
|
assert.ok(!branches.includes("milestone/M001"), "branch gone after fix");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test 3: Corrupt merge state detection & fix ───────────────────
|
|
test('corrupt_merge_state', async () => {
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Inject MERGE_HEAD into .git
|
|
const headHash = run("git rev-parse HEAD", dir);
|
|
writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n");
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const mergeIssues = detect.issues.filter(i => i.code === "corrupt_merge_state");
|
|
assert.ok(mergeIssues.length > 0, "detects corrupt merge state");
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
assert.ok(fixed.fixesApplied.some(f => f.includes("cleaned merge state")), "fix cleans merge state");
|
|
|
|
// Verify MERGE_HEAD is gone
|
|
assert.ok(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after fix");
|
|
});
|
|
|
|
// ─── Test 4: Tracked runtime files detection & fix ─────────────────
|
|
test('tracked_runtime_files', async () => {
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Force-add a runtime file
|
|
const activityDir = join(dir, ".gsd", "activity");
|
|
mkdirSync(activityDir, { recursive: true });
|
|
writeFileSync(join(activityDir, "test.log"), "log data\n");
|
|
run("git add -f .gsd/activity/test.log", dir);
|
|
run("git commit -m \"track runtime file\"", dir);
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const trackedIssues = detect.issues.filter(i => i.code === "tracked_runtime_files");
|
|
assert.ok(trackedIssues.length > 0, "detects tracked runtime files");
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
assert.ok(fixed.fixesApplied.some(f => f.includes("untracked")), "fix untracks runtime files");
|
|
|
|
// Verify file is no longer tracked
|
|
const tracked = run("git ls-files .gsd/activity/", dir);
|
|
assert.deepStrictEqual(tracked, "", "runtime file untracked after fix");
|
|
});
|
|
|
|
// ─── Test 5: Non-git directory — graceful degradation ──────────────
|
|
test('non-git directory', async () => {
|
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
|
|
cleanups.push(dir);
|
|
|
|
// Create minimal .gsd structure (no git)
|
|
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
|
|
const result = await runGSDDoctor(dir);
|
|
const gitIssues = result.issues.filter(i =>
|
|
["orphaned_auto_worktree", "stale_milestone_branch", "corrupt_merge_state", "tracked_runtime_files"].includes(i.code)
|
|
);
|
|
assert.deepStrictEqual(gitIssues.length, 0, "no git issues in non-git directory");
|
|
// Should not throw — reaching here means no crash
|
|
assert.ok(true, "non-git directory does not crash");
|
|
});
|
|
|
|
// ─── Test 6: Active worktree NOT flagged (false positive prevention) ─
|
|
if (process.platform !== "win32") {
|
|
test('active worktree safety', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create worktree for in-progress milestone under .gsd/worktrees/
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
|
|
|
|
const detect = await runGSDDoctor(dir, { isolationMode: "worktree" });
|
|
const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
|
|
assert.deepStrictEqual(orphanIssues.length, 0, "active worktree NOT flagged as orphaned");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test 7: none-mode skips orphaned worktree check ───────────────
|
|
// NOTE: loadEffectiveGSDPreferences() resolves PROJECT_PREFERENCES_PATH
|
|
// at module load time from process.cwd(). We write the prefs file to
|
|
// the test runner's cwd .gsd/PREFERENCES.md and clean up afterwards.
|
|
if (process.platform !== "win32") {
|
|
test('none-mode skips orphaned worktree', async () => {
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create worktree with milestone/M001 branch under .gsd/worktrees/
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
|
|
|
|
const result = await runGSDDoctor(dir, { isolationMode: "none" });
|
|
const orphanIssues = result.issues.filter(i => i.code === "orphaned_auto_worktree");
|
|
assert.deepStrictEqual(orphanIssues.length, 0, "none-mode: orphaned worktree NOT detected");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test 8: none-mode skips stale branch check ────────────────────
|
|
if (process.platform !== "win32") {
|
|
test('none-mode skips stale branch', async () => {
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create a milestone/M001 branch (no worktree)
|
|
run("git branch milestone/M001", dir);
|
|
|
|
const result = await runGSDDoctor(dir, { isolationMode: "none" });
|
|
const staleIssues = result.issues.filter(i => i.code === "stale_milestone_branch");
|
|
assert.deepStrictEqual(staleIssues.length, 0, "none-mode: stale branch NOT detected");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test: Integration branch missing ──────────────────────────────
|
|
if (process.platform !== "win32") {
|
|
test('integration_branch_missing', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Write integration branch metadata for M001 pointing to a non-existent branch
|
|
const metaPath = join(dir, ".gsd", "milestones", "M001", "M001-META.json");
|
|
writeFileSync(metaPath, JSON.stringify({ integrationBranch: "feat/does-not-exist" }, null, 2));
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const missingBranchIssues = detect.issues.filter(i => i.code === "integration_branch_missing");
|
|
assert.ok(missingBranchIssues.length > 0, "detects missing integration branch");
|
|
assert.ok(
|
|
missingBranchIssues[0]?.message.includes("feat/does-not-exist"),
|
|
"message includes the missing branch name",
|
|
);
|
|
assert.deepStrictEqual(missingBranchIssues[0]?.fixable, true, "integration_branch_missing is auto-fixable via fallback");
|
|
assert.deepStrictEqual(missingBranchIssues[0]?.severity, "warning", "severity is warning (fallback available)");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test: Integration branch present — no false positive ──────────
|
|
if (process.platform !== "win32") {
|
|
test('integration_branch_missing (no false positive)', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Write integration branch metadata for M001 pointing to "main" (which exists)
|
|
const metaPath = join(dir, ".gsd", "milestones", "M001", "M001-META.json");
|
|
writeFileSync(metaPath, JSON.stringify({ integrationBranch: "main" }, null, 2));
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const missingBranchIssues = detect.issues.filter(i => i.code === "integration_branch_missing");
|
|
assert.deepStrictEqual(missingBranchIssues.length, 0, "existing integration branch NOT flagged");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test: Orphaned worktree directory ─────────────────────────────
|
|
test('integration_branch_missing: stale metadata with detected fallback', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
const metaPath = join(dir, ".gsd", "milestones", "M001", "M001-META.json");
|
|
writeFileSync(metaPath, JSON.stringify({ integrationBranch: "feat/does-not-exist" }, null, 2));
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const missingBranchIssues = detect.issues.filter(i => i.code === "integration_branch_missing");
|
|
assert.deepStrictEqual(missingBranchIssues.length, 1, "reports one stale integration branch issue");
|
|
assert.deepStrictEqual(missingBranchIssues[0]?.severity, "warning", "stale metadata is warning when a fallback branch exists");
|
|
assert.deepStrictEqual(missingBranchIssues[0]?.fixable, true, "stale metadata becomes auto-fixable when fallback exists");
|
|
assert.ok(
|
|
missingBranchIssues[0]?.message.includes("feat/does-not-exist") &&
|
|
missingBranchIssues[0]?.message.includes("main"),
|
|
"warning mentions stale recorded branch and detected fallback branch",
|
|
);
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
assert.ok(
|
|
fixed.fixesApplied.some(f => f.includes('updated integration branch for M001 to "main"')),
|
|
"doctor fix rewrites stale integration branch metadata to detected fallback branch",
|
|
);
|
|
|
|
const repairedMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
assert.deepStrictEqual(repairedMeta.integrationBranch, "main", "metadata rewritten to detected fallback branch");
|
|
});
|
|
|
|
test('integration_branch_missing: stale metadata with configured fallback', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
run("git branch trunk", dir);
|
|
writeFileSync(join(dir, ".gsd", "PREFERENCES.md"), `---\ngit:\n isolation: "worktree"\n main_branch: "trunk"\n---\n`);
|
|
|
|
const metaPath = join(dir, ".gsd", "milestones", "M001", "M001-META.json");
|
|
writeFileSync(metaPath, JSON.stringify({ integrationBranch: "feat/does-not-exist" }, null, 2));
|
|
|
|
const previousCwd = process.cwd();
|
|
process.chdir(dir);
|
|
try {
|
|
const detect = await runGSDDoctor(dir);
|
|
const missingBranchIssues = detect.issues.filter(i => i.code === "integration_branch_missing");
|
|
assert.deepStrictEqual(missingBranchIssues.length, 1, "configured fallback still reports one stale integration branch issue");
|
|
assert.deepStrictEqual(missingBranchIssues[0]?.severity, "warning", "configured fallback keeps stale metadata at warning severity");
|
|
assert.deepStrictEqual(missingBranchIssues[0]?.fixable, true, "configured fallback remains auto-fixable");
|
|
assert.ok(
|
|
missingBranchIssues[0]?.message.includes("feat/does-not-exist") &&
|
|
missingBranchIssues[0]?.message.includes("trunk"),
|
|
"warning mentions stale recorded branch and configured fallback branch",
|
|
);
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
assert.ok(
|
|
fixed.fixesApplied.some(f => f.includes('updated integration branch for M001 to "trunk"')),
|
|
"doctor fix rewrites stale metadata to configured fallback branch",
|
|
);
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
}
|
|
|
|
const repairedMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
assert.deepStrictEqual(repairedMeta.integrationBranch, "trunk", "metadata rewritten to configured fallback branch");
|
|
});
|
|
|
|
if (process.platform !== "win32") {
|
|
test('worktree_directory_orphaned', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create a worktrees/ dir with an entry that is NOT in git worktree list
|
|
const orphanDir = join(dir, ".gsd", "worktrees", "orphan-feature");
|
|
mkdirSync(orphanDir, { recursive: true });
|
|
writeFileSync(join(orphanDir, "some-file.txt"), "leftover content\n");
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const orphanDirIssues = detect.issues.filter(i => i.code === "worktree_directory_orphaned");
|
|
assert.ok(orphanDirIssues.length > 0, "detects orphaned worktree directory");
|
|
assert.ok(
|
|
orphanDirIssues[0]?.message.includes("orphan-feature"),
|
|
"message includes the orphaned directory name",
|
|
);
|
|
assert.ok(orphanDirIssues[0]?.fixable === true, "worktree_directory_orphaned is fixable");
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
assert.ok(
|
|
fixed.fixesApplied.some(f => f.includes("removed orphaned worktree directory")),
|
|
"fix removes orphaned worktree directory",
|
|
);
|
|
assert.ok(!existsSync(orphanDir), "orphaned directory removed after fix");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test: Registered worktree NOT flagged as orphaned ─────────────
|
|
if (process.platform !== "win32") {
|
|
test('worktree_directory_orphaned (registered worktree not flagged)', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create a real registered worktree under .gsd/worktrees/
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b worktree/feature-1 .gsd/worktrees/feature-1", dir);
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const orphanDirIssues = detect.issues.filter(i => i.code === "worktree_directory_orphaned");
|
|
assert.deepStrictEqual(orphanDirIssues.length, 0, "registered worktree NOT flagged as orphaned");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test 9: none-mode still detects corrupt merge state ───────────
|
|
test('none-mode keeps corrupt merge state', async () => {
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Inject MERGE_HEAD into .git
|
|
const headHash = run("git rev-parse HEAD", dir);
|
|
writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n");
|
|
|
|
const result = await runGSDDoctor(dir, { isolationMode: "none" });
|
|
const mergeIssues = result.issues.filter(i => i.code === "corrupt_merge_state");
|
|
assert.ok(mergeIssues.length > 0, "none-mode: corrupt merge state IS detected");
|
|
});
|
|
|
|
// ─── Test 10: none-mode still detects tracked runtime files ────────
|
|
test('none-mode keeps tracked runtime files', async () => {
|
|
const dir = createRepoWithCompletedMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Force-add a runtime file
|
|
const activityDir = join(dir, ".gsd", "activity");
|
|
mkdirSync(activityDir, { recursive: true });
|
|
writeFileSync(join(activityDir, "test.log"), "log data\n");
|
|
run("git add -f .gsd/activity/test.log", dir);
|
|
run("git commit -m \"track runtime file\"", dir);
|
|
|
|
const result = await runGSDDoctor(dir, { isolationMode: "none" });
|
|
const trackedIssues = result.issues.filter(i => i.code === "tracked_runtime_files");
|
|
assert.ok(trackedIssues.length > 0, "none-mode: tracked runtime files IS detected");
|
|
});
|
|
|
|
// ─── Test: Symlinked .gsd does not cause false orphan detection ────
|
|
if (process.platform !== "win32") {
|
|
test('worktree_directory_orphaned (symlinked .gsd not false-positive)', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Move .gsd to an external location and replace with a symlink.
|
|
// This simulates the ~/.gsd/projects/<hash> layout where .gsd is a symlink.
|
|
const externalGsd = join(realpathSync(mkdtempSync(join(tmpdir(), "doc-git-symlink-"))), "gsd-data");
|
|
cleanups.push(externalGsd);
|
|
renameSync(join(dir, ".gsd"), externalGsd);
|
|
symlinkSync(externalGsd, join(dir, ".gsd"));
|
|
|
|
// Create a real registered worktree under the (now symlinked) .gsd/worktrees/
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b worktree/symlink-test .gsd/worktrees/symlink-test", dir);
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const orphanDirIssues = detect.issues.filter(i => i.code === "worktree_directory_orphaned");
|
|
assert.deepStrictEqual(orphanDirIssues.length, 0, "registered worktree via symlinked .gsd NOT flagged as orphaned");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test: worktree_branch_merged detection & fix ──────────────────
|
|
if (process.platform !== "win32") {
|
|
test('worktree_branch_merged', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create a worktree, make a commit, then merge the branch into main
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b worktree/merged-feature .gsd/worktrees/merged-feature", dir);
|
|
const wtPath = join(dir, ".gsd", "worktrees", "merged-feature");
|
|
writeFileSync(join(wtPath, "feature.txt"), "feature\n");
|
|
run("git add -A", wtPath);
|
|
run("git -c user.email=test@test.com -c user.name=Test commit -m \"feature work\"", wtPath);
|
|
|
|
// Merge the worktree branch into main
|
|
run("git merge worktree/merged-feature --no-edit", dir);
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const mergedIssues = detect.issues.filter(i => i.code === "worktree_branch_merged");
|
|
assert.ok(mergedIssues.length > 0, "detects merged worktree branch");
|
|
assert.ok(mergedIssues[0]?.message.includes("safe to remove"), "message says safe to remove");
|
|
assert.ok(mergedIssues[0]?.fixable === true, "merged worktree is fixable");
|
|
|
|
// Fix should remove the worktree
|
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
assert.ok(fixed.fixesApplied.some(f => f.includes("removed merged worktree")), "fix removes merged worktree");
|
|
assert.ok(!existsSync(wtPath), "worktree directory removed after fix");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test: merged milestone/* worktree removes milestone branch ────
|
|
if (process.platform !== "win32") {
|
|
test('worktree_branch_merged (milestone branch cleanup)', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
|
|
const wtPath = join(dir, ".gsd", "worktrees", "M001");
|
|
writeFileSync(join(wtPath, "feature.txt"), "feature\n");
|
|
run("git add -A", wtPath);
|
|
run("git -c user.email=test@test.com -c user.name=Test commit -m \"feature work\"", wtPath);
|
|
run("git merge milestone/M001 --no-edit", dir);
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
assert.ok(fixed.fixesApplied.some(f => f.includes("removed merged worktree")), "fix removes merged milestone worktree");
|
|
assert.ok(!existsSync(wtPath), "milestone worktree directory removed after fix");
|
|
|
|
const branches = run("git branch --list milestone/M001", dir);
|
|
assert.deepStrictEqual(branches, "", "milestone/M001 branch deleted after merged worktree cleanup");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test: worktree_branch_merged NOT flagged for unmerged worktree ─
|
|
if (process.platform !== "win32") {
|
|
test('worktree_branch_merged (no false positive)', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
|
|
run("git worktree add -b worktree/active-feature .gsd/worktrees/active-feature", dir);
|
|
const wtPath = join(dir, ".gsd", "worktrees", "active-feature");
|
|
writeFileSync(join(wtPath, "wip.txt"), "work in progress\n");
|
|
run("git add -A", wtPath);
|
|
run("git -c user.email=test@test.com -c user.name=Test commit -m \"wip\"", wtPath);
|
|
|
|
// Do NOT merge — branch is ahead of main
|
|
const detect = await runGSDDoctor(dir);
|
|
const mergedIssues = detect.issues.filter(i => i.code === "worktree_branch_merged");
|
|
assert.deepStrictEqual(mergedIssues.length, 0, "unmerged worktree NOT flagged as merged");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
// ─── Test: legacy_slice_branches now fixable ───────────────────────
|
|
if (process.platform !== "win32") {
|
|
test('legacy_slice_branches (fixable)', async () => {
|
|
const dir = createRepoWithActiveMilestone();
|
|
cleanups.push(dir);
|
|
|
|
// Create legacy gsd/M001/S01 branches
|
|
run("git branch gsd/M001/S01", dir);
|
|
run("git branch gsd/M001/S02", dir);
|
|
// Active quick branches share gsd/*/* shape and must NOT be deleted.
|
|
run("git branch gsd/quick/1-fix-typo", dir);
|
|
|
|
const detect = await runGSDDoctor(dir);
|
|
const legacyIssues = detect.issues.filter(i => i.code === "legacy_slice_branches");
|
|
assert.ok(legacyIssues.length > 0, "detects legacy slice branches");
|
|
assert.ok(legacyIssues[0]?.fixable === true, "legacy branches are fixable");
|
|
|
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
assert.ok(fixed.fixesApplied.some(f => f.includes("legacy slice branch")), "fix deletes legacy branches");
|
|
|
|
// Verify branches are gone
|
|
const remaining = run("git branch --list gsd/*/*", dir);
|
|
assert.deepStrictEqual(remaining, "gsd/quick/1-fix-typo", "quick branch preserved; legacy branches removed");
|
|
});
|
|
} else {
|
|
}
|
|
|
|
} finally {
|
|
for (const dir of cleanups) {
|
|
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
});
|