diff --git a/src/main/git/branch-rename.test.ts b/src/main/git/branch-rename.test.ts index caf6ae2b984..023bb542011 100644 --- a/src/main/git/branch-rename.test.ts +++ b/src/main/git/branch-rename.test.ts @@ -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[]) => { diff --git a/src/main/git/branch-rename.ts b/src/main/git/branch-rename.ts index fd43759c9be..1237b82cf5a 100644 --- a/src/main/git/branch-rename.ts +++ b/src/main/git/branch-rename.ts @@ -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 { // 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') }) }) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 1965a9a1760..a167b4eb83a 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -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) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 39cea18337a..c5d1c8f2a9c 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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) { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7cf71bea957..c4ff3fb0a30 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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[] = [] 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[] = [] 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[] = [ { 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[] = [] 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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aa4400472ff..4bed03a5078 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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((resolve, reject) => { - let proc: ReturnType + let proc: ReturnType 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) { diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index 8a98b9fa444..9e69339bfcd 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -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 | 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 diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 968ac61b1d0..a5ae6d3cb7a 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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 { 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 = '' diff --git a/src/relay/relay-command-env.test.ts b/src/relay/relay-command-env.test.ts index f2120c177cf..e9164fb86f7 100644 --- a/src/relay/relay-command-env.test.ts +++ b/src/relay/relay-command-env.test.ts @@ -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'])) + }) +}) diff --git a/src/relay/relay-command-env.ts b/src/relay/relay-command-env.ts index ad64703207d..3d4c22f7f30 100644 --- a/src/relay/relay-command-env.ts +++ b/src/relay/relay-command-env.ts @@ -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 } +} diff --git a/src/shared/git-output-locale.ts b/src/shared/git-output-locale.ts new file mode 100644 index 00000000000..7711399892e --- /dev/null +++ b/src/shared/git-output-locale.ts @@ -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