fix(doctor): Windows path compatibility for worktree/branch detection

Three fixes for Windows CI failures:

1. Remove single quotes from git branch --list glob patterns. On Windows,
   cmd.exe passes single quotes literally to git, preventing glob expansion.
   Affects shouldUseWorktreeIsolation() and stale branch detection.

2. Extend listWorktrees() branch-name fallback to cover milestone/* branches,
   not just worktree/* branches. On Windows, path normalization can prevent
   path-based worktree matching; the branch-name fallback is the safety net.

3. Use path.sep instead of hardcoded "/" in CWD prefix checks (doctor.ts
   orphan fix guard, worktree-manager.ts removeWorktree guard).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-15 09:15:30 -06:00
parent d27bf45740
commit 954e333228
3 changed files with 15 additions and 7 deletions

View file

@ -54,7 +54,9 @@ export function shouldUseWorktreeIsolation(basePath: string, overridePrefs?: { i
// Legacy detection: check for existing gsd/*/* branches (branch-per-slice pattern)
try {
const output = execSync("git branch --list 'gsd/*/*'", {
// Use unquoted glob pattern — single quotes are not interpreted by cmd.exe on Windows,
// causing the pattern to match literally instead of as a glob.
const output = execSync("git branch --list gsd/*/*", {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",

View file

@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { join, sep } from "node:path";
import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
@ -511,7 +511,7 @@ async function checkGitHealth(
if (shouldFix("orphaned_auto_worktree")) {
// Never remove a worktree matching current working directory
const cwd = process.cwd();
if (wt.path === cwd || cwd.startsWith(wt.path + "/")) {
if (wt.path === cwd || cwd.startsWith(wt.path + sep)) {
fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
} else {
try {
@ -527,7 +527,9 @@ async function checkGitHealth(
// ── Stale milestone branches ─────────────────────────────────────────
try {
const branchOutput = execSync("git branch --list 'milestone/*'", { cwd: basePath, stdio: "pipe" }).toString().trim();
// Use unquoted glob — single quotes are not interpreted by cmd.exe on Windows,
// causing the pattern to match literally instead of as a glob.
const branchOutput = execSync("git branch --list milestone/*", { cwd: basePath, stdio: "pipe" }).toString().trim();
if (branchOutput) {
const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));

View file

@ -17,7 +17,7 @@
import { existsSync, mkdirSync, realpathSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, resolve } from "node:path";
import { join, resolve, sep } from "node:path";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -213,7 +213,11 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
const entryPath = wtLine.replace("worktree ", "");
const branch = branchLine.replace("branch refs/heads/", "");
const branchWorktreeName = branch.startsWith("worktree/") ? branch.slice("worktree/".length) : null;
const branchWorktreeName = branch.startsWith("worktree/")
? branch.slice("worktree/".length)
: branch.startsWith("milestone/")
? branch.slice("milestone/".length)
: null;
const entryVariants = [resolve(entryPath)];
if (existsSync(entryPath)) {
entryVariants.push(realpathSync(entryPath));
@ -272,7 +276,7 @@ export function removeWorktree(
// If we're inside the worktree, move out first — git can't remove an in-use directory
const cwd = process.cwd();
const resolvedCwd = existsSync(cwd) ? realpathSync(cwd) : cwd;
if (resolvedCwd === resolvedWtPath || resolvedCwd.startsWith(resolvedWtPath + "/")) {
if (resolvedCwd === resolvedWtPath || resolvedCwd.startsWith(resolvedWtPath + sep)) {
process.chdir(basePath);
}