From e9a2928ce7149517c63f14b85c748fcdba6d545e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 16 Mar 2026 16:24:19 -0400 Subject: [PATCH] fix: validate auto-worktree is a real git worktree before use (#695) getAutoWorktreePath() only checked existsSync() on the worktree directory, treating any directory under .gsd/worktrees// as a valid auto-worktree. A stray (non-git) directory would be accepted, causing auto-mode to derive state from an empty/invalid path and conclude no milestones exist. Add git worktree validation to both getAutoWorktreePath() and enterAutoWorktree(): check that the directory contains a .git file (not directory) with a 'gitdir:' pointer, which is the hallmark of a real git worktree checkout. Return null / throw if validation fails. This ensures stray directories are ignored and auto-mode falls through to normal worktree creation or root-state derivation. Closes #695 --- src/resources/extensions/gsd/auto-worktree.ts | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 0e95b2f40..672b6bb93 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -264,11 +264,29 @@ export function isInAutoWorktree(basePath: string): boolean { } /** - * Get the filesystem path for an auto-worktree, or null if it doesn't exist. + * Get the filesystem path for an auto-worktree, or null if it doesn't exist + * or is not a valid git worktree. + * + * Validates that the path is a real git worktree (has a .git file with a + * gitdir: pointer) rather than just a stray directory. This prevents + * mis-detection of leftover directories as active worktrees (#695). */ export function getAutoWorktreePath(basePath: string, milestoneId: string): string | null { const p = worktreePath(basePath, milestoneId); - return existsSync(p) ? p : null; + if (!existsSync(p)) return null; + + // Validate this is a real git worktree, not a stray directory. + // A git worktree has a .git *file* (not directory) containing "gitdir: ". + const gitPath = join(p, ".git"); + if (!existsSync(gitPath)) return null; + try { + const content = readFileSync(gitPath, "utf8").trim(); + if (!content.startsWith("gitdir: ")) return null; + } catch { + return null; + } + + return p; } /** @@ -283,6 +301,21 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string throw new Error(`Auto-worktree for ${milestoneId} does not exist at ${p}`); } + // Validate this is a real git worktree, not a stray directory (#695) + const gitPath = join(p, ".git"); + if (!existsSync(gitPath)) { + throw new Error(`Auto-worktree path ${p} exists but is not a git worktree (no .git)`); + } + try { + const content = readFileSync(gitPath, "utf8").trim(); + if (!content.startsWith("gitdir: ")) { + throw new Error(`Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`); + } + } catch (err) { + if (err instanceof Error && err.message.includes("worktree")) throw err; + throw new Error(`Auto-worktree path ${p} exists but .git is unreadable`); + } + const previousCwd = process.cwd(); try {