Skip to content

Commit 78b74ec

Browse files
authored
fix(terminal): inherit the host UTF-8 locale instead of forcing en_US.UTF-8 (#1713)
Commands spawned by the execa terminal spread process.env and then hardcoded LANG and LC_ALL to en_US.UTF-8, so a host that already resolved to a UTF-8 locale (for example en_AU.UTF-8) was overridden and every command printed "setlocale: LC_ALL: cannot change locale (en_US.UTF-8)". Resolve the effective locale the way POSIX does (LC_ALL, then LC_CTYPE, then LANG) and only fall back to en_US.UTF-8 when that locale is not UTF-8, which keeps the UTF-8 guarantee for Ruby/CocoaPods on hosts that do not configure a UTF-8 locale. Fixes #1084
1 parent 01928c3 commit 78b74ec

4 files changed

Lines changed: 135 additions & 3 deletions

File tree

‎src/integrations/terminal/ExecaTerminalProcess.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { RooTerminal } from "./types"
66
import { BaseTerminal } from "./BaseTerminal"
77
import { BaseTerminalProcess } from "./BaseTerminalProcess"
88
import { getShell } from "../../utils/shell"
9+
import { getUtf8LocaleEnv } from "./localeEnv"
910

1011
export class ExecaTerminalProcess extends BaseTerminalProcess {
1112
private terminalRef: WeakRef<RooTerminal>
@@ -48,9 +49,9 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
4849
stdin: "ignore",
4950
env: {
5051
...process.env,
51-
// Ensure UTF-8 encoding for Ruby, CocoaPods, etc.
52-
LANG: "en_US.UTF-8",
53-
LC_ALL: "en_US.UTF-8",
52+
// Keep the host locale when it already is UTF-8 (e.g. en_AU.UTF-8), otherwise
53+
// fall back to en_US.UTF-8 so tools such as Ruby and CocoaPods still emit UTF-8.
54+
...getUtf8LocaleEnv(),
5455
},
5556
})`${command}`
5657

‎src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,20 @@ describe("ExecaTerminalProcess", () => {
6363
})
6464

