diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 5a16fec93..a70a5af9c 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -5,7 +5,7 @@ import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPla import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js"; import { deriveState, isMilestoneComplete } from "./state.js"; import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js"; -import { listWorktrees } from "./worktree-manager.js"; +import { listWorktrees, resolveGitDir } from "./worktree-manager.js"; import { abortAndReset } from "./git-self-heal.js"; import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js"; import { nativeIsRepo, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js"; @@ -480,7 +480,7 @@ async function checkGitHealth( return; // Not a git repo — skip all git health checks } - const gitDir = join(basePath, ".git"); + const gitDir = resolveGitDir(basePath); // ── Orphaned auto-worktrees ────────────────────────────────────────── try { diff --git a/src/resources/extensions/gsd/tests/worktree-bugfix.test.ts b/src/resources/extensions/gsd/tests/worktree-bugfix.test.ts new file mode 100644 index 000000000..72899a1ce --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-bugfix.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for worktree edge-case bugfixes: + * + * 1. resolveGitDir() follows gitdir: pointer in worktrees + * 2. captureIntegrationBranch() is a no-op in worktrees + * 3. detectWorktreeName() correctly identifies worktree paths + */ + +import { + mkdtempSync, mkdirSync, writeFileSync, rmSync, + existsSync, readFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { describe, it, after } from "node:test"; + +import { resolveGitDir } from "../worktree-manager.ts"; +import { detectWorktreeName, captureIntegrationBranch } from "../worktree.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function initRepo(dir: string): void { + execSync("git init && git commit --allow-empty -m init", { cwd: dir, stdio: "ignore" }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────── + +describe("worktree-bugfix", () => { + const dirs: string[] = []; + after(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + report(); + }); + + it("resolveGitDir returns .git directory in normal repo", () => { + const repo = mkdtempSync(join(tmpdir(), "gsd-wt-fix-")); + dirs.push(repo); + initRepo(repo); + const gitDir = resolveGitDir(repo); + assertTrue(gitDir.endsWith(".git"), "ends with .git"); + assertTrue(existsSync(gitDir), ".git dir exists"); + }); + + it("resolveGitDir follows gitdir: pointer in worktree", () => { + const repo = mkdtempSync(join(tmpdir(), "gsd-wt-fix-")); + dirs.push(repo); + initRepo(repo); + + // Simulate a worktree .git file (git worktree add creates these) + const wtDir = mkdtempSync(join(tmpdir(), "gsd-wt-fix-wt-")); + dirs.push(wtDir); + const realGitDir = join(repo, ".git", "worktrees", "test-wt"); + mkdirSync(realGitDir, { recursive: true }); + writeFileSync(join(wtDir, ".git"), `gitdir: ${realGitDir}\n`); + + const resolved = resolveGitDir(wtDir); + assertEq(resolved, realGitDir, "resolves to real git dir"); + }); + + it("resolveGitDir returns default when .git doesn't exist", () => { + const noGit = mkdtempSync(join(tmpdir(), "gsd-wt-fix-")); + dirs.push(noGit); + const gitDir = resolveGitDir(noGit); + assertTrue(gitDir.endsWith(".git"), "returns default .git path"); + }); + + it("detectWorktreeName returns name for worktree path", () => { + assertEq( + detectWorktreeName("/project/.gsd/worktrees/M005"), + "M005", + "detects worktree name", + ); + }); + + it("detectWorktreeName returns null for normal repo", () => { + assertEq( + detectWorktreeName("/project"), + null, + "null for non-worktree path", + ); + }); + + it("captureIntegrationBranch is a no-op when in a worktree", () => { + const repo = mkdtempSync(join(tmpdir(), "gsd-wt-fix-")); + dirs.push(repo); + initRepo(repo); + + // Create a fake worktree path structure + const wtPath = join(repo, ".gsd", "worktrees", "M005"); + mkdirSync(wtPath, { recursive: true }); + mkdirSync(join(wtPath, ".gsd", "milestones", "M005"), { recursive: true }); + // Initialize git in the worktree so getService doesn't fail + execSync("git init && git commit --allow-empty -m init", { cwd: wtPath, stdio: "ignore" }); + + // captureIntegrationBranch should be a no-op — no META.json written + const metaPath = join(wtPath, ".gsd", "milestones", "M005", "M005-META.json"); + captureIntegrationBranch(wtPath, "M005"); + assertTrue(!existsSync(metaPath), "no META.json written in worktree"); + }); + + it("detectWorktreeName prevents pull in worktree context", () => { + // Verifies the guard pattern: if detectWorktreeName returns non-null, + // the caller should skip pull/fetch operations + const inWorktree = detectWorktreeName("/project/.gsd/worktrees/M006"); + const inNormal = detectWorktreeName("/project"); + assertTrue(inWorktree !== null, "worktree detected → skip pull"); + assertTrue(inNormal === null, "normal repo → allow pull"); + }); +}); diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 0a7a36746..aa846b5ec 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -15,7 +15,7 @@ * 4. remove() — git worktree remove + branch cleanup */ -import { existsSync, mkdirSync, realpathSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { join, resolve, sep } from "node:path"; import { nativeBranchDelete, @@ -74,6 +74,34 @@ export function getMainBranch(basePath: string): string { return nativeDetectMainBranch(basePath); } +// ─── resolveGitDir ───────────────────────────────────────────────────────── + +/** + * Resolve the actual git directory for a given repository path. + * + * In a normal repo, .git is a directory → returns `/.git`. + * In a worktree, .git is a file containing `gitdir: ` → resolves + * and returns that path. + * + * This is critical for operations that reference git metadata files like + * MERGE_HEAD, SQUASH_MSG, etc. — these live in the git directory, not + * in the working tree root. Without this, worktree merges fail because + * they look for MERGE_HEAD in the wrong location. + */ +export function resolveGitDir(basePath: string): string { + const gitPath = join(basePath, ".git"); + if (!existsSync(gitPath)) return join(basePath, ".git"); + try { + const content = readFileSync(gitPath, "utf-8").trim(); + if (content.startsWith("gitdir: ")) { + return resolve(basePath, content.slice(8)); + } + } catch { + // Not a file or unreadable — fall through to default + } + return join(basePath, ".git"); +} + export function worktreesDir(basePath: string): string { return join(basePath, ".gsd", "worktrees"); } diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 59c4e9543..a74b673f1 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -55,6 +55,9 @@ export function setActiveMilestoneId(basePath: string, milestoneId: string | nul * if on a GSD slice branch. */ export function captureIntegrationBranch(basePath: string, milestoneId: string, options?: { commitDocs?: boolean }): void { + // In a worktree, the base branch is implicit (worktree/). + // Writing it to META.json would leave stale metadata after merge back to main. + if (detectWorktreeName(basePath)) return; const svc = getService(basePath); const current = svc.getCurrentBranch(); writeIntegrationBranch(basePath, milestoneId, current, options);