From a7cf125970a364935f47908e0214cdbe21552295 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 22 Mar 2026 19:05:50 -0400 Subject: [PATCH] fix(git): force LC_ALL=C in GIT_NO_PROMPT_ENV to support non-English locales (#2035) On non-English systems (e.g. LANG=de_DE.UTF-8), git produces localized stderr output. GSD's stderr.includes() guards are hardcoded to English strings and never match, causing every git add with exclusions to throw GSD_GIT_ERROR and merge failures to be misclassified. - Add LC_ALL: "C" to GIT_NO_PROMPT_ENV in git-constants.ts - Add env: GIT_NO_PROMPT_ENV to nativeMergeSquash fallback execFileSync - Add regression tests for both fixes Fixes #1997 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/git-constants.ts | 1 + .../extensions/gsd/native-git-bridge.ts | 1 + .../extensions/gsd/tests/git-locale.test.ts | 133 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/git-locale.test.ts diff --git a/src/resources/extensions/gsd/git-constants.ts b/src/resources/extensions/gsd/git-constants.ts index 7213798ca..4925f4271 100644 --- a/src/resources/extensions/gsd/git-constants.ts +++ b/src/resources/extensions/gsd/git-constants.ts @@ -8,4 +8,5 @@ export const GIT_NO_PROMPT_ENV = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GIT_SVN_ID: "", + LC_ALL: "C", // force English git output so stderr string checks work on all locales (#1997) }; diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index ab2361296..dd6d7bae9 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -847,6 +847,7 @@ export function nativeMergeSquash(basePath: string, branch: string): GitMergeRes cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, }); return { success: true, conflicts: [] }; } catch (err: unknown) { diff --git a/src/resources/extensions/gsd/tests/git-locale.test.ts b/src/resources/extensions/gsd/tests/git-locale.test.ts new file mode 100644 index 000000000..d4e95704a --- /dev/null +++ b/src/resources/extensions/gsd/tests/git-locale.test.ts @@ -0,0 +1,133 @@ +/** + * Regression tests for #1997: git locale not forced to C. + * + * Validates that GIT_NO_PROMPT_ENV includes LC_ALL=C so git always produces + * English output, and that nativeMergeSquash passes the env to execFileSync. + */ + +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; + +import { GIT_NO_PROMPT_ENV } from "../git-constants.ts"; +import { nativeAddAllWithExclusions } from "../native-git-bridge.ts"; +import { RUNTIME_EXCLUSION_PATHS } from "../git-service.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); +} + +function initTempRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "gsd-locale-")); + git(dir, "init"); + git(dir, "config", "user.email", "test@test.com"); + git(dir, "config", "user.name", "Test"); + // Initial commit so HEAD exists + writeFileSync(join(dir, "init.txt"), "init"); + git(dir, "add", "-A"); + git(dir, "commit", "-m", "init"); + return dir; +} + +function createFile(base: string, relPath: string, content: string): void { + const full = join(base, relPath); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); +} + +async function main(): Promise { + // ─── GIT_NO_PROMPT_ENV includes LC_ALL=C ───────────────────────────── + + console.log("\n=== GIT_NO_PROMPT_ENV includes LC_ALL=C ==="); + + assertEq( + GIT_NO_PROMPT_ENV.LC_ALL, + "C", + "GIT_NO_PROMPT_ENV must set LC_ALL to 'C' to force English git output" + ); + + assertTrue( + "GIT_TERMINAL_PROMPT" in GIT_NO_PROMPT_ENV, + "GIT_NO_PROMPT_ENV still contains GIT_TERMINAL_PROMPT" + ); + + // ─── nativeAddAllWithExclusions: non-English locale does not throw ─── + + console.log("\n=== nativeAddAllWithExclusions: non-English locale does not throw ==="); + + { + // Simulate what happens on a German system: .gsd is gitignored, + // exclusion pathspecs trigger an advisory warning exit code 1. + // With LC_ALL=C the English stderr guard should match and suppress. + const repo = initTempRepo(); + + writeFileSync(join(repo, ".gitignore"), ".gsd\n"); + createFile(repo, ".gsd/STATE.md", "# State"); + createFile(repo, "src/app.ts", "export const x = 1;"); + + // Save original LC_ALL / LANG and force German locale env + const origLcAll = process.env.LC_ALL; + const origLang = process.env.LANG; + process.env.LANG = "de_DE.UTF-8"; + delete process.env.LC_ALL; + + let threw = false; + try { + nativeAddAllWithExclusions(repo, RUNTIME_EXCLUSION_PATHS); + } catch (e) { + threw = true; + console.error(" unexpected error:", e); + } + + // Restore + if (origLcAll !== undefined) process.env.LC_ALL = origLcAll; + else delete process.env.LC_ALL; + if (origLang !== undefined) process.env.LANG = origLang; + else delete process.env.LANG; + + assertTrue( + !threw, + "nativeAddAllWithExclusions must not throw on non-English locale when .gsd is gitignored (#1997)" + ); + + const staged = git(repo, "diff", "--cached", "--name-only"); + assertTrue(staged.includes("src/app.ts"), "real file staged despite German locale"); + + rmSync(repo, { recursive: true, force: true }); + } + + // ─── nativeMergeSquash: env is passed (merge-squash stderr is English) ─ + + console.log("\n=== nativeMergeSquash fallback uses GIT_NO_PROMPT_ENV ==="); + + { + // We verify indirectly: the source code must pass env: GIT_NO_PROMPT_ENV. + // Read the source and check for the pattern. This is a static check. + const src = readFileSync( + join(import.meta.dirname, "..", "native-git-bridge.ts"), + "utf-8" + ); + + // Find the nativeMergeSquash function and check it uses GIT_NO_PROMPT_ENV + const fnStart = src.indexOf("export function nativeMergeSquash"); + assertTrue(fnStart !== -1, "nativeMergeSquash function exists in source"); + + const fnBody = src.slice(fnStart, src.indexOf("\nexport function", fnStart + 1)); + const hasEnv = fnBody.includes("env: GIT_NO_PROMPT_ENV"); + assertTrue( + hasEnv, + "nativeMergeSquash fallback must pass env: GIT_NO_PROMPT_ENV to execFileSync (#1997)" + ); + } + + report(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});