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/<MID>/ 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
This commit is contained in:
Tom Boucher 2026-03-16 16:24:19 -04:00
parent a90aa0c8d6
commit e9a2928ce7

View file

@ -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: <path>".
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 {