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:
parent
b6b9f44758
commit
57529d0c7f
4 changed files with 147 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
113
src/resources/extensions/gsd/tests/worktree-bugfix.test.ts
Normal file
113
src/resources/extensions/gsd/tests/worktree-bugfix.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue