fix: detect worktree paths resolved through .gsd symlinks (#1585)

When .gsd is a symlink (e.g., openclip/.gsd -> ~/.gsd/projects/<hash>),
worktrees resolve to ~/.gsd/projects/<hash>/worktrees/<name> instead of
the expected <repo>/.gsd/worktrees/<name>. All worktree detection
functions used the marker /.gsd/worktrees/ which did not match the
resolved path /.gsd/projects/<hash>/worktrees/.

This caused three cascading failures:
1. escapeStaleWorktree failed to detect stale worktree CWD
2. isUnderGsdWorktrees returned false, causing nested worktrees
3. Empty registry was conflated with "all milestones complete"

Changes:
- Add findWorktreeSegment helper matching both direct and symlink layouts
- Refactor detectWorktreeName and resolveProjectRoot to use the helper
- Fix escapeStaleWorktree in auto-worktree-sync.ts for symlink paths
- Fix isUnderGsdWorktrees in auto-start.ts for symlink paths
- Fix resolveCapturesPath in captures.ts for symlink paths
- Distinguish empty registry from all-complete in auto-loop.ts
- Add tests for symlink-resolved path detection
This commit is contained in:
Juan Francisco Lebrero 2026-03-20 11:10:45 -03:00 committed by GitHub
parent 1b2ff19615
commit f1a27b02b8
6 changed files with 124 additions and 21 deletions

View file

@ -787,7 +787,7 @@ export async function autoLoop(
(m: { status: string }) =>
m.status !== "complete" && m.status !== "parked",
);
if (incomplete.length === 0) {
if (incomplete.length === 0 && state.registry.length > 0) {
// All milestones complete — merge milestone branch before stopping
if (s.currentMilestoneId) {
deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
@ -804,6 +804,18 @@ export async function autoLoop(
"success",
);
await deps.stopAuto(ctx, pi, "All milestones complete");
} else if (incomplete.length === 0 && state.registry.length === 0) {
// Empty registry — no milestones visible, likely a path resolution bug
const diag = `basePath=${s.basePath}, phase=${state.phase}`;
ctx.ui.notify(
`No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`,
"error",
);
await deps.stopAuto(
ctx,
pi,
`No milestones found — check basePath resolution`,
);
} else if (state.phase === "blocked") {
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
await deps.stopAuto(ctx, pi, blockerMsg);

View file

@ -429,10 +429,16 @@ export async function bootstrapAutoSession(
s.originalBasePath = base;
const isUnderGsdWorktrees = (p: string): boolean => {
// Direct layout: /.gsd/worktrees/
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
if (p.includes(marker)) return true;
const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`;
return p.endsWith(worktreesSuffix);
if (p.endsWith(worktreesSuffix)) return true;
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
const symlinkRe = new RegExp(
`\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees(?:\\${pathSep}|$)`,
);
return symlinkRe.test(p);
};
if (

View file

@ -153,9 +153,18 @@ export function checkResourcesStale(
* Returns the corrected base path.
*/
export function escapeStaleWorktree(base: string): string {
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
const idx = base.indexOf(marker);
if (idx === -1) return base;
// Direct layout: /.gsd/worktrees/
const directMarker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
let idx = base.indexOf(directMarker);
if (idx === -1) {
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
const symlinkRe = new RegExp(
`\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`,
);
const match = base.match(symlinkRe);
if (!match || match.index === undefined) return base;
idx = match.index;
}
// base is inside .gsd/worktrees/<something> — extract the project root
const projectRoot = base.slice(0, idx);

View file

@ -59,8 +59,17 @@ const VALID_CLASSIFICATIONS: readonly string[] = [
*/
export function resolveCapturesPath(basePath: string): string {
const resolved = resolve(basePath);
// Direct layout: /.gsd/worktrees/
const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`;
const idx = resolved.indexOf(worktreeMarker);
let idx = resolved.indexOf(worktreeMarker);
if (idx === -1) {
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
const symlinkRe = new RegExp(
`\\${sep}\\.gsd\\${sep}projects\\${sep}[a-f0-9]+\\${sep}worktrees\\${sep}`,
);
const match = resolved.match(symlinkRe);
if (match && match.index !== undefined) idx = match.index;
}
if (idx !== -1) {
// basePath is inside a worktree — resolve to project root
const projectRoot = resolved.slice(0, idx);

View file

@ -11,6 +11,7 @@ import {
getMainBranch,
getSliceBranchName,
parseSliceBranch,
resolveProjectRoot,
setActiveMilestoneId,
SLICE_BRANCH_RE,
} from "../worktree.ts";
@ -165,6 +166,52 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ── detectWorktreeName: symlink-resolved paths ───────────────────────────
console.log("\n=== detectWorktreeName (symlink-resolved paths) ===");
assertEq(
detectWorktreeName("/Users/fran/.gsd/projects/89e1c9ad49bf/worktrees/M001"),
"M001",
"detects milestone in symlink-resolved path",
);
assertEq(
detectWorktreeName("/Users/fran/.gsd/projects/abc123/worktrees/M002/subdir"),
"M002",
"detects milestone with trailing subdir in symlink-resolved path",
);
assertEq(
detectWorktreeName("/Users/fran/.gsd/projects/abc123"),
null,
"returns null for project root without worktrees segment",
);
assertEq(
detectWorktreeName("/foo/.gsd/worktrees/M001"),
"M001",
"still detects direct layout path",
);
// ── resolveProjectRoot: symlink-resolved paths ──────────────────────────
console.log("\n=== resolveProjectRoot (symlink-resolved paths) ===");
assertEq(
resolveProjectRoot("/Users/fran/.gsd/projects/89e1c9ad49bf/worktrees/M001"),
"/Users/fran",
"resolves to user home for symlink-resolved path",
);
assertEq(
resolveProjectRoot("/foo/.gsd/worktrees/M001"),
"/foo",
"still resolves direct layout path",
);
assertEq(
resolveProjectRoot("/some/repo"),
"/some/repo",
"returns unchanged for non-worktree path",
);
assertEq(
resolveProjectRoot("/data/.gsd/projects/deadbeef/worktrees/M003/nested"),
"/data",
"resolves correctly with nested subdirs after worktree name",
);
rmSync(base, { recursive: true, force: true });
report();
}

View file

@ -67,40 +67,60 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string,
// ─── Pure Utility Functions (unchanged) ────────────────────────────────────
/**
* Find the worktrees segment in a path, supporting both direct
* (`/.gsd/worktrees/`) and symlink-resolved (`/.gsd/projects/<hash>/worktrees/`)
* layouts. When `.gsd` is a symlink to `~/.gsd/projects/<hash>`, resolved
* paths contain the intermediate `projects/<hash>/` segment that the old
* single-marker check missed.
*/
function findWorktreeSegment(normalizedPath: string): { gsdIdx: number; afterWorktrees: number } | null {
// Direct layout: /.gsd/worktrees/<name>
const directMarker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(directMarker);
if (idx !== -1) {
return { gsdIdx: idx, afterWorktrees: idx + directMarker.length };
}
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/<name>
const symlinkRe = /\/\.gsd\/projects\/[a-f0-9]+\/worktrees\//;
const match = normalizedPath.match(symlinkRe);
if (match && match.index !== undefined) {
return { gsdIdx: match.index, afterWorktrees: match.index + match[0].length };
}
return null;
}
/**
* Detect the active worktree name from the current working directory.
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
*/
export function detectWorktreeName(basePath: string): string | null {
const normalizedPath = basePath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
if (idx === -1) return null;
const afterMarker = normalizedPath.slice(idx + marker.length);
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return null;
const afterMarker = normalizedPath.slice(seg.afterWorktrees);
const name = afterMarker.split("/")[0];
return name || null;
}
/**
* Resolve the project root from a path that may be inside a worktree.
* If the path contains `/.gsd/worktrees/<name>/`, returns the portion
* before `/.gsd/`. Otherwise returns the input unchanged.
* If the path contains a worktrees segment, returns the portion before
* `/.gsd/`. Otherwise returns the input unchanged.
*
* Use this in commands that call `process.cwd()` to ensure they always
* operate against the real project root, not a worktree subdirectory.
*/
export function resolveProjectRoot(basePath: string): string {
const normalizedPath = basePath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
if (idx === -1) return basePath;
// Return the original path up to the .gsd/ marker (un-normalized)
// Account for potential OS-specific separators
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return basePath;
// Return the original path up to the /.gsd/ boundary
const sep = basePath.includes("\\") ? "\\" : "/";
const markerOs = `${sep}.gsd${sep}worktrees${sep}`;
const idxOs = basePath.indexOf(markerOs);
if (idxOs !== -1) return basePath.slice(0, idxOs);
return basePath.slice(0, idx);
const gsdMarker = `${sep}.gsd${sep}`;
const gsdIdx = basePath.indexOf(gsdMarker);
if (gsdIdx !== -1) return basePath.slice(0, gsdIdx);
return basePath.slice(0, seg.gsdIdx);
}
/**