From baf5c225b22a6df3da2455bb5382e62f1efad3ed Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 24 Sep 2026 22:06:38 -0400 Subject: [PATCH] Fix Windows CI regressions from #51 - git-exec: point GIT_CONFIG_GLOBAL/SYSTEM at "/dev/null" again; Git for Windows special-cases it, while Windows ARM64 Git cannot open "NUL". - seatbelt: accept a drive-less \Users\ home, which a POSIX-rooted home normalizes to on Windows. - Tests: feed the large index through --index-info stdin (Windows caps a command line at 32K), compare worktree paths through native realpath (8.3 short names) and resolved Git output (forward slashes), remove the hidden .git file before rewriting it, guard the host-cp probe test like its siblings, and give the fresh-fixer pipeline test its 120s budget. --- runtime/server.mjs | 8 +++++--- src/git/git-exec.ts | 6 ++++-- src/platform/sandbox/seatbelt.ts | 2 +- tests/runtime/dependency-link.test.ts | 4 +++- tests/runtime/git-exec.test.ts | 11 +++++------ tests/runtime/pipeline-runtime.test.ts | 2 +- tests/runtime/worktree-manager.test.ts | 5 ++++- tests/runtime/worktree-sweep.test.ts | 9 +++++++-- 8 files changed, 30 insertions(+), 17 deletions(-) diff --git a/runtime/server.mjs b/runtime/server.mjs index fcf2cb1..b688dcb 100644 --- a/runtime/server.mjs +++ b/runtime/server.mjs @@ -36984,9 +36984,11 @@ async function git(cwd, args, indexFileOrOptions) { const maxOutputBytes = options.maxOutputBytes ?? (gitSubcommand(args) === "ls-files" ? INDEX_LISTING_MAX_BYTES : DEFAULT_MAX_OUTPUT_BYTES); const env = { PATH: process.env.PATH ?? "", + // Git for Windows special-cases "/dev/null" for config paths; "NUL" is + // an unreadable file to some builds (Windows ARM64 Git fails on it). ...options.userIdentity === true ? userConfigEnvironment() : { - GIT_CONFIG_GLOBAL: nullDevice, - GIT_CONFIG_SYSTEM: nullDevice, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" }, GIT_ATTR_NOSYSTEM: "1", @@ -38740,7 +38742,7 @@ function isDeclaredStateRoot(normalized, invocation, policy) { if (!rel.startsWith("..") && !isAbsolute(rel)) return true; } const userHomePattern = /^(\/Users\/[^/]+|\/home\/[^/]+|\/root)(?:\/.*)?$/u; - const winUserHomePattern = /^[a-zA-Z]:\\Users\\[^\\]+(?:\\.*)?$/u; + const winUserHomePattern = /^(?:[a-zA-Z]:)?\\Users\\[^\\]+(?:\\.*)?$/u; return userHomePattern.test(normalized) || winUserHomePattern.test(normalized); } function isValidInheritedStatePath(path43, invocation, policy) { diff --git a/src/git/git-exec.ts b/src/git/git-exec.ts index 7396d54..f11c22c 100644 --- a/src/git/git-exec.ts +++ b/src/git/git-exec.ts @@ -264,9 +264,11 @@ export async function git( ?? (gitSubcommand(args) === "ls-files" ? INDEX_LISTING_MAX_BYTES : DEFAULT_MAX_OUTPUT_BYTES); const env: Record = { PATH: process.env.PATH ?? "", + // Git for Windows special-cases "/dev/null" for config paths; "NUL" is + // an unreadable file to some builds (Windows ARM64 Git fails on it). ...(options.userIdentity === true ? userConfigEnvironment() : { - GIT_CONFIG_GLOBAL: nullDevice, - GIT_CONFIG_SYSTEM: nullDevice, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", }), GIT_ATTR_NOSYSTEM: "1", diff --git a/src/platform/sandbox/seatbelt.ts b/src/platform/sandbox/seatbelt.ts index feca109..5d5662d 100644 --- a/src/platform/sandbox/seatbelt.ts +++ b/src/platform/sandbox/seatbelt.ts @@ -108,7 +108,7 @@ function isDeclaredStateRoot( } const userHomePattern = /^(\/Users\/[^/]+|\/home\/[^/]+|\/root)(?:\/.*)?$/u; - const winUserHomePattern = /^[a-zA-Z]:\\Users\\[^\\]+(?:\\.*)?$/u; + const winUserHomePattern = /^(?:[a-zA-Z]:)?\\Users\\[^\\]+(?:\\.*)?$/u; return userHomePattern.test(normalized) || winUserHomePattern.test(normalized); } diff --git a/tests/runtime/dependency-link.test.ts b/tests/runtime/dependency-link.test.ts index 14b0005..1dc110d 100644 --- a/tests/runtime/dependency-link.test.ts +++ b/tests/runtime/dependency-link.test.ts @@ -39,7 +39,9 @@ describe("probeCowSupport", () => { }, ); - it("reports a forced clone failure and removes the probe directory", async () => { + // The probe resolves the host's system `cp` by absolute path before cloning. + it.skipIf(process.platform !== "darwin" && process.platform !== "linux")( + "reports a forced clone failure and removes the probe directory", async () => { let probeRoot: string | undefined; await expect(probeCowSupport({ diff --git a/tests/runtime/git-exec.test.ts b/tests/runtime/git-exec.test.ts index c346945..a2795b8 100644 --- a/tests/runtime/git-exec.test.ts +++ b/tests/runtime/git-exec.test.ts @@ -211,13 +211,12 @@ describe("index listing bound", () => { // Index-only entries with long names: ~9 MB of listing, no files on disk. const directory = "d".repeat(200); const total = 11_000; - for (let start = 0; start < total; start += 500) { - const args = ["update-index", "--add"]; - for (let index = start; index < start + 500; index += 1) { - args.push("--cacheinfo", `100644,${blob},${directory}/${directory}/${directory}/${directory}/f${index}`); - } - expect((await git(repo, args)).exitCode).toBe(0); + // Through stdin: Windows caps a command line at 32K characters. + let entries = ""; + for (let index = 0; index < total; index += 1) { + entries += `100644 ${blob}\t${directory}/${directory}/${directory}/${directory}/f${index}\n`; } + expect((await git(repo, ["update-index", "--index-info"], { stdin: entries })).exitCode).toBe(0); const listed = await git(repo, ["ls-files", "-v", "-z"]); diff --git a/tests/runtime/pipeline-runtime.test.ts b/tests/runtime/pipeline-runtime.test.ts index 10d38a0..e1ab157 100644 --- a/tests/runtime/pipeline-runtime.test.ts +++ b/tests/runtime/pipeline-runtime.test.ts @@ -2885,7 +2885,7 @@ describe("runPipeline", () => { "pipeline-fresh-fixers-round-2-fix", ]); expect(worktrees.every(entry => !entry.residue)).toBe(true); - }); + }, 120_000); it("fixes a blocker and returns decision-ready after a clean re-review", async () => { const repo = await initRepo(); diff --git a/tests/runtime/worktree-manager.test.ts b/tests/runtime/worktree-manager.test.ts index 0aad238..3f29582 100644 --- a/tests/runtime/worktree-manager.test.ts +++ b/tests/runtime/worktree-manager.test.ts @@ -102,7 +102,8 @@ async function canonicalPathsEqual(left: string, right: string): Promise { @@ -206,6 +207,8 @@ describe("WorktreeManager", () => { `[diff "x"]\n\ttextconv = "${process.execPath}" -e "require('fs').writeFileSync('${marker.replaceAll("\\", "/")}','')"\n`, { flag: "a" }, ); + // Git for Windows marks .git hidden, which a plain overwrite cannot open. + await rm(join(attempt.path, ".git")); await writeFile(join(attempt.path, ".git"), `gitdir: ${join(hostile, ".git")}\n`); await writeFile(join(attempt.path, ".gitattributes"), "a.txt diff=x\n"); await writeFile(join(attempt.path, "a.txt"), "changed\n"); diff --git a/tests/runtime/worktree-sweep.test.ts b/tests/runtime/worktree-sweep.test.ts index fb24508..861ca6d 100644 --- a/tests/runtime/worktree-sweep.test.ts +++ b/tests/runtime/worktree-sweep.test.ts @@ -14,6 +14,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -903,8 +904,12 @@ describe("startup worktree sweep", () => { await expectMissing(orphan.path); const listed = await git(repo.directory, ["worktree", "list", "--porcelain", "-z"]); - expect(listed.stdout).not.toContain(orphan.path); - expect(listed.stdout).toContain(await realpath(userWorktree)); + // Git reports forward slashes on Windows; compare resolved paths. + const registered = listed.stdout.split("\0") + .filter(field => field.startsWith("worktree ")) + .map(field => path.resolve(field.slice("worktree ".length))); + expect(registered).not.toContain(path.resolve(orphan.path)); + expect(registered).toContain(path.resolve(realpathSync.native(userWorktree))); await expect(readFile( path.join(repo.directory, ".worktrees", "claude-architect", ".gitignore"), "utf8",