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:
parent
1b2ff19615
commit
f1a27b02b8
6 changed files with 124 additions and 21 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue