fix: worktree edge cases — resolveGitDir, captureIntegrationBranch guard, doctor path

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/<name>), 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) <noreply@anthropic.com>
This commit is contained in:
deseltrus 2026-03-16 20:00:58 +01:00
parent b6b9f44758
commit 57529d0c7f
4 changed files with 147 additions and 3 deletions

View file

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

View file

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

View file

@ -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 `<basePath>/.git`.
* In a worktree, .git is a file containing `gitdir: <path>` 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");
}

View file

@ -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/<name>).
// 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);