diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index d03500ead..c46194105 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -761,6 +761,24 @@ export function teardownAutoWorktree( branch, deleteBranch: !preserveBranch, }); + + // Verify cleanup succeeded — warn if the worktree directory is still on disk. + // On Windows, bash-based cleanup can silently fail when paths contain + // backslashes (#1436), leaving ~1 GB+ orphaned directories. + const wtDir = worktreePath(originalBasePath, milestoneId); + if (existsSync(wtDir)) { + console.error( + `[GSD] WARNING: Worktree directory still exists after teardown: ${wtDir}\n` + + ` This is likely an orphaned directory consuming disk space.\n` + + ` Remove it manually with: rm -rf "${wtDir.replaceAll("\\", "/")}"`, + ); + // Attempt a direct filesystem removal as a fallback + try { + rmSync(wtDir, { recursive: true, force: true }); + } catch { + // Non-fatal — the warning above tells the user how to clean up + } + } } /** diff --git a/src/resources/extensions/gsd/tests/windows-path-normalization.test.ts b/src/resources/extensions/gsd/tests/windows-path-normalization.test.ts new file mode 100644 index 000000000..3b119b426 --- /dev/null +++ b/src/resources/extensions/gsd/tests/windows-path-normalization.test.ts @@ -0,0 +1,99 @@ +/** + * windows-path-normalization.test.ts — Verify Windows backslash paths are + * normalised to forward slashes before embedding in bash command strings. + * + * Regression test for #1436: on Windows, `cd C:\Users\user\project` in bash + * strips backslashes (escape characters), producing `C:Usersuserproject`. + */ + +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── shellEscape + path normalization ────────────────────────────────────── + +// Replicate the shellEscape helper from cmux/index.ts +function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +// The bashPath pattern used in subagent/index.ts +function bashPath(p: string): string { + return shellEscape(p.replaceAll("\\", "/")); +} + +console.log("\n=== Windows backslash path normalization (#1436) ==="); + +// Backslash paths are converted to forward slashes +assertEq( + bashPath("C:\\Users\\user\\project"), + "'C:/Users/user/project'", + "backslash path normalised to forward slashes in shell-escaped string", +); + +// Unix paths pass through unchanged +assertEq( + bashPath("/home/user/project"), + "'/home/user/project'", + "Unix path unchanged", +); + +// Mixed separators are normalised +assertEq( + bashPath("C:\\Users/user\\project/src"), + "'C:/Users/user/project/src'", + "mixed separators normalised", +); + +// Paths with single quotes are still properly escaped +assertEq( + bashPath("C:\\Users\\o'brien\\project"), + "'C:/Users/o'\\''brien/project'", + "single quote in path is escaped after normalisation", +); + +// UNC paths +assertEq( + bashPath("\\\\server\\share\\dir"), + "'//server/share/dir'", + "UNC path normalised", +); + +// Empty string +assertEq( + bashPath(""), + "''", + "empty string handled", +); + +// ─── cd command construction ─────────────────────────────────────────────── + +console.log("\n=== cd command construction with normalised paths ==="); + +const windowsCwd = "C:\\Users\\user\\project\\.gsd\\worktrees\\M001"; +const cdCommand = `cd ${bashPath(windowsCwd)}`; +assertEq( + cdCommand, + "cd 'C:/Users/user/project/.gsd/worktrees/M001'", + "cd command uses forward slashes for Windows worktree path", +); + +// Verify the mangled form from #1436 is NOT produced +assertTrue( + !cdCommand.includes("C:Users"), + "mangled path C:Usersuserproject must not appear", +); + +// ─── Worktree teardown orphan detection ──────────────────────────────────── + +console.log("\n=== teardown orphan warning path formatting ==="); + +const windowsWtDir = "C:\\Users\\user\\project\\.gsd\\worktrees\\M001"; +const helpCommand = `rm -rf "${windowsWtDir.replaceAll("\\", "/")}"`; +assertEq( + helpCommand, + 'rm -rf "C:/Users/user/project/.gsd/worktrees/M001"', + "orphan cleanup help command uses forward slashes", +); + +report(); diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index c9609572f..62b60757f 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -516,12 +516,16 @@ async function runSingleAgentInCmuxSplit( const bundledPaths = (process.env.GSD_BUNDLED_EXTENSION_PATHS ?? "").split(path.delimiter).map((s) => s.trim()).filter(Boolean); const extensionArgs = bundledPaths.flatMap((p) => ["--extension", p]); const processArgs = [process.env.GSD_BIN_PATH!, ...extensionArgs, ...buildSubagentProcessArgs(agent, task, tmpPromptPath)]; + // Normalize all paths to forward slashes before embedding in bash strings. + // On Windows, backslashes are interpreted as escape characters by bash, + // mangling paths like C:\Users\user into C:Useruser (#1436). + const bashPath = (p: string) => shellEscape(p.replaceAll("\\", "/")); const innerScript = [ - `cd ${shellEscape(cwd ?? defaultCwd)}`, + `cd ${bashPath(cwd ?? defaultCwd)}`, "set -o pipefail", - `${shellEscape(process.execPath)} ${processArgs.map(shellEscape).join(" ")} 2> >(tee ${shellEscape(stderrPath)} >&2) | tee ${shellEscape(stdoutPath)}`, + `${bashPath(process.execPath)} ${processArgs.map(a => bashPath(a)).join(" ")} 2> >(tee ${bashPath(stderrPath)} >&2) | tee ${bashPath(stdoutPath)}`, "status=${PIPESTATUS[0]}", - `printf '%s' "$status" > ${shellEscape(exitPath)}`, + `printf '%s' "$status" > ${bashPath(exitPath)}`, ].join("; "); const sent = await cmuxClient.sendSurface(cmuxSurfaceId, `bash -lc ${shellEscape(innerScript)}`);