From 57529d0c7fcfcb526c1fceea82e34b7cad61036c Mon Sep 17 00:00:00 2001 From: deseltrus Date: Mon, 16 Mar 2026 20:00:58 +0100 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20worktree=20edge=20cases=20=E2=80=94?= =?UTF-8?q?=20resolveGitDir,=20captureIntegrationBranch=20guard,=20doctor?= =?UTF-8?q?=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for git worktree usage: 1. Add resolveGitDir() that follows the gitdir: pointer in worktree .git files. Without this, any code looking for MERGE_HEAD, SQUASH_MSG, or rebase state checks the wrong path in worktrees. 2. Guard captureIntegrationBranch() with detectWorktreeName() — in a worktree the base branch is implicit (worktree/), so writing it to META.json leaves stale metadata that persists after merge-back. 3. Use resolveGitDir() in doctor.ts for corrupt merge state detection. Previously hardcoded join(basePath, ".git") which misses worktrees. Includes 7 regression tests covering all three fixes. Relates to #654 (stale state from main branch instead of worktree) Relates to #672 (parallel milestone orchestration) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/doctor.ts | 4 +- .../gsd/tests/worktree-bugfix.test.ts | 113 ++++++++++++++++++ .../extensions/gsd/worktree-manager.ts | 30 ++++- src/resources/extensions/gsd/worktree.ts | 3 + 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/worktree-bugfix.test.ts 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); From 25eab8f3686790a2d976307b5e4fe6a54ed571d9 Mon Sep 17 00:00:00 2001 From: deseltrus Date: Mon, 16 Mar 2026 20:13:02 +0100 Subject: [PATCH 2/3] test: fix worktree-bugfix tests for CI (git config + Windows compat) Use separate git commands instead of && chains (fails on Windows). Configure git user.name/email before commit (not set in CI runners). Mirrors the pattern from worktree-e2e.test.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extensions/gsd/tests/worktree-bugfix.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/gsd/tests/worktree-bugfix.test.ts b/src/resources/extensions/gsd/tests/worktree-bugfix.test.ts index 72899a1ce..e0766c065 100644 --- a/src/resources/extensions/gsd/tests/worktree-bugfix.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-bugfix.test.ts @@ -23,8 +23,15 @@ const { assertEq, assertTrue, report } = createTestContext(); // ─── Helpers ────────────────────────────────────────────────────────────── +function run(cmd: string, cwd: string): void { + execSync(cmd, { cwd, stdio: "ignore" }); +} + function initRepo(dir: string): void { - execSync("git init && git commit --allow-empty -m init", { cwd: dir, stdio: "ignore" }); + run("git init", dir); + run("git config user.email test@test.com", dir); + run("git config user.name Test", dir); + run("git commit --allow-empty -m init", dir); } // ─── Tests ──────────────────────────────────────────────────────────────── @@ -94,7 +101,7 @@ describe("worktree-bugfix", () => { 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" }); + initRepo(wtPath); // captureIntegrationBranch should be a no-op — no META.json written const metaPath = join(wtPath, ".gsd", "milestones", "M005", "M005-META.json"); From 35f63f050a3e1ade10a64568ce4fd7f6e2479936 Mon Sep 17 00:00:00 2001 From: deseltrus Date: Mon, 16 Mar 2026 20:13:16 +0100 Subject: [PATCH 3/3] fix: derive initial state from worktree when one exists (#654) When auto-mode restarts after being stopped, the initial deriveState() reads from the project root which has stale .gsd/ metadata. Completed units appear incomplete, causing re-dispatch of finished work. The auto-worktree (if it exists from the previous run) has the current state. After the initial deriveState(base), check if an auto-worktree exists for the active milestone and re-derive from there. This is safe because: - Only triggers when worktree isolation is enabled - Only when not already inside a worktree - Only when an auto-worktree actually exists for the milestone - The worktree setup at lines 976+ still runs normally after Fixes #654 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/auto.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index b9058d6e6..2574272f8 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -824,6 +824,23 @@ export async function startAuto( let state = await deriveState(base); + // ── Stale worktree state recovery (#654) ───────────────────────────────── + // When auto-mode was previously stopped and restarted, the project root's + // .gsd/ directory may have stale metadata (completed units showing as + // incomplete). If an auto-worktree exists for the active milestone, it has + // the current state — re-derive from there to avoid re-dispatching + // finished work. + if ( + state.activeMilestone && + shouldUseWorktreeIsolation() && + !detectWorktreeName(base) + ) { + const wtPath = getAutoWorktreePath(base, state.activeMilestone.id); + if (wtPath) { + state = await deriveState(wtPath); + } + } + // ── Milestone branch recovery (#601) ───────────────────────────────────── // When auto-mode was previously stopped, the milestone branch is preserved // but the worktree is removed. The project root (integration branch) may