mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
* Fix automatic branch rename for non-English git locales A gettext-enabled git (Homebrew git, most Linux distro gits) under a non-English locale translates every diagnostic, including the `fatal:` prefix, so Orca's stderr phrase parsers stop matching. The first-message branch auto-rename was the headline casualty: isNoUpstreamError missed the translated no-upstream error, branchHasUpstream failed closed to "has upstream", and the rename settled silently and permanently. - Force LC_ALL=C on all Orca-spawned machine-parsed git: the local prompt-guard env chokepoint, the three relay git spawn sites, and both local clone spawns (progress + failure-message parsing). User terminals are untouched. - Replace the boolean upstream check with a tri-state probe: rename proceeds only on a proven missing upstream; an unreadable probe now raises the rename-failed badge and retries instead of settling. Fixes #7808 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * 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> * Scrub credential-bearing URLs from clone failure messages --------- Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local> Co-authored-by: Claude <noreply@anthropic.com>
276 lines
8.1 KiB
TypeScript
276 lines
8.1 KiB
TypeScript
/**
|
|
* Git-based fallbacks for file listing and text search.
|
|
*
|
|
* Why: the relay depends on ripgrep (rg) for fs.listFiles and fs.search, but
|
|
* rg is not installed on many remote machines. These functions use git ls-files
|
|
* and git grep as universal fallbacks — git is always available since this is
|
|
* a git-focused app.
|
|
*/
|
|
import { spawn } from 'node:child_process'
|
|
import { fileListingCancellationError } from '../shared/file-listing-cancellation'
|
|
import type { SearchOptions, SearchResult } from './fs-handler-utils'
|
|
import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter'
|
|
import { expandQuickOpenGitFilesWithNestedRepos } from '../shared/quick-open-readdir-walk'
|
|
import {
|
|
buildGitGrepArgs,
|
|
buildSubmatchRegex,
|
|
createAccumulator,
|
|
finalize,
|
|
ingestGitGrepLine,
|
|
SEARCH_TIMEOUT_MS
|
|
} from '../shared/text-search'
|
|
import { buildRelayGitEnv } from './relay-command-env'
|
|
|
|
/**
|
|
* List files using `git ls-files`. Fallback when rg is not installed.
|
|
*
|
|
* Why both passes: primary surfaces tracked + untracked-non-ignored;
|
|
* ignoredPass surfaces gitignored files that users frequently Quick Open.
|
|
* Exclude pathspecs are prepended by the shared builder so nested linked
|
|
* worktrees are pruned by git directly; post-filtering remains as a
|
|
* correctness backstop.
|
|
*/
|
|
export function listFilesWithGit(
|
|
rootPath: string,
|
|
excludePathPrefixes: readonly string[] = [],
|
|
options: { signal?: AbortSignal } = {}
|
|
): Promise<string[]> {
|
|
const { signal } = options
|
|
if (signal?.aborted) {
|
|
return Promise.reject(fileListingCancellationError(signal))
|
|
}
|
|
const gitPaths = new Set<string>()
|
|
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
|
|
const children: {
|
|
child: ReturnType<typeof spawn>
|
|
isDone: () => boolean
|
|
reject: (error: Error) => void
|
|
}[] = []
|
|
|
|
const runGitLsFiles = (args: string[]): Promise<void> => {
|
|
return new Promise((resolve, reject) => {
|
|
let buf = ''
|
|
let done = false
|
|
|
|
const processPath = (path: string): void => {
|
|
if (!path) {
|
|
return
|
|
}
|
|
gitPaths.add(path)
|
|
}
|
|
|
|
const child = spawn('git', ['ls-files', ...args], {
|
|
cwd: rootPath,
|
|
env: buildRelayGitEnv(),
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
const cleanup = (): void => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
child.stdout!.off('data', handleStdoutData)
|
|
child.stderr!.off('data', handleStderrData)
|
|
child.off('error', handleError)
|
|
child.off('close', handleClose)
|
|
}
|
|
const rejectPass = (error: Error): void => {
|
|
if (done) {
|
|
return
|
|
}
|
|
done = true
|
|
buf = ''
|
|
cleanup()
|
|
reject(error)
|
|
}
|
|
const resolvePass = (): void => {
|
|
if (done) {
|
|
return
|
|
}
|
|
done = true
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
children.push({
|
|
child,
|
|
isDone: () => done,
|
|
reject: rejectPass
|
|
})
|
|
|
|
function handleStdoutData(chunk: string): void {
|
|
buf += chunk
|
|
let start = 0
|
|
let idx = buf.indexOf('\0', start)
|
|
while (idx !== -1) {
|
|
processPath(buf.substring(start, idx))
|
|
start = idx + 1
|
|
idx = buf.indexOf('\0', start)
|
|
}
|
|
buf = start < buf.length ? buf.substring(start) : ''
|
|
}
|
|
function handleStderrData(): void {
|
|
/* drain */
|
|
}
|
|
function handleError(err: Error): void {
|
|
rejectPass(err)
|
|
}
|
|
function handleClose(code: number | null, signal: NodeJS.Signals | null): void {
|
|
if (done) {
|
|
return
|
|
}
|
|
if (signal) {
|
|
// Why: a signal exit means the child was killed (timeout or
|
|
// external). Treat that as a load failure rather than silently
|
|
// resolving with whatever git had managed to print.
|
|
rejectPass(new Error(`git ls-files killed by ${signal}`))
|
|
return
|
|
}
|
|
if (buf) {
|
|
processPath(buf)
|
|
}
|
|
if (code === 0) {
|
|
resolvePass()
|
|
return
|
|
}
|
|
// Why: a non-zero exit (e.g. not a git repo) means the listing is
|
|
// incomplete; reject so the caller surfaces the failure instead of
|
|
// expanding a partial result set. Matches the main-process fallback.
|
|
rejectPass(new Error(`git ls-files exited with code ${code}`))
|
|
}
|
|
|
|
child.stdout!.setEncoding('utf-8')
|
|
child.stdout!.on('data', handleStdoutData)
|
|
child.stderr!.on('data', handleStderrData)
|
|
child.once('error', handleError)
|
|
child.once('close', handleClose)
|
|
timer = setTimeout(() => {
|
|
child.kill()
|
|
rejectPass(new Error('git ls-files timed out'))
|
|
}, 10_000)
|
|
})
|
|
}
|
|
|
|
const killSurvivors = (reason: string): void => {
|
|
// Why: Promise.all returns after the first failed pass, but the sibling
|
|
// git process can keep streaming on SSH unless we cancel it explicitly.
|
|
for (const entry of children) {
|
|
if (entry.isDone()) {
|
|
continue
|
|
}
|
|
if (entry.child.exitCode === null && entry.child.signalCode === null) {
|
|
entry.child.kill()
|
|
}
|
|
entry.reject(new Error(reason))
|
|
}
|
|
}
|
|
|
|
// Why: a cancelled scan (workspace switch, superseded request) must stop
|
|
// its git children right away instead of streaming a huge tree the caller
|
|
// has already abandoned over the shared SSH channel.
|
|
const onAbort = (): void => killSurvivors('git ls-files cancelled')
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
|
|
return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)])
|
|
.then(() =>
|
|
expandQuickOpenGitFilesWithNestedRepos({
|
|
rootPath,
|
|
gitPaths,
|
|
excludePathPrefixes,
|
|
signal
|
|
})
|
|
)
|
|
.catch((err) => {
|
|
killSurvivors('git ls-files canceled after sibling failure')
|
|
if (signal?.aborted) {
|
|
throw fileListingCancellationError(signal)
|
|
}
|
|
throw err
|
|
})
|
|
.finally(() => {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Text search using `git grep`. Fallback when rg is not installed.
|
|
*/
|
|
export function searchWithGitGrep(
|
|
rootPath: string,
|
|
query: string,
|
|
opts: SearchOptions
|
|
): Promise<SearchResult> {
|
|
return new Promise((resolve) => {
|
|
const gitArgs = buildGitGrepArgs(query, opts)
|
|
const matchRegex = buildSubmatchRegex(query, opts)
|
|
const acc = createAccumulator()
|
|
let stdoutBuffer = ''
|
|
let done = false
|
|
|
|
const child = spawn('git', gitArgs, {
|
|
cwd: rootPath,
|
|
env: buildRelayGitEnv(),
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
let killTimeout: ReturnType<typeof setTimeout>
|
|
|
|
function resolveOnce(): void {
|
|
if (done) {
|
|
return
|
|
}
|
|
done = true
|
|
clearTimeout(killTimeout)
|
|
// Why: child.kill() is advisory. If git ignores it, detach our
|
|
// closures so repeated relay searches do not retain old scans.
|
|
child.stdout!.off('data', handleStdoutData)
|
|
child.stderr!.off('data', handleStderrData)
|
|
child.off('error', handleError)
|
|
child.off('close', handleClose)
|
|
resolve(finalize(acc))
|
|
}
|
|
|
|
function processLine(line: string): void {
|
|
const verdict = ingestGitGrepLine(line, rootPath, matchRegex, acc, opts.maxResults)
|
|
if (verdict === 'stop') {
|
|
child.kill()
|
|
}
|
|
}
|
|
|
|
function handleStdoutData(chunk: string): void {
|
|
stdoutBuffer += chunk
|
|
const lines = stdoutBuffer.split('\n')
|
|
stdoutBuffer = lines.pop() ?? ''
|
|
for (const l of lines) {
|
|
processLine(l)
|
|
}
|
|
}
|
|
|
|
function handleStderrData(): void {
|
|
/* drain */
|
|
}
|
|
|
|
function handleError(): void {
|
|
resolveOnce()
|
|
}
|
|
|
|
function handleClose(): void {
|
|
if (stdoutBuffer) {
|
|
processLine(stdoutBuffer)
|
|
}
|
|
resolveOnce()
|
|
}
|
|
|
|
child.stdout!.setEncoding('utf-8')
|
|
child.stdout!.on('data', handleStdoutData)
|
|
child.stderr!.on('data', handleStderrData)
|
|
child.once('error', handleError)
|
|
child.once('close', handleClose)
|
|
|
|
killTimeout = setTimeout(() => {
|
|
acc.truncated = true
|
|
child.kill()
|
|
resolveOnce()
|
|
}, SEARCH_TIMEOUT_MS)
|
|
})
|
|
}
|