Skip to content

Commit 371aa57

Browse files
committed
fix(windows): resolve PATH whatever casing the child was handed
Routing the release script through the launcher was necessary but not sufficient, because the launcher was reading the wrong list. Windows treats environment variable names case-insensitively, so a spawned child can arrive with Path, PATH, or both -- and the release test built its env as {...process.env, PATH: shims}, which on Windows leaves the inherited Path sitting beside the new PATH. The resolver read two fixed spellings and matched whichever came first, so the shim directory was invisible and the real git ran. That is why the branch guard kept reporting dev when the test had faked main. The resolver now matches the key however it is spelled, and the test sets a single Path entry instead of layering a second casing on top of the inherited one. Both halves matter: either alone leaves the same ambiguity for the next caller to trip over. A host-platform run cannot observe any of this, which is how the suite stayed green on macOS through four red Windows rounds, so the casing matrix is pinned as a test rather than left to CI to rediscover.
1 parent 0af17fb commit 371aa57

3 files changed

Lines changed: 53 additions & 4 deletions

File tree

src/lib/win-exec.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,18 @@ export function resolveWindowsCommand(command: string, deps: ResolveDeps = {}):
4444
if (win32.extname(command) || command.includes("\\") || command.includes("/") || win32.isAbsolute(command)) {
4545
return command;
4646
}
47-
const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
48-
for (const dir of (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean)) {
47+
// Windows environment variables are case-insensitive, and a spawned child can
48+
// arrive with `Path`, `PATH`, or both depending on who built its env. Reading
49+
// only two fixed spellings silently resolved against the wrong list once a
50+
// caller added a second casing, so match however the key is spelled.
51+
const lookup = (name: string): string | undefined => {
52+
const direct = env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()];
53+
if (direct !== undefined) return direct;
54+
const key = Object.keys(env).find(k => k.toLowerCase() === name.toLowerCase());
55+
return key ? env[key] : undefined;
56+
};
57+
const exts = (lookup("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
58+
for (const dir of (lookup("PATH") ?? "").split(win32.delimiter).filter(Boolean)) {
4959
for (const ext of exts) {
5060
const candidate = win32.join(dir, command + ext.toLowerCase());
5161
if (exists(candidate)) return candidate;

tests/release-helper.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,23 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) {
192192
installCommandShim(shimDir, name);
193193
}
194194

195+
// Windows names the variable `Path`, and `...process.env` copies it in under
196+
// that spelling. Adding a separate `PATH` key leaves BOTH present, and which
197+
// one wins is not something this test should be gambling on — the child saw
198+
// the real git instead of the shim, so the branch guard read `dev` and the
199+
// script aborted before logging a single call. Strip every case variant, then
200+
// set exactly one.
201+
const inheritedEnv = Object.fromEntries(
202+
Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "path"),
203+
);
204+
const pathKey = process.platform === "win32" ? "Path" : "PATH";
205+
const pathValue = `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? process.env.Path ?? ""}`;
206+
195207
const result = spawnSync(process.execPath, [releaseScriptPath, version], {
196208
cwd: repoRoot,
197209
env: {
198-
...process.env,
199-
PATH: `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
210+
...inheritedEnv,
211+
[pathKey]: pathValue,
200212
FAKE_RELEASE_LOG: logPath,
201213
FAKE_GIT_BRANCH: scenario.branch ?? "main",
202214
FAKE_GIT_HEAD_SHA: scenario.headSha ?? "abc123def456",

tests/win-exec.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,33 @@ describe("resolveWindowsCommand", () => {
6060
expect(resolveWindowsCommand("claude", { env, exists: () => false })).toBe("claude");
6161
expect(resolveWindowsCommand("claude", { env: {}, exists: () => true })).toBe("claude");
6262
});
63+
64+
/**
65+
* Windows env vars are case-insensitive and a spawned child can arrive with
66+
* `Path`, `PATH`, or both — `{...process.env, PATH: x}` on Windows leaves the
67+
* inherited `Path` in place beside the new `PATH`. Reading two fixed spellings
68+
* resolved against whichever happened to be listed first, which is how a test
69+
* that put shims on PATH still reached the real `git`: the branch guard then
70+
* read the wrong branch and the script aborted before running anything.
71+
*/
72+
test("PATH and PATHEXT resolve whatever casing the child was handed", () => {
73+
const exists = (p: string) => p === "D:\\shims\\git.cmd";
74+
75+
for (const pathKey of ["PATH", "Path", "path"]) {
76+
const cased = { [pathKey]: "D:\\shims", PATHEXT: ".COM;.EXE;.BAT;.CMD" };
77+
expect(resolveWindowsCommand("git", { env: cased, exists })).toBe("D:\\shims\\git.cmd");
78+
}
79+
80+
for (const extKey of ["PATHEXT", "PathExt", "pathext"]) {
81+
const cased = { Path: "D:\\shims", [extKey]: ".COM;.EXE;.BAT;.CMD" };
82+
expect(resolveWindowsCommand("git", { env: cased, exists })).toBe("D:\\shims\\git.cmd");
83+
}
84+
85+
// Both spellings present: the shim directory must still win, whichever key
86+
// the platform ends up honouring.
87+
const both = { Path: "D:\\shims", PATH: "D:\\shims", PATHEXT: ".CMD" };
88+
expect(resolveWindowsCommand("git", { env: both, exists })).toBe("D:\\shims\\git.cmd");
89+
});
6390
});
6491

6592
describe("commandInvocation", () => {

0 commit comments

Comments
 (0)