fix: normalize Windows backslash paths in bash command strings (#1436) (#1863)

On Windows, paths embedded in bash command strings have backslashes
stripped by the shell (e.g. C:\Users\user becomes C:Useruser), causing
cd and other commands to fail silently. This left ~1.4 GB orphaned
worktree directories after milestone completion.

- Normalize all paths to forward slashes before embedding in the
  subagent cmux bash script (cd, tee, process args)
- Add post-teardown orphan detection: warn and attempt rmSync fallback
  if the worktree directory persists after removeWorktree
- Add regression tests for Windows path normalization

Closes #1436

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-21 14:57:58 -06:00 committed by GitHub
parent 333c769e21
commit e5ae9fd249
3 changed files with 124 additions and 3 deletions

View file

@ -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
}
}
}
/**

View file

@ -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();

View file

@ -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)}`);