mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
* Suppress Git Credential Manager OAuth popup on git clone (fixes #7652)
Orca's git runner disables the interactive credential prompt on every git
call that goes through gitExecFileAsync/gitStreamStdout, but the two raw
'git clone' spawns (desktop repos:clone and the runtime clone path) passed
no env, so they inherited process.env with no guard. On Windows a clone
that needs GitHub auth then makes Git Credential Manager pop its
'Connect to GitHub' OAuth window, and in a network-restricted intranet the
browser/device flow never completes while git's credential retry re-pops it.
Apply nonInteractiveGitEnv() to both clone spawns so the prompt is
suppressed (GCM_INTERACTIVE=never, credential.interactive=false,
GIT_TERMINAL_PROMPT=0). The credential *helper* is kept, so cached-token
clones for private repos still work; only the interactive fallback popup is
disabled and the clone fails fast with a clear error instead.
* Suppress GCM OAuth popup in agent terminals and setup hooks too (#7652)
The clone-spawn fix stopped Orca's own managed git from popping Git
Credential Manager, but git run in terminals and setup scripts inherited
process.env with no guard. That is the more likely source of the reported
loop: agents are told to run 'git pull --rebase'/'git fetch'/retry 'git
push' (preamble + conflict/push-failure prompts), and each retry re-pops
GCM's 'Connect to GitHub' window in a network-restricted intranet.
Apply the credential-prompt guard to:
- setup/archive/hook scripts (hooks.ts non-WSL exec env), which run
unattended on worktree create/archive.
- the shared PTY host env (buildPtyHostEnv), via a small
applyTerminalGitCredentialPromptGuard helper. Agent terminals are
guarded unconditionally (they cannot dismiss a GUI popup); user
terminals are guarded by default via the new
terminalSuppressGitCredentialPrompt setting so power users can opt out.
The credential helper is kept, so cached gh auth still works; only the
interactive fallback prompt is disabled. Verified end-to-end in a real
Orca terminal (GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=never by default;
absent when the opt-out is set).
* Scope user-terminal credential guard to Windows, add settings toggle, forward guard into WSL (#7652)
* Retrigger PR checks (Actions dropped the synchronize dispatch for 57e7ce249)
* Keep shell locale out of the terminal/hook credential guard (#7652 review fix)
* Fix Fable review findings: guard WSL hook branch, wire settings search, catalog keyword keys, sparse-env askpass, one-shot agent classification (#7652)
* fix(terminal): harden Git credential popup guard
* test(pty): cover SSH credential guard setting
* fix(git): guard remote clones and setup runners
* fix(git): scope credential guards to unattended work
---------
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
1671 lines
57 KiB
TypeScript
1671 lines
57 KiB
TypeScript
/* eslint-disable max-lines -- Why: command routing, WSL translation, and
|
|
git/gh/glab wrappers must stay co-located so platform behavior remains
|
|
consistent across every repo-scoped subprocess call. */
|
|
/**
|
|
* 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 ExecFileOptions,
|
|
type SpawnOptions
|
|
} from 'node:child_process'
|
|
import { StringDecoder } from 'node:string_decoder'
|
|
import { withGitSpan } from '../observability/instrumentation'
|
|
import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe'
|
|
import {
|
|
classifyGhRateLimitBucket,
|
|
createGhRateLimitBlockedError,
|
|
getGhRateLimitBlockedUntilMs,
|
|
isGhPrimaryRateLimitStderr,
|
|
isGhRateLimitProbe,
|
|
notifyGhPrimaryRateLimit
|
|
} from './gh-rate-limit-breaker'
|
|
import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
|
|
import { addWslEnvKeys } from '../wsl-env'
|
|
import {
|
|
appendGitConfigEnv,
|
|
gitCredentialPromptGuardEnv
|
|
} from '../../shared/git-credential-prompt-env'
|
|
import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils'
|
|
import {
|
|
buildWslLoginShellCommand,
|
|
escapeWslShCommandForWindows,
|
|
quotePosixShell
|
|
} from '../../shared/wsl-login-shell-command'
|
|
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale'
|
|
import { endSubprocessStdin } from '../../shared/subprocess-stdin-write'
|
|
|
|
// ─── 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[]
|
|
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(translateArgForWsl)
|
|
}
|
|
|
|
function translateArgForWsl(arg: string): string {
|
|
// 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
|
|
}
|
|
|
|
function hasExplicitRepoArg(args: string[]): boolean {
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (
|
|
(args[i] === '--repo' || args[i] === '-R') &&
|
|
typeof args[i + 1] === 'string' &&
|
|
args[i + 1].trim()
|
|
) {
|
|
return true
|
|
}
|
|
if (args[i].startsWith('--repo=') || args[i].startsWith('-R=')) {
|
|
return args[i].slice(args[i].indexOf('=') + 1).trim().length > 0
|
|
}
|
|
if (args[i].startsWith('-R') && args[i].length > 2) {
|
|
return args[i].slice(2).trim().length > 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function argsUseGhApiPlaceholders(args: string[]): boolean {
|
|
return args.some(
|
|
(arg) => arg.includes('{owner}') || arg.includes('{repo}') || arg.includes('{branch}')
|
|
)
|
|
}
|
|
|
|
function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean {
|
|
if (hasExplicitRepoArg(args)) {
|
|
return true
|
|
}
|
|
if (args[0] === 'api') {
|
|
return !argsUseGhApiPlaceholders(args)
|
|
}
|
|
return args[0] === 'auth'
|
|
}
|
|
|
|
function isMissingCommandInWsl(stderr: string, command: string): boolean {
|
|
const s = stderr.toLowerCase()
|
|
const c = command.toLowerCase()
|
|
return s.includes(`${c}: command not found`) || s.includes(`${c}: not found`)
|
|
}
|
|
|
|
function canFallBackToHostGitHubCli(
|
|
command: 'gh',
|
|
args: string[],
|
|
resolved: ResolvedCommand,
|
|
stderr: string
|
|
): boolean {
|
|
return (
|
|
process.platform === 'win32' &&
|
|
resolved.wsl !== null &&
|
|
isMissingCommandInWsl(stderr, command) &&
|
|
canRunGitHubCliWithoutRepoCwd(args)
|
|
)
|
|
}
|
|
|
|
function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
|
|
return {
|
|
binary: command,
|
|
args,
|
|
// Why: host gh cannot use a WSL UNC cwd reliably. We only fall back
|
|
// for commands with explicit repo/API context, so no repo cwd is required.
|
|
cwd: undefined,
|
|
wsl: null
|
|
}
|
|
}
|
|
|
|
function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null {
|
|
const distro = getDefaultWslDistro()
|
|
return distro ? resolveCommand(command, args, undefined, distro) : null
|
|
}
|
|
|
|
function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean {
|
|
if (!err || typeof err !== 'object') {
|
|
return false
|
|
}
|
|
const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown }
|
|
if (e.code === 'ENOENT') {
|
|
return true
|
|
}
|
|
const message = typeof e.message === 'string' ? e.message.toLowerCase() : ''
|
|
return (
|
|
message.includes('enoent') &&
|
|
(message.includes(command) || e.path === command || e.syscall === 'spawn')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
options: { useWslLoginShell?: boolean } = {}
|
|
): 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: 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
|
|
// single quotes themselves, which we escape as '\'' (end quote, escaped
|
|
// literal, reopen quote).
|
|
const escapedArgs = translatedArgs.map(quotePosixShell)
|
|
// 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 linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null)
|
|
const shellCmd = linuxCwd
|
|
? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
|
|
: `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}`
|
|
|
|
if (options.useWslLoginShell) {
|
|
return {
|
|
binary: 'wsl.exe',
|
|
args: [
|
|
'-d',
|
|
wsl.distro,
|
|
'--',
|
|
'sh',
|
|
'-lc',
|
|
escapeWslShCommandForWindows(buildWslLoginShellCommand(shellCmd))
|
|
],
|
|
cwd: undefined,
|
|
wsl
|
|
}
|
|
}
|
|
|
|
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 ───────────────────────────────────────────
|
|
|
|
// Why: Node's execFile only honors maxBuffer when it is a number — passing
|
|
// `undefined` (which happens whenever a caller omits the option) disables the
|
|
// cap entirely, so a command that prints more than V8's ~512MB max string
|
|
// length crashes the main process uncatchably inside execFile's exit handler
|
|
// (Array.join over the buffered chunks). Apply this floor so no git call can
|
|
// ever buffer without a bound. Matches the relay's MAX_GIT_BUFFER.
|
|
export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024
|
|
|
|
type GitExecOptions = {
|
|
cwd: string
|
|
encoding?: BufferEncoding | 'buffer'
|
|
maxBuffer?: number
|
|
timeout?: number
|
|
stdin?: string
|
|
env?: NodeJS.ProcessEnv
|
|
signal?: AbortSignal
|
|
wslDistro?: string
|
|
useConfiguredSshCommandForNetwork?: boolean
|
|
}
|
|
|
|
type CommandExecOptions = {
|
|
cwd?: string
|
|
encoding?: BufferEncoding
|
|
maxBuffer?: number
|
|
timeout?: number
|
|
env?: NodeJS.ProcessEnv
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
function isMissingCommandError(error: unknown): boolean {
|
|
return Boolean(
|
|
error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'
|
|
)
|
|
}
|
|
|
|
function hasPathSeparator(command: string): boolean {
|
|
return command.includes('/') || command.includes('\\')
|
|
}
|
|
|
|
function shouldRetryWindowsCommandShim(error: unknown, resolved: ResolvedCommand): boolean {
|
|
return (
|
|
process.platform === 'win32' &&
|
|
resolved.wsl === null &&
|
|
isMissingCommandError(error) &&
|
|
!hasPathSeparator(resolved.binary) &&
|
|
!/\.[A-Za-z0-9]+$/.test(resolved.binary)
|
|
)
|
|
}
|
|
|
|
function createAbortError(): Error {
|
|
const error = new Error('The operation was aborted.')
|
|
error.name = 'AbortError'
|
|
return error
|
|
}
|
|
|
|
function killSpawnedCommandTree(child: ChildProcess): void {
|
|
const pid = child.pid
|
|
if (!pid || process.platform !== 'win32') {
|
|
child.kill()
|
|
return
|
|
}
|
|
try {
|
|
// Why: Windows package-manager CLIs are often .cmd shims. Killing only
|
|
// cmd.exe leaves the underlying node/npm/pnpm child running.
|
|
const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], {
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
})
|
|
killer.on('error', () => child.kill())
|
|
killer.unref()
|
|
} catch {
|
|
child.kill()
|
|
}
|
|
}
|
|
|
|
type ExecFileCaptureOptions = Omit<ExecFileOptions, 'timeout'> & {
|
|
timeout?: number
|
|
stdin?: string
|
|
}
|
|
|
|
function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer {
|
|
return options.encoding === 'buffer' ? Buffer.alloc(0) : ''
|
|
}
|
|
|
|
function isExecFileResultObject(
|
|
value: unknown
|
|
): value is { stdout: string | Buffer; stderr: string | Buffer } {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Buffer.isBuffer(value) &&
|
|
'stdout' in value &&
|
|
'stderr' in value
|
|
)
|
|
}
|
|
|
|
function execFileCapture(
|
|
command: string,
|
|
args: string[],
|
|
options: ExecFileCaptureOptions
|
|
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
|
|
return new Promise((resolve, reject) => {
|
|
if (options.signal?.aborted) {
|
|
reject(createAbortError())
|
|
return
|
|
}
|
|
|
|
let settled = false
|
|
let child: ChildProcess | null = null
|
|
let timer: NodeJS.Timeout | null = null
|
|
const cleanup = (): void => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
options.signal?.removeEventListener('abort', onAbort)
|
|
}
|
|
const finish = (
|
|
error: Error | null,
|
|
stdout: string | Buffer = emptyExecFileOutput(options),
|
|
stderr: string | Buffer = emptyExecFileOutput(options)
|
|
): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanup()
|
|
if (error) {
|
|
const enriched = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer }
|
|
enriched.stdout ??= stdout
|
|
enriched.stderr ??= stderr
|
|
reject(enriched)
|
|
return
|
|
}
|
|
resolve({ stdout, stderr })
|
|
}
|
|
const onAbort = (): void => {
|
|
if (child) {
|
|
killSpawnedCommandTree(child)
|
|
}
|
|
finish(createAbortError())
|
|
}
|
|
|
|
try {
|
|
const spawnStartedAt = performance.now()
|
|
child = execFile(
|
|
command,
|
|
args,
|
|
{
|
|
cwd: options.cwd,
|
|
encoding: options.encoding,
|
|
maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
|
|
env: options.env,
|
|
signal: options.signal
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (!error && stderr === undefined && isExecFileResultObject(stdout)) {
|
|
finish(null, stdout.stdout, stdout.stderr)
|
|
return
|
|
}
|
|
finish(error, stdout, stderr)
|
|
}
|
|
)
|
|
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
|
|
} catch (error) {
|
|
finish(error instanceof Error ? error : new Error(String(error)))
|
|
return
|
|
}
|
|
|
|
child.once('error', (error) => finish(error))
|
|
|
|
if (options.stdin !== undefined) {
|
|
endSubprocessStdin(child.stdin, options.stdin)
|
|
}
|
|
|
|
// Why: Node's native execFile timeout waits for the child to exit after
|
|
// signaling it. Some CLIs ignore that signal, so reject the UI operation
|
|
// on our own timer and kill the child only as best effort.
|
|
if (options.timeout && options.timeout > 0) {
|
|
timer = setTimeout(() => {
|
|
if (child) {
|
|
killSpawnedCommandTree(child)
|
|
}
|
|
finish(new Error(`${command} timed out.`))
|
|
}, options.timeout)
|
|
}
|
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
})
|
|
}
|
|
|
|
async function spawnCommandCapture(
|
|
command: string,
|
|
args: string[],
|
|
options: CommandExecOptions
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args)
|
|
return new Promise((resolve, reject) => {
|
|
if (options.signal?.aborted) {
|
|
reject(createAbortError())
|
|
return
|
|
}
|
|
let settled = false
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let stdoutBytes = 0
|
|
let stderrBytes = 0
|
|
const spawnStartedAt = performance.now()
|
|
const child = spawn(spawnCmd, spawnArgs, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt)
|
|
let timer: NodeJS.Timeout | null = null
|
|
const onAbort = (): void => {
|
|
killSpawnedCommandTree(child)
|
|
finish(createAbortError())
|
|
}
|
|
const cleanupListeners = (): void => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
options.signal?.removeEventListener('abort', onAbort)
|
|
child.stdout?.off('data', onStdoutData)
|
|
child.stderr?.off('data', onStderrData)
|
|
child.off('error', onError)
|
|
child.off('close', onClose)
|
|
}
|
|
const finish = (error: Error | null): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanupListeners()
|
|
if (error) {
|
|
reject(Object.assign(error, { stdout, stderr }))
|
|
return
|
|
}
|
|
resolve({ stdout, stderr })
|
|
}
|
|
timer = options.timeout
|
|
? setTimeout(() => {
|
|
killSpawnedCommandTree(child)
|
|
finish(new Error(`${command} timed out.`))
|
|
}, options.timeout)
|
|
: null
|
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
function onStdoutData(chunk: Buffer): void {
|
|
stdoutBytes += chunk.byteLength
|
|
if (options.maxBuffer && stdoutBytes > options.maxBuffer) {
|
|
killSpawnedCommandTree(child)
|
|
finish(new Error(`${command} stdout exceeded maxBuffer.`))
|
|
return
|
|
}
|
|
stdout += chunk.toString(options.encoding ?? 'utf-8')
|
|
}
|
|
function onStderrData(chunk: Buffer): void {
|
|
stderrBytes += chunk.byteLength
|
|
if (options.maxBuffer && stderrBytes > options.maxBuffer) {
|
|
killSpawnedCommandTree(child)
|
|
finish(new Error(`${command} stderr exceeded maxBuffer.`))
|
|
return
|
|
}
|
|
stderr += chunk.toString(options.encoding ?? 'utf-8')
|
|
}
|
|
function onError(error: Error): void {
|
|
finish(error)
|
|
}
|
|
function onClose(code: number | null): void {
|
|
if (code === 0) {
|
|
finish(null)
|
|
return
|
|
}
|
|
finish(new Error(`${command} exited with ${code}.`))
|
|
}
|
|
child.stdout?.on('data', onStdoutData)
|
|
child.stderr?.on('data', onStderrData)
|
|
child.on('error', onError)
|
|
child.on('close', onClose)
|
|
})
|
|
}
|
|
|
|
export function gitOptionalLocksDisabledEnv(
|
|
env: NodeJS.ProcessEnv = process.env
|
|
): NodeJS.ProcessEnv {
|
|
return {
|
|
...env,
|
|
GIT_OPTIONAL_LOCKS: '0'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append git config entries through the GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n /
|
|
* GIT_CONFIG_VALUE_n env protocol (git >= 2.31), composing with any count
|
|
* already present in `env` so we never clobber config a caller injected the
|
|
* same way.
|
|
*/
|
|
export { appendGitConfigEnv }
|
|
|
|
/**
|
|
* 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,
|
|
platform: NodeJS.Platform = process.platform
|
|
): NodeJS.ProcessEnv {
|
|
return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform)
|
|
}
|
|
|
|
/**
|
|
* Credential-prompt guard for a general-purpose shell environment (terminal
|
|
* PTYs, hook scripts): everything promptGuardGitEnv does EXCEPT the issue-7808
|
|
* locale pins. Those exist so Orca can parse stderr of git it spawns itself;
|
|
* forcing LC_ALL/LANG/LANGUAGE onto a user's shell would change the locale of
|
|
* every child process, not just git's.
|
|
*/
|
|
export function promptGuardShellEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
platform: NodeJS.Platform = process.platform
|
|
): NodeJS.ProcessEnv {
|
|
return gitCredentialPromptGuardEnv(env, platform)
|
|
}
|
|
|
|
/**
|
|
* Force git to be non-interactive so it fails fast instead of blocking forever
|
|
* on a prompt. Without this, a git read-path call (status, worktree list, …)
|
|
* that hits an auth/credential prompt or an SSH host-key confirmation hangs on
|
|
* stdin with no terminal to answer it; on the headless `serve` runtime those
|
|
* stuck calls pile up and the runtime stops answering all clients (issue #5308).
|
|
*
|
|
* - GIT_TERMINAL_PROMPT=0: git refuses to prompt for credentials and errors out.
|
|
* - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI/askpass helper can
|
|
* pop a prompt and block. A caller-provided askpass is preserved on purpose —
|
|
* custom askpass setups commonly *serve* credentials non-interactively, and
|
|
* blanking them would break those fetches.
|
|
* - GIT_SSH_COMMAND BatchMode=yes: SSH fails instead of waiting on an
|
|
* interactive password/host-key prompt. BatchMode does NOT change host trust
|
|
* (an unknown host still errors, it just won't hang). Only added when the
|
|
* caller hasn't set its own GIT_SSH_COMMAND.
|
|
*/
|
|
export function nonInteractiveGitEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
platform: NodeJS.Platform = process.platform
|
|
): NodeJS.ProcessEnv {
|
|
const next = promptGuardGitEnv(env, platform)
|
|
if (!next.GIT_SSH_COMMAND) {
|
|
next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
|
|
if (platform === 'win32') {
|
|
// Why: forward across the WSL boundary only when we set the value —
|
|
// plain `ssh` resolves inside the distro, whereas a caller's
|
|
// Windows-specific GIT_SSH_COMMAND must not leak into Linux git.
|
|
addWslEnvKeys(next, ['GIT_SSH_COMMAND'])
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
|
|
type GitSshPolicyMode =
|
|
| 'default'
|
|
| 'explicit-env'
|
|
| 'fallback'
|
|
| 'configured-openssh'
|
|
| 'configured-wrapper-passthrough'
|
|
|
|
const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500
|
|
|
|
function commandBasename(command: string): string {
|
|
const pieces = command.split(/[\\/]+/)
|
|
return pieces.at(-1)?.toLowerCase() ?? command.toLowerCase()
|
|
}
|
|
|
|
function isMergeableOpenSshCommand(command: string): boolean {
|
|
const basename = commandBasename(command)
|
|
return basename === 'ssh' || basename === 'ssh.exe'
|
|
}
|
|
|
|
function shellTokenize(command: string): string[] | null {
|
|
const tokens: string[] = []
|
|
let current = ''
|
|
let quote: "'" | '"' | null = null
|
|
let escaped = false
|
|
|
|
for (let i = 0; i < command.length; i++) {
|
|
const char = command[i]
|
|
if (escaped) {
|
|
current += char
|
|
escaped = false
|
|
continue
|
|
}
|
|
if (char === '\\') {
|
|
const next = command[i + 1]
|
|
if (next && /[\s'"\\]/.test(next)) {
|
|
escaped = true
|
|
} else {
|
|
current += char
|
|
}
|
|
continue
|
|
}
|
|
if (quote) {
|
|
if (char === quote) {
|
|
quote = null
|
|
} else {
|
|
current += char
|
|
}
|
|
continue
|
|
}
|
|
if (char === "'" || char === '"') {
|
|
quote = char
|
|
continue
|
|
}
|
|
if (/\s/.test(char)) {
|
|
if (current) {
|
|
tokens.push(current)
|
|
current = ''
|
|
}
|
|
continue
|
|
}
|
|
if (';&|<>()`'.includes(char)) {
|
|
return null
|
|
}
|
|
current += char
|
|
}
|
|
|
|
if (escaped || quote) {
|
|
return null
|
|
}
|
|
if (current) {
|
|
tokens.push(current)
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
function shellQuoteToken(token: string): string {
|
|
return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(token) ? token : quotePosixShell(token)
|
|
}
|
|
|
|
function containsShellExpansionSyntax(command: string): boolean {
|
|
return command.includes('$')
|
|
}
|
|
|
|
function withoutBatchModeOptions(tokens: string[]): string[] {
|
|
const next: string[] = []
|
|
for (let i = 0; i < tokens.length; i++) {
|
|
const token = tokens[i]
|
|
const lower = token.toLowerCase()
|
|
if (lower === '-o') {
|
|
const option = tokens[i + 1]?.toLowerCase()
|
|
if (option?.startsWith('batchmode')) {
|
|
i += 1
|
|
continue
|
|
}
|
|
}
|
|
if (lower.startsWith('-obatchmode')) {
|
|
continue
|
|
}
|
|
next.push(token)
|
|
}
|
|
return next
|
|
}
|
|
|
|
function buildOpenSshBatchModeCommand(configuredCommand: string): string | null {
|
|
if (containsShellExpansionSyntax(configuredCommand)) {
|
|
return null
|
|
}
|
|
const tokens = shellTokenize(configuredCommand)
|
|
if (!tokens || tokens.length === 0 || !isMergeableOpenSshCommand(tokens[0])) {
|
|
return null
|
|
}
|
|
return [...withoutBatchModeOptions(tokens), '-o', 'BatchMode=yes'].map(shellQuoteToken).join(' ')
|
|
}
|
|
|
|
async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{
|
|
env: NodeJS.ProcessEnv
|
|
mode: GitSshPolicyMode
|
|
}> {
|
|
const promptEnv = promptGuardGitEnv(options.env)
|
|
if (promptEnv.GIT_SSH_COMMAND) {
|
|
return { env: promptEnv, mode: 'explicit-env' }
|
|
}
|
|
|
|
const resolved = resolveCommand(
|
|
'git',
|
|
['config', '--get', 'core.sshCommand'],
|
|
options.cwd,
|
|
options.wslDistro,
|
|
{ useWslLoginShell: Boolean(options.wslDistro) }
|
|
)
|
|
let configuredCommand = ''
|
|
try {
|
|
const { stdout } = await execFileCapture(resolved.binary, resolved.args, {
|
|
cwd: resolved.cwd,
|
|
encoding: 'utf-8',
|
|
maxBuffer: DEFAULT_GIT_MAX_BUFFER,
|
|
timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS,
|
|
env: promptEnv,
|
|
signal: options.signal
|
|
})
|
|
configuredCommand = String(stdout).trim()
|
|
} catch {
|
|
configuredCommand = ''
|
|
}
|
|
|
|
if (!configuredCommand) {
|
|
const env = { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
|
|
// Why: WSL routing can come from either an explicit distro or a UNC cwd.
|
|
if (resolved.wsl) {
|
|
addWslEnvKeys(env, ['GIT_SSH_COMMAND'])
|
|
}
|
|
return { env, mode: 'fallback' }
|
|
}
|
|
|
|
const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand)
|
|
if (!batchModeCommand) {
|
|
// Why: custom wrappers are executable user policy; rewriting their argv is
|
|
// riskier than relying on prompt guards plus the caller's target timeout.
|
|
return { env: promptEnv, mode: 'configured-wrapper-passthrough' }
|
|
}
|
|
|
|
const env = { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand }
|
|
if (resolved.wsl) {
|
|
addWslEnvKeys(env, ['GIT_SSH_COMMAND'])
|
|
}
|
|
return { env, mode: 'configured-openssh' }
|
|
}
|
|
|
|
/**
|
|
* 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 }> {
|
|
// Why wrap here: the resolved binary path / WSL detection is internal
|
|
// detail; the span attributes track the user-visible `git <subcommand>
|
|
// <args…>` form so dashboards group cleanly by intent rather than by
|
|
// platform-conditional binary path.
|
|
return withGitSpan(
|
|
{ args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) },
|
|
async () => {
|
|
const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, {
|
|
useWslLoginShell: Boolean(options.wslDistro)
|
|
})
|
|
const policy = options.useConfiguredSshCommandForNetwork
|
|
? await buildNetworkSshPolicyEnv(options)
|
|
: { env: nonInteractiveGitEnv(options.env), mode: 'default' as const }
|
|
let result: { stdout: string | Buffer; stderr: string | Buffer }
|
|
try {
|
|
result = await execFileCapture(resolved.binary, resolved.args, {
|
|
cwd: resolved.cwd,
|
|
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
|
|
maxBuffer: options.maxBuffer,
|
|
timeout: options.timeout,
|
|
stdin: options.stdin,
|
|
// Why: never let a git read-path call block on an interactive prompt
|
|
// (issue #5308) — fail fast instead of hanging the runtime.
|
|
env: policy.env,
|
|
signal: options.signal
|
|
})
|
|
} catch (error) {
|
|
if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') {
|
|
Object.assign(error, { gitSshPolicyMode: policy.mode })
|
|
}
|
|
throw error
|
|
}
|
|
const { stdout, stderr } = result
|
|
return { stdout: stdout as string, stderr: stderr as string }
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Async command execution with the same WSL cwd translation as repo-scoped git.
|
|
* Keep this for fixed binary+argv call sites; never pass shell fragments.
|
|
*/
|
|
export async function commandExecFileAsync(
|
|
command: string,
|
|
args: string[],
|
|
options: CommandExecOptions = {}
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
const resolved = resolveCommand(command, args, options.cwd)
|
|
const binary =
|
|
resolved.wsl === null ? resolveWindowsCommand(resolved.binary, options.env) : resolved.binary
|
|
if (isWindowsBatchScript(binary)) {
|
|
return spawnCommandCapture(binary, resolved.args, {
|
|
...options,
|
|
cwd: resolved.cwd
|
|
})
|
|
}
|
|
try {
|
|
const { stdout, stderr } = await execFileCapture(binary, resolved.args, {
|
|
cwd: resolved.cwd,
|
|
encoding: options.encoding ?? 'utf-8',
|
|
maxBuffer: options.maxBuffer,
|
|
timeout: options.timeout,
|
|
env: options.env,
|
|
signal: options.signal
|
|
})
|
|
return { stdout: stdout as string, stderr: stderr as string }
|
|
} catch (error) {
|
|
if (shouldRetryWindowsCommandShim(error, resolved)) {
|
|
return spawnCommandCapture(
|
|
resolveWindowsCommand(`${resolved.binary}.cmd`, options.env),
|
|
resolved.args,
|
|
{
|
|
...options,
|
|
cwd: resolved.cwd
|
|
}
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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; wslDistro?: string }
|
|
): Promise<{ stdout: Buffer }> {
|
|
const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, {
|
|
useWslLoginShell: Boolean(options.wslDistro)
|
|
})
|
|
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
|
|
cwd: resolved.cwd,
|
|
encoding: 'buffer',
|
|
maxBuffer: options.maxBuffer,
|
|
env: untranslatedGitOutputEnv()
|
|
})) as { stdout: Buffer }
|
|
return { stdout }
|
|
}
|
|
|
|
/** Result of a streamed git command. `stoppedEarly` is true when the caller's
|
|
* onStdout hook asked to stop and the child was killed before exiting. */
|
|
export type GitStreamResult = { stoppedEarly: boolean }
|
|
|
|
type GitStreamOptions = {
|
|
cwd: string
|
|
env?: NodeJS.ProcessEnv
|
|
wslDistro?: string
|
|
signal?: AbortSignal
|
|
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
|
|
maxBuffer?: number
|
|
/**
|
|
* Called for each decoded stdout chunk as it arrives. Return true to stop:
|
|
* the child is killed and the promise resolves with stoppedEarly=true. This
|
|
* lets a streaming parser bail out (e.g. once an entry limit is reached)
|
|
* without ever buffering the full output.
|
|
*/
|
|
onStdout: (chunk: string) => boolean | void
|
|
}
|
|
|
|
/**
|
|
* Stream a git command's stdout incrementally instead of buffering it whole.
|
|
*
|
|
* Why: status on a repo with an enormous un-ignored folder can emit more output
|
|
* than fits in a single string, crashing the process when buffered. Streaming
|
|
* lets the parser count entries as they arrive and stop git the moment a limit
|
|
* is crossed, so memory stays bounded. Built on gitSpawn so WSL routing is
|
|
* preserved. stderr is bounded; a non-zero exit rejects (unless we stopped it).
|
|
*/
|
|
export async function gitStreamStdout(
|
|
args: string[],
|
|
options: GitStreamOptions
|
|
): Promise<GitStreamResult> {
|
|
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
|
|
return withGitSpan({ args, cwd: options.cwd }, async () => {
|
|
return new Promise<GitStreamResult>((resolve, reject) => {
|
|
if (options.signal?.aborted) {
|
|
reject(createAbortError())
|
|
return
|
|
}
|
|
const child = gitSpawn(args, {
|
|
cwd: options.cwd,
|
|
env: nonInteractiveGitEnv(options.env),
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
wslDistro: options.wslDistro,
|
|
windowsHide: true
|
|
})
|
|
|
|
let settled = false
|
|
let stoppedEarly = false
|
|
let stdoutBytes = 0
|
|
let stderr = ''
|
|
let stderrBytes = 0
|
|
// Why: decode statefully so a multibyte UTF-8 character split across two
|
|
// chunks (common with non-ASCII filenames) isn't corrupted into
|
|
// replacement characters and mis-parsed.
|
|
const stdoutDecoder = new StringDecoder('utf8')
|
|
const stderrDecoder = new StringDecoder('utf8')
|
|
|
|
const cleanup = (): void => {
|
|
child.stdout?.off('data', onStdoutData)
|
|
child.stderr?.off('data', onStderrData)
|
|
child.off('error', onError)
|
|
child.off('close', onClose)
|
|
options.signal?.removeEventListener('abort', onAbort)
|
|
// Flush any bytes the decoders were holding for an incomplete sequence.
|
|
stdoutDecoder.end()
|
|
stderrDecoder.end()
|
|
}
|
|
const finish = (error: Error | null): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanup()
|
|
if (error) {
|
|
reject(Object.assign(error, { stderr }))
|
|
return
|
|
}
|
|
resolve({ stoppedEarly })
|
|
}
|
|
|
|
function onStdoutData(chunk: Buffer): void {
|
|
stdoutBytes += chunk.byteLength
|
|
if (stdoutBytes > maxBuffer) {
|
|
killSpawnedCommandTree(child)
|
|
finish(new Error('git stdout exceeded maxBuffer.'))
|
|
return
|
|
}
|
|
const decoded = stdoutDecoder.write(chunk)
|
|
if (decoded.length === 0) {
|
|
return
|
|
}
|
|
// Why: the parser callback is caller-supplied; a throw here would escape
|
|
// the stream event handler and crash the main process (the exact failure
|
|
// mode this streaming path exists to prevent). Convert it to a rejection.
|
|
let shouldStop: boolean | void
|
|
try {
|
|
shouldStop = options.onStdout(decoded)
|
|
} catch (error) {
|
|
killSpawnedCommandTree(child)
|
|
finish(error instanceof Error ? error : new Error(String(error)))
|
|
return
|
|
}
|
|
if (shouldStop === true) {
|
|
// Why: parser hit its limit. Kill git and resolve cleanly — the
|
|
// partial output we already parsed is the intended result.
|
|
stoppedEarly = true
|
|
killSpawnedCommandTree(child)
|
|
finish(null)
|
|
}
|
|
}
|
|
function onStderrData(chunk: Buffer): void {
|
|
stderrBytes += chunk.byteLength
|
|
if (stderrBytes > maxBuffer) {
|
|
killSpawnedCommandTree(child)
|
|
finish(new Error('git stderr exceeded maxBuffer.'))
|
|
return
|
|
}
|
|
stderr += stderrDecoder.write(chunk)
|
|
}
|
|
function onError(error: Error): void {
|
|
finish(error)
|
|
}
|
|
function onClose(code: number | null): void {
|
|
if (stoppedEarly || code === 0) {
|
|
finish(null)
|
|
return
|
|
}
|
|
finish(new Error(`git exited with ${code}: ${stderr}`))
|
|
}
|
|
function onAbort(): void {
|
|
killSpawnedCommandTree(child)
|
|
finish(createAbortError())
|
|
}
|
|
|
|
child.stdout?.on('data', onStdoutData)
|
|
child.stderr?.on('data', onStderrData)
|
|
child.on('error', onError)
|
|
child.on('close', onClose)
|
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
if (options.signal?.aborted) {
|
|
onAbort()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// Why: sync git calls run on the Electron main thread. Local git is normally
|
|
// fast, but a repo on a dead network drive / cloud-placeholder path can hang
|
|
// git on filesystem timeouts for minutes with no timeout set — the leading
|
|
// explanation for issue #7225's 127s "Not Responding" freeze. Callers needing
|
|
// longer operations should use the async runners instead.
|
|
const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000
|
|
|
|
/**
|
|
* 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']
|
|
timeout?: number
|
|
}
|
|
): string {
|
|
const resolved = resolveCommand('git', args, options.cwd)
|
|
const spawnStartedAt = performance.now()
|
|
try {
|
|
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
|
|
} finally {
|
|
// Sync exec holds the main thread for its whole duration, so the entire
|
|
// call is main-thread block time — the cost issue #7576 flags.
|
|
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Spawn a git child process. Drop-in replacement for
|
|
* `spawn('git', args, { cwd, stdio, ... })`.
|
|
*/
|
|
export function gitSpawn(
|
|
args: string[],
|
|
options: SpawnOptions & { cwd: string; wslDistro?: string }
|
|
): ChildProcess {
|
|
const { wslDistro, ...spawnOptions } = options
|
|
const resolved = resolveCommand('git', args, options.cwd, wslDistro, {
|
|
useWslLoginShell: Boolean(wslDistro)
|
|
})
|
|
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)
|
|
return child
|
|
}
|
|
|
|
// ─── 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.
|
|
// Why: `idempotent` gates the transient-error retry. When undefined we
|
|
// auto-detect from argv (writes are detected by `-X POST/PATCH/PUT/DELETE`
|
|
// or a `query=mutation …` arg); callers can also pass an explicit override.
|
|
// A 5xx/socket reset after the request reaches GitHub but before the
|
|
// response returns is the canonical case where the server-side write
|
|
// succeeded; retrying would create a duplicate comment/issue/label addition.
|
|
// See bug-scan finding 1.
|
|
type GhExecOptions = Omit<GitExecOptions, 'cwd'> & {
|
|
cwd?: string
|
|
wslDistro?: string
|
|
idempotent?: boolean
|
|
}
|
|
|
|
const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE'])
|
|
// `gh <noun> <verb>` write subcommands. Reads (view/list/status/checks)
|
|
// are absent on purpose so the default of "retry" stays for them.
|
|
const NON_IDEMPOTENT_GH_VERBS = new Set([
|
|
'create',
|
|
'edit',
|
|
'update',
|
|
'delete',
|
|
'close',
|
|
'reopen',
|
|
'merge',
|
|
'comment',
|
|
'review',
|
|
'ready',
|
|
'lock',
|
|
'unlock',
|
|
'pin',
|
|
'unpin',
|
|
'transfer',
|
|
'develop'
|
|
])
|
|
|
|
function argsLookIdempotent(args: string[]): boolean {
|
|
let explicitMethodSeen = false
|
|
let hasApiBodyField = false
|
|
let hasGraphQlQuery = false
|
|
const isGraphQlApi = args[0] === 'api' && args[1] === 'graphql'
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i]
|
|
if (a === '-X' || a === '--method') {
|
|
explicitMethodSeen = true
|
|
const next = args[i + 1]
|
|
if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) {
|
|
return false
|
|
}
|
|
}
|
|
// Single-token form `--method=POST` (gh accepts this).
|
|
if (a.startsWith('--method=')) {
|
|
explicitMethodSeen = true
|
|
const value = a.slice('--method='.length)
|
|
if (NON_IDEMPOTENT_METHODS.has(value.toUpperCase())) {
|
|
return false
|
|
}
|
|
}
|
|
// `gh api` auto-switches GET→POST when -f/-F/--field/--raw-field body
|
|
// fields are supplied without an explicit -X. Track those to classify
|
|
// such calls as non-idempotent.
|
|
if (a === '-f' || a === '-F' || a === '--field' || a === '--raw-field') {
|
|
hasApiBodyField = true
|
|
} else if (
|
|
a.startsWith('-f=') ||
|
|
a.startsWith('-F=') ||
|
|
a.startsWith('--field=') ||
|
|
a.startsWith('--raw-field=')
|
|
) {
|
|
hasApiBodyField = true
|
|
}
|
|
// `gh api graphql -f query=mutation(...){ ... }` — detect mutation queries
|
|
// so writes via the GraphQL endpoint also fail fast on transient errors.
|
|
if (a.startsWith('query=')) {
|
|
hasGraphQlQuery = true
|
|
const trimmed = a.slice('query='.length).trimStart().toLowerCase()
|
|
if (trimmed.startsWith('mutation')) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
// `gh api ... -f foo=bar` with no explicit method: gh switches to POST.
|
|
// Treat as non-idempotent so a transient 5xx after the server applied
|
|
// the write doesn't retry and duplicate it. GraphQL reads are the exception:
|
|
// gh sends them as POST body fields, but a query operation is idempotent.
|
|
if (
|
|
args[0] === 'api' &&
|
|
hasApiBodyField &&
|
|
!explicitMethodSeen &&
|
|
!(isGraphQlApi && hasGraphQlQuery)
|
|
) {
|
|
return false
|
|
}
|
|
// `gh issue close`, `gh pr edit`, `gh pr merge`, etc. The first arg is the
|
|
// noun (issue/pr/repo/label/...) and the second is the verb. Defaulting
|
|
// `gh api` calls without an explicit -X to GET-equivalent (idempotent) is
|
|
// intentional: callers that POST through `gh api` set `-X POST`.
|
|
if (args.length >= 2 && args[0] !== 'api') {
|
|
if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* 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 raw = findRetryAfterHeaderValue(stderr)
|
|
if (raw === null) {
|
|
return null
|
|
}
|
|
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())
|
|
}
|
|
|
|
function findRetryAfterHeaderValue(stderr: string): string | null {
|
|
const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0)
|
|
if (headerIndex === -1) {
|
|
return null
|
|
}
|
|
let valueStart = headerIndex + 'retry-after:'.length
|
|
while (valueStart < stderr.length) {
|
|
const code = stderr.charCodeAt(valueStart)
|
|
if (code !== 9 && code !== 32) {
|
|
break
|
|
}
|
|
valueStart++
|
|
}
|
|
let valueEnd = valueStart
|
|
while (valueEnd < stderr.length) {
|
|
const code = stderr.charCodeAt(valueEnd)
|
|
if (code === 10 || code === 13) {
|
|
break
|
|
}
|
|
valueEnd++
|
|
}
|
|
const value = stderr.slice(valueStart, valueEnd).trim()
|
|
return value.length > 0 ? value : null
|
|
}
|
|
|
|
function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number {
|
|
const lastStart = value.length - search.length
|
|
for (let index = Math.max(0, fromIndex); index <= lastStart; index++) {
|
|
let matches = true
|
|
for (let offset = 0; offset < search.length; offset++) {
|
|
const code = value.charCodeAt(index + offset)
|
|
const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code
|
|
if (normalizedCode !== search.charCodeAt(offset)) {
|
|
matches = false
|
|
break
|
|
}
|
|
}
|
|
if (matches) {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000
|
|
|
|
async function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function defaultGhExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
const raw = env.ORCA_GH_EXEC_TIMEOUT_MS
|
|
if (!raw) {
|
|
return DEFAULT_GH_EXEC_TIMEOUT_MS
|
|
}
|
|
const parsed = Number(raw)
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GH_EXEC_TIMEOUT_MS
|
|
}
|
|
|
|
function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
return {
|
|
...env,
|
|
GH_PROMPT_DISABLED: env.GH_PROMPT_DISABLED ?? '1'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 }> {
|
|
// Why: while a bucket's primary rate limit is exhausted, every spawn would
|
|
// return the same 403 — fail fast without paying the subprocess cost. The
|
|
// rate_limit probe itself is exempt so the breaker can learn the reset time.
|
|
const rateLimitBucket = classifyGhRateLimitBucket(args)
|
|
if (!isGhRateLimitProbe(args)) {
|
|
const blockedUntilMs = getGhRateLimitBlockedUntilMs(rateLimitBucket)
|
|
if (blockedUntilMs !== null) {
|
|
throw createGhRateLimitBlockedError(rateLimitBucket, blockedUntilMs)
|
|
}
|
|
}
|
|
let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro)
|
|
let lastError: unknown
|
|
let attemptedHostFallback = false
|
|
let attemptedDefaultWslFallback = false
|
|
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
|
|
try {
|
|
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
|
|
cwd: resolved.cwd,
|
|
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
|
|
maxBuffer: options.maxBuffer,
|
|
// Why: GitHub detail IPC powers PR cards, Tasks, and URL worktree
|
|
// creation; one stuck gh child must fail visibly, not wedge every lane.
|
|
timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env),
|
|
env: nonInteractiveGhEnv(options.env)
|
|
})
|
|
return { stdout: stdout as string, stderr: stderr as string }
|
|
} catch (err) {
|
|
lastError = err
|
|
const { stderr } = extractExecError(err)
|
|
if (isGhPrimaryRateLimitStderr(stderr)) {
|
|
notifyGhPrimaryRateLimit(rateLimitBucket)
|
|
}
|
|
if (
|
|
process.platform === 'win32' &&
|
|
!attemptedDefaultWslFallback &&
|
|
resolved.wsl === null &&
|
|
!options.cwd &&
|
|
!options.wslDistro &&
|
|
isHostCommandMissing(err, 'gh')
|
|
) {
|
|
const wslResolved = resolveDefaultWslCli('gh', args)
|
|
if (wslResolved) {
|
|
// Why: WSL-only Windows installs have no gh.exe on the host PATH, but
|
|
// global calls like rate_limit/auth do not carry a repo cwd to route by.
|
|
resolved = wslResolved
|
|
attemptedDefaultWslFallback = true
|
|
attempt = -1
|
|
continue
|
|
}
|
|
}
|
|
if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) {
|
|
resolved = resolveHostGitHubCli('gh', args)
|
|
attemptedHostFallback = true
|
|
attempt = -1
|
|
continue
|
|
}
|
|
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
|
|
// Why: only retry idempotent calls. A 5xx/socket reset can arrive
|
|
// after the server already applied a POST/PATCH/PUT/DELETE; retrying
|
|
// would duplicate the write (e.g. double-post a comment, double-add
|
|
// a label). When the caller doesn't say, we auto-detect from argv —
|
|
// explicit `-X <method>` and GraphQL `query=mutation …` are treated
|
|
// as non-idempotent. See bug-scan finding 1.
|
|
const idempotent = options.idempotent ?? argsLookIdempotent(args)
|
|
if (idempotent && !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
|
|
}
|
|
|
|
// ─── glab CLI runner ────────────────────────────────────────────────
|
|
// Why: parallel to gh CLI runner above. GitLab support is added by
|
|
// cloning gh's surface rather than abstracting both behind a generic
|
|
// runner — keeping them as parallel implementations matches the
|
|
// project's clone-and-adapt approach for new providers and avoids
|
|
// touching the working gh path. Reuses the shared retry/transient
|
|
// helpers since HTTP-status- and TCP-error-based classification is
|
|
// provider-agnostic.
|
|
|
|
type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
|
|
cwd?: string
|
|
wslDistro?: string
|
|
idempotent?: boolean
|
|
allowDefaultWslFallback?: boolean
|
|
}
|
|
|
|
/**
|
|
* Async glab CLI execution. Drop-in replacement for
|
|
* `execFileAsync('glab', args, { cwd, encoding, ... })`.
|
|
*
|
|
* Retry policy mirrors ghExecFileAsync.
|
|
*/
|
|
/**
|
|
* glab's `--hostname` flag rejects a host that carries a port
|
|
* ("error parsing --hostname: invalid hostname"). A self-hosted GitLab on a
|
|
* non-default port (e.g. `gitlab.example.com:8443`) must instead be selected
|
|
* via the `GITLAB_HOST` env var, which accepts `host:port`. Translate any
|
|
* `--hostname host:port` pair into `GITLAB_HOST` so every call site (`api`,
|
|
* `auth status`, …) works against ported self-hosted instances. Port-less
|
|
* `--hostname` values are left untouched.
|
|
*
|
|
* @internal exported for tests.
|
|
*/
|
|
export function redirectPortedHostnameToEnv(
|
|
args: string[],
|
|
options: GlabExecOptions
|
|
): { args: string[]; options: GlabExecOptions } {
|
|
const i = args.indexOf('--hostname')
|
|
if (i === -1 || i + 1 >= args.length) {
|
|
return { args, options }
|
|
}
|
|
const host = args[i + 1]
|
|
if (!/^[^/\s]+:\d+$/.test(host)) {
|
|
return { args, options }
|
|
}
|
|
return {
|
|
args: [...args.slice(0, i), ...args.slice(i + 2)],
|
|
options: { ...options, env: { ...(options.env ?? process.env), GITLAB_HOST: host } }
|
|
}
|
|
}
|
|
|
|
export async function glabExecFileAsync(
|
|
args: string[],
|
|
options: GlabExecOptions = {}
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
;({ args, options } = redirectPortedHostnameToEnv(args, options))
|
|
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
|
|
let lastError: unknown
|
|
let attemptedDefaultWslFallback = false
|
|
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
|
|
try {
|
|
const { stdout, stderr } = await execFileCapture(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)
|
|
if (
|
|
process.platform === 'win32' &&
|
|
!attemptedDefaultWslFallback &&
|
|
resolved.wsl === null &&
|
|
!options.cwd &&
|
|
!options.wslDistro &&
|
|
options.allowDefaultWslFallback !== false &&
|
|
isHostCommandMissing(err, 'glab')
|
|
) {
|
|
const wslResolved = resolveDefaultWslCli('glab', args)
|
|
if (wslResolved) {
|
|
// Why: mirror gh's WSL-only fallback for global GitLab project/auth calls.
|
|
resolved = wslResolved
|
|
attemptedDefaultWslFallback = true
|
|
attempt = -1
|
|
continue
|
|
}
|
|
}
|
|
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
|
|
// Why: mirror gh's write-safety gate. A transient error after GitLab
|
|
// applies a POST/PATCH/PUT/DELETE must not create duplicate comments,
|
|
// issues, or merge actions through an automatic retry.
|
|
const idempotent = options.idempotent ?? argsLookIdempotent(args)
|
|
if (idempotent && !isLastAttempt && isTransientGhError(stderr)) {
|
|
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
|
|
}
|
|
}
|
|
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; wslDistro?: string; useWslLoginShell?: boolean }
|
|
): ChildProcess {
|
|
const { wslDistro, useWslLoginShell, ...spawnOptions } = options
|
|
const resolved = resolveCommand(command, args, options.cwd, wslDistro, {
|
|
useWslLoginShell
|
|
})
|
|
const spawnStartedAt = performance.now()
|
|
const child = spawn(resolved.binary, resolved.args, {
|
|
...spawnOptions,
|
|
cwd: resolved.cwd
|
|
})
|
|
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
|
|
return child
|
|
}
|
|
|
|
// ─── 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,
|
|
options: { wslDistro?: string } = {}
|
|
): string {
|
|
const wsl = parseWslPath(originalCwd)
|
|
const distro = wsl?.distro ?? options.wslDistro
|
|
if (!distro) {
|
|
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, 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'
|