Merge pull request #675 from deseltrus/fix/worktree-edge-cases
fix: worktree edge cases (resolveGitDir, captureIntegrationBranch, doctor)
This commit is contained in:
commit
d66679e6ad
5 changed files with 171 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
src/resources/extensions/gsd/tests/worktree-bugfix.test.ts
Normal file
120
src/resources/extensions/gsd/tests/worktree-bugfix.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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