Consolidate untranslated-git-locale into runner and relay primitives

Replace the five per-site LC_ALL=C patches with one shared
UNTRANSLATED_GIT_OUTPUT_ENV (LANGUAGE=en LC_ALL=en_US.UTF-8
LANG=en_US.UTF-8) injected inside the git runner primitives
(promptGuardGitEnv, gitSpawn, gitExecFileSync, gitExecFileAsyncBuffer)
and a relay buildRelayGitEnv() helper, so every current and future
machine-parsed git spawn is covered by construction — including the
fs-handler-git-fallback sites the per-site approach missed. The UTF-8
English locale keeps a UTF-8 LC_CTYPE for hooks git spawns; LANGUAGE is
pinned because gettext consults it before LC_ALL.

WSL-routed git gets the same values as a shell assignment prefix built
in resolveCommand, since spawn env cannot cross the wsl.exe boundary —
closing the WSL gap the first pass accepted.

Also scrub credential-bearing remote URLs from the probe-failed message
surfaced on the worktree card.

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Brennan Benson
2026-07-09 22:58:39 -07:00
parent 13c3730211
commit 040bcdb907
12 changed files with 128 additions and 49 deletions
+14
View File
@@ -66,6 +66,20 @@ describe('probeBranchUpstream', () => {
})
})
it('scrubs credential-bearing URLs from the probe-failed message', async () => {
// The message surfaces on the worktree card, so an embedded remote URL
// must not leak a token or password into the UI.
const exec: GitExec = vi
.fn()
.mockRejectedValue(
new Error('fatal: unable to access https://user:hunter2@example.com/repo.git/: timed out')
)
expect(await probeBranchUpstream(exec)).toEqual({
outcome: 'probe-failed',
message: 'fatal: unable to access https://example.com/repo.git/: timed out'
})
})
it('reports probe-failed, not has-upstream, for localized git diagnostics (issue #7808)', async () => {
// A gettext-enabled git under de_DE translates even the `fatal:` prefix.
const exec: GitExec = vi.fn(async (args: string[]) => {
+3 -2
View File
@@ -1,4 +1,4 @@
import { isNoUpstreamError } from '../../shared/git-remote-error'
import { isNoUpstreamError, stripCredentialsFromMessage } from '../../shared/git-remote-error'
import {
resolveEffectiveGitUpstream,
type GitCommandRunner
@@ -31,9 +31,10 @@ export async function probeBranchUpstream(exec: GitExec): Promise<BranchUpstream
}
// Why: an unexpected failure is not proof either way — report it as its own
// outcome so callers skip the rename but stay retryable (issue #7808).
// The message surfaces in the UI, so scrub credential-bearing remote URLs.
return {
outcome: 'probe-failed',
message: error instanceof Error ? error.message : String(error)
message: stripCredentialsFromMessage(error instanceof Error ? error.message : String(error))
}
}
}
+12 -3
View File
@@ -9,7 +9,8 @@ import {
nonInteractiveGitEnv,
parseRetryAfterMs,
promptGuardGitEnv,
redirectPortedHostnameToEnv
redirectPortedHostnameToEnv,
untranslatedGitOutputEnv
} from './runner'
// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol
@@ -224,11 +225,19 @@ describe('git env forces untranslated diagnostics (issue #7808)', () => {
// A gettext-enabled git under de_DE translates even the `fatal:` prefix,
// breaking isNoUpstreamError and every other stderr phrase match.
const env = promptGuardGitEnv({ PATH: '/usr/bin', LC_ALL: 'de_DE.UTF-8' })
expect(env.LC_ALL).toBe('C')
expect(env.LC_ALL).toBe('en_US.UTF-8')
expect(env.LANG).toBe('en_US.UTF-8')
})
it('pins LANGUAGE, which outranks LC_ALL in gettext lookups', () => {
const env = untranslatedGitOutputEnv({ PATH: '/usr/bin', LANGUAGE: 'de:en' })
expect(env.LANGUAGE).toBe('en')
expect(env.LC_ALL).toBe('en_US.UTF-8')
})
it('applies to nonInteractiveGitEnv as well', () => {
const env = nonInteractiveGitEnv({ PATH: '/usr/bin', LANG: 'fr_FR.UTF-8' })
expect(env.LC_ALL).toBe('C')
expect(env.LC_ALL).toBe('en_US.UTF-8')
expect(env.LANGUAGE).toBe('en')
})
})
+30 -9
View File
@@ -35,9 +35,17 @@ import {
escapeWslShCommandForWindows,
quotePosixShell
} from '../../shared/wsl-login-shell-command'
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale'
// ─── Core resolution ────────────────────────────────────────────────
// `LANGUAGE=en LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8` — env-assignment prefix for
// WSL-routed git, where spawn env cannot cross the wsl.exe boundary. Values are
// shell-safe unquoted (alnum, dot, dash, underscore only).
const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV)
.map(([key, value]) => `${key}=${value}`)
.join(' ')
type ResolvedCommand = {
binary: string
args: string[]
@@ -202,6 +210,10 @@ function resolveCommand(
}
const translatedArgs = translateArgsForWsl(args)
// Why: env set on wsl.exe stays on the Windows side (WSLENV forwards only
// named vars), so the untranslated-output locale must ride the command
// string for git stderr parsers to work inside the distro (issue #7808).
const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : ''
const escapedCommand = quotePosixShell(command)
// Why: shell-escape each argument to prevent word splitting / glob expansion
// inside the bash -c string. Single quotes are safe for all chars except
@@ -214,8 +226,8 @@ function resolveCommand(
// doesn't need a particular cwd for global calls like `api rate_limit`.
const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null)
const shellCmd = linuxCwd
? `cd ${quotePosixShell(linuxCwd)} && ${escapedCommand} ${escapedArgs.join(' ')}`
: `${escapedCommand} ${escapedArgs.join(' ')}`
? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
: `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
if (options.useWslLoginShell) {
return {
@@ -560,15 +572,21 @@ export function appendGitConfigEnv(
return next
}
/**
* Pin Orca-spawned git to untranslated English output so stderr/progress
* parsers keep working under any user locale (issue #7808; see
* UNTRANSLATED_GIT_OUTPUT_ENV for the full rationale). Terminal git is
* untouched. Injected by every git runner in this module; WSL-routed spawns
* get the same values via GIT_OUTPUT_LOCALE_SHELL_PREFIX instead.
*/
export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV }
}
export function promptGuardGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return appendGitConfigEnv(
{
...env,
// Why: Orca parses git's stderr diagnostics (isNoUpstreamError & friends);
// a gettext-enabled git under a non-English locale translates even the
// `fatal:` prefix and silently breaks those parsers (issue #7808). Force
// the C locale for Orca-spawned git only — terminal git is untouched.
LC_ALL: 'C',
...untranslatedGitOutputEnv(env),
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
@@ -875,7 +893,8 @@ export async function gitExecFileAsyncBuffer(
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
maxBuffer: options.maxBuffer
maxBuffer: options.maxBuffer,
env: untranslatedGitOutputEnv()
})) as { stdout: Buffer }
return { stdout }
}
@@ -1056,6 +1075,7 @@ export function gitExecFileSync(
return execFileSync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
env: untranslatedGitOutputEnv(),
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS
}) as string
@@ -1081,6 +1101,7 @@ export function gitSpawn(
const spawnStartedAt = performance.now()
const child = spawn(resolved.binary, resolved.args, {
...spawnOptions,
env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env),
cwd: resolved.cwd
})
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
-4
View File
@@ -2253,10 +2253,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
try {
proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], {
cwd: args.destination,
// Why: emitCloneProgressFromText and getGitCloneFailureMessage
// parse untranslated progress/`fatal:` lines, which a gettext
// git localizes (issue #7808). Locale only — auth is unchanged.
env: { ...process.env, LC_ALL: 'C' },
stdio: ['ignore', 'ignore', 'pipe']
})
} catch (err) {
+11 -11
View File
@@ -5452,7 +5452,7 @@ describe('OrcaRuntimeService', () => {
it('keeps project clone setup on the cloned host-qualified repo', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
@@ -5514,7 +5514,7 @@ describe('OrcaRuntimeService', () => {
it('adopts public clone repos into host-qualified project setup', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = []
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const runtimeStore = {
@@ -5584,7 +5584,7 @@ describe('OrcaRuntimeService', () => {
it('keeps project clone repos split by runtime host on the same clone path', async () => {
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
const clonePath = join(destination, 'orca')
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const repos: Record<string, unknown>[] = [
{
id: 'repo-host-a',
@@ -5755,7 +5755,7 @@ describe('OrcaRuntimeService', () => {
})
it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const added: Record<string, unknown>[] = []
const colorStore = {
...store,
@@ -5791,7 +5791,7 @@ describe('OrcaRuntimeService', () => {
})
it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
spawnSpy.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
@@ -5889,7 +5889,7 @@ describe('OrcaRuntimeService', () => {
})
it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const runtime = createRuntime()
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
const destination = join(tempRoot, 'destination')
@@ -5906,7 +5906,7 @@ describe('OrcaRuntimeService', () => {
})
it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const runtime = createRuntime()
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
const destination = join(tempRoot, 'destination')
@@ -5923,7 +5923,7 @@ describe('OrcaRuntimeService', () => {
})
it('removes an owned runtime clone target when git exits unsuccessfully', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
spawnSpy.mockReturnValue(proc as never)
@@ -5950,7 +5950,7 @@ describe('OrcaRuntimeService', () => {
})
it('preserves an existing runtime clone target when git exits unsuccessfully', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
spawnSpy.mockReturnValue(proc as never)
@@ -5977,7 +5977,7 @@ describe('OrcaRuntimeService', () => {
})
it('skips runtime clone failure cleanup when the owned target is replaced', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
proc.stderr = new EventEmitter()
spawnSpy.mockReturnValue(proc as never)
@@ -6006,7 +6006,7 @@ describe('OrcaRuntimeService', () => {
})
it('serializes concurrent runtime clones for the same target', async () => {
const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn')
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
firstProc.stderr = new EventEmitter()
spawnSpy.mockReturnValueOnce(firstProc as never)
+3 -7
View File
@@ -41,7 +41,7 @@ import {
AGENT_PROMPT_SUBMIT_DELAY_MS,
buildAgentPromptPasteBytes
} from '../../shared/agent-prompt-injection'
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
import { gitExecFileAsync, gitSpawn } from '../git/runner'
import {
cleanupClaimedCloneTarget,
claimCloneTarget,
@@ -10662,14 +10662,10 @@ export class OrcaRuntimeService {
await mkdir(trimmedDestination, { recursive: true })
const claimedTarget = await claimCloneTarget(clonePath)
await new Promise<void>((resolve, reject) => {
let proc: ReturnType<typeof wslAwareSpawn>
let proc: ReturnType<typeof gitSpawn>
try {
proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], {
proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], {
cwd: trimmedDestination,
// Why: getGitCloneFailureMessage matches `fatal:`/`error:` lines,
// which a gettext-enabled git translates under a non-English locale
// (issue #7808). Locale only — clone auth behavior stays unchanged.
env: { ...process.env, LC_ALL: 'C' },
stdio: ['ignore', 'ignore', 'pipe']
})
} catch (err) {
+3 -3
View File
@@ -19,7 +19,7 @@ import {
ingestGitGrepLine,
SEARCH_TIMEOUT_MS
} from '../shared/text-search'
import { buildRelayCommandEnv } from './relay-command-env'
import { buildRelayGitEnv } from './relay-command-env'
/**
* List files using `git ls-files`. Fallback when rg is not installed.
@@ -61,7 +61,7 @@ export function listFilesWithGit(
const child = spawn('git', ['ls-files', ...args], {
cwd: rootPath,
env: buildRelayCommandEnv(),
env: buildRelayGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let timer: ReturnType<typeof setTimeout> | null = null
@@ -209,7 +209,7 @@ export function searchWithGitGrep(
const child = spawn('git', gitArgs, {
cwd: rootPath,
env: buildRelayCommandEnv(),
env: buildRelayGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let killTimeout: ReturnType<typeof setTimeout>
+4 -9
View File
@@ -52,7 +52,7 @@ import {
resolveEffectiveGitUpstream
} from '../shared/git-effective-upstream'
import { loadGitHistoryFromExecutor } from '../shared/git-history'
import { buildRelayCommandEnv } from './relay-command-env'
import { buildRelayGitEnv } from './relay-command-env'
import {
removeSafeUntrackedDiscardTarget,
removeSafeUntrackedDiscardTargets
@@ -254,11 +254,7 @@ export class GitHandler {
stdin?: string
}
): Promise<{ stdout: string; stderr: string }> {
const env = buildRelayCommandEnv()
// Why: callers parse git's stderr diagnostics and progress lines; a
// gettext-enabled git under a non-English remote locale translates them
// and breaks the parsers (issue #7808). Relay git is machine-driven only.
env.LC_ALL = 'C'
const env = buildRelayGitEnv()
if (opts?.disableOptionalLocks) {
env.GIT_OPTIONAL_LOCKS = '0'
}
@@ -285,7 +281,7 @@ export class GitHandler {
private async gitBuffer(args: string[], cwd: string): Promise<Buffer> {
const { stdout } = (await execFileAsync('git', args, {
cwd,
env: { ...buildRelayCommandEnv(), LC_ALL: 'C' },
env: buildRelayGitEnv(),
encoding: 'buffer',
maxBuffer: MAX_GIT_BUFFER
})) as { stdout: Buffer }
@@ -1101,8 +1097,7 @@ export class GitHandler {
return await new Promise((resolve, reject) => {
const child = spawn('git', args, {
cwd: expandTilde(cwd),
// LC_ALL=C: the progress regex below only matches untranslated lines.
env: { ...buildRelayCommandEnv(), LC_ALL: 'C' },
env: buildRelayGitEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
+18 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { homedir } from 'node:os'
import { buildRelayCommandEnv } from './relay-command-env'
import { buildRelayCommandEnv, buildRelayGitEnv } from './relay-command-env'
// homedir() is the fallback when the relay env carries no HOME; mock it so the
// fallback path is deterministic and the "no resolvable home" branch is reachable.
@@ -228,3 +228,20 @@ describe('buildRelayCommandEnv', () => {
expect(env.PATH?.split(':')).toContain('/home/fallback/.local/bin')
})
})
describe('buildRelayGitEnv', () => {
it('pins an untranslated locale on top of the command env (issue #7808)', () => {
// Relay git stderr/progress is machine-parsed; a non-English host locale
// with a gettext git would translate it and break the parsers.
const env = buildRelayGitEnv(
{ HOME: '/home/me', PATH: '/usr/bin', LC_ALL: 'de_DE.UTF-8' },
'linux'
)
expect(env.LC_ALL).toBe('en_US.UTF-8')
expect(env.LANG).toBe('en_US.UTF-8')
expect(env.LANGUAGE).toBe('en')
// PATH fallback behavior is unchanged.
expect(env.PATH?.split(':')).toEqual(expect.arrayContaining(['/usr/bin', '/usr/local/bin']))
})
})
+14
View File
@@ -1,5 +1,6 @@
import { homedir } from 'node:os'
import { posix, win32 } from 'node:path'
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../shared/git-output-locale'
const POSIX_RELAY_PATH_FALLBACKS = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin']
const WINDOWS_RELAY_PATH_FALLBACKS = [
@@ -154,3 +155,16 @@ export function buildRelayCommandEnv(
[key]: [...segments].join(delimiter)
}
}
/**
* Env for relay-spawned git specifically: the PATH fallbacks above plus a
* pinned English UTF-8 locale, because relay git output is machine-parsed
* (stderr phrase matching, progress regexes) and a gettext-enabled git under
* a non-English host locale translates it (issue #7808).
*/
export function buildRelayGitEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
return { ...buildRelayCommandEnv(baseEnv, platform), ...UNTRANSLATED_GIT_OUTPUT_ENV }
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Env pinning git subprocess output to untranslated English (issue #7808).
*
* Why: Orca parses git's stderr diagnostics and progress lines (e.g.
* isNoUpstreamError, clone progress/`fatal:` matching); a gettext-enabled git
* under a non-English locale translates even the `fatal:` prefix and silently
* breaks those parsers. An English UTF-8 locale rather than plain `C` keeps a
* UTF-8 LC_CTYPE for hooks git spawns (commit, push); a host without
* en_US.UTF-8 falls back to C, which still emits untranslated messages.
* LANGUAGE outranks LC_ALL in gettext's lookup, so it is pinned too.
*/
export const UNTRANSLATED_GIT_OUTPUT_ENV = {
LANGUAGE: 'en',
LC_ALL: 'en_US.UTF-8',
LANG: 'en_US.UTF-8'
} as const