From f1a27b02b88dfe7d3d2c0840b2c5113f855d52a7 Mon Sep 17 00:00:00 2001 From: Juan Francisco Lebrero <101231690+frizynn@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:10:45 -0300 Subject: [PATCH] fix: detect worktree paths resolved through .gsd symlinks (#1585) When .gsd is a symlink (e.g., openclip/.gsd -> ~/.gsd/projects/), worktrees resolve to ~/.gsd/projects//worktrees/ instead of the expected /.gsd/worktrees/. All worktree detection functions used the marker /.gsd/worktrees/ which did not match the resolved path /.gsd/projects//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 --- src/resources/extensions/gsd/auto-loop.ts | 14 +++++- src/resources/extensions/gsd/auto-start.ts | 8 ++- .../extensions/gsd/auto-worktree-sync.ts | 15 ++++-- src/resources/extensions/gsd/captures.ts | 11 +++- .../extensions/gsd/tests/worktree.test.ts | 47 +++++++++++++++++ src/resources/extensions/gsd/worktree.ts | 50 +++++++++++++------ 6 files changed, 124 insertions(+), 21 deletions(-) diff --git a/src/resources/extensions/gsd/auto-loop.ts b/src/resources/extensions/gsd/auto-loop.ts index b2e5800ea..b0245ed1a 100644 --- a/src/resources/extensions/gsd/auto-loop.ts +++ b/src/resources/extensions/gsd/auto-loop.ts @@ -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); diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 3a988ba35..5f8cbce0b 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -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//worktrees/ + const symlinkRe = new RegExp( + `\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees(?:\\${pathSep}|$)`, + ); + return symlinkRe.test(p); }; if ( diff --git a/src/resources/extensions/gsd/auto-worktree-sync.ts b/src/resources/extensions/gsd/auto-worktree-sync.ts index 76ef7c065..d4328008a 100644 --- a/src/resources/extensions/gsd/auto-worktree-sync.ts +++ b/src/resources/extensions/gsd/auto-worktree-sync.ts @@ -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//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/ — extract the project root const projectRoot = base.slice(0, idx); diff --git a/src/resources/extensions/gsd/captures.ts b/src/resources/extensions/gsd/captures.ts index 2c18a987c..72447876e 100644 --- a/src/resources/extensions/gsd/captures.ts +++ b/src/resources/extensions/gsd/captures.ts @@ -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//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); diff --git a/src/resources/extensions/gsd/tests/worktree.test.ts b/src/resources/extensions/gsd/tests/worktree.test.ts index 995c45be6..cf3dae359 100644 --- a/src/resources/extensions/gsd/tests/worktree.test.ts +++ b/src/resources/extensions/gsd/tests/worktree.test.ts @@ -11,6 +11,7 @@ import { getMainBranch, getSliceBranchName, parseSliceBranch, + resolveProjectRoot, setActiveMilestoneId, SLICE_BRANCH_RE, } from "../worktree.ts"; @@ -165,6 +166,52 @@ async function main(): Promise { 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(); } diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 7669aa9db..0027c5ca4 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -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//worktrees/`) + * layouts. When `.gsd` is a symlink to `~/.gsd/projects/`, resolved + * paths contain the intermediate `projects//` segment that the old + * single-marker check missed. + */ +function findWorktreeSegment(normalizedPath: string): { gsdIdx: number; afterWorktrees: number } | null { + // Direct layout: /.gsd/worktrees/ + const directMarker = "/.gsd/worktrees/"; + const idx = normalizedPath.indexOf(directMarker); + if (idx !== -1) { + return { gsdIdx: idx, afterWorktrees: idx + directMarker.length }; + } + // Symlink-resolved layout: /.gsd/projects//worktrees/ + 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//). */ 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//`, 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); } /**