Merge pull request #675 from deseltrus/fix/worktree-edge-cases

fix: worktree edge cases (resolveGitDir, captureIntegrationBranch, doctor)
This commit is contained in:
TÂCHES 2026-03-16 13:19:52 -06:00 committed by GitHub
commit d66679e6ad
5 changed files with 171 additions and 3 deletions

View file

@ -827,6 +827,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

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";
@ -481,7 +481,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 & Stale milestone branches ────────────────
// These checks only apply in worktree/branch modes — skip in none mode

View file

@ -0,0 +1,120 @@
/**
* 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 run(cmd: string, cwd: string): void {
execSync(cmd, { cwd, stdio: "ignore" });
}
function initRepo(dir: string): void {
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 ────────────────────────────────────────────────────────────────
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
initRepo(wtPath);
// 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);