6565
describe("UTF-8 encoding fix", () => {
66+
/**
67+
* Clears the locale variables so the assertion does not depend on the locale of the
68+
* machine (or CI runner) that executes the test.
69+
*/
70+
const clearLocaleVariables = () => {
71+
delete process.env.LANG
72+
delete process.env.LC_ALL
73+
delete process.env.LC_CTYPE
74+
}
75+
6676
it("should set LANG and LC_ALL to en_US.UTF-8", async () => {
6777
// Deterministic shell so the assertion focuses solely on LANG/LC_ALL.
6878
vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh")
79+
clearLocaleVariables()
6980
await terminalProcess.run("echo test")
7081
const execaMock = vitest.mocked(execa)
7182
expect(execaMock).toHaveBeenCalledWith(
@@ -81,6 +92,18 @@ describe("ExecaTerminalProcess", () => {
8192
)
8293
})
8394

95+
it("preserves an inherited UTF-8 locale instead of forcing en_US.UTF-8 (#1084)", async () => {
96+
process.env.LANG = "en_AU.UTF-8"
97+
delete process.env.LC_ALL
98+
delete process.env.LC_CTYPE
99+
terminalProcess = new ExecaTerminalProcess(mockTerminal)
100+
await terminalProcess.run("echo test")
101+
const execaMock = vitest.mocked(execa)
102+
const calledOptions = execaMock.mock.calls[0][0] as unknown as { env: NodeJS.ProcessEnv }
103+
expect(calledOptions.env.LANG).toBe("en_AU.UTF-8")
104+
expect(calledOptions.env.LC_ALL).toBeUndefined()
105+
})
106+
84107
it("should preserve existing environment variables", async () => {
85108
process.env.EXISTING_VAR = "existing"
86109
terminalProcess = new ExecaTerminalProcess(mockTerminal)
@@ -91,6 +114,7 @@ describe("ExecaTerminalProcess", () => {
91114
})
92115

93116
it("should override existing LANG and LC_ALL values", async () => {
117+
// "C" and "POSIX" select ASCII, not UTF-8, so the UTF-8 fallback still applies.
94118
process.env.LANG = "C"
95119
process.env.LC_ALL = "POSIX"
96120
terminalProcess = new ExecaTerminalProcess(mockTerminal)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { getUtf8LocaleEnv } from "../localeEnv"
2+
3+
describe("getUtf8LocaleEnv", () => {
4+
it("falls back to en_US.UTF-8 when the host provides no locale at all", () => {
5+
expect(getUtf8LocaleEnv({})).toEqual({ LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" })
6+
})
7+
8+
it("falls back when the host only provides empty locale variables", () => {
9+
expect(getUtf8LocaleEnv({ LANG: "", LC_ALL: "", LC_CTYPE: "" })).toEqual({
10+
LANG: "en_US.UTF-8",
11+
LC_ALL: "en_US.UTF-8",
12+
})
13+
})
14+
15+
it("falls back for ASCII locales such as C and POSIX", () => {
16+
expect(getUtf8LocaleEnv({ LANG: "C" })).toEqual({ LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" })
17+
expect(getUtf8LocaleEnv({ LC_ALL: "POSIX" })).toEqual({ LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" })
18+
})
19+
20+
it("leaves an inherited UTF-8 LANG untouched (#1084)", () => {
21+
expect(getUtf8LocaleEnv({ LANG: "en_AU.UTF-8" })).toEqual({})
22+
expect(getUtf8LocaleEnv({ LANG: "en_GB.UTF-8", PATH: "/usr/bin" })).toEqual({})
23+
})
24+
25+
it("leaves an inherited UTF-8 LC_ALL untouched", () => {
26+
expect(getUtf8LocaleEnv({ LC_ALL: "de_DE.UTF-8" })).toEqual({})
27+
})
28+
29+
it("accepts the utf8 spelling and LC_CTYPE as a UTF-8 signal", () => {
30+
expect(getUtf8LocaleEnv({ LANG: "en_AU.utf8" })).toEqual({})
31+
expect(getUtf8LocaleEnv({ LC_CTYPE: "zh_CN.UTF-8" })).toEqual({})
32+
})
33+
34+
it("honors the POSIX precedence order, where LC_ALL wins over LANG", () => {
35+
expect(getUtf8LocaleEnv({ LANG: "en_AU.UTF-8", LC_ALL: "POSIX" })).toEqual({
36+
LANG: "en_US.UTF-8",
37+
LC_ALL: "en_US.UTF-8",
38+
})
39+
})
40+
41+
it("honors the POSIX precedence order, where LC_CTYPE wins over LANG", () => {
42+
// Guards the resolution order: reading LANG before LC_CTYPE would wrongly
43+
// treat the ASCII LC_CTYPE as an inherited UTF-8 locale.
44+
expect(getUtf8LocaleEnv({ LANG: "en_AU.UTF-8", LC_CTYPE: "POSIX" })).toEqual({
45+
LANG: "en_US.UTF-8",
46+
LC_ALL: "en_US.UTF-8",
47+
})
48+
})
49+
50+
it("honors the POSIX precedence order, where LC_ALL wins over LC_CTYPE", () => {
51+
// LC_ALL overrides the category variables, so a UTF-8 LC_CTYPE must not
52+
// rescue an ASCII LC_ALL.
53+
expect(getUtf8LocaleEnv({ LC_ALL: "POSIX", LC_CTYPE: "de_DE.UTF-8" })).toEqual({
54+
LANG: "en_US.UTF-8",
55+
LC_ALL: "en_US.UTF-8",
56+
})
57+
})
58+
59+
it("returns a fresh object so callers cannot share and mutate the override", () => {
60+
const first = getUtf8LocaleEnv({})
61+
first.LANG = "mutated"
62+
63+
expect(getUtf8LocaleEnv({})).toEqual({ LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" })
64+
})
65+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Locale environment handling for commands spawned by Zoo Code.
3+
*
4+
* Commands used to be spawned with a hardcoded `LANG`/`LC_ALL` of `en_US.UTF-8` so that
5+
* tools such as Ruby and CocoaPods always emit UTF-8. Overriding the locale
6+
* unconditionally is harmful on hosts where `en_US.UTF-8` is not generated: every
7+
* command then prints `setlocale: LC_ALL: cannot change locale (en_US.UTF-8)`, and
8+
* locale-sensitive tools behave as if the machine were US English.
9+
*/
10+
11+
/** UTF-8 locale used when (and only when) the host does not provide one of its own. */
12+
const FALLBACK_UTF8_LOCALE = "en_US.UTF-8"
13+
14+
/**
15+
* Resolves the effective locale of a process environment.
16+
*
17+
* POSIX resolves the locale from the first non-empty value of `LC_ALL`, `LC_CTYPE` and
18+
* `LANG`, so the effective locale is what matters here, not the individual variables.
19+
*/
20+
function getEffectiveLocale(env: NodeJS.ProcessEnv): string {
21+
return env.LC_ALL || env.LC_CTYPE || env.LANG || ""
22+
}
23+
24+
/** Whether a locale string selects a UTF-8 codeset (accepts the `UTF-8` and `utf8` spellings). */
25+
function isUtf8Locale(locale: string): boolean {
26+
return /utf-?8/i.test(locale)
27+
}
28+
29+
/**
30+
* Returns the locale overrides to merge into the environment of a spawned command.
31+
*
32+
* A host that already resolves to a UTF-8 locale is left untouched so that commands
33+
* inherit the user's locale; only a host without any UTF-8 locale receives the UTF-8
34+
* fallback that the historical hardcoded override was meant to provide.
35+
*/
36+
export function getUtf8LocaleEnv(env: NodeJS.ProcessEnv = process.env): { LANG?: string; LC_ALL?: string } {
37+
if (isUtf8Locale(getEffectiveLocale(env))) {
38+
return {}
39+
}
40+
41+
return { LANG: FALLBACK_UTF8_LOCALE, LC_ALL: FALLBACK_UTF8_LOCALE }
42+
}

0 commit comments

Comments
 (0)