Files
orca/src/main/git/runner.ts
T
JinjingandOrca 70ffa0a73d [feat] Add a full Github project view under "Tasks" (#1424)
* WIP: auto-review-fix iteration 1 (project-view.ts fixes applied)

Co-authored-by: Orca <help@stably.ai>

* refactor(github-project): split project-view and slug-dialog into modules

Co-authored-by: Orca <help@stably.ai>

* fix(lint): resolve oxlint errors in project-view modules

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-05-04 23:24:52 -07:00

431 lines
16 KiB
TypeScript

/**
* Centralized git/gh/command runner with transparent WSL support.
*
* Why: When a repo lives on a WSL filesystem (UNC path like \\wsl.localhost\Ubuntu\...),
* native Windows binaries (git.exe, gh.exe, rg.exe) are either absent or extremely slow.
* This module detects WSL paths and routes command execution through `wsl.exe -d <distro>`
* with translated Linux paths, so every call site gets WSL support for free.
*/
import {
execFile,
execFileSync,
spawn,
type ChildProcess,
type SpawnOptions
} from 'child_process'
import { promisify } from 'util'
import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
const execFileAsync = promisify(execFile)
// ─── Core resolution ────────────────────────────────────────────────
type ResolvedCommand = {
binary: string
args: string[]
cwd: string | undefined
/** Non-null when the command was routed through WSL. */
wsl: WslPathInfo | null
}
/**
* Translate any Windows-style paths in command arguments to Linux paths
* when the command will execute inside WSL.
*
* Why: callers like worktree-create pass Windows paths (e.g. the workspace
* directory) as git arguments. WSL git doesn't understand Windows paths,
* so we must translate them. WSL UNC paths (\\wsl.localhost\...) are
* converted to their native Linux form; regular Windows drive paths
* (C:\Users\...) are converted to /mnt/c/Users/...
*/
function translateArgsForWsl(args: string[]): string[] {
return args.map((arg) => {
// WSL UNC path → native linux path
const wslInfo = parseWslPath(arg)
if (wslInfo) {
return wslInfo.linuxPath
}
// Windows drive path (e.g. C:\Users\...) → /mnt/c/Users/...
const driveMatch = arg.match(/^([A-Za-z]):[/\\](.*)$/)
if (driveMatch) {
const driveLetter = driveMatch[1].toLowerCase()
const rest = driveMatch[2].replace(/\\/g, '/')
return `/mnt/${driveLetter}/${rest}`
}
return arg
})
}
/**
* Given a command, its arguments, and a working directory, resolve whether
* the invocation should be routed through wsl.exe.
*
* Why `bash -c "cd ... && ..."` instead of `--cd`: wsl.exe's --cd flag
* does not work reliably when invoked via Node's execFile/spawn (it fails
* with ERROR_PATH_NOT_FOUND in some configurations). Using bash -c with
* an explicit cd is universally supported.
*/
function resolveCommand(
command: string,
args: string[],
cwd: string | undefined,
wslDistroOverride?: string
): ResolvedCommand {
if (process.platform !== 'win32') {
return { binary: command, args, cwd, wsl: null }
}
// Why: global gh callers (rate_limit, listAccessibleProjects) have no
// meaningful cwd to derive a WSL distro from. On WSL-only Windows setups,
// gh.exe isn't on the host PATH and the spawn fails with ENOENT. Allow
// callers to pass a distro hint so we can route through wsl.exe regardless.
// TODO(wsl-default-distro): the codebase currently has no persistent
// "default WSL distro" setting — distros are derived from individual repo
// paths. Until such a setting exists, global gh callers without an explicit
// override silently fall back to host gh.exe, which on WSL-only Windows
// installs will ENOENT. The wslDistroOverride parameter is the hook for
// wiring a future setting in without re-plumbing the runner.
const cwdWsl = cwd ? parseWslPath(cwd) : null
const wsl: WslPathInfo | null =
cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null)
if (!wsl) {
return { binary: command, args, cwd, wsl: null }
}
const translatedArgs = translateArgsForWsl(args)
// Why: shell-escape each argument to prevent word splitting / glob expansion
// inside the bash -c string. Single quotes are safe for all chars except
// single quotes themselves, which we escape as '\'' (end quote, escaped
// literal, reopen quote).
const escapedArgs = translatedArgs.map(
(a) => `'${a.replace(/'/g, "'\\''")}'`
)
// Why: when cwd is supplied as a WSL UNC path, prepend `cd <linuxPath> &&`
// so the command runs in the expected directory. When the caller only
// supplied a distro override (no cwd), skip the cd entirely — the gh CLI
// doesn't need a particular cwd for global calls like `api rate_limit`.
const shellCmd = cwdWsl
? `cd '${cwdWsl.linuxPath.replace(/'/g, "'\\''")}' && ${command} ${escapedArgs.join(' ')}`
: `${command} ${escapedArgs.join(' ')}`
return {
binary: 'wsl.exe',
args: ['-d', wsl.distro, '--', 'bash', '-c', shellCmd],
// Why: cwd is set to undefined because wsl.exe handles directory switching
// via the cd inside bash -c. Setting a UNC cwd on the Node process would
// be redundant and can cause issues with some Node internals.
cwd: undefined,
wsl
}
}
// ─── Git-specific runners ───────────────────────────────────────────
type GitExecOptions = {
cwd: string
encoding?: BufferEncoding | 'buffer'
maxBuffer?: number
timeout?: number
env?: NodeJS.ProcessEnv
}
/**
* Async git command execution. Drop-in replacement for
* `execFileAsync('git', args, { cwd, encoding, ... })`.
*/
export async function gitExecFileAsync(
args: string[],
options: GitExecOptions
): Promise<{ stdout: string; stderr: string }> {
const resolved = resolveCommand('git', args, options.cwd)
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env
})
return { stdout: stdout as string, stderr: stderr as string }
}
/**
* Async git command execution that returns a Buffer.
* Used for reading binary blobs (git show).
*/
export async function gitExecFileAsyncBuffer(
args: string[],
options: { cwd: string; maxBuffer?: number }
): Promise<{ stdout: Buffer }> {
const resolved = resolveCommand('git', args, options.cwd)
const { stdout } = (await execFileAsync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: 'buffer',
maxBuffer: options.maxBuffer
})) as { stdout: Buffer }
return { stdout }
}
/**
* Sync git command execution. Drop-in replacement for
* `execFileSync('git', args, { cwd, encoding, ... })`.
*
* Returns trimmed stdout as a string.
*/
export function gitExecFileSync(
args: string[],
options: {
cwd: string
encoding?: BufferEncoding
stdio?: SpawnOptions['stdio']
}
): string {
const resolved = resolveCommand('git', args, options.cwd)
return execFileSync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe']
}) as string
}
/**
* Spawn a git child process. Drop-in replacement for
* `spawn('git', args, { cwd, stdio, ... })`.
*/
export function gitSpawn(
args: string[],
options: SpawnOptions & { cwd: string }
): ChildProcess {
const resolved = resolveCommand('git', args, options.cwd)
return spawn(resolved.binary, resolved.args, {
...options,
cwd: resolved.cwd
})
}
// ─── gh CLI runners ─────────────────────────────────────────────────
// Why: non-repo-scoped gh calls (listAccessibleProjects, rate_limit, etc.)
// have no meaningful cwd. Allow it to be omitted so the one WSL-aware wrapper
// serves both repo-scoped and global callers and we stop having two spawn
// sites (the other one — a plain execFileAsync in project-view.ts — bypasses
// retry/backoff and any future quota tracker).
// Why: `wslDistro` is an explicit hint for global (cwd-less) gh callers on
// WSL-only Windows installs where gh.exe isn't on the host PATH. When set,
// resolveCommand routes the spawn through `wsl.exe -d <distro> -- gh ...`
// even without a UNC cwd to parse a distro from. Repo-scoped callers should
// keep using cwd — the distro derives from the path automatically there.
type GhExecOptions = Omit<GitExecOptions, 'cwd'> & { cwd?: string; wslDistro?: string }
/**
* Extract stderr from an execFile rejection.
*
* Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr`
* fields populated separately from `.message`. Reading `err.message` alone is
* unreliable — it can truncate stderr or omit it entirely depending on Node
* version and maxBuffer behavior. We prefer the explicit fields and fall
* back to `.message` only when neither is present.
*/
export function extractExecError(err: unknown): { stderr: string; stdout: string } {
if (err && typeof err === 'object') {
const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
const stderr =
typeof e.stderr === 'string'
? e.stderr
: Buffer.isBuffer(e.stderr)
? e.stderr.toString('utf-8')
: ''
const stdout =
typeof e.stdout === 'string'
? e.stdout
: Buffer.isBuffer(e.stdout)
? e.stdout.toString('utf-8')
: ''
if (stderr || stdout) {
return { stderr, stdout }
}
if (typeof e.message === 'string') {
return { stderr: e.message, stdout: '' }
}
}
return { stderr: String(err), stdout: '' }
}
/**
* Detect a Retry-After hint in gh stderr and return the suggested delay in ms,
* or null when the response includes no Retry-After.
*
* Why: gh forwards response headers when verbose, and prints "Retry-After:
* <seconds>" in error output for primary rate-limit 429s. When present, the
* caller is better served by propagating the error so the UI can surface the
* real wait time — retrying on our own 250ms cadence just earns another 429
* and burns the retry budget. Also supports HTTP-date Retry-After values.
*/
export function parseRetryAfterMs(stderr: string): number | null {
const m = stderr.match(/retry-after:\s*([^\r\n]+)/i)
if (!m) {
return null
}
const raw = m[1].trim()
if (/^\d+$/.test(raw)) {
const seconds = Number(raw)
return Number.isFinite(seconds) ? seconds * 1000 : null
}
const ts = Date.parse(raw)
if (Number.isNaN(ts)) {
return null
}
return Math.max(0, ts - Date.now())
}
/**
* Classify whether a gh execFile rejection is worth retrying.
*
* Why: gh surfaces HTTP status in stderr as "HTTP 504", "HTTP 502", etc.
* Network resets and DNS hiccups also show up as stderr substrings. We retry
* those and 429 (rate-limited) — but only 429s without an explicit
* Retry-After (the caller is better off propagating so the UI can show the
* actual wait time). The primary-rate-limit 403 branch is NOT retried: those
* require the user to back off for minutes, which is not transient.
*/
export function isTransientGhError(stderr: string): boolean {
const s = stderr.toLowerCase()
if (
s.includes('http 500') ||
s.includes('http 502') ||
s.includes('http 503') ||
s.includes('http 504') ||
s.includes('econnreset') ||
s.includes('etimedout') ||
s.includes('socket hang up')
) {
return true
}
// 429 without Retry-After: retry. With Retry-After: propagate.
if (s.includes('http 429')) {
return parseRetryAfterMs(stderr) === null
}
return false
}
// Why: total of 3 attempts (original + 2 retries) with 250ms → 1s backoff.
// These are standard "transient 5xx" values. Longer waits push past user
// patience for an interactive action; shorter waits would hammer the same
// unhealthy upstream that just failed. The array length defines retry count;
// total attempts = length + 1.
const GH_RETRY_DELAYS_MS = [250, 1000] as const
// Why: the upstream Retry-After header is server-suggested but unbounded —
// GitHub has been observed to send tens-of-seconds values on rare incidents,
// and a malicious or misconfigured proxy could send anything. Cap the wait
// at 30s so a single transient gh call can never block the IPC main thread
// for longer than the user's patience budget for an interactive action.
const GH_RETRY_AFTER_MAX_MS = 30_000
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Async gh CLI execution. Drop-in replacement for
* `execFileAsync('gh', args, { cwd, encoding, ... })`.
*
* Retries transient 5xx / 429 (without Retry-After) / network-reset failures
* with exponential backoff. Non-transient errors (auth, 404, rate-limit 403,
* validation, 429-with-Retry-After) fail fast on the first attempt.
*/
export async function ghExecFileAsync(
args: string[],
options: GhExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
const resolved = resolveCommand('gh', args, options.cwd, options.wslDistro)
let lastError: unknown
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
lastError = err
const { stderr } = extractExecError(err)
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
if (!isLastAttempt && isTransientGhError(stderr)) {
// Why: when the upstream surfaced a Retry-After (e.g. on a transient
// 5xx that GitHub explicitly recommends backing off for), honor it
// instead of using our default backoff — sleeping less than the
// server suggests just earns another failure and burns our retry
// budget. Cap at GH_RETRY_AFTER_MAX_MS so a pathologically large
// hint can't block IPC for minutes; if the real wait is longer, the
// attempt will fail again and the error will propagate to the UI
// where the user can see it.
const retryAfterMs = parseRetryAfterMs(stderr)
const delayMs =
retryAfterMs !== null
? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS)
: GH_RETRY_DELAYS_MS[attempt]
await sleep(delayMs)
continue
}
throw err
}
}
// Unreachable: the loop either returns or throws. Here for TS exhaustiveness.
throw lastError
}
// ─── Generic command runner (for rg, etc.) ──────────────────────────
/**
* Spawn any command with WSL awareness.
* Used for non-git binaries like `rg` that also need WSL routing.
*/
export function wslAwareSpawn(
command: string,
args: string[],
options: SpawnOptions & { cwd?: string }
): ChildProcess {
const resolved = resolveCommand(command, args, options.cwd)
return spawn(resolved.binary, resolved.args, {
...options,
cwd: resolved.cwd
})
}
// ─── Path translation helpers ───────────────────────────────────────
/**
* Translate absolute Linux paths in git output back to Windows UNC paths.
*
* Why: when git runs inside WSL, paths in output (e.g. `git worktree list`)
* are Linux-native (/home/user/repo). The rest of Orca needs Windows UNC
* paths (\\wsl.localhost\Ubuntu\home\user\repo) to read files via Node fs.
*/
export function translateWslOutputPaths(
output: string,
originalCwd: string
): string {
const wsl = parseWslPath(originalCwd)
if (!wsl) {
return output
}
// Replace absolute Linux paths that start with / and look like filesystem
// paths in structured git output (e.g. "worktree /home/user/repo/feature")
return output.replace(
/(?<=worktree )(\/.+)$/gm,
(_match, linuxPath: string) => toWindowsWslPath(linuxPath, wsl.distro)
)
}
/**
* Get the WSL info for a path, if applicable. Convenience re-export so
* consumers don't need to import from wsl.ts directly.
*/
export { parseWslPath, toLinuxPath, toWindowsWslPath, isWslPath } from '../wsl'