diff --git a/src/main/daemon/terminal-view-attribute-responder.test.ts b/src/main/daemon/terminal-view-attribute-responder.test.ts index d1811831cd9..e25e5255121 100644 --- a/src/main/daemon/terminal-view-attribute-responder.test.ts +++ b/src/main/daemon/terminal-view-attribute-responder.test.ts @@ -17,7 +17,7 @@ describe('installTerminalViewAttributeResponder', () => { emitReply: (reply) => replies.push(reply) }) await write(terminal, '\x1b]7501;?\x1b\\\x1b]11;?\x1b\\') - // Why no OSC 11 reply: without a renderer push the color queries stay silent by design. + // Color queries need renderer attributes; program-status detection is independent. expect(replies).toEqual([PROGRAM_STATUS_QUERY_REPLY]) terminal.dispose() }) diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts index b28e0591fc8..d35e1812802 100644 --- a/src/main/daemon/terminal-view-attribute-responder.ts +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -1,13 +1,4 @@ -/** - * Phase 5 slice 2 (View-attribute bridge): OSC 4/10/11/12, DSR ?996n and OSC 7501 - * feature-detection responder handlers for the runtime - * headless emulator. The headless xterm core has no theme service, so these - * handlers compute replies from the renderer's pushed attribute snapshot, - * with per-PTY OSC SET mutations layered on top — mirroring exactly what the - * renderer's ThemeService reports for a visible pane. Replies route through - * the caller's emit sink, which the slice-1 forwarding window already gates, - * so seeded/replayed bytes and delivered chunks never produce a reply. - */ +/** Headless xterm lacks a theme service; answer from renderer attributes with per-PTY OSC color overrides. */ import type { Terminal } from '@xterm/headless' import { formatXColorRgbSpec, @@ -22,26 +13,20 @@ type ViewAttributeParser = Pick TerminalViewAttributes | null /** Must already be replay/forwarding-window gated by the caller. */ emitReply: (reply: string) => void } export type TerminalViewAttributeResponder = { - /** A changed renderer attribute push replaces the whole palette, exactly - * like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors - * on a visible pane's theme apply. Identical re-pushes (fresh renderer - * process) are filtered in main's store and never reach this. */ + /** Clear OSC overrides on changed renderer attributes, matching visible xterm theme replacement. */ clearColorOverrides: () => void } type SpecialColorSlot = 'foreground' | 'background' | 'cursor' -// OSC 10/11/12 stack extra params onto consecutive slots (xterm's -// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background. +// Extra OSC 10/11/12 parameters address consecutive slots: OSC 10;?;? queries foreground then background. const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor'] const SPECIAL_COLOR_IDENTS: Record = { foreground: '10', @@ -53,8 +38,7 @@ function isValidColorIndex(value: number): boolean { return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT } -// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) — -// the math CoreBrowserTerminal._reportColorScheme answers ?996n with. +// Match xterm’s relativeLuminance2 calculation for private DSR ?996n replies. function relativeLuminance([r, g, b]: TerminalViewRgb): number { const linear = (channel: number): number => { const c = channel / 255 @@ -66,19 +50,12 @@ function relativeLuminance([r, g, b]: TerminalViewRgb): number { export function installTerminalViewAttributeResponder( deps: TerminalViewAttributeResponderDeps ): TerminalViewAttributeResponder { - // Why per-instance maps: SET mutations are per PTY (one emulator per PTY); - // they die with the emulator at teardown, like every other model state. - // They deliberately survive a reveal→re-hide cycle even though the revealed - // xterm restores without palette mutations (SerializeAddon emits no OSC - // color SETs): the TUI never reset its SET, so holding it is - // protocol-correct — the visible-side loss is the pre-existing restore - // limitation, not this model's. + // Preserve per-PTY OSC colors across visibility changes even though snapshot replay loses visible palette mutations. const ansiOverrides = new Map() const specialOverrides = new Map() const reportColor = (ident: string, rgb: TerminalViewRgb): void => { - // Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the - // renderer xterm's reply (CoreBrowserTerminal._handleColorEvent). + // Match renderer xterm replies byte-for-byte: ST termination and 16-bit channels. deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`) } @@ -101,8 +78,7 @@ export function installTerminalViewAttributeResponder( } } } - // True consumes the sequence; the headless core's own OSC 10/11/12 - // handler only fires an onColor event nothing consumes. + // Consume the query because the headless core emits color events without a reply handler. return true } @@ -137,8 +113,7 @@ export function installTerminalViewAttributeResponder( deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1)) deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2)) - // OSC 104/110/111/112 restore the themed color — dropping the override - // falls back to the pushed base, the model twin of ThemeService.restoreColor. + // Removing OSC overrides restores the renderer’s pushed theme colors. deps.parser.registerOscHandler(104, (data) => { if (!data) { ansiOverrides.clear() @@ -166,16 +141,11 @@ export function installTerminalViewAttributeResponder( deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => { if (params[0] !== 996) { - // Fall through to the core for every other private DSR (?6n CPR etc.). return false } const base = deps.getBaseAttributes() if (base) { - // Why luminance and not base.colorSchemeMode: a visible xterm answers - // ?996n from the relative luminance of the CURRENT (OSC-SET-mutated) - // background vs foreground (CoreBrowserTerminal._reportColorScheme), - // so a dark terminal theme in a light app mode still answers dark. - // colorSchemeMode is the app mode and feeds the 2031/997 path only. + // xterm derives ?996n from current OSC-mutated color luminance, not the app’s colorSchemeMode. const background = specialOverrides.get('background') ?? base.background const foreground = specialOverrides.get('foreground') ?? base.foreground const dark = relativeLuminance(background) < relativeLuminance(foreground) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a2485c82841..3d1ad44e549 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -20,7 +20,6 @@ export type { InspectProcessRequest } from './daemon-foreground-process-protocol' -// ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TuiAgent } from '../../shared/tui-agent' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' @@ -50,23 +49,14 @@ export { supportsPtyStartupIngress } from './daemon-protocol-version' -// ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported' -// The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it -// depends only on TerminalModes here) — re-exported so existing importers of -// `./types` keep working. export type { TerminalCheckpointFile } from './daemon-checkpoint-file' -// ─── NDJSON Protocol Messages ─────────────────────────────────────── - -// Hello handshake (first message on each socket) export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daemon-hello-protocol' -// ─── RPC Requests (Client → Daemon, on control socket) ───────────── - export type CreateOrAttachRequest = { id: string type: 'createOrAttach' @@ -82,19 +72,12 @@ export type CreateOrAttachRequest = { launchAgent?: TuiAgent /** Rejects an absent session instead of interpreting mount uncertainty as create permission. */ attachOnly?: boolean - /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). - * The daemon forwards this to its subprocess spawner so each tab honors - * the shell picked in the "+" menu or the persisted default-shell setting, - * instead of defaulting to COMSPEC (which is always cmd.exe on Windows) - * or the hard-coded powershell.exe fallback. */ + /** Preserve the selected shell instead of defaulting to the daemon’s platform fallback. */ shellOverride?: string terminalShellArgs?: string[] /** Preferred WSL distro for generic `wsl.exe` launches. */ terminalWindowsWslDistro?: string | null - /** Why: the UI keeps PowerShell as one shell family, but the runtime may - * need to substitute pwsh.exe for powershell.exe when the user selected - * PowerShell 7+. Forward the persisted implementation choice so the daemon - * PTY path resolves the same effective executable as LocalPtyProvider. */ + /** Preserve the selected PowerShell implementation so daemon and local spawns resolve the same executable. */ terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean shellReadyTimeoutMs?: number @@ -135,10 +118,7 @@ export type ResizeRequest = { } } -// ─── Producer flow control (v19+) ─────────────────────────────────── -// Why fire-and-forget notifications (like write/resize): pause/resume ride the -// hot data path and are best-effort — the daemon-side 5s failsafe, not an RPC -// reply, is what guarantees a paused shell can never stay wedged. +// Producer flow-control notifications (v19+) avoid RPC latency; a 5s failsafe releases lost client pauses. export type PausePtyRequest = { id: string type: 'pausePty' @@ -155,9 +135,7 @@ export type ResumePtyRequest = { } } -// Why the notification stays backward-tolerated: unknown notify types are -// swallowed by old daemons. The adapter's v20 capability gate separately -// prevents v19 thinning without a sequence-safe recovery snapshot. +// Older daemons ignore this notification; the adapter’s v20 gate prevents sequence-unsafe v19 thinning. export type SetSessionBackgroundRequest = { id: string type: 'setSessionBackground' @@ -240,9 +218,7 @@ export type GetSnapshotRequest = { } } -// Why: read-only readback of the size the PTY actually applied (vs the size the -// renderer last requested via the fire-and-forget resize notify). Lets the -// renderer's resume drift-check re-assert a resize the daemon dropped/coerced. +// Read applied dimensions to detect dropped or coerced fire-and-forget resizes. export type GetSizeRequest = { id: string type: 'getSize' @@ -251,12 +227,7 @@ export type GetSizeRequest = { } } -// ─── Incremental checkpoint records (v13+) ────────────────────────── -// Why: the 5s checkpoint used to re-serialize the full emulator buffer per -// tick, stalling the daemon's PTY pump for O(buffer). Incremental checkpoints -// take only the raw records accumulated since the last take; the emulator is -// serialized only when a full snapshot is explicitly requested (clean -// shutdown, pending-buffer overflow, or the on-disk log reaching its cap). +// Incremental checkpoint records (v13+) avoid serializing the full emulator on every checkpoint tick. export type PendingOutputRecord = | { kind: 'output'; data: string } | { kind: 'resize'; cols: number; rows: number } @@ -267,31 +238,20 @@ export type TakePendingOutputRequest = { type: 'takePendingOutput' payload: { sessionId: string - /** When true, the daemon serializes a full snapshot in the SAME - * synchronous turn as the take. This atomicity is load-bearing: a - * snapshot taken in a separate request could include bytes that a later - * take would replay again, duplicating content on cold restore. */ + /** Take the snapshot and records atomically so cold restore cannot replay snapshot-covered bytes twice. */ includeSnapshot?: boolean - /** True only for final checkpoints taken immediately before PTY teardown. - * This lets the daemon release pending parser-state bytes that should be - * preserved before the backing PTY is destroyed, without disturbing live - * full checkpoints or warm-reconnect checkpoints. */ + /** Final pre-teardown checkpoints may release held parser bytes without disturbing live or warm snapshots. */ teardownSnapshot?: boolean } } export type TakePendingOutputResult = { records: PendingOutputRecord[] - /** Drained pending queue. Absent on older daemons. includeSnapshot still - * keeps `records` as held-only so mixed-version adapters do not double-replay. */ + /** Absent on older daemons; records stays held-only with includeSnapshot to avoid mixed-version duplicate replay. */ drainedRecords?: PendingOutputRecord[] - /** Non-decreasing per-session batch sequence. The history log stores it so the - * cold-restore reader can detect a lost batch (gap) and discard the log - * instead of replaying a stream with missing bytes. Snapshot, record, and - * overflow takes advance it; empty incremental takes repeat the prior value. */ + /** Persist nondecreasing batch numbers to detect lost records; empty incremental takes repeat the prior value. */ seq: number - /** True when the session's pending buffer exceeded its cap and records were - * dropped. The caller must fall back to a full snapshot checkpoint. */ + /** Lost pending records require a full snapshot checkpoint. */ overflowed: boolean snapshot: TerminalSnapshot | null } @@ -328,8 +288,6 @@ export type DaemonRequest = | PtyOwnerQueryProtocol.CloseStartupQueryAuthorityRequest | PtyOwnerQueryProtocol.SetColorQueryReplyColorsRequest -// ─── RPC Responses (Daemon → Client, on control socket) ──────────── - export type RpcResponseOk = { id: string ok: true @@ -380,26 +338,16 @@ export type SessionInfo = { agentSessionOwners?: AgentSessionOwnerBinding[] } -// Why: SessionInfo + source protocol version, so the Manage Sessions UI can -// label legacy-backed sessions. Populated by the router/adapter at RPC time; -// never transmitted over the daemon wire (daemon only speaks its own -// protocol version and doesn't know about other versions). +/** Router/adapter metadata for legacy-session labels; never transmitted on the daemon wire. */ export type DaemonSessionInfo = SessionInfo & { protocolVersion: number } -// Stream-socket event shapes live in daemon-stream-events.ts; re-exported so -// existing importers keep one types entry point. export type * from './daemon-stream-events' -// ─── Notify prefix ────────────────────────────────────────────────── -// Requests with IDs starting with this prefix are fire-and-forget: -// the daemon processes them but does not send a response. +// Fire-and-forget request IDs: the daemon processes these without a response. export const NOTIFY_PREFIX = 'notify_' -// ─── Error types ──────────────────────────────────────────────────── -// Re-exported so existing importers of `./types` keep working; the classes -// live in daemon-errors.ts (this file is capped for wire-shape declarations). export { TerminalAttachCanceledError, DaemonConnectionLostError, diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts index a10d054926c..4618cfbf013 100644 --- a/src/main/daemon/windows-conpty-warmup.test.ts +++ b/src/main/daemon/windows-conpty-warmup.test.ts @@ -65,7 +65,6 @@ describe('warmWindowsConptyOnce', () => { expect(args).toEqual(['/c', 'exit']) expect(options).toMatchObject({ useConptyDll: true, cols: 2, rows: 1 }) - // A clean exit must not leave the kill timer to fire later. fireExit() expect(proc.kill).not.toHaveBeenCalled() }) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts index 44c34c184e2..5c102e1b2b1 100644 --- a/src/main/daemon/windows-conpty-warmup.ts +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -12,18 +12,12 @@ const spawnWarmupPty: typeof pty.spawn = (file, args, options) => { return nodePty.spawn(file, args, options) } -/** - * Pays the one-time cost of the first ConPTY spawn (conpty native module - * load, bundled conpty.dll + OpenConsole.exe first launch, Defender scans of - * those binaries) at daemon boot instead of on the user's first terminal. - * Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after. - */ +/** Move first-ConPTY startup cost ahead of the user’s first terminal when possible. */ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = spawnWarmupPty): void { if (process.platform !== 'win32') { return } - // Why: setImmediate keeps the ready/handshake path ahead of the warm-up; a - // real spawn arriving first simply does the warming itself. + // Defer warmup so the daemon’s ready/handshake path can proceed first. setImmediate(() => { try { // Warm-up children must die with the daemon, even before its first real terminal. @@ -34,15 +28,14 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = spawnWarmupPt rows: 1, cwd: os.homedir(), env: process.env as Record, - // Match real terminal spawns so the bundled ConPTY binaries are the - // ones warmed, not the legacy system ConPTY. + // Warm the bundled ConPTY binaries used by real terminal spawns. useConptyDll: true }) const killTimer = setTimeout(() => { try { proc.kill() } catch { - /* best-effort cleanup of a stuck warm-up shell */ + /* A stuck warmup shell must not make cleanup fail. */ } }, WARMUP_KILL_TIMEOUT_MS) killTimer.unref?.() @@ -50,7 +43,7 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = spawnWarmupPt clearTimeout(killTimer) }) } catch { - /* warm-up is best-effort; real spawns surface their own errors */ + /* Warmup is optional; real spawns report their own errors. */ } }) } diff --git a/src/main/daemon/xterm-env-polyfill.ts b/src/main/daemon/xterm-env-polyfill.ts index 27605b8fa9a..e9e5e37210b 100644 --- a/src/main/daemon/xterm-env-polyfill.ts +++ b/src/main/daemon/xterm-env-polyfill.ts @@ -1,6 +1,4 @@ -// @xterm/headless checks for `window` to detect browser vs node environment. -// In ELECTRON_RUN_AS_NODE mode, `window` is undefined. This polyfill must be -// imported before any @xterm/headless import. +// Import before @xterm/headless: ELECTRON_RUN_AS_NODE lacks the window global its environment check expects. if (globalThis.window === undefined) { ;(globalThis as Record).window = globalThis } diff --git a/src/main/devin/hook-config-json.ts b/src/main/devin/hook-config-json.ts index 8c73c4b916a..12f15ed9678 100644 --- a/src/main/devin/hook-config-json.ts +++ b/src/main/devin/hook-config-json.ts @@ -16,7 +16,6 @@ export function readDevinHooksConfig(configPath: string): HooksConfig | null { } } -/** Original file text alongside its parsed form, so a write can edit the text in place. */ export function readDevinHooksSource( configPath: string ): { text: string | null; config: HooksConfig } | null { @@ -34,11 +33,7 @@ export function readDevinHooksSource( return config === null ? null : { text, config } } -/** - * Serialize by editing the original JSONC text one hook event at a time, so the user's - * comments, key order, and formatting survive. A parse -> JSON.stringify round trip would - * silently drop all of them. - */ +/** Edit JSONC in place to preserve user comments, key order, and formatting. */ export function serializeDevinHooksConfig( originalText: string | null, nextConfig: HooksConfig @@ -52,8 +47,7 @@ export function serializeDevinHooksConfig( const nextHooks = nextConfig.hooks ?? {} let text = originalText - // Why: touch only the events that actually changed, so comments attached to a user's - // own untouched hook entries stay put. + // Leave unchanged events intact so their attached comments survive. for (const eventName of new Set([...Object.keys(previousHooks), ...Object.keys(nextHooks)])) { const nextValue = nextHooks[eventName] if (JSON.stringify(previousHooks[eventName]) === JSON.stringify(nextValue)) { @@ -61,7 +55,7 @@ export function serializeDevinHooksConfig( } text = applyEdits( text, - // Why: `undefined` removes the key, which is how remove() drops an emptied event. + // jsonc-parser removes a key when its replacement is undefined. modify(text, ['hooks', eventName], nextValue, { formattingOptions: { insertSpaces: true, tabSize: 2 } }) diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index 9367868d4ba..a9767fbba15 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -66,8 +66,7 @@ describe('DevinHookService', () => { } const script = readFileSync(getDevinManagedScriptPath(), 'utf8') expect(script).toContain('/hook/devin') - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive). + // stdin keeps large payloads off the command line to avoid EDR false positives. expect(script).toContain('printf \'%s\' "$payload" | curl') expect(script).toContain('--data-urlencode "payload@-"') expect(script).not.toContain('--data-urlencode "payload=${payload}"') @@ -106,8 +105,7 @@ describe('DevinHookService', () => { const status = new DevinHookService().install() expect(status.state).toBe('installed') - // Why: parse as JSONC, not JSON — asserting with JSON.parse would only pass once the - // comment had been stripped, which is the regression this test exists to catch. + // JSON.parse would reject the comments this test must preserve. expect(parseJsonc(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined() }) @@ -183,9 +181,9 @@ describe('DevinHookService', () => { try { const scriptPath = 'C:\\Users\\ada\\.orca\\agent-hooks\\devin-hook.cmd' const command = getDevinManagedCommand(scriptPath) - // Why: Git Bash strips backslashes from a bare path; forward slashes survive every host. + // Forward slashes survive Git Bash path handling. expect(command).toBe('C:/Users/ada/.orca/agent-hooks/devin-hook.cmd') - // A UNC home is cmd-safe but has no drive letter, so it keeps the encoded launcher. + // UNC paths retain the encoded launcher because they have no drive letter. expect( getDevinManagedCommand('\\\\server\\share\\ada\\.orca\\agent-hooks\\devin-hook.cmd') ).toMatch(/ -EncodedCommand \S+$/) @@ -248,7 +246,6 @@ describe('DevinHookService', () => { mkdirSync(dirname(scriptPath), { recursive: true }) writeFileSync(scriptPath, '#!/bin/sh\n') - // Only install the managed hook for UserPromptSubmit writeFileSync( configPath, `${JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command }] }] } }, null, 2)}\n` @@ -277,7 +274,6 @@ describe('DevinHookService', () => { join('C:\\Users\\test\\AppData\\Roaming', 'devin', 'config.json') ) - // Fallback when APPDATA is unset delete process.env.APPDATA expect(getDevinConfigPath()).toBe(join(homeDir, 'AppData', 'Roaming', 'devin', 'config.json')) } finally { diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index a5e3667551e..ea0091c8664 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -43,7 +43,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: endpoint file holds the live port/token; a PTY that outlives an Orca restart carries stale env, so `call` it to refresh (else PTY env). + // Refresh endpoint credentials for PTYs that outlive an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('devin'), @@ -57,8 +57,8 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '#!/bin/sh', ...buildPosixHookPayloadCapture(), ...buildPosixHookSpoolLines('devin'), - // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). - // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open). + // Refresh endpoint credentials for PTYs that outlive an Orca restart. + // Keep endpoint-file races and parse errors out of agent transcripts. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', @@ -66,8 +66,8 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' spool_hook_event', ' exit 0', 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in shell is unsafe (quotes/newlines); post as form fields instead. - // Why: pipe payload to curl's stdin (payload@-) not an inline arg, so large tool output stays off the command line (EDR false positives). + // Form encoding handles filesystem paths containing quotes or newlines. + // stdin keeps large payloads off the command line to avoid EDR false positives. 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -103,7 +103,6 @@ export class DevinHookService { } } - // Why: report `partial` when only some managed events are registered, so the sidebar shows a degraded install instead of a false `installed`. const command = getDevinManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 @@ -169,15 +168,12 @@ export class DevinHookService { return this.getStatus() } - // Why: install the Devin hook on the remote box (SFTP handle + resolved remote $HOME); POSIX-only by design. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - // Why: remote-Windows is out of scope for v1; process.platform here is the local box, not the remote, so assume POSIX. + // Remote hook scripts use POSIX syntax regardless of the local platform. const remoteConfigPath = getDevinRemoteConfigPath(remoteHome) const remoteScriptFileName = getDevinPosixManagedScriptFileName() const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` - // Why: SFTP I/O fails far more often than local fs; wrap the flow so failures surface as a structured error, not an unhandled rejection. try { - // Why: Devin config.json is JSONC (comments), so JSON.parse rejects it; parse via jsonc-parser. const body = await readTextFileRemote(sftp, remoteConfigPath) const config = body === null ? {} : parseDevinHooksConfigText(body, 'remote Devin config.json') @@ -191,12 +187,10 @@ export class DevinHookService { } } - // Why: the POSIX wrapper body is path-independent, so reuse the same helper. const command = getDevinRemoteManagedCommand(remoteScriptPath) const nextConfig = applyDevinManagedHooks(config, command, remoteScriptFileName) - // Why: write script before settings so a mid-install failure never leaves settings.json referencing a missing script. - // Why: SSH remotes use POSIX `.sh` hooks even when Orca runs on Windows; never derive remote script syntax from local OS. + // Publish the script before its config so partial installs cannot reference a missing script. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig, { serialized: serializeDevinHooksConfig(body, nextConfig) diff --git a/src/main/devin/hook-settings.ts b/src/main/devin/hook-settings.ts index 14472029961..d77c93e44a5 100644 --- a/src/main/devin/hook-settings.ts +++ b/src/main/devin/hook-settings.ts @@ -19,8 +19,7 @@ export const DEVIN_EVENTS = [ { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PostCompaction', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, - // Why: Devin treats matchers as regexes and says omitted means "all"; - // Claude's "*" matcher is not a valid Devin regex. + // Omit matchers: Devin expects regexes, so Claude's "*" is invalid. { eventName: 'PreToolUse', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PostToolUse', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PermissionRequest', definition: { hooks: [{ type: 'command', command: '' }] } } diff --git a/src/main/diagnostics/event-loop-delay-probe.ts b/src/main/diagnostics/event-loop-delay-probe.ts index 50950e184ea..023a084140f 100644 --- a/src/main/diagnostics/event-loop-delay-probe.ts +++ b/src/main/diagnostics/event-loop-delay-probe.ts @@ -22,16 +22,11 @@ export type EventLoopDelayWindow = { samples: number } -/** E2E runs (local and CI) or an explicit dev opt-in; a packaged build sets neither. */ export function isEventLoopDelayProbeEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return Boolean(env.ORCA_E2E_USER_DATA_DIR) || env[EVENT_LOOP_DELAY_PROBE_ENV] === '1' } -/** - * Latency instrument for main and orcad: every second appends the window's event-loop delay - * (perf_hooks histogram) to userData. Same windowed-report shape as event-loop-stall-probe, but - * runs for the whole session because flood and command latency are steady-state. - */ +/** Record event-loop delay throughout the session to capture steady-state stalls. */ export function startEventLoopDelayProbe( processKind: EventLoopDelayWindow['process'], userDataPath: string, diff --git a/src/main/diagnostics/main-thread-churn-probe.test.ts b/src/main/diagnostics/main-thread-churn-probe.test.ts index bb87989d56d..e836c3368f3 100644 --- a/src/main/diagnostics/main-thread-churn-probe.test.ts +++ b/src/main/diagnostics/main-thread-churn-probe.test.ts @@ -69,8 +69,6 @@ describe('classifySubprocessCommand', () => { }) it('never treats positionals or git-flag values as subcommands for non-subcommand CLIs', () => { - // rg's -C takes a number; it must not be consumed as a git-style flag - // value, and "3"/"pattern" must not become fake subcommand buckets. expect(classifySubprocessCommand('rg', ['-C', '3', 'pattern'])).toBe('rg') expect(classifySubprocessCommand('node', ['script.js'])).toBe('node') }) @@ -98,8 +96,6 @@ describe('recordSubprocessSpawn', () => { }) describe('startMainThreadChurnProbe', () => { - // Counters nothing reads are counters nobody can act on: the probe report is what makes - // "the diff cache never hit" visible outside a test run. it('folds caller-contributed counters into the report line', async () => { vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') writeStartupDiagnosticLineMock.mockClear() diff --git a/src/main/diagnostics/main-thread-churn-probe.ts b/src/main/diagnostics/main-thread-churn-probe.ts index 7b68fd384c1..e002190761a 100644 --- a/src/main/diagnostics/main-thread-churn-probe.ts +++ b/src/main/diagnostics/main-thread-churn-probe.ts @@ -2,10 +2,7 @@ import { writeStartupDiagnosticLine } from '../startup/startup-diagnostics' export const MAIN_THREAD_DIAGNOSTICS_ENV = 'ORCA_MAIN_THREAD_DIAGNOSTICS' -// Why: 25ms mirrors event-loop-stall-probe — a timer that fires late by N ms -// proves the main thread was blocked for N ms, which is the direct in-process -// measurement of the macOS "Performance Diagnostics" main-thread warnings -// reported in issue #7576. +// Match the startup probe's 25ms sampling interval for comparable stall windows. const TICK_MS = 25 const REPORT_EVERY_MS = 5_000 @@ -13,22 +10,13 @@ export function isMainThreadDiagnosticsEnabled(env: NodeJS.ProcessEnv = process. return env[MAIN_THREAD_DIAGNOSTICS_ENV] === '1' } -// Git global options that precede the subcommand. Value-taking flags must be -// skipped together with their value to find the real subcommand. +// Skip Git global options together with their separate values. const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--exec-path']) -// Why: only subcommand-style CLIs get a " " bucket; for -// anything else (rg, node, …) the first positional is an operand, not a -// subcommand, and would fragment the aggregation. +// Operand-based CLIs would fragment aggregation if bucketed by their first positional. const SUBCOMMAND_BINARIES = new Set(['git', 'gh', 'glab']) -/** - * Reduce a resolved spawn to a stable aggregation key like "git status" or - * "gh api". Handles WSL wrapping (`wsl.exe -d -- git …`), absolute - * binary paths, `.exe` suffixes, and git global flags before the subcommand. - */ -// Split on both separators so Windows-style paths classify correctly even -// when the classifier itself runs in a posix test environment. +// Accept both path separators even when classifying foreign-platform commands. function binaryName(command: string): string { const leaf = command.split(/[\\/]/).pop() ?? command return leaf.replace(/\.exe$/i, '').toLowerCase() @@ -38,8 +26,7 @@ export function classifySubprocessCommand(command: string, args: readonly string let binary = binaryName(command) const rest = [...args] if (binary === 'wsl') { - // Orca spawns guest commands with `--exec`; `--`/`-e` still appear on - // wsl.exe processes started outside Orca, so unwrap either separator. + // Accept external WSL invocations using -- or -e as well as Orca's --exec. while (rest.length > 0) { const arg = rest.shift() if (arg === '--' || arg === '--exec' || arg === '-e') { @@ -62,8 +49,6 @@ export function classifySubprocessCommand(command: string, args: readonly string subcommand = arg break } - // Why: only git's global flags take a separate value; e.g. rg's -C takes - // a number that must not be consumed as if it were a flag value. if (binary === 'git' && GIT_VALUE_FLAGS.has(arg)) { i++ } @@ -76,19 +61,13 @@ export function classifySubprocessCommand(command: string, args: readonly string export type SubprocessSpawnStats = { count: number - // Cumulative and worst main-thread block: the spawn initiation (uv_spawn → - // posix_spawn) for async launches, the whole run for runProcessSync. + // Async launches measure spawn initiation; synchronous launches measure the entire run. blockMsTotal: number blockMsMax: number } const spawnStatsByCommand = new Map() -/** - * Record one subprocess spawn from the main process. `blockMs` is how long - * the spawn call held the main thread (until exit for synchronous launches). - * No-op unless ORCA_MAIN_THREAD_DIAGNOSTICS=1. - */ export function recordSubprocessSpawn( command: string, args: readonly string[], @@ -121,12 +100,7 @@ export function drainSubprocessSpawnStats(): Record Record } -/** - * Long-running main-process jank probe for benchmarks and field diagnosis of - * issue #7576. Every 5s emits one `[main-thread] {json}` stderr line with the - * window's worst event-loop stall, stall counts over 50/250ms, drained subprocess - * spawn stats, and any counters the caller contributes. Unlike the startup stall - * probe this never stops: the churn it measures (git status polling, updater - * retries) is steady-state. - */ +/** Unlike the startup probe, report steady-state stalls and subprocess costs throughout the session. */ export function startMainThreadChurnProbe(options: MainThreadChurnProbeOptions = {}): void { if (!isMainThreadDiagnosticsEnabled()) { return diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index 5d399a8be80..0d6be0c08c2 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -83,7 +83,7 @@ describe('DroidHookService', () => { expect(config.hooks.PreToolUse[0].hooks[0].command).not.toContain(userDataDir) }) - // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under Droid's shell. + // Shell execution is covered separately by windows-hook-payload-delivery.test.ts. it('registers the Windows batch hook command for every event', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') try { diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index a8b864046ca..bd496a29a42 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -29,16 +29,12 @@ import { buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' -// Why: SessionStart is installed (not just listened for) so that resuming a -// droid session via `droid --resume` resets the per-pane prompt/tool caches -// without waiting for the next UserPromptSubmit. Mirrors the codex pattern in -// src/main/codex/hook-service.ts. +// SessionStart clears prompt/tool caches on resume before the next user prompt. const DROID_EVENTS = [ { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, - // Why: sub-droid completion is mission progress, not parent Droid completion. - // The listener intentionally ignores SubagentStop so it cannot notify. + // SubagentStop reports mission progress; the listener must not treat it as parent completion. { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PreToolUse', @@ -48,8 +44,7 @@ const DROID_EVENTS = [ eventName: 'PostToolUse', definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } }, - // Why: Droid approval prompts are emitted at PermissionRequest, including - // low-impact Edit approvals that have no riskLevel marker on PreToolUse. + // PermissionRequest covers Edit approvals that have no PreToolUse riskLevel. { eventName: 'PermissionRequest', definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } @@ -70,7 +65,7 @@ function getManagedScriptPath(): string { } function getManagedCommand(scriptPath: string): string { - // Why: Factory runs hooks via cmd.exe, which splits a bare path at a profile space (#6078). + // Factory runs hooks through cmd.exe, where unquoted profile spaces split the path. return process.platform === 'win32' ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) @@ -101,10 +96,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' spool_hook_event', ' exit 0', 'fi', - // Timeout caps best-effort hook posts if the local listener stalls. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. + // Bound stalled posts and keep large payloads on stdin to avoid EDR command-line alerts. 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -121,10 +113,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\n') } -// Why: local install() and installRemote() must produce byte-identical Factory -// settings.json hook trees; sharing this mutation is what keeps the SSH install -// from drifting off the local one (the exact class of bug that left Droid with -// no remote installer at all — issue #7253). +// Keep local and SSH installs on the same hook-tree mutation path. function buildInstalledDroidConfig( config: HooksConfig, command: string, @@ -134,7 +123,6 @@ function buildInstalledDroidConfig( const isManagedCommand = createManagedCommandMatcher(scriptFileName) const managedEvents = new Set(DROID_EVENTS.map((event) => event.eventName)) - // Why: sweep managed entries out of events we no longer subscribe to. for (const [eventName, definitions] of Object.entries(nextHooks)) { if (managedEvents.has(eventName) || !Array.isArray(definitions)) { continue @@ -198,10 +186,7 @@ export class DroidHookService { const managedHooksPresent = presentCount > 0 let state: AgentHookInstallState let detail: string | null - // Why: surface hooksDisabled across every branch — without this, a - // disabled-AND-partially-installed (or disabled-AND-not-installed) state - // would silently swallow the disabled flag and the user would think a - // re-install fixed it. + // Report hooksDisabled even for partial installs so reinstalling cannot appear to fix it. if (missing.length === 0) { if (config.hooksDisabled === true) { state = 'partial' @@ -248,11 +233,7 @@ export class DroidHookService { return this.getStatus() } - // Why: SSH remotes run the Droid CLI on the remote host, so its hook config - // and managed script must be written into the remote ~/.factory + ~/.orca via - // SFTP. Without this, Droid never fires the managed hook over SSH and its - // status row is absent from the task tree (issue #7253). Mirrors the local - // install() but always emits POSIX script/paths — even from a Windows host. + // Remote installs always use POSIX scripts and paths, including from Windows clients. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const home = remoteHome.replace(/\/$/, '') const remoteConfigPath = `${home}/.factory/settings.json` @@ -270,8 +251,7 @@ export class DroidHookService { } buildInstalledDroidConfig(config, wrapPosixHookCommand(remoteScriptPath), 'droid-hook.sh') - // Why: script first, config last — a partial config write must never - // point Droid at a script that isn't on disk yet. + // Publish the script before config so interrupted installs cannot reference a missing file. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, config) diff --git a/src/main/dsh/dsh-home-patch.test.ts b/src/main/dsh/dsh-home-patch.test.ts index 53ab8986715..db032284f6a 100644 --- a/src/main/dsh/dsh-home-patch.test.ts +++ b/src/main/dsh/dsh-home-patch.test.ts @@ -8,7 +8,6 @@ import { const HOOKS_PATH = '/home/dev/.orca/agent-hooks/dsh-hooks.json' -/** applyManagedDshPatch returns null only for files it refuses to edit; these cases expect an edit. */ function applyOrFail(text: string, hooksPath = HOOKS_PATH): string { const next = applyManagedDshPatch(text, hooksPath) if (next === null) { @@ -17,7 +16,6 @@ function applyOrFail(text: string, hooksPath = HOOKS_PATH): string { return next } -// The body DSH writes into a freshly initialized patch file. const PRISTINE = [ '# Your patch layer for this dsh profile, applied after every bundle layer:', '# a top-level YAML array of loader patch entries (id-targeted config', @@ -38,7 +36,6 @@ describe('applyManagedDshPatch', () => { it('replaces the empty flow sequence DSH ships, keeping its comments', () => { const text = applyOrFail(PRISTINE) - // `- item` after `[]` is a YAML parse error, so the `[]` token has to go. expect(text).not.toMatch(/^\[]$/m) expect(text).toContain('# Your patch layer for this dsh profile') expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) @@ -64,7 +61,6 @@ describe('applyManagedDshPatch', () => { }) it('treats `[] # comment` as the empty document it is, keeping the comment', () => { - // The exact-match check missed this and appended `- insert:` after the flow sequence. const text = '[] # keep empty\n' const next = applyOrFail(text) expect(next).not.toMatch(/^\s*\[]/m) @@ -72,19 +68,17 @@ describe('applyManagedDshPatch', () => { expect(readManagedDshHooksConfigPath(next)).toBe(HOOKS_PATH) }) - it.each([ - '- id: llm-deepseek\n config: {}\n', // block sequence — editable - '\n', - '# only a comment\n' - ])('still edits %j', (text) => { - expect(applyManagedDshPatch(text, HOOKS_PATH)).not.toBeNull() - }) + it.each(['- id: llm-deepseek\n config: {}\n', '\n', '# only a comment\n'])( + 'still edits %j', + (text) => { + expect(applyManagedDshPatch(text, HOOKS_PATH)).not.toBeNull() + } + ) it.each(['[{ id: llm-deepseek }]\n', '[\n { id: a },\n { id: b }\n]\n'])( 'refuses to append after the non-empty flow sequence %j', (text) => { - // YAML forbids a block entry after a flow sequence: appending would leave DSH unable - // to parse the user's own layer either, so there is no safe in-place edit. + // YAML forbids appending block entries after a flow sequence. expect(applyManagedDshPatch(text, HOOKS_PATH)).toBeNull() } ) @@ -100,7 +94,6 @@ describe('removeManagedDshPatch', () => { const installed = applyOrFail(PRISTINE) const { text, changed } = removeManagedDshPatch(installed) expect(changed).toBe(true) - // Without this the file would come back as an unparseable empty document. expect(text.trimEnd().endsWith('[]')).toBe(true) expect(text).toContain('# Your patch layer for this dsh profile') }) @@ -120,17 +113,14 @@ describe('findManagedDshPatchRegion', () => { const ORPHAN_START = '# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>' it('fails closed on a truncated region rather than guessing its extent', () => { - // Splicing a guessed end marker would delete the user rows that follow. + // Guessing a missing end marker could delete subsequent user rows. const truncated = `${ORPHAN_START}\n${USER_ROWS}` expect(findManagedDshPatchRegion(truncated)).toBeNull() expect(removeManagedDshPatch(truncated).changed).toBe(false) }) it('never pairs an orphan start with a later block\u2019s end', () => { - // The data-loss shape: an interrupted write leaves an orphan start above the user's - // rows, and the next install appends a complete block below them. Pairing the orphan - // with the new end marker would make the region cover the user's rows, so install - // (rewrite) and remove (strip) would both delete them. + // An orphan start must not pair with a later block's end and swallow the intervening user rows. const truncated = `${ORPHAN_START}\n${USER_ROWS}` const installed = applyOrFail(truncated) expect(installed).toContain('apiKeyEnv') @@ -140,7 +130,6 @@ describe('findManagedDshPatchRegion', () => { const covered = installed.split('\n').slice(region?.startLine ?? 0, (region?.endLine ?? 0) + 1) expect(covered.join('\n')).not.toContain('apiKeyEnv') - // Both mutating paths must leave the user's rows intact, twice over. expect(applyOrFail(installed)).toContain('apiKeyEnv') expect(removeManagedDshPatch(installed).text).toContain('apiKeyEnv') }) diff --git a/src/main/dsh/dsh-home-patch.ts b/src/main/dsh/dsh-home-patch.ts index 723d030e214..51d5ec7ea82 100644 --- a/src/main/dsh/dsh-home-patch.ts +++ b/src/main/dsh/dsh-home-patch.ts @@ -1,23 +1,9 @@ -/** - * Orca's managed block inside `$DSH_HOME/cordis.patch.yml`. - * - * That file is a hand-editable top-level YAML sequence of loader patch entries, and no - * YAML library is vendored in the main process, so Orca manages only its own - * marker-delimited region: install rewrites the region, remove strips it, and everything - * outside the markers is copied through byte for byte. Appending sequence entries to a - * block sequence is always valid YAML, so the region can live at the end of any file. - * - * The one shape that is not append-safe is an empty *flow* sequence (`[]`), which is what - * DSH writes into a freshly initialized patch file. `- item` after `[]` is a parse error, - * so that token is dropped when the managed block is added and restored when it is the - * last thing removed — otherwise the file would come back as an unparseable empty - * document. - */ +/** Edit only Orca's marker-delimited region in the user-owned YAML patch layer. */ const START_MARKER = '# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>' const END_MARKER = '# <<< orca-managed-dsh-hooks <<<' -/** The loader row id Orca owns. A patch row is addressed by id, so this must be stable. */ +/** DSH targets patch rows by ID, so keep this stable. */ const MANAGED_ROW_ID = 'orca-agent-hooks' const EMPTY_FLOW_SEQUENCE = '[]' @@ -28,14 +14,8 @@ function splitLines(text: string): string[] { return text.split('\n') } -/** Locate the managed region, or null when the file carries none. */ export function findManagedDshPatchRegion(text: string): ManagedDshPatchRegion | null { - // Why the NEAREST preceding start, not the first one: an interrupted write can leave an - // orphan start marker with no end. Pairing that orphan with a LATER block's end marker - // makes the region swallow every row in between — so the next install (which rewrites the - // region) or remove (which strips it) would delete the user's own rows. Walking forward - // and resetting the candidate on each start keeps an orphan un-paired, which leaves it as - // an inert comment line rather than a deletion range. + // Use the nearest start marker so orphan starts cannot extend deletion across user rows. let startLine = -1 for (const [index, line] of splitLines(text).entries()) { const trimmed = line.trim() @@ -60,7 +40,6 @@ function buildManagedBlock(managedHooksPath: string): string[] { ] } -/** Single-quoted YAML scalar: the only escape inside one is a doubled quote. */ function quoteYamlScalar(value: string): string { return `'${value.replaceAll("'", "''")}'` } @@ -76,7 +55,6 @@ function unquoteYamlScalar(value: string): string { return trimmed } -/** The `configPath` Orca's managed region currently points at, if any. */ export function readManagedDshHooksConfigPath(text: string): string | undefined { const region = findManagedDshPatchRegion(text) if (!region) { @@ -107,29 +85,17 @@ function documentBody(lines: readonly string[]): readonly string[] { return lines.filter((line) => !isBlank(line) && !isComment(line)) } -/** Strips a trailing `# …` so `[] # keep empty` reads as the empty sequence it is. */ function withoutTrailingComment(line: string): string { const hash = line.indexOf('#') return (hash === -1 ? line : line.slice(0, hash)).trim() } -/** True when the body is the empty flow sequence, with or without a trailing comment. */ function isEmptyFlowDocument(lines: readonly string[]): boolean { const body = documentBody(lines) return body.length === 1 && withoutTrailingComment(body[0]) === EMPTY_FLOW_SEQUENCE } -/** - * True when Orca cannot append its block to this file: the body is a NON-EMPTY flow - * sequence (`[a, b]`, or one spread over lines). - * - * Why it matters: YAML forbids a block entry after a flow sequence, so appending Orca's - * `- insert:` would produce a file DSH cannot parse — losing the user's own patch layer as - * well as Orca's hooks. There is no safe in-place edit, so install refuses instead. - * - * Exported because status has to report the same refusal on every read, not just on the - * install that first hit it. - */ +/** Reject nonempty flow sequences: appending block entries would invalidate the user's patch layer. */ export function isDshPatchFileUnappendable(text: string): boolean { const lines = splitLines(text) const body = documentBody(lines) @@ -146,13 +112,7 @@ function joinPreservingTrailingNewline(lines: readonly string[]): string { return text.endsWith('\n') || text.length === 0 ? text : `${text}\n` } -/** - * Install (or refresh) Orca's managed region so the DSH hook bridge reads - * `managedHooksPath`. Everything outside the markers is preserved. - * - * Returns null when the file cannot be edited safely — see isNonEmptyFlowDocument. The - * caller reports that; it must never write a file DSH would then fail to parse. - */ +/** Return null when appending the managed block would make the YAML invalid. */ export function applyManagedDshPatch(text: string, managedHooksPath: string): string | null { const lines = splitLines(text) const block = buildManagedBlock(managedHooksPath) @@ -167,8 +127,7 @@ export function applyManagedDshPatch(text: string, managedHooksPath: string): st if (isDshPatchFileUnappendable(text)) { return null } - // Why dropped: `- item` after `[]` is a parse error, and an empty sequence has nothing to - // preserve. Only the token goes — a trailing comment on that line stays. + // Drop only the empty [] token; YAML cannot append block entries after a flow sequence. const kept = isEmptyFlowDocument(lines) ? lines.map((line) => withoutTrailingComment(line) === EMPTY_FLOW_SEQUENCE @@ -179,15 +138,13 @@ export function applyManagedDshPatch(text: string, managedHooksPath: string): st return joinPreservingTrailingNewline([...withoutTrailingBlanks(kept), ...block]) } -/** Strip Orca's managed region, restoring `[]` when nothing else is left. */ export function removeManagedDshPatch(text: string): { text: string; changed: boolean } { const region = findManagedDshPatchRegion(text) if (!region) { return { text, changed: false } } const kept = withoutTrailingBlanks(stripRegion(splitLines(text), region)) - // Why restore `[]`: a document of comments alone is not a valid entry list, so removing - // Orca's block must not leave DSH a file it cannot parse. + // Restore an empty sequence because a comments-only document is not a patch-entry list. const body = kept.every((line) => isBlank(line) || isComment(line)) ? [...kept, EMPTY_FLOW_SEQUENCE] : kept diff --git a/src/main/dsh/hook-service.test.ts b/src/main/dsh/hook-service.test.ts index 91644106107..56691eccb0e 100644 --- a/src/main/dsh/hook-service.test.ts +++ b/src/main/dsh/hook-service.test.ts @@ -14,12 +14,7 @@ import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-h import { DshHookService } from './hook-service' import { DSH_HOOK_EVENTS } from './hook-settings' -// Why: getSharedManagedScriptPath() writes under homedir()/.orca and the patch layer -// resolves via DSH_HOME ?? ~/.dsh. Point the home env at a temp dir and clear DSH_HOME so -// install/remove never touches the real ~/.orca or a developer's own DSH home. -// Why both names: os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows, and this -// file asserts the Windows script name too — setting only HOME would let a Windows run edit -// the developer's real home. +// Redirect HOME and USERPROFILE and clear DSH_HOME so tests cannot edit real user config. let home: string let originalHome: string | undefined let originalUserProfile: string | undefined @@ -81,8 +76,6 @@ describe('DshHookService', () => { expect(patch).toContain("name: '@deepseek-ai/dsh-hooks-claude-code'") expect(patch).toContain(`configPath: '${managedHooksPath()}'`) - // Exactly the events DSH's bridge can fire — registering more would register nothing - // for them and leave the status permanently `partial`. expect(Object.keys(readManagedHooks().hooks).sort()).toEqual([...DSH_HOOK_EVENTS].sort()) expect(readFileSync(scriptPath(), 'utf-8').length).toBeGreaterThan(0) }) @@ -93,8 +86,7 @@ describe('DshHookService', () => { }) it('restores the pane identity DSH scrubs before anything reads it', () => { - // DSH drops env names containing KEY/TOKEN, so the script has to recover ORCA_PANE_KEY - // and ORCA_AGENT_LAUNCH_TOKEN from their aliases before the guard or the spool run. + // DSH scrubs KEY/TOKEN variables, so restore aliases before guards or spooling. new DshHookService().install() const script = readFileSync(scriptPath(), 'utf-8') const restoreAt = script.indexOf('ORCA_AGENT_PANE') @@ -104,7 +96,7 @@ describe('DshHookService', () => { expect(restoreAt).toBeLessThan(guardAt) }) - // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under DSH's shell. + // Shell execution is covered separately by windows-hook-payload-delivery.test.ts. it('registers the Windows batch hook command for every event', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') try { @@ -174,10 +166,8 @@ describe('DshHookService', () => { expect(status.detail).toContain('/somewhere/else.json') }) - // Why POSIX-only: on Windows chmod toggles the read-only attribute, so `mode & 0o777` - // reads 0o666 for any writable file and the assertion cannot mean what it says. + // Windows chmod controls read-only state rather than POSIX permission bits. it.skipIf(process.platform === 'win32')('keeps an owner-only patch file owner-only', () => { - // CWE-732: the temp+rename replacement must not widen the file to the umask default. const userRows = '- id: llm-deepseek\n config: {}\n' mkdirSync(join(home, '.dsh'), { recursive: true }) writeFileSync(configPath(), userRows, 'utf-8') @@ -188,8 +178,6 @@ describe('DshHookService', () => { }) it('refuses a flow-style patch file instead of corrupting it', () => { - // Appending a block entry after `[…]` is invalid YAML — DSH would then fail to load the - // user's own layer as well as Orca's hooks, so install must change nothing. const flow = '[{ id: llm-deepseek }]\n' mkdirSync(join(home, '.dsh'), { recursive: true }) writeFileSync(configPath(), flow, 'utf-8') @@ -200,8 +188,6 @@ describe('DshHookService', () => { expect(installed.detail).toContain('flow-style sequence') expect(readFileSync(configPath(), 'utf-8')).toBe(flow) - // Why re-read: a later status poll must keep saying why, not decay to a bare - // `not_installed` that gives the user nothing to act on. const polled = service.getStatus() expect(polled.state).toBe('error') expect(polled.detail).toContain('flow-style sequence') diff --git a/src/main/dsh/hook-service.ts b/src/main/dsh/hook-service.ts index 0767f686f24..88e89ea9b33 100644 --- a/src/main/dsh/hook-service.ts +++ b/src/main/dsh/hook-service.ts @@ -47,7 +47,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: same scrub as POSIX — restore the canonical names from their aliases first. + // Restore DSH-scrubbed KEY/TOKEN variables before any guard or spool code reads them. 'if not defined ORCA_PANE_KEY if defined ORCA_AGENT_PANE set "ORCA_PANE_KEY=%ORCA_AGENT_PANE%"', 'if not defined ORCA_AGENT_LAUNCH_TOKEN if defined ORCA_AGENT_LAUNCH set "ORCA_AGENT_LAUNCH_TOKEN=%ORCA_AGENT_LAUNCH%"', 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', @@ -61,18 +61,13 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', - // Why first: DSH's shell executor drops every env var whose NAME contains KEY, TOKEN, - // SECRET or PASSWORD before the hook starts, which takes ORCA_PANE_KEY and - // ORCA_AGENT_LAUNCH_TOKEN with it. Orca mirrors both onto scrub-safe aliases at spawn - // (see agent-hook-scrub-safe-env.ts); restore the canonical names from them so every - // line below — including the shared spool and post builders — is unchanged. + // DSH scrubs KEY/TOKEN variables; recover canonical names from the aliases set at spawn. ': "${ORCA_PANE_KEY:=${ORCA_AGENT_PANE:-}}"', ': "${ORCA_AGENT_LAUNCH_TOKEN:=${ORCA_AGENT_LAUNCH:-}}"', 'export ORCA_PANE_KEY ORCA_AGENT_LAUNCH_TOKEN', ...buildPosixHookPayloadCapture(), ...buildPosixHookSpoolLines('dsh'), - // Why: the endpoint file holds the live port/token; a PTY that outlived an Orca - // restart carries stale env, so source it to reach the new server. + // Refresh endpoint credentials for PTYs that outlive an Orca restart. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', @@ -88,7 +83,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\n') } -/** '' when the file is absent (both are created lazily), null when it exists but cannot be read. */ +/** Distinguish absent files ('') from unreadable files (null). */ function readTextOrAbsent(path: string): string | null { try { return readFileSync(path, 'utf-8') @@ -97,7 +92,6 @@ function readTextOrAbsent(path: string): string | null { } } -/** null for anything that is not parseable JSON; the caller reads that as "no events". */ function parseJsonOrNull(text: string): unknown { try { return JSON.parse(text) @@ -108,12 +102,10 @@ function parseJsonOrNull(text: string): unknown { function writePatchText(configPath: string, text: string): void { mkdirSync(dirname(configPath), { recursive: true }) - // Why writeHooksJson: it owns the temp+rename and the rolling .bak this file needs too. - // Why preserveMode: an owner-only patch file must not widen to the umask default on rewrite. + // Reuse atomic replacement and backups while preserving owner-only permissions. writeHooksJson(configPath, {}, { serialized: text, preserveMode: true }) } -/** Why one constant: status has to say exactly what install said, on every later read. */ const FLOW_STYLE_DETAIL = 'The DSH home patch is a flow-style sequence ([…]); rewrite it as a block sequence (one `- ` entry per line) so Orca can add its hooks without breaking it' @@ -135,8 +127,7 @@ function buildStatus( if (managedText === null) { return status(configPath, 'error', 'Could not read Orca managed hooks file') } - // Why before the pointer check: a refused file carries no managed region, so the pointer - // path would report a bare `not_installed` and drop the one detail that says why. + // Check refusal first: an absent managed pointer would otherwise hide the actionable error. if (isDshPatchFileUnappendable(patchText)) { return status(configPath, 'error', FLOW_STYLE_DETAIL) } @@ -158,15 +149,12 @@ function buildStatus( if (missing.length === 0) { return status(configPath, 'installed', null, true) } - // Why the split: nothing present is an uninstalled agent; some present is a broken install, - // and naming the gap is the only way a user can tell those apart. return present.size === 0 ? status(configPath, 'not_installed', null) : status(configPath, 'partial', `Managed hook missing for events: ${missing.join(', ')}`, true) } -/** Installs Orca's status hooks into DSH. See `docs/reference/dsh-harness-integration.md` - * for the profile/patch-layer model and the env-scrub finding the aliases work around. */ +/** DSH patch-layer and env-scrub constraints: docs/reference/dsh-harness-integration.md. */ export class DshHookService { async refreshManagedScripts(): Promise { await refreshManagedScriptIfPresent(getDshManagedScriptPath(), getManagedScript()) @@ -190,8 +178,7 @@ export class DshHookService { } const scriptPath = getDshManagedScriptPath() const managedHooksPath = getDshManagedHooksPath() - // Write the script and the managed hooks file first so the patch layer never points at - // files that do not exist yet — a bridge that cannot read its config runs no hooks. + // Publish dependencies before the patch layer so it cannot reference missing files. writeManagedScript(scriptPath, getManagedScript()) writeHooksJson( managedHooksPath, @@ -200,8 +187,6 @@ export class DshHookService { ) const nextText = applyManagedDshPatch(patchText, managedHooksPath) if (nextText === null) { - // Why refuse rather than edit: YAML forbids a block entry after a flow sequence, so - // appending here would leave DSH unable to parse the user's own patch layer either. return status(configPath, 'error', FLOW_STYLE_DETAIL) } if (nextText !== patchText) { @@ -210,7 +195,6 @@ export class DshHookService { return this.getStatus() } - /** Install on an SSH execution host, where DSH's shell contract is always POSIX. */ async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remoteConfigPath = getDshRemoteConfigPath(remoteHome) const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/dsh-hook.sh` @@ -244,7 +228,6 @@ export class DshHookService { if (changed) { writePatchText(configPath, nextText) } - // Why force: the file is Orca's own and may already be gone; its absence is the goal. rmSync(getDshManagedHooksPath(), { force: true }) return this.getStatus() } diff --git a/src/main/dsh/hook-settings.ts b/src/main/dsh/hook-settings.ts index 5fcd486af6b..f4e71aad5b4 100644 --- a/src/main/dsh/hook-settings.ts +++ b/src/main/dsh/hook-settings.ts @@ -12,13 +12,7 @@ import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json const DSH_SCRIPT_BASE = 'dsh-hook' -/** - * The events DeepSeek Harness's own Claude-Code hook bridge - * (`@deepseek-ai/dsh-hooks-claude-code`) can fire. This is a strict subset of Claude's: - * the bridge documents no `Notification`, no `PermissionRequest` and no `SessionEnd`, - * and registering an unsupported event name makes it register nothing for that event. - * `normalizeDshEvent` is written against exactly this list. - */ +/** Register only bridge-supported events; unsupported Claude events never fire in DSH. */ export const DSH_HOOK_EVENTS = [ 'SessionStart', 'UserPromptSubmit', @@ -29,25 +23,17 @@ export const DSH_HOOK_EVENTS = [ export const DSH_MANAGED_HOOKS_FILE_NAME = 'dsh-hooks.json' -/** `$DSH_HOME`, matching the launcher's own `DSH_HOME ?? ~/.dsh` resolution. */ export function getDshHome(): string { return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh') } -/** - * The home-level patch layer. - * - * Why here and not in a profile: DSH composes every profile as bundle patches, then the - * profile's own `cordis.patch.yml`, then this file. Installing one layer above every - * profile means a pane the user started themselves — any profile, including one Orca - * never launched — still reports status, and Orca never edits a profile the user owns. - */ +/** Use the home layer so every profile inherits hooks without editing user-owned profile layers. */ export function getDshConfigPath(): string { return join(getDshHome(), 'cordis.patch.yml') } export function getDshRemoteConfigPath(remoteHome: string): string { - // Why: a remote $DSH_HOME is unknown over SFTP; default matches the launcher's own resolution. + // SFTP cannot resolve remote DSH_HOME; use the launcher's default home. return pathPosix.join(remoteHome.replace(/\/$/, ''), '.dsh', 'cordis.patch.yml') } @@ -73,7 +59,6 @@ export function getDshRemoteManagedHooksPath(remoteHome: string): string { } export function getDshManagedCommand(scriptPath: string): string { - // Why: DSH runs hooks through `ctx.shell`: bash off Windows, PowerShell on it, which runs a bare .cmd too. return process.platform === 'win32' ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) @@ -87,11 +72,7 @@ export function getDshManagedCommandMatcher(): (command: string | undefined) => return createManagedCommandMatcher(getDshManagedScriptFileName()) } -/** - * The managed hooks file is Orca's outright: DSH has no user-owned `hooks.json` - * convention of its own, and the bridge reads whatever single path it is pointed at. So - * generate it wholesale rather than merging into someone's file. - */ +/** This is Orca-owned config: DSH reads the explicit path rather than a user hooks.json convention. */ export function buildDshManagedHooksFile(command: string): string { const hooks: Record = {} for (const event of DSH_HOOK_EVENTS) { diff --git a/src/main/emulator/android/adb-devices.ts b/src/main/emulator/android/adb-devices.ts index 7264a825310..eb2625fbbf5 100644 --- a/src/main/emulator/android/adb-devices.ts +++ b/src/main/emulator/android/adb-devices.ts @@ -1,6 +1,3 @@ -// Pure parsing/arg-building for the `adb` CLI. No process execution here: -// the caller prepends the resolved adb binary path to every arg array. - export type AndroidAdbDeviceState = | 'device' | 'offline' @@ -20,11 +17,6 @@ export type AndroidAdbDevice = { const DEVICES_HEADER = 'List of devices attached' -/** - * Parses `adb devices -l` stdout. Skips the header and blank lines. Each device - * line is `\s+[ key:value ...]`; `model`/`product` come from the - * trailing `-l` tokens when present. - */ export function parseAdbDevices(stdout: string): AndroidAdbDevice[] { const devices: AndroidAdbDevice[] = [] @@ -40,8 +32,7 @@ export function parseAdbDevices(stdout: string): AndroidAdbDevice[] { continue } - // `no permissions` is the only two-word state adb emits; every other state - // is a single token, so the key:value tokens start one position later. + // "no permissions" occupies two state tokens before the key:value metadata. let state: AndroidAdbDeviceState let tokenStart: number if (tokens[1] === 'no' && tokens[2] === 'permissions') { @@ -78,10 +69,7 @@ export function parseAdbDevices(stdout: string): AndroidAdbDevice[] { return devices } -/** - * Parses `adb shell wm size` stdout. An `Override size:` line reflects the - * active resolution, so it wins over `Physical size:` when both are present. - */ +// Override size is the active resolution when both sizes are reported. export function parseWmSize(stdout: string): { width: number; height: number } | null { const override = /Override size:\s*(\d+)x(\d+)/.exec(stdout) const physical = /Physical size:\s*(\d+)x(\d+)/.exec(stdout) @@ -92,7 +80,6 @@ export function parseWmSize(stdout: string): { width: number; height: number } | return { width: Number(match[1]), height: Number(match[2]) } } -/** `adb shell getprop sys.boot_completed` prints `1` once the framework is up. */ export function isBootCompleted(getpropStdout: string): boolean { return getpropStdout.trim() === '1' } diff --git a/src/main/emulator/android/android-adb-result.ts b/src/main/emulator/android/android-adb-result.ts index c53121dd50d..704c733e091 100644 --- a/src/main/emulator/android/android-adb-result.ts +++ b/src/main/emulator/android/android-adb-result.ts @@ -1,8 +1,7 @@ import { EmulatorError } from '../emulator-errors' import type { AndroidCommandResult } from './android-command-runner' -// AndroidCommandRunner resolves non-zero exits as data; callers must opt into -// throwing so adb failures do not become silent successful emulator actions. +// The runner resolves nonzero exits; callers must explicitly reject unsuccessful actions. export function ensureAdbOk(result: AndroidCommandResult, label: string): AndroidCommandResult { if (result.code !== 0) { throw new EmulatorError( diff --git a/src/main/emulator/android/android-app-control.ts b/src/main/emulator/android/android-app-control.ts index 7b53a07e4cc..ae7254bd79f 100644 --- a/src/main/emulator/android/android-app-control.ts +++ b/src/main/emulator/android/android-app-control.ts @@ -1,7 +1,3 @@ -// Pure arg-building for `adb` app install/launch. No process execution here: -// the caller prepends the resolved adb binary path to every arg array. - -// `adb -s install [-r] ` (-r reinstalls over an existing app). export function installApkArgs( serial: string, apkPath: string, @@ -15,9 +11,7 @@ export function installApkArgs( return args } -// `adb -s shell am start -n /` when an activity is -// known; otherwise launch the default LAUNCHER activity via monkey, which does -// not require the caller to resolve the entry-point component name. +// monkey launches the default activity without requiring its component name. export function launchAppArgs(serial: string, packageName: string, activity?: string): string[] { if (activity && activity.trim() !== '') { return ['-s', serial, 'shell', 'am', 'start', '-n', `${packageName}/${activity}`] diff --git a/src/main/emulator/android/android-avd-boot.ts b/src/main/emulator/android/android-avd-boot.ts index 049f8a1bc27..e48a8967b82 100644 --- a/src/main/emulator/android/android-avd-boot.ts +++ b/src/main/emulator/android/android-avd-boot.ts @@ -13,8 +13,6 @@ export type AndroidBootOptions = { sleep: (ms: number) => Promise } -// Returns the running adb serial for a device/AVD, booting the AVD (detached) -// and polling sys.boot_completed when it is not already running. export async function bootAndroidDevice( runner: AndroidCommandRunner, sdk: AndroidSdkPaths, @@ -29,8 +27,7 @@ export async function bootAndroidDevice( if (existing) { return existing } - // Validate the target is a real AVD before spawning, so a stale/offline serial - // doesn't launch an invalid `-avd` and burn the full boot timeout. + // Reject stale serials before launching an invalid AVD and waiting out the boot timeout. const avds = parseAvdList((await runner(sdk.emulator, listAvdsArgs)).stdout) if (!avds.includes(deviceOrName)) { throw new EmulatorError( @@ -43,19 +40,13 @@ export async function bootAndroidDevice( return waitForNewBootedSerial(runner, sdk, deviceOrName, known, options) } -// Launches the emulator with spawn (NOT the command runner: execFile would kill -// the long-running, verbose emulator at its timeout / stdout maxBuffer). It is -// NOT detached: DETACHED_PROCESS gives the console-subsystem emulator no console, -// so it (and its qemu/netsim children) pop their own visible one. windowsHide -// gives it a hidden console instead; unref lets the app exit without waiting, and -// managed emulators are shut down on quit anyway. -no-window keeps it headless. +// Use an unrefed, hidden spawn: detached Windows emulators open consoles; execFile bounds long-running output. function launchAvd(emulatorPath: string, avdName: string): void { const child = spawn(emulatorPath, [...bootAvdArgs(avdName), '-no-window'], { stdio: 'ignore', windowsHide: true }) - // An unhandled 'error' (ENOENT/EACCES) on a ChildProcess crashes the main - // process; swallow + log it so the boot wait surfaces a timeout instead. + // Handle spawn errors here so the boot poll reports failure instead of crashing main. child.on('error', (error) => emulatorProbeError('emulator.launch.fail', error, { avdName })) child.unref() } diff --git a/src/main/emulator/android/android-capability-operations.ts b/src/main/emulator/android/android-capability-operations.ts index b9d52d8475c..9cf6525e5bc 100644 --- a/src/main/emulator/android/android-capability-operations.ts +++ b/src/main/emulator/android/android-capability-operations.ts @@ -7,9 +7,6 @@ import { permissionArgs, type AndroidPermissionOp } from './android-permissions' import { logcatArgs, parseLogcatLine, type LogcatEntry } from './android-logcat' import { parseUiAutomatorXml, type AndroidAxNode } from './uiautomator-tree' -// Impure capability operations: compose the pure arg-builders/parsers with the -// command runner. The backend exposes thin delegations to these. - const UIAUTOMATOR_DUMP_PATH = '/sdcard/window_dump.xml' export async function installAndroidApk( @@ -58,8 +55,7 @@ export async function dumpAndroidAccessibilityTree( sdk: AndroidSdkPaths, serial: string ): Promise { - // uiautomator dump writes XML to a device file; read it back and parse. Check - // the dump first — otherwise `cat` can return a stale file from a prior dump. + // Check the dump before reading its file to avoid returning stale XML. ensureAdbOk( await runner(sdk.adb, ['-s', serial, 'shell', 'uiautomator', 'dump', UIAUTOMATOR_DUMP_PATH]), 'uiautomator dump' @@ -77,7 +73,7 @@ export async function captureAndroidLogcat( serial: string, options?: { lines?: number; filters?: readonly string[] } ): Promise { - // One-shot dump for a request/response RPC; follow-mode would need streaming. + // RPC reads need a one-shot dump; follow mode requires streaming. const result = ensureAdbOk( await runner(sdk.adb, logcatArgs(serial, { ...options, dump: true })), 'adb logcat' diff --git a/src/main/emulator/android/android-command-runner.ts b/src/main/emulator/android/android-command-runner.ts index 18e369d5a45..f4071edd66a 100644 --- a/src/main/emulator/android/android-command-runner.ts +++ b/src/main/emulator/android/android-command-runner.ts @@ -4,8 +4,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' export type AndroidCommandResult = { stdout: string; stderr: string; code: number } -// Runs an Android SDK binary (adb/emulator) and resolves with its output. Never -// rejects — callers branch on `code` so a non-zero exit is data, not an throw. +/** Nonzero exits resolve as data; callers must check code. */ export type AndroidCommandRunner = ( binary: string, args: readonly string[], @@ -33,7 +32,6 @@ export const execFileAndroidCommandRunner: AndroidCommandRunner = (binary, args, stderr: stderr?.toString() ?? '', code: exitCode } - // PROBE: every adb/emulator command + outcome, for test diagnostics. if (exitCode === 0) { emulatorProbe('cmd', { bin: basename(binary), args }) } else { diff --git a/src/main/emulator/android/android-device-inventory.ts b/src/main/emulator/android/android-device-inventory.ts index 63c3b17a2e8..bba5900d855 100644 --- a/src/main/emulator/android/android-device-inventory.ts +++ b/src/main/emulator/android/android-device-inventory.ts @@ -4,11 +4,6 @@ import { adbDevicesArgs, parseAdbDevices, type AndroidAdbDevice } from './adb-de import { listAvdsArgs, parseAvdList } from './avd-manager' import type { EmulatorDevice } from '../backends/emulator-backend' -// Android device discovery: turns raw `adb`/`emulator` output into the -// cross-backend EmulatorDevice list and resolves AVD names to running serials. -// Kept separate from the backend so the inventory is testable in isolation and -// the backend file stays focused on lifecycle + input. - export async function listRunningAdbDevices( runner: AndroidCommandRunner, sdk: AndroidSdkPaths @@ -65,7 +60,7 @@ export async function listAndroidDevices( return mergeAndroidDevices(running, avds, runningAvdBySerial) } -// `adb -s emu avd name` prints the AVD name then a trailing "OK" line. +// adb appends an OK status after the AVD name. function firstNonStatusLine(stdout: string): string | null { for (const raw of stdout.split('\n')) { const line = raw.trim() diff --git a/src/main/emulator/android/android-input-commands.ts b/src/main/emulator/android/android-input-commands.ts index 1197e248c91..68d3f76a672 100644 --- a/src/main/emulator/android/android-input-commands.ts +++ b/src/main/emulator/android/android-input-commands.ts @@ -8,9 +8,6 @@ import { } from './android-input-mapping' import type { EmulatorGesturePoint } from '../emulator-gesture-sender' -// Android control via `adb shell input`, so it works without the scrcpy server. -// The backend resolves the serial + screen size and delegates here. - export function androidShellArgs(serial: string, command: readonly string[]): string[] { return ['-s', serial, 'shell', ...command] } @@ -45,8 +42,7 @@ export async function androidSwipe( if (!first || !last || points.length < 2) { return } - // adb input only supports a straight swipe, so approximate the path by its - // endpoints; the scrcpy control phase replaces this with true multi-touch. + // adb input supports only straight swipes, so approximate the path with its endpoints. const start = normalizedToDevicePixels(first.x, first.y, size) const end = normalizedToDevicePixels(last.x, last.y, size) ensureAdbOk( @@ -128,8 +124,7 @@ export async function androidExec( serial: string, command: string ): Promise { - // Pass the whole command as a single arg so the device shell parses quotes, - // pipes, and compound commands instead of naively splitting on spaces. + // Preserve shell quoting and compound commands by passing the command as one argument. const result = ensureAdbOk(await runner(sdk.adb, androidShellArgs(serial, [command])), 'adb exec') return result.stdout } diff --git a/src/main/emulator/android/android-input-mapping.test.ts b/src/main/emulator/android/android-input-mapping.test.ts index 18d94077f5c..541273794e4 100644 --- a/src/main/emulator/android/android-input-mapping.test.ts +++ b/src/main/emulator/android/android-input-mapping.test.ts @@ -30,11 +30,8 @@ describe('normalizedToDevicePixels', () => { }) it('rounds to the nearest pixel', () => { - // 0.4999 * 1080 = 539.892 -> 540 (rounds up) expect(normalizedToDevicePixels(0.4999, 0, PIXEL_7).x).toBe(540) - // 0.5002 * 1080 = 540.216 -> 540 (rounds down) expect(normalizedToDevicePixels(0.5002, 0, PIXEL_7).x).toBe(540) - // 0.5008 * 1080 = 540.864 -> 541 (rounds up) expect(normalizedToDevicePixels(0.5008, 0, PIXEL_7).x).toBe(541) }) }) diff --git a/src/main/emulator/android/android-input-mapping.ts b/src/main/emulator/android/android-input-mapping.ts index d068e450e47..f2bc7e9687f 100644 --- a/src/main/emulator/android/android-input-mapping.ts +++ b/src/main/emulator/android/android-input-mapping.ts @@ -2,8 +2,7 @@ import { EmulatorError } from '../emulator-errors' export type DeviceScreenSize = { width: number; height: number } -// Why: serve-sim reports taps in normalized 0..1 (top-left origin); adb input -// needs integer device pixels, and the max addressable pixel is dimension-1. +// Input uses normalized coordinates, while the last addressable device pixel is dimension - 1. function toPixel(normalized: number, dimension: number): number { const rounded = Math.round(normalized * dimension) const max = dimension - 1 @@ -16,8 +15,6 @@ function toPixel(normalized: number, dimension: number): number { return rounded } -// Normalized 0..1 (top-left origin) -> integer device pixels, clamped to -// [0, width-1]/[0, height-1]. export function normalizedToDevicePixels( x: number, y: number, @@ -45,8 +42,6 @@ const BUTTON_KEYCODES: Record = { voldown: 25 } -// Accepts the canonical names plus the common aliases above. Throws -// EmulatorError('emulator_error', ...) on an unknown name. export function androidButtonKeycode(name: string): number { const keycode = BUTTON_KEYCODES[name] if (keycode === undefined) { diff --git a/src/main/emulator/android/android-logcat.ts b/src/main/emulator/android/android-logcat.ts index 9db5f6c674a..d03044eaab0 100644 --- a/src/main/emulator/android/android-logcat.ts +++ b/src/main/emulator/android/android-logcat.ts @@ -1,10 +1,4 @@ -// Pure arg-building and line parsing for `adb logcat`. No process execution -// here: the caller prepends the resolved adb binary path to every arg array. - -// `adb -s logcat` with `-d` to dump-and-exit (vs. follow), the -// `threadtime` format always (gives pid/tid and matches the parser), an optional -// `-t ` tail, and trailing filterspec tokens (e.g. ['MyTag:D', '*:S']) -// that must come last on the adb command line. +// Filters must follow all logcat options; threadtime matches the line parser below. export function logcatArgs( serial: string, options?: { dump?: boolean; lines?: number; filters?: readonly string[] } @@ -25,8 +19,7 @@ export function logcatArgs( export type LogcatEntry = { timestamp?: string; level?: string; tag?: string; message: string } -// Matches `MM-DD HH:MM:SS.mmm : `. The tag is -// every non-colon char up to the first colon, so colons in the message survive. +// Match only the first colon so colons inside messages survive. const LOGCAT_LINE = /^(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s+\d+\s+\d+\s+([A-Z])\s+([^:]+):(.*)$/ export function parseLogcatLine(line: string): LogcatEntry { diff --git a/src/main/emulator/android/android-permissions.ts b/src/main/emulator/android/android-permissions.ts index c1b2ccdded8..34279ec1b42 100644 --- a/src/main/emulator/android/android-permissions.ts +++ b/src/main/emulator/android/android-permissions.ts @@ -1,13 +1,7 @@ import { EmulatorError } from '../emulator-errors' -// Pure arg-building for `adb shell pm` permission ops. No process execution -// here: the caller prepends the resolved adb binary path to every arg array. - export type AndroidPermissionOp = 'grant' | 'revoke' | 'reset' -// grant/revoke: `adb -s shell pm ` -// reset: `adb -s shell pm reset-permissions ` -// `reset` clears every runtime grant, so the permission argument is ignored. export function permissionArgs( serial: string, op: AndroidPermissionOp, @@ -15,12 +9,10 @@ export function permissionArgs( permission?: string ): string[] { const base = ['-s', serial, 'shell', 'pm'] - // `pm reset-permissions` resets every app's runtime grants and rejects a - // package argument, so it is omitted here. + // reset-permissions is global and rejects a package argument. if (op === 'reset') { return [...base, 'reset-permissions'] } - // grant/revoke target a single permission, so it must be present. if (!permission || permission.trim() === '') { throw new EmulatorError('emulator_error', `pm ${op} requires a permission name`) } diff --git a/src/main/emulator/android/android-sdk-discovery.test.ts b/src/main/emulator/android/android-sdk-discovery.test.ts index 1fd505430f7..7e49e1c416a 100644 --- a/src/main/emulator/android/android-sdk-discovery.test.ts +++ b/src/main/emulator/android/android-sdk-discovery.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { discoverAndroidSdk } from './android-sdk-discovery' import type { DiscoverAndroidSdkOptions } from './android-sdk-discovery' -// Fake `exists` predicate: only paths placed in the set are reported present. const existsIn = (paths: Iterable): ((path: string) => boolean) => { const set = new Set(paths) return (path: string) => set.has(path) @@ -15,7 +14,6 @@ const adbFor = (sdkRoot: string, win32: boolean): string => const emulatorFor = (sdkRoot: string, win32: boolean): string => join(sdkRoot, 'emulator', win32 ? 'emulator.exe' : 'emulator') -// discoverAndroidSdk requires both adb and the emulator binary to be present. const sdkToolsFor = (sdkRoot: string, win32: boolean): string[] => [ adbFor(sdkRoot, win32), emulatorFor(sdkRoot, win32) diff --git a/src/main/emulator/android/android-sdk-discovery.ts b/src/main/emulator/android/android-sdk-discovery.ts index 359a8fd5568..d852422f650 100644 --- a/src/main/emulator/android/android-sdk-discovery.ts +++ b/src/main/emulator/android/android-sdk-discovery.ts @@ -14,16 +14,13 @@ export type DiscoverAndroidSdkOptions = { exists: (path: string) => boolean } -// Pure/dependency-injected: every external input (env, platform, homedir, fs probe) -// is a parameter so the resolver is unit-testable; Phase B wires the real I/O. export function discoverAndroidSdk(options: DiscoverAndroidSdkOptions): AndroidSdkPaths | null { const { env, platform, homedir, exists } = options const win32 = platform === 'win32' for (const sdkRoot of candidateSdkRoots(env, platform, homedir)) { const paths = resolveToolPaths(sdkRoot, win32) - // Require both tools the backend depends on: adb (devices/input/stream) and - // the emulator binary (boot + list AVDs). A partial SDK isn't usable. + // Both adb and the emulator binary are required for backend operations. if (exists(paths.adb) && exists(paths.emulator)) { return paths } diff --git a/src/main/emulator/android/android-sdk-host-discovery.ts b/src/main/emulator/android/android-sdk-host-discovery.ts index 49e32f60bed..a7f93acbfa2 100644 --- a/src/main/emulator/android/android-sdk-host-discovery.ts +++ b/src/main/emulator/android/android-sdk-host-discovery.ts @@ -4,17 +4,12 @@ import { discoverAndroidSdk, type AndroidSdkPaths } from './android-sdk-discover let configuredSdkPath: string | null = null -// Lets the user point Orca at an Android SDK in a non-standard location (saved in -// settings). Applied as the highest-priority candidate; an invalid path falls -// back to ANDROID_HOME / ANDROID_SDK_ROOT / the default install location. +// Invalid configured paths fall back to environment and platform defaults. export function setConfiguredAndroidSdkPath(path: string | null): void { const trimmed = path?.trim() configuredSdkPath = trimmed ? trimmed : null } -// Discovers the Android SDK from the real host environment (process env + fs), -// returning null on any failure so the backend degrades to "unsupported". The -// pure resolver lives in android-sdk-discovery; this wires it to the real host. export function discoverAndroidSdkFromHost(): AndroidSdkPaths | null { const env = configuredSdkPath ? { ...process.env, ANDROID_HOME: configuredSdkPath } : process.env try { diff --git a/src/main/emulator/android/android-sdk-state.ts b/src/main/emulator/android/android-sdk-state.ts index 74558b75fd8..640873c6429 100644 --- a/src/main/emulator/android/android-sdk-state.ts +++ b/src/main/emulator/android/android-sdk-state.ts @@ -4,10 +4,7 @@ import type { AndroidSdkPaths } from './android-sdk-discovery' const SDK_MISSING = 'Android SDK not found. Install Android Studio and set ANDROID_HOME.' -// Resolves the backend's Android SDK. An injected SDK (tests, or an explicit -// null) is fixed; for the real host it re-runs discovery on every call so a -// newly-installed SDK or a changed configured path takes effect without a -// restart. Host discovery is only a few existsSync probes, so this is cheap. +// Rediscover host SDKs per call so installs and settings changes work without restarting. export class AndroidSdkState { constructor( private readonly injected: boolean, diff --git a/src/main/emulator/android/android-stream-controller.ts b/src/main/emulator/android/android-stream-controller.ts index dcdebf23cdb..c472b97d8b9 100644 --- a/src/main/emulator/android/android-stream-controller.ts +++ b/src/main/emulator/android/android-stream-controller.ts @@ -16,8 +16,7 @@ export type AndroidStreamControllerDeps = { maxSize: number } -// Owns the per-serial scrcpy stream lifecycle and dedupes concurrent starts so -// two racing attaches never spawn duplicate servers fighting for the same port. +// Deduplicate starts so racing attaches cannot launch competing scrcpy servers. export class AndroidStreamController { private readonly handles = new Map() private readonly starts = new Map>() @@ -39,8 +38,6 @@ export class AndroidStreamController { } private async begin(serial: string): Promise { - // Reuse only a live stream; if the session died on its own the registry entry - // is gone, so drop the stale handle and start fresh. if (this.handles.has(serial) && scrcpyVideoRegistry.has(serial)) { return androidStreamSessionInfo(serial) } diff --git a/src/main/emulator/android/android-stream-session-starter.ts b/src/main/emulator/android/android-stream-session-starter.ts index ea8ba23a24c..410f1a30644 100644 --- a/src/main/emulator/android/android-stream-session-starter.ts +++ b/src/main/emulator/android/android-stream-session-starter.ts @@ -24,9 +24,6 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer { return arrayBuffer } -// Starts a scrcpy session for a booted device and pipes its meta + H.264 frames -// into the video registry (which the renderer subscribes to via IPC). Returns -// the session info (streamCodec h264) and a handle that tears everything down. export const startAndroidStreamSession: StartAndroidStream = async ({ runner, sdk, @@ -35,8 +32,7 @@ export const startAndroidStreamSession: StartAndroidStream = async ({ maxSize }) => { let session: ScrcpyStreamSession | null = null - // ScrcpyStreamSession.start waits for video meta, and that same startup path - // may emit config/keyframes. Register first so those events seed replay cache. + // Register before startup so early config/keyframes enter the replay cache. scrcpyVideoRegistry.register(serial, () => session?.close()) try { session = await ScrcpyStreamSession.start( diff --git a/src/main/emulator/android/avd-manager.ts b/src/main/emulator/android/avd-manager.ts index 45075579031..c227a63a942 100644 --- a/src/main/emulator/android/avd-manager.ts +++ b/src/main/emulator/android/avd-manager.ts @@ -1,18 +1,12 @@ -// `emulator -list-avds` export const listAvdsArgs: readonly string[] = ['-list-avds'] -// Emulator log lines look like `INFO | ...` (marker + padding + pipe). AVD -// names contain no whitespace, so match the log prefix exactly rather than via -// substring — otherwise a name like `PixelWARNINGTest` would be dropped. +// Match log prefixes rather than substrings so names such as PixelWARNINGTest survive. const EMULATOR_LOG_PREFIX = /^(INFO|WARNING|ERROR|DEBUG|VERBOSE|PANIC)\s/ function isNoiseLine(line: string): boolean { return line === '' || line.startsWith('No AVD') || EMULATOR_LOG_PREFIX.test(line) } -// Parses `emulator -list-avds` stdout: one AVD name per line. Drops blank lines -// and informational/warning lines the emulator binary sometimes prints, trimming -// each kept name. export function parseAvdList(stdout: string): string[] { return stdout .split('\n') @@ -27,8 +21,6 @@ export type BootAvdOptions = { gpu?: string } -// Args to boot an AVD via the emulator binary: ['-avd', name] then any requested -// flags in a stable, documented order. export function bootAvdArgs(name: string, options: BootAvdOptions = {}): string[] { const args = ['-avd', name] if (options.noSnapshot) { @@ -46,7 +38,6 @@ export function bootAvdArgs(name: string, options: BootAvdOptions = {}): string[ return args } -// `adb -s emu kill` to stop a running emulator instance. export function emuKillArgs(serial: string): string[] { return ['-s', serial, 'emu', 'kill'] } diff --git a/src/main/emulator/android/scrcpy-server-deploy.ts b/src/main/emulator/android/scrcpy-server-deploy.ts index c75e08c5d27..41ce681824f 100644 --- a/src/main/emulator/android/scrcpy-server-deploy.ts +++ b/src/main/emulator/android/scrcpy-server-deploy.ts @@ -1,12 +1,8 @@ -// scrcpy server v2.4 deployment command builders (pure). The impure orchestration -// (push the jar, open the tunnel, spawn the server, connect sockets) lives in -// scrcpy-stream-session. The option names/order are version-coupled to scrcpy and -// pinned to SCRCPY_SERVER_VERSION (validated live against that jar on a device). +// Command options are coupled to SCRCPY_SERVER_VERSION; update protocol handling with the pinned server. export const SCRCPY_SERVER_VERSION = '2.4' export const SCRCPY_DEVICE_JAR_PATH = '/data/local/tmp/scrcpy-server.jar' -// `adb -s push ` export function pushScrcpyServerArgs( serial: string, localJarPath: string, @@ -15,9 +11,7 @@ export function pushScrcpyServerArgs( return ['-s', serial, 'push', localJarPath, deviceJarPath] } -// `adb -s forward tcp: localabstract:scrcpy_` — with -// tunnel_forward the server listens on the abstract socket and adb forwards a -// local TCP port to it. +// tunnel_forward makes adb forward local TCP to the server's abstract socket. export function scrcpyForwardArgs(serial: string, localPort: number, scid: string): string[] { return ['-s', serial, 'forward', `tcp:${localPort}`, `localabstract:scrcpy_${scid}`] } @@ -35,7 +29,6 @@ export type ScrcpyServerOptions = { version?: string } -// `adb -s shell CLASSPATH= app_process / com.genymobile.scrcpy.Server ` export function startScrcpyServerArgs(serial: string, options: ScrcpyServerOptions): string[] { const version = options.version ?? SCRCPY_SERVER_VERSION const jar = options.deviceJarPath ?? SCRCPY_DEVICE_JAR_PATH diff --git a/src/main/emulator/android/scrcpy-server-download.ts b/src/main/emulator/android/scrcpy-server-download.ts index ac264f6f68a..a9d66baf10d 100644 --- a/src/main/emulator/android/scrcpy-server-download.ts +++ b/src/main/emulator/android/scrcpy-server-download.ts @@ -8,9 +8,7 @@ import { EmulatorError } from '../emulator-errors' import { emulatorProbe, emulatorProbeError } from '../emulator-probe' import { SCRCPY_SERVER_VERSION } from './scrcpy-server-deploy' -// The scrcpy server jar is fetched by the client on first use (not bundled in -// the repo) into the per-user cache, pinned to the version our client protocol -// targets. The GitHub release asset is unversioned-extension, so we store it as .jar. +// Cache the version-pinned release asset as .jar; GitHub publishes it without that extension. const DOWNLOAD_URL = `https://github.com/Genymobile/scrcpy/releases/download/v${SCRCPY_SERVER_VERSION}/scrcpy-server-v${SCRCPY_SERVER_VERSION}` const MIN_VALID_BYTES = 10_000 @@ -29,9 +27,6 @@ export function isScrcpyServerJarReady(): boolean { let inFlightDownload: Promise | null = null -// Returns the cached jar path, downloading it once if missing. Concurrent callers -// (e.g. two devices on first run) share one download. Throws a clear -// EmulatorError when the download fails (e.g. offline). export async function ensureScrcpyServerJar(): Promise { const path = scrcpyServerJarPath() if (isScrcpyServerJarReady()) { @@ -75,7 +70,6 @@ function downloadTo(url: string, dest: string, redirects = 0): Promise { } const req = get(url, (res: IncomingMessage) => { const status = res.statusCode ?? 0 - // GitHub release downloads 302-redirect to the asset CDN. if (status >= 300 && status < 400 && res.headers.location) { // Resolve relative Locations and refuse protocol downgrades (http:). const next = new URL(res.headers.location, url) @@ -93,12 +87,10 @@ function downloadTo(url: string, dest: string, redirects = 0): Promise { reject(new Error(`HTTP ${status}`)) return } - // pipeline destroys the write stream and rejects on any stream error - // (incl. mid-body errors on res), so the Promise always settles. + // pipeline rejects mid-body errors and closes the destination stream. pipeline(res, createWriteStream(dest)).then(resolve, reject) }) req.on('error', reject) - // Don't hang forever if GitHub/CDN stalls before or during the response. req.setTimeout(30_000, () => req.destroy(new Error('download timed out'))) }) } diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index b8c65aecf89..7ea6fea85c6 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -20,10 +20,7 @@ import { } from './scrcpy-video-frame-parser' import { emulatorProbe, emulatorProbeError } from '../emulator-probe' -// A live scrcpy session validated against a real emulator. The connection -// handshake (dummy byte, 64-byte device name, codec meta) and the server option -// set are coupled to the pinned scrcpy server version; pure video framing is -// unit-tested, while this orchestration is exercised live via probes. +// Handshake and server options must match the pinned scrcpy protocol version. const DEVICE_NAME_BYTES = 64 const DUMMY_BYTE = 1 @@ -46,13 +43,10 @@ export type ScrcpyStreamOptions = { } function newScid(): string { - // scrcpy parses scid as a SIGNED 32-bit hex int, so mask to 31 bits and pad to - // 8 hex digits to match the server's own %08x format. + // scrcpy expects a signed 32-bit scid formatted as eight hex digits. return (randomBytes(4).readUInt32BE(0) & 0x7fffffff).toString(16).padStart(8, '0') } -// A live scrcpy session: owns the server process, the adb tunnel, and the video -// + control sockets. Created via ScrcpyStreamSession.start(). export class ScrcpyStreamSession { private server: ChildProcess | null = null private videoSocket: Socket | null = null @@ -119,8 +113,7 @@ export class ScrcpyStreamSession { private spawnServer(): void { const { sdk, serial, maxSize } = this.options - // The server is long-running, so spawn it directly rather than via the - // request/response command runner. + // The command runner's timeout is unsuitable for the long-running server. this.server = spawn(sdk.adb, startScrcpyServerArgs(serial, { scid: this.scid, maxSize }), { stdio: ['ignore', 'pipe', 'pipe'] }) @@ -145,16 +138,13 @@ export class ScrcpyStreamSession { this.openVideoSocket(0) } - // adb accepts the forwarded TCP connection before the server's abstract socket - // exists (then resets it), so retry until the server actually delivers bytes - // (the dummy byte). Only then is the connection real; connect control after. + // adb can accept TCP before scrcpy is ready; require its dummy byte before connecting control. private openVideoSocket(attempt: number): void { if (this.closed) { return } const socket = connect(this.port, '127.0.0.1') - // A failed connect emits both 'error' and 'close'; settle once so retries - // (and post-delivery close) don't fan out into exponential connection storms. + // error and close can both fire; settle once to avoid duplicate retry chains. let settled = false const retry = (): void => { if (settled || this.closed) { @@ -184,8 +174,7 @@ export class ScrcpyStreamSession { }) socket.once('error', retry) socket.once('close', retry) - // adb may accept the forwarded TCP connection but never deliver if the server - // stalls; a short idle timeout retries instead of hanging the connect forever. + // Retry accepted connections that never deliver the readiness byte. socket.setTimeout(2000, retry) } @@ -232,9 +221,7 @@ export class ScrcpyStreamSession { shouldResolveReady = true buffer.discard(12) } - // The parser throws on a desynced stream (e.g. an absurd frame size); catch - // it here so it fails the session via the normal teardown path rather than - // surfacing as an unhandled exception in this socket 'data' listener. + // Route malformed frames through session teardown instead of throwing from the data listener. let frames: ScrcpyVideoFrame[] try { frames = parseScrcpyVideoFrames(buffer) diff --git a/src/main/emulator/android/scrcpy-video-frame-parser.test.ts b/src/main/emulator/android/scrcpy-video-frame-parser.test.ts index 78f1771eaa4..18281f7a9ea 100644 --- a/src/main/emulator/android/scrcpy-video-frame-parser.test.ts +++ b/src/main/emulator/android/scrcpy-video-frame-parser.test.ts @@ -132,8 +132,6 @@ describe('parseScrcpyVideoFrames', () => { }) it('throws on a desynced frame size instead of buffering toward OOM', () => { - // A header declaring a frame far larger than any real one would otherwise - // never be satisfied, leaving the whole buffer pending forever. const header = Buffer.alloc(12) header.writeUInt32BE(64 * 1024 * 1024, 8) const pending = new RelayFrameBuffer() diff --git a/src/main/emulator/android/scrcpy-video-frame-parser.ts b/src/main/emulator/android/scrcpy-video-frame-parser.ts index fd702823350..42e44a2e424 100644 --- a/src/main/emulator/android/scrcpy-video-frame-parser.ts +++ b/src/main/emulator/android/scrcpy-video-frame-parser.ts @@ -1,14 +1,10 @@ -// scrcpy server video protocol v2.4 (send_codec_meta + send_frame_meta enabled). -// Pure parsing of the H.264 byte stream the scrcpy server writes to the video -// socket. The socket reader (scrcpy-stream-session) feeds chunks here; this file -// has no I/O so the framing is unit-testable. +// scrcpy v2.4 with send_codec_meta and send_frame_meta enabled. import type { RelayFrameBuffer } from '../../../shared/relay-frame-buffer' const FRAME_HEADER_SIZE = 12 const CODEC_META_SIZE = 12 -// scrcpy frames are well under this at the configured max_size; a larger -// size means a desynced stream — fail fast instead of buffering toward OOM. +// Reject implausible frame sizes before buffering toward OOM. const MAX_FRAME_BYTES = 16 * 1024 * 1024 // Caps per-object overhead when a socket delivers one frame as many tiny chunks. export const MAX_PENDING_CHUNKS = 1024 @@ -19,7 +15,6 @@ const PTS_MASK = (1n << 62n) - 1n export type ScrcpyVideoMeta = { codecId: string; width: number; height: number } -// The codec id is a 4-byte ascii tag, null-padded when shorter than 4 chars. function parseCodecId(bytes: Buffer): string { let id = '' for (const byte of bytes) { @@ -30,8 +25,7 @@ function parseCodecId(bytes: Buffer): string { return id } -// Leading 12-byte codec metadata sent once before the frame stream: a 4-char -// codec id (e.g. "h264") followed by the initial width and height. +// Wire layout: 4-byte codec ID, 32-bit width, 32-bit height. export function parseScrcpyVideoMeta(buffer: Buffer): ScrcpyVideoMeta | null { if (buffer.length < CODEC_META_SIZE) { return null @@ -76,7 +70,6 @@ export function parseScrcpyVideoFrames(buffer: RelayFrameBuffer): ScrcpyVideoFra if (frames.length > 0 && buffer.length > 0) { const pendingHead = buffer.peek(1) - // Compact only mostly consumed allocations larger than the reusable Buffer slab. if (pendingHead.buffer.byteLength > Math.max(Buffer.poolSize, pendingHead.length * 2)) { buffer.append(Buffer.from(buffer.drain())) } diff --git a/src/main/emulator/android/uiautomator-tree.test.ts b/src/main/emulator/android/uiautomator-tree.test.ts index 041c1a4abbd..632c5cfcdda 100644 --- a/src/main/emulator/android/uiautomator-tree.test.ts +++ b/src/main/emulator/android/uiautomator-tree.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest' import { EmulatorError } from '../emulator-errors' import { parseAndroidBounds, parseUiAutomatorXml } from './uiautomator-tree' -// Realistic `uiautomator dump` output: an XML prolog, a root, and -// nested self-describing elements (mix of container + self-closing leaf). const HIERARCHY_XML = ` @@ -55,7 +53,6 @@ describe('parseUiAutomatorXml', () => { it('omits string fields whose attribute is absent or empty', () => { const frame = parseUiAutomatorXml(HIERARCHY_XML).children[0] - // class is present, but text/resource-id/content-desc are "" -> omitted. expect('text' in frame).toBe(false) expect('resourceId' in frame).toBe(false) expect('contentDesc' in frame).toBe(false) diff --git a/src/main/emulator/android/uiautomator-tree.ts b/src/main/emulator/android/uiautomator-tree.ts index 3a3bb7d6e6a..c083c84e4ec 100644 --- a/src/main/emulator/android/uiautomator-tree.ts +++ b/src/main/emulator/android/uiautomator-tree.ts @@ -15,11 +15,9 @@ export type AndroidAxNode = { children: AndroidAxNode[] } -// Raw element produced by the parser before mapping to the typed Android node. type RawElement = { tag: string; attributes: Record; children: RawElement[] } -// Parses an Android bounds string "[left,top][right,bottom]" -> AndroidAxBounds, -// or null when the format doesn't match. Coordinates may be negative (off-screen). +// Bounds may be negative for off-screen elements. export function parseAndroidBounds(value: string): AndroidAxBounds | null { const match = value.trim().match(/^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/) if (!match) { @@ -33,9 +31,7 @@ export function parseAndroidBounds(value: string): AndroidAxBounds | null { } } -// Parses uiautomator dump XML. The returned node is the synthetic root that -// holds the top-level children of . Throws -// EmulatorError('emulator_error', ...) on unparseable input. +/** Return a synthetic root containing the top-level Android nodes. */ export function parseUiAutomatorXml(xml: string): AndroidAxNode { if (xml.trim() === '') { throw new EmulatorError('emulator_error', 'Cannot parse empty uiautomator XML') @@ -52,8 +48,6 @@ export function parseUiAutomatorXml(xml: string): AndroidAxNode { `Failed to parse uiautomator XML: ${(error as Error).message}` ) } - // A bare root is treated as the single top-level node; otherwise take - // the children of . const topLevel = root.tag === 'node' ? [root] : root.children.filter((child) => child.tag === 'node') return { children: topLevel.map(mapNode) } @@ -64,7 +58,6 @@ function mapNode(raw: RawElement): AndroidAxNode { const node: AndroidAxNode = { children: raw.children.filter((child) => child.tag === 'node').map(mapNode) } - // Omit string fields whose attribute is absent or empty (no empty-string fields). setString(node, 'className', attrs['class']) setString(node, 'text', attrs['text']) setString(node, 'resourceId', attrs['resource-id']) @@ -102,8 +95,6 @@ function setBool( } } -// Recursive-descent parser for the well-formed XML `uiautomator dump` emits. -// All cursor state is local so the function is pure and reentrant. function parseDocument(xml: string): RawElement { let i = 0 const n = xml.length @@ -137,7 +128,6 @@ function parseDocument(xml: string): RawElement { return xml.slice(start, i) } - // Skip prolog: whitespace, declarations, comments, and . const skipProlog = (): void => { for (;;) { skipWs() @@ -217,7 +207,7 @@ function parseDocument(xml: string): RawElement { fail(`Unterminated element <${tag}>`) } if (xml[i] !== '<') { - // Text content between elements carries no node data; skip to next tag. + // Text between elements carries no accessibility-node data. while (i < n && xml[i] !== '<') { i++ } @@ -255,7 +245,6 @@ function parseDocument(xml: string): RawElement { return parseElement() } -// Decodes the predefined XML entities plus numeric character references. function decodeEntities(value: string): string { if (!value.includes('&')) { return value diff --git a/src/main/emulator/backends/android-emulator-backend.test.ts b/src/main/emulator/backends/android-emulator-backend.test.ts index 65e889872c4..981270358cb 100644 --- a/src/main/emulator/backends/android-emulator-backend.test.ts +++ b/src/main/emulator/backends/android-emulator-backend.test.ts @@ -4,7 +4,7 @@ import { AndroidEmulatorBackend } from './android-emulator-backend' import type { AndroidCommandResult, AndroidCommandRunner } from '../android/android-command-runner' import type { AndroidSdkPaths } from '../android/android-sdk-discovery' -// The AVD boot spawns the emulator detached (not via the command runner). +// AVD boot spawns directly, bypassing the injected command runner. vi.mock('node:child_process', async (importOriginal) => { const actual = (await importOriginal()) as Record return { ...actual, spawn: vi.fn(() => ({ on: () => {}, unref: () => {} })) } @@ -22,8 +22,6 @@ const ok = (stdout: string): AndroidCommandResult => ({ stdout, stderr: '', code const RUNNING_ADB = 'List of devices attached\nemulator-5554\tdevice product:sdk_gphone64 model:Pixel_7 device:emu64a' -// Default runner answering the standard discovery/query calls for a single -// booted Pixel_7 emulator plus a shutdown Pixel_Tablet AVD. function defaultRunner(): ReturnType { return vi.fn(async (binary: string, args: readonly string[]) => { const a = args.join(' ') @@ -276,8 +274,7 @@ describe('AndroidEmulatorBackend', () => { }) it('preserves all unowned forwards when stopping without a live session', async () => { - // One adb server holds every device's forwards; `--list` and `--remove-all` - // ignore `-s`, and `--remove` matches the local name alone. + // adb forward listing/removal is server-wide, so unowned forwards must survive. const forwards = [ 'emulator-5554 tcp:41234 localabstract:scrcpy_1a2b3c4d', 'emulator-5554 tcp:8081 tcp:8081', diff --git a/src/main/emulator/backends/android-emulator-backend.ts b/src/main/emulator/backends/android-emulator-backend.ts index 57c9f5d3b41..8a678997c87 100644 --- a/src/main/emulator/backends/android-emulator-backend.ts +++ b/src/main/emulator/backends/android-emulator-backend.ts @@ -49,7 +49,7 @@ import type { EmulatorGesturePoint } from '../emulator-gesture-sender' export type AndroidEmulatorBackendOptions = { runner?: AndroidCommandRunner - // Inject discovered SDK paths (tests); undefined means auto-discover, null means "no SDK". + // undefined discovers the host SDK; null explicitly disables it. sdk?: AndroidSdkPaths | null bootTimeoutMs?: number pollIntervalMs?: number @@ -62,9 +62,6 @@ export type AndroidEmulatorBackendOptions = { const DEFAULT_BOOT_TIMEOUT_MS = 180_000 const DEFAULT_POLL_INTERVAL_MS = 2_000 -// The Android backend. Device discovery + lifecycle + input run through `adb` -// and the `emulator` binary; the live H.264 pane streams via scrcpy. Input uses -// `adb shell input`, so it works without sending on the scrcpy control socket. export class AndroidEmulatorBackend implements EmulatorBackend { readonly kind = 'android' as const readonly streamCodec = 'h264' as const @@ -186,8 +183,7 @@ export class AndroidEmulatorBackend implements EmulatorBackend { } async isSessionReusable(info: EmulatorSessionInfo): Promise { - // Reuse a live scrcpy stream so a renderer remount reconnects to it (the - // registry replays meta + config + GOP) instead of respawning the server. + // Reuse the registered stream so remounts replay cached video instead of restarting scrcpy. return scrcpyVideoRegistry.has(info.deviceUdid) } @@ -288,7 +284,6 @@ export class AndroidEmulatorBackend implements EmulatorBackend { return run(this.requireSdk(), await this.resolveDeviceId(deviceId)) } - // Boots an AVD (by name) when not running and waits for boot; returns the serial. async ensureBooted(deviceOrName: string): Promise { return bootAndroidDevice(this.runner, this.requireSdk(), deviceOrName, { bootTimeoutMs: this.bootTimeoutMs, diff --git a/src/main/emulator/backends/emulator-backend.ts b/src/main/emulator/backends/emulator-backend.ts index 6186a493498..67b7d4eb04a 100644 --- a/src/main/emulator/backends/emulator-backend.ts +++ b/src/main/emulator/backends/emulator-backend.ts @@ -7,8 +7,7 @@ import type { export type { EmulatorBackendKind, EmulatorStreamCodec } -// A device exposed by a backend, normalized across iOS (simulator UDID) and -// Android (adb serial / AVD name). `id` is opaque and backend-resolved. +// IDs are opaque selectors resolved by each backend (simulator UDID, adb serial, or AVD name). export type EmulatorDevice = { backend: EmulatorBackendKind id: string @@ -18,8 +17,6 @@ export type EmulatorDevice = { isAvailable: boolean } -// Which optional verbs a backend supports. The router uses these to reject -// unsupported commands with a clear error instead of a silent no-op. export type EmulatorBackendCapabilities = { install: boolean launch: boolean @@ -32,22 +29,16 @@ export type BackendAvailability = { available: boolean devices: EmulatorDevice[] message: string - // Resolved toolchain root when found (e.g. the Android SDK path), for setup UI. sdkPath?: string } -// Target selectors accepted by the router's public input methods. export type EmulatorTargetOpts = { device?: string emulator?: string worktreeId?: string } -// One emulator platform (iOS serve-sim today, Android scrcpy next). The -// EmulatorBridge router owns the session registry and per-worktree active -// state; a backend owns only device/helper/input mechanics for its platform. -// All device-facing methods take an opaque device id/selector that the backend -// resolves to its native id via resolveDeviceId. +// The bridge owns active-workspace state; backends own device, helper, and input operations. export type EmulatorBackend = { readonly kind: EmulatorBackendKind readonly streamCodec: EmulatorStreamCodec @@ -59,29 +50,23 @@ export type EmulatorBackend = { ownsDevice(id: string): Promise resolveDeviceId(deviceOrName: string): Promise - // Start (booting if needed) the helper/stream for a device and return its session. startSession(deviceId: string): Promise // Stop the helper for a device without powering it off. stopHelperForDevice( deviceId: string, options?: { helperPid?: number; includeOrphaned?: boolean } ): Promise - // Power off the underlying device/AVD. shutdownDevice(deviceId: string): Promise - // Whether an active session can be reused (stream reachable + helper alive). isSessionReusable(info: EmulatorSessionInfo): Promise tap(deviceId: string, x: number, y: number): Promise - // wsUrl is the iOS gesture stream from the registry; Android backends ignore it - // and drive their own control socket keyed by deviceId. + // Android ignores the iOS gesture URL and uses its own device control channel. gesture(deviceId: string, points: EmulatorGesturePoint[], wsUrl: string | null): Promise type(deviceId: string, text: string): Promise button(deviceId: string, name: string): Promise rotate(deviceId: string, orientation: string): Promise exec(deviceId: string, command: string): Promise - // Capability-gated verbs. The router checks `capabilities` - // before calling these and rejects unsupported backends with emulator_unsupported. installApp?(deviceId: string, apkPath: string, options?: { reinstall?: boolean }): Promise launchApp?(deviceId: string, packageName: string, activity?: string): Promise setPermission?( diff --git a/src/main/emulator/backends/ios-emulator-backend.test.ts b/src/main/emulator/backends/ios-emulator-backend.test.ts index 3d97c91a422..cbd94bfa9d9 100644 --- a/src/main/emulator/backends/ios-emulator-backend.test.ts +++ b/src/main/emulator/backends/ios-emulator-backend.test.ts @@ -264,8 +264,7 @@ describe('IosEmulatorBackend', () => { }) it('recycles the device and retries when the helper finds no framebuffer', async () => { - // The real-world failure: simctl reports Booted but the display IO ports - // never came up, so serve-sim --detach dies with the framebuffer error. + // CoreSimulator can report Booted while framebuffer ports remain unavailable. execServeSimCommandMock .mockRejectedValueOnce( new EmulatorError( @@ -284,7 +283,6 @@ describe('IosEmulatorBackend', () => { const info = await backend.startSession('device-1') expect(info.deviceUdid).toBe('device-1') expect(shutdownSimulatorDeviceMock).toHaveBeenCalledWith('device-1') - // Booted once up front, again after the recycle shutdown. expect(ensureSimulatorBootedMock).toHaveBeenCalledTimes(2) expect(execServeSimCommandMock).toHaveBeenCalledTimes(2) }) @@ -328,10 +326,8 @@ describe('IosEmulatorBackend', () => { const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true }) await expect(backend.startSession('device-1')).rejects.toMatchObject({ code: 'emulator_helper_failed', - // Actionable headline plus the raw helper log for diagnosis. message: expect.stringMatching(/simctl erase[\s\S]*No framebuffer display descriptor found/) }) - // Exactly one recycle attempt; no shutdown/boot loop against a broken device. expect(shutdownSimulatorDeviceMock).toHaveBeenCalledTimes(1) expect(execServeSimCommandMock).toHaveBeenCalledTimes(2) }) diff --git a/src/main/emulator/backends/ios-emulator-backend.ts b/src/main/emulator/backends/ios-emulator-backend.ts index f52f26abdb5..de23936187f 100644 --- a/src/main/emulator/backends/ios-emulator-backend.ts +++ b/src/main/emulator/backends/ios-emulator-backend.ts @@ -33,9 +33,6 @@ import type { EmulatorDevice } from './emulator-backend' -// The iOS/serve-sim backend: device/helper mechanics extracted from the former -// monolithic EmulatorBridge. The bridge now routes to this (and, later, an -// Android backend); per-worktree session state stays in the router. export class IosEmulatorBackend implements EmulatorBackend { readonly kind = 'ios' as const readonly streamCodec = 'mjpeg' as const @@ -54,10 +51,7 @@ export class IosEmulatorBackend implements EmulatorBackend { this.waitForEndpointReady = options.waitForEndpointReady ?? waitForServeSimEndpointReady } - // Why: resolving the executable can materialize the serve-sim runtime (a one-time - // recursive copy + xattr subprocess on macOS after each version bump). Defer it - // off the startup path — the bridge is constructed before the main window shows — - // so it only runs when an emulator command is actually issued. + // Defer runtime materialization and its macOS copy/xattr work until the first emulator command. private get serveSimExecutable(): ServeSimExecutable { this.cachedServeSimExecutable ??= resolveServeSimExecutable() return this.cachedServeSimExecutable @@ -72,7 +66,6 @@ export class IosEmulatorBackend implements EmulatorBackend { } async ownsDevice(id: string): Promise { - // iOS only owns devices on macOS; otherwise it must not claim Android serials. if (!this.isSupportedOnHost()) { return false } @@ -90,8 +83,6 @@ export class IosEmulatorBackend implements EmulatorBackend { return devices.map((device) => toEmulatorDevice(device)) } - // iOS-specific passthroughs the router exposes for back-compat with the - // runtime/availability code (not part of the cross-backend interface). async listSimulators(): Promise { return listSimulatorDevices() } @@ -216,8 +207,7 @@ export class IosEmulatorBackend implements EmulatorBackend { ) } - // Why: CoreSimulator can report "Booted" with the display IO ports down - // (HID alive, no framebuffer); a shutdown/boot recycle is the only recovery. + // CoreSimulator can report Booted without framebuffer ports; retry once after shutdown/boot. let didRecycleWedgedBoot = false const startHelperRecyclingWedgedBoot = async (): Promise => { try { @@ -253,7 +243,7 @@ export class IosEmulatorBackend implements EmulatorBackend { ) } } - // Why: serve-sim/CoreSimulator can surface Simulator.app while Orca embeds the stream. + // Keep the native Simulator window hidden while Orca embeds its stream. await hideNativeSimulatorApp().catch(() => {}) return { ...info, streamCodec: 'mjpeg', backend: 'ios' } } @@ -263,8 +253,7 @@ export class IosEmulatorBackend implements EmulatorBackend { options: { helperPid?: number; includeOrphaned?: boolean } = {} ): Promise { await this.execServeSim(serveSimKillArgs(deviceId)).catch(() => {}) - // Why: serve-sim --kill depends on its state file; stale helper binaries - // can survive state loss and keep old streams/listeners around. + // Stale helpers can outlive the state file that serve-sim --kill relies on. await killServeSimHelperProcessesForDevice(deviceId, options).catch(() => {}) } @@ -295,8 +284,6 @@ export class IosEmulatorBackend implements EmulatorBackend { } } -// Why: serve-sim's capture helper prints exactly this when a booted device has -// no com.apple.framebuffer.display IO port: the signature of a wedged boot. const MISSING_FRAMEBUFFER_RE = /No framebuffer display descriptor found/i function isMissingFramebufferError(error: unknown): error is EmulatorError { diff --git a/src/main/emulator/emulator-availability.test.ts b/src/main/emulator/emulator-availability.test.ts index b38d0c178d4..4a1e8db7de4 100644 --- a/src/main/emulator/emulator-availability.test.ts +++ b/src/main/emulator/emulator-availability.test.ts @@ -17,9 +17,6 @@ const NO_ANDROID: BackendAvailability = { message: 'Android SDK not found. Install Android Studio and set ANDROID_HOME.' } -// A minimal stand-in exposing only what inspectEmulatorAvailability touches: the -// registered backends (iOS host gate + Android checkAvailability) and the iOS -// passthroughs. function fakeBridge(overrides: FakeBridgeOverrides = {}): EmulatorBridge { const android = overrides.android ?? NO_ANDROID return { diff --git a/src/main/emulator/emulator-availability.ts b/src/main/emulator/emulator-availability.ts index fee51505c70..cac92574c31 100644 --- a/src/main/emulator/emulator-availability.ts +++ b/src/main/emulator/emulator-availability.ts @@ -66,8 +66,6 @@ async function inspectIosAvailability(bridge: EmulatorBridge): Promise 0, devices, simctl, serveSim } } -// Android devices are surfaced through the same SimulatorDevice-shaped list the -// settings pane already renders (name + state + a synthetic "Android" runtime). function toSimulatorRow(device: EmulatorDevice): SimulatorDevice { return { name: device.name, @@ -78,8 +76,6 @@ function toSimulatorRow(device: EmulatorDevice): SimulatorDevice { } } -// Aggregates availability across backends so the Mobile Emulator pane works on -// every desktop platform: iOS (macOS only) plus Android (any host with the SDK). export async function inspectEmulatorAvailability( bridge: EmulatorBridge ): Promise { @@ -98,8 +94,7 @@ export async function inspectEmulatorAvailability( const devices = [...ios.devices, ...android.devices.map(toSimulatorRow)] const available = ios.available || android.available - // Why: on non-macOS hosts the iOS messages are irrelevant, so surface the - // Android setup message instead of "requires macOS". + // Outside macOS, report Android setup rather than an irrelevant iOS host error. const message = available ? 'Ready' : currentPlatform === 'darwin' diff --git a/src/main/emulator/emulator-bridge.test.ts b/src/main/emulator/emulator-bridge.test.ts index a2a2d030bd0..d8960d73f46 100644 --- a/src/main/emulator/emulator-bridge.test.ts +++ b/src/main/emulator/emulator-bridge.test.ts @@ -54,7 +54,6 @@ vi.mock('./android/android-sdk-host-discovery', () => ({ setConfiguredAndroidSdkPath: () => {} })) -// These tests exercise the iOS backend, which is gated to macOS. vi.mock('os', async (importOriginal) => { const actual = (await importOriginal()) as Record return { ...actual, platform: () => 'darwin' } @@ -70,7 +69,6 @@ function session(deviceUdid: string): EmulatorSessionInfo { wsUrl: `ws://127.0.0.1:3100/${deviceUdid}`, axUrl: `http://127.0.0.1:3100/${deviceUdid}/ax`, helperPid: 1234, - // iOS serve-sim sessions round-trip through the registry as mjpeg. streamCodec: 'mjpeg' } } @@ -192,7 +190,6 @@ describe('EmulatorBridge helper ownership', () => { it('rejects a capability the resolved backend does not support', async () => { const bridge = new EmulatorBridge() - // device-1 resolves to the iOS backend, which advertises no explicit-verb caps. await expect( bridge.runCapability('install', { device: 'device-1' }, async () => 'unused') ).rejects.toMatchObject({ code: 'emulator_unsupported' }) @@ -468,7 +465,6 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { }) }) - // Routing test: normalization is covered in serve-sim-ax-normalization.test.ts. await expect(commands.emulatorAx({ worktree: 'wt-1' })).resolves.toMatchObject([ { type: 'Application' } ]) @@ -523,8 +519,6 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { } ]) const bridge = new EmulatorBridge() - // The session lives under another worktree; the CLI still resolves the - // caller's cwd worktree, which has nothing attached. bridge.registerActiveEmulator('wt-other', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, @@ -581,7 +575,6 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { const tree = [{ type: 'Application', children: [] }] netFetchMock.mockResolvedValue(new Response(JSON.stringify(tree), { status: 200 })) const bridge = new EmulatorBridge() - // No axUrl on the registered session (e.g. reattach path predating derivation). bridge.registerActiveEmulator( 'wt-1', { diff --git a/src/main/emulator/emulator-bridge.ts b/src/main/emulator/emulator-bridge.ts index b201b34963a..be36d6459ce 100644 --- a/src/main/emulator/emulator-bridge.ts +++ b/src/main/emulator/emulator-bridge.ts @@ -21,10 +21,6 @@ import type { EmulatorTargetOpts } from './backends/emulator-backend' -// Routes emulator commands to the backend that owns the target device while -// owning the per-worktree active-session registry and lifecycle orchestration. -// Backends supply only device/helper/input mechanics; the bridge decides which -// backend a command targets (by the session's recorded backend, else by device). export class EmulatorBridge { private readonly sessionRegistry = new EmulatorSessionRegistry() private readonly startLeases = new EmulatorStartLeaseRegistry() @@ -35,8 +31,7 @@ export class EmulatorBridge { constructor(options: EmulatorBridgeOptions = {}) { this.iosBackend = new IosEmulatorBackend(options) this.androidBackend = new AndroidEmulatorBackend() - // Why: backends are always registered (not host-gated) so explicitly targeted - // commands still reach them; availability reporting handles host support. + // Register all backends so explicit targets reach their own host-support errors. this.backends = [this.iosBackend, this.androidBackend] } @@ -44,13 +39,10 @@ export class EmulatorBridge { return this.backends } - // Aggregated device list across host-supported backends (iOS simulators + - // Android devices/AVDs), for the unified `orca emulator list`. async listAllDevices(): Promise { return listAvailableEmulatorDevices(this.backends) } - // iOS-specific passthroughs kept for back-compat with the runtime + availability code. async listSimulators(): Promise { return this.iosBackend.listSimulators() } @@ -79,8 +71,7 @@ export class EmulatorBridge { return this.sessionRegistry.getActiveForWorktree(worktreeId) } - // On a device switch, keep slow-to-boot Android emulators running for instant - // switch-back; shut down other backends' devices so they are not leaked. + // Keep slow-booting Android devices alive across switches for fast reattachment. async stopActiveForSwitch(worktreeId: string): Promise { const keepAlive = this.backendForActiveWorktree(worktreeId)?.kind === 'android' return this.stopActiveForWorktreeInternal(worktreeId, { shutdownDevice: !keepAlive }) @@ -99,8 +90,7 @@ export class EmulatorBridge { return null } if (device) { - // resolveDeviceId throws for a not-yet-booted AVD; treat that as "not the - // active device" so the caller falls through to a fresh (booting) attach. + // An unbooted AVD cannot resolve yet; let the caller fall through to a fresh attach. const resolved = await backend.resolveDeviceId(device).catch(() => null) if (resolved !== active.deviceUdid) { return null @@ -205,9 +195,7 @@ export class EmulatorBridge { } const udid = await backend.resolveDeviceId(device) const worktreeId = opts?.worktreeId - // Fall back to the udid-keyed session so an explicit --device read works - // from a worktree with no active emulator (matching tap/type reachability); - // sessions are stored once per udid, so both lookups hit the same state. + // Explicit device reads must work without an active workspace attachment. const session = (worktreeId ? this.getActiveForWorktree(worktreeId) : null) ?? this.sessionRegistry.getSession(udid) @@ -217,15 +205,12 @@ export class EmulatorBridge { `iOS simulator ${udid} is not active for this worktree (active: ${session.deviceUdid}); attach the requested simulator first.` ) } - // Heal sessions registered without an axUrl (parse-time derivation only - // covers fresh --detach output) by deriving it from the mjpeg stream URL. + // Older registered sessions may lack the AX URL derived for fresh sessions. const axUrl = session?.axUrl ?? deriveAxUrlFromStreamUrl(session?.streamUrl) return backend.accessibilityTree!(udid, axUrl) }) } - // Runs a capability-gated verb against the resolved target, rejecting backends - // that do not advertise the capability (e.g. install/logcat on iOS). async runCapability( capability: keyof EmulatorBackendCapabilities, opts: EmulatorTargetOpts | undefined, @@ -345,9 +330,7 @@ export class EmulatorBridge { return backend } } - // Why: fall back to a host-supported backend, else the platform-primary one, - // so an unrecognized device (e.g. no SDK yet) surfaces the right setup error - // — Android on Windows/Linux, iOS/CoreSimulator on macOS — not iOS-on-Windows. + // Choose the host's backend so unknown devices produce the relevant setup error. return ( this.backends.find((backend) => backend.isSupportedOnHost()) ?? (platform() === 'darwin' ? this.iosBackend : this.androidBackend) diff --git a/src/main/emulator/emulator-default-attach-device.ts b/src/main/emulator/emulator-default-attach-device.ts index f7befc65410..cdbedffc574 100644 --- a/src/main/emulator/emulator-default-attach-device.ts +++ b/src/main/emulator/emulator-default-attach-device.ts @@ -1,9 +1,6 @@ import type { EmulatorBridge } from './emulator-bridge' import { pickDefaultSimulatorDevice } from './emulator-availability' -// Resolves a default device to attach when none is specified: the iOS default -// picker first (booted iPhone etc. on macOS), else the first booted (else first) -// device across host backends, e.g. Android on Windows/Linux. export async function resolveDefaultAttachDevice( bridge: EmulatorBridge ): Promise { diff --git a/src/main/emulator/emulator-errors.ts b/src/main/emulator/emulator-errors.ts index 0f552b89e54..8abd07e52e0 100644 --- a/src/main/emulator/emulator-errors.ts +++ b/src/main/emulator/emulator-errors.ts @@ -1,4 +1,4 @@ -// Why: shared error codes for emulator (mirrors BrowserErrorCode in shared/runtime-types; used by bridge, runtime, dispatcher, CLI handlers, skill examples). Keep codes stable for agents. +// Keep error codes stable: agents and CLI callers depend on them. export class EmulatorError extends Error { code: EmulatorErrorCode constructor(code: EmulatorErrorCode, message: string) { diff --git a/src/main/emulator/emulator-probe.ts b/src/main/emulator/emulator-probe.ts index 39e83ba5706..cc8ddd2eae2 100644 --- a/src/main/emulator/emulator-probe.ts +++ b/src/main/emulator/emulator-probe.ts @@ -2,8 +2,7 @@ import { appendFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -// Diagnostic probes for emulator bring-up. They are opt-in because command -// params can include typed text, package names, and local paths. +// Opt in because probe data can include typed text and local paths. export const EMULATOR_PROBE_LOG = join(tmpdir(), 'orca-android-emu-probe.log') const EMULATOR_PROBE_ENABLED = process.env.ORCA_EMULATOR_PROBE === '1' diff --git a/src/main/emulator/emulator-session-registry.ts b/src/main/emulator/emulator-session-registry.ts index 917b14a9e39..0df75c89d19 100644 --- a/src/main/emulator/emulator-session-registry.ts +++ b/src/main/emulator/emulator-session-registry.ts @@ -19,8 +19,7 @@ export class EmulatorSessionRegistry { pid: info.helperPid, managed: options.managed === true, initialized: true, - // Why: default to the iOS/serve-sim contract so existing callers that - // predate multi-backend keep their prior behavior. + // Preserve iOS defaults for callers predating multiple backends. backend: info.backend ?? options.backend ?? 'ios', streamCodec: info.streamCodec ?? 'mjpeg' }) diff --git a/src/main/emulator/emulator-types.ts b/src/main/emulator/emulator-types.ts index f675d11bc66..8b631c0c3b2 100644 --- a/src/main/emulator/emulator-types.ts +++ b/src/main/emulator/emulator-types.ts @@ -1,8 +1,5 @@ -// Which emulator backend owns a device/session. Lives here (the low-level types -// file) so both the bridge types and the backend interface import it without a cycle. export type EmulatorBackendKind = 'ios' | 'android' -// How the live pane decodes a backend's frames: iOS serve-sim is MJPEG, Android scrcpy is H.264. export type EmulatorStreamCodec = 'mjpeg' | 'h264' export type EmulatorSessionInfo = { diff --git a/src/main/emulator/mjpeg-frame-parser.test.ts b/src/main/emulator/mjpeg-frame-parser.test.ts index 40af8ba01be..20bab778736 100644 --- a/src/main/emulator/mjpeg-frame-parser.test.ts +++ b/src/main/emulator/mjpeg-frame-parser.test.ts @@ -28,17 +28,14 @@ describe('extractJpegFrames', () => { expect(second.frames).toEqual([JPEG_A]) }) - // Regression: frames are returned as views into the chunk (no per-frame copy), - // so the retained `pending` must still be an independent copy — otherwise a - // later mutation of the source chunk could corrupt buffered partial frames. + // Retained partial frames must not share mutable input storage. it('does not retain a view aliased to the input chunk', () => { - const chunk = Buffer.concat([JPEG_A, Buffer.from([0xff, 0xd8, 0x09])]) // A + partial B + const chunk = Buffer.concat([JPEG_A, Buffer.from([0xff, 0xd8, 0x09])]) const result = extractJpegFrames(Buffer.alloc(0), chunk) expect(result.frames).toEqual([JPEG_A]) expect(result.pending).toEqual(Buffer.from([0xff, 0xd8, 0x09])) - // Mutating the original chunk after parsing must not change pending. chunk.fill(0) expect(result.pending).toEqual(Buffer.from([0xff, 0xd8, 0x09])) }) diff --git a/src/main/emulator/mjpeg-frame-parser.ts b/src/main/emulator/mjpeg-frame-parser.ts index 90733e2c1fe..0bc4ef80d74 100644 --- a/src/main/emulator/mjpeg-frame-parser.ts +++ b/src/main/emulator/mjpeg-frame-parser.ts @@ -22,11 +22,7 @@ export function extractJpegFrames( chunk: Buffer, maxPendingBytes = DEFAULT_MAX_PENDING_BYTES ): MjpegFrameParseResult { - // Why: when there are no leftover bytes the chunk already holds whole frames, - // so read it directly instead of copying — frames below are views consumed - // synchronously (the IPC layer copies into a transferable ArrayBuffer), and - // any retained `pending` is copied out via trimPendingBuffer, so no chunk - // memory is held across calls. At ~30fps this avoids a full-frame copy/frame. + // Reuse the input when possible: frames are consumed synchronously and retained pending bytes are copied. let cursor = pending.length > 0 ? Buffer.concat([pending, chunk]) : chunk const frames: Buffer[] = [] @@ -46,8 +42,6 @@ export function extractJpegFrames( } const nextOffset = frameEnd + JPEG_END.length - // A view, not a copy: the caller consumes each frame synchronously before - // the next chunk arrives, and `cursor` is only ever re-sliced (never mutated). frames.push(cursor.subarray(0, nextOffset)) cursor = cursor.subarray(nextOffset) } diff --git a/src/main/emulator/scrcpy-video-registry.test.ts b/src/main/emulator/scrcpy-video-registry.test.ts index 9d551ed70d8..34456bb825e 100644 --- a/src/main/emulator/scrcpy-video-registry.test.ts +++ b/src/main/emulator/scrcpy-video-registry.test.ts @@ -19,14 +19,14 @@ describe('scrcpyVideoRegistry', () => { const events: ScrcpyVideoEvent['type'][] = [] const unsubscribe = scrcpyVideoRegistry.subscribe('dev', (event) => events.push(event.type)) - expect(events).toEqual(['meta', 'frame']) // replayed cached meta + config + expect(events).toEqual(['meta', 'frame']) scrcpyVideoRegistry.pushFrame('dev', frame(false)) expect(events).toEqual(['meta', 'frame', 'frame']) unsubscribe() scrcpyVideoRegistry.pushFrame('dev', frame(false)) - expect(events).toEqual(['meta', 'frame', 'frame']) // no delivery after unsubscribe + expect(events).toEqual(['meta', 'frame', 'frame']) scrcpyVideoRegistry.stop('dev') expect(close).toHaveBeenCalledTimes(1) @@ -61,7 +61,6 @@ describe('scrcpyVideoRegistry', () => { scrcpyVideoRegistry.subscribe('gop', (event) => seen.push(tag(event)))() expect(seen).toEqual(['K1', 'D2']) - // A new keyframe drops the prior GOP so replay always starts decodeable. scrcpyVideoRegistry.pushFrame('gop', { config: false, keyFrame: true, diff --git a/src/main/emulator/scrcpy-video-registry.ts b/src/main/emulator/scrcpy-video-registry.ts index 417317ac550..08e0bd83a6c 100644 --- a/src/main/emulator/scrcpy-video-registry.ts +++ b/src/main/emulator/scrcpy-video-registry.ts @@ -1,14 +1,8 @@ import type { ScrcpyVideoMeta } from './android/scrcpy-video-frame-parser' -// In-memory pub/sub bridging a live scrcpy session (fed by AndroidEmulatorBackend) -// to renderer subscribers (the video pane, via the emulator-video-stream IPC). -// Caches the codec meta, the config (SPS/PPS) frame, and the current GOP -// (keyframe + following deltas) so a late subscriber can initialize its WebCodecs -// decoder and decode from the keyframe immediately, not after the next GOP. +// Cache metadata, decoder config, and the current GOP for late subscribers. -// scrcpy keyframes ~every 10s; high-motion content can otherwise buffer -// hundreds of deltas. Cap the replayed GOP so memory stays bounded and a -// late subscriber isn't flooded — always keep the keyframe at index 0. +// Bound high-motion replay caches while retaining their leading keyframe. const MAX_GOP_FRAMES = 120 export type ScrcpyVideoFrameMessage = { @@ -58,14 +52,11 @@ class ScrcpyVideoRegistry { if (frame.config) { entry.config = frame } else if (frame.keyFrame) { - // A keyframe starts a fresh decodeable GOP; buffer it + the following - // deltas so a late subscriber can decode immediately on replay. entry.gop = [frame] } else if (entry.gop.length > 0) { - // Only buffer deltas once a keyframe anchors the GOP (a delta alone is - // undecodable); deltas before the first keyframe are still sent live below. + // Cache deltas only after a keyframe; pre-keyframe deltas are still delivered live. entry.gop.push(frame) - // Drop the oldest delta (never index 0, the keyframe) so replay stays decodable. + // Preserve the leading keyframe when bounding the cache. if (entry.gop.length > MAX_GOP_FRAMES) { entry.gop.splice(1, 1) } @@ -75,8 +66,6 @@ class ScrcpyVideoRegistry { } } - // Subscribe a renderer; replays the cached meta + config so the decoder can - // start without waiting for the next keyframe. Returns an unsubscribe fn. subscribe(deviceId: string, subscriber: ScrcpyVideoSubscriber): () => void { const entry = this.entries.get(deviceId) if (!entry) { @@ -88,7 +77,6 @@ class ScrcpyVideoRegistry { if (entry.config) { subscriber({ type: 'frame', frame: entry.config }) } - // Replay the current GOP (keyframe + deltas) so the decoder starts now. for (const frame of entry.gop) { subscriber({ type: 'frame', frame }) } diff --git a/src/main/emulator/serve-sim-accessibility-tree.ts b/src/main/emulator/serve-sim-accessibility-tree.ts index dab53d0e382..6d82d26490d 100644 --- a/src/main/emulator/serve-sim-accessibility-tree.ts +++ b/src/main/emulator/serve-sim-accessibility-tree.ts @@ -32,8 +32,7 @@ export async function requestServeSimAccessibilityTree(axUrl: string): Promise { ] const [root] = normalizeServeSimAxTree(raw) - // Root consumes one slot of the 500-node budget. expect(root.children).toHaveLength(499) expect(root.truncated).toBe(true) expect(root.children[0]!.truncated).toBeUndefined() diff --git a/src/main/emulator/serve-sim-ax-normalization.ts b/src/main/emulator/serve-sim-ax-normalization.ts index f84022e2810..13f178a570d 100644 --- a/src/main/emulator/serve-sim-ax-normalization.ts +++ b/src/main/emulator/serve-sim-ax-normalization.ts @@ -1,16 +1,10 @@ -// Normalizes serve-sim's raw /ax node tree into a compact nested tree whose -// frames are in 0..1 device coordinates. serve-sim's helper reports frames in -// absolute pixels; `tap`/`gesture` take normalized 0..1 — so we normalize here -// to let agents feed element positions straight back into input commands. -// Frame derivation mirrors normalizeAxTree in serve-sim/src/ax.ts: the first -// root's frame is the device screen. +// Normalize pixel frames against the first root's screen frame for reuse in tap/gesture commands. export type NormalizedAxFrame = { x: number; y: number; width: number; height: number } // Matches serve-sim's own snapshot cap; an unbounded tree can flood agent output. const MAX_AX_NODES = 500 -// One accessibility element, position normalized, children nested (raw tree shape). export type NormalizedAxNode = { role: string type: string @@ -89,7 +83,6 @@ function normalizeNode( frame: normalizeFrame(readFrame(node.frame), screen), children } - // AXUniqueId is often null; only surface it when the helper provides one. const uniqueId = asString(node.AXUniqueId) if (uniqueId) { normalized.id = uniqueId diff --git a/src/main/emulator/serve-sim-detached-session.ts b/src/main/emulator/serve-sim-detached-session.ts index d45bde41ff0..69c5c27a860 100644 --- a/src/main/emulator/serve-sim-detached-session.ts +++ b/src/main/emulator/serve-sim-detached-session.ts @@ -10,8 +10,7 @@ function streamUrlFromServeSimUrl(url: string): string { return url.endsWith(MJPEG_STREAM_SUFFIX) ? url : `${url.replace(/\/$/, '')}${MJPEG_STREAM_SUFFIX}` } -// Derive the helper /ax endpoint by swapping the mjpeg stream suffix. Guarded to -// that suffix so a non-mjpeg stream URL never fabricates a bogus /ax endpoint. +// Derive /ax only from MJPEG URLs to avoid fabricating endpoints for other stream types. export function deriveAxUrlFromStreamUrl(streamUrl: string | undefined): string | undefined { if (!streamUrl || !streamUrl.endsWith(MJPEG_STREAM_SUFFIX)) { return undefined diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index 96213a9b3fe..8e0f60676d8 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -60,7 +60,6 @@ function getServeSimEnv(executable: ServeSimExecutable): NodeJS.ProcessEnv { return env } -// Cached per process: materialization is a one-time copy; later calls only stat. let materializedServeSimPackageDir: string | null | undefined function resolveMaterializedServeSimPackageDir(bundledPackageDir: string): string | null { @@ -87,9 +86,7 @@ export function resolveServeSimExecutable(): ServeSimExecutable { (process.platform === 'darwin' ? join(app.getPath('exe'), '..', '..', 'Resources') : join(app.getPath('exe'), '..', 'resources')) - // macOS releases used to copy serve-sim to Resources/serve-sim explicitly. - // The runtime closure now owns a single copy under Resources/node_modules; - // keep the root path first so already-installed bundles continue to work. + // Prefer the legacy Resources/serve-sim path for already-installed bundles. for (const bundledPackageDir of [ join(bundledResourcesPath, 'serve-sim'), join(bundledResourcesPath, 'node_modules', 'serve-sim') @@ -99,9 +96,7 @@ export function resolveServeSimExecutable(): ServeSimExecutable { continue } if (process.platform === 'darwin') { - // Why: the bundled camera dylib is signed but has no Gatekeeper ticket - // (iOS-simulator arch), so injecting it from the quarantined app bundle - // can trip syspolicyd; run serve-sim from an unquarantined copy instead. + // Materialize an unquarantined copy so Gatekeeper accepts the injected simulator dylib. const materializedDir = resolveMaterializedServeSimPackageDir(bundledPackageDir) if (materializedDir) { return { diff --git a/src/main/emulator/serve-sim-runtime-materializer.test.ts b/src/main/emulator/serve-sim-runtime-materializer.test.ts index 6ead7fd9a1d..0554d248f8d 100644 --- a/src/main/emulator/serve-sim-runtime-materializer.test.ts +++ b/src/main/emulator/serve-sim-runtime-materializer.test.ts @@ -71,7 +71,6 @@ describe('materializeServeSimRuntime', () => { }) expect(materialized).toBe(join(root, 'runtime', '1.2.3', 'node_modules', 'serve-sim')) - // The dylib must be byte-identical to the bundled (Developer-ID-signed) copy. const dylibPath = join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib') expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT) expect(clearQuarantine).toHaveBeenCalledTimes(1) @@ -125,8 +124,7 @@ describe('materializeServeSimRuntime', () => { const winnerNodeModulesDir = join(targetRootDir, '1.2.3', 'node_modules') const winnerPackageDir = join(winnerNodeModulesDir, 'serve-sim') - // Simulate another instance finishing first: right before our rename, drop a - // complete target dir in place so renameSync fails but the runtime is current. + // Simulate another instance publishing a complete runtime just before our rename. const materialized = materializeServeSimRuntime({ bundledPackageDir, targetRootDir, @@ -259,14 +257,13 @@ describe('materializeServeSimRuntime', () => { ) }) - // Regression: serve-sim 0.1.47's ESM entry imports `ws`, which a bare package copy cannot resolve. + // The copied ESM entry must resolve dependencies such as ws outside the original package. it('lets the real serve-sim entry resolve every declared dependency from the copy', async () => { const root = await createRoot() const installedPackageDir = realpathSync(join(process.cwd(), 'node_modules', 'serve-sim')) const manifest = JSON.parse(await readFile(join(installedPackageDir, 'package.json'), 'utf8')) const dependencyNames = Object.keys(manifest.dependencies ?? {}) expect(dependencyNames).toContain('ws') - // Mirror the packaged app: a real serve-sim dir with its dependencies hoisted beside it. const bundledNodeModulesDir = join(root, 'Resources', 'node_modules') const bundledPackageDir = join(bundledNodeModulesDir, 'serve-sim') cpSync(installedPackageDir, bundledPackageDir, { recursive: true }) @@ -282,7 +279,7 @@ describe('materializeServeSimRuntime', () => { }) expect(materialized).not.toBeNull() - // Probe from the entry's own directory so ESM resolution walks up exactly as serve-sim.js does. + // Probe beside the entry to exercise the same ESM resolution paths. const probePath = join(materialized!, 'dist', 'orca-dependency-probe.mjs') await writeFile( probePath, diff --git a/src/main/emulator/serve-sim-runtime-materializer.ts b/src/main/emulator/serve-sim-runtime-materializer.ts index a890fe25078..b503eaecbbd 100644 --- a/src/main/emulator/serve-sim-runtime-materializer.ts +++ b/src/main/emulator/serve-sim-runtime-materializer.ts @@ -23,13 +23,7 @@ function defaultClearQuarantine(dir: string): void { if (process.platform !== 'darwin') { return } - // Why: a downloaded/updated .app carries com.apple.quarantine, and cpSync - // clones it onto the copy. serve-sim DYLD-injects libSimCameraInjector.dylib - // (an iOS-simulator binary Apple never Gatekeeper-tickets) into a simulator - // process; if that copy is quarantined, syspolicyd malware-rejects the load. - // Running from an unquarantined copy is what avoids the rejection (#6877). - // Remove only the quarantine attribute (not `-cr`, which strips every xattr); - // recursive `-d` exits 0 even for files that never had it. + // Clear only quarantine: copied simulator dylibs have no Gatekeeper ticket, but other xattrs must survive. execFileSync('/usr/bin/xattr', ['-rd', 'com.apple.quarantine', dir], { timeout: 30_000 }) } @@ -61,8 +55,7 @@ function readDependencyNames(packageDir: string): string[] { return dependencies && typeof dependencies === 'object' ? Object.keys(dependencies) : [] } -// Why: the copy has no node_modules of its own, so its bare imports (e.g. `ws`) must resolve -// through links to the bundle's installed siblings; a dangling link (moved or translocated app) is stale. +// Link bare imports to bundled dependencies; moved bundles make existing links stale. function bundledDependencyLinks( bundledPackageDir: string, runtimeNodeModulesDir: string @@ -90,13 +83,7 @@ function isMaterializedRuntimeCurrent(bundledPackageDir: string, versionDir: str } } -// Copies the bundled serve-sim package to a per-version directory outside the -// signed app bundle and strips quarantine, so the camera dylib injected from -// it is not subject to Gatekeeper assessment. The bundled dylib stays signed -// and in place (it must, or the app fails notarization) — this only relocates -// the copy that actually gets DYLD-injected. serve-sim resolves the dylib and -// helper relative to its own entry, so the whole package moves together. -// Layout: /node_modules/serve-sim plus links to its bundled dependencies. +// Relocate the whole package for relative helper/dylib paths while preserving the signed bundle. export function materializeServeSimRuntime( options: ServeSimRuntimeMaterializerOptions ): string | null { diff --git a/src/main/emulator/simctl-simulator-devices.test.ts b/src/main/emulator/simctl-simulator-devices.test.ts index deff0defbe6..27fbd8c01aa 100644 --- a/src/main/emulator/simctl-simulator-devices.test.ts +++ b/src/main/emulator/simctl-simulator-devices.test.ts @@ -120,8 +120,7 @@ describe('shutdownSimulatorDevice', () => { }) it('propagates real shutdown failures instead of swallowing them', async () => { - // execFile's message echoes the command line, which always contains - // "shutdown"; a hung device (timeout kill) must still reject. + // Command text always contains shutdown; only the actual device state proves it is already off. execFileMock.mockImplementation((_command, _args, _options, callback) => { callback( Object.assign( diff --git a/src/main/emulator/simctl-simulator-devices.ts b/src/main/emulator/simctl-simulator-devices.ts index 6c913d45cc4..55466caf8a8 100644 --- a/src/main/emulator/simctl-simulator-devices.ts +++ b/src/main/emulator/simctl-simulator-devices.ts @@ -55,8 +55,7 @@ function mapSimctlError(error: ExecFileException, stderr?: string | Buffer): Emu (lower.includes('unable to find utility') && lower.includes('simctl')) || lower.includes('not a developer tool') ) { - // Why: xcrun exposes setup problems as raw execFile text; UI and CLI need - // the actionable Xcode fix instead of "Command failed: xcrun ...". + // Translate raw xcrun setup errors into an actionable Xcode message. return new EmulatorError('emulator_simctl_unavailable', SIMCTL_UNAVAILABLE_MESSAGE) } return new EmulatorError('emulator_error', raw.trim() || 'xcrun simctl command failed.') @@ -191,8 +190,7 @@ export async function shutdownSimulatorDevice(udid: string): Promise { resolve() return } - // Why: execFile's message echoes the command line, so only the actual - // already-off state is idempotent; other current states are real failures. + // Match the actual shutdown state; execFile also echoes shutdown in the command text. const message = `${error.message}\n${stderr?.toString() ?? ''}`.toLowerCase() if (/\bcurrent state:\s*shutdown\b/.test(message)) { resolve() diff --git a/src/main/emulator/simulator-app-visibility.ts b/src/main/emulator/simulator-app-visibility.ts index e360c36f4ee..2837f3a3a20 100644 --- a/src/main/emulator/simulator-app-visibility.ts +++ b/src/main/emulator/simulator-app-visibility.ts @@ -7,8 +7,7 @@ export async function hideNativeSimulatorApp(): Promise { } await new Promise((resolve) => { - // Why: Simulator.app does not expose a direct hide command, but System Events - // can hide the process after CoreSimulator/serve-sim surfaces a native window. + // Simulator has no hide command; use System Events to hide the native process. execFile( 'osascript', [ diff --git a/src/main/ephemeral-vm/failed-start-cleanup.ts b/src/main/ephemeral-vm/failed-start-cleanup.ts index fd720821b00..5c454e6e3da 100644 --- a/src/main/ephemeral-vm/failed-start-cleanup.ts +++ b/src/main/ephemeral-vm/failed-start-cleanup.ts @@ -53,7 +53,7 @@ export async function cleanupFailedEphemeralVmStart( if (!args.recipe.checkoutMode) { throw error } - // Why: cleanup retry metadata must survive even when its feature companion is unreadable. + // Retain cleanup retry metadata even if the companion feature file is unreadable. upsertEphemeralVmRuntimeRollbackRecovery(args.userDataPath, recovery) } return false diff --git a/src/main/ephemeral-vm/runtime-environment-release.ts b/src/main/ephemeral-vm/runtime-environment-release.ts index 6f6e17941d3..50b9ecfcdce 100644 --- a/src/main/ephemeral-vm/runtime-environment-release.ts +++ b/src/main/ephemeral-vm/runtime-environment-release.ts @@ -3,12 +3,12 @@ import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment- import { listEnvironments, removeEnvironment } from '../../shared/runtime-environment-store' import { getOrcadSshDeployment } from '../../shared/runtime-environments' -/** Forgets the servers a destroyed VM ran; a managed one's tunnel would otherwise keep redialing. */ +/** Remove destroyed VM servers so managed tunnels stop reconnecting. */ export async function releaseEphemeralVmRuntimeEnvironments( userDataPath: string, runtime: Pick ): Promise { - // Why the target too: a relay-era VM converted on connect never recorded its server's id. + // Relay-era conversions may have recorded only the SSH target, not the server ID. const environments = listEnvironments(userDataPath).filter( (entry) => entry.id === runtime.runtimeEnvironmentId || diff --git a/src/main/ephemeral-vm/runtime-service.test.ts b/src/main/ephemeral-vm/runtime-service.test.ts index 8fc46e0b282..f853b13316a 100644 --- a/src/main/ephemeral-vm/runtime-service.test.ts +++ b/src/main/ephemeral-vm/runtime-service.test.ts @@ -51,7 +51,7 @@ describe('ephemeral VM runtime service', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') beforeEach(() => { - // Why: secure-file has dedicated ACL coverage; these tests focus on lifecycle semantics. + // Isolate lifecycle tests from platform ACL behavior, which secure-file tests cover. Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) }) @@ -94,8 +94,7 @@ describe('ephemeral VM runtime service', () => { const recipe: OrcaVmRecipe = { id: 'cloud-sandbox', name: 'Cloud Sandbox', - // Repo-owned recipes predate plugin bounds; snapshotting must not fail - // after create has already provisioned external resources. + // Legacy repository recipes may exceed plugin limits after resources have already been provisioned. description: 'x'.repeat(2_048), create: nodeCommand(startPath), destroy: nodeCommand(cleanupPath) diff --git a/src/main/ephemeral-vm/runtime-ssh.ts b/src/main/ephemeral-vm/runtime-ssh.ts index 276024cce02..2eb410788b6 100644 --- a/src/main/ephemeral-vm/runtime-ssh.ts +++ b/src/main/ephemeral-vm/runtime-ssh.ts @@ -11,7 +11,6 @@ import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' export type RuntimeOwnedSshConnectionResult = { targetId: string target: SshTarget - /** The managed Orca server that serves this host. */ environmentId: string } @@ -30,14 +29,12 @@ export async function connectRuntimeOwnedSshTarget(args: { if (state.status !== 'connected') { throw new Error(state.error || `SSH target did not connect: ${state.status}`) } - // Why no wait: only a managed server can serve the host, so an unserved connect fails now. if (state.managedServer?.kind !== 'managed') { throw new SshHostNotServedError(target.id) } environmentId = state.managedServer.environmentId } catch (error) { - // The target is persisted at upsert, so a failed connect would orphan it; - // remove it (idempotent) before rethrowing so cleanup is complete. + // Upsert persisted the target; remove it on failure to avoid orphaning it. await removeRuntimeOwnedSshTarget(target.id).catch(() => undefined) throw error } diff --git a/src/main/ephemeral-vm/workspace-suspend.ts b/src/main/ephemeral-vm/workspace-suspend.ts index 474467bbaa6..5a5eb6c34dd 100644 --- a/src/main/ephemeral-vm/workspace-suspend.ts +++ b/src/main/ephemeral-vm/workspace-suspend.ts @@ -5,7 +5,6 @@ import { suspendEphemeralVmRuntime } from './runtime-service' import { disconnectRuntimeOwnedSshTarget } from './runtime-ssh' import { getRuntimeRecipeContext } from '../ipc/ephemeral-vm-recipe-context' -/** Suspends the VM a workspace runs on; a workspace with no VM does nothing. */ export async function suspendEphemeralVmWorkspace( store: Store, workspaceId: string @@ -30,13 +29,9 @@ export async function suspendEphemeralVmWorkspace( if (!result.ok) { throw new Error(result.error) } - // Only tear down SSH for a real suspend; a skipped suspend keeps the runtime - // 'running', so disconnecting would break the still-active session with no resume. + // A skipped suspend leaves the VM running, so its SSH connection must remain live. if (runtime.connectionMode === 'ssh' && !result.skipped) { - // Why: the suspend recipe already succeeded (VM is suspended), so a failed - // LOCAL relay teardown must NOT flip to 'suspend_failed' — that status is not - // resume-eligible (see resume gate below), which would strand the runtime - // unrecoverable. Keep 'suspended'; resume re-establishes the relay anyway. + // Keep a successfully suspended VM resume-eligible even if local SSH teardown fails. await disconnectRuntimeOwnedSshTarget(runtime.sshTargetId).catch(() => undefined) } } diff --git a/src/main/external-conversations/connector-agents.ts b/src/main/external-conversations/connector-agents.ts index 33b283e07c1..809902f4228 100644 --- a/src/main/external-conversations/connector-agents.ts +++ b/src/main/external-conversations/connector-agents.ts @@ -4,11 +4,7 @@ import { type AgentStatusHooksSettings } from '../../shared/agent-status-hooks-setting' -/** - * The one list of agents a connector may start. Each needs hook status reporting (checked below) - * and a terminal that takes input mid-turn; no capability data records the latter for terminals - * yet, so membership here is where that fact lives. - */ +// Membership records mid-turn terminal input support until capability data covers it. export const CONNECTOR_AGENTS = ['claude', 'codex'] as const satisfies readonly ConnectorAgent[] export function admitConnectorAgent( diff --git a/src/main/external-conversations/external-conversation-chain.ts b/src/main/external-conversations/external-conversation-chain.ts index 3ede7a36bbe..a9b7ce065cd 100644 --- a/src/main/external-conversations/external-conversation-chain.ts +++ b/src/main/external-conversations/external-conversation-chain.ts @@ -7,16 +7,11 @@ import { type Lane = { controller: AbortController; signal: AbortSignal; tail: Promise } -/** Why an aborted conversation signal stopped: the seam's disable, or this conversation's cancel. */ export function refusalOf(signal: AbortSignal): ConnectorRefusal { return CONNECTOR_REFUSALS.find((reason) => reason === signal.reason) ?? 'connectors_disabled' } -/** - * Ordering per conversation: one active head, the rest behind it in arrival order. Disposal, or - * cancelling one conversation, settles everything still waiting at once and aborts the head's - * signal; the head keeps the outcome of what it already wrote. - */ +/** Serialize each conversation; cancellation aborts its head and settles queued work without undoing prior writes. */ export class ConversationChain { private readonly lanes = new Map() @@ -46,10 +41,7 @@ export class ConversationChain { return result } - /** - * Settles this conversation's running and queued work; later work starts a fresh lane. Given a - * lane's signal, only that lane is settled, never one opened after it. - */ + /** With laneSignal, cancel only that lane so a replacement lane remains untouched. */ cancel(id: string, reason: ConnectorRefusal, laneSignal?: AbortSignal): void { const lane = this.lanes.get(id) if (!lane || (laneSignal && lane.signal !== laneSignal)) { diff --git a/src/main/external-conversations/external-conversation-delivery-receipts.test.ts b/src/main/external-conversations/external-conversation-delivery-receipts.test.ts index 8b3153c6ac5..d63b3c7a76c 100644 --- a/src/main/external-conversations/external-conversation-delivery-receipts.test.ts +++ b/src/main/external-conversations/external-conversation-delivery-receipts.test.ts @@ -48,7 +48,7 @@ describe('DeliveryReceipts ledger capacity', () => { expect(db.countPendingMutationReceipts(['connector:dev'])).toBe(CONNECTOR_PENDING_RECEIPT_LIMIT) db.close() - // Process loss, then a restart that never starts connectors. + // Reopen without starting connectors to model a process restart before connector initialization. const reopened = new OrchestrationDb(path) expect(reopened.beginMutationReceipt(ORDINARY).disposition).toBe('started') reopened.close() @@ -88,7 +88,6 @@ describe('DeliveryReceipts ledger capacity', () => { expect( reopened.getMutationReceipt('connector:dev', JSON.stringify(['thread', '0']))?.state ).toBe('completed') - // The settled rows no longer hold the connector's budget. await expect(after.run(KEY, 'new', 'connector.send', ['hello'], execute)).resolves.toEqual({ outcome: 'accepted' }) diff --git a/src/main/external-conversations/external-conversation-delivery-receipts.ts b/src/main/external-conversations/external-conversation-delivery-receipts.ts index 9a727093bf2..55ee35a2202 100644 --- a/src/main/external-conversations/external-conversation-delivery-receipts.ts +++ b/src/main/external-conversations/external-conversation-delivery-receipts.ts @@ -17,19 +17,13 @@ const RecordedDelivery = z.discriminatedUnion('outcome', [ const LOST: Delivery = { outcome: 'unknown', reason: 'replayed_after_restart' } -/** Unsettled connector deliveries the shared ledger holds at once, so orchestration keeps the rest. */ +// Reserve only part of the shared ledger for unsettled connector deliveries. export const CONNECTOR_PENDING_RECEIPT_LIMIT = 1_000 const callerFingerprint = (source: ConnectorSource): string => `connector:${source}` const CONNECTOR_FINGERPRINTS = CONNECTOR_SOURCES.map(callerFingerprint) -/** - * A connector's delivery ids on orchestration's durable mutation receipts, under one caller - * identity per source: the same id and payload share one answer, across restarts too, and another - * payload is refused. A duplicate of one still running waits for it; a running one a restart - * interrupted reads `unknown`. Refusals and throws wrote nothing, so they leave no receipt. - * A new delivery past `CONNECTOR_PENDING_RECEIPT_LIMIT` unsettled ones is refused unrecorded. - */ +/** Deduplicate durable delivery IDs; interrupted writes remain unknown and refused deliveries leave no receipt. */ export class DeliveryReceipts { private readonly inFlight = new Map>() private leftoversSettled = false @@ -52,8 +46,7 @@ export class DeliveryReceipts { const flightKey = JSON.stringify([identity.callerFingerprint, identity.requestId]) const db = this.db() if (!this.leftoversSettled) { - // Pending rows from a previous process already answer only unknown; record that, never delete - // (they may have written), so the ledger can prune them and they leave the limit. + // Settle old pending rows as unknown; deleting them could repeat effects they already wrote. db.settlePendingMutationReceipts(CONNECTOR_FINGERPRINTS, JSON.stringify(LOST)) this.leftoversSettled = true } diff --git a/src/main/external-conversations/external-conversation-links.ts b/src/main/external-conversations/external-conversation-links.ts index 7fff389eec3..a66d5517c15 100644 --- a/src/main/external-conversations/external-conversation-links.ts +++ b/src/main/external-conversations/external-conversation-links.ts @@ -23,7 +23,6 @@ import type { ConversationKey } from './external-conversation-types' type LinkRecord = { externalConversations?: unknown } -/** The workspace records links live on; the runtime's store satisfies it. */ export type ExternalConversationLinkStore = WorkspaceRecordStore< LinkRecord, { externalConversations: ExternalConversationLink[] } @@ -42,7 +41,7 @@ const ExternalConversationLinkSchema = z.object({ externalId: z.string(), paneKey: z.string(), agent: z.enum(CONNECTOR_AGENTS), - // A malformed session drops alone: the link still delivers, it just cannot reopen. + // A malformed provider session prevents reopening, but must not discard a usable link. providerSession: z .unknown() .transform((raw) => normalizeAgentProviderSession(raw) ?? undefined) @@ -56,7 +55,7 @@ const ExternalConversationLinkSchema = z.object({ createdAt: z.number() }) -/** The workspace record's links; malformed entries (hand edits, a newer host's shape) are skipped. */ +/** Skip malformed links so hand edits or newer host shapes do not break reads. */ export function readLinks( store: ExternalConversationLinkStore, workspace: AgentPaneWorkspace @@ -77,7 +76,6 @@ function sameConversation(link: ExternalConversationLink, key: ConversationKey): return link.source === key.source && link.externalId === key.externalId } -/** Replaces this conversation's link on the workspace record, host-qualified like any meta write. */ export function writeLink( store: ExternalConversationLinkStore, workspace: AgentPaneWorkspace, @@ -85,17 +83,13 @@ export function writeLink( ): void { const others = readLinks(store, workspace).filter((existing) => !sameConversation(existing, link)) writeWorkspaceRecord(store, workspace, { externalConversations: [...others, link] }) - // The record writer skips a workspace whose host it cannot name rather than guess. + // The record writer refuses ambiguous hosts; verify that the link actually persisted. if (!readLinks(store, workspace).some((current) => current.paneKey === link.paneKey)) { throw new Error('external_conversation_link_unwritten') } } -/** - * Every persisted link, read from every host's canonical rows each time: the locator-keyed map - * keeps only one host's row per id, and a removed, renamed or re-adopted workspace is never - * answered from a cache. - */ +/** Read canonical rows on every host; the locator map can hide same-path workspaces on other hosts. */ export function* allLinks(store: ExternalConversationLinkStore): Generator { const hosts = new Set([ LOCAL_EXECUTION_HOST_ID, diff --git a/src/main/external-conversations/external-conversation-reopen.test.ts b/src/main/external-conversations/external-conversation-reopen.test.ts index 4ccc503ddd1..3979e9ffb90 100644 --- a/src/main/external-conversations/external-conversation-reopen.test.ts +++ b/src/main/external-conversations/external-conversation-reopen.test.ts @@ -78,7 +78,6 @@ describe('ExternalConversationSeam reopen', () => { expect(link()).toEqual([ expect.objectContaining({ paneKey: PANE, providerSession: SESSION, captureHostId: 'local' }) ]) - // The tab closes: its PTY and its hook rows go with it. terminal.exit() terminal.reopenable() expect(await seam.send(host, KEY, 'd1', 'what did you say?')).toEqual(REOPENED) @@ -99,7 +98,6 @@ describe('ExternalConversationSeam reopen', () => { }) ) expect(link()).toEqual([expect.objectContaining({ paneKey: REOPENED_PANE })]) - // The reopened agent is the conversation's from now on. expect(await seam.send(host, KEY, 'd2', 'next')).toEqual({ outcome: 'accepted' }) expect(terminal.resumeAgentSessionInWorkspace).toHaveBeenCalledTimes(1) }) @@ -168,7 +166,7 @@ describe('ExternalConversationSeam reopen', () => { const { seam, host, terminal, link } = await started(records, 'ssh:box') terminal.state.rows = [sessionRow(PANE, 'box')] expect(await seam.send(host, KEY, 'd1', 'one')).toEqual({ outcome: 'accepted' }) - // The relay drops: the PTY stays registered but cannot be written, and nothing proves an exit. + // A disconnected relay cannot prove the old process exited, so reopening remains unsafe. terminal.sendTerminalAgentPrompt.mockRejectedValueOnce(new Error('terminal_not_writable')) expect(await seam.send(host, KEY, 'd2', 'two')).toEqual(UNREACHABLE) expect(terminal.resumeAgentSessionInWorkspace).toHaveBeenCalledWith( @@ -193,7 +191,6 @@ describe('ExternalConversationSeam reopen', () => { expect(await head).toEqual({ outcome: 'accepted' }) expect(await follower).toEqual(UNREACHABLE) expect(terminal.resumeAgentSessionInWorkspace).not.toHaveBeenCalled() - // The next message opens a new lane, which reopens. expect(await seam.send(host, KEY, 'd3', 'three')).toEqual(REOPENED) expect(terminal.writes).toEqual(['one', 'three']) }) diff --git a/src/main/external-conversations/external-conversation-reopen.ts b/src/main/external-conversations/external-conversation-reopen.ts index dda0f8b8573..8217315d9e6 100644 --- a/src/main/external-conversations/external-conversation-reopen.ts +++ b/src/main/external-conversations/external-conversation-reopen.ts @@ -7,7 +7,6 @@ import { refusalOf } from './external-conversation-chain' import { bindPane, type FollowUpTerminal } from './external-conversation-send' import type { ConnectorRefusal } from './external-conversation-types' -/** What reopening reads and drives on the host: its hook rows, the safe resume, readiness. */ export type ReopenTerminal = Pick< OrcaRuntimeService, 'resumeAgentSessionInWorkspace' | 'getAgentStatusRowsForPane' @@ -17,17 +16,13 @@ export type ReopenTerminal = Pick< const REOPEN_REFUSALS: Record, ConnectorRefusal> = { window_owns_wake: 'agent_asleep_in_window', - // Why: the old pane may still run it (an SSH host out of contact included), so it is not reopened. + // An unreachable SSH host may still run the old pane, so reopening would risk duplicates. held_elsewhere: 'agent_not_reachable', no_resume_command: 'reopen_refused', create_failed: 'reopen_refused' } -/** - * Resumes the linked agent's own session in a fresh pane of its workspace, through the lifecycle's - * safe resume: it refuses while any pane may still hold the session, and never starts a shell or a - * fresh agent. Resolves once the resumed agent is ready for input. - */ +/** Resume only the linked session after proving it is unheld; never substitute a shell or fresh agent. */ export async function reopenLinkedAgent( terminal: ReopenTerminal, worktreeId: string, @@ -68,7 +63,7 @@ export async function reopenLinkedAgent( if (signal.aborted) { return { ok: false, refusal: refusalOf(signal) } } - // Why: a composer that never opened (a dialog, a dead process) must not take the message. + // A missing or unsatisfied composer must not accept the message. return ready === null || (ready && !ready.satisfied) ? { ok: false, refusal: 'agent_not_reachable' } : { ok: true, pane } diff --git a/src/main/external-conversations/external-conversation-seam.test-fixture.ts b/src/main/external-conversations/external-conversation-seam.test-fixture.ts index 8925c288c18..de703154373 100644 --- a/src/main/external-conversations/external-conversation-seam.test-fixture.ts +++ b/src/main/external-conversations/external-conversation-seam.test-fixture.ts @@ -7,7 +7,6 @@ import { ExternalConversationSeam } from './external-conversation-seam' import type { SessionRows } from './external-conversation-session-capture' import { fakeLinkStore } from './external-conversation.test-fixture' -/** A seam over fake records, receipts, workspace removals and hook session reports. */ export function harness( settings: Partial = {}, records = fakeLinkStore(), diff --git a/src/main/external-conversations/external-conversation-seam.test.ts b/src/main/external-conversations/external-conversation-seam.test.ts index 501e17fcd3c..af42dbb56a1 100644 --- a/src/main/external-conversations/external-conversation-seam.test.ts +++ b/src/main/external-conversations/external-conversation-seam.test.ts @@ -218,7 +218,7 @@ describe('ExternalConversationSeam delivery ids', () => { ).toEqual({ outcome: 'accepted' }) - // A start the host died during: its receipt is still pending. + // A pending receipt models process loss during the first start. db.beginMutationReceipt({ callerFingerprint: 'connector:dev', requestId: JSON.stringify(['thread-9', 'd1']), @@ -258,7 +258,6 @@ describe('ExternalConversationSeam links', () => { externalConversations: row(WORKTREE)?.externalConversations }) remove(WORKTREE) - // Started in this period, so a second start goes to the renamed workspace's pane. expect( (await seam.start({ launcher, terminal: fakeTerminal().terminal }, KEY, 'd2', START)).initial ).toEqual({ outcome: 'accepted' }) @@ -269,7 +268,7 @@ describe('ExternalConversationSeam links', () => { it('finds an SSH twin’s link that only its host’s row holds, after a restart too', async () => { const records = fakeLinkStore([LOCAL_REPO, { ...LOCAL_REPO, connectionId: 'box' }]) - // The local twin wrote this locator first, so the locator-keyed map keeps its row. + // The locator projection keeps the local twin; the SSH link exists only in canonical host rows. records.put(WORKTREE, 'local', {}) const { seam, restart, writes } = harness({}, records) const { launcher, launch } = fakeLauncher(async () => launchResult('handed-to-terminal'), { @@ -474,7 +473,6 @@ describe('ExternalConversationSeam send', () => { expect(await head).toEqual(gone) expect(await follower).toEqual(gone) expect(terminal.writes).toEqual([]) - // A later delivery opens a new lane on the process there now. expect(await seam.send(host, KEY, 'd3', 'three')).toEqual({ outcome: 'accepted' }) expect(terminal.writes).toEqual(['three']) }) @@ -590,7 +588,6 @@ describe('ExternalConversationSeam start-headed lanes', () => { expect((await start).initial).toEqual({ outcome: 'accepted' }) expect(await follower).toEqual(GONE) expect(terminal.writes).toEqual([]) - // A later delivery opens a new lane on the process there now. expect(await seam.send(host, KEY, 'd2', 'later')).toEqual({ outcome: 'accepted' }) expect(terminal.writes).toEqual(['later']) }) diff --git a/src/main/external-conversations/external-conversation-seam.ts b/src/main/external-conversations/external-conversation-seam.ts index ecc5eed2b7b..359f0c78591 100644 --- a/src/main/external-conversations/external-conversation-seam.ts +++ b/src/main/external-conversations/external-conversation-seam.ts @@ -34,7 +34,6 @@ import { type Delivery } from './external-conversation-types' -/** What the seam drives on its host: the launch path, and the linked terminals and their reopen. */ export type ExternalConversationHost = { launcher: ConversationLauncher terminal: ConversationTerminal @@ -49,13 +48,10 @@ export function runtimeConversationHost(runtime: OrcaRuntimeService): ExternalCo export type ExternalConversationSeamDeps = { store: ExternalConversationLinkStore readSettings: () => AgentStatusHooksSettings - /** Outlives the seam: a re-enable must not take this process's running deliveries for leftovers. */ + /** Re-enabling must not settle this process's running deliveries as abandoned leftovers. */ receipts: DeliveryReceipts - /** Worktree removals; returns the release. */ onWorkspaceRemoved: (listener: (worktreeId: string) => void) => () => void - /** The hook rows linked agents' sessions are read from. */ sessionRows: SessionRows - /** The hook store's provider sessions by pane, on every change; returns the release. */ onProviderSessionsChanged: ( listener: (sessions: readonly { paneKey: string; sessionId: string }[]) => void ) => () => void @@ -67,22 +63,14 @@ export type ExternalConversationSeamDeps = { type StartInput = Omit & { agent: string } -/** - * The connector seam for one enabled period. Turning connectors off disposes it: queued work - * settles `connectors_disabled` at once and running work stops at its next boundary. Turning them - * on builds a fresh one. - */ +/** One enabled period: disabling settles queued work and aborts running work at its next boundary. */ export class ExternalConversationSeam { private readonly abort = new AbortController() private readonly chain = new ConversationChain(this.abort.signal) - /** Conversations this period started, so a second start for one shares its creation. */ + // Only starts from this enabled period can prove their launch outcome. private readonly startedHere = new Set() - /** Conversations with work sent here, by the workspace their link was on. */ private readonly linked = new Map() - /** - * The process each lane admitted first, by a launch or a send; its queued work goes there or - * nowhere. Null: a launch published no process this lane can prove, so queued work goes nowhere. - */ + // Bind each lane to its first admitted process; null means no published process could be proven. private readonly admitted = new WeakMap() private readonly sessions: LinkedSessionCapture private readonly prompts: ConversationPrompts @@ -94,8 +82,7 @@ export class ExternalConversationSeam { constructor(private readonly deps: ExternalConversationSeamDeps) { this.sessions = new LinkedSessionCapture(deps.store, deps.sessionRows) this.prompts = new ConversationPrompts(deps.readSettings, deps.readHookRow) - // A removed workspace may keep its PTY registered a while; its held and queued sends end now. - // The event names no host, so a same-path twin's removal is told apart by the link surviving. + // Cancel removed-workspace lanes; surviving links distinguish same-path twins on other hosts. this.releaseRemovals = deps.onWorkspaceRemoved((worktreeId) => { for (const [id, { key, worktreeId: linkedTo }] of this.linked) { if (linkedTo === worktreeId && !findLink(this.deps.store, key)) { @@ -107,11 +94,10 @@ export class ExternalConversationSeam { this.releaseSessions = deps.onProviderSessionsChanged((sessions) => this.sessions.sessionsChanged(sessions) ) - // Subscribed first, so a session reported while the snapshot is read is not missed. + // Subscribe before snapshotting so sessions reported during restore cannot be missed. this.sessions.restore() } - /** Deferred work passes this to every effect, so nothing starts or writes after a disable. */ get signal(): AbortSignal { return this.abort.signal } @@ -190,7 +176,7 @@ export class ExternalConversationSeam { } const linked = findLink(this.deps.store, key) if (linked) { - // A link from before this period cannot say what became of its launch. + // A prior enabled period's link cannot prove the launch outcome. return this.startedHere.has(conversationId(key)) ? this.deliver(host.terminal, key, linked, deliveryId, input.prompt, signal) : { outcome: 'unknown', reason: 'launch_outcome_unknown' } @@ -206,7 +192,7 @@ export class ExternalConversationSeam { this.startedHere.add(conversationId(key)) this.sessions.capture(key, paneKey) }, - // Before link resolution or prompt delivery settle, so no follower adopts a successor. + // Capture publication before followers run so they cannot adopt a successor process. (paneKey) => { this.admitted.set( signal, @@ -253,7 +239,7 @@ export class ExternalConversationSeam { const linked = this.sessions.capture(key, found.link.paneKey) ?? found this.linked.set(id, { key, worktreeId: linked.workspace.worktreeId }) const pane = bindPane(terminal, linked.link.paneKey) - // A lane that already reached a process may only ever write to that one. + // Once a lane admits a process, queued writes must never reach a replacement. const laneAdmitted = this.admitted.has(signal) const admitted = laneAdmitted ? this.admitted.get(signal) : pane?.binding if (pane && admitted && sameBinding(pane.binding, admitted)) { @@ -263,17 +249,12 @@ export class ExternalConversationSeam { return delivery } } - // The admitted process is gone or replaced: queued work must not reach its successor. return laneAdmitted ? this.lost(id, signal) : this.reopen(terminal, key, linked, deliveryId, text, signal) } - /** - * The lane's first write found no agent: the lifecycle's safe resume reopens the conversation in - * its workspace, or refuses while the old pane may still hold it (an SSH host out of contact - * included). The reopened process becomes the lane's, so work queued behind follows it. - */ + /** A lane with no admitted process may safely resume the linked session; queued work then binds to that process. */ private async reopen( terminal: ConversationTerminal, key: ConversationKey, @@ -330,7 +311,7 @@ export class ExternalConversationSeam { }) } - /** Settles this conversation's queue before it advances; a later send opens a new lane. */ + /** Cancel the queue before advancing; later sends create a fresh lane. */ private lost( id: string, laneSignal: AbortSignal, diff --git a/src/main/external-conversations/external-conversation-send.ts b/src/main/external-conversations/external-conversation-send.ts index a9f0e3f099f..bf12f22c867 100644 --- a/src/main/external-conversations/external-conversation-send.ts +++ b/src/main/external-conversations/external-conversation-send.ts @@ -21,7 +21,6 @@ const UNREACHABLE = new Set([ 'agent_not_in_foreground' ]) -/** The linked pane's handle and the process it is bound to now; null once nothing is. */ export function bindPane( terminal: Pick< FollowUpTerminal, @@ -41,11 +40,7 @@ export function bindPane( } } -/** - * One follow-up through the shared writer: it waits out an approval on its own predicate, refuses - * a process other than `expectedBinding`, and stops on `signal`. A throw before - * any guarded write passed wrote nothing; after that it may have, so it is `unknown`. - */ +/** Failures after a write guard passes may have written bytes, so report unknown instead of allowing retry. */ export async function deliverFollowUp( terminal: FollowUpTerminal, followUp: { @@ -70,7 +65,7 @@ export async function deliverFollowUp( turn: { purpose: 'external-message', body: followUp.text, operationId: followUp.requestId }, beforeWrite: async (ptyId) => { await guard.beforeWrite(ptyId) - // The writer's own checks after this are synchronous, so an abort cannot land between. + // The writer checks synchronously afterward, so an abort cannot interleave before the write. if (!signal.aborted) { passes++ } diff --git a/src/main/external-conversations/external-conversation-session-capture.ts b/src/main/external-conversations/external-conversation-session-capture.ts index b7a96012e65..34e72af5001 100644 --- a/src/main/external-conversations/external-conversation-session-capture.ts +++ b/src/main/external-conversations/external-conversation-session-capture.ts @@ -21,7 +21,6 @@ export type SessionRows = Pick type CapturedSession = Required> -/** The linked agent's session as the pane's freshest hook row names it, with its reporting host. */ export function hookReportedSession( rows: SessionRows, link: Pick @@ -41,7 +40,7 @@ export function hookReportedSession( if (!freshest) { return null } - // Why: a WSL relay tag is local transport; only an SSH connection names another host. + // WSL relay tags are local transport; only SSH connection IDs identify remote hosts. const { connectionId } = freshest.row const captureHostId: ExecutionHostId = getConnectionExecutionHostId( isLocalHookConnectionId(connectionId) ? null : connectionId @@ -49,12 +48,8 @@ export function hookReportedSession( return { providerSession: freshest.session, captureHostId } } -/** - * Keeps each linked agent's session on its conversation link as hooks report it. A closed tab - * takes its hook rows with it, so the session has to be on the link before then for a reopen. - */ +/** Persist sessions before pane closure removes hook rows, so linked conversations can reopen. */ export class LinkedSessionCapture { - /** Linked panes, by pane key, with the session last seen there. */ private readonly watched = new Map() constructor( @@ -62,14 +57,13 @@ export class LinkedSessionCapture { private readonly rows: SessionRows ) {} - /** Takes up every persisted link, as a fresh seam must: their sessions may move before a send. */ + // Watch restored links before their sessions move, even if no send occurs. restore(): void { for (const { link } of allLinks(this.store)) { this.capture(link, link.paneKey) } } - /** The hook store's sessions changed; captures each watched pane whose session moved. */ sessionsChanged(sessions: readonly { paneKey: string; sessionId: string }[]): void { for (const { paneKey, sessionId } of sessions) { const watch = this.watched.get(paneKey) @@ -79,10 +73,6 @@ export class LinkedSessionCapture { } } - /** - * Copies the pane's hook-reported session onto its link when it changed, and watches the pane - * while the link names it; answers the link as it now stands. - */ capture(key: ConversationKey, paneKey: string): LinkedConversation | null { const linked = findLink(this.store, key) if (!linked || linked.link.paneKey !== paneKey) { diff --git a/src/main/external-conversations/external-conversation-start.ts b/src/main/external-conversations/external-conversation-start.ts index 04ae8be293c..7f5756ef715 100644 --- a/src/main/external-conversations/external-conversation-start.ts +++ b/src/main/external-conversations/external-conversation-start.ts @@ -25,14 +25,10 @@ export type ConversationLauncher = { onSurfacePublished: (surface: AgentLaunchPublishedSurface) => void, signal: AbortSignal ): Promise - /** The launched workspace as hook rows name it, qualified by the host that runs it. */ paneWorkspace(worktreeId: string): Promise } -/** - * `agent.launch`'s own executor and factories with no paired caller: a background terminal agent - * that takes the prompt on argv, in a workspace the target names or creates (worktree or folder). - */ +/** Reuse agent.launch so conversations support both git worktrees and folder workspaces. */ export function runtimeConversationLauncher(runtime: OrcaRuntimeService): ConversationLauncher { const context = { runtime } return { @@ -62,11 +58,7 @@ export function runtimeConversationLauncher(runtime: OrcaRuntimeService): Conver } } -/** - * Launches the conversation's agent and links its pane as soon as the surface is published. Only - * a failure before anything was published throws, so its delivery id may retry; after that an - * agent may exist, so any failure, the link's included, is `unknown` and never launches again. - */ +/** After surface publication, failures are unknown rather than retryable because an agent may already exist. */ export async function launchConversation( launcher: ConversationLauncher, input: ConversationLaunchInput, @@ -75,7 +67,7 @@ export async function launchConversation( /** Called synchronously at publication with the agent's pane, null when it has none. */ onPublished: (paneKey: string | null) => void ): Promise { - // Settled to its error, so a failed write waits for the launch instead of going unhandled. + // Settle link errors as values so they wait for launch instead of becoming unhandled rejections. const surface: { published: boolean; linked: Promise | null } = { published: false, linked: null @@ -100,7 +92,6 @@ export async function launchConversation( result = await launcher.launch(input, onSurfacePublished, signal) } catch (error) { if (!surface.published) { - // Turned off before the agent existed: the launch stopped before any effect. if (signal.aborted) { return refused('connectors_disabled') } diff --git a/src/main/external-conversations/external-conversation-types.ts b/src/main/external-conversations/external-conversation-types.ts index 5de3d88dbd1..e68fe0fa9e2 100644 --- a/src/main/external-conversations/external-conversation-types.ts +++ b/src/main/external-conversations/external-conversation-types.ts @@ -2,14 +2,12 @@ import type { ConnectorSource } from '../../shared/external-conversation-link' export type ConversationKey = { source: ConnectorSource; externalId: string } -/** Why the seam declined before any byte left it. */ export const CONNECTOR_REFUSALS = [ 'connectors_disabled', 'agent_not_reachable', 'unsupported_agent', 'delivery_id_reused', 'delivery_backlog_full', - // The three a closed agent's reopen ends on; each asks the user to open it in Orca. 'agent_asleep_in_window', 'no_session_to_reopen', 'reopen_refused', @@ -20,11 +18,7 @@ export const CONNECTOR_REFUSALS = [ ] as const export type ConnectorRefusal = (typeof CONNECTOR_REFUSALS)[number] -/** - * The connector's own answer, weaker than a `WriteSettlement`: `accepted` means the writer - * returned, never that the agent read it. `reopened`: the agent had exited or its tab was closed, - * so it was resumed in its workspace before the write. - */ +/** accepted confirms writer completion, not agent receipt; reopened marks a resumed session before delivery. */ export type Delivery = | { outcome: 'accepted'; reopened?: true } | { outcome: 'refused'; reason: ConnectorRefusal } @@ -39,7 +33,7 @@ export function refused(reason: ConnectorRefusal): Delivery { return { outcome: 'refused', reason } } -/** One string per conversation, unambiguous for any id text. */ +// Encode the pair structurally to avoid collisions when IDs contain delimiters. export function conversationId(key: ConversationKey): string { return JSON.stringify([key.source, key.externalId]) } diff --git a/src/main/external-conversations/external-conversation.test-fixture.ts b/src/main/external-conversations/external-conversation.test-fixture.ts index fc0ee9b8171..2e83f6e8b61 100644 --- a/src/main/external-conversations/external-conversation.test-fixture.ts +++ b/src/main/external-conversations/external-conversation.test-fixture.ts @@ -28,10 +28,7 @@ export const LOCAL_REPO: Repo = { type LinkRow = { externalConversations?: unknown; hostId?: ExecutionHostId } -/** - * The real store's two projections: each host's canonical rows, and the locator-keyed map that - * keeps only the first host to write an id. - */ +/** Model canonical host rows separately from the locator map that keeps the first same-path host. */ export function fakeLinkStore(repos: Repo[] = [LOCAL_REPO]) { const byHost = new Map>() const legacy: Record = {} @@ -101,7 +98,6 @@ export function launchResult( } } -/** Publishes, then settles as `settle` says; `workspace` is the pane's host-qualified workspace. */ export function fakeLauncher( settle: () => Promise, workspace: AgentPaneWorkspace = { worktreeId: WORKTREE, sshHostId: null }, @@ -130,7 +126,6 @@ type WriteOptions = { expectedBinding?: { ptyId: string; processIncarnation: string } } -/** A hook row naming the agent's session in `paneKey`, as the hook store reports it. */ export function sessionRow( paneKey: string, connectionId: string | null = null, @@ -148,12 +143,7 @@ export function sessionRow( } } -/** - * A terminal whose prompt writer keeps the shared writer's order: hold while blocked (under - * `wait`), then the binding fence, the guard, the signal, and the write. Its hook store names - * the session in the agent's pane while the agent runs; its safe resume refuses as a pane that - * may still hold the session does, until `reopenable` lets it resume into a fresh pane. - */ +/** Preserve writer ordering: approval wait, binding fence, guard, abort check, then write. */ export function fakeTerminal() { const state: { ptyId: string | null @@ -270,7 +260,6 @@ export function fakeTerminal() { sendTerminalAgentPrompt, resumeAgentSessionInWorkspace, waitForFreshWorkerComposer, - /** The safe resume starts the session in a fresh pane, as after a proven exit or a closed tab. */ reopenable() { resumeAgentSessionInWorkspace.mockImplementation(async () => { state.paneKey = REOPENED_PANE @@ -280,7 +269,6 @@ export function fakeTerminal() { }) }, - /** Holds waiting on the approval. */ held: () => waiters.size, answer() { state.blocked = false @@ -290,7 +278,7 @@ export function fakeTerminal() { state.ptyId = null ;[...waiters].forEach((settle) => settle(new Error('terminal_not_writable'))) }, - /** A new process in the same pane and PTY id; `answer` then hands held writes to it. */ + /** Replace the process without changing pane or PTY IDs, exercising the incarnation fence. */ replace() { state.incarnation += 1 } diff --git a/src/main/external-conversations/external-conversations-install.test.ts b/src/main/external-conversations/external-conversations-install.test.ts index 9d93bf2928f..f9047529e5d 100644 --- a/src/main/external-conversations/external-conversations-install.test.ts +++ b/src/main/external-conversations/external-conversations-install.test.ts @@ -63,7 +63,6 @@ describe('installExternalConversations', () => { const folders = vi.spyOn(store, 'getFolderWorkspaces') const rows = vi.spyOn(host, 'getAgentStatusRowsForPane') const connectors = installExternalConversations(store, host) - // Nothing is read until the host's first `current()`. expect(getSettings).not.toHaveBeenCalled() expect(connectors.current()).toBeNull() update({ experimentalConnectors: false }) @@ -174,7 +173,6 @@ describe('installExternalConversations', () => { return () => reports.delete(listener) } } - // What each runtime host does at startup, before any connector call. const connectors = installExternalConversations(store, host) connectors.current() const cleared = { ...SESSION, id: 'claude-session-after-clear' } diff --git a/src/main/external-conversations/external-conversations-install.ts b/src/main/external-conversations/external-conversations-install.ts index fb78d93821e..1e5de19650f 100644 --- a/src/main/external-conversations/external-conversations-install.ts +++ b/src/main/external-conversations/external-conversations-install.ts @@ -15,13 +15,7 @@ export type ExternalConversationStore = ExternalConversationLinkStore & { onSettingsChanged?: (listener: (updates: Partial) => void) => unknown } -/** - * Owns the seam's lifetime, on the settings-change pattern `installStructuredAgentAvailability` - * uses: one exists exactly while `experimentalConnectors` is on. The flag is - * first read on the first `current()`, which each runtime host makes at startup so an enabled - * seam watches its links from then; a change after that disposes or rebuilds the seam. The host is the - * authority, so every connector entry point asks it rather than its caller's profile. - */ +/** Host settings own seam lifetime; startup initializes it and later setting changes dispose or recreate it. */ export function installExternalConversations( store: ExternalConversationStore | null, runtime: { @@ -36,7 +30,7 @@ export function installExternalConversations( if (!store) { return { current: () => null } } - // One per process, so leftover settlement only reaches a previous process's receipts. + // Reuse receipts across enable periods so only previous-process deliveries count as leftovers. const receipts = new DeliveryReceipts(() => runtime.getOrchestrationDb()) let seam: ExternalConversationSeam | null = null let reconciled = false @@ -55,7 +49,7 @@ export function installExternalConversations( } }), sessionRows: runtime, - // A host without a hook store reports no sessions; reopen then refuses for want of one. + // Without hook session reports, reopening cannot prove which session to resume. onProviderSessionsChanged: (listener) => runtime.subscribeAgentProviderSessionChangesFn?.(listener) ?? (() => {}), readHookRow: (paneKey) => runtime.agentTransitions.latestRow(paneKey), diff --git a/src/main/foreign-sqlite-readers/cursor-profile-result.ts b/src/main/foreign-sqlite-readers/cursor-profile-result.ts index 2b875f19cec..9237d46f264 100644 --- a/src/main/foreign-sqlite-readers/cursor-profile-result.ts +++ b/src/main/foreign-sqlite-readers/cursor-profile-result.ts @@ -31,7 +31,7 @@ const resultSchema = z.discriminatedUnion('status', [ }) ]) -/** A worker reply arrives as a structured clone; null when it is not a profile read result. */ +// Validate structured-clone replies at the worker boundary. export function parseCursorProfileReadResult( value: unknown ): CursorDesktopProfileReadResult | null { diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts index ec28e6e4c25..69cb7d18652 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client-worker-lifecycle.test.ts @@ -4,8 +4,6 @@ import { afterEach, describe, expect, it } from 'vitest' import type { WorkerRequestTransport } from '../lazy-worker-thread-host' import { ForeignSqliteReaderClient } from './foreign-sqlite-reader-client' -// Ported from the dedicated Cursor worker's tests (#24572), against the shared client. - const FAILURE = { status: 'error', error: 'Unable to read the Cursor desktop login' } const MISSING = { status: 'missing' } const ANSWER_MISSING = `const { parentPort } = require('node:worker_threads'); @@ -14,7 +12,7 @@ const ANSWER_MISSING = `const { parentPort } = require('node:worker_threads'); const clients: ForeignSqliteReaderClient[] = [] afterEach(() => clients.splice(0).forEach((client) => client.dispose())) -// The host clears retirement a few microtasks after terminate() settles. +// Retirement clears a few microtasks after worker termination resolves. function afterRetirement(): Promise { return new Promise((resolve) => setImmediate(resolve)) } @@ -78,7 +76,6 @@ describe('ForeignSqliteReaderClient worker lifecycle', () => { expect(workers).toHaveLength(2) }) - // Every reader shares the lane, so each must keep the retirement gate. it.each([ { reader: 'cursorProfile', diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts index 91a25f7124a..318fc2b8c69 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.test.ts @@ -9,7 +9,6 @@ const OK = { profile: { accessToken: 't', email: null, membershipType: null, subscriptionStatus: null } } -// Records posted requests and lets a test drive message/exit without a built worker. class FakeWorker implements WorkerRequestTransport { posted: ForeignSqliteReaderRequest[] = [] terminated = false @@ -144,7 +143,7 @@ describe('ForeignSqliteReaderClient', () => { }, log: (message) => logs.push(message) }) - // A real path: a main-thread fallback would read it and answer `ok`. + // An existing path would expose an accidental main-thread fallback. await expect(client.readCursorProfile(__filename)).resolves.toEqual(FAILURE) expect(logs.join('\n')).toContain('entry not found') client.dispose() @@ -160,7 +159,6 @@ describe('ForeignSqliteReaderClient', () => { workers[0]?.reply(OK) await expect(Promise.all([first, second])).resolves.toEqual([OK, OK]) - // Settled reads are not cached: the next call reads again. const third = client.readCursorProfile('/a/state.vscdb') await settle() expect(workers[0]?.posted).toHaveLength(2) @@ -200,7 +198,6 @@ describe('ForeignSqliteReaderLane', () => { dbPath: '/quick.db' })) expect(workers).toHaveLength(2) - // The second reader answers while the first is still stuck on its database. workers[1]?.reply('quick') await expect(quick).resolves.toBe('quick') workers[0]?.reply('slow') @@ -222,7 +219,6 @@ describe('ForeignSqliteReaderLane', () => { idleTeardownMs: 30_000 } ) - // One active plus eight queued fill the lane; the tenth distinct read is refused. const reads = Array.from({ length: 10 }, (_, index) => lane.read(`/db-${index}`, (id) => ({ id, kind: 'cursorProfile', dbPath: `/db-${index}` })) ) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts index 854d3a06dcd..ad25bd300cd 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-client.ts @@ -17,24 +17,20 @@ import type { ForeignSqliteReaderResponse } from './foreign-sqlite-reader-protocol' -// Why: an open of another app's database can block for seconds on a large -wal, -// so it never runs on the main thread. Each reader gets its own lazily started -// thread (all from one factory) so a slow database delays only its own reader. +// Foreign SQLite opens can block on WAL rebuilds; isolate each reader so slow databases cannot stall main or peers. -// Limits from the dedicated Cursor worker this replaces (#24572). const READ_TIMEOUT_MS: Record = { - // Covers the 4-6 s WAL-index rebuild reported in #24360 with margin. + // Allow margin beyond observed 4–6s WAL-index rebuilds. cursorProfile: 10_000, openCodeBinderSessions: 60_000 } -// Why per reader: a thread torn down between a poller's rounds is respawned every round. +// Keep workers alive across poll intervals to avoid spawning on every round. const DEFAULT_IDLE_TEARDOWN_MS: Record = { cursorProfile: 30_000, - // The OpenCode binder polls every 60 s. + // The binder polls once per minute. openCodeBinderSessions: 120_000 } const MAX_CONSECUTIVE_DEATHS = 2 -// Reads are deduped per key, so a backlog past this is pile-up, not demand. const MAX_QUEUED_READS = 8 type LaneSettings = { @@ -44,7 +40,6 @@ type LaneSettings = { idleTeardownMs: number } -/** One reader's thread plus its in-flight reads, keyed by what they read. */ export class ForeignSqliteReaderLane { private readonly queue: WorkerThreadRequestQueue< ForeignSqliteReaderRequest, @@ -78,12 +73,7 @@ export class ForeignSqliteReaderLane { }) } - /** - * Read one database on this reader's thread. - * @param key - Concurrent reads with one key share a request, so it must cover every input the result depends on. - * @param buildRequest - Builds the request around the queue's correlation id. - * @returns The parsed value, or the reader's failure value if the worker cannot answer. - */ + /** Concurrent reads share a key, so it must encode every input that can change the result. */ read(key: string, buildRequest: (id: number) => ForeignSqliteReaderRequest): Promise { const pending = this.inFlight.get(key) if (pending) { @@ -116,7 +106,7 @@ export class ForeignSqliteReaderLane { } return value } catch (err) { - // Timeout, crash, or no worker: never retried on the main thread. + // A failed worker read must never fall back to blocking the main thread. settings.log(`Foreign SQLite reader ${kind} did not answer: ${errorText(err)}`) return this.failure() } @@ -153,28 +143,16 @@ export class ForeignSqliteReaderClient { ) } - /** - * Read the Cursor IDE's stored session off the main thread. - * @param dbPath - Cursor's state.vscdb. - * @returns The reader's result; its failure value when the worker cannot answer. - */ readCursorProfile(dbPath: string): Promise { - // The request is the path alone, so the path is the whole key. return this.cursorProfile.read(dbPath, (id) => ({ id, kind: 'cursorProfile', dbPath })) } - /** - * List OpenCode 1 sessions newer than `cursor` off the main thread. - * @param dbPath - The shared server's opencode.db. - * @param cursor - Store position the binder has handled up to. - * @returns Rows oldest first; `[]` when the store or the worker cannot answer. - */ + /** Return oldest-first sessions after cursor, or [] if the worker cannot answer. */ readOpenCodeBinderSessions( dbPath: string, cursor: OpenCodeSessionCursor ): Promise { - // Why the cursor in the key: a round from before a stop can still be in flight - // with an older cursor, and its rows are not the answer for a restarted round. + // A restarted poll must not deduplicate against an older cursor still in flight. const key = JSON.stringify([dbPath, cursor.ms, cursor.id]) return this.openCodeBinderSessions.read(key, (id) => ({ id, diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts index 0d25bca0a53..19b7e35b514 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.test.ts @@ -38,7 +38,6 @@ describe('handleForeignSqliteReaderRequest', () => { }) it('rejects a kind no reader owns instead of running one', () => { - // Parsed, as a structured clone arrives: untyped, with a kind outside the union. const request = JSON.parse('{"id":9,"kind":"list","dbPaths":[]}') expect(handleForeignSqliteReaderRequest(request)).toEqual({ id: 9, diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts index c1e5b420d6d..3e7bd6abb55 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts @@ -5,15 +5,11 @@ import type { import { readCursorProfile } from './readers/cursor-profile' import { readOpenCodeBinderSessions } from './readers/opencode-binder-sessions' -/** - * Run one foreign-app SQLite read on the worker thread. - * @param request - A structured clone from the main process; its kind is untrusted. - * @returns The reader's value, or an error naming a kind no reader owns. - */ +/** Structured-clone requests may carry unknown kinds despite the static type. */ export function handleForeignSqliteReaderRequest( request: ForeignSqliteReaderRequest ): ForeignSqliteReaderResponse { - // Destructured from the parameter so `kind` narrows `request` and ends as `never`. + // Destructuring here preserves request narrowing through kind and exhaustiveness at never. const { id, kind } = request try { switch (kind) { @@ -22,14 +18,13 @@ export function handleForeignSqliteReaderRequest( case 'openCodeBinderSessions': return { id, ok: true, value: readOpenCodeBinderSessions(request.dbPath, request.cursor) } } - // A structured clone can carry any kind; one without a reader is refused, not guessed at. return unknownKind(id, kind) } catch (err) { return { id, ok: false, error: err instanceof Error ? err.message : String(err) } } } -// Why `never`: adding a request kind without a case here fails to compile. +// never makes new unhandled request kinds a compile error. function unknownKind(id: number, kind: never): ForeignSqliteReaderResponse { return { id, ok: false, error: `Unknown foreign SQLite reader kind: ${String(kind)}` } } diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.ts index 68649f03a79..8e4ed7499be 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry-path.ts @@ -6,26 +6,16 @@ import { resolveWorkerThreadEntryPath, type WorkerEntryLayout } from '../worker- export const FOREIGN_SQLITE_READER_ENTRY_FILENAME = 'foreign-sqlite-reader-entry.js' -/** - * Find the built entry beside a bundle that runs straight off disk. - * @param runtimeDir - The calling module's `__dirname`. - * @returns The first existing candidate, else the adjacent path so the miss names it. - */ export function findForeignSqliteReaderEntry( runtimeDir: string, pathExists: (path: string) => boolean = existsSync ): string { const adjacent = join(runtimeDir, FOREIGN_SQLITE_READER_ENTRY_FILENAME) - // Rollup factors shared callers into out/main/chunks; worker entries stay in out/main. + // Shared chunks sit one directory below the built worker entries. const parent = join(runtimeDir, '..', FOREIGN_SQLITE_READER_ENTRY_FILENAME) return pathExists(adjacent) || !pathExists(parent) ? adjacent : parent } -/** - * Resolve the entry for an Electron main-process caller. - * @param layout - Packaged flag, resources root, and the caller's `__dirname`. - * @returns The packaged app.asar path, or the on-disk build path. - */ export function resolveForeignSqliteReaderEntryPath( layout: WorkerEntryLayout, pathExists: (path: string) => boolean = existsSync diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts index 0bd9f5e745a..00976398c60 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts @@ -7,8 +7,7 @@ import type { ForeignSqliteReaderResponse } from './foreign-sqlite-reader-protocol' -// Why two dispatches: the OpenCode one is also bundled into the SSH/WSL relay -// reader, so the other readers stay out of it and ship only in this entry. +// Keep OpenCode dispatch separate because SSH/WSL relays bundle it without the other readers. if (!parentPort) { throw new Error('Foreign SQLite reader worker must run with a parent port.') @@ -38,8 +37,7 @@ port.on('message', (request: ReaderRequest) => { try { port.postMessage(response) } catch { - // A non-cloneable result would otherwise post nothing and leave the client - // waiting out its timeout; fail that request fast instead. + // Reply with a serialization failure instead of leaving the client waiting for its timeout. port.postMessage({ id: request.id, ok: false, diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts index bebb7dd155e..572fb4fcead 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts @@ -8,14 +8,7 @@ import { Worker } from 'node:worker_threads' import SyncDatabase from '../sqlite/sync-database' import { ForeignSqliteReaderClient } from './foreign-sqlite-reader-client' -// Why: the reported freeze is a sole opener rebuilding the WAL index of a huge -// un-checkpointed -wal. The fixture reproduces that shape at CI size; a small -// page size multiplies the frames per MB so tens of MB block measurably. -// -// Both arms read identical copies and are compared against each other, not a -// fixed threshold: the inline arm (the pre-worker main-thread read) establishes -// how long the open blocks, and the worker arm must leave the calling thread's -// timer on time and its event loop mostly idle meanwhile. +// Compare identical large-WAL opens inline and in a worker; relative timings avoid fixed runner-speed assumptions. const PAGE_SIZE = 512 const TRANSACTIONS = 2_400 @@ -31,11 +24,7 @@ function stateDbPath(name: string): string { return join(root, `${name}.vscdb`) } -/** - * Leave a WAL with no -shm beside it. A read-only connection that closes last - * cannot checkpoint, so the writer's frames stay in the -wal; deleting the -shm - * then forces the next opener to rebuild the index from every frame. - */ +/** Leave WAL frames but remove shm so the next opener must rebuild the index. */ function writeLargeWalFixture(): void { const path = stateDbPath('worker') const writer = new SyncDatabase(path) @@ -76,7 +65,6 @@ function readTokenInline(path: string): string | null { type Measured = { value: T; activeMs: number; wallMs: number; maxTimerLatenessMs: number } -/** Run `fn` while a main-thread timer ticks, recording its worst lateness. */ async function measure(fn: () => Promise): Promise> { let maxTimerLatenessMs = 0 let expectedAt = performance.now() + TIMER_INTERVAL_MS @@ -88,12 +76,12 @@ async function measure(fn: () => Promise): Promise> { timer = setTimeout(tick, TIMER_INTERVAL_MS) } timer = setTimeout(tick, TIMER_INTERVAL_MS) - // Let the timer arm before the read starts, so an inline block is seen as lateness. + // Arm the timer before opening so inline stalls register as lateness. await new Promise((done) => setTimeout(done, TIMER_INTERVAL_MS * 2)) const before = performance.eventLoopUtilization() const startedAt = performance.now() const value = await fn() - // One more tick so a block that ended just now is observed. + // Let a post-read tick observe any stall that just ended. await new Promise((done) => setTimeout(done, TIMER_INTERVAL_MS * 2)) const wallMs = performance.now() - startedAt const activeMs = performance.eventLoopUtilization(before).active @@ -111,8 +99,7 @@ beforeAll(async () => { root = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-event-loop-')) writeLargeWalFixture() workerEntryPath = join(root, 'foreign-sqlite-reader-entry.cjs') - // Why bundle here: `new Worker` needs JavaScript, and the production entry is - // emitted by the app build. Bundling the same source runs the real reader. + // Bundle the production reader source because Worker requires emitted JavaScript. await build({ entryPoints: [resolve(__dirname, 'foreign-sqlite-reader-entry.ts')], outfile: workerEntryPath, @@ -131,7 +118,7 @@ afterAll(() => { describe('foreign SQLite reader event-loop occupancy', () => { it('keeps the calling thread responsive while a large-WAL open blocks the worker', async () => { - // Presence precondition: a fixture whose WAL was checkpointed away would make both arms fast. + // A checkpointed-away WAL would make the test pass without exercising a slow open. expect(statSync(`${stateDbPath('worker')}-wal`).size).toBeGreaterThan(MIN_WAL_BYTES) expect(statSync(`${stateDbPath('inline')}-wal`).size).toBeGreaterThan(MIN_WAL_BYTES) @@ -140,7 +127,7 @@ describe('foreign SQLite reader event-loop occupancy', () => { log: () => {} }) try { - // Warm the thread first so the measured arm is the read, not the spawn. + // Exclude worker startup from the measured database-open interval. await expect(client.readCursorProfile(join(root, 'absent.vscdb'))).resolves.toEqual({ status: 'missing' }) @@ -151,7 +138,6 @@ describe('foreign SQLite reader event-loop occupancy', () => { expect(inline.value).toBe(TOKEN) const report = `${describeArm('worker', worker)}; ${describeArm('inline', inline)}` - // The worker arm waited on the open, so it took about as long as the inline one. expect(worker.wallMs, report).toBeGreaterThan(inline.activeMs / 4) expect(worker.activeMs, report).toBeLessThan(inline.activeMs / 5) expect(worker.maxTimerLatenessMs, report).toBeLessThan(inline.maxTimerLatenessMs / 2) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts index 2dc35084356..f74d029858a 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-protocol.ts @@ -1,4 +1,4 @@ -// Type-only and electron-free: the worker entry and the main-process client both import it. +// Keep this Electron-free so both worker and main-process clients can import it. import type { OpenCodeSessionCursor } from './opencode-binder-sessions-result' diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts index 043a317130d..5ee4184205a 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-spawn.ts @@ -7,12 +7,8 @@ import { ForeignSqliteReaderClient } from './foreign-sqlite-reader-client' import { resolveForeignSqliteReaderEntryPath } from './foreign-sqlite-reader-entry-path' import type { BinderSessionRow, OpenCodeSessionCursor } from './opencode-binder-sessions-result' -// Why: owns the process-wide client and the real worker factory, so the client -// class stays testable with a fake factory and callers see only plain functions. - function defaultWorkerFactory(): Worker { const workerPath = resolveForeignSqliteReaderEntryPath(currentWorkerEntryLayout(__dirname)) - // A missing entry (e.g. a host whose build omits it) throws here so reads fail closed. if (!existsSync(workerPath)) { throw new Error(`Foreign SQLite reader entry not found: ${workerPath}`) } @@ -26,27 +22,17 @@ function getSharedClient(): ForeignSqliteReaderClient { return sharedClient } -/** - * Read the Cursor IDE's stored session on the foreign SQLite reader worker. - * @param dbPath - Cursor's state.vscdb. - * @returns `missing`, `ok`, or `error` (also when the worker cannot answer). - */ export function readCursorDesktopProfile(dbPath: string): Promise { return getSharedClient().readCursorProfile(dbPath) } -/** - * List OpenCode 1 sessions newer than `cursor` on the foreign SQLite reader worker. - * @param dbPath - The shared server's opencode.db. - * @param cursor - Store position the binder has handled up to. - * @returns Rows oldest first; `[]` when the store or the worker cannot answer. - */ +/** Return oldest-first rows after cursor; unreadable or unavailable readers answer []. */ export async function readOpenCodeBinderSessions( dbPath: string, cursor: OpenCodeSessionCursor ): Promise { try { - // Missing stores must not keep an otherwise unused reader worker alive on every binder poll. + // Skip absent stores before waking a worker on every binder poll. await stat(dbPath) } catch (error) { if ( @@ -56,7 +42,7 @@ export async function readOpenCodeBinderSessions( ) { return [] } - // The worker still owns reporting other failures and checking for changes after this probe. + // Other stat failures still go through worker error reporting and its fresh open. } return getSharedClient().readOpenCodeBinderSessions(dbPath, cursor) } diff --git a/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts b/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts index 9a27f89f2ea..80c374e1898 100644 --- a/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts +++ b/src/main/foreign-sqlite-readers/opencode-binder-sessions-result.ts @@ -1,6 +1,5 @@ import { z } from 'zod' -/** One session store row feeding a binder round. */ export type BinderSessionRow = { id: string directory: string @@ -8,13 +7,12 @@ export type BinderSessionRow = { parentId: string | null } -/** Position in the session store; composite so same-millisecond rows are never skipped. */ +/** Include ID so a page boundary cannot skip rows sharing a millisecond. */ export type OpenCodeSessionCursor = { ms: number id: string } -/** No sessions: what the binder read has always answered when the store can't be read. */ export function openCodeBinderSessionsFailure(): BinderSessionRow[] { return [] } @@ -28,7 +26,7 @@ const rowsSchema = z.array( }) ) -/** A worker reply arrives as a structured clone; null when it is not a list of session rows. */ +// Validate structured-clone replies at the worker boundary. export function parseOpenCodeBinderSessions(value: unknown): BinderSessionRow[] | null { const parsed = rowsSchema.safeParse(value) return parsed.success ? parsed.data : null diff --git a/src/main/foreign-sqlite-readers/readers/cursor-profile.ts b/src/main/foreign-sqlite-readers/readers/cursor-profile.ts index d28f02136fa..7df665adb77 100644 --- a/src/main/foreign-sqlite-readers/readers/cursor-profile.ts +++ b/src/main/foreign-sqlite-readers/readers/cursor-profile.ts @@ -24,10 +24,7 @@ function valueAsString(value: unknown): string | null { return null } -/** - * Reads the Cursor IDE's stored session. Opens read-only in place; state.vscdb can be multi-GB - * and its -wal can be too, so this runs only on the foreign SQLite reader worker. - */ +/** Open only in a worker: the database and its WAL can each be multiple gigabytes. */ export function readCursorProfile(dbPath: string): CursorDesktopProfileReadResult { if (!existsSync(dbPath)) { return { status: 'missing' } diff --git a/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts index 83396ff02d9..56fd1291073 100644 --- a/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts +++ b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.test.ts @@ -74,7 +74,6 @@ describe('readOpenCodeBinderSessions', () => { expect(readOpenCodeBinderSessions(dbPath, START)).toEqual([]) }) - // #24577: an absent store is normal, so it answers [] and the client logs nothing. it('reads a missing database as empty and detects it once it appears', () => { expect(readOpenCodeBinderSessions(join(dir, 'later.db'), START)).toEqual([]) expect(readOpenCodeBinderSessions(join(dir, 'missing', 'opencode.db'), START)).toEqual([]) diff --git a/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts index 817e9469f8e..e12d468e074 100644 --- a/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts +++ b/src/main/foreign-sqlite-readers/readers/opencode-binder-sessions.ts @@ -3,13 +3,7 @@ import { readOpenCodeDatabase } from '../../ai-vault/session-scanner-opencode-sq import { columnExists, tableExists } from '../../opencode-usage/schema-helpers' import type { BinderSessionRow, OpenCodeSessionCursor } from '../opencode-binder-sessions-result' -/** - * Sessions newer than `cursor`, oldest first. The composite `(time_created, id)` - * position re-lists rows sharing a millisecond with the cursor, including rows - * the LIMIT cut off last round. Unknown shapes read as empty so an OpenCode - * schema move degrades to unbound sessions. An absent store is empty (#24577); - * any other failed open throws, so the client logs it. - */ +/** Composite cursor pagination preserves same-millisecond rows; unknown schemas or absent stores return empty. */ export function readOpenCodeBinderSessions( dbPath: string, cursor: OpenCodeSessionCursor @@ -20,8 +14,7 @@ export function readOpenCodeBinderSessions( return readOpenCodeDatabase({ dbPath, read: (db) => { - // Why `session` only: OpenCode 1 writes it; OpenCode 2 writes `session_v2`, and its - // posts name their own pane, so its sessions must never bind. + // Exclude OpenCode 2 session_v2 rows: its posts already identify the pane and must not bind again. const table = 'session' if ( !tableExists(db, table) || @@ -62,7 +55,7 @@ export function readOpenCodeBinderSessions( }) } -// Why stat, not existsSync: a permission failure must surface, not read as an unused store. +// stat surfaces permission errors that existsSync would confuse with an absent store. function storeExists(dbPath: string): boolean { try { statSync(dbPath) diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index 099403d35e2..12d30962d3c 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -111,7 +111,7 @@ describe('GeminiHookService', () => { expect(config.hooks.BeforeTool[0].hooks[0].command).toMatch(managedCommandPattern) }) - // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under Gemini's shell. + // Shell execution is covered separately by windows-hook-payload-delivery.test.ts. it('registers the Windows batch hook command for every event', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') try { diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index df640661a66..a51fb0d066a 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -29,8 +29,8 @@ import { buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' -// Why: Gemini has no permission-prompt hook (approvals are inline UI), so Orca can't show a waiting state — upstream limitation. -// Why: Gemini's pre-tool event is BeforeTool, not Claude/Codex's PreToolUse; sweep stale PreToolUse entries below. +// Gemini has no permission-prompt hook, so approvals cannot produce an Orca waiting state. +// Gemini uses BeforeTool; remove stale Claude-style PreToolUse registrations below. const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool'] as const function getConfigPath(): string { @@ -56,9 +56,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early. + // Reply before guards can exit so Gemini always receives valid hook JSON. 'echo {}', - // Why: source the endpoint file so a surviving PTY reaches the current server. See claude/hook-service.ts. + // Refresh endpoint credentials for PTYs that outlive an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('gemini'), @@ -70,11 +70,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', - // Why: emit `{}` first so Gemini never stalls parsing stdout, even if the guards below exit early. + // Reply before guards can exit so Gemini always receives valid hook JSON. 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), ...buildPosixHookSpoolLines('gemini'), - // Why: source refreshes endpoint coords so a PTY surviving an Orca restart keeps reporting. See claude/hook-service.ts. + // Refresh endpoint credentials for PTYs that outlive an Orca restart. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', @@ -82,8 +82,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' spool_hook_event', ' exit 0', 'fi', - // Why: worktreeId embeds a path, so post form fields, not hand-built JSON that breaks on quotes/newlines. - // Why: pipe payload via curl stdin (`payload@-`) so large tool output stays off the command line (EDR false positives). + // Form encoding handles quotes/newlines in paths; stdin keeps large payloads off the command line. 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -166,12 +165,11 @@ export class GeminiHookService { const command = getManagedCommand(scriptPath) const nextHooks = { ...config.hooks } - // Why: match by filename not exact command, so installs sweep stale entries instead of duplicating them. + // Match filenames so moved scripts replace stale registrations rather than duplicating them. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) const managedEvents = new Set(GEMINI_EVENTS) - // Why: sweep managed entries from dropped event buckets so stale hooks (e.g. PreToolUse) don't keep firing. for (const [eventName, definitions] of Object.entries(nextHooks)) { if (managedEvents.has(eventName)) { continue @@ -191,7 +189,7 @@ export class GeminiHookService { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) const definition: HookDefinition = { - // Why: Gemini's hook `timeout` unit is milliseconds, unlike Claude/Codex. + // Gemini hook timeouts use milliseconds. hooks: [buildManagedCommandHook(command, MANAGED_HOOK_TIMEOUT_MILLISECONDS)] } nextHooks[eventName] = [...cleaned, definition] @@ -203,7 +201,7 @@ export class GeminiHookService { return this.getStatus() } - // POSIX-only remote install mirroring ClaudeHookService.installRemote; the managed script/JSON shape must match local install() or remote panes report a different status. + // Remote hooks use POSIX paths and the same event/config contract as local installation. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.gemini/settings.json` const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/gemini-hook.sh` @@ -224,7 +222,6 @@ export class GeminiHookService { const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh') const managedEvents = new Set(GEMINI_EVENTS) - // Why: sweep legacy managed event buckets so stale PreToolUse stops warning in SSH Gemini sessions. for (const [eventName, definitions] of Object.entries(nextHooks)) { if (managedEvents.has(eventName)) { continue @@ -244,15 +241,15 @@ export class GeminiHookService { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) const definition: HookDefinition = { - // Why: Gemini's hook `timeout` unit is milliseconds, unlike Claude/Codex. + // Gemini hook timeouts use milliseconds. hooks: [buildManagedCommandHook(command, MANAGED_HOOK_TIMEOUT_MILLISECONDS)] } nextHooks[eventName] = [...cleaned, definition] } config.hooks = nextHooks - // Why: write the script before settings.json so an interrupted install never points at a missing script. - // Why: SSH remotes always use POSIX `.sh` paths even when Orca runs on Windows. + // Publish scripts before config so interrupted installs cannot reference missing files. + // Remote hook syntax is POSIX regardless of the client platform. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, config) @@ -288,10 +285,8 @@ export class GeminiHookService { } const nextHooks = { ...config.hooks } - // Why: match by filename so remove() sweeps stale entries even after the script path moved. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) for (const [eventName, definitions] of Object.entries(nextHooks)) { - // Why: fail open on malformed (non-array) entries so a broken user config never blocks uninstall. if (!Array.isArray(definitions)) { continue } diff --git a/src/main/ghostty/discovery.test.ts b/src/main/ghostty/discovery.test.ts index cd125480c54..9049e87b767 100644 --- a/src/main/ghostty/discovery.test.ts +++ b/src/main/ghostty/discovery.test.ts @@ -22,7 +22,6 @@ function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } -// Why: Capture original env values once so we can restore them after every test. const originalXdgConfigHome = process.env.XDG_CONFIG_HOME const originalAppData = process.env.APPDATA diff --git a/src/main/ghostty/discovery.ts b/src/main/ghostty/discovery.ts index 8c9675381c9..487bbf1cf20 100644 --- a/src/main/ghostty/discovery.ts +++ b/src/main/ghostty/discovery.ts @@ -2,7 +2,7 @@ import { homedir, platform } from 'node:os' import path from 'node:path' import { stat } from 'node:fs/promises' -// Why: Ghostty honors XDG before native macOS paths; we replicate that precedence. +// Ghostty gives XDG paths priority over native macOS paths. function xdgConfigDirs(home: string): string[] { if (process.env.XDG_CONFIG_HOME) { return [path.posix.join(process.env.XDG_CONFIG_HOME, 'ghostty')] @@ -10,8 +10,7 @@ function xdgConfigDirs(home: string): string[] { return [path.posix.join(home, '.config', 'ghostty')] } -// Why: Ghostty loads the modern filename before the legacy `config` fallback, -// and later files in this order override earlier files. +// Load config.ghostty before legacy config; later files override earlier ones. function withFilenames(dirs: string[]): string[] { return dirs.flatMap((dir) => [ path.posix.join(dir, 'config.ghostty'), @@ -26,7 +25,6 @@ export function getGhosttyConfigPaths(): string[] { switch (plat) { case 'darwin': { const dirs = xdgConfigDirs(home) - // Why: Native macOS path is the final fallback after XDG candidates. dirs.push(path.posix.join(home, 'Library', 'Application Support', 'com.mitchellh.ghostty')) return withFilenames(dirs) } @@ -36,7 +34,6 @@ export function getGhosttyConfigPaths(): string[] { case 'win32': { const appData = process.env.APPDATA || home const base = path.win32.join(appData, 'ghostty') - // Why: path.win32.join preserves backslashes even when tests run on macOS/Linux. return [path.win32.join(base, 'config.ghostty'), path.win32.join(base, 'config')] } case 'aix': @@ -60,7 +57,7 @@ export async function findGhosttyConfigPaths(): Promise { found.push(p) } } catch { - // ENOENT or permission error — continue probing other paths. + // Unreadable candidates must not prevent probing the remaining locations. } } return found diff --git a/src/main/ghostty/index.test.ts b/src/main/ghostty/index.test.ts index 9247cb450c6..ebc1d52e9e3 100644 --- a/src/main/ghostty/index.test.ts +++ b/src/main/ghostty/index.test.ts @@ -272,7 +272,6 @@ background = #1a1a1a }) readFileMock.mockResolvedValue('font-family = JetBrains Mono\n') - // Why: Replace timer globals temporarily to detect any polling setup. const originalSetInterval = globalThis.setInterval const originalSetTimeout = globalThis.setTimeout globalThis.setInterval = setIntervalMock as unknown as typeof setInterval diff --git a/src/main/ghostty/index.ts b/src/main/ghostty/index.ts index e6e041aacfb..878267a61b8 100644 --- a/src/main/ghostty/index.ts +++ b/src/main/ghostty/index.ts @@ -7,18 +7,10 @@ import { parseGhosttyConfig } from './parser' import { mapGhosttyToOrca } from './mapper' import { resolveGhosttyThemeColors } from './theme-resolution' -// Why: defensive upper bound on the Ghostty config size we're willing to read -// into memory on the main process. Real configs are a few KB; anything past -// this is almost certainly a symlink mistake or a pathological file and we -// would rather surface an error than OOM the main process. +// Bound config reads to avoid loading arbitrarily large files into main. const MAX_CONFIG_BYTES = 1_000_000 -// Why: mapGhosttyToOrca creates new object instances for nested values like -// terminalColorOverrides. A reference comparison (!==) would always report -// them as changed even when the contents are identical. The stringifier sorts -// keys at every object depth so persisted settings whose storage preserves a -// different key order compare equal. Arrays keep their ordering (meaningful -// for list-valued settings). +// Compare nested values structurally with sorted keys while preserving meaningful array order. function stableStringify(v: unknown): string { if (v === null || typeof v !== 'object') { return JSON.stringify(v) @@ -69,10 +61,7 @@ function usesConditionalThemePair(themeName: string): boolean { .some((part) => part.startsWith('light:') || part.startsWith('dark:')) } -// Why: `theme = ` is how most Ghostty configs pick their colors, so -// dropping it silently loses the entire palette. Resolve the referenced theme -// file and merge its colors as defaults: explicit config keys win, and config -// palette entries are appended after the theme's so per-index overrides apply. +// Theme colors are defaults; explicit config colors and palette entries take precedence. async function applyThemeReference(parsed: Record): Promise { const rawTheme = parsed['theme'] if (rawTheme === undefined) { diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts index 402e9253f21..8f313cdf852 100644 --- a/src/main/ghostty/mapper-extended.test.ts +++ b/src/main/ghostty/mapper-extended.test.ts @@ -48,10 +48,7 @@ describe('mapGhosttyToOrca — unfocused-split-opacity', () => { }) describe('mapGhosttyToOrca — scrollback-limit', () => { - // Why: Ghostty's scrollback-limit is a byte budget (where 0 means unlimited), - // while xterm's scrollback is a row count (where 0 means disabled). The - // units and sentinel values don't line up, so we treat the key as - // unsupported rather than silently mis-applying it by orders of magnitude. + // Ghostty budgets bytes (0 unlimited); xterm counts rows (0 disabled), so values cannot map safely. it('marks scrollback-limit as unsupported', () => { const result = mapGhosttyToOrca({ 'scrollback-limit': '50000' }) expect(result.diff).toEqual({}) @@ -200,8 +197,6 @@ describe('mapGhosttyToOrca — cursor-text', () => { }) describe('mapGhosttyToOrca — bold-color', () => { - // Why: xterm.js ITheme has no bold color slot (xtermjs/xterm.js#6032), so bold-color can never - // render; the importer must list it as unsupported rather than claim it was applied (#8595). it('reports valid bold-color as unsupported and does not apply it', () => { const result = mapGhosttyToOrca({ 'bold-color': '#ff0000' }) expect(result.diff).toEqual({}) diff --git a/src/main/ghostty/mapper.test.ts b/src/main/ghostty/mapper.test.ts index c8076db63ff..71391fda4c9 100644 --- a/src/main/ghostty/mapper.test.ts +++ b/src/main/ghostty/mapper.test.ts @@ -171,8 +171,6 @@ describe('mapGhosttyToOrca — background & colors', () => { }) it('maps background-blur-radius = 0 to windowBackgroundBlur false without a drop note', () => { - // Why: 0 means blur is off — there is no radius value being lost, so the - // "radius not preserved" note would be misleading here. const result = mapGhosttyToOrca({ 'background-blur-radius': '0' }) expect(result.diff).toEqual({ windowBackgroundBlur: false }) expect(result.unsupportedKeys).toEqual([]) @@ -281,9 +279,7 @@ describe('mapGhosttyToOrca — palette', () => { }) describe('mapGhosttyToOrca — window & padding', () => { - // Why: window-padding-color and window-padding-balance are not imported — - // the CSS vars Orca sets for them have no consuming rules today, so the - // mapper must treat the keys as unsupported rather than silently dropping. + // These window-padding variables have no consuming CSS rules, so importing them would be a silent no-op. it('marks window-padding-color as unsupported', () => { const result = mapGhosttyToOrca({ 'window-padding-color': '#202020' }) expect(result.diff).toEqual({}) diff --git a/src/main/ghostty/mapper.ts b/src/main/ghostty/mapper.ts index ad8b8d050c8..0ae8163fbdf 100644 --- a/src/main/ghostty/mapper.ts +++ b/src/main/ghostty/mapper.ts @@ -172,9 +172,7 @@ export function mapGhosttyToOrca( return { key: 'terminalPaddingY', value: num } }, - // Why: only percentages translate to xterm's line-height ratio; Ghostty's - // pixel form depends on the rendered cell height, which we can't know here. - // The settings UI clamps terminalLineHeight to [1, 3], so reject outside it. + // Pixel offsets need rendered cell height; accept percentages only within Orca's [1, 3] line-height range. 'adjust-cell-height': (v) => { const match = /^\+?(\d+(?:\.\d+)?)%$/.exec(v.trim()) if (!match) { @@ -242,8 +240,6 @@ export function mapGhosttyToOrca( }, 'cursor-style-blink': (v) => { - // Why: Ghostty uses 'true'/'false' strings for booleans; anything else - // is treated as unsupported rather than silently coerced. if (v !== 'true' && v !== 'false') { return null } @@ -251,8 +247,6 @@ export function mapGhosttyToOrca( }, 'focus-follows-mouse': (v) => { - // Why: Ghostty's focus-follows-mouse is semantically identical to Orca's - // terminalFocusFollowsMouse — both control pointer-hover focus transfer. if (v !== 'true' && v !== 'false') { return null } @@ -270,17 +264,13 @@ export function mapGhosttyToOrca( for (const [key, rawValue] of Object.entries(parsed)) { const value = Array.isArray(rawValue) ? (rawValue.at(-1) ?? '') : rawValue - // Why: Ghostty's selection-word-chars defines characters that ARE part of a - // word, while xterm.js wordSeparator defines characters that BREAK words. - // Passing the same string inverts the semantics, and correctly inverting a - // character set is non-trivial. Treat as unsupported to avoid silent misbehavior. + // Ghostty lists word members; xterm lists separators, so importing unchanged would invert the meaning. if (key === 'selection-word-chars') { unsupportedKeys.push(key) continue } - // Why: xterm.js ITheme has no bold color slot (xtermjs/xterm.js#6032), so importing - // bold-color would render as a no-op; report it as unsupported instead of silently dropping. + // xterm has no bold-color theme slot, so report this unsupported rather than implying success. if (key === 'bold-color') { unsupportedKeys.push(key) continue @@ -303,9 +293,7 @@ export function mapGhosttyToOrca( continue } - // Why: Orca's windowBackgroundBlur is a boolean; the numeric radius is lost. - // Only note the drop when blur is actually being turned on — a `0` cleanly - // maps to `false` and there is no radius to lose. + // Orca preserves only whether blur is enabled; report radius loss only for nonzero values. if ( key === 'background-blur-radius' && !Array.isArray(result) && diff --git a/src/main/ghostty/numeric-config-values.ts b/src/main/ghostty/numeric-config-values.ts index f8a7ce7b468..40a37beaf33 100644 --- a/src/main/ghostty/numeric-config-values.ts +++ b/src/main/ghostty/numeric-config-values.ts @@ -1,6 +1,4 @@ -// Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty -// config with `window-padding-x = 1e9` would sail through the mapper and land -// an absurd value in the store. Restrict to plain decimal integers. +// Reject exponent notation: Number.isInteger also accepts values such as 1e10. const STRICT_INT_RE = /^-?\d+$/ export const parseStrictInt = (v: string): number | null => { if (!STRICT_INT_RE.test(v)) { @@ -10,9 +8,7 @@ export const parseStrictInt = (v: string): number | null => { return Number.isFinite(num) ? num : null } -// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings, -// but Orca stores a single value per axis — average the pair so the total -// padding along the axis stays the same. +// Average paired padding values to preserve total padding with Orca's symmetric per-axis setting. export const parsePaddingValue = (v: string): number | null => { const parts = v.split(',') if (parts.length > 2) { diff --git a/src/main/ghostty/parser.ts b/src/main/ghostty/parser.ts index 66eb610f68f..75724688cf4 100644 --- a/src/main/ghostty/parser.ts +++ b/src/main/ghostty/parser.ts @@ -32,12 +32,9 @@ export function parseGhosttyConfig(content: string): Record { expect(result.diff).toEqual({ terminalColorOverrides: { - // Why: config's palette index 1 and background win; theme keeps index 2. red: '#ff0000', green: '#b9ca4a', background: '#101010' diff --git a/src/main/ghostty/theme-resolution.ts b/src/main/ghostty/theme-resolution.ts index eedb2c1592d..515dd6d21f3 100644 --- a/src/main/ghostty/theme-resolution.ts +++ b/src/main/ghostty/theme-resolution.ts @@ -3,8 +3,7 @@ import path from 'node:path' import { readFile, stat } from 'node:fs/promises' import { parseGhosttyConfig } from './parser' -// Why: theme files ship a few dozen short lines; anything larger is not a -// Ghostty theme and should not be read into the main process. +// Bound theme reads to keep large or misidentified files out of main-process memory. const MAX_THEME_BYTES = 262_144 type ThemeColors = Record type ThemeReadResult = @@ -12,8 +11,7 @@ type ThemeReadResult = | { status: 'missing' } | { status: 'invalid' } -// Why: only color-bearing keys may flow from a theme into the import — a theme -// file must not be able to smuggle font/window settings past the user's config. +// Themes may supply colors only; font and window settings must come from explicit config. const THEME_COLOR_KEYS = new Set([ 'palette', 'background', @@ -44,13 +42,11 @@ function resourceThemeDirs(plat: 'darwin' | 'linux'): string[] { return ['/usr/share/ghostty/themes', '/usr/local/share/ghostty/themes'] } -// Why: mirrors Ghostty's lookup — user themes shadow the bundled themes shipped -// inside the Ghostty resources directory. +// User themes shadow bundled themes, matching Ghostty lookup order. export function getGhosttyThemeSearchDirs(): string[] { const home = homedir() const plat = platform() if (plat !== 'darwin' && plat !== 'linux') { - // Why: Ghostty has no Windows build, so there are no named theme dirs to probe. return [] } return [...xdgThemeDirs(home), ...resourceThemeDirs(plat)] @@ -100,8 +96,7 @@ export async function resolveGhosttyThemeColors(name: string): Promise { }) describe('addSparseWorktree', () => { - // Why: argv now depends on the host OS, so pin a non-Windows default or the exact-argv - // assertions below would fail for a maintainer running vitest on Windows. + // Pin the OS so exact-argv assertions are portable. let platformSpy: MockInstance<() => NodeJS.Platform> beforeEach(() => { @@ -71,8 +70,6 @@ describe('addSparseWorktree', () => { }) it('enables long paths on the Windows commands that actually write the deep checkout', async () => { - // Why: `worktree add --no-checkout` writes nothing, so the long-path flag on it alone - // left sparse creation failing with "Filename too long" (issue #15785). platformSpy.mockReturnValue('win32') gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) @@ -115,11 +112,7 @@ describe('addSparseWorktree', () => { it('removes the worktree and deletes the created branch when sparse setup fails', async () => { mockGitCommands({ - // Why: addWorktree probes push.autoSetupRemote after `worktree add` to - // decide whether to set it locally. Without an explicit mock the helper - // returns empty stdout and the production code skips the `--local` write, - // exercising the wrong branch. Throw with code 1 to mirror git's "key - // unset" exit, which is what worktree.ts treats as "needs to be set". + // Exit 1 means the config key is unset and exercises the local-write path. 'git config --get push.autoSetupRemote': { error: Object.assign(new Error('key unset'), { code: 1 }) }, diff --git a/src/main/git/branch-rename.test.ts b/src/main/git/branch-rename.test.ts index 023bb542011..c4ba70cd10b 100644 --- a/src/main/git/branch-rename.test.ts +++ b/src/main/git/branch-rename.test.ts @@ -67,8 +67,6 @@ 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( @@ -81,7 +79,7 @@ describe('probeBranchUpstream', () => { }) 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. + // gettext can translate even Git's fatal: prefix. const exec: GitExec = vi.fn(async (args: string[]) => { if (args[0] === 'symbolic-ref') { return { stdout: 'feature\n', stderr: '' } @@ -100,7 +98,7 @@ describe('resolveUniqueBranchName', () => { const compute = (leaf: string): string => `you/${leaf}` it('returns the first candidate when no branch collides', async () => { - const exec: GitExec = vi.fn().mockRejectedValue(new Error('not found')) // show-ref misses + const exec: GitExec = vi.fn().mockRejectedValue(new Error('not found')) const result = await resolveUniqueBranchName(exec, 'fix-auth', compute, 'you/Nautilus') expect(result).toBe('you/fix-auth') }) @@ -109,7 +107,7 @@ describe('resolveUniqueBranchName', () => { const exec: GitExec = vi.fn(async (args: string[]) => { const ref = args.at(-1) if (ref === 'refs/heads/you/fix-auth') { - return { stdout: '', stderr: '' } // exists + return { stdout: '', stderr: '' } } throw new Error('not found') }) @@ -118,8 +116,6 @@ describe('resolveUniqueBranchName', () => { }) it('does not treat the branch being renamed away from as a collision', async () => { - // exec would report every ref as existing; only the currentBranch shortcut - // lets a candidate through. const exec: GitExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) const result = await resolveUniqueBranchName(exec, 'octopus', compute, 'you/octopus') expect(result).toBe('you/octopus') diff --git a/src/main/git/branch-rename.ts b/src/main/git/branch-rename.ts index 1237b82cf5a..2f8e758890c 100644 --- a/src/main/git/branch-rename.ts +++ b/src/main/git/branch-rename.ts @@ -4,11 +4,6 @@ import { type GitCommandRunner } from '../../shared/git-effective-upstream' -/** - * Git runner so branch-rename logic works identically for local worktrees - * (`gitExecFileAsync`) and SSH worktrees (`provider.exec`). Same contract the - * shared upstream-status helpers use. - */ export type GitExec = GitCommandRunner export type BranchUpstreamProbe = @@ -16,11 +11,7 @@ export type BranchUpstreamProbe = | { outcome: 'no-upstream' } | { outcome: 'probe-failed'; message: string } -/** - * Whether the branch has an upstream — i.e. it has been pushed or is tracking - * a remote. Auto-rename refuses to touch such a branch because `git branch -m` - * would orphan the remote branch and break any open PR. - */ +/** Auto-renaming a branch with an effective upstream can strand its remote branch and open review. */ export async function probeBranchUpstream(exec: GitExec): Promise { try { const upstream = await resolveEffectiveGitUpstream(exec) @@ -29,9 +20,7 @@ export async function probeBranchUpstream(exec: GitExec): Promise`). */ export async function renameCurrentBranch(exec: GitExec, newBranch: string): Promise { await exec(['branch', '-m', newBranch]) } diff --git a/src/main/git/canonical-repo-key.test.ts b/src/main/git/canonical-repo-key.test.ts index 87965bcaed6..3ae96da7ea3 100644 --- a/src/main/git/canonical-repo-key.test.ts +++ b/src/main/git/canonical-repo-key.test.ts @@ -28,7 +28,6 @@ describe('readGitCommonDir', () => { }) it('drops the flag Git older than 2.31 echoes back, and resolves the relative answer', () => { - // Without this every repository on such a host would answer `.git` and collide. expect(readGitCommonDir('--path-format=absolute\n.git\n', '/repo')).toBe('/repo/.git') }) diff --git a/src/main/git/canonical-repo-key.ts b/src/main/git/canonical-repo-key.ts index ca268f1bd19..7c923435fcb 100644 --- a/src/main/git/canonical-repo-key.ts +++ b/src/main/git/canonical-repo-key.ts @@ -4,13 +4,7 @@ import { toWslExecutionSpace } from '../../shared/wsl-paths' import { gitExecFileAsync } from './runner' import { resolveRevParsePath } from './worktree-path-comparison' -/** - * One repository on one execution host, named by its Git common dir. - * - * Shared by the fetch controller (which serializes fetches on it) and idle ref - * maintenance (which scopes all of its state to it), so both agree on what "the - * same repo" means across every worktree that points at it. - */ +/** Fetch serialization and ref maintenance share a host-scoped Git common-dir identity across worktrees. */ export type CanonicalRepoKeyOptions = LocalGitExecOptions & Pick @@ -18,11 +12,7 @@ export type CanonicalRepoKeyOptions = LocalGitExecOptions & const CACHE_MAX = 512 const cache = new Map() -/** - * Git < 2.31 ignores `--path-format=absolute`: it echoes the unrecognized flag, - * exits 0, and prints a relative `.git`. Taking the raw stdout there would give - * every repository on the host the same key. - */ +/** Git < 2.31 echoes unsupported --path-format=absolute; resolve its relative answer to prevent key collisions. */ export function readGitCommonDir(stdout: string, repoPath: string): string | undefined { const commonDir = stdout .split('\n') diff --git a/src/main/git/checkout.ts b/src/main/git/checkout.ts index a7b610f93cd..c71dc9aa5da 100644 --- a/src/main/git/checkout.ts +++ b/src/main/git/checkout.ts @@ -3,25 +3,14 @@ import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' -/** - * Reject branch names git would parse as an option (`-`/`--…`) or that aren't a - * valid ref. Defense-in-depth: callers also validate at the RPC schema, but the - * relay entrypoint is reachable independently, so the helper guards too. - */ +/** Reject empty and option-like names here too: relay callers can bypass RPC schema validation. */ export function assertValidBranchName(branch: string): void { if (branch.length === 0 || branch.startsWith('-')) { throw new Error('invalid_branch_name') } } -/** - * Switch the worktree to an existing local branch. Git itself refuses (and - * surfaces a "would be overwritten by checkout" error) when uncommitted changes - * would conflict, so we let that message propagate to the caller rather than - * forcing — mobile shows it as a toast. Flag-injection is prevented by - * `assertValidBranchName` (rejects `-…`); the trailing `--` marks that no - * pathspecs follow, so the token is unambiguously treated as a branch ref. - */ +/** Leave conflicting changes to Git; trailing -- disambiguates the branch from pathspecs. */ export async function checkoutBranch( worktreePath: string, branch: string, @@ -33,11 +22,7 @@ export async function checkoutBranch( ) } -/** - * List local branch short-names for the branch picker, current branch first. - * Uses `for-each-ref` (stable, scriptable output) instead of `branch` to avoid - * locale-dependent decoration. - */ +/** for-each-ref avoids localized branch decoration; return the current branch first. */ export async function listLocalBranches( worktreePath: string, options: GitRuntimeOptions = {} @@ -61,8 +46,6 @@ export async function listLocalBranches( } branches.push(name) } - // Why: surface the checked-out branch first so the picker reads "you are here" - // at the top, then the rest in git's ref order. branches.sort((a, b) => { if (a === current) { return -1 diff --git a/src/main/git/coalesced-probe.ts b/src/main/git/coalesced-probe.ts index ce09ba4eaeb..e95dca20906 100644 --- a/src/main/git/coalesced-probe.ts +++ b/src/main/git/coalesced-probe.ts @@ -1,24 +1,12 @@ -/** - * Coalescing for repeated host probes (P1-D). - * - * Why: the in-flight maps under the hosted-review funnel only drop a probe when - * it settles, so one that never does pins every later retry for the life of the - * process — the outer deadline then unpins the UI without restoring any lookup - * progress. Joining a probe only while it is young enough to still be making - * progress is what lets a host recover in-session. - */ +/** Stop joining stale probes so an unsettled host lookup cannot block all future retries. */ export type CoalescedProbe = { startedAt: number; token: object; promise: Promise } export type CoalescedProbes = Map> -/** Every step under a probe is bounded well inside this, so an older one is wedged, not slow. */ +// Retry after this age; elapsed time alone does not prove a probe has stopped progressing. export const PROBE_COALESCE_STALE_MS = 60_000 -/** - * `createProbe` receives `ownsKey`: false once this probe has been abandoned for - * a successor, which is what tells a late one not to publish its answer over the - * fresher one someone else is already being served. - */ +/** ownsKey prevents an abandoned probe from publishing over its successor. */ export async function runCoalescedProbe( probes: CoalescedProbes, key: string, @@ -31,7 +19,7 @@ export async function runCoalescedProbe( return existing.promise } if (existing) { - // The abandoned probe keeps running; nothing here will await it again. + // Observe failures from the abandoned probe, which may still be running. void existing.promise.catch(() => {}) } const token = {} diff --git a/src/main/git/command-runner/captured-git-output.ts b/src/main/git/command-runner/captured-git-output.ts index 3e8693d2900..eeffdb9b379 100644 --- a/src/main/git/command-runner/captured-git-output.ts +++ b/src/main/git/command-runner/captured-git-output.ts @@ -1,12 +1,6 @@ import type { ResolvedCommand } from './wsl-command-resolution' -/** - * Slice a fenced payload out of raw bytes. - * - * Why bytes: blob content may be binary, so decoding to a string to find the - * fence would corrupt it. Returns the buffer untouched when the command was not - * fenced or the fence is absent. - */ +/** Find fences in bytes to preserve binary blobs; return untouched output if no fence is found. */ export function readCapturedGitBuffer(stdout: Buffer, resolved: ResolvedCommand): Buffer { const captured = resolved.captured if (!captured) { diff --git a/src/main/git/command-runner/command-exec-file.ts b/src/main/git/command-runner/command-exec-file.ts index d67fa24f249..d94bf827ebc 100644 --- a/src/main/git/command-runner/command-exec-file.ts +++ b/src/main/git/command-runner/command-exec-file.ts @@ -18,10 +18,7 @@ function shouldRetryWindowsCommandShim(error: unknown, resolved: ResolvedCommand ) } -/** - * 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. - */ +/** WSL-aware execution for a fixed binary and argv; never pass shell fragments. */ export async function commandExecFileAsync( command: string, args: string[], diff --git a/src/main/git/command-runner/exec-file-capture.ts b/src/main/git/command-runner/exec-file-capture.ts index 5196b02dbd2..fddfddd652d 100644 --- a/src/main/git/command-runner/exec-file-capture.ts +++ b/src/main/git/command-runner/exec-file-capture.ts @@ -33,8 +33,7 @@ export async function execFileCaptureToTermination( options: Omit, termination?: WslProcessGroupTermination ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { - // Spawn cost is reported by spawnProcess's observer, which runProcess goes - // through; recording it again here would double-count every capture. + // runProcess already reports spawn cost through spawnProcess. let program: string try { program = resolveSelectedLocalCommand(command, { @@ -78,9 +77,7 @@ export async function execFileCaptureToTermination( options.signal?.aborted ? 'The operation was aborted.' : result.outputTruncated - ? // Why fail instead of returning the clipped text: callers parse this - // as JSON or JSONL, where a clipped answer reads as a shorter valid - // one. execFile's own maxBuffer overrun errored for the same reason. + ? // Reject clipped output: JSON/JSONL consumers could otherwise accept an incomplete result. `${command} produced more than ${options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER} bytes of output.` : cleanStderr.trim() || `${command} exited with ${result.code}.` ) @@ -122,8 +119,7 @@ function observeStderr( try { observer(typeof chunk === 'string' ? chunk : chunk.toString('utf8')) } catch (error) { - // Why: a throw inside a stream listener is an uncaught main-process - // exception; an observer must never be able to fail the command. + // Detach throwing observers so stream callbacks cannot crash main. child.stderr?.off('data', onData) console.warn('git stderr observer failed; detaching it', error) } @@ -146,7 +142,6 @@ export function execFileCapture( let settled = false let terminating = false let child: ChildProcess | WorkerThreadChildProcess | null = null - // Native macOS first; Windows/WSL keep their existing tree-termination path. const useWorker = options.spawnOnWorkerThread === true && process.platform === 'darwin' let timer: NodeJS.Timeout | null = null let terminationReported = false @@ -209,9 +204,7 @@ export function execFileCapture( args, { cwd: options.cwd, - // Why: git.exe is console-subsystem and Orca's main process owns no - // console, so every spawn without this flashes a conhost that takes - // foreground. Git runs on nearly every interaction (#14543). + // Hide console-subsystem children so frequent Git calls cannot flash or take focus. windowsHide: true, encoding: options.encoding, maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, diff --git a/src/main/git/command-runner/gh-bound-account-env.ts b/src/main/git/command-runner/gh-bound-account-env.ts index 7cb0405595d..c1fcb1564f2 100644 --- a/src/main/git/command-runner/gh-bound-account-env.ts +++ b/src/main/git/command-runner/gh-bound-account-env.ts @@ -21,10 +21,7 @@ export function isGhBoundAccountError(error: unknown): boolean { ) } -/** - * Child env for a bound gh call: pins the host, resolves the bound login's - * token, and injects it into this child only — never process-wide. - */ +/** Pin the host and inject the bound account token into this child only. */ export async function resolveBoundGhExecEnv( options: GhExecOptions, resolved: ResolvedCommand, @@ -35,7 +32,7 @@ export async function resolveBoundGhExecEnv( if (!binding || args[0] === 'auth') { return options.env } - // Why: dynamic import avoids a load-time cycle (command runner ↔ github token module). + // Dynamic import breaks the runner/token-module load cycle. const { buildBoundGhChildEnv, createGhBoundAccountHostMismatchError, diff --git a/src/main/git/command-runner/gh-exec-file-deadline.test.ts b/src/main/git/command-runner/gh-exec-file-deadline.test.ts index fa07dec32be..37dd9aca096 100644 --- a/src/main/git/command-runner/gh-exec-file-deadline.test.ts +++ b/src/main/git/command-runner/gh-exec-file-deadline.test.ts @@ -30,12 +30,7 @@ function settleChild(child: ChildProcess, stdout: string): void { child.emit('close', 0, null) } -/** - * The contract the star check depends on after #18234: a `gh` that never exits - * is killed at the deadline, and the kill reaches the whole chain. On the - * reporter's box `gh` was a shell wrapper calling `mise x gh`, so signalling - * only the direct child left the rest of the chain running under init. - */ +// gh may be a shim chain; the deadline must kill descendants as well as the wrapper. describe('gh exec deadline', () => { beforeEach(() => { vi.useFakeTimers() @@ -53,8 +48,6 @@ describe('gh exec deadline', () => { 'signals the whole process group, not just the child, when gh never exits', async () => { const child = mockChild() - // Why never emitting exit: this is exactly the stuck child from #18234 — - // spawned, spinning, and never reporting an exit. spawnMock.mockReturnValue(child) const pending = ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], { @@ -63,8 +56,7 @@ describe('gh exec deadline', () => { const rejection = expect(pending).rejects.toThrow('timed out') await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) - // The child must be its own group leader, or the signal below would go to - // whatever group it inherited — Orca's own. + // A detached group keeps cleanup signals from reaching Orca's own process group. expect(spawnMock.mock.calls[0][2].detached).toBe(true) expect(processKillMock).not.toHaveBeenCalled() diff --git a/src/main/git/command-runner/gh-exec-file.ts b/src/main/git/command-runner/gh-exec-file.ts index 75945f65a0a..4cf569ccdb8 100644 --- a/src/main/git/command-runner/gh-exec-file.ts +++ b/src/main/git/command-runner/gh-exec-file.ts @@ -39,17 +39,13 @@ import { GH_RETRY_DELAYS_MS } from './gh-retry-policy' -// `cwd?` omitted for non-repo-scoped gh calls (rate_limit, listAccessibleProjects) so one WSL-aware wrapper serves both. -// `wslDistro?` routes global cwd-less gh through `wsl.exe -d ` on WSL-only Windows where gh.exe isn't on host PATH. -// `idempotent?` gates transient-error retry (auto-detected from argv); retrying a write that already reached GitHub would duplicate it. +// cwd is optional; wslDistro routes global calls when Windows has no host gh binary. +// Retry only idempotent calls: a failed response can follow a completed write. export type GhExecOptions = Omit & { cwd?: string wslDistro?: string idempotent?: boolean - // Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's - // default host, not the repo's remote. Carrying the host here lets the - // runner qualify every spawn once, so call sites can't silently fall back - // to github.com for GHES repos; it also scopes the rate-limit breaker. + // Pin API/repo shorthand to its host, including GHES, and scope rate-limit state there. host?: string /** Per-project account binding; the runner resolves and injects a child-only token. */ ghAccount?: { host: string; user: string } @@ -68,8 +64,7 @@ function ghRateLimitScope( resolved: ResolvedCommand ): string { const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native' - // Why: an explicit argv hostname controls the actual gh request even when - // GH_HOST or options.host disagree, so breaker state must follow that host. + // Explicit argv hosts determine the actual request and therefore its breaker scope. const host = explicitGhHostname(args) ?? options.host ?? @@ -100,13 +95,7 @@ function assertGhRateLimitScopeAvailable( } } -/** - * 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; other errors fail fast. - */ +/** Execute gh with bounded retries for transient failures on idempotent calls. */ export async function ghExecFileAsync( args: string[], options: GhExecOptions = {}, @@ -124,24 +113,20 @@ export async function ghExecFileWithScopeAsync( ): Promise<{ stdout: string; stderr: string; rateLimitScope: string }> { // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. const idempotent = options.idempotent ?? argsLookIdempotent(args) - // Why: legacy github.com ownerRepos omit `host`; bound calls still need a pinned - // options.host before token-var selection and argv qualification. + // Legacy bound calls may omit host; pin it before selecting token variables or qualifying argv. if (options.ghAccount && !options.host?.trim() && args[0] !== 'auth') { options = { ...options, host: options.ghAccount.host.trim().toLowerCase() } } args = applyGhHostToArgs(args, options.host) let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) - // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset, - // and `auth token` is exempt because a keyring read never spends API quota (a bound token resolve must not report "unavailable"). - // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. + // Skip blocked API buckets; exempt reset probes and local token reads, and isolate host/runtime quotas. const rateLimitBucket = classifyGhRateLimitBucket(args) const rateLimitExempt = isGhRateLimitProbe(args) || isGhLocalOnlyCommand(args) let boundEnv: NodeJS.ProcessEnv | undefined = options.env let boundEnvResolvedKey: string | null = null const boundEnvKeyFor = (command: ResolvedCommand): string => command.wsl ? `wsl:${command.wsl.distro}` : 'native' - // Why: unbound calls must reach the spawn in the same tick — callers that abort - // synchronously would otherwise never get a child to kill. + // Preserve synchronous spawning for unbound calls so immediate aborts still have a child to kill. const ensureBoundEnv = (hooks?: GhCaptureLifetimeHooks): Promise | null => { const key = boundEnvKeyFor(resolved) if (boundEnvResolvedKey === key) { @@ -162,8 +147,7 @@ export async function ghExecFileWithScopeAsync( let lastError: unknown let attemptedHostFallback = false let attemptedDefaultWslFallback = false - // Why: a bound resolve fails before any spawn when gh is missing inside WSL; reuse the - // host fallback and re-resolve the token for the host it now runs on. + // A missing WSL gh can fail during token resolution; fall back and resolve credentials on the new host. const tryHostFallbackAfterBoundResolveFailure = (error: unknown): boolean => { if (attemptedHostFallback) { return false @@ -196,11 +180,7 @@ export async function ghExecFileWithScopeAsync( if (lifetime) { await lifetime.waitPending(options.signal) } - // Why to-termination and not execFileCapture: `gh` on PATH is routinely a - // shim (mise, asdf, volta, a hand-written wrapper), so the deadline below - // has a chain to reap, not one process. execFileCapture's POSIX kill only - // signals the direct child, which orphans the rest to init — a wedged - // helper then outlives the timeout that was supposed to bound it (#18234). + // gh may be a shim chain; terminate the whole tree so descendants cannot outlive the deadline. const { stdout, stderr } = await execFileCaptureToTermination( resolved.binary, resolved.args, @@ -208,7 +188,6 @@ export async function ghExecFileWithScopeAsync( cwd: resolved.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, - // Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane. timeout: timeoutMs, env: nonInteractiveGhEnv(boundEnv ?? options.env), signal: options.signal, @@ -269,10 +248,10 @@ export async function ghExecFileWithScopeAsync( ) { const wslResolved = resolveDefaultWslCli('gh', args) if (wslResolved) { - // Why: WSL-only Windows installs have no host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. + // Global calls have no cwd to route WSL-only Windows installs. resolved = wslResolved attemptedDefaultWslFallback = true - // Why: token and capability are per execution host — re-resolve after native→WSL. + // Resolve the token again on the new execution host. boundEnvResolvedKey = null assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitExempt) attempt = -1 @@ -282,7 +261,7 @@ export async function ghExecFileWithScopeAsync( if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { resolved = resolveHostGitHubCli('gh', args) attemptedHostFallback = true - // Why: token and capability are per execution host — re-resolve after WSL→host. + // Resolve the token again on the new execution host. boundEnvResolvedKey = null assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitExempt) attempt = -1 @@ -290,7 +269,7 @@ export async function ghExecFileWithScopeAsync( } const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { - // Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC. + // Honor Retry-After, capped to keep IPC bounded. const retryAfterMs = parseRetryAfterMs(stderr) const delayMs = retryAfterMs !== null @@ -304,6 +283,5 @@ export async function ghExecFileWithScopeAsync( lifetime?.seal() } } - // Unreachable: the loop either returns or throws. Here for TS exhaustiveness. throw lastError } diff --git a/src/main/git/command-runner/gh-host-args.ts b/src/main/git/command-runner/gh-host-args.ts index 68b9f98f469..cc72be4a2f5 100644 --- a/src/main/git/command-runner/gh-host-args.ts +++ b/src/main/git/command-runner/gh-host-args.ts @@ -3,20 +3,13 @@ function hasGhHostnameFlag(args: readonly string[]): boolean { } function hostQualifiedGhRepoValue(value: string, host: string): string { - // URLs and already-qualified HOST/OWNER/REPO values pass through untouched. if (value.includes('://') || value.split('/').length !== 2) { return value } return `${host}/${value}` } -/** - * Host-qualify a gh invocation from `options.host`: `--hostname` for `api` - * calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run - * gh with no cwd, so this is their only host signal (#8312). - * - * @internal exported for tests. - */ +/** Pin API and repo shorthand to the requested host; SSH calls have no cwd to supply it. */ export function applyGhHostToArgs(args: string[], host?: string): string[] { if (!host) { return args @@ -25,12 +18,8 @@ export function applyGhHostToArgs(args: string[], host?: string): string[] { if (result[0] === 'api' && !hasGhHostnameFlag(result)) { result = ['api', '--hostname', host, ...result.slice(1)] } - // Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST - // when set — so github.com must be qualified too, not just GHES, or a - // process-level GH_HOST redirects pinned github.com commands. - // Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten: - // no call site uses them, and prefix-matching `-R` corrupts free-text values - // of other flags (e.g. a --title that happens to start with `-R`). + // Qualify github.com too: GH_HOST can redirect bare OWNER/REPO values. + // Leave attached -R forms alone to avoid rewriting free-text flag values. const qualified: string[] = [] for (let i = 0; i < result.length; i += 1) { const arg = result[i] @@ -108,7 +97,7 @@ export function collectGhArgvHostSignals(args: readonly string[]): string[] { } else if (args[i].startsWith('-R=')) { repoValue = args[i].slice('-R='.length) } else if (args[i].startsWith('-R') && args[i].length > 2 && !args[i].startsWith('-R-')) { - // Why: gh accepts attached `-Rhost/owner/repo`; reject host drift on bound calls. + // Attached -R values also participate in bound-account host checks. repoValue = args[i].slice(2) } const parts = repoValue?.trim().split('/') diff --git a/src/main/git/command-runner/gh-idempotency.ts b/src/main/git/command-runner/gh-idempotency.ts index de24f11c33c..5e17c84f00c 100644 --- a/src/main/git/command-runner/gh-idempotency.ts +++ b/src/main/git/command-runner/gh-idempotency.ts @@ -1,5 +1,5 @@ const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) -// `gh ` write subcommands; reads are absent on purpose so they keep retrying. +// Write verbs cannot be safely retried after an uncertain response. const NON_IDEMPOTENT_GH_VERBS = new Set([ 'create', 'edit', @@ -33,7 +33,6 @@ export function argsLookIdempotent(args: string[]): boolean { return false } } - // Single-token form `--method=POST` (gh accepts this). if (a.startsWith('--method=')) { explicitMethodSeen = true const value = a.slice('--method='.length) @@ -52,7 +51,7 @@ export function argsLookIdempotent(args: string[]): boolean { ) { hasApiBodyField = true } - // Detect GraphQL `query=mutation(…)` so endpoint writes also fail fast on transient errors. + // GraphQL mutations must not be retried after an uncertain response. if (a.startsWith('query=')) { hasGraphQlQuery = true const trimmed = a.slice('query='.length).trimStart().toLowerCase() @@ -61,7 +60,7 @@ export function argsLookIdempotent(args: string[]): boolean { } } } - // `gh api -f foo=bar` with no -X auto-POSTs → non-idempotent; GraphQL query bodies are the exception (still reads). + // Body fields imply POST unless a method is explicit; GraphQL queries remain safe to retry. if ( args[0] === 'api' && hasApiBodyField && @@ -70,7 +69,6 @@ export function argsLookIdempotent(args: string[]): boolean { ) { return false } - // `gh ` writes (args[1]); `gh api` without -X defaults to idempotent GET, so it's excluded here. if (args.length >= 2 && args[0] !== 'api') { if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { return false diff --git a/src/main/git/command-runner/gh-retry-policy.ts b/src/main/git/command-runner/gh-retry-policy.ts index b3b06a1a88e..1a8ab117998 100644 --- a/src/main/git/command-runner/gh-retry-policy.ts +++ b/src/main/git/command-runner/gh-retry-policy.ts @@ -1,13 +1,7 @@ import { parseRetryAfterMs } from '../exec-error' import { createAbortError } from './abort-error' -/** - * Classify whether a gh execFile rejection is worth retrying. - * - * Why: gh surfaces HTTP status as stderr substrings ("HTTP 504", econnreset, …). - * Retry 5xx/network resets and 429 only without Retry-After (propagate those so - * the UI can show the wait); primary-rate-limit 403 is never transient. - */ +/** Retry 5xx/resets and 429 without Retry-After; propagate explicit waits and primary 403s. */ export function isTransientGhError(stderr: string): boolean { const s = stderr.toLowerCase() if ( @@ -21,17 +15,16 @@ export function isTransientGhError(stderr: string): boolean { ) { return true } - // 429 without Retry-After: retry. With Retry-After: propagate. if (s.includes('http 429')) { return parseRetryAfterMs(stderr) === null } return false } -// Why: 3 attempts total (250ms → 1s backoff); array length defines retry count (total attempts = length + 1). +// Two backoffs allow three attempts. export const GH_RETRY_DELAYS_MS = [250, 1000] as const -// Why: Retry-After is unbounded and untrusted; cap at 30s so a gh call can't block the IPC thread indefinitely. +// Cap untrusted Retry-After hints so IPC stays bounded. export const GH_RETRY_AFTER_MAX_MS = 30_000 const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 diff --git a/src/main/git/command-runner/git-admission-output-parity.test.ts b/src/main/git/command-runner/git-admission-output-parity.test.ts index c75519cfc7c..785067f5ce2 100644 --- a/src/main/git/command-runner/git-admission-output-parity.test.ts +++ b/src/main/git/command-runner/git-admission-output-parity.test.ts @@ -1,5 +1,4 @@ -// Runs on every platform: parity against real git is the Windows-relevant half of -// the admission evidence, so it must not share the storm harness's POSIX gate. +// Keep real-Git parity coverage on Windows even though the storm harness is POSIX-only. import { execFileSync } from 'node:child_process' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/src/main/git/command-runner/git-admission-storm-measurement.test.ts b/src/main/git/command-runner/git-admission-storm-measurement.test.ts index 40bab1322c6..34968460245 100644 --- a/src/main/git/command-runner/git-admission-storm-measurement.test.ts +++ b/src/main/git/command-runner/git-admission-storm-measurement.test.ts @@ -179,5 +179,4 @@ describe.skipIf(process.platform === 'win32')('git admission storm measurement', assertAdmissionLedger(enabled) } ) - // Real-git output parity remains in git-admission-output-parity.test.ts, including Windows. }) diff --git a/src/main/git/command-runner/git-admission-storm-test-fixture.ts b/src/main/git/command-runner/git-admission-storm-test-fixture.ts index 4ee1219f027..bdf3f58b4ad 100644 --- a/src/main/git/command-runner/git-admission-storm-test-fixture.ts +++ b/src/main/git/command-runner/git-admission-storm-test-fixture.ts @@ -1,7 +1,4 @@ -/** - * POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path. - * Timing distributions are reported for field comparison; CI assertions stay structural. - */ +// PATH-injected POSIX fixture: report timings, but assert only structure in CI. import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' diff --git a/src/main/git/command-runner/git-command-resolution.ts b/src/main/git/command-runner/git-command-resolution.ts index a70dd136804..1e8adf317a1 100644 --- a/src/main/git/command-runner/git-command-resolution.ts +++ b/src/main/git/command-runner/git-command-resolution.ts @@ -15,8 +15,7 @@ import { withWslWindowsCheckoutGitConfig } from '../wsl-windows-checkout-git-con import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' import type { GitExecOptions } from './git-exec-options' -// Why: Linux Git misreads a Windows Git checkout unless it runs with Windows Git's settings. -// Classification reads the caller's args; only the spawned argv carries the injected config. +// Linux Git needs Windows-checkout settings; classify original args and inject only at spawn. function wslGitArgs(args: string[], options: GitExecOptions): string[] { return withWslWindowsCheckoutGitConfig(args, options.cwd, options.wslDistro) } @@ -54,13 +53,7 @@ export function resolveGitCommand( return resolveGitCommandWithoutProbe(args, options, captureLoginShellOutput) } -/** - * The distro this command could run shell-free in, or null when it can't. - * - * Why cwd counts: a `\\wsl.localhost\\...` worktree names its distro in - * the path, so requiring the caller to have resolved a WSL project runtime first - * left every diff read on the login shell — one rc run per `git show`. - */ +/** Infer the direct-read distro from overrides or UNC cwd so plain reads can bypass login rc output. */ function directWslGitReadDistro( args: string[], options: GitExecOptions, @@ -75,9 +68,7 @@ function directWslGitReadDistro( function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boolean { return Boolean( process.platform === 'win32' && - // Why either: callers can still opt in explicitly, but a plain read no - // longer has to -- it needs nothing the login shell provides, and routing - // it through one is what exposes callers to the shell's rc output. + // Known reads need no login environment; explicit callers may also opt in. (options.preferWslDirectGit || isWslDirectGitReadCommand(args)) && !options.useConfiguredSshCommandForNetwork && !Object.entries(options.env ?? {}).some( @@ -87,28 +78,17 @@ function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boo ) } -/** - * Give a read the chance to take the shell-free route instead of silently - * falling back while the probe is still resolving. - * - * The probe is one `wsl.exe` call, shared per distro and reused by every later - * command, so waiting costs at most once. Bounded because a cold or wedged - * distro must not hold a read behind it — past the bound the login-shell route - * runs exactly as it did before. - */ +/** Wait once per distro for direct-read setup, bounded so cold or wedged WSL falls back to a login shell. */ export function pendingWslDirectGitReadEnvironment( args: string[], options: GitExecOptions ): Promise | null { - // Why null rather than a resolved promise: every non-WSL git call goes through here, and - // awaiting even an already-settled promise would push the spawn into a later microtask. - // A settled probe — including a distro whose direct route was permanently disabled — has - // nothing left to wait for, and neither does a read that is already aborted. + // Return null for settled/aborted probes: even awaiting a resolved promise would defer native spawning. const distro = directWslGitReadDistro(args, options, false) if (!distro || isWslGitReadEnvironmentSettled(distro) || options.signal?.aborted) { return null } - // withTimeout also absorbs rejection, so a probe failure can never become a read failure. + // Absorb probe failures so setup cannot fail the read. return withTimeout( waitForPromiseWithSignal(getWslGitReadEnvironment(distro), options.signal), WSL_GIT_READ_ENVIRONMENT_WAIT_MS, @@ -146,14 +126,7 @@ export function directWslGitExitCode(error: unknown, resolved: ResolvedCommand): return typeof code === 'number' ? code : null } -/** - * Exit 1 with nothing on stderr is Git answering "no" (a missing ref under - * `show-ref --verify --quiet`, no differences under `diff --quiet`), not a - * broken environment. `wsl.exe` always explains its own launch failures, so - * this cannot mask a dead distro. Retrying such an exit through the user's - * interactive login shell doubles the spawn count and runs the distro's rc - * files for a result the direct route already produced correctly. - */ +/** Quiet exit 1 is Git control flow; WSL launch errors include diagnostics and may need shell fallback. */ export function isQuietGitControlFlowExit(error: unknown): boolean { if (!error || typeof error !== 'object') { return false diff --git a/src/main/git/command-runner/git-exec-admission-lifetime.test.ts b/src/main/git/command-runner/git-exec-admission-lifetime.test.ts index 3a2245e7816..ba8582eb568 100644 --- a/src/main/git/command-runner/git-exec-admission-lifetime.test.ts +++ b/src/main/git/command-runner/git-exec-admission-lifetime.test.ts @@ -29,10 +29,7 @@ vi.mock('@orca/process-host/process-tree-termination', () => ({ signalProcessTree: signalProcessTreeMock, forceTerminateProcessTree: forceTerminateProcessTreeMock })) -// Why: the real FETCH_HEAD key derivation walks the filesystem (realpath/stat/readFile), so -// concurrent same-repo callers join the lock lane in libuv threadpool completion order rather -// than call order. Keep the real FIFO lock and drop only the key walk, which owns its coverage -// in `src/shared/git-fetch-head-lock.test.ts`. +// Mock only filesystem key derivation so libuv completion order cannot reorder the real FIFO lock. vi.mock('../../../shared/git-fetch-head-lock', async (importOriginal) => { const actual = await importOriginal() const { runWithGitOperationLock } = await import('../../../shared/git-operation-lock') diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index 7e7b0538abf..9165500f7ab 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -29,21 +29,16 @@ import { readCapturedGitBuffer, readCapturedGitString } from './captured-git-out import { runUnlessGitCwdKnownMissing } from './missing-git-spawn-cwd' import { trackGitWorktreeMutation } from '../git-worktree-mutation-state' -/** - * Async git command execution. Drop-in replacement for - * `execFileAsync('git', args, { cwd, encoding, ... })`. - */ async function gitExecFileAsyncUnlocked( args: string[], options: GitExecOptions, terminationState: { current: Promise | null } ): Promise<{ stdout: string; stderr: string }> { - // Why: span the user-visible `git ` form, not the resolved binary, so dashboards group by intent. + // Trace caller intent rather than the resolved binary so WSL and native commands group together. return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, async (span) => { if (options.onStderr && options.terminationBarrier) { - // Why: checked before admission; that path's streams belong to runProcess. throw new Error('onStderr is not supported with terminationBarrier.') } const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options) @@ -115,7 +110,7 @@ async function gitExecFileAsyncUnlocked( command.termination ) : execFileCapture(command.binary, command.args, captureOptions) - // Why: a dead WSL distro fails with an empty stderr, so the span would carry no cause at all. + // Add a cause when a dead WSL distro returns empty stderr. return captured.catch((error: unknown) => { throw annotateWslHostFailure(error, command) }) @@ -183,7 +178,7 @@ export function gitExecFileAsync( const releaseMutation = trackGitWorktreeMutation(args, options) const terminationState: { current: Promise | null } = { current: null } const command = resolveGitFetchHeadCommand(args, options.cwd) - // Why outside the lock and span: a known-missing folder starts no process, so it records no git.exec. + // Missing folders start no child and must not record a git.exec span. const result = runUnlessGitCwdKnownMissing(options, () => command.needsLock ? runWithGitFetchHeadLock( @@ -204,10 +199,6 @@ export function gitExecFileAsync( }) } -/** - * Async git command execution that returns a Buffer. - * Used for reading binary blobs (git show). - */ async function gitExecFileAsyncBufferInSpan( args: string[], options: { @@ -232,9 +223,7 @@ async function gitExecFileAsyncBufferInSpan( if (readEnvironmentReady) { await readEnvironmentReady } - // `git show` is a read, so this normally runs with no shell at all. The fence - // still matters for the login-shell fallback: these are raw blob bytes going - // straight to the diff/blob viewer, where a banner becomes file content. + // Fence fallback login-shell output so rc banners cannot become raw blob content. let resolved = resolveGitCommand(args, options, false, true) const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) if (environmentReady) { @@ -284,7 +273,7 @@ async function gitExecFileAsyncBufferInSpan( }) } -// Why: sync git blocks the main thread; a dead network drive can hang git for minutes without a timeout (issue #7225's 127s freeze). +// Bound synchronous Git: a dead network drive can freeze the main thread. const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000 export function gitExecFileAsyncBuffer( @@ -294,12 +283,7 @@ export function gitExecFileAsyncBuffer( return runUnlessGitCwdKnownMissing(options, () => gitExecFileAsyncBufferInSpan(args, options)) } -/** - * Sync git command execution. Drop-in replacement for - * `execFileSync('git', args, { cwd, encoding, ... })`. - * - * Returns trimmed stdout as a string. - */ +/** Return trimmed stdout; prefer async execution to avoid blocking main. */ export function gitExecFileSync( args: string[], options: { @@ -321,7 +305,6 @@ export function gitExecFileSync( windowsHide: true }) as string } finally { - // Sync exec blocks the main thread for its whole duration — the cost issue #7576 flags. recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) } } diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index 72e1a03298e..9a9e5783a01 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -1,7 +1,6 @@ -// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER. +// Bound output to avoid V8 string overflow; match the relay's MAX_GIT_BUFFER. export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 -// Why: the admission tier is a wire value, so it is declared with its params schema. import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params' export type { GitAdmissionTier } @@ -31,7 +30,6 @@ export type GitExecOptions = { admissionExempt?: true /** Native macOS deletion spawns use the shared process worker. */ spawnOnWorkerThread?: boolean - /** Sees each stderr chunk as it arrives; capture and errors are unchanged. Not - * supported with `terminationBarrier`. */ + /** Observe stderr without changing capture/errors; incompatible with terminationBarrier. */ onStderr?: (chunk: string) => void } diff --git a/src/main/git/command-runner/git-process-env.ts b/src/main/git/command-runner/git-process-env.ts index 5e5bba423ad..eee838cf2b6 100644 --- a/src/main/git/command-runner/git-process-env.ts +++ b/src/main/git/command-runner/git-process-env.ts @@ -14,16 +14,10 @@ export function gitOptionalLocksDisabledEnv( } } -/** - * Append git config via the GIT_CONFIG_COUNT/KEY_n/VALUE_n env protocol (git >= 2.31), - * composing with any count already in `env` so we never clobber a caller's config. - */ +/** Append Git >= 2.31 config-env entries without overwriting the caller's existing entries. */ export { appendGitConfigEnv } -/** - * Pin Orca-spawned git to untranslated English output so stderr/progress parsers - * work under any user locale (issue #7808). Terminal git is untouched. - */ +/** Pin Git diagnostics to English so parsers work under any user locale. */ export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV } } @@ -35,11 +29,7 @@ export function promptGuardGitEnv( return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform) } -/** - * Credential-prompt guard for a general-purpose shell (PTYs, hook scripts): - * like promptGuardGitEnv but without the issue-7808 locale pins, which would - * change the locale of every child process, not just git's. - */ +/** Guard PTYs/hooks without locale pins, which would affect every shell child. */ export function promptGuardShellEnv( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform @@ -47,31 +37,20 @@ export function promptGuardShellEnv( return gitCredentialPromptGuardEnv(env, platform) } -/** - * Force git non-interactive so it fails fast instead of hanging on a prompt with - * no terminal to answer it; on headless `serve` those stuck calls wedge every - * client (issue #5308). - * - * - GIT_TERMINAL_PROMPT=0: git errors instead of prompting for credentials. - * - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI helper blocks; a - * caller-provided askpass is preserved (custom setups serve creds non-interactively). - * - GIT_SSH_COMMAND BatchMode=yes: SSH errors instead of prompting (doesn't change - * host trust); only added when the caller hasn't set its own. - */ +/** Prevent unattended credential prompts; preserve caller askpass/SSH overrides. */ export function nonInteractiveGitEnv( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform ): NodeJS.ProcessEnv { const next = promptGuardGitEnv(env, platform) if (platform === 'win32') { - // Why: without it wsl.exe writes its OWN failures ("no distribution with the supplied name") as - // UTF-16LE (#9010), which is how a dead distro reached telemetry as an error with no text. + // Decode wsl.exe launch errors as UTF-8 rather than its default UTF-16LE. next.WSL_UTF8 = '1' } if (!next.GIT_SSH_COMMAND && !next.GIT_SSH) { next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes' if (platform === 'win32') { - // Why: forward GIT_SSH_COMMAND to WSL only when we set it — a caller's Windows-specific value must not leak into Linux git. + // Forward only our SSH override; caller values may be Windows-specific. addWslEnvKeys(next, ['GIT_SSH_COMMAND']) } } diff --git a/src/main/git/command-runner/git-spawn.ts b/src/main/git/command-runner/git-spawn.ts index f49aeb8c85a..004a73232cc 100644 --- a/src/main/git/command-runner/git-spawn.ts +++ b/src/main/git/command-runner/git-spawn.ts @@ -11,10 +11,6 @@ import { acquireGitAdmission } from './git-subprocess-admission' import { classifyGitCommand } from '../../../shared/git-command-classification' import { buildNetworkSshPolicyEnv } from './git-ssh-policy-env' -/** - * Spawn a git child process. Drop-in replacement for - * `spawn('git', args, { cwd, stdio, ... })`. - */ export type GitSpawnOptions = SpawnOptions & { cwd: string wslDistro?: string diff --git a/src/main/git/command-runner/git-stream-stdout.ts b/src/main/git/command-runner/git-stream-stdout.ts index 8cd3a6d1311..4aaefed94d5 100644 --- a/src/main/git/command-runner/git-stream-stdout.ts +++ b/src/main/git/command-runner/git-stream-stdout.ts @@ -41,20 +41,11 @@ export type GitStreamOptions = { /** Overrides only the default read deadline in tests. */ timeoutMsForTest?: number admissionTier?: GitAdmissionTier - /** - * Called for each decoded stdout chunk. Return true to stop: the child is - * killed and the promise resolves with stoppedEarly=true. - */ + /** Return true to kill the child and resolve with stoppedEarly=true. */ onStdout: (chunk: string) => boolean | void } -/** - * Stream a git command's stdout incrementally instead of buffering it whole. - * - * Why: output larger than V8's max string (e.g. status on a repo with a huge - * un-ignored folder) crashes the process when buffered; streaming keeps memory - * bounded and lets the parser stop git early. Built on gitSpawn for WSL routing. - */ +/** Stream bounded stdout so large repositories cannot overflow V8 and parsers can stop early. */ export async function gitStreamStdout( args: string[], options: GitStreamOptions @@ -147,7 +138,7 @@ export async function gitStreamStdout( let stdoutBytes = 0 let stderr = '' let stderrBytes = 0 - // Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars. + // Decode across chunk boundaries to preserve split UTF-8 characters. const stdoutDecoder = new StringDecoder('utf8') const stderrDecoder = new StringDecoder('utf8') @@ -161,7 +152,6 @@ export async function gitStreamStdout( 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() } @@ -189,7 +179,7 @@ export async function gitStreamStdout( if (decoded.length === 0) { return } - // Why: a throw from the caller's parser would escape this event handler and crash main; convert to a rejection. + // Reject parser failures rather than letting stream callbacks crash main. let shouldStop: boolean | void try { shouldStop = options.onStdout(decoded) @@ -199,7 +189,6 @@ export async function gitStreamStdout( return } if (shouldStop === true) { - // Parser hit its limit: kill git and resolve cleanly with the partial output. stoppedEarly = true void killSpawnedCommandTree(child) finish(null) diff --git a/src/main/git/command-runner/glab-exec-file.ts b/src/main/git/command-runner/glab-exec-file.ts index 37aad697b95..4354c947b0f 100644 --- a/src/main/git/command-runner/glab-exec-file.ts +++ b/src/main/git/command-runner/glab-exec-file.ts @@ -13,7 +13,6 @@ import { GH_RETRY_DELAYS_MS } from './gh-retry-policy' -// Why: cloned from the gh runner rather than abstracted behind a generic runner, to avoid touching the working gh path. const DEFAULT_GLAB_EXEC_TIMEOUT_MS = 30_000 export type GlabExecOptions = Omit & { @@ -23,11 +22,7 @@ export type GlabExecOptions = Omit & { allowDefaultWslFallback?: boolean } -/** Async glab CLI execution; drop-in for execFileAsync('glab', …). Retry policy mirrors ghExecFileAsync. */ -/** - * glab's `--hostname` rejects host:port, so a ported self-hosted GitLab must use the GITLAB_HOST env var instead — translate it. - * @internal exported for tests. - */ +/** glab rejects --hostname host:port; use GITLAB_HOST for ported self-hosted instances. */ export function redirectPortedHostnameToEnv( args: string[], options: GlabExecOptions @@ -40,13 +35,9 @@ export function redirectPortedHostnameToEnv( if (!/^[^/\s]+:\d+$/.test(host)) { return { args, options } } - // Why WSLENV: a glab routed into a distro only sees Windows-side variables - // named in WSLENV, so without this the ported host silently never crosses and - // glab talks to gitlab.com instead (#12557). Credit: #12558. + // Forward GITLAB_HOST through WSLENV so distro calls cannot silently target gitlab.com. const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), GITLAB_HOST: host } - // Unguarded by platform on purpose: WSLENV is meaningless outside Windows, so - // the extra key is inert there, and gating it would need the test to know the - // platform for no behavioural gain. + // WSLENV is inert outside Windows. addWslEnvKeys(env, ['GITLAB_HOST']) return { args: [...args.slice(0, i), ...args.slice(i + 2)], @@ -65,8 +56,7 @@ export async function glabExecFileAsync( let attemptedDefaultWslFallback = false for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { try { - // Why to-termination: same shim chain as gh — the deadline has to reap the - // whole tree, not just the wrapper that spawned it (#18234). + // Kill the whole shim tree so descendants cannot outlive the deadline. const { stdout, stderr } = await execFileCaptureToTermination( resolved.binary, resolved.args, @@ -96,7 +86,6 @@ export async function glabExecFileAsync( ) { 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 @@ -104,7 +93,7 @@ export async function glabExecFileAsync( } } const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - // Why: mirror gh's write-safety gate — don't auto-retry a non-idempotent write that GitLab may already have applied. + // Never retry a write that GitLab may already have applied. const idempotent = options.idempotent ?? argsLookIdempotent(args) if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { const retryAfterMs = parseRetryAfterMs(stderr) diff --git a/src/main/git/command-runner/hosted-cli-deadline-log.test.ts b/src/main/git/command-runner/hosted-cli-deadline-log.test.ts index 06ad0e6261f..5f25171e197 100644 --- a/src/main/git/command-runner/hosted-cli-deadline-log.test.ts +++ b/src/main/git/command-runner/hosted-cli-deadline-log.test.ts @@ -22,11 +22,7 @@ function mockChild(pid = 4321): ChildProcess { return child as unknown as ChildProcess } -/** - * #18234 took four rounds of strace/perf/proc spelunking from the reporter - * because a deadline kill produced no evidence at all. The resolved path is the - * fact that names a self-recursive wrapper. - */ +// A resolved path identifies recursive wrappers without logging secret-bearing arguments. describe('hosted CLI deadline logging', () => { let warn: ReturnType diff --git a/src/main/git/command-runner/hosted-cli-deadline-log.ts b/src/main/git/command-runner/hosted-cli-deadline-log.ts index 3a8be660d14..5e1a12021bd 100644 --- a/src/main/git/command-runner/hosted-cli-deadline-log.ts +++ b/src/main/git/command-runner/hosted-cli-deadline-log.ts @@ -1,16 +1,4 @@ -/** - * One log line when `gh`/`glab` is killed at its deadline without answering. - * - * Why this exists: the deadline kill was completely silent. In #18234 a user's - * `~/.local/bin/gh` wrapper (`exec mise x gh -- gh "$@"`) re-execed itself in - * place at 100% CPU on every invocation, and the only evidence Orca produced was - * that GitHub features quietly did nothing. Diagnosing it took the reporter four - * rounds of `strace`, `perf` and `/proc` spelunking. The resolved path below is - * the single most useful fact — it names the wrapper. - * - * Why not the full argv: `gh api` carries `-H Authorization: …` and `--field` - * bodies, so only the subcommand and an argument count are safe to print. - */ +/** Log the resolved wrapper on deadline kills; omit argv because headers and bodies can contain credentials. */ export function logHostedCliDeadlineKill( cli: string, resolvedBinary: string, diff --git a/src/main/git/command-runner/missing-git-spawn-cwd.ts b/src/main/git/command-runner/missing-git-spawn-cwd.ts index f8d26ac285c..76623f8b2bc 100644 --- a/src/main/git/command-runner/missing-git-spawn-cwd.ts +++ b/src/main/git/command-runner/missing-git-spawn-cwd.ts @@ -4,18 +4,18 @@ import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { isMissingCommandBinaryError } from '../exec-error' const MAX_KNOWN_MISSING_CWDS = 256 -// Why: in memory only; a deleted project folder otherwise spawns a failing Git on every sweep. +// Remember missing folders in memory to avoid a failing Git spawn on every sweep. const knownMissingCwds = new Set() type GitCwdOptions = { cwd?: string; wslDistro?: string; signal?: AbortSignal } -// Why: only native routes; WSL and UNC absence is not a cheap local answer, and loss of contact is not proof. +// Cache native absence only; unreachable WSL/UNC paths do not prove deletion. function recordableCwd({ cwd, wslDistro }: GitCwdOptions): string | null { const routesThroughWsl = process.platform === 'win32' && Boolean(wslDistro) return cwd && !routesThroughWsl && !/^[\\/]{2}/.test(cwd) ? cwd : null } -// Why stat: spawn's chdir follows symlinks, so a symlinked root whose target is gone is absent too. +// Match spawn chdir semantics: stat follows symlinked roots. function isCwdAbsent(cwd: string): Promise { return stat(cwd).then(() => false, isDefinitiveAbsence) } @@ -24,7 +24,7 @@ async function noteMissingCwd(error: unknown, cwd: string, signal?: AbortSignal) if (!isMissingCommandBinaryError(error) || signal?.aborted || knownMissingCwds.has(cwd)) { return } - // Why re-check: concurrent first failures for one folder must record and warn once. + // Recheck after stat so concurrent failures record and warn once. if (!(await isCwdAbsent(cwd)) || knownMissingCwds.has(cwd)) { return } @@ -57,7 +57,7 @@ export function runUnlessGitCwdKnownMissing( knownMissingCwds.delete(cwd) return runAndNote() } - // Why: same shape as the real spawn failure, so every caller keeps its existing result. + // Preserve the spawn failure shape expected by existing callers. throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git', diff --git a/src/main/git/command-runner/spawned-command-tree-kill.ts b/src/main/git/command-runner/spawned-command-tree-kill.ts index 30ac7456e42..d7fee795dfc 100644 --- a/src/main/git/command-runner/spawned-command-tree-kill.ts +++ b/src/main/git/command-runner/spawned-command-tree-kill.ts @@ -19,15 +19,14 @@ export function killSpawnedCommandTree( if ( !admitSelfInitiatedTreeKill({ pid, site: 'git-command-tree-kill', scope: 'win-taskkill-tree' }) ) { - // Refusal blocks the pid-addressed tree walk, never the termination: the - // handle-addressed root kill cannot reach a recycled pid. + // A refused PID tree walk still permits handle-based root termination without risking a reused PID. child.kill() return Promise.resolve() } return new Promise((resolve) => { let killer: ChildProcess try { - // Why: Windows shims/wsl.exe own descendants; wait for /t tree cleanup so a timed-out command can't outlive its probe. + // Await Windows tree cleanup so shim/WSL descendants cannot outlive their probe. killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true diff --git a/src/main/git/command-runner/wsl-aware-spawn.ts b/src/main/git/command-runner/wsl-aware-spawn.ts index ff87c4bee5d..5567dc941ba 100644 --- a/src/main/git/command-runner/wsl-aware-spawn.ts +++ b/src/main/git/command-runner/wsl-aware-spawn.ts @@ -2,10 +2,6 @@ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' import { resolveCommand } from './wsl-command-resolution' -/** - * 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[], diff --git a/src/main/git/command-runner/wsl-command-resolution-object-store.test.ts b/src/main/git/command-runner/wsl-command-resolution-object-store.test.ts index 9585a2e8874..e1200a2f41a 100644 --- a/src/main/git/command-runner/wsl-command-resolution-object-store.test.ts +++ b/src/main/git/command-runner/wsl-command-resolution-object-store.test.ts @@ -24,8 +24,7 @@ async function alternatesSeenByWslGit(profileAlternates: string | undefined): Pr return result.stdout.trim() } -// Why pin this: a WSL login profile that sets GIT_ALTERNATE_OBJECT_DIRECTORIES used to replace a -// WSLENV-forwarded value, so a quarantined merge-tree lost the real object store and failed. +// Login profiles must not replace the quarantine's WSLENV-forwarded object-store values. describe.skipIf(originalPlatform === 'win32')('WSL Git object-store variables', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index 9be36f40a89..8fc936f70c1 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -15,7 +15,7 @@ import { import { translateArgForWsl, translateArgsForWsl } from './wsl-path-translation' import { resolveWslInteropSpawnCwd } from '../../wsl-interop-spawn-directory' -// Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted. +// Carry Git locale pins in the WSL command; these assignment values are shell-safe. const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) .map(([key, value]) => `${key}=${value}`) .join(' ') @@ -23,12 +23,11 @@ const GIT_OUTPUT_LOCALE_ENV_ARGS = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV).m ([key, value]) => `${key}=${value}` ) -// Why on the command string: a login profile runs first and could replace WSLENV-forwarded values; -// alternates append to whatever the profile set, as Git's own scratch object stores do. +// Override object-store values after login profiles run, appending profile alternates. function wslGitObjectStorePrefix(env: NodeJS.ProcessEnv | undefined): string { const objectDirectory = env?.GIT_OBJECT_DIRECTORY const alternates = env?.GIT_ALTERNATE_OBJECT_DIRECTORIES - // Why compare with process.env: only a quarantine's own values, never a Windows-side inherited one. + // Forward only this quarantine's overrides, never inherited Windows-side paths. if ( objectDirectory === undefined || alternates === undefined || @@ -46,7 +45,6 @@ export type ResolvedCommand = { binary: string args: string[] cwd: string | undefined - /** Non-null when the command was routed through WSL. */ wsl: WslPathInfo | null wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null /** Present only when the caller opted into a fenced login-shell read. */ @@ -56,7 +54,6 @@ export type ResolvedCommand = { let defaultWslDistroOverride: string | null = null -// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails. export function setDefaultWslDistroOverride(distro: string | null): void { defaultWslDistroOverride = distro } @@ -69,12 +66,7 @@ export function resolveDefaultWslCli( return distro ? resolveCommand(command, args, undefined, distro) : null } -/** - * Resolve whether a command invocation should be routed through wsl.exe. - * - * Why `bash -c "cd … && …"` instead of `--cd`: wsl.exe's --cd fails with - * ERROR_PATH_NOT_FOUND under Node's execFile/spawn in some configs. - */ +/** Use shell cd for WSL cwd: wsl.exe --cd can fail with ERROR_PATH_NOT_FOUND under Node spawning. */ export function resolveCommand( command: string, args: string[], @@ -88,11 +80,7 @@ export function resolveCommand( terminationBarrier?: boolean /** Pre-quoted shell expression that names the program inside WSL, replacing `command`. */ wslShellCommand?: string - /** - * Exit with this code when the `cd` fails, instead of letting `&&` swallow it as the shell's - * own exit 1. Why: ripgrep also exits 1 for "no matches", so an unreachable workspace would - * otherwise be indistinguishable from an empty result. - */ + /** Distinguish failed cd from rg's no-match exit 1. */ cwdFailureExitCode?: number } = {} ): ResolvedCommand { @@ -100,8 +88,7 @@ export function resolveCommand( return { binary: command, args, cwd, wsl: null, wslMode: null } } - // Why: global gh callers (rate_limit, listAccessibleProjects) have no cwd to derive a distro from; a distro hint still routes through wsl.exe. - // TODO(wsl-default-distro): no default-distro setting yet, so override-less global gh callers fall back to host gh.exe (ENOENT on WSL-only installs). + // Distro hints route global CLI calls that have no cwd to identify a WSL guest. const cwdWsl = cwd ? parseWslPath(cwd) : null const wsl: WslPathInfo | null = cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null) @@ -110,15 +97,14 @@ export function resolveCommand( } const translatedArgs = translateArgsForWsl(args) - // Why: env on wsl.exe stays Windows-side (WSLENV forwards only named vars), so the locale must ride the command string (issue #7808). + // WSL does not inherit unlisted Windows env vars; carry locale pins in the command. const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ${wslGitObjectStorePrefix(options.env)}` : '' const escapedCommand = options.wslShellCommand ?? quotePosixShell(command) - // Why: shell-escape each arg to prevent word splitting / glob expansion inside the bash -c string. + // Quote each argument against shell splitting and expansion. const escapedArgs = translatedArgs.map(quotePosixShell) - // Why: prepend `cd &&` for a UNC cwd; skip it when only a distro override was given (global gh needs no cwd). const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) const invocation = `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` const enterCwd = linuxCwd @@ -155,10 +141,7 @@ export function resolveCommand( } if (options.useWslLoginShell) { - // Why opt-in: the login shell is interactive for bash/zsh, so its rc output - // lands on stdout ahead of the payload. Callers that buffer the whole stream - // fence it; streaming consumers (git grep, ls-files -z) must not, because a - // marker would be glued onto their first record. + // Fence only buffered reads: login rc output contaminates stdout, but markers would corrupt streaming records. if (options.captureLoginShellOutput) { const captured = buildWslCapturedLoginShellCommand(shellCmd) return withWslProcessGroupTermination( @@ -189,10 +172,7 @@ export function resolveCommand( { binary: 'wsl.exe', args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), - // Why: the `cd` inside bash -c handles the Linux directory. This names an - // explicit Windows directory anyway, because `undefined` makes - // CreateProcessW inherit the parent's — which is a deletable WSL UNC path - // when Orca was launched from a worktree (#16463). + // Use a stable Win32 spawn cwd; inheriting Orca's deletable UNC cwd can break CreateProcessW. cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'non-login-shell' diff --git a/src/main/git/command-runner/wsl-host-failure.test.ts b/src/main/git/command-runner/wsl-host-failure.test.ts index 4b495817f6f..aaef4140a26 100644 --- a/src/main/git/command-runner/wsl-host-failure.test.ts +++ b/src/main/git/command-runner/wsl-host-failure.test.ts @@ -71,7 +71,6 @@ describe('wsl.exe host failure classification', () => { stderr: 'fatal: not a git repository\n' }) expect(readWslHostFailureDiagnostic(guestFailure, WSL_COMMAND)).toBeNull() - // Same exit code, but wsl.exe was never involved. expect( readWslHostFailureDiagnostic(hostFailure(WSL_DIAGNOSTIC), { binary: 'git', @@ -93,7 +92,6 @@ describe('wsl.exe host failure classification', () => { expect(error.message).toContain('kali-linux') expect(error.wslHostFailure).toBe(true) expect(error.wslDistro).toBe('kali-linux') - // The original failure detail must survive for callers that classify on it. expect(error.code).toBe(4294967295) expect(error.message).toContain('Command failed: wsl.exe') }) diff --git a/src/main/git/command-runner/wsl-host-failure.ts b/src/main/git/command-runner/wsl-host-failure.ts index 74f98509d0b..2358678ff4c 100644 --- a/src/main/git/command-runner/wsl-host-failure.ts +++ b/src/main/git/command-runner/wsl-host-failure.ts @@ -10,14 +10,7 @@ function outputText(value: unknown): string { return Buffer.isBuffer(value) ? value.toString('utf8') : '' } -/** - * The message wsl.exe prints when it — not the guest — failed: a distro that was renamed or - * removed, or a VM that would not start. - * - * Why this needs decoding at all: wsl.exe exits 0xFFFFFFFF, leaves stderr EMPTY, and writes - * `Error code: Wsl/Service/WSL_E_*` to stdout, so every caller that reports stderr reports nothing. - * NULs are stripped because a wsl.exe that ignores WSL_UTF8 writes that line as UTF-16LE (#9010). - */ +/** Decode WSL launch diagnostics from stdout, stripping UTF-16LE NULs when WSL_UTF8 was ignored. */ export function readWslHostFailureDiagnostic( error: unknown, command: ResolvedCommand @@ -32,7 +25,7 @@ export function readWslHostFailureDiagnostic( stderr?: unknown } const exitCode = typeof code === 'number' ? code : typeof status === 'number' ? status : null - // A guest failure always explains itself on stderr; an empty one plus this exit is the host. + // Recognize the host launch-failure shape without reclassifying guest stderr. if (exitCode !== WSL_HOST_FAILURE_EXIT_CODE || outputText(stderr).trim().length > 0) { return null } @@ -40,10 +33,7 @@ export function readWslHostFailureDiagnostic( return diagnostic.length > 0 ? diagnostic : 'wsl.exe reported no diagnostic.' } -/** - * Move a wsl.exe host failure into the error's message, which is what `git.exec` spans record ahead - * of the stack. Left untouched when the failure came from git itself. - */ +/** Put WSL launch diagnostics in the message recorded by git.exec; preserve guest errors. */ export function annotateWslHostFailure(error: unknown, command: ResolvedCommand): unknown { const diagnostic = readWslHostFailureDiagnostic(error, command) if (diagnostic === null || !(error instanceof Error) || !command.wsl) { diff --git a/src/main/git/command-runner/wsl-path-translation.ts b/src/main/git/command-runner/wsl-path-translation.ts index a3cdfd2079a..5aae39c0fbe 100644 --- a/src/main/git/command-runner/wsl-path-translation.ts +++ b/src/main/git/command-runner/wsl-path-translation.ts @@ -1,23 +1,16 @@ import { parseWslPath, toWindowsWslPath } from '../../wsl' -/** - * Translate Windows-style path arguments to Linux paths for commands run in WSL. - * - * Why: callers pass Windows paths as git arguments, which WSL git can't read. - * UNC paths (\\wsl.localhost\…) become native Linux; drive paths (C:\…) → /mnt/c/… - */ +/** Windows arguments need Linux spellings for WSL Git: UNC paths become native and drives map under /mnt. */ export function translateArgsForWsl(args: string[]): string[] { return args.map(translateArgForWsl) } export 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() @@ -28,10 +21,7 @@ export function translateArgForWsl(arg: string): string { return arg } -/** - * Translate absolute Linux paths in git output back to Windows UNC paths. - * Why: git-in-WSL emits Linux-native paths, but Orca reads files via Node fs, which needs Windows UNC. - */ +/** Map Linux worktree output to UNC paths Node can open on Windows. */ export function translateWslOutputPaths( output: string, originalCwd: string, @@ -43,7 +33,6 @@ export function translateWslOutputPaths( return output } - // Rewrite absolute Linux paths in structured git output (e.g. "worktree /home/user/repo/feature") to Windows UNC. return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) => toWindowsWslPath(linuxPath, distro) ) diff --git a/src/main/git/compare-base-ref-fetch.ts b/src/main/git/compare-base-ref-fetch.ts index 31b9482cb4c..5822158b42f 100644 --- a/src/main/git/compare-base-ref-fetch.ts +++ b/src/main/git/compare-base-ref-fetch.ts @@ -1,17 +1,6 @@ -// Why: PR (GitHub) and MR (GitLab) base resolution both need this exact -// trade-off, and both regressed the same way before; one copy keeps them from -// drifting on the next fix. - type CompareBaseGitExec = (args: string[]) => Promise<{ stdout: string }> -/** - * Refresh the compare base ref, reporting whether callers may keep it. - * - * Why: dropping compareBaseRef on any fetch failure makes worktree create fall - * back to the base branch — the review head itself for fork reviews — so Source - * Control diffs the worktree against itself. Keep the base whenever the local - * ref still resolves; only truly missing/absent refs lose it. - */ +/** Keep a locally resolvable base after fetch failure; dropping it can make fork reviews diff against themselves. */ export async function fetchCompareBaseRefWithLocalFallback(options: { compareBaseRef: string | undefined fetchCompareBaseRef: (compareBaseRef: string) => Promise diff --git a/src/main/git/create-event-repo-probe.test.ts b/src/main/git/create-event-repo-probe.test.ts index 2bd993f0be1..a03829c8bdd 100644 --- a/src/main/git/create-event-repo-probe.test.ts +++ b/src/main/git/create-event-repo-probe.test.ts @@ -81,7 +81,6 @@ describe('probeCreateEventRepoFacts', () => { it('does not count a spare whose admin directory Git has not written yet', async () => { await registerWorktree('feature-a') - // A spare that has only just started has no entry under .git/worktrees, so nothing to drop. expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ worktreeCount: 2 }) }) diff --git a/src/main/git/create-event-repo-probe.ts b/src/main/git/create-event-repo-probe.ts index 6725c6948f5..b607b144e8e 100644 --- a/src/main/git/create-event-repo-probe.ts +++ b/src/main/git/create-event-repo-probe.ts @@ -6,7 +6,6 @@ import { parseWorktreePreparationOwnerPid } from '../../shared/worktree/create-p /** Existence only: the hook's content is never read. */ export type PostCheckoutHookPresence = 'present' | 'absent' | 'custom_hooks_path' | 'unknown' -/** What the create event says about the repo, read from its `.git` directory. */ export type CreateEventRepoFacts = { postCheckoutHook: PostCheckoutHookPresence /** Entries in the index header, i.e. tracked files; absent when the count is missing or unreliable. */ @@ -19,7 +18,7 @@ const PROBE_TIMEOUT_MS = 2_000 // Shorter than the whole probe, so a slow per-worktree read cannot cost the other facts. const WORKTREE_COUNT_TIMEOUT_MS = 1_500 -/** A hung network or WSL mount must not hold the caller; every answer here is best-effort. */ +/** Mount reads are best-effort and must not hold the caller indefinitely. */ async function withTimeout(operation: Promise, ms: number, fallback: T): Promise { let timer: ReturnType | undefined const timeout = new Promise((resolve) => { @@ -34,12 +33,8 @@ async function withTimeout(operation: Promise, ms: number, fallback: T): P } /** - * Reads, from files only and never by spawning Git, whether `git worktree add` in this repo will - * run a repo-local post-checkout hook, how many files the repo tracks, and how many worktrees it has. - * - * A `core.hooksPath` in the repo's config is reported as such rather than followed, and global or - * included config is not read, so a hooks path set there reads as `absent`. A `.git` file (a repo - * registered from a linked worktree or submodule) needs Git to resolve, so it yields no facts. + * Read repo-local facts without spawning Git; linked-worktree/submodule .git files yield none. + * Report local core.hooksPath without following it; global/included configuration is not read. */ export function probeCreateEventRepoFacts( repoPath: string, @@ -80,11 +75,7 @@ async function readFacts( } } -/** - * The worktree count from Git's registry of linked worktrees, since the create itself no longer - * lists them. Like the listing, an entry whose lock reason names an Orca preparation is a prepared - * checkout, not the user's worktree, whichever process or crash left it. - */ +/** Exclude prepared checkouts from every process, including crash leftovers. */ async function readWorktreeCount(gitDir: string): Promise { const registry = path.join(gitDir, 'worktrees') try { @@ -108,11 +99,7 @@ async function readWorktreeCount(gitDir: string): Promise { /** `core.sparseCheckout` or `index.sparse` set to anything Git reads as true, bare key included. */ const SPARSE_CHECKOUT_ON = /^\s*sparse(checkout)?\s*(=(?!\s*(false|no|off|0)\s*$).*)?$/im -/** - * The entry count from the index header (`DIRC`, version, big-endian count), which is the same in - * every index version. A split index keeps entries elsewhere and a sparse index collapses them into - * directories, so with either possible (any sparse checkout) the count is left out. - */ +/** Read DIRC entry count; omit split/sparse indexes because their header count is not the tracked-file total. */ async function readIndexEntryCount( gitDir: string, config: string | null diff --git a/src/main/git/exact-ref-probe.test.ts b/src/main/git/exact-ref-probe.test.ts index b1ba0a3cd12..431ad918543 100644 --- a/src/main/git/exact-ref-probe.test.ts +++ b/src/main/git/exact-ref-probe.test.ts @@ -68,8 +68,7 @@ describe('probeExactRefs', () => { await probeExactRefs(runGit, refs) expect(runGit).toHaveBeenCalledTimes(20) - // Equality, not a ceiling: 20 refs saturate the pool, so a regression to - // serial probing has to fail here. + // Require saturation so a regression to serial probing fails. expect(maxActive).toBe(8) }) }) @@ -92,8 +91,7 @@ describe('probeAnyExactRef', () => { expect(runGit.mock.calls.length).toBeLessThanOrEqual(8) }) it('treats exit 1 with stderr as an inconclusive probe, not an absent ref', async () => { - // Why: `wsl.exe` exits 1 for its own launch failures. Reading that as - // absence would collapse `unverifiable` into `exited`. + // WSL launch failure is unknown ref state, never proof of absence. const runGit = vi.fn(async () => { throw Object.assign(new Error('wsl fail'), { code: 1, diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts index b8857a3c1f8..879e388113a 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -121,9 +121,7 @@ export type ExactRefProbeStdinExec = ( options: ExactRefProbeExecOptions & { stdin: string } ) => Promise<{ stdout: string }> -/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data - * rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`. - * A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */ +/** Batch exact probes in one child; missing refs remain data rather than aborting the batch. */ export async function probeAnyExactRefBatched( runGit: ExactRefProbeStdinExec, refs: readonly string[], @@ -143,7 +141,7 @@ export async function probeAnyExactRefBatched( } catch { return { found: false, unknown: true } } - // Trim per line so a CRLF-translating host's `\r` does not become part of the type. + // Trim CRLF so carriage returns cannot become part of the type. const lines = stdout .split('\n') .map((line) => line.trim()) diff --git a/src/main/git/exec-error.ts b/src/main/git/exec-error.ts index 6e0385091d5..414d2b5a678 100644 --- a/src/main/git/exec-error.ts +++ b/src/main/git/exec-error.ts @@ -1,20 +1,6 @@ -/** - * Pure helpers for reading diagnostics out of a subprocess (git/gh/glab) - * rejection. Kept in a standalone module (not `runner.ts`) so lightweight - * consumers — e.g. PR-refresh error classification — can import them without - * pulling in the runner's heavy execution machinery, and so tests that mock - * `./runner` still get the real implementations. - */ +// Keep diagnostics independent of runner imports and mocks. -/** - * Extract stderr/stdout 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. - */ +/** Prefer stdout/stderr fields: Node may truncate or omit them from message. */ export function extractExecError(err: unknown): { stderr: string; stdout: string } { if (err && typeof err === 'object') { const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } @@ -53,16 +39,7 @@ export function isMissingCommandBinaryError(err: unknown): boolean { ) } -/** - * 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: - * " 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. - */ +/** Parse Retry-After seconds or HTTP dates; callers can surface explicit server waits. */ export function parseRetryAfterMs(stderr: string): number | null { const raw = findRetryAfterHeaderValue(stderr) if (raw === null) { diff --git a/src/main/git/fetch-error-classification.ts b/src/main/git/fetch-error-classification.ts index 105f57bf832..21350a5faf8 100644 --- a/src/main/git/fetch-error-classification.ts +++ b/src/main/git/fetch-error-classification.ts @@ -3,11 +3,7 @@ import { isExecKilledError } from '../../shared/git-remote-error' import { isMissingRemoteRefGitError } from '../../shared/git-missing-remote-ref' export { isMissingRemoteRefGitError } from '../../shared/git-missing-remote-ref' -// Why: allowlist, not blocklist — soft-keeping a durable review-head ref is -// only safe when the fetch plainly died in transport. A deleted PR head, auth -// failure, or stale-relay method-not-found must surface, or the caller checks -// out a dead/unauthorized tip. Covers raw git stderr and the relay's -// normalized messages ("Network error. Check your connection.", "… timed out"). +// Keep durable review heads only after transport failures; auth, deleted refs, and stale RPC failures must surface. const TRANSIENT_FETCH_ERROR_PATTERNS = [ 'timed out', 'timeout', diff --git a/src/main/git/fork-remote-refspec.ts b/src/main/git/fork-remote-refspec.ts index c924fdb2cb2..192a530d427 100644 --- a/src/main/git/fork-remote-refspec.ts +++ b/src/main/git/fork-remote-refspec.ts @@ -1,23 +1,5 @@ -// Why: a fork-PR remote added with a bare `git remote add` writes the default -// `+refs/heads/*:refs/remotes//*` refspec, so any later plain `git fetch ` -// (user, agent, or Orca's own Fetch action) imports the fork's entire branch set -- -// a large fork can carry 1000+ branches. Every mint/reuse/migration path funnels -// through `ensureRemoteTracksBranchNarrowly` so a fork remote never tracks more than -// the branches Orca actually knows about (see #17828). -// -// The tracked-branch refspec source carries a trailing `*` (`refs/heads/*`) -// rather than being a literal exact match. This is deliberate: Orca is terminal-centric -// (agents run raw git in worktrees), and a *bare* `git fetch` inside a fork-PR worktree -// resolves to this remote via `branch..remote` -- it is the single most common -// fetch shape here, more common than `git fetch `. A literal refspec -// makes that fetch (and `git fetch `) hard-fail with `couldn't find remote ref` -// the moment the tracked branch is deleted/renamed upstream, where the old wide default -// silently no-op'd. A trailing `*` keeps git's wildcard zero-match tolerance (verified -// against real git: exit 0, and `--prune` correctly reclaims the ref once it can't be -// found) while still bounding the import to branches sharing that literal prefix -- -// not the fork's entire branch set. The residual widening (an unrelated sibling branch -// that happens to share the prefix, e.g. `fix` also matching `fix-v2`) is accepted as -// far narrower than the bug this fixes. +// Narrow fork fetches to known branch prefixes instead of importing every branch. +// A trailing * permits zero-match fetches after deletion; prefix-sharing sibling branches may also match. export type GitExecFn = ( args: string[], cwd: string @@ -55,13 +37,7 @@ function refspecSource(refspec: string): string { return refspec.replace(/^\+/, '').split(':')[0]! } -/** - * True if `branchName`'s remote-tracking ref already exists locally under `remoteName`. - * Used to skip a redundant fetch on the common repeat-materialize case (the ref was - * already pulled in by an earlier mint/fetch) while still fetching it on demand the - * first time a sibling worktree widens an existing remote onto a new branch -- a bare - * refspec-config widen never itself imports anything (see `ensureRemoteTracksBranchNarrowly`). - */ +/** Configuring a refspec does not fetch it; skip fetching only when its tracking ref already exists. */ export async function forkRemoteTrackingRefExists( execGit: GitExecFn, repoPath: string, @@ -79,14 +55,7 @@ export async function forkRemoteTrackingRefExists( } } -/** - * True only if `remote..url` is actually set. Deliberately plumbing (`config --get`), - * not porcelain `git remote get-url` -- the latter falls back to echoing the remote *name* - * as a bogus "URL" when the section exists but has no url key (verified against real git), - * which would hide exactly the config-only ghost state this guards against (see - * `worktree-push-target-refspec-migration.ts`'s concurrent-removal race with #17842's - * reconciliation sweep). - */ +/** config --get detects URL-less remotes; remote get-url can return the remote name as a bogus URL. */ export async function remoteHasUrl( execGit: GitExecFn, repoPath: string, @@ -100,13 +69,7 @@ export async function remoteHasUrl( } } -/** - * Adds `branchName` to `remoteName`'s tracked set without dropping any other branch - * already tracked (a sibling worktree on the same fork may track a different branch -- - * see #17828 reuse-path discussion on why this widens rather than replaces). Replaces - * the wide default wildcard refspec outright since nothing should still depend on it. - * Also pins `tagOpt=--no-tags` so tags never auto-follow into the shared namespace. - */ +/** Preserve sibling worktrees' branch refspecs, replace the broad default, and disable tag auto-follow. */ export async function ensureRemoteTracksBranchNarrowly( execGit: GitExecFn, repoPath: string, @@ -116,9 +79,7 @@ export async function ensureRemoteTracksBranchNarrowly( const desired = buildNarrowForkFetchRefspec(remoteName, branchName) const existing = await getRemoteFetchRefspecs(execGit, repoPath, remoteName) if (!existing.includes(desired)) { - // Strip the wide default outright, and any stray literal (non-suffixed) entry for - // this exact branch -- e.g. a hand-edited config -- since it would shadow the same - // source prefix and defeats the point of the trailing `*`. + // Drop broad defaults and exact entries that would defeat wildcard zero-match tolerance. const literalForBranch = `refs/heads/${branchName}` const toDrop = existing.includes(wildcardForkFetchRefspec(remoteName)) ? existing @@ -135,14 +96,7 @@ export async function ensureRemoteTracksBranchNarrowly( await execGit(['config', `remote.${remoteName}.tagOpt`, '--no-tags'], repoPath) } -/** - * Removes every `remote..fetch` entry, leaving the remote pushable but importing - * nothing on a plain fetch. For a fork remote with no branch pinning it at all (no - * worktree metadata, no `branch.*.remote`/`.pushRemote` config), there's nothing to - * narrow *to* -- but leaving the wide default in place means the next plain fetch still - * re-imports the fork's entire branch set. See the migration sweep's caller for the - * provenance check gating when this is safe to call. - */ +/** Clear fetch refspecs for an unpinned fork remote without disabling pushes; callers must verify provenance. */ export async function clearForkRemoteFetchRefspec( execGit: GitExecFn, repoPath: string, @@ -151,16 +105,7 @@ export async function clearForkRemoteFetchRefspec( await execGit(['config', '--unset-all', `remote.${remoteName}.fetch`], repoPath).catch(() => {}) } -/** - * Deletes remote-tracking refs under `refs/remotes//` that fall outside - * `keepBranches`. Needed because migrating away from the old wide default leaves behind - * refs for every branch the earlier wide fetch already pulled in, and a plain fetch under - * the new narrow refspec never revisits (or reclaims) a branch outside its own prefix. - * A tracking ref is kept if its branch name equals, or starts with, an entry in - * `keepBranches` -- matching what `buildNarrowForkFetchRefspec`'s trailing `*` would also - * match, so this never deletes a ref the configured refspec will just re-fetch anyway. - * Returns the deleted ref names. Never touches `HEAD`. - */ +/** Prune refs outside kept branch prefixes, preserving HEAD and anything the narrow refspec would fetch again. */ export async function pruneUntrackedForkRemoteRefs( execGit: GitExecFn, repoPath: string, diff --git a/src/main/git/fork-remote-stale-branch-refspec.test.ts b/src/main/git/fork-remote-stale-branch-refspec.test.ts index 50cb4ee445b..4edd2e8307a 100644 --- a/src/main/git/fork-remote-stale-branch-refspec.test.ts +++ b/src/main/git/fork-remote-stale-branch-refspec.test.ts @@ -92,7 +92,7 @@ describe('fetchForkRemoteWithStaleRefspecRepair', () => { } return { stdout: '', stderr: '' } }) - // Why: mirrors real git reporting exactly one missing ref per fetch attempt (see #17828 PR). + // Git reports only one missing ref per failed fetch. const staleBranches = ['refs/heads/gone-a', 'refs/heads/gone-b'] const runFetch = vi.fn(async () => { const stillStale = staleBranches.find((branch) => refspecs.some((r) => r.includes(branch))) diff --git a/src/main/git/fork-remote-stale-branch-refspec.ts b/src/main/git/fork-remote-stale-branch-refspec.ts index 992e7c28f69..bd242c0c0df 100644 --- a/src/main/git/fork-remote-stale-branch-refspec.ts +++ b/src/main/git/fork-remote-stale-branch-refspec.ts @@ -1,11 +1,4 @@ -// Why: Orca's fork-remote refspecs (#17828) carry a trailing `*` specifically so a -// deleted/renamed upstream branch degrades to a silent zero-match fetch, not a hard -// failure -- see `buildNarrowForkFetchRefspec`'s comment. That is now the primary -// defense. This wrapper is a cheap backstop for the one case the `*` doesn't cover: a -// truly literal (non-wildcard) `refs/heads/` refspec somehow ends up configured -// (hand-edited `.git/config`, a future regression, etc), which -- unlike a wide refspec's -// silently-skipped wildcards -- exits nonzero with "couldn't find remote ref" and fetches -// NOTHING for that remote, not even other branches it also tracks. +// Literal stale refspecs can fail the entire fetch; wildcard zero-match tolerance does not cover them. import { extractExecError } from './exec-error' import { removeStaleForkFetchRefspec, type GitExecFn } from './fork-remote-refspec' @@ -15,15 +8,10 @@ export function parseMissingForkBranchRef(stderr: string): string | null { return MISSING_REMOTE_REF_PATTERN.exec(stderr)?.[1] ?? null } -// Bounds the repair loop; git reports one missing ref per fetch attempt (see #17828 PR -// description), so this comfortably covers even a fork remote tracking many stale branches. +// Git reports one missing ref per fetch; bound the repair loop. const MAX_STALE_REFSPEC_REPAIR_ATTEMPTS = 20 -/** - * Runs `runFetch`, and on a "couldn't find remote ref" failure for a refspec this remote - * tracks, drops that one stale refspec and retries -- so a branch deleted upstream degrades - * to "no longer updates for that branch" instead of "fetch fails for the whole remote". - */ +/** Remove a configured literal refspec only when Git reports its source missing, then retry. */ export async function fetchForkRemoteWithStaleRefspecRepair( execGit: GitExecFn, repoPath: string, @@ -43,7 +31,6 @@ export async function fetchForkRemoteWithStaleRefspecRepair( ) { throw error } - // loop: retry now that the dead refspec is gone } } throw new Error(`Exceeded stale fork-remote refspec repair attempts for "${remoteName}"`) diff --git a/src/main/git/fork-sync.ts b/src/main/git/fork-sync.ts index 314a2379048..f14a6c66c27 100644 --- a/src/main/git/fork-sync.ts +++ b/src/main/git/fork-sync.ts @@ -14,8 +14,6 @@ export async function gitSyncForkDefaultBranch( expectedUpstream: GitForkSyncExpectedUpstream, options: GitRuntimeOptions = {} ): Promise { - // Compose the caller's cancel signal with the 60s timeout so neither is lost — - // the caller's signal was previously clobbered by the timeout controller. const signal = abortSignalWithTimeout(options.signal, 60_000) try { return await syncForkDefaultBranch( diff --git a/src/main/git/gh-rate-limit-breaker.test.ts b/src/main/git/gh-rate-limit-breaker.test.ts index cc0d675b90f..1a1352f04ed 100644 --- a/src/main/git/gh-rate-limit-breaker.test.ts +++ b/src/main/git/gh-rate-limit-breaker.test.ts @@ -168,9 +168,7 @@ describe('breaker state', () => { }) it('fires the reset probe with the tripping scope for non-default scopes', () => { - // Why: a WSL-only Windows install (or GHES) would otherwise stay on the - // blunt 5-minute fallback forever — the probe must learn which scope to - // query. + // Reset probes need the blocked host/runtime, including GHES and WSL-only installs. const probe = vi.fn() registerGhRateLimitResetProbe(probe) diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts index dce23864307..f5c8648de67 100644 --- a/src/main/git/gh-rate-limit-breaker.ts +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -1,32 +1,14 @@ -/** - * Global circuit breaker for gh CLI primary rate limits. - * - * Why: a primary rate-limit 403 means every further request in that bucket - * will fail until GitHub's reset time. Without shared state, a 90-repo - * Tasks-page fan-out keeps spawning gh subprocesses that each take a fresh - * 403 — burning main-thread spawn time and log noise while returning nothing - * (the Discord "countWorkItems failed ×90" storm). This module holds - * blocked-until state consulted by the runner before every gh spawn. - * - * Lives under git/ (not github/) with zero imports so both the runner and - * the github rate-limit prober can use it without an import cycle. - */ +/** Share primary-limit blocks across gh calls; no imports to avoid runner/prober cycles. */ export type GhRateLimitBucket = 'core' | 'search' | 'graphql' -// Why: a primary 403 does not carry the reset time. The search window resets -// every minute; core/graphql reset hourly but blocking a full hour on a guess -// would be too punishing, so pick a modest re-probe interval. The registered -// reset probe (gh api rate_limit — exempt from limits) refines these to the -// real reset time right after the breaker trips. +// Use short fallback blocks until the reset probe learns the true reset time. const FALLBACK_BLOCK_MS: Record = { search: 70_000, core: 5 * 60_000, graphql: 5 * 60_000 } -// Why: the runner derives scopes with this builder too — a format drift would -// silently split blocks from the default-scope reads below. export function ghRateLimitScopeKey(runtime: string, host: string): string { return `${runtime}:${host.toLowerCase()}` } @@ -35,17 +17,14 @@ export type GhRateLimitScopeParts = | { runtime: 'native'; host: string } | { runtime: 'wsl'; wslDistro: string; host: string } -// Why: the reset probe must spawn gh against the tripping scope's runtime and -// host. Parsing lives next to the builder so the key format stays private to -// this module instead of being string-split at call sites. +// Parse beside the builder so reset probes use the blocked host/runtime without duplicating the format. export function parseGhRateLimitScopeKey(scope: string): GhRateLimitScopeParts | null { if (scope.startsWith('native:')) { const host = scope.slice('native:'.length) return host ? { runtime: 'native', host } : null } if (scope.startsWith('wsl:')) { - // Hosts may carry a port (host:port), so only the first colon after the - // distro is a separator. + // Split only after the distro; host values may contain a port. const rest = scope.slice('wsl:'.length) const sep = rest.indexOf(':') if (sep <= 0 || sep === rest.length - 1) { @@ -121,8 +100,7 @@ export function classifyGhRateLimitBucket(args: readonly string[]): GhRateLimitB return 'core' } -/** `gh api rate_limit` is exempt from rate limits and must never be blocked — - * it is the probe that refines the breaker's reset time. */ +/** Never block rate_limit: it refines the breaker's reset time. */ export function isGhRateLimitProbe(args: readonly string[]): boolean { if (args[0] !== 'api') { return false @@ -136,10 +114,7 @@ export function isGhLocalOnlyCommand(args: readonly string[]): boolean { return args[0] === 'auth' && args[1] === 'token' } -/** - * Primary rate-limit detection. Secondary limits ("secondary rate limit") - * carry Retry-After and are handled by the runner's transient retry logic. - */ +/** Recognize primary limits; secondary limits use Retry-After handling. */ export function isGhPrimaryRateLimitStderr(stderr: string): boolean { const s = stderr.toLowerCase() return s.includes('api rate limit exceeded') && !s.includes('secondary rate limit') @@ -152,9 +127,7 @@ export function recordGhPrimaryRateLimit( ): void { const key = breakerKey(bucket, scope) const existing = blockedUntilMsByScopeAndBucket.get(key) ?? 0 - // Why: host scopes can originate from pasted GHES URLs. Refresh insertion - // order and cap retained blocks so a stream of failing hosts cannot grow the - // main-process map for the lifetime of the app. + // Bound pasted-host state and refresh insertion order so one-off failures cannot grow the map forever. blockedUntilMsByScopeAndBucket.delete(key) blockedUntilMsByScopeAndBucket.set(key, Math.max(existing, blockedUntilMs)) while (blockedUntilMsByScopeAndBucket.size > GH_RATE_LIMIT_BLOCK_MAX_ENTRIES) { @@ -189,8 +162,7 @@ export function getGhRateLimitBlockedUntilMs( blockedUntilMsByScopeAndBucket.delete(key) return null } - // Why: active blocks are hot entries; refresh their insertion order so a - // stream of one-off hostile hosts evicts cold scopes before an active one. + // Refresh active scopes so one-off hosts evict colder blocks first. blockedUntilMsByScopeAndBucket.delete(key) blockedUntilMsByScopeAndBucket.set(key, blockedUntil) return blockedUntil @@ -201,24 +173,18 @@ export function _getGhRateLimitBlockCount(): number { return blockedUntilMsByScopeAndBucket.size } -/** Register the (single) callback that refreshes precise reset times after a - * breaker trip. Registered by the github rate-limit prober at module load. */ +/** Register the GitHub prober's callback to refine reset times after a breaker trip. */ export function registerGhRateLimitResetProbe( probe: ((bucket: GhRateLimitBucket, scope: string) => void) | null ): void { resetProbe = probe } -/** Called by the runner when a gh spawn came back with a primary 403. */ export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void { recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket], scope) - // The probe receives the tripping scope so it can run gh against that - // runtime/host and refine the fallback block into the real reset time. try { resetProbe?.(bucket, scope) - } catch { - // best-effort refinement; the fallback block stands - } + } catch {} } export function createGhRateLimitBlockedError( @@ -226,8 +192,7 @@ export function createGhRateLimitBlockedError( blockedUntilMs: number ): Error & { stderr: string; ghRateLimitBlocked: true } { const resetsIn = Math.max(1, Math.ceil((blockedUntilMs - Date.now()) / 1000)) - // Why: "rate limit" (and no "HTTP 403") so classifyGhError maps this to - // rate_limited instead of permission_denied, matching a real gh failure. + // Match classifyGhError's rate_limited classification rather than HTTP 403 permission_denied. const message = `GitHub API rate limit exceeded (${bucket}); retrying in ~${resetsIn}s without spawning gh.` return Object.assign(new Error(message), { stderr: message, diff --git a/src/main/git/git-runtime-options.ts b/src/main/git/git-runtime-options.ts index b1ed5ac885c..429cae7cffc 100644 --- a/src/main/git/git-runtime-options.ts +++ b/src/main/git/git-runtime-options.ts @@ -18,12 +18,7 @@ export function gitOptionsForWorktree( } } -/** - * Options for a git invocation that only reads. Opting in explicitly keeps the - * shell-free WSL route from depending on `wsl-direct-git-read-commands` - * classifying the argv, which is a heuristic these call sites already know the - * answer to. - */ +/** Explicit read callers may use shell-free WSL without relying on argv classification. */ export function gitReadOptionsForWorktree( cwd: string, options: GitRuntimeOptions = {} diff --git a/src/main/git/git-status-read-lease-owner.ts b/src/main/git/git-status-read-lease-owner.ts index eeae5eedf95..05916a1838c 100644 --- a/src/main/git/git-status-read-lease-owner.ts +++ b/src/main/git/git-status-read-lease-owner.ts @@ -1,3 +1,2 @@ -// Why: the class moved to src/shared so the relay host can coalesce the same -// reads; this re-export keeps main's import path (and its tests) stable. +// Preserve main import paths while sharing read ownership with relay hosts. export { GitStatusReadLeaseOwner } from '../../shared/git-status-read-lease-owner' diff --git a/src/main/git/git-upstream-status-read-owner.ts b/src/main/git/git-upstream-status-read-owner.ts index c122674d9d2..ec745248e0b 100644 --- a/src/main/git/git-upstream-status-read-owner.ts +++ b/src/main/git/git-upstream-status-read-owner.ts @@ -2,12 +2,7 @@ import type { GitUpstreamStatus } from '../../shared/git-status-types' import type { GitPushTarget } from '../../shared/worktree/types' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -/** - * Why the exhaustive destructure: stableInFlightKey is JSON.stringify, which is not - * key-order stable, so push-target fields must be listed in a fixed order. Spreading - * the remainder into Record makes a newly added GitPushTarget field a - * compile error instead of a silently shared lease between two different targets. - */ +/** Fix key order for JSON.stringify and make new push-target fields fail the exhaustive type check. */ function pushTargetKeyParts(pushTarget: GitPushTarget): readonly unknown[] { const { remoteName, branchName, remoteUrl, remoteCreated, ...rest } = pushTarget const exhaustive: Record = rest diff --git a/src/main/git/git-username.ts b/src/main/git/git-username.ts index 03b2a69d2ff..43b8cf76576 100644 --- a/src/main/git/git-username.ts +++ b/src/main/git/git-username.ts @@ -5,17 +5,9 @@ import { resolveDefaultBaseRefViaExec } from './repo' const EXPLICIT_USERNAME_CONFIG_KEYS = ['github.user', 'user.username'] as const const GH_LOGIN_PROBE_TIMEOUT_MS = 2500 -// Why: a timeout-killed gh can leave a grandchild holding the stdio pipes, so -// the exec promise may settle long after the kill. The wall keeps the resolver -// on schedule either way (issue #7225: a hung gh froze startup for 127s). It -// must exceed ghExecFileAsync's full worst-case envelope — 3 attempts × -// GH_LOGIN_PROBE_TIMEOUT_MS plus its transient-retry backoff sleeps (250ms + -// 1000ms ≈ 8.75s total) — or a slow-but-recovering gh would be misread as -// stuck and start the retry cooldown. Retry-After sleeps can exceed any wall; -// bounding those is exactly what the wall is for. +// Allow all three 2.5s probes plus 1.25s backoff; cap longer Retry-After waits and stuck cleanup. const GH_LOGIN_PROBE_WALL_MS = 10_000 -// Why: a timed-out probe says nothing about the account, so don't pin '' for -// the whole session — retry after a cooldown instead of hammering a stuck gh. +// Timeout proves nothing about the login; retry after a cooldown instead of caching failure forever. const GH_LOGIN_TIMEOUT_RETRY_MS = 5 * 60 * 1000 const LOCAL_GIT_READ_TIMEOUT_MS = 5000 @@ -29,11 +21,7 @@ export function normalizeGitUsername(value: string): string { return localPart.replace(/^\d+\+/, '') } -/** - * Hosted account logins used as branch-prefix segments must be single-path - * tokens. Reject multi-line / JSON error bodies from a failed `gh api user` - * (rate-limit 403 still prints JSON on stdout) so they never become branch names. - */ +/** Reject failed API JSON/multiline output before it can become a branch prefix. */ export function isPlausibleHostedLogin(value: string): boolean { // Preserve GitHub's length/separator limits while allowing the EMU _shortcode suffix. return ( @@ -42,16 +30,10 @@ export function isPlausibleHostedLogin(value: string): boolean { ) } -// Not a check-ref-format rule: a login becomes one slash-free branch component, -// which a loose ref stores as a single filename (255-byte cap on ext4/APFS/NTFS). -// The ASCII-only charset below makes character count equal byte count. +// A login occupies one loose-ref filename; ASCII length equals the 255-byte filesystem limit. const MAX_BRANCH_SAFE_LOGIN_LENGTH = 255 -/** - * Provider-agnostic branch-safe token: GitLab/Bitbucket/self-hosted logins may - * carry `_`/`.` and run longer than GitHub's 39-char limit, so the strict - * GitHub rule must not gate explicitly configured usernames. - */ +/** Configured logins accept provider-specific characters and lengths beyond GitHub's rules. */ export function isBranchSafeHostedLogin(value: string): boolean { if (value.length > MAX_BRANCH_SAFE_LOGIN_LENGTH) { return false @@ -74,19 +56,13 @@ function normalizeConfiguredLogin(value: string): string { return normalized && isBranchSafeHostedLogin(normalized) ? normalized : '' } -/** - * A resolved username plus whether every probe on the way to it completed. - * Non-authoritative '' (a probe timed out) must not overwrite a previously - * persisted username; authoritative '' should clear one. - */ +/** Unknown empty logins preserve persisted values; authoritative empty logins clear them. */ export type ResolvedGitUsername = { username: string; authoritative: boolean } type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean } type GhLoginOutcome = { login: string; timedOut: boolean } -// gh reports one account for the whole machine, so the login is cached -// per-process rather than per-repo (mirrors the old sync cache). Timed-out -// probes use the soft retry timestamp instead of the permanent cache. +// Share gh login results across repos; timeouts use a retry timestamp instead of permanent caching. let cachedGhLogin: string | null = null let ghLoginTimedOutAt: number | null = null let ghLoginProbeInFlight: Promise | null = null @@ -96,9 +72,7 @@ function isExecTimeoutError(err: unknown): boolean { return false } const { code, killed, signal } = err as { code?: unknown; killed?: unknown; signal?: unknown } - // Why: on Windows a timeout kill surfaces as killed/SIGTERM with a null - // code, not ETIMEDOUT — the old ETIMEDOUT-only check let a stuck first - // probe fall through to a second equally stuck probe (issue #7225). + // Windows timeout kills can report killed/SIGTERM with no ETIMEDOUT code. return code === 'ETIMEDOUT' || killed === true || signal === 'SIGTERM' } @@ -114,8 +88,7 @@ async function runGhLoginProbe(args: string[]): Promise { const exec = ghExecFileAsync(args, { timeout: GH_LOGIN_PROBE_TIMEOUT_MS }).then( ({ stdout, stderr }) => ({ stdout, stderr, timedOut: false }), (err: unknown) => { - // Why: `gh auth status` reports the login on stderr with a non-zero - // exit when partially authenticated, so failures still carry output. + // Partially authenticated gh can return login details on stderr even with a nonzero exit. const { stdout, stderr } = extractExecError(err) return { stdout, stderr, timedOut: isExecTimeoutError(err) } } @@ -129,10 +102,7 @@ async function runGhLoginProbe(args: string[]): Promise { } } -// Why: `gh auth status` prints one block per account — the login line first, -// then `Active account: true/false`. Parse block-wise so a multi-account -// output resolves the ACTIVE account instead of whatever login line happens -// to follow the first `Active account: true` marker. +// Parse account blocks so the Active account marker selects its own login. function parseGhAuthStatusLogin(output: string): string { let currentLogin = '' let firstLogin = '' @@ -159,8 +129,7 @@ async function probeGhLoginOnce(): Promise { return { login: apiLogin, timedOut: false } } if (api.timedOut) { - // Why: if `gh api user` timed out, `gh auth status` is likely to hit the - // same stuck keychain/network path. Keep resolution bounded to one probe. + // Avoid a second stuck keychain/network probe after the API probe times out. return { login: '', timedOut: true } } const status = await runGhLoginProbe(['auth', 'status']) @@ -230,13 +199,7 @@ async function getConfiguredBranchRemote(repoPath: string, branch: string | null return remote === '.' ? '' : remote } -/** - * Faithful async port of the old candidate-ordered GitHub gate: only the - * repo's *effective* remote (current-branch remote, default-branch remote, - * default-base remote, origin, or a lone remote) may authorize the gh login. - * Why: a GitLab-primary repo with a secondary GitHub mirror must NOT pick up - * the GitHub account name as its branch prefix. - */ +/** Only the effective remote may authorize a GitHub prefix; a secondary GitHub mirror must not. */ async function localRepoHasEffectiveGitHubRemote(repoPath: string): Promise { const remoteList = await gitExecFileAsync(['remote'], { cwd: repoPath, @@ -278,13 +241,7 @@ async function localRepoHasEffectiveGitHubRemote(repoPath: string): Promise { @@ -294,14 +251,11 @@ export async function resolveLocalGitUsernameDetailed( cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS }) - // Why: config can hold free-form strings; only branch-safe logins become prefixes. const username = normalizeConfiguredLogin(stdout) if (username) { return { username, authoritative: true } } - } catch { - // Missing config keys are expected; try the next explicit username key. - } + } catch {} } if (await localRepoHasEffectiveGitHubRemote(repoPath)) { const outcome = await getGhLoginOutcome() @@ -309,7 +263,6 @@ export async function resolveLocalGitUsernameDetailed( return { username: outcome.login, authoritative: !outcome.timedOut } } } - // Author names become prefixes only when they already form a safe branch component. const authorName = (await readGitStdout(repoPath, ['config', '--get', 'user.name'])).trim() if (isBranchSafeHostedLogin(authorName)) { return { username: authorName, authoritative: true } diff --git a/src/main/git/hosted-remote-url.ts b/src/main/git/hosted-remote-url.ts index eb89710144c..22b911e8a7a 100644 --- a/src/main/git/hosted-remote-url.ts +++ b/src/main/git/hosted-remote-url.ts @@ -1,6 +1,5 @@ // Scoped local fork of the hosted-git-info@9.0.3 behavior Orca used. -// Why: source links only need GitHub/GitLab/Bitbucket remote parsing and file -// URL construction, so we keep that small surface local and tested. +// Keep only the provider parsing and file/commit URL construction needed by source links. type HostedRemoteProvider = 'github' | 'gitlab' | 'bitbucket' type HostedRemote = { @@ -23,8 +22,7 @@ const shorthandHosts: Record [])) return existing.filter((name) => !ignored.has(name)) } -/** - * Append a folder pattern to the worktree's .gitignore (creating it if absent), - * skipping the write if the exact line is already present. Returns true on write. - * - * `folderName` comes from the renderer, so it is restricted to the known-huge - * allowlist (single path segment, no separators/newlines) before being written - * — otherwise a crafted value could inject arbitrary lines into .gitignore. - */ +/** Append an allowlisted folder pattern once; reject renderer input that could inject arbitrary ignore lines. */ export async function appendFolderToGitignore( worktreePath: string, folderName: string @@ -61,9 +44,7 @@ export async function appendFolderToGitignore( let existingContent = '' try { existingContent = await readFile(gitignorePath, 'utf-8') - } catch { - // .gitignore doesn't exist yet — we'll create it below - } + } catch {} const alreadyListed = existingContent .split(/\r?\n/) .map((l) => l.trim()) @@ -71,7 +52,6 @@ export async function appendFolderToGitignore( if (alreadyListed) { return false } - // Why: keep a clean trailing newline whether or not the file ended with one. const needsLeadingNewline = existingContent.length > 0 && !existingContent.endsWith('\n') await appendFile(gitignorePath, `${needsLeadingNewline ? '\n' : ''}${line}\n`, 'utf-8') return true diff --git a/src/main/git/local-git-object-quarantine.test.ts b/src/main/git/local-git-object-quarantine.test.ts index 0ef6758a0db..28ebcb03e36 100644 --- a/src/main/git/local-git-object-quarantine.test.ts +++ b/src/main/git/local-git-object-quarantine.test.ts @@ -20,7 +20,6 @@ vi.mock('./worktree-list-reader', () => ({ })) vi.mock('../../shared/git-object-quarantine', async (importOriginal) => ({ ...(await importOriginal()), - // Why: hands the resolved objects dir straight to the command; these tests pin routing, not disk. createGitObjectQuarantine: ( resolve: () => Promise ): GitObjectQuarantine => ({ diff --git a/src/main/git/local-git-object-quarantine.ts b/src/main/git/local-git-object-quarantine.ts index 02db1002e75..e613ec87e9c 100644 --- a/src/main/git/local-git-object-quarantine.ts +++ b/src/main/git/local-git-object-quarantine.ts @@ -73,8 +73,7 @@ export function createLocalGitObjectQuarantine( ...(options.spawnOnWorkerThread ? { spawnOnWorkerThread: true } : {}), ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) }) - // Why after the read: that first Git call is what settles the WSL linked-worktree route, - // and Windows Git on that route may not share the spelling resolved above. + // Probe first to settle linked-worktree routing; host Git may need a different object-store spelling. if (!commonDir || usesHostGitForWslLinkedWorktree(repoPath, options.wslDistro)) { return undefined } @@ -82,7 +81,7 @@ export function createLocalGitObjectQuarantine( }) return { run: (command) => - // Why no WSLENV: the WSL runner puts both variables on WSL Git's command line. + // The WSL runner injects these variables in the command, without WSLENV. quarantine.run((env) => command(env ? { ...process.env, ...env } : undefined)) } } diff --git a/src/main/git/local-repo-ref-maintenance.test.ts b/src/main/git/local-repo-ref-maintenance.test.ts index d84c0d6f84e..72a08b0cd39 100644 --- a/src/main/git/local-repo-ref-maintenance.test.ts +++ b/src/main/git/local-repo-ref-maintenance.test.ts @@ -49,9 +49,7 @@ afterEach(() => { describe('local repo ref maintenance target', () => { it('never hands the pack child an abort signal', async () => { - // Killing a `pack-refs` strands a `refs/**` lock about one time in five, and - // on Windows a force-kill inside the rewrite strands `packed-refs.lock` - // every time. The child must always be allowed to finish. + // Killing pack-refs can strand packed-refs or per-ref locks; let the child finish. readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) @@ -117,7 +115,6 @@ describe('local repo ref maintenance target', () => { }) it('translates a WSL repo answer back to the UNC path the main process can open', async () => { - // Git answers in its own execution space, which for WSL is a Linux path. readRepoCommonDirFromGitMock.mockResolvedValue('/home/dev/repo/.git') await expect(target('Ubuntu').resolveRefsDirectory(NO_ABORT)).resolves.toBe( @@ -151,7 +148,6 @@ describe('local repo ref maintenance scheduling', () => { }) it('is free when nothing has ever been armed', async () => { - // The common case by far: no timers, no instance, no reason to pay anything. await expect(withRepoRefMaintenancePaused('git-fetch', async () => 'done')).resolves.toBe( 'done' ) diff --git a/src/main/git/local-repo-ref-maintenance.ts b/src/main/git/local-repo-ref-maintenance.ts index 46638a7aee2..1a448944713 100644 --- a/src/main/git/local-repo-ref-maintenance.ts +++ b/src/main/git/local-repo-ref-maintenance.ts @@ -20,15 +20,7 @@ import { import { gitExecFileAsync } from './runner' import { readRepoCommonDirFromGit } from './worktree-list-reader' -/** - * Main-process wiring for idle loose-ref packing on the local execution host - * (native and WSL). - * - * SSH-hosted repos are deliberately out of scope: the execution host owns - * anything that touches execution, so maintaining them means running host-side - * on the relay, which today has neither admission control nor spans. Keying all - * state by execution host is what keeps this path from reaching across. - */ +// Maintain native/WSL repos locally; SSH maintenance belongs on its execution host. export type RepoMaintenanceActivityProbe = () => boolean @@ -36,9 +28,7 @@ const REPO_BUSY_PROBE_MAX = 64 let activityProbe: RepoMaintenanceActivityProbe | null = null let shared: RepoRefMaintenance | null = null -// Why keyed here rather than captured in the target: a repo can be armed from -// the fetch controller or from a user-initiated fetch, and every arming must see -// the same "this repo has work in flight" answer, not whichever closure was last. +// All arming paths must share the current per-repo busy probe. const repoBusyProbes = new Map boolean>() /** Register the owner of "this repo has a fetch in flight" for `key`. */ @@ -54,24 +44,18 @@ export function setRepoRefMaintenanceBusyProbe(key: string, probe: () => boolean } } -/** - * Register the app-wide "do not start maintenance now" signal. Owned by the - * main entry point because the inputs (live agents, battery, quit) are not - * visible from the git layer. - */ +/** The main entry point supplies agent, battery, creation and quit activity unavailable in the Git layer. */ export function setRepoMaintenanceActivityProbe(probe: RepoMaintenanceActivityProbe | null): void { activityProbe = probe } -/** Support escape hatch: kills the sweep without touching the user's git config. */ function isDisabled(): boolean { return process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE === '1' } function localMaintenanceOptions(): RepoRefMaintenanceOptions { return { - // Fail closed: without the app-level gate installed we cannot see agents, - // creates, or battery, and running blind is worse than not running. + // Without the app activity gate, defer maintenance. isBusy: () => activityProbe?.() ?? true, observe: (attempt) => withSpan('repo.ref_maintenance', (span) => attempt(span), { @@ -88,14 +72,7 @@ export function getLocalRepoRefMaintenance(): RepoRefMaintenance { return shared } -/** - * Cancels every armed timer and waits out any `packed-refs` rewrite in progress. - * - * Deliberately does not kill the child. A pack orphaned by the app quitting - * finishes on its own; a pack signalled mid-prune strands a ref lock about one - * time in five, and on Windows a force-kill inside the rewrite strands - * `packed-refs.lock` every time -- which blocks every later ref deletion. - */ +/** Cancel timers and await the rewrite window; never kill pack-refs, which can strand ref locks. */ export function disposeLocalRepoRefMaintenance(): Promise { const settling = shared?.awaitPackedRefsLockRelease() ?? Promise.resolve() shared?.dispose() @@ -105,24 +82,12 @@ export function disposeLocalRepoRefMaintenance(): Promise { return settling } -/** - * Hold every repository open while `run` touches refs. - * - * A ref deletion needs `packed-refs.lock`, which a running pack holds only while - * it rewrites the file -- 0.03-1.37s of a 23-32s run. Waiting that out turns the - * collision into a short pause. Cancelling the pack instead would strand a - * `refs/**` lock about one time in five, which Git never clears, so the ref - * stays undeletable indefinitely. - */ +/** Wait out packed-refs rewrites before ref work; killing the pack can strand persistent per-ref locks. */ export async function withRepoRefMaintenancePaused( reason: string, run: () => Promise ): Promise { - // Taken unconditionally rather than only when something is already armed: a - // fetch inside `run` can arm the sweep, and one counter bump against an idle - // instance costs a microtask. This can rebuild the instance after the - // quit-time dispose; harmless, because a fresh one has no armed timers and its - // activity probe is gone, so it fails closed. + // Pause even before arming: run may schedule maintenance. Recreated shutdown instances fail closed. const release = await getLocalRepoRefMaintenance().pause(reason) try { return await run() @@ -136,13 +101,7 @@ export function awaitPackedRefsLockRelease(): Promise { return shared ? shared.awaitPackedRefsLockRelease() : Promise.resolve() } -/** - * Count user-initiated ref work as activity and restart every armed countdown. - * - * Deliberately not keyed to a repo: resolving one would cost a `rev-parse` on a - * path the user is waiting on, and a manual fetch or pull says the user is at - * the keyboard, which is a reason to defer every repository. - */ +/** User ref work postpones every repo; resolving one would add a probe to the interactive path. */ export function postponeRepoRefMaintenance(): void { shared?.postponeAll() } @@ -158,12 +117,7 @@ export function _resetLocalRepoRefMaintenanceForTests( clearRepoPackIndexMaintenanceCache() } -/** - * Git reports the common dir in its own execution space, so a WSL repo answers - * with a Linux path the Windows main process cannot open. Translate it back to - * the UNC spelling for the dirent walk; the walk reads directories, not files, - * so the handful of round trips stays cheap even over the share. - */ +/** Translate WSL Git's common dir to a UNC refs directory the main process can enumerate. */ function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | undefined): string { if (wslDistro && !isWslUncPath(commonDir) && !isWindowsAbsolutePathLike(commonDir)) { return win32.join(toWindowsWslPath(commonDir, wslDistro), 'refs') @@ -172,12 +126,7 @@ function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | unde return (isWindowsAbsolutePathLike(commonDir) ? win32 : posix).join(commonDir, 'refs') } -/** - * `maintenance.auto=false` and `gc.auto=0` are the two knobs a user reaches for - * to tell Git to stop maintaining a repository on its own. Orca sets both on its - * own fetches, but only as per-invocation `-c` flags, so this probe sees the - * user's persisted config and never Orca's own suppression. - */ +/** Read user opt-outs; Orca's per-command maintenance suppression never appears in persisted config. */ export function isGitAutoMaintenanceDisabled(configOutput: string): boolean { return configOutput .split('\n') @@ -188,13 +137,7 @@ export function isGitAutoMaintenanceDisabled(configOutput: string): boolean { ) } -/** - * The common dir in the spelling the main process can open. - * - * Derived from the converted refs path, not the raw one: a WSL answer arrives as - * a Linux path but converts to a UNC path with no `/` in it, so choosing the - * path flavour before conversion collapses the whole thing to `.`. - */ +/** Choose path syntax after WSL-to-UNC conversion; choosing before it would derive . instead. */ function gitCommonDirForMainProcess(commonDir: string, wslDistro: string | undefined): string { const refs = refsDirectoryForMainProcess(commonDir, wslDistro) return (isWindowsAbsolutePathLike(refs) ? win32 : posix).dirname(refs) @@ -207,11 +150,7 @@ export type LocalRepoRefMaintenanceTargetArgs = { readonly wslDistro?: string } -/** - * Record a write to this repo and restart its quiet-period countdown. The only - * entry point callers need: the kill switch is honoured before anything is - * scheduled, so a disabled build arms no timers at all. - */ +/** Restart the quiet period after a write; check the kill switch before arming timers. */ export function armLocalRepoRefMaintenance(args: LocalRepoRefMaintenanceTargetArgs): void { if (isDisabled()) { return @@ -223,8 +162,7 @@ export function createLocalRepoRefMaintenanceTarget( args: LocalRepoRefMaintenanceTargetArgs ): RepoRefMaintenanceTarget { const gitOptions = args.wslDistro ? { wslDistro: args.wslDistro } : {} - // The engine always probes before it packs, so the pack reuses this answer - // rather than spending a second rev-parse on the same repository. + // Reuse the required pre-pack probe instead of spending another rev-parse. let commonDir: string | undefined const resolveCommonDir = async (signal?: AbortSignal): Promise => { commonDir ??= await readRepoCommonDirFromGit(args.repoPath, { @@ -282,9 +220,7 @@ export function createLocalRepoRefMaintenanceTarget( if (!claim.ok) { throw new RefMaintenanceRepoLocked(claim.reason) } - // Only admission is cancellable. A pack that is - // killed mid-prune strands a `refs/**` lock about one time in five, and - // Git never clears those; waiting out the window costs at most ~1.4s. + // Cancel admission only: killing a running pack can strand persistent per-ref locks. const watch = owner?.watchLock((held) => lock.setHeld(held)) try { await gitExecFileAsync([...PACK_REFS_ARGS], { diff --git a/src/main/git/merge-tree-object-quarantine-real-git.test.ts b/src/main/git/merge-tree-object-quarantine-real-git.test.ts index bca3b91168a..cbf7bc94953 100644 --- a/src/main/git/merge-tree-object-quarantine-real-git.test.ts +++ b/src/main/git/merge-tree-object-quarantine-real-git.test.ts @@ -33,13 +33,11 @@ vi.mock('node:fs/promises', async (importOriginal) => { } }) -/** Records what a first successful check teaches the host; the cold path has its own test. */ function rememberMergeTreeWritesTrees(): void { getLocalGitCapabilityCache().rememberSupported('merge-tree-write-tree') } -// Why: every divergent `merge-tree --write-tree` used to leave unreachable loose objects in the -// real store; enough of them keeps Git's auto-gc re-running forever on large repositories. +// Quarantine throwaway merge objects so unreachable loose objects cannot repeatedly trigger auto-GC. describe('merge-tree runs against a scratch object store (real Git)', () => { let fixture: DivergentRepoFixture @@ -91,7 +89,6 @@ describe('merge-tree runs against a scratch object store (real Git)', () => { expect(fixture.looseObjectCount()).toBe(before) expect(fixture.scratchDirectories()).toEqual([]) expect(summary?.files).toEqual(['shared.txt']) - // The unquarantined run proves the fixture really writes objects and the output matches. expect(unquarantinedConflictFiles(cwd, base, head)).toEqual(summary?.files) expect(fixture.looseObjectCount()).toBeGreaterThan(before) }) @@ -119,7 +116,6 @@ describe('merge-tree runs against a scratch object store (real Git)', () => { expect(fixture.scratchDirectories()).toEqual([]) expect(extra).toEqual({ preservedBranch: { branchName: 'feature-extra', head: extraHead } }) expect(squashed).toEqual({}) - // Same verdicts as an unquarantined merge-tree, which does write objects. const mainTree = fixture.git(cwd, 'rev-parse', 'main^{tree}').trim() const mergeTree = (branch: string): string => fixture.git(cwd, 'merge-tree', '--write-tree', 'main', branch).trim() @@ -143,7 +139,7 @@ describe('merge-tree runs against a scratch object store (real Git)', () => { expect(first?.files).toEqual(['shared.txt']) expect(second?.files).toEqual(['shared.txt']) - // The first check fetched the missing blobs into the real store; the second found them there. + // The first check lazily fetches missing blobs; the second reuses them. expect(packsAfterFirst).toBeGreaterThan(packsBefore) expect(clone.packCount()).toBe(packsAfterFirst) expect(clone.looseObjectCount()).toBe(looseBefore) @@ -227,7 +223,6 @@ describe('merge-tree runs against a scratch object store (real Git)', () => { }) }) -// Why: the quarantine must leave Git seeing exactly the object stores it sees without it. describe('merge-tree quarantine keeps the object stores Git would otherwise see (real Git)', () => { let fixture: DivergentRepoFixture @@ -316,7 +311,6 @@ describe('merge-tree quarantine keeps the object stores Git would otherwise see await expectUnquarantinedVerdicts(repo) - // Unquarantined, Git writes the merge results into the store it was handed. expect(fixture.looseObjectCount()).toBeGreaterThan(before) expect(fixture.scratchDirectories()).toEqual([]) }) @@ -333,7 +327,6 @@ describe('merge-tree quarantine keeps the object stores Git would otherwise see await expectUnquarantinedVerdicts(repo) - // A store with its own alternates runs unquarantined, so Git writes where it always has. expect(looseObjectsIn(repo)).toBeGreaterThan(0) }) }) diff --git a/src/main/git/pack-refs-lock-ownership.test.ts b/src/main/git/pack-refs-lock-ownership.test.ts index e181feb9976..5693b48386d 100644 --- a/src/main/git/pack-refs-lock-ownership.test.ts +++ b/src/main/git/pack-refs-lock-ownership.test.ts @@ -64,7 +64,6 @@ describe('packed-refs lock ownership', () => { it('refuses a lock it cannot prove is its own', async () => { const commonDir = await gitCommonDir() - // A lock with no marker belongs to the user's own git, or to another tool. await writeFile(paths(commonDir).lock, 'someone else') await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) @@ -84,7 +83,6 @@ describe('packed-refs lock ownership', () => { }) it('reclaims the lock its own dead process left behind', async () => { - // SIGKILL and power loss bypass git's cleanup, and git never clears this itself. const commonDir = await gitCommonDir() const { lock, marker } = paths(commonDir) await writeFile(lock, 'abandoned mid-rewrite') @@ -100,8 +98,7 @@ describe('packed-refs lock ownership', () => { }) it('leaves a young lock alone even when the marker names a dead process', async () => { - // A marker outlives its lock, so a foreign lock can appear after our death. - // Age is the only thing separating our wreckage from somebody's live lock. + // Markers can outlive locks; a fresh foreign lock must remain untouched. const commonDir = await gitCommonDir() const { lock, marker } = paths(commonDir) await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) @@ -150,9 +147,7 @@ describe('packed-refs lock ownership', () => { describe('stranded per-ref locks', () => { it('clears the empty refs/**/*.lock files its own dead process left behind', async () => { - // `tempfile.c` opens the lock O_EXCL before linking it into the list the - // signal handler walks, so a kill in that window leaves a 0-byte file that - // Git never clears -- and `update-ref -d` on that ref then fails forever. + // Git can die between O_EXCL creation and registering signal cleanup, leaving a persistent empty lock. const commonDir = await gitCommonDir() const namespace = join(commonDir, 'refs', 'remotes', 'origin') await mkdir(namespace, { recursive: true }) @@ -163,7 +158,6 @@ describe('stranded per-ref locks', () => { await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) await expect(exists(join(namespace, 'main.lock'))).resolves.toBe(false) - // The ref itself is untouched. await expect(exists(join(namespace, 'main'))).resolves.toBe(true) }) diff --git a/src/main/git/pack-refs-lock-ownership.ts b/src/main/git/pack-refs-lock-ownership.ts index 9e7273b143b..caa5e3d3419 100644 --- a/src/main/git/pack-refs-lock-ownership.ts +++ b/src/main/git/pack-refs-lock-ownership.ts @@ -6,7 +6,7 @@ import { PACKED_REFS_LOCK_POLL_MS } from '../../shared/repo-ref-maintenance-policy' -/** No legitimate `pack-refs` outlives its own deadline, so an older lock is abandoned. */ +// Minimum lock age before recovery considers an attributed pack abandoned. const ABANDONED_LOCK_AGE_MS = PACK_REFS_TIMEOUT_MS /** Beyond this a recorded pid may have been recycled, so it stops being evidence of life. */ @@ -16,20 +16,9 @@ const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 const REF_LOCK_SCAN_CEILING = 4096 /** - * Makes a `packed-refs.lock` Orca left behind attributable, and only that one. - * - * Git registers signal handlers that clean the lock up, but SIGKILL and power - * loss bypass them, and Git never removes a stale `packed-refs.lock` on its own - * -- every later ref deletion in that repository fails until someone deletes a - * file they have never heard of. Recording our pid beside the lock lets a later - * run recognise its own wreckage. - * - * Three independent conditions must all hold before anything is unlinked, - * because deleting a lock somebody else is holding is far worse than declining - * to pack: a marker must exist at all, the lock must be older than any - * `pack-refs` could legitimately run for, and the recorded process must be gone. - * A marker can outlive its lock, so age is what separates "our wreckage" from a - * foreign lock that happened to appear afterwards. + * Recover locks attributed by an Orca marker only after an age and owner-liveness check. + * Markers can outlive their locks; the age gate avoids removing a newly created foreign lock. + * Beyond the PID-reuse horizon, the recorded PID no longer prevents packed-refs recovery. */ export class PackRefsLockOwnership { private readonly lockPath: string @@ -41,14 +30,12 @@ export class PackRefsLockOwnership { this.markerPath = path.join(gitCommonDir, 'packed-refs.orca-owner') } - /** Refused when the lock belongs to something we cannot prove is our own wreckage. */ async claim(now = Date.now()): Promise { const reclaim = await this.reclaimAbandonedLock(now) if (!reclaim.ok) { return reclaim } - // Per-ref strands outlive their pack and are invisible to Git, which never - // clears a `refs/**\/*.lock` it did not create in this process. + // Git does not clean per-ref locks stranded by an earlier process. await this.reclaimStrandedRefLocks(now) try { await writeFile(this.markerPath, JSON.stringify({ pid: process.pid }), 'utf-8') @@ -58,10 +45,7 @@ export class PackRefsLockOwnership { return { ok: true } } - /** - * Poll `packed-refs.lock` so the scheduler knows when the exclusive rewrite - * window opens and closes. Cheap: one `stat` on a fixed path. - */ + /** Poll the exclusive packed-refs rewrite window for the scheduler. */ watchLock(report: (held: boolean) => void): { stop: () => void } { let stopped = false let last = false @@ -95,18 +79,16 @@ export class PackRefsLockOwnership { if (lockAgeMs === null) { return { ok: true } } - // No marker means the lock is not ours to reason about, let alone remove. + // Never reclaim an unattributed lock. const marker = await readOwnerMarker(this.markerPath) if (marker === null) { return { ok: false, reason: 'held by another process' } } if (lockAgeMs < ABANDONED_LOCK_AGE_MS) { - // Ours, but too young to be certain the writer is gone. Worth retrying soon. + // Even an attributed lock may belong to a newer writer; defer recovery. return { ok: false, reason: 'our own lock, not yet old enough to reclaim' } } - // Past the pid-reuse horizon the pid proves nothing, and a lock this old is - // abandoned whoever wrote it -- otherwise a recycled pid would wedge the - // repository permanently. + // Beyond the reuse horizon, the recorded PID cannot reliably identify the original writer. if (isProcessAlive(marker.pid) && lockAgeMs < PID_REUSE_HORIZON_MS) { return { ok: false, reason: 'the recorded owner is still running' } } @@ -115,15 +97,7 @@ export class PackRefsLockOwnership { return { ok: true } } - /** - * Clear `refs/**\/*.lock` files a dead pack of ours left behind. - * - * `tempfile.c` opens the lock `O_EXCL` before `activate_tempfile()` links it - * into the list the signal handler walks, so a kill inside that window leaves - * a 0-byte file. Afterwards `update-ref -d` and any fetch touching that ref - * fail with `cannot lock ref ... File exists`, forever. Same three conditions - * as the packed-refs lock, plus a size check: a live writer's lock is not empty. - */ + /** An old marker and dead owner permit recovery of empty per-ref locks stranded before signal cleanup registration. */ private async reclaimStrandedRefLocks(now: number): Promise { const marker = await readOwnerMarker(this.markerPath) if (marker === null || isProcessAlive(marker.pid)) { @@ -161,7 +135,7 @@ export class PackRefsLockOwnership { export type PackRefsLockClaim = { ok: true } | { ok: false; reason: string } -/** A strand from the `O_EXCL` window is 0 bytes; a live writer's lock is not. */ +// Empty files are candidates for O_EXCL strands; ownership/age checks happen before scanning. async function isEmptyFile(path: string): Promise { try { return (await stat(path)).size === 0 @@ -180,7 +154,7 @@ async function readOwnerMarker(path: string): Promise<{ pid: number } | null> { } } -/** Null when the file does not exist. Uses stat: the lock holds a whole packed-refs. */ +/** Stat rather than read the potentially large lock; ENOENT alone means absent. */ async function fileAgeMs(path: string, now: number): Promise { try { return Math.max(0, now - (await stat(path)).mtimeMs) diff --git a/src/main/git/porcelain-v1-records.test.ts b/src/main/git/porcelain-v1-records.test.ts index 396788b2e17..8f4193b3590 100644 --- a/src/main/git/porcelain-v1-records.test.ts +++ b/src/main/git/porcelain-v1-records.test.ts @@ -48,9 +48,7 @@ describe('parsePorcelainV1Records', () => { ]) }) - // Why: a rename emits its ORIGIN as a second NUL field. Reading that origin as - // its own record would invent a status code from the first bytes of a path — - // and `?? ` is exactly what the shared-symlink filter keys on. + // Rename origins are extra NUL fields; treating one as a record could invent an untracked status. it('consumes the origin path of a rename instead of emitting it as a record', () => { const repo = createRepo() writeFileSync(join(repo, 'original.txt'), 'content\n') diff --git a/src/main/git/porcelain-v1-records.ts b/src/main/git/porcelain-v1-records.ts index d8836df612c..b4e748a604f 100644 --- a/src/main/git/porcelain-v1-records.ts +++ b/src/main/git/porcelain-v1-records.ts @@ -1,33 +1,23 @@ -/** One `git status --porcelain -z` record: the two-letter status code and the - * path it applies to. Rename/copy origins are consumed, not reported. */ +/** Rename/copy origins are consumed rather than reported as records. */ export type PorcelainV1Record = { xy: string path: string } -/** Parse `git status --porcelain -z` (v1) into records. - * - * Why `-z` and a real parser rather than splitting lines: without `-z` Git - * quotes and escapes paths containing spaces, quotes, or non-ASCII bytes, so a - * path comparison against a configured entry would silently miss. With `-z` - * paths are raw, but a rename or copy emits its origin as a *second* - * NUL-separated field — treating that origin as its own record would invent a - * status code out of the leading bytes of a path. */ +/** Parse raw NUL paths to avoid quoted-path mismatches; rename/copy records also consume an origin field. */ export function parsePorcelainV1Records(stdout: string): PorcelainV1Record[] { const fields = stdout.split('\0') const records: PorcelainV1Record[] = [] for (let index = 0; index < fields.length; index++) { const field = fields[index] - // Why: the trailing NUL yields a final empty field; a record is always - // `XY`, so anything shorter cannot be one. + // Ignore trailing empties and fields too short for XY. if (field.length < 4) { continue } const xy = field.slice(0, 2) records.push({ xy, path: field.slice(3) }) if (xy.includes('R') || xy.includes('C')) { - // Skip the origin path that follows a rename or copy. index++ } } diff --git a/src/main/git/remote-name-listing.ts b/src/main/git/remote-name-listing.ts index fc563fac780..66856b42c42 100644 --- a/src/main/git/remote-name-listing.ts +++ b/src/main/git/remote-name-listing.ts @@ -68,12 +68,7 @@ function listingGitConfigContext( } } -/** - * `git remote` names for one repo/runtime. Failed listings are not cached: a - * missed `upstream` would otherwise send issue/PR resolvers to origin on a - * contributor clone (#7331). An `ssh:` repo is not served here, so it lists as - * failed (`null`), never as this machine's remotes. - */ +/** Cache repo/runtime names; failed or SSH-local reads return null so callers cannot infer a missing upstream. */ export async function listCachedRemoteNames( repoPath: string, connectionId?: string | null, diff --git a/src/main/git/remote-ref-probe-cache.test.ts b/src/main/git/remote-ref-probe-cache.test.ts index 3058facc9c0..91b8b54d0bb 100644 --- a/src/main/git/remote-ref-probe-cache.test.ts +++ b/src/main/git/remote-ref-probe-cache.test.ts @@ -56,8 +56,7 @@ describe('remote ref probe cache (P1-D)', () => { await expect(cache.get('/repo', 'origin')).resolves.toBeNull() expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - // A remote added after the miss is only visible once the negative expires; - // nothing here watches .git/config, and SSH/WSL repos have no file to watch. + // Expiring negatives reveal new remotes without filesystem watchers on SSH/WSL. gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) @@ -87,8 +86,7 @@ describe('remote ref probe cache (P1-D)', () => { vi.setSystemTime(1_000_000 + PROBE_COALESCE_STALE_MS + 1) await expect(cache.get('/repo', 'origin')).resolves.toEqual({ repo: 'team/repo' }) - // The abandoned probe still answers the caller that started it, but the repo - // state it read is older than the one already cached — it must not publish. + // Abandoned probes still answer their caller but must not overwrite newer cached state. stalled.resolve({ stdout: 'git@elsewhere.example:team/repo.git\n' }) await expect(abandoned).resolves.toBeNull() diff --git a/src/main/git/remote-ref-probe-cache.ts b/src/main/git/remote-ref-probe-cache.ts index f69780c4f2b..1c6ed35ed39 100644 --- a/src/main/git/remote-ref-probe-cache.ts +++ b/src/main/git/remote-ref-probe-cache.ts @@ -2,22 +2,9 @@ import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe' import { isTransientGitProbeError, readRemoteUrl } from './remote-url-probe' import type { GitAdmissionTier } from './command-runner/git-exec-options' -/** - * The "is this repo mine?" probe every forge integration runs: read the remote's - * URL once per repo/runtime, cache what the provider's parser made of it, and - * never cache an answer a failed probe never gave. - */ - const REPO_REF_CACHE_MAX_ENTRIES = 512 -/** - * Why: "not this provider" only holds until someone edits the repo's remotes — - * and a repo first probed before it had any remote answers that way too. - * Nothing here watches `.git/config`, and a watcher cannot cover the SSH and WSL - * runtimes this cache also serves, so negatives expire instead: one probe per - * repo per interval is what lets a remote added mid-session be picked up without - * a restart. Positives stay, as they did before. - */ +// Expire negatives so newly added remotes appear without restart, including on SSH/WSL without config watchers. export const NEGATIVE_ENTRY_TTL_MS = 5 * 60_000 type CachedRepoRef = { value: Ref | null; expiresAt: number } @@ -66,9 +53,7 @@ export function createRemoteRefProbeCache( connectionId: string | null | undefined, localGitOptions: RemoteRefLocalGitOptions ): Promise { - // Why: a probe abandoned as stale still runs, and its answer describes a repo - // state older than whatever the successor is about to store — or already has. - // It may still answer its own callers; it may not publish. + // Stale probes may answer their own callers, but cannot publish over a successor. const publish = (value: Ref | null): void => { if (ownsKey()) { remember(cacheKey, value) @@ -84,7 +69,7 @@ export function createRemoteRefProbeCache( }, remoteName ) - // Why: null is an SSH repo this process cannot read, not an answer about the remote. + // null means an SSH repo unavailable to this local reader, not a missing remote. if (stdout === null) { return null } @@ -92,7 +77,7 @@ export function createRemoteRefProbeCache( publish(result) return result } catch (error) { - // Why: a probe killed on its deadline says nothing about the remote. + // Transient probe failure cannot establish a negative cache entry. if (isTransientGitProbeError(error)) { return null } @@ -112,9 +97,7 @@ export function createRemoteRefProbeCache( } repoRefCache.delete(cacheKey) } - // Why: every branch of a repo resolves its forge through this probe, so a - // poll of the worktree list arrives as a burst of identical lookups. One - // young probe answers all of them instead of spawning a `git` per branch. + // Coalesce worktree-list bursts so each branch does not spawn the same probe. return runCoalescedProbe(inFlight, cacheKey, (ownsKey) => probe(cacheKey, ownsKey, repoPath, remoteName, connectionId, localGitOptions) ) diff --git a/src/main/git/remote-url-probe.ts b/src/main/git/remote-url-probe.ts index bd232a361b8..e50b15ba1a5 100644 --- a/src/main/git/remote-url-probe.ts +++ b/src/main/git/remote-url-probe.ts @@ -4,16 +4,7 @@ import type { GitAdmissionTier } from './command-runner/git-exec-options' import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch' import { getConnectionExecutionHostId } from '../../shared/execution-host' -/** - * The `git remote get-url` probe every forge integration runs to decide whether - * a repo is theirs (P1-D). - * - * Why: it is a local config read, so the only way it outlasts this bound is a - * wedged host — a dead network mount or stalled WSL interop. Unbounded, the call - * never returns and every caller above it hangs with it. Passing a timeout is - * also what arms the runner's kill path; Node's own waits forever on a child - * that ignores signals. - */ +// Bound config probes so stalled mounts or WSL cannot hold every forge lookup indefinitely. export const REMOTE_URL_PROBE_TIMEOUT_MS = 30_000 export type RemoteUrlProbeContext = { @@ -48,11 +39,7 @@ const TRANSIENT_PROBE_PATTERNS = [ /connection (?:dropped|closed|refused|reset)/i ] -/** - * A probe that was killed on its deadline, or died with its transport, says - * nothing about the remote. Callers must neither cache it as an answer nor - * report it as "no review": it is an unavailable result, not a negative one. - */ +/** Timeout or transport failure leaves remote state unknown; never cache it as absence. */ export function isTransientGitProbeError(error: unknown): boolean { // Why: an abort — this probe's deadline, or a caller cancelling — carries no // message a pattern could match, but it is the emptiest answer of all. @@ -83,11 +70,7 @@ export function isTransientGitProbeError(error: unknown): boolean { return TRANSIENT_PROBE_PATTERNS.some((pattern) => pattern.test(text)) } -/** - * Throws when the remote could not be read, and returns normally for every - * answer — including a repo with no remote at all. Lets a caller that treats - * "nothing found" as cacheable tell that apart from a lookup that never got to ask. - */ +/** Throw on unavailable reads; a proven missing remote is a valid negative answer. */ export async function assertRemoteUrlReadable( context: RemoteUrlProbeContext, remoteName = 'origin' diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index bd8578e8d02..a8a99fb353a 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -198,8 +198,6 @@ describe('git remote operations', () => { ) }) - // Regression: normalizing a URL-valued push remote used to run `git remote` and then a - // serial `git remote get-url` per remote -- 59 subprocesses on a 58-remote repo. it('normalizes a URL-valued push remote from one remote table read at 58 remotes', async () => { const remotes = [ { name: 'origin', url: 'https://github.com/stablyai/orca.git' }, @@ -417,11 +415,9 @@ describe('git remote operations', () => { 'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.' ) gitExecFileAsyncMock - // First attempt: plain pull rejects with git's reconciliation error. .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) .mockRejectedValueOnce(divergentError) - // Fallback attempt: pull --no-rebase (merge) succeeds. .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) .mockResolvedValueOnce({ stdout: '', stderr: '' }) @@ -447,7 +443,6 @@ describe('git remote operations', () => { ) await expect(gitFastForward('/repo')).rejects.toThrow('Not possible to fast-forward') - // No fallback attempt: only the three probe/pull calls ran. expect(gitExecFileAsyncMock.mock.calls).toHaveLength(3) }) @@ -456,16 +451,13 @@ describe('git remote operations', () => { 'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.' ) gitExecFileAsyncMock - // First attempt: validate the target, then the plain pull rejects. .mockResolvedValueOnce({ stdout: '', stderr: '' }) .mockRejectedValueOnce(divergentError) - // Fallback attempt: re-validate, then pull --no-rebase (merge) succeeds. .mockResolvedValueOnce({ stdout: '', stderr: '' }) .mockResolvedValueOnce({ stdout: '', stderr: '' }) await gitPull('/repo', { remoteName: 'fork', branchName: 'feature/fix' }) - // The merge flag is spliced ahead of the positional remote/branch args. expect(gitExecFileAsyncMock.mock.calls).toEqual([ [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], [['pull', 'fork', 'feature/fix'], { cwd: '/repo' }], @@ -482,17 +474,14 @@ describe('git remote operations', () => { 'Command failed: git pull --no-rebase\nCONFLICT (content): Merge conflict in file.txt' ) gitExecFileAsyncMock - // First attempt fails with the reconciliation error. .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) .mockRejectedValueOnce(divergentError) - // The single merge fallback then fails on a real conflict. .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) .mockRejectedValueOnce(mergeConflictError) await expect(gitPull('/repo')).rejects.toThrow() - // At-most-once retry: probe+pull, then probe+fallback-pull — no further attempts. expect(gitExecFileAsyncMock.mock.calls).toHaveLength(6) }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index aa7932687ca..248670a7f3d 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -33,16 +33,7 @@ export async function gitPush( if (pushTarget) { await validateGitPushTarget(worktreePath, pushTarget, options) } - // Why: push to the branch's configured upstream when one exists. PR-created - // worktrees can track a contributor fork remote; hardcoding origin here - // would send review commits to the upstream repository instead. - // - // When no upstream exists, keep the existing first-publish behavior: - // create/update origin/ and set it as upstream. - // - // Branch-vs-base reporting (the "Committed on Branch" section) is - // unaffected because it uses branchCompare against an explicit baseRef - // from worktree config, not the upstream relationship. + // Honor configured fork push targets; use origin/HEAD only for first publication. const target = pushTarget ? explicitPushTarget(pushTarget) : await resolveConfiguredGitPushTarget((args) => @@ -79,8 +70,7 @@ async function gitPullWithArgs( gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) ) if (upstream && !upstream.isConfiguredUpstream) { - // Why: legacy Orca branches may still track origin/main while pushes - // target origin/. Pull the same effective branch the UI reports. + // Legacy worktrees may track origin/main; pull the effective branch shown by the UI. await gitExecFileAsync( ['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName], gitOptionsForWorktree(worktreePath, options) @@ -103,9 +93,7 @@ export async function gitPull( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - // Why: plain `git pull` uses the user's configured pull strategy (merge by - // default) so diverged branches reconcile instead of erroring out. Conflicts - // surface through the existing conflict-resolution flow. + // Respect the configured pull strategy and surface conflicts through the existing resolution flow. postponeRepoRefMaintenance() await withRepoRefMaintenancePaused('git-pull', () => runWithGitWorktreeOperationLock(worktreePath, options.signal, () => @@ -134,9 +122,7 @@ export async function gitFetch( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - // `--prune` deletes remote-tracking refs, which needs the `packed-refs` lock a - // running idle pack holds while it rewrites -- ~1.4s at most. This is the user - // clicking Fetch, so wait that window out rather than letting it fail on the lock. + // Pruning needs packed-refs.lock; wait for an idle pack's rewrite window rather than failing Fetch. postponeRepoRefMaintenance() try { await withRepoRefMaintenancePaused('git-fetch', async () => { diff --git a/src/main/git/remove-worktree-branch-cleanup.test.ts b/src/main/git/remove-worktree-branch-cleanup.test.ts index e3aa1a9cd51..5314f8c4b03 100644 --- a/src/main/git/remove-worktree-branch-cleanup.test.ts +++ b/src/main/git/remove-worktree-branch-cleanup.test.ts @@ -51,7 +51,7 @@ import { forceDeleteLocalBranch, removeWorktree } from './worktree' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) -// Why: removal argv carries core.longpaths on Windows; pin a non-Windows default for exact argv. +// Pin non-Windows argv; Windows removal also sets core.longpaths. let platformSpy: MockInstance<() => NodeJS.Platform> beforeEach(() => { diff --git a/src/main/git/remove-worktree-test-harness.ts b/src/main/git/remove-worktree-test-harness.ts index 509c6b580ea..3977c82e69c 100644 --- a/src/main/git/remove-worktree-test-harness.ts +++ b/src/main/git/remove-worktree-test-harness.ts @@ -10,10 +10,7 @@ export type MockResult = { stderr?: string } -// Why: detectSparseCheckout on main also requires core.sparseCheckout=true in git -// config (not just a non-empty pattern file). Unit tests that assert isSparse must -// present an enabled flag; other paths never reach this read after the pattern-file -// fast-path ENOENT. +// Sparse detection requires core.sparseCheckout=true as well as a pattern file. const ENABLED_SPARSE_CHECKOUT_CONFIG = '[core]\nsparseCheckout = true\n' function enoent(): Error { @@ -68,8 +65,7 @@ export function expectGitCallOrder(calls: string[], beforeCall: string, afterCal export function mockSparseCheckoutEnabledConfig(readFileMock: Mock): void { readFileMock.mockImplementation(async (filePath: string) => { const normalized = String(filePath).replaceAll('\\', '/') - // Why: linked worktrees may point at a common dir; treat missing commondir as - // "this gitdir is the common dir" so the shared config read still runs. + // Missing commondir makes this gitdir its own common dir, allowing the config read. if (normalized.endsWith('/commondir')) { throw enoent() } @@ -80,7 +76,6 @@ export function mockSparseCheckoutEnabledConfig(readFileMock: Mock): void { }) } -/** Per-test reset shared by every worktree suite: caches cleared. */ export function resetWorktreeRemovalState(): void { clearGitCapabilityStateForTests() _resetWorktreeScanCacheForTests() @@ -103,8 +98,6 @@ export function resetWorktreeGitMocks(mocks: WorktreeGitMocks): void { mocks.translateWslOutputPathsMock.mockReset() mocks.translateWslOutputPathsMock.mockImplementation((output: string) => output) mocks.statMock.mockReset() - // Default: no worktree has a sparse-checkout config file. Tests that need - // sparse detection override this. mocks.statMock.mockRejectedValue(enoent()) if (mocks.readFileMock) { mocks.readFileMock.mockReset() diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index bed0fb3df29..cd75f91058d 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -54,8 +54,7 @@ import { const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) -// Why: removal argv carries core.longpaths on Windows, so pin a non-Windows default or the -// exact-argv assertions below fail for a maintainer running vitest on Windows. +// Pin non-Windows argv; Windows removal also sets core.longpaths. let platformSpy: MockInstance<() => NodeJS.Platform> beforeEach(() => { @@ -249,8 +248,7 @@ branch refs/heads/main await removeWorktree('/repo', '/repo-feature') - // A large checkout takes Git 30 s or more to delete; any timeout here would cut that short. - // Exempt from general admission: deletes queue under their own limit instead. + // Large deletions must finish without a deadline; their own queue bounds admission. expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'remove', '/repo-feature'], { cwd: '/repo', admissionExempt: true, @@ -536,7 +534,6 @@ branch refs/heads/feature/test 'git worktree remove failed' ) expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') - // An unrelated failure must not re-prove cleanliness. expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') }) @@ -646,9 +643,7 @@ branch refs/heads/feature/test await removeWorktree('/repo', '/repo-feature', false, { deleteBranch: false }) - // Why not assert size 0: the pre-removal `listWorktrees` lookup inside `performRemoveWorktree` - // re-annotates every row it saw (including the untouched main worktree), caching a fresh entry - // for `/repo`. Only the removed path's own entry must be gone, proven by a fresh stat call below. + // Other rows may be re-cached during removal; only the removed path must require a fresh stat. const statCallsBefore = statMock.mock.calls.length expect(await detectSparseCheckoutCached('/repo', '/repo-feature')).toBe(false) expect(statMock.mock.calls.length).toBeGreaterThan(statCallsBefore) diff --git a/src/main/git/repo-base-ref-search.ts b/src/main/git/repo-base-ref-search.ts index 10de8b834e5..2428e6533df 100644 --- a/src/main/git/repo-base-ref-search.ts +++ b/src/main/git/repo-base-ref-search.ts @@ -31,14 +31,10 @@ function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM } -/** Build excludes for the symbolic `/HEAD` slot without hiding - * nested branch names such as `/feature/HEAD`. */ +/** Exclude direct remote HEAD slots while preserving nested branch names ending in HEAD. */ function getRemoteHeadExcludes(remoteNames: readonly string[] | undefined): string[] { if (remoteNames && remoteNames.length > 0) { - // A single-component wildcard covers the overwhelmingly common remote - // shape. Keep exact excludes only for slash-containing remote names, where - // that wildcard cannot reach the direct `/HEAD` slot without also - // hiding legal nested branches such as `/feature/HEAD`. + // Ordinary remotes share a wildcard; slash-containing remotes need exact excludes. const slashRemotes = [...new Set(remoteNames)].filter( (remote) => remote.includes('/') && isSafeGitRefName(`refs/remotes/${remote}/HEAD`) ) @@ -47,8 +43,7 @@ function getRemoteHeadExcludes(remoteNames: readonly string[] | undefined): stri ...slashRemotes.map((remote) => `--exclude=refs/remotes/${remote}/HEAD`) ] } - // `*` does not cross `/`, unlike `**`; this keeps unknown nested remotes - // eligible for the parser's branch-name matching. + // * does not cross slashes, so unknown nested remotes remain eligible. return ['--exclude=refs/remotes/*/HEAD'] } @@ -63,8 +58,7 @@ export function buildSearchBaseRefsArgv( } = {} ): string[] { const excludeRemoteHead = options.excludeRemoteHead ?? true - // A caller may ask for more rows than the retained-result cap. Keep that - // request useful while bounding the Git command to the safe probe window. + // Bound the Git scan even when the caller asks for more retained rows. const boundedScanLimit = clampRepoSearchRefsScanLimit(limit) const candidateCount = getRefSearchCandidateCount(boundedScanLimit, excludeRemoteHead) const base = [ diff --git a/src/main/git/repo-branch-conflict-batched-probe.test.ts b/src/main/git/repo-branch-conflict-batched-probe.test.ts index 31aa0dc10f3..e1a366c1a0d 100644 --- a/src/main/git/repo-branch-conflict-batched-probe.test.ts +++ b/src/main/git/repo-branch-conflict-batched-probe.test.ts @@ -1,8 +1,4 @@ -// Why: the batched `cat-file --batch-check` conflict probe decides from stdout, so a -// WSL login-shell fallback that prints the distro banner onto that stream desynchronizes -// the one-line-per-ref contract. Every batch then came back undecided and fell through to -// one `show-ref` subprocess per remote -- the cost the batch exists to remove. These tests -// pin the fence request and the resulting subprocess count at 58 remotes. +// Fence login banners so batched probes keep one stdout record per ref and avoid per-remote fallback. import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,10 +16,7 @@ const WSL_BANNER = type GitExecOptions = { stdin?: string; captureWslLoginShellOutput?: boolean } -/** - * Stand-in for a WSL-routed runner: the login shell prepends its banner to stdout unless - * the caller asked for the fenced form, which slices the payload back out. - */ +/** Simulate a login banner unless fenced capture was requested. */ function installLoginShellRunner(): { argv: string[][] } { const argv: string[][] = [] gitExecFileAsyncMock.mockImplementation(async (args: string[], options: GitExecOptions = {}) => { diff --git a/src/main/git/repo-branch-conflict-real-git.test.ts b/src/main/git/repo-branch-conflict-real-git.test.ts index 34092273eda..1aad351a523 100644 --- a/src/main/git/repo-branch-conflict-real-git.test.ts +++ b/src/main/git/repo-branch-conflict-real-git.test.ts @@ -30,7 +30,6 @@ describe('branch conflict real Git contract', () => { git('commit', '--quiet', '-m', 'base') const head = git('rev-parse', 'HEAD').trim() - // Many remotes is the shape that used to cost one subprocess each. for (let index = 0; index < 12; index += 1) { git('remote', 'add', `remote${index}`, 'https://example.test/repo.git') } @@ -38,7 +37,6 @@ describe('branch conflict real Git contract', () => { await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote') await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull() - // The allowed base ref is the one remote spelling that is not a conflict. await expect( getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken') ).resolves.toBeNull() diff --git a/src/main/git/repo-branch-conflict.test.ts b/src/main/git/repo-branch-conflict.test.ts index bba3bdf542e..e9240e94bdf 100644 --- a/src/main/git/repo-branch-conflict.test.ts +++ b/src/main/git/repo-branch-conflict.test.ts @@ -75,7 +75,6 @@ describe('getBranchConflictKindViaExec', () => { getBranchConflictKindViaExec(exec, 'feature/fix', 'origin/feature/fix') ).resolves.toBeNull() expect(calls.some((argv) => argv[0] === 'show-ref')).toBe(false) - // A different base still sees the other remote's branch as taken. await expect(getBranchConflictKindViaExec(exec, 'feature/fix', 'origin/main')).resolves.toBe( 'remote' ) @@ -124,7 +123,6 @@ describe('getBranchConflictKindViaExec', () => { return { stdout: 'origin\n' } } if (argv[0] === 'show-ref') { - // The exact ref is absent even though a descendant exists. throw new Error('missing exact ref') } throw new Error(`unexpected git command: ${argv.join(' ')}`) @@ -168,8 +166,7 @@ describe('getBranchConflictKindViaExec', () => { await expect(getBranchConflictKindViaExec(exec, 'feature')).resolves.toBeNull() expect(probeCount).toBe(12) - // Equality, not a ceiling: 12 candidates saturate the pool, so a regression - // to serial probing has to fail here. + // Require pool saturation so a regression to serial probing fails. expect(maxActiveProbes).toBe(8) }) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index f7769de9eda..3c79e16defe 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -24,9 +24,7 @@ function runGit( } function canQueryRemoteBranchName(branchName: string): boolean { - // Validate the complete local ref before interpolating the name into any Git - // argument. This rejects glob/control/refspec syntax while retaining valid - // slash-containing branch names. + // Validate before interpolation to reject glob/control/refspec syntax while allowing slashes. return !branchName.startsWith('-') && isSafeGitRefName(`refs/heads/${branchName}`) } @@ -96,16 +94,14 @@ function buildRemoteBranchConflictRefs( if (!isSafeGitRefName(ref)) { continue } - // Why (#26344): the create is checking out that remote branch, so the same name on another - // remote (a fork's `upstream`) is not a collision; checking it would silently suffix the pick. + // Matching the checkout base is allowed even when multiple remotes carry the same branch. if ( isAllowedRemoteBaseRef(ref, allowedBaseRef) && resolveConfiguredRemoteBranchName(ref, remoteNames) === branchName ) { return [] } - // Match the conflict policy's longest-remote-prefix interpretation when - // remote names overlap (for example, `foo` and `foo/bar`). + // Prefer the longest configured remote prefix when names overlap. if ( !isAllowedRemoteBaseRef(ref, allowedBaseRef) && resolveConfiguredRemoteBranchName(ref, remoteNames) === branchName @@ -116,8 +112,7 @@ function buildRemoteBranchConflictRefs( return [...refs] } -/** One batched child answers for every remote; the per-ref probes only run when the host cannot - * feed stdin, or when the batch came back undecided. */ +/** Batch remote probes; use per-ref lookups only when stdin is unavailable or the batch is undecided. */ async function probeAnyRemoteConflictRef( exec: ExactRefProbeExec, batchedExec: ExactRefProbeStdinExec | undefined, @@ -125,7 +120,6 @@ async function probeAnyRemoteConflictRef( probeOptions: ExactRefProbeExecOptions ): Promise<{ found: boolean }> { if (batchedExec) { - // A present ref is always decisive, so `found` never survives with `unknown` set. const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions) if (!batched.unknown) { return { found: batched.found } @@ -146,9 +140,7 @@ export async function getBranchConflictKindViaExec( if (!canQueryRemoteBranchName(branchName)) { return null } - // Preserve the host runner's existing output/timeout contract. Exact probes - // are quiet, so introducing a smaller implicit cap would only make a large - // remote configuration look like a missing conflict. + // Preserve host output/deadline limits; smaller implicit caps could hide conflicts. const probeOptions: ExactRefProbeExecOptions = options const localRef = `refs/heads/${branchName}` let presence = await probeLocalBranchRef(exec, localRef, probeOptions) @@ -211,12 +203,7 @@ export function getBranchConflictKind( branchName, allowedBaseRef, {}, - // Why fenced: the batch decides from stdout, and a WSL login-shell fallback writes - // the distro's rc/motd banner to that same stream. The extra lines break the - // one-line-per-ref contract, so every batch came back undecided and fell through to - // one `show-ref` subprocess per remote -- the exact cost the batch exists to remove. - // `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs - // no fence; the capture wrapper preserves the payload's exit status either way. + // Fence login banners to preserve one stdout line per ref; quiet exit-code probes need no markers. (argv, commandOptions) => runLocalGit(argv, commandOptions, true), allowLocalBranch ) diff --git a/src/main/git/repo-clone-path.ts b/src/main/git/repo-clone-path.ts index c1a3ecde65a..104914e7c9b 100644 --- a/src/main/git/repo-clone-path.ts +++ b/src/main/git/repo-clone-path.ts @@ -15,8 +15,7 @@ export type ClaimedCloneTarget = { type CloneDirectoryIdentity = Pick export function deriveCloneRepoNameFromUrl(url: string): string { - // Why: direct callers can supply URLs whose default git clone folder would - // be "." or ".."; rejecting them prevents parent/destination deletion. + // Reject dot destinations before cleanup could remove a parent or unrelated folder. const source = url.replace(/\.git\/?$/, '') const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') const scpPath = @@ -65,8 +64,7 @@ export function getClonePathComparisonKey(clonePath: string): string { const normalized = normalizeRuntimePathSeparators(resolvedClonePath) const wslUncMatch = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i) if (wslUncMatch) { - // Why: WSL UNC paths cross into a case-sensitive Linux filesystem, so only - // the Windows UNC server alias and distro segment should be case-folded. + // WSL paths stay case-sensitive after the UNC server/distro prefix. const linuxPath = (wslUncMatch[2] ?? '').replace(/\/+$/, '') return `//wsl/${wslUncMatch[1].toLowerCase()}${linuxPath}` } @@ -113,14 +111,11 @@ export async function cleanupClaimedCloneTarget( return } - await rm(clonePath, { recursive: true, force: true }).catch(() => { - // Best-effort cleanup - do not mask the original clone failure. - }) + await rm(clonePath, { recursive: true, force: true }).catch(() => {}) } function cloneDirectoryIdentity(stats: Stats): CloneDirectoryIdentity { - // Why: fast remove/recreate cycles can reuse an inode; birthtime keeps us - // from treating a replacement directory as the clone target we created. + // Birthtime distinguishes replacements when rapid remove/create cycles reuse an inode. return { dev: stats.dev, ino: stats.ino, birthtimeMs: stats.birthtimeMs } } diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index 093b938e552..ae0af094a82 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -49,10 +49,7 @@ describe('isGitRepo', () => { }) it('accepts a real repository when git itself cannot be run', () => { - // Why: regression guard for the spurious "Open as Folder" prompt. When the - // `git rev-parse` probe fails for an environmental reason (here simulated by - // making `git` unresolvable), a directory carrying valid Git metadata must - // still be recognized rather than silently downgraded to a plain folder. + // Valid metadata must survive an unavailable Git probe instead of triggering Open as Folder. const realRepo = path.join(tmpDir, 'real') mkdirSync(realRepo) git(realRepo, ['init', '--quiet']) @@ -310,9 +307,7 @@ describe('isGitRepo', () => { mkdirSync(nestedDir, { recursive: true }) git(tmpDir, ['init', '--quiet', repoRoot]) - // Why: derive the expected root from git's own --show-toplevel so the - // assertion matches getGitRepoRoot's canonicalization (e.g. macOS resolves - // the /var tmpdir symlink to /private/var) across all platforms. + // Ask Git for the canonical root, including macOS /var-to-/private/var resolution. const expectedRoot = git(repoRoot, ['rev-parse', '--show-toplevel']).trim().replace(/\\/g, '/') expect(getGitRepoRoot(nestedDir)).toBe(expectedRoot) }) @@ -422,21 +417,14 @@ describe('getLinkedWorktreeMainRepoRoot', () => { }) }) -/** - * Run `fn` with `git` removed from PATH so the in-process git probe fails the - * same way a transient spawn failure would, exercising the `.git`-marker - * fallback path. PATH is restored afterward. - */ +/** Remove Git from PATH temporarily to exercise metadata fallback after a spawn failure. */ function withGitUnavailable(fn: () => void): void { const originalPath = process.env.PATH - // An empty PATH leaves no directory to resolve the bare `git` binary, so the - // probe throws ENOENT — the indeterminate failure the fallback exists for. process.env.PATH = '' try { fn() } finally { - // Why: restoring an originally-unset PATH via assignment would write the - // string "undefined", corrupting PATH for later tests in this process. + // Assignment of undefined would corrupt PATH with the literal string. if (originalPath === undefined) { delete process.env.PATH } else { diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts index f153fb2789a..08fe18e9827 100644 --- a/src/main/git/repo-detection.ts +++ b/src/main/git/repo-detection.ts @@ -27,7 +27,6 @@ export type GitRepoRegistrationInfo = { let warnedMarkerFallbackThisSession = false -/** Check if a path is a valid git repository (regular or bare). */ function* isGitRepoCommands(path: string): GitRepoCommands { try { if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { @@ -89,7 +88,6 @@ function* probeGitRepo(path: string, includeLocation = false): GitRepoCommands { @@ -168,9 +166,7 @@ function* getGitRepoRootCommands(path: string): GitRepoCommands { return normalizeGitRepoRootForInputPath(path, toplevel) } } - } catch { - // Fall through to preserving the original path. - } + } catch {} return rootPathFromMarker(path) } diff --git a/src/main/git/repo-ref-maintenance-real-git.test.ts b/src/main/git/repo-ref-maintenance-real-git.test.ts index fc74a1c2d18..49b7fbaf2ee 100644 --- a/src/main/git/repo-ref-maintenance-real-git.test.ts +++ b/src/main/git/repo-ref-maintenance-real-git.test.ts @@ -16,8 +16,7 @@ import { forceDeleteLocalBranch } from './worktree-branch-removal' import { maintainRepoPackIndex, PACK_INDEX_THRESHOLD } from './repo-pack-index-maintenance' const roots: string[] = [] -// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts -// three real `pack-refs` runs before the deferral budget is spent. +// Let the capped deferral ladder outlast three real pack-refs runs. const QUIET_MS = 25 const THRESHOLD = 20 let packFixtureRoot = '' @@ -58,7 +57,6 @@ async function createFragmentedPacks(repoPath: string): Promise { } beforeAll(async () => { - // Each test gets its own pack files; generating the same 64 packs only needs to happen once. packFixtureRoot = await mkdtemp(join(tmpdir(), 'orca-fragmented-packs-')) git(packFixtureRoot, ['init', '--quiet']) packFixtureBlobs = await buildFragmentedPacks(packFixtureRoot) @@ -90,7 +88,6 @@ function maintainIndex(repoPath: string) { }) } -/** A repo whose only loose-ref backlog is the one the test asks for. */ async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> { const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-')) roots.push(root) @@ -290,7 +287,6 @@ describe('idle ref maintenance against real Git', () => { maintenance.dispose() await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ count: 0, saturated: false }) - // The refs survived the move into packed-refs; nothing was lost. expect(git(repoPath, ['for-each-ref', '--format=%(refname)']).split('\n')).toHaveLength( THRESHOLD + 31 ) @@ -382,10 +378,7 @@ describe('idle ref maintenance against real Git', () => { describe('yielding the repository to work that deletes refs', () => { it('waits for the packed-refs lock and succeeds while the prune continues', async () => { - // The pack is never killed. `packed-refs.lock` is held for ~1.4s of a 30s - // run; the rest is the prune, during which a concurrent `update-ref -d` - // succeeds on its own because per-ref locks last microseconds. Signalling - // the child there strands a `refs/**` lock Git never clears. + // Await the packed-refs rewrite window; killing the later prune can strand per-ref locks. const { repoPath } = await createRepo(0) git(repoPath, ['branch', 'doomed']) const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) @@ -400,7 +393,6 @@ describe('yielding the repository to work that deletes refs', () => { packRefs: async (lock) => { packing = true lock.setHeld(true) - // Stands in for the rewrite window, then the long prune that follows it. await new Promise((resolve) => { releaseLock = () => { lock.setHeld(false) @@ -414,18 +406,15 @@ describe('yielding the repository to work that deletes refs', () => { } expect(packing).toBe(true) - // The real deletion path, which routes through withRepoRefMaintenancePaused. let deleted = false const deletion = forceDeleteLocalBranch(repoPath, 'doomed', head).then(() => { deleted = true }) - // It must still be waiting: the rewrite window is open. await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) expect(deleted).toBe(false) expect(git(repoPath, ['branch', '--list', 'doomed'])).toContain('doomed') - // Releasing the window is enough -- the pack is never cancelled. releaseLock?.() await deletion expect(deleted).toBe(true) @@ -433,8 +422,6 @@ describe('yielding the repository to work that deletes refs', () => { }, 30_000) it('does not block the caller once the rewrite window has closed', async () => { - // The prune phase is concurrency-safe, so a caller arriving during it pays - // nothing at all. const { repoPath } = await createRepo(0) git(repoPath, ['branch', 'doomed']) const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) diff --git a/src/main/git/repo-username.test.ts b/src/main/git/repo-username.test.ts index 417c2974bdb..96b5f97a79b 100644 --- a/src/main/git/repo-username.test.ts +++ b/src/main/git/repo-username.test.ts @@ -48,8 +48,7 @@ describe('isBranchSafeHostedLogin', () => { } ) - // Length is Orca's defensive bound, not a check-ref-format rule: a login is one - // branch component, so a loose ref stores it as a single 255-byte-max filename. + // One branch component occupies a single loose-ref filename, capped at 255 bytes. it('accepts long provider-agnostic logins up to the loose-ref filename cap', () => { expect(isBranchSafeHostedLogin('a'.repeat(255))).toBe(true) }) @@ -99,7 +98,6 @@ describe('resolveLocalGitUsername', () => { return { stdout: `${currentBranch}\n`, stderr: '' } } if (args[0] === 'symbolic-ref') { - // origin/HEAD unset — resolveDefaultBaseRefViaExec falls through to probes. throw makeExecError('no origin/HEAD') } if (args[0] === 'rev-parse') { @@ -204,13 +202,11 @@ describe('resolveLocalGitUsername', () => { gitConfig['user.name'] = 'Demo User' ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' }) - // Spaces make "Demo User" branch-unsafe; free-form author identity stays out of prefixes. await expect(resolveLocalGitUsername('/repo')).resolves.toBe('') expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) it('falls back to branch-safe user.name when github.user and gh login are absent', async () => { - // Issue #11590: only `user.name = lorengroves` is configured; branch prefix was empty. originRemoteUrl = 'https://github.com/stablyai/orca.git' gitConfig['user.name'] = 'lorengroves' ghExecFileAsyncMock.mockRejectedValue(makeExecError('gh unavailable')) @@ -241,9 +237,6 @@ describe('resolveLocalGitUsername', () => { }) it('ignores a secondary GitHub mirror when the effective remote is GitLab', async () => { - // Why: a GitLab-primary repo with a GitHub mirror must not pick up the - // GitHub account name as its branch prefix — only the effective remote - // (branch remote / default base remote / origin / lone remote) counts. originRemoteUrl = 'https://gitlab.com/stablyai/orca.git' remoteUrls['github-mirror'] = 'https://github.com/stablyai/orca.git' ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' }) @@ -259,7 +252,6 @@ describe('resolveLocalGitUsername', () => { await expect(resolveLocalGitUsername('/repo')).resolves.toBe('') await expect(resolveLocalGitUsername('/repo')).resolves.toBe('') - // api + auth-status fallback on the first resolution; cached afterwards. expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) for (const [, options] of ghExecFileAsyncMock.mock.calls) { expect(options).toMatchObject({ timeout: 2500 }) @@ -292,9 +284,6 @@ describe('resolveLocalGitUsername', () => { }) it('treats a Windows timeout kill (SIGTERM, no ETIMEDOUT code) as a timeout', async () => { - // Why: on Windows the exec timeout kill surfaces killed/SIGTERM without an - // ETIMEDOUT code; the old sync probe missed this and ran a second equally - // stuck probe (issue #7225). originRemoteUrl = 'https://github.com/stablyai/orca.git' ghExecFileAsyncMock.mockRejectedValueOnce( makeExecError('gh was killed', { killed: true, signal: 'SIGTERM' }) @@ -315,7 +304,6 @@ describe('resolveLocalGitUsername', () => { username: '', authoritative: false }) - // Within the cooldown the timeout result is reused without a new spawn. await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({ username: '', authoritative: false @@ -359,8 +347,7 @@ describe('resolveLocalGitUsername', () => { it('settles within the wall even when the gh child never exits', async () => { vi.useFakeTimers() originRemoteUrl = 'https://github.com/stablyai/orca.git' - // A promise that never settles — models a killed gh whose grandchild - // keeps the stdio pipes open past the exec timeout. + // Simulate a killed gh whose descendants keep its stdio pipes open. ghExecFileAsyncMock.mockImplementation(() => new Promise(() => {})) const resolution = resolveLocalGitUsernameDetailed('/repo') diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 197d4999f5d..e2658979d41 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -20,7 +20,7 @@ import { REPO_SEARCH_REFS_MAX_SCAN_LIMIT } from '../../shared/repo-search-limits' -// Why: use real git state (not mocked) because the bug is in the for-each-ref glob shape a mock would miss. +// Real Git exercises ref globs that mocks cannot validate. function git(cwd: string, args: string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }) @@ -28,14 +28,13 @@ function git(cwd: string, args: string[]): string { function initRepo(dir: string): void { git(dir, ['init', '--quiet']) - // Why: `--initial-branch=main` needs git >= 2.28; symbolic-ref before the first commit forces `main` on any git version. + // symbolic-ref selects main before the first commit on Git < 2.28 too. git(dir, ['symbolic-ref', 'HEAD', 'refs/heads/main']) git(dir, ['config', 'user.email', 'test@test.com']) git(dir, ['config', 'user.name', 'Test']) git(dir, ['commit', '--allow-empty', '-m', 'initial', '--quiet']) } -/** Create a remote-tracking ref via `update-ref`, avoiding a live remote. */ function createRemoteRef(mainDir: string, shortName: string, sha: string): void { git(mainDir, ['update-ref', `refs/remotes/${shortName}`, sha]) } @@ -72,8 +71,6 @@ describe('buildSearchBaseRefsArgv', () => { }) const excludes = argv.filter((arg) => arg.startsWith('--exclude=')) - // One wildcard handles ordinary remotes; slash-containing names need an - // exact pattern because `*` does not cross the remote-name slash. expect(excludes).toEqual([ '--exclude=refs/remotes/*/HEAD', '--exclude=refs/remotes/foo/bar/HEAD' @@ -174,7 +171,6 @@ describe('searchBaseRefs (widened glob)', () => { expect(results).toContain('local-only') }) - // Why: fnmatch `*` doesn't cross `/`, so a single-word query needs `**` to match any segment of a slashed name. it('finds a local slashed branch when the query lands in a deep segment', async () => { git(tmpDir, ['branch', 'feature/login']) @@ -483,8 +479,7 @@ describe('searchBaseRefs (widened glob)', () => { it('allows checking out a remote branch that another remote also carries (#26344)', async () => { const sha = getHeadSha(tmpDir) - // Why register both: a ref only tracks a branch when a remote actually owns its - // prefix, and this case is about a second *tracking* ref, not an orphan. + // Configure owners so these are tracking refs rather than orphan prefixes. git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git']) git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git']) createRemoteRef(tmpDir, 'origin/feature/something', sha) @@ -493,7 +488,6 @@ describe('searchBaseRefs (widened glob)', () => { expect( await getBranchConflictKind(tmpDir, 'feature/something', 'origin/feature/something') ).toBeNull() - // Any other base still sees the remote branch as taken. expect(await getBranchConflictKind(tmpDir, 'feature/something', 'origin/main')).toBe('remote') }) @@ -512,9 +506,7 @@ describe('searchBaseRefs (widened glob)', () => { createRemoteRef(tmpDir, 'fork/my-task', sha) expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBe('remote') - // Why not `remote remove`: that prunes the tracking refs too, which would pass - // with or without the ownership guard. Dropping only the config leaves the - // orphan ref behind, which is the state this guard exists for. + // Remove config only: remote remove would also prune the orphan ref this test needs. git(tmpDir, ['config', '--remove-section', 'remote.fork']) expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBeNull() @@ -620,7 +612,6 @@ describe('searchBaseRefs (widened glob)', () => { await expect(searchBaseRefs(tmpDir, '', Number.MAX_VALUE)).resolves.toEqual([]) }) - // Why: users retype the displayed `/` format, so a slashed query must still match. it('finds the ref when the query is in display format `/`', async () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) @@ -640,7 +631,6 @@ describe('searchBaseRefs (widened glob)', () => { expect(results).toContain('upstream/feature-x') expect(results).toContain('upstream/feature-y') - // Why: `upstream/feat` pins the remote segment to *upstream*, so origin/feature-x must not leak in. expect(results).not.toContain('origin/feature-x') }) @@ -648,7 +638,6 @@ describe('searchBaseRefs (widened glob)', () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) - // Why: each token is pinned to its own segment (remote vs branch), so `main/upstream` must not match `upstream/main`. const results = await searchBaseRefs(tmpDir, 'main/upstream') expect(results).not.toContain('upstream/main') @@ -659,7 +648,6 @@ describe('searchBaseRefs (widened glob)', () => { createRemoteRef(tmpDir, 'upstream/main', sha) git(tmpDir, ['symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main']) - // Why: the HEAD filter runs after the glob match, so display-format queries must still drop the pseudo-ref. const results = await searchBaseRefs(tmpDir, 'upstream/HEAD') expect(results).not.toContain('upstream/HEAD') @@ -709,7 +697,6 @@ describe('searchBaseRefs (widened glob)', () => { const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) - // Why: empty tokens from stray slashes would degrade to `**` and match nothing, so they're filtered out. expect(await searchBaseRefs(tmpDir, 'upstream/')).toContain('upstream/main') expect(await searchBaseRefs(tmpDir, '/upstream')).toContain('upstream/main') expect(await searchBaseRefs(tmpDir, 'upstream//main')).toContain('upstream/main') @@ -868,13 +855,11 @@ describe('getBaseRefDefault (regression — unchanged behavior)', async () => { }) it('does NOT fall through to upstream/main when origin/* is absent', async () => { - // Why: default probe order is origin-only by design; upstream-aware defaulting is deferred. const sha = getHeadSha(tmpDir) createRemoteRef(tmpDir, 'upstream/main', sha) const result = await getBaseRefDefault(tmpDir) - // initRepo creates a local `main`, so with no origin/* we expect it — not `upstream/main`. expect(result).toBe('main') expect(result).not.toBe('upstream/main') }) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index a24490c592f..42b02e8091d 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -38,13 +38,11 @@ export { getBranchConflictKind } from './repo-branch-conflict' export type { BranchConflictKind } from './repo-branch-conflict' export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' -/** Get a human-readable name for the repo from its path. */ export function getRepoName(path: string): string { const name = basename(path) return name.endsWith('.git') ? name.slice(0, -4) : name } -/** Get the remote origin URL, or null if not set. */ export async function getRemoteUrl(path: string): Promise { try { const { stdout } = await gitExecFileAsync(['remote', 'get-url', 'origin'], { cwd: path }) @@ -79,7 +77,6 @@ export async function getRemoteDrift( } } -/** Return recent subjects on the remote side of the local/remote delta. */ export async function getRecentDriftSubjects( repoPath: string, localRef: string, @@ -109,7 +106,6 @@ export async function getRecentDriftSubjects( } } -/** Parse `git remote` stdout into a remote count. */ function parseRemoteCount(stdout: string): number { return stdout.split('\n').filter((line) => line.trim().length > 0).length } @@ -125,7 +121,6 @@ export async function getRemoteCount(path: string): Promise { } } -/** Resolve the configured push remote without assuming a provider. */ export async function getDefaultRemote( path: string, options: LocalGitExecOptions = {}, @@ -148,9 +143,7 @@ export async function getDefaultRemote( if (value) { return value } - } catch { - // Fall through: branch has no explicit remote configured. - } + } catch {} } try { @@ -182,7 +175,6 @@ export async function getDefaultRemote( } } -/** Build a hosted file URL when the origin belongs to a supported provider. */ export async function getRemoteFileUrl( repoPath: string, relativePath: string, @@ -204,7 +196,6 @@ export async function getRemoteFileUrl( ) } -/** Build a hosted commit URL when the origin belongs to a supported provider. */ export async function getRemoteCommitUrl(repoPath: string, sha: string): Promise { const remoteUrl = await getRemoteUrl(repoPath) return remoteUrl ? buildHostedRemoteCommitUrl(remoteUrl, sha) : null diff --git a/src/main/git/review-head-remote-identity.ts b/src/main/git/review-head-remote-identity.ts index b5d28355ab7..cc392fe6366 100644 --- a/src/main/git/review-head-remote-identity.ts +++ b/src/main/git/review-head-remote-identity.ts @@ -6,8 +6,7 @@ type LocalGitExecOptions = { wslDistro?: string } -// Why: the durable review-head ref embeds the remote's identity, and a missing -// remote must fail with an actionable message instead of a raw fetch error. +// Resolve remote identity before constructing its durable review ref, with actionable errors for missing remotes. export async function getReviewHeadRemoteComponent( remote: string, localGitExecOptions: LocalGitExecOptions diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 48f689f042d..9cf6b38e5c1 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -43,10 +43,7 @@ function createMockChildProcess(pid: number): MockChildProcess { return child } -/** - * Spawn stand-in for the gh/glab deadline tests: the CLI hangs, while the `ps` - * quiescence probe the tree termination runs answers immediately. - */ +/** Hang the CLI while allowing its process-tree quiescence probes to answer. */ function mockWedgedCliSpawn(child: MockChildProcess): void { spawnMock.mockImplementation((program: string) => { if (program !== 'ps') { @@ -293,9 +290,7 @@ describe('runner execFile timeout handling', () => { } ) - // Why the group and not the child (#18234): `gh` and `glab` on PATH are often - // shims, so the deadline has a chain to reap. Signalling only the direct child - // leaves the rest of it running under init long after the deadline passed. + // CLI shims have descendants; direct-child signals can leave them running beyond the deadline. it('signals the whole gh process group when gh never calls back', async () => { const child = createMockChildProcess(1234) mockWedgedCliSpawn(child) @@ -430,9 +425,6 @@ describe('runner execFile timeout handling', () => { expect(capturedEnv?.ORCA_TEST_ENV).toBe('kept') }) - // Issue #5308: git read-path calls must be forced non-interactive so a - // credential / SSH host-key prompt fails fast instead of blocking forever on - // stdin and wedging the serve runtime for all clients. it('runs git non-interactively so a prompt fails fast instead of hanging', async () => { const child = createMockChildProcess(1234) let capturedEnv: NodeJS.ProcessEnv | undefined @@ -469,10 +461,7 @@ describe('runner execFile timeout handling', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)], - // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit - // Orca's own cwd, a deletable WSL UNC path when it was launched from a - // worktree. The Linux directory still rides inside the command (/mnt/c/repo, - // asserted below). + // Use a stable Win32 cwd instead of inheriting Orca's deletable UNC cwd. expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) @@ -506,10 +495,7 @@ describe('runner execFile timeout handling', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', expect.any(String)], - // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit - // Orca's own cwd, a deletable WSL UNC path when it was launched from a - // worktree. The Linux directory still rides inside the command (/mnt/c/repo, - // asserted below). + // Use a stable Win32 cwd instead of inheriting Orca's deletable UNC cwd. expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) @@ -623,7 +609,6 @@ describe('gitStreamStdout', () => { let calls = 0 const promise = gitStreamStdout(['status'], { cwd: '/repo', - // Stop after the first chunk — mirrors a parser hitting its entry limit. onStdout: () => { calls += 1 return true diff --git a/src/main/git/runner-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts index 3a763547a21..31882977695 100644 --- a/src/main/git/runner-gh-rate-limit-breaker.test.ts +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -48,8 +48,6 @@ describe('ghExecFileAsync rate-limit breaker', () => { ).rejects.toThrow('rate limit') expect(spawnMock).toHaveBeenCalledTimes(1) - // The 90-repo storm case: every further search-bucket call must fail fast - // without a subprocess. await expect( ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:c/d&per_page=1']) ).rejects.toMatchObject({ ghRateLimitBlocked: true }) @@ -71,8 +69,7 @@ describe('ghExecFileAsync rate-limit breaker', () => { mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() - // The breaker scope now comes from options.host / GH_HOST / wslDistro, not - // from sniffing argv — so an Enterprise host must be routed via options.host. + // Route this request to an independent Enterprise host. mockGhSuccess('[]') await expect( ghExecFileAsync(['api', 'repos/a/b/pulls'], { host: 'github.acme-corp.com' }) @@ -148,9 +145,7 @@ describe('ghExecFileAsync rate-limit breaker', () => { }) it('scopes a WSL UNC-cwd 403 to the distro runtime and probes that scope', async () => { - // Why: a \\wsl.localhost\... cwd routes gh through wsl.exe, so the 403 - // belongs to the distro's account — the native scope must stay usable and - // the reset probe must be told which scope tripped. + // WSL-cwd failures belong to the distro account; keep native quotas usable and probe the blocked scope. const probe = vi.fn() registerGhRateLimitResetProbe(probe) const originalPlatform = process.platform diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index aa0df0340aa..0a2605da28c 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -369,8 +369,6 @@ describe('WSL direct Git reads', () => { }) }) - // A UNC worktree names its distro in the path, so a read there needs no resolved WSL project - // runtime to skip the shell -- requiring one is what kept every diff read on the login shell. it('takes the direct read route for an override-less WSL UNC cwd', async () => { await withPlatform('win32', async () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) @@ -388,8 +386,6 @@ describe('WSL direct Git reads', () => { }) }) - // The classifier already recognizes `show`, so the blob reads behind every diff take the - // direct route from the cwd alone -- no caller-supplied distro, no explicit opt-in. it('takes the direct read route for an unclassified-caller blob read on a UNC cwd', async () => { await withPlatform('win32', async () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) @@ -403,8 +399,6 @@ describe('WSL direct Git reads', () => { }) }) - // Kicking the probe off and resolving without it left the reads issued before it - // answered on the login shell -- the slow route, chosen by nothing but timing. it('waits for a cold probe so the first read already skips the shell', async () => { await withPlatform('win32', async () => { execFileMock.mockImplementation((_command, args, _options, callback) => { @@ -435,7 +429,6 @@ describe('WSL direct Git reads', () => { try { execFileMock.mockImplementation((_command, args, _options, callback) => { const child = createMockChild() - // The probe never answers; only the git command itself does. if (!String(args).includes('_orca_git_path')) { queueMicrotask(() => callback?.(null, 'ok', '')) } @@ -448,8 +441,6 @@ describe('WSL direct Git reads', () => { await vi.advanceTimersByTimeAsync(WSL_GIT_READ_ENVIRONMENT_WAIT_MS) await pending - // Exactly the routing this read had before the wait existed: an unanswered - // probe must cost the bound and nothing else. const resolved = execFileMock.mock.calls.at(-1)?.[1] ?? [] expect(resolved.slice(3, 5)).toEqual(['bash', '-c']) expect(resolved).not.toContain('/usr/bin/env') @@ -459,8 +450,6 @@ describe('WSL direct Git reads', () => { }) }) - // Once the route is disabled the answer is already known, so paying for a timer and two - // extra microtask hops on every later read is pure overhead on the host this PR targets. it('stops deferring reads once the direct route is disabled for the distro', async () => { await withPlatform('win32', async () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) @@ -493,7 +482,6 @@ describe('WSL direct Git reads', () => { await withPlatform('win32', async () => { vi.useFakeTimers() try { - // The probe never answers, so only the abort can end the wait. execFileMock.mockImplementation(() => createMockChild()) const controller = new AbortController() diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index e2ee30d2646..e275d4c304e 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -91,9 +91,7 @@ describe('ghExecFileAsync WSL fallback', () => { '-c', "cd '/home/jinwoo/stably/noqa' && 'gh' 'issue' 'list' '--repo' 'stablyhq/noqa' '--json' 'number,title'" ], - // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit - // Orca's own cwd, a deletable WSL UNC path when it was launched from a - // worktree. The Linux directory still rides inside the command. + // Use a stable Win32 cwd instead of inheriting Orca's deletable UNC cwd. expect.objectContaining({ cwd: expect.any(String) }) ) expect(spawnMock).toHaveBeenNthCalledWith( @@ -284,10 +282,7 @@ describe('ghExecFileAsync WSL fallback', () => { 2, 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'api' 'rate_limit'"], - // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit - // Orca's own cwd, a deletable WSL UNC path when it was launched from a - // worktree. This global call has no repo directory at all, so nothing about - // where it runs changes. + // Global calls still need a stable Win32 spawn cwd. expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -404,10 +399,7 @@ describe('ghExecFileAsync WSL fallback', () => { 2, 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'api' 'projects'"], - // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit - // Orca's own cwd, a deletable WSL UNC path when it was launched from a - // worktree. This global call has no repo directory at all, so nothing about - // where it runs changes. + // Global calls still need a stable Win32 spawn cwd. expect.objectContaining({ cwd: expect.any(String) }) ) }) @@ -419,8 +411,7 @@ describe('ghExecFileAsync WSL fallback', () => { const taskkill = createFakeSpawnedChild(3600) spawnMock .mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab'))) - // Why a child that never exits: this is the wedged WSL helper the deadline - // has to reap, so nothing must settle the promise before taskkill reports. + // Keep the child unsettled until taskkill reports completion. .mockImplementationOnce(() => wslChild) .mockImplementation(() => taskkill) @@ -512,7 +503,6 @@ describe('ghExecFileAsync WSL fallback', () => { }) it('resolves fallback to the overridden distro if configured, and falls back to default WSL distro otherwise', async () => { - // 1) Test with override configured (should use 'Debian' override) setDefaultWslDistroOverride('Debian') getDefaultWslDistroMock.mockReturnValue('Ubuntu') @@ -539,7 +529,6 @@ describe('ghExecFileAsync WSL fallback', () => { expect.any(Object) ) - // 2) Test without override (should use default 'Ubuntu') spawnMock.mockClear() setDefaultWslDistroOverride(null) diff --git a/src/main/git/runner-wsl-login-shell-capture.test.ts b/src/main/git/runner-wsl-login-shell-capture.test.ts index c2aeec6a49c..3354dc9c0ac 100644 --- a/src/main/git/runner-wsl-login-shell-capture.test.ts +++ b/src/main/git/runner-wsl-login-shell-capture.test.ts @@ -79,7 +79,6 @@ describe('WSL login-shell reads are fenced', () => { }) it('keeps binary blob bytes intact through the fence', async () => { - // Why bytes: decoding to a string to find the fence would mangle a PNG header. const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe, 0x0a, 0x00, 0x1b]) respondWithFencedPayload(binary) @@ -112,8 +111,7 @@ describe('WSL login-shell reads are fenced', () => { } it('keeps the BatchMode guard when core.sshCommand is unset behind a banner', async () => { - // Banner-only reply: git printed nothing, so no sshCommand is configured. - // Unfenced, the banner reads as a configured wrapper and the guard is dropped. + // An unfenced banner could masquerade as core.sshCommand and drop the BatchMode guard. respondToSshPolicyProbe('') await gitExecFileAsync(['fetch', 'origin'], { diff --git a/src/main/git/runner-wsl-read-routing.test.ts b/src/main/git/runner-wsl-read-routing.test.ts index 9bb957c48ef..9a9685c96d8 100644 --- a/src/main/git/runner-wsl-read-routing.test.ts +++ b/src/main/git/runner-wsl-read-routing.test.ts @@ -65,8 +65,6 @@ describe('WSL git read routing', () => { resetWslGitReadEnvironmentForTests() }) - // Why these two: they are the parse #10917 reports, and they run through the - // general git options that never opted into the shell-free route. it.each([ ['remote get-url', ['remote', 'get-url', 'origin']], ['remote list', ['remote']], @@ -83,7 +81,6 @@ describe('WSL git read routing', () => { expect(resolved).toContain('--exec') expect(resolved).toContain('/usr/bin/env') expect(resolved).toContain(`PATH=${LOGIN_ENVIRONMENT.path}`) - // No shell means no rc file, so no banner can reach the parsed stdout. expect(resolved).not.toContain('-lc') expect(resolved).not.toContain('sh') expect(resolved.join(' ')).not.toContain('getent passwd') diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts index 30851fe2882..72142b7a79f 100644 --- a/src/main/git/runner.test.ts +++ b/src/main/git/runner.test.ts @@ -1,6 +1,3 @@ -// Why: covers two recent classifier fixes — Retry-After honoring on 429 -// (transient detection must propagate, not silently retry on 250ms cadence) -// and stderr extraction from execFile rejections (err.message is unreliable). import { afterEach, describe, expect, it, vi } from 'vitest' import { appendGitConfigEnv, @@ -15,8 +12,6 @@ import { } from './runner' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' -// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol -// back into a plain key→value map so tests can assert on it directly. function readGitConfigEnv(env: NodeJS.ProcessEnv): Record { const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? '0', 10) const config: Record = {} @@ -117,9 +112,6 @@ describe('isTransientGhError', () => { }) it('does NOT retry 429 with Retry-After', () => { - // Why: when GitHub returns Retry-After, the server is telling us how long - // to wait. Retrying on our 250ms cadence just earns another 429 and burns - // the retry budget. expect(isTransientGhError('HTTP 429 Too Many Requests\nRetry-After: 60\n')).toBe(false) }) @@ -184,10 +176,8 @@ describe('appendGitConfigEnv', () => { [['credential.guiPrompt', 'false']] ) expect(env.GIT_CONFIG_COUNT).toBe('2') - // Existing entry preserved. expect(env.GIT_CONFIG_KEY_0).toBe('core.quotePath') expect(env.GIT_CONFIG_VALUE_0).toBe('false') - // New entry appended at the next index. expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt') expect(env.GIT_CONFIG_VALUE_1).toBe('false') }) @@ -237,15 +227,12 @@ describe('mergeGitConfigEnvProtocol', () => { describe('promptGuardGitEnv credential-interactivity disable (STA-1292)', () => { it('disables the GCM GUI prompt without nuking the credential helper', () => { const env = promptGuardGitEnv({ PATH: '/usr/bin' }) - // GCM: never show the GUI, but still serve cached credentials. expect(env.GCM_INTERACTIVE).toBe('never') const config = readGitConfigEnv(env) expect(config['credential.interactive']).toBe('false') expect(config['credential.guiPrompt']).toBe('false') - // Regression guard: we must NOT clear the helper — that would break - // cached-credential auth for private repos. + // Preserve credential helpers so private repos can still use cached authentication. expect(config['credential.helper']).toBeUndefined() - // Existing prompt guards remain intact. expect(env.GIT_TERMINAL_PROMPT).toBe('0') }) }) @@ -258,7 +245,6 @@ describe('nonInteractiveGitEnv credential-interactivity disable (STA-1292)', () expect(config['credential.interactive']).toBe('false') expect(config['credential.guiPrompt']).toBe('false') expect(config['credential.helper']).toBeUndefined() - // Its own BatchMode SSH guard is still applied and unaffected. expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') }) }) @@ -310,7 +296,6 @@ describe('promptGuardShellEnv keeps the shell locale (#7652 x #7808)', () => { expect(env.GIT_TERMINAL_PROMPT).toBe('0') expect(env.GCM_INTERACTIVE).toBe('never') expect((env.WSLENV ?? '').split(':')).toContain('GIT_TERMINAL_PROMPT') - // The user's locale survives; no pins appear where none existed. expect(env.LC_ALL).toBe('ja_JP.UTF-8') expect(env.LANG).toBeUndefined() expect(env.LANGUAGE).toBeUndefined() @@ -319,8 +304,6 @@ describe('promptGuardShellEnv keeps the shell locale (#7652 x #7808)', () => { describe('git env forces untranslated diagnostics (issue #7808)', () => { it('overrides an inherited non-English locale so stderr parsers keep working', () => { - // 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('en_US.UTF-8') expect(env.LANG).toBe('en_US.UTF-8') @@ -341,9 +324,6 @@ describe('git env forces untranslated diagnostics (issue #7808)', () => { describe('redirectPortedHostnameToEnv WSLENV forwarding', () => { it('names GITLAB_HOST in WSLENV so it can cross into a distro', async () => { - // A WSL-routed glab only sees Windows variables listed in WSLENV; without - // the entry the ported host is silently dropped and glab talks to - // gitlab.com (#12557). const { redirectPortedHostnameToEnv } = await import('./runner') const { options } = redirectPortedHostnameToEnv( ['api', '--hostname', 'gitlab.example.com:8443'], diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index c2aca2fffb3..9dc369ec7da 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1,14 +1,5 @@ -/** - * Centralized git/gh/command runner with transparent WSL support. - * - * Why: when a repo lives on a WSL filesystem, native Windows binaries (git.exe, - * gh.exe, rg.exe) are absent or slow, so this routes execution through - * `wsl.exe -d ` with translated Linux paths. - * - * The implementation lives in ./command-runner/*; this module stays the entry - * point its ~100 call sites (and their vi.mock factories) import from. - */ -// Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module. +/** Stable git/gh/glab execution entry point; implementations live in command-runner. */ +// Lightweight consumers can import exec-error directly to avoid runner dependencies. export { extractExecError, parseRetryAfterMs } from './exec-error' export { setDefaultWslDistroOverride } from './command-runner/wsl-command-resolution' @@ -45,5 +36,4 @@ export { glabExecFileAsync, redirectPortedHostnameToEnv } from './command-runner export { wslAwareSpawn } from './command-runner/wsl-aware-spawn' export { translateWslOutputPaths } from './command-runner/wsl-path-translation' -/** Convenience re-export of wsl.ts path helpers so consumers don't import it directly. */ export { parseWslPath, toLinuxPath, toWindowsWslPath, isWslPath } from '../wsl' diff --git a/src/main/git/saved-clone-target.test.ts b/src/main/git/saved-clone-target.test.ts index 6525b6a0f48..0bd7d9a3627 100644 --- a/src/main/git/saved-clone-target.test.ts +++ b/src/main/git/saved-clone-target.test.ts @@ -14,8 +14,7 @@ function git(cwd: string, ...args: string[]): void { execFileSync('git', ['-C', cwd, '-c', 'user.name=t', '-c', 'user.email=t@t.invalid', ...args]) } -// What `git clone url` leaves at /orca. `finished: false` is a clone killed before checkout; -// `commits: false` is a finished clone of an empty repository, whose HEAD stays unborn. +// finished=false models interrupted checkout; commits=false models a finished empty clone. function checkout(originUrl: string, { finished = true, commits = true } = {}): string { const root = mkdtempSync(join(tmpdir(), 'orca-saved-clone-target-')) roots.push(root) @@ -69,7 +68,6 @@ describe('reuseSavedCloneTarget', () => { await expect(decide(project, 'https://github.com/stablyai/orca')).resolves.toBe(project) }) - // A brand-new repository has no commits, so a repeat clone must not read as a failed one. it('reuses a finished clone of an empty repository, whose HEAD is unborn', async () => { const project = saved(checkout(url, { commits: false })) await expect(decide(project)).resolves.toBe(project) @@ -123,7 +121,6 @@ describe('reuseSavedCloneTarget', () => { await expect(decide(project)).rejects.toThrow('"orca" is already an Orca project') }) - // The two reasons a clone can be refused need different things from the user, so say which it is. it('says the recorded repository differs without disclosing its credential-bearing URL', async () => { const project = saved(join(tmpdir(), 'orca-saved-clone-target-absent'), { gitRemoteIdentity: { @@ -156,7 +153,6 @@ describe('reuseSavedCloneTarget', () => { } const path = checkout(url, { finished: false }) await expect(decide(saved(path))).rejects.toThrow('already an Orca project') - // Same repo by its saved identity: let git clone, which refuses the non-empty folder. await expect(decide(saved(path, { gitRemoteIdentity: identity }))).resolves.toBeNull() }) diff --git a/src/main/git/saved-clone-target.ts b/src/main/git/saved-clone-target.ts index d9534c8dd61..0c6bd98b595 100644 --- a/src/main/git/saved-clone-target.ts +++ b/src/main/git/saved-clone-target.ts @@ -21,13 +21,11 @@ function probeIn(repoPath: string, hostId: ExecutionHostId, signal?: AbortSignal } } -/** Only an empty first line means the folder is the checkout's own top level: a subfolder prints - * `../`, and a bare repo prints nothing so whatever follows lands on the first line instead. */ +/** Empty first line proves top level; subfolders print ../ and bare repos supply no cdup record. */ function isCheckoutTopLevel(cdupStdout: string | null): boolean { return cdupStdout !== null && cdupStdout.split(/\r?\n/)[0] === '' } -/** True when git shows `repoPath` is a checkout's own top level whose HEAD git finished writing. */ async function isFinishedCheckout( probe: GitProbe ): Promise<{ unbornBranch: string | null } | null> { @@ -83,8 +81,7 @@ async function readCloneOrigin( return values.get('remote.origin.url') ?? null } -/** True only when git on `hostId` shows `repoPath` is a finished checkout whose single origin URL - * names the same repo as `url` — what a finished `git clone url` leaves. */ +/** Require a finished top-level checkout with exactly one origin URL matching the requested repository. */ async function isFinishedCloneOf( repoPath: string, url: string, @@ -108,12 +105,7 @@ async function isFinishedCloneOf( } } -/** - * Decides what a clone does about a saved project already at its path. Returns the project when its - * folder is already a clone of `url`, null when git should clone (nothing saved, or the saved - * project was this repo and lost its folder), and throws when the saved project is something else. - * `findSaved` must match path and host, so re-reading it after the probe also checks the host. - */ +/** Reuse finished saved clones, allow same-repo reclones, and reject mismatches; findSaved must match host and path. */ export async function reuseSavedCloneTarget( findSaved: () => Repo | undefined, url: string, @@ -130,15 +122,14 @@ export async function reuseSavedCloneTarget( } if (isClone) { const current = findSaved() - // Why: removed or replaced while git answered; git clone then refuses the non-empty folder. + // Recheck identity after Git I/O; clone itself refuses any remaining nonempty folder. return current?.id === saved.id ? current : null } const requestedKey = normalizeGitRemoteUrl(url) - // Why: `origin` is the only stored remote that names the project's own repo. `upstream` names the - // repo a fork came from, and so do `Repo.upstream` and the GitHub avatar `repoIcon` derived from it. + // Only origin names this project's own repo; upstream metadata and avatars may identify its source fork. const storedOrigin = saved.gitRemoteIdentity?.remoteName === 'origin' ? saved.gitRemoteIdentity : null - // Why: the project was this repo, so its settings still belong once git re-creates the folder. + // Keep settings when the same repository needs its missing folder recreated. if (requestedKey && storedOrigin?.canonicalKey === requestedKey) { return null } diff --git a/src/main/git/settled-diff-cache-bounds.test.ts b/src/main/git/settled-diff-cache-bounds.test.ts index 8d25bba40de..bf3597ecddf 100644 --- a/src/main/git/settled-diff-cache-bounds.test.ts +++ b/src/main/git/settled-diff-cache-bounds.test.ts @@ -51,7 +51,6 @@ describe('SettledDiffCache bounds', () => { expect(cache.get('hot', settledStamp('hot-stamp'))).toBeDefined() }) - // One entry can legitimately hold megabytes, so an entry count alone bounds nothing. it('holds total retained content under the character budget', () => { const cache = new SettledDiffCache() const chunk = MAX_SETTLED_DIFF_CACHE_RESULT_CHARACTERS @@ -107,9 +106,7 @@ describe('SettledDiffCache bounds', () => { }) }) -// Why (#15036 review): the margin compares this host's clock to mtimes the WSL guest -// wrote. A guest running ahead refuses every store for as long as the skew lasts, and -// without its own counter that is indistinguishable from a repo nobody has touched. +// Count WSL guest-clock skew separately from ordinary racy writes so repeated store refusals are observable. describe('settled diff cache clock skew', () => { const result: GitDiffResult = diffOfSize(10) @@ -117,14 +114,12 @@ describe('settled diff cache clock skew', () => { const cache = new SettledDiffCache() const now = Date.now() - // Honestly fresh: written just now, margin not yet satisfied. cache.set( 'fresh', { value: 'a', newestMtimeMs: now, capturedAtMs: now }, result, cache.beginRead() ) - // Skewed: mtime in this host's future, which no local write can produce. cache.set( 'skewed', { value: 'b', newestMtimeMs: now + 60_000, capturedAtMs: now }, diff --git a/src/main/git/source-control/branch-compare.ts b/src/main/git/source-control/branch-compare.ts index f91b12dd290..d0415365124 100644 --- a/src/main/git/source-control/branch-compare.ts +++ b/src/main/git/source-control/branch-compare.ts @@ -29,13 +29,12 @@ export async function getBranchCompare( status: 'loading' } - // The base-ref probe peels to a commit. Only branch refs are guaranteed to store - // commits; remote-tracking refs may store annotated tags whose raw oid must be preserved. + // Reuse peeled commit OIDs only for branch refs; remote-tracking refs may hold annotated tags. const reusableProbedOidByRef = new Map() const { compareRef, headOidResult, baseOidResult } = await readBranchCompareHead({ readCompareRef: () => resolveCompareRef(worktreePath, options), resolveBaseRef: () => - // Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref. + // Short remote names can collide with local branches; resolve the qualified ref. resolveWorktreeAddBaseRef(baseRef, async (qualifiedRef) => { const oid = await resolveWorktreeBaseCommitOid(worktreePath, qualifiedRef, options) if (oid !== null && qualifiedRef.startsWith('refs/heads/')) { @@ -62,7 +61,7 @@ export async function getBranchCompare( if (baseOidResult.ok) { baseOid = baseOidResult.oid summary.baseOid = baseOid - // Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken. + // An unborn branch has no changes to compare. summary.changedFiles = 0 summary.commitsAhead = 0 summary.commitsBehind = 0 diff --git a/src/main/git/source-control/branch-diff.ts b/src/main/git/source-control/branch-diff.ts index 9dd2183ef29..65c866d42ad 100644 --- a/src/main/git/source-control/branch-diff.ts +++ b/src/main/git/source-control/branch-diff.ts @@ -42,8 +42,7 @@ async function loadBranchDiff( ): Promise { try { const leftPath = args.oldPath ?? args.filePath - // Why concurrent: the two sides are independent `git show` spawns, so awaiting - // them in series doubles the latency of every diff the review panel opens. + // Read independent blobs concurrently to avoid doubling diff latency. const [leftBlob, rightBlob] = await Promise.all([ readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options), readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) diff --git a/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts b/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts index 4b8a7a7aa94..4a0e190aa67 100644 --- a/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts +++ b/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts @@ -128,7 +128,7 @@ describe('bulk pathspec command-line budget', () => { const filePaths = realisticChangedPaths(120) const { bulkDiscardChanges } = await import('./discard-changes') - // Empty ls-files output: every path is untracked, so all of them take the clean lane. + // Empty ls-files output routes every path to git clean. await bulkDiscardChanges(WSL_WORKTREE, filePaths, { wslDistro: WSL_DISTRO }) const cleans = capturedInvocations().filter((args) => args[0] === 'clean') @@ -170,7 +170,6 @@ describe('bulk pathspec command-line budget', () => { it('gives a native Windows git.exe the CreateProcess cap and a POSIX host a larger one', async () => { const { bulkPathspecCommands } = await import('./git-pathspec') - // Long enough that the raw argv alone passes the Windows cap with no wrapper in sight. const filePaths = Array.from( { length: 100 }, (_, index) => `packages/${'deeply-nested-module/'.repeat(18)}file-${index}.ts` @@ -192,7 +191,6 @@ describe('bulk pathspec command-line budget', () => { await bulkStageFiles('/home/emilio/projects/orca', realisticChangedPaths(100)) - // Same 100 paths that need several chunks under the WSL wrapper stay one native call. expect(capturedInvocations()).toHaveLength(1) }) }) diff --git a/src/main/git/source-control/commit-changes.ts b/src/main/git/source-control/commit-changes.ts index 51624479755..bcac078568d 100644 --- a/src/main/git/source-control/commit-changes.ts +++ b/src/main/git/source-control/commit-changes.ts @@ -13,7 +13,7 @@ export async function commitChanges( await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options)) return { success: true } } catch (error) { - // Why: useful message may be on stderr (hook/GPG failures) or stdout ("nothing to commit"), so try both then message. + // Hook/GPG failures use stderr; nothing-to-commit output can be on stdout. const readStringField = (field: string): string | null => { if (typeof error === 'object' && error && field in error) { const v = (error as Record)[field] diff --git a/src/main/git/source-control/commit-diff.ts b/src/main/git/source-control/commit-diff.ts index 0c17f1c600d..32d60fdb5b6 100644 --- a/src/main/git/source-control/commit-diff.ts +++ b/src/main/git/source-control/commit-diff.ts @@ -42,8 +42,7 @@ async function loadCommitDiff( ): Promise { try { const leftPath = args.oldPath ?? args.filePath - // Why concurrent: the two sides are independent `git show` spawns. A root - // commit has no parent to read, so that side resolves without a spawn. + // Read independent blobs concurrently; root commits have no parent blob. const [leftBlob, rightBlob] = await Promise.all([ args.parentOid ? readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) diff --git a/src/main/git/source-control/compare-ref-oids.ts b/src/main/git/source-control/compare-ref-oids.ts index d3657e62c7d..e43bcdf3625 100644 --- a/src/main/git/source-control/compare-ref-oids.ts +++ b/src/main/git/source-control/compare-ref-oids.ts @@ -40,8 +40,7 @@ export async function resolveMergeBase( return stdout.trim() } -// Why: `--left-right --count` on the symmetric range answers both directions in one -// rev-list walk; a rebased branch is usually ahead AND behind its base. +// One symmetric rev-list walk counts both directions, including rebased branches. export async function countCompareDivergence( worktreePath: string, baseOid: string, diff --git a/src/main/git/source-control/discard-changes.ts b/src/main/git/source-control/discard-changes.ts index 5c9c91625b8..f4bb434653a 100644 --- a/src/main/git/source-control/discard-changes.ts +++ b/src/main/git/source-control/discard-changes.ts @@ -14,9 +14,6 @@ import { redoWriteForDriveCheckoutSuspects } from './drive-checkout-suspect-writ const DISCARD_WRITE = ['restore', '--worktree'] as const -/** - * Discard working tree changes for a file. - */ export async function discardChanges( worktreePath: string, filePath: string, @@ -39,9 +36,7 @@ export async function discardChanges( } ) tracked = true - } catch { - // File is not tracked by git - } + } catch {} if (tracked) { await gitExecFileAsync( @@ -72,12 +67,10 @@ async function listTrackedPathSpecs( for (const args of commands) { const { stdout } = await gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options), - // Why: this buffers rather than streams, so it must fence -- an unfenced WSL - // login shell glues its rc banner onto the first NUL record, and a tracked - // path that fails to match is silently reclassified as untracked. + // Fence WSL rc banners: a corrupted first NUL record could misclassify a tracked path as untracked. captureWslLoginShellOutput: true }) - // Why: a tracked directory can hold enough paths to exceed the JS argument limit. + // Iterate instead of spreading: tracked directories can exceed the JS argument limit. for (const trackedPath of stdout.split('\0')) { if (trackedPath) { trackedPaths.push(trackedPath) @@ -92,17 +85,13 @@ async function cleanUntrackedPaths( filePaths: readonly string[], options: GitRuntimeOptions = {} ): Promise { - // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - // A pathspec-free `clean -ffdx` would sweep the whole worktree; the chunker emits no empty chunk. + // Git pathspecs avoid recursive deletion through symlinked parents; empty chunks must never invoke clean. const commands = bulkPathspecCommands(['clean', '-ffdx', '--'], filePaths, worktreePath, options) for (const args of commands) { await gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options) }) } } -/** - * Discard working tree changes for many paths in a small number of subprocesses. - */ export async function bulkDiscardChanges( worktreePath: string, filePaths: string[], diff --git a/src/main/git/source-control/drive-checkout-suspect-paths.ts b/src/main/git/source-control/drive-checkout-suspect-paths.ts index 49b70c9d62c..b328329568c 100644 --- a/src/main/git/source-control/drive-checkout-suspect-paths.ts +++ b/src/main/git/source-control/drive-checkout-suspect-paths.ts @@ -1,16 +1,9 @@ import * as path from 'node:path' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' -/** - * Files a minimal-checkStat status can miss on a Windows Git drive checkout. - * - * `core.checkStat=minimal` compares only mtime seconds and size, so a same-size - * rewrite that keeps the old whole-second mtime (`cp -p`, `touch -r`) reads as - * clean. The local watcher sees every such write; its paths get a default-stat - * recheck on the next poll instead of the whole tree paying ~8 ms per file. - */ +// Minimal checkStat can miss same-size writes preserving whole-second mtime; recheck watcher paths with default stat. -// Why ~300: the default-stat recheck costs ~8 ms per file, so this bounds a poll's extra work to ~2.4 s. +// At ~8ms per path, cap a poll's extra recheck cost near 2.4s. export const DRIVE_CHECKOUT_SUSPECT_LIMIT = 300 export type SuspectDegradeReason = @@ -35,12 +28,7 @@ function sameSegment(a: string, b: string, windows: boolean): boolean { return windows ? a.normalize('NFC').toLowerCase() === b.normalize('NFC').toLowerCase() : a === b } -/** - * Repo-relative POSIX path of a watcher event, or null when it is the repo - * itself, outside it, or inside `.git`. Matches the root segment-wise so a - * case or Unicode spelling difference never shifts the kept tail, which stays - * exactly as the filesystem reported it. - */ +/** Preserve the reported tail via segment matching; reject repo roots, outside paths and .git. */ export function repoRelativeWatcherPath(repoPath: string, eventPath: string): string | null { const windows = isWindowsAbsolutePathLike(repoPath) const pathApi = windows ? path.win32 : path.posix @@ -131,11 +119,7 @@ export class DriveCheckoutSuspectPaths { } } - /** - * Suspects (path → event sequence) for this poll, or null when it stays - * minimal-only. `currentRoot` is the live watcher covering the repo now; - * confirmed-dirty paths are rechecked even without one. - */ + /** Snapshot suspects by event sequence; confirmed-dirty paths remain eligible without a live watcher. */ beginPoll(currentRoot: SuspectWatchRoot | null): Map | null { if (currentRoot === null) { if (this.boundRoot !== null || this.lastDegradeReason !== 'no-watcher') { diff --git a/src/main/git/source-control/drive-checkout-suspect-real-git.test.ts b/src/main/git/source-control/drive-checkout-suspect-real-git.test.ts index 68a9147fbe6..60263e0e0a5 100644 --- a/src/main/git/source-control/drive-checkout-suspect-real-git.test.ts +++ b/src/main/git/source-control/drive-checkout-suspect-real-git.test.ts @@ -19,7 +19,7 @@ const mocks = vi.hoisted(() => { return state }) -// Why: stands in for a detected Windows Git checkout, so every Git call here runs minimal-stat. +// Simulate a Windows Git drive checkout so calls use minimal stat. vi.mock('../wsl-windows-checkout-git-config', async (importOriginal) => { const actual = await importOriginal() return { @@ -107,7 +107,6 @@ describe('drive checkout suspect paths with real Git', () => { await rewriteKeepingMtime('c d.txt', 'bbbb\n') const beforeEvent = await statusPaths() - // Why assert the miss: proves minimal stat alone cannot see this write. expect(beforeEvent).toEqual([]) notifyLocalWatcherEvents(root, [{ type: 'update', path: path.join(repo, 'c d.txt') }]) @@ -118,7 +117,6 @@ describe('drive checkout suspect paths with real Git', () => { expect(targeted).toHaveLength(1) expect(targeted[0].filter((arg) => arg.startsWith(':(literal)'))).toEqual([':(literal)c d.txt']) - // Kept while dirty: the next poll still reports it with no new watcher event. expect(await statusPaths()).toEqual(['unstaged:c d.txt']) await rewriteKeepingMtime('c d.txt', 'aaaa\n') diff --git a/src/main/git/source-control/drive-checkout-suspect-status.ts b/src/main/git/source-control/drive-checkout-suspect-status.ts index 839fa5f4348..6658478d217 100644 --- a/src/main/git/source-control/drive-checkout-suspect-status.ts +++ b/src/main/git/source-control/drive-checkout-suspect-status.ts @@ -36,7 +36,6 @@ const STAT_CONCURRENCY = 8 const trackers = new Map() let stopObserving: (() => void) | null = null -/** Whether `args` run under the Windows-checkout minimal stat config in this checkout. */ function runsMinimalStat( args: string[], worktreePath: string, @@ -79,10 +78,7 @@ function trackerFor(worktreePath: string, options: GetStatusOptions): DriveCheck return tracker } -/** - * Replaces minimal-stat unstaged rows for `checked` paths with the default-stat - * rows. Staged rows compare index to HEAD, which checkStat never touches. - */ +/** Replace checked unstaged rows; staged index-to-HEAD rows are independent of checkStat. */ export function mergeSuspectStatus( minimal: readonly GitStatusEntry[], checked: ReadonlySet, @@ -169,10 +165,7 @@ async function readTargetedStatus( return parser.entries } -/** - * Rechecks watcher-reported paths with default checkStat and overrides their - * minimal-stat rows. Without a live watcher for the checkout this is a no-op. - */ +/** Recheck watcher suspects with default stat, retaining confirmed-dirty paths even without a live watcher. */ export async function reconcileDriveCheckoutSuspects( worktreePath: string, entries: GitStatusEntry[], @@ -247,10 +240,7 @@ function pathspecScope(pathspec: string): string { return scope === '.' ? '' : scope } -/** - * Suspects inside `pathspecs` (worktree-relative files or folders; `.` is the whole worktree) - * when writes there run minimal-stat, else none. Reads only the existing tracker. - */ +/** Read existing suspects within worktree-relative pathspecs (. means all), only for minimal-stat writes. */ export function driveCheckoutSuspectsWithin( worktreePath: string, pathspecs: readonly string[], diff --git a/src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts b/src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts index 3452e58ad00..b6834568bd5 100644 --- a/src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts +++ b/src/main/git/source-control/drive-checkout-suspect-writes-real-git.test.ts @@ -19,7 +19,7 @@ const mocks = vi.hoisted(() => { return state }) -// Why: stands in for a detected Windows Git checkout, so every Git call here runs minimal-stat. +// Simulate a Windows Git drive checkout so calls use minimal stat. vi.mock('../wsl-windows-checkout-git-config', async (importOriginal) => { const actual = await importOriginal() return { @@ -123,7 +123,7 @@ describe('drive checkout suspect writes with real Git', () => { it('stages a suspect that minimal-stat add treats as unchanged', async () => { await rewriteSuspect('c d.txt', 'bbbb\n') - // Why assert the miss: proves the plain minimal-stat add is the silent no-op. + // Demonstrate that minimal-stat add misses the rewrite. await git(['-c', 'core.checkStat=minimal', 'add', '--', 'c d.txt']) expect(await stagedPaths()).toEqual([]) diff --git a/src/main/git/source-control/drive-checkout-suspect-writes.ts b/src/main/git/source-control/drive-checkout-suspect-writes.ts index 2d32ca24468..fc22989417f 100644 --- a/src/main/git/source-control/drive-checkout-suspect-writes.ts +++ b/src/main/git/source-control/drive-checkout-suspect-writes.ts @@ -37,13 +37,7 @@ async function listMergedTrackedPaths( return [...tracked] } -/** - * Redoes a path write with default checkStat for the drive-checkout suspects inside `pathspecs`. - * - * Why: under minimal stat, `add` and `restore` treat a same-size rewrite that kept its mtime as - * unchanged, while status rechecks suspects with default stat and shows it modified; the write - * would silently do nothing. Only suspects pay the drvfs rehash, so the main write stays minimal. - */ +/** Redo suspect writes with default stat: minimal-stat add/restore can silently miss paths status already reports dirty. */ export async function redoWriteForDriveCheckoutSuspects( worktreePath: string, pathspecs: readonly string[], diff --git a/src/main/git/source-control/effective-upstream-status-cache.ts b/src/main/git/source-control/effective-upstream-status-cache.ts index cf153b71b70..58941f79270 100644 --- a/src/main/git/source-control/effective-upstream-status-cache.ts +++ b/src/main/git/source-control/effective-upstream-status-cache.ts @@ -19,7 +19,6 @@ export const effectiveUpstreamStatusWriteGeneration = new Map() let writeGenerationSequence = 0 let evictedWriteGeneration = 0 -// Why: tests reuse this hook, so every memoization layer resets together despite the upstream-only name. export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusCache.clear() effectiveUpstreamStatusInFlight.clear() @@ -133,7 +132,7 @@ export function rememberEffectiveUpstreamStatus( probedSameNameOriginRef: boolean, writeGeneration: number ): void { - // Why: hasConfiguredPushTarget gates a write action; re-probe each poll rather than cache a stale positive. + // Positive state gates writes; probe each poll instead of caching stale permission to push. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusWriteGeneration.set(cacheKey, ++writeGenerationSequence) @@ -149,7 +148,7 @@ export function rememberEffectiveUpstreamStatus( if (!probedSameNameOriginRef) { return } - // Why: cache the negative so a stable no-upstream branch doesn't re-probe every poll (TTL lets push/fetch refs appear). + // Cache negative state briefly to avoid repeated probes while allowing new refs to appear. effectiveUpstreamStatusCache.set(cacheKey, { status, expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS diff --git a/src/main/git/source-control/effective-upstream-status-probe.ts b/src/main/git/source-control/effective-upstream-status-probe.ts index d00350dd19f..734bdd14cd2 100644 --- a/src/main/git/source-control/effective-upstream-status-probe.ts +++ b/src/main/git/source-control/effective-upstream-status-probe.ts @@ -44,7 +44,6 @@ export async function readOrProbeEffectiveUpstreamStatus( } } - // Why: overlapping refreshes at startup — coalesce the upstream probe so a stable missing ref fails once. const writeGeneration = getEffectiveUpstreamStatusWriteGeneration(cacheKey) const probe = probeOrRevalidateEffectiveUpstreamStatus( cacheKey, @@ -94,11 +93,11 @@ async function probeOrRevalidateEffectiveUpstreamStatus( ) return { status, probedSameNameOriginRef: false } } catch (error) { - // Why: an aborted probe says nothing about the ref; don't evict the warm name cache. + // Aborts prove nothing about ref state; retain the warm name cache. if (options.signal?.aborted) { throw error } - // Ref deleted or repo state changed — fall through to a full re-resolve. + // Re-resolve after an inconclusive cached-ref probe. resolvedUpstreamNameCache.delete(cacheKey) } } diff --git a/src/main/git/source-control/file-diff.ts b/src/main/git/source-control/file-diff.ts index 2d870d95c6e..b28a6fc4f5a 100644 --- a/src/main/git/source-control/file-diff.ts +++ b/src/main/git/source-control/file-diff.ts @@ -26,9 +26,6 @@ import { } from './submodule-gitlink-oid' import { buildSubmoduleInnerCommitRangeDiff, buildSubmodulePointerDiff } from './submodule-diff' -/** - * Get original and modified content for diffing a file. - */ export async function getDiff( worktreePath: string, filePath: string, @@ -44,8 +41,7 @@ export async function getDiff( compareAgainstHead, ...gitRuntimeOptionsKey(options) ]) - // Why: register the dedupe synchronously (before any await) so concurrent identical reads - // coalesce — including on the settled-cache lookup, which is itself I/O. + // Register before any await so concurrent reads also coalesce settled-cache lookup I/O. return gitDiffReadDedupe.run(readKey, () => loadDiffThroughSettledCache( readKey, @@ -58,14 +54,7 @@ export async function getDiff( ) } -/** - * Serve a settled diff when the git state it was built from is provably - * unchanged, otherwise read and — only if the read proved everything it touched - * — record it under the stamp taken *before* the read. - * - * Stamping first is what makes staleness impossible: anything that moves during - * or after the read leaves the stored stamp behind, so the next lookup misses. - */ +/** Reuse only proven inputs; stamp before reading so later state changes invalidate the cached result. */ async function loadDiffThroughSettledCache( readKey: string, worktreePath: string, @@ -74,8 +63,7 @@ async function loadDiffThroughSettledCache( compareAgainstHead: boolean, options: GitRuntimeOptions ): Promise { - // Why before the stamp read: the stamp is itself several awaited stats, and a mutation that - // lands entirely inside that window would otherwise leave the fence covering only the git read. + // Start the mutation fence before awaited stats so writes during stamp collection also invalidate the read. const readGeneration = settledDiffCache.beginRead() // A staged diff compares HEAD to the index, so the working tree is not one of its inputs. const stamp = await readWorktreeDiffStamp(worktreePath, filePath, !staged, options) @@ -90,11 +78,7 @@ async function loadDiffThroughSettledCache( return loaded.result } -/** - * `reusable` is false when the result cannot be proven to describe the stamped - * state: a submodule route, whose inputs live in another repo and are stamped by - * that repo's own read, or a blob read that failed rather than proving absence. - */ +/** Submodule routes and failed blob reads cannot prove this repo's stamped state reusable. */ type LoadedDiff = { result: GitDiffResult; reusable: boolean } async function loadDiff( @@ -104,12 +88,12 @@ async function loadDiff( compareAgainstHead: boolean, options: GitRuntimeOptions ): Promise { - // Why: gitlink paths can't be read as blobs, so route submodule diffs explicitly (root → pointer, inner → recurse). + // Gitlinks are not blobs: route root paths to pointer diffs and inner paths to submodule reads. const submodulePaths = await listSubmodulePaths(worktreePath, options) if (submodulePaths.length > 0) { const matchedSubmodule = findContainingSubmodule(submodulePaths, filePath) if (matchedSubmodule) { - // Why: validate the .gitmodules-derived path against the worktree boundary so a crafted one can't escape the repo. + // Validate .gitmodules paths so crafted entries cannot escape the worktree. const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, matchedSubmodule) const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') if (normalizedFilePath === matchedSubmodule) { @@ -132,7 +116,7 @@ async function loadDiff( const toOid = staged ? await readGitlinkOidFromIndex(worktreePath, matchedSubmodule, options) : await readWorkingSubmoduleHead(submoduleWorktreePath, options) - // Why: a moved gitlink with a clean submodule worktree means the change is committed — diff the two commits. + // A moved gitlink represents committed changes even when the submodule worktree is clean. if (fromOid && toOid && fromOid !== toOid) { return notReusable( await buildSubmoduleInnerCommitRangeDiff( @@ -160,8 +144,7 @@ async function loadDiff( try { if (staged) { - // Why concurrent: HEAD and the index are independent `git show` spawns. - // Only this branch qualifies — the unstaged left read chains index→HEAD. + // HEAD and index blobs are independent; read them concurrently. const [leftBlob, rightBlob] = await Promise.all([ readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options), readGitBlobAtIndexPath(worktreePath, filePath, options) @@ -173,9 +156,8 @@ async function loadDiff( modifiedDeleted = !rightBlob.exists && !rightBlob.failed readFailed = leftBlob.failed === true || rightBlob.failed === true } else { - // The left chain (index→HEAD) is sequential within itself, but the working - // tree read is a plain fs read that does not depend on it. - // Git can run in the distro against a raw Linux worktree path while Node reads it through Win32. + // The index-to-HEAD fallback is sequential internally, but independent of the working-tree read. + // WSL Git can use a Linux cwd while Node needs its Win32 spelling. const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) const [leftBlob, workingTreeBlob] = await Promise.all([ compareAgainstHead @@ -193,7 +175,6 @@ async function loadDiff( readFailed = leftBlob.failed === true || workingTreeBlob.failed === true } } catch { - // Fallback readFailed = true } @@ -204,7 +185,7 @@ async function loadDiff( modifiedIsBinary, filePath ) - // Why: mark a proven deletion so previewers don't mistake a read failure's empty side for one. + // Mark proven deletions so previewers can distinguish them from failed empty reads. if (result.kind === 'binary' && modifiedDeleted) { return { result: { ...result, modifiedDeleted: true }, reusable: !readFailed } } diff --git a/src/main/git/source-control/get-status-options.ts b/src/main/git/source-control/get-status-options.ts index d8f4099c77a..4a3f949c434 100644 --- a/src/main/git/source-control/get-status-options.ts +++ b/src/main/git/source-control/get-status-options.ts @@ -4,19 +4,11 @@ export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean includeLineStats?: boolean reuseLineStats?: boolean - /** Merge-base OID the caller wants the branch line total measured against; - * omitted means the chip is hidden, so no ranged diff runs at all. */ + /** Omit to hide the branch chip and skip its ranged diff. */ branchLineTotalMergeBase?: string - /** - * Max changed-file entries before git is stopped and the result is marked - * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. - */ + /** Stop after this many entries and mark didHitLimit; defaults to DEFAULT_GIT_STATUS_LIMIT, 0 disables. */ limit?: number bypassEffectiveUpstreamNegativeCache?: boolean - /** Paths Orca may have symlinked into this worktree (per-user shared paths - * plus `orca.yaml` shared directories). Untracked entries that are one of - * these *and* really symlinks are dropped: Git cannot ignore them when the - * repo's rule is directory-only (`node_modules/`), but they are Orca's own - * artifacts, not user work. */ + /** Hide real symlinks at Orca-shared paths; directory-only ignore rules cannot hide those links. */ sharedLinkPaths?: readonly string[] } diff --git a/src/main/git/source-control/git-blob-read.ts b/src/main/git/source-control/git-blob-read.ts index e3490b0f744..5d3d158e47a 100644 --- a/src/main/git/source-control/git-blob-read.ts +++ b/src/main/git/source-control/git-blob-read.ts @@ -13,11 +13,7 @@ export type GitBlobReadResult = { content: string isBinary: boolean exists: boolean - /** - * The read did not complete: the blob is neither known-present nor proven - * absent. Callers must not persist a diff built on one, because the empty side - * it produces is indistinguishable from a genuinely new file. - */ + /** Unknown presence: never cache a diff whose empty side came from an incomplete read. */ failed?: boolean } @@ -39,7 +35,7 @@ export async function readGitBlobAtIndexPath( filePath: string, options: GitRuntimeOptions = {} ): Promise { - // Why: Git's `:` syntax expects forward slashes even on Windows. + // Git blob syntax requires forward slashes, including on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const { stdout } = await gitExecFileAsyncBuffer(['show', `:${gitPath}`], { @@ -67,7 +63,7 @@ export async function readGitBlobAtOidPath( filePath: string, options: GitRuntimeOptions = {} ): Promise { - // Why: Git's `:` syntax expects forward slashes even on Windows. + // Git blob syntax requires forward slashes, including on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const { stdout } = await gitExecFileAsyncBuffer( @@ -97,7 +93,7 @@ export async function readWorkingTreeFile(filePath: string): Promise MAX_GIT_SHOW_BYTES) { - // Why: mirror git's maxBuffer cap for working-tree reads so readFile can't pull in huge assets. + // Mirror the Git buffer cap so filesystem reads cannot load huge assets. return { content: '', isBinary: true, exists: true } } try { const buffer = await readFile(filePath) return bufferToBlob(buffer, filePath) } catch { - // Why: the file exists but could not be read — a read failure, not a deletion. return { content: '', isBinary: false, exists: true, failed: true } } } function bufferToBlob(buffer: Buffer, filePath?: string): GitBlobReadResult { const isBinary = isBinaryBuffer(buffer) - // Return base64 for recognized image formats so the renderer can display them + // Encode previewable binaries for the renderer. const isPreviewableBinary = filePath ? !!PREVIEWABLE_BINARY_MIME_TYPES[path.extname(filePath).toLowerCase()] : false diff --git a/src/main/git/source-control/git-conflict-operation.ts b/src/main/git/source-control/git-conflict-operation.ts index 7d9bb5d86d9..78614269017 100644 --- a/src/main/git/source-control/git-conflict-operation.ts +++ b/src/main/git/source-control/git-conflict-operation.ts @@ -7,8 +7,7 @@ import { gitExecFileAsync } from '../runner' import { runWithGitReadCacheInvalidation } from './git-read-cache-invalidation' import { resolveGitDir } from './resolve-git-dir' -// Why: the git-status → existsSync race can miss a transient HEAD; fall back to 'unknown' for one poll cycle. -// Why: detect rebase from rebase-merge/ or rebase-apply/ dirs (persist all steps), not REBASE_HEAD (partial, lingers → stale badge). +// HEAD detection races may yield unknown for one poll; rebase directories persist more reliably than REBASE_HEAD. export async function detectConflictOperation( worktreePath: string, options: Pick = {} @@ -19,8 +18,7 @@ export async function detectConflictOperation( const rebaseMergeDir = path.join(gitDir, 'rebase-merge') const rebaseApplyDir = path.join(gitDir, 'rebase-apply') - // Why async and concurrent: this runs on every status poll, and on a WSL/UNC git dir each - // probe is a 9p round trip — four of them synchronously blocked the Electron main thread. + // Probe concurrently without blocking main: each WSL/UNC path can cost a 9p round trip. const [hasMergeHead, hasCherryPickHead, hasRebaseMergeDir, hasRebaseApplyDir] = await Promise.all( [mergeHead, cherryPickHead, rebaseMergeDir, rebaseApplyDir].map(pathExists) ) @@ -38,7 +36,7 @@ export async function detectConflictOperation( return 'unknown' } -/** Mirrors existsSync: any failure to reach the path reads as absent, never as a throw. */ +/** Treat inaccessible paths as absent without throwing. */ async function pathExists(target: string): Promise { try { await access(target) diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts index c128b94a7d4..c0604e16452 100644 --- a/src/main/git/source-control/git-pathspec.ts +++ b/src/main/git/source-control/git-pathspec.ts @@ -12,12 +12,7 @@ import type { GitRuntimeOptions } from '../git-runtime-options' /** Ceiling on argv entries per invocation; under WSL the byte budget bites first. */ const BULK_CHUNK_SIZE = 100 -/** - * POSIX hosts have no CreateProcess cap: ARG_MAX is 256KB on macOS and 2MB on - * Linux, shared with the environment block. Half the macOS floor keeps a native - * or SSH-host invocation clear of E2BIG without charging it the WSL wrapper's - * quoting overhead, which is a different transport's problem. - */ +// Use half macOS's 256KB ARG_MAX floor, leaving space for the environment on native and SSH hosts. const POSIX_COMMAND_LINE_BUDGET = 128_000 export function literalPathspec( @@ -25,29 +20,19 @@ export function literalPathspec( worktreePath: string, options: GitRuntimeOptions ): string { - // Why: Git inside WSL needs POSIX paths, but host paths must stay literal, so convert backslashes only for WSL. - // The runner also routes a WSL-UNC cwd into WSL when options carry no distro, so check the cwd too. + // Convert separators only for WSL, including UNC cwd routing; native host paths stay literal. const runsInWsl = options.wslDistro || wslDistroForCommand(worktreePath) const runtimePath = runsInWsl ? filePath.replace(/\\/g, '/') : filePath return `:(literal)${runtimePath}` } -/** - * Length of the line the OS will actually be handed, wrapper included. - * - * Why resolve rather than estimate: a WSL-routed write goes through the login - * shell, which shell-quotes every pathspec, quotes the resulting command line - * again, and embeds that three times (one branch per guest shell). The finished - * line runs ~3.4x the raw pathspec bytes, and nothing about the path list says - * so. Writes never take the direct-git lane, so this is the exact shape they - * get; a read that does take it resolves shorter, so the estimate stays safe. - */ +/** Measure the resolved line: WSL login wrappers quote and repeat pathspecs, multiplying raw argv length. */ function finishedCommandLineLength( args: readonly string[], worktreePath: string, options: GitRuntimeOptions ): number { - // Why worst case: the Windows-checkout config can be prepended at spawn, after this chunking. + // Include config that Windows-checkout routing may add after chunking. const budgetArgs = options.wslDistro ? [...WINDOWS_CHECKOUT_GIT_CONFIG_ARGS, ...args] : [...args] const resolved = resolveGitCommandWithoutProbe(budgetArgs, { cwd: worktreePath, @@ -57,20 +42,8 @@ function finishedCommandLineLength( } /** - * Split a bulk pathspec operation into invocations the host can actually spawn. - * - * Why a byte budget and not a path count: 100 was chosen against a raw argv, but - * a WSL-routed `git add` is folded into one login-shell command line, so 100 - * ordinary paths reached ~43,000 characters -- past the 32,767 CreateProcess cap - * -- and the bulk stage failed with nothing staged. Cost is measured per - * pathspec through the real resolver so the wrapper's quoting rules live in one - * place. - * - * Chunks split only between whole pathspecs, and a pathspec that alone exceeds - * the budget still ships alone rather than being dropped or truncated. If an - * invocation fails partway through, the earlier chunks stay applied: every - * operation here is idempotent and per-path, so `git status` shows the true - * state and re-running converges. + * Split at whole pathspecs using the resolved command-line budget; oversized single paths still ship alone. + * Earlier chunks remain applied on failure; per-path operations are idempotent, so retrying converges. */ export function bulkPathspecCommands( leadingArgs: readonly string[], diff --git a/src/main/git/source-control/git-read-cache-invalidation.ts b/src/main/git/source-control/git-read-cache-invalidation.ts index 13abe4706a8..02b2ae039a8 100644 --- a/src/main/git/source-control/git-read-cache-invalidation.ts +++ b/src/main/git/source-control/git-read-cache-invalidation.ts @@ -16,8 +16,7 @@ export const settledDiffCache = new SettledDiffCache() export const statusReadLeaseOwner = new GitStatusReadLeaseOwner() -// Why: clear every in-flight git read cache; clearing only some would let a post-mutation -// getStatus() join a pre-mutation read and publish it as current. +// Clear every read cache so post-mutation calls cannot join stale pre-mutation reads. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() settledDiffCache.clear() @@ -34,7 +33,7 @@ export async function runWithGitReadCacheInvalidation(run: () => Promise): try { return await run() } finally { - // Why: a read that started mid-mutation can be stale too, so invalidate again after. + // Invalidate again: reads started during the mutation can also be stale. invalidateGitReadCaches() } } diff --git a/src/main/git/source-control/resolve-git-dir.ts b/src/main/git/source-control/resolve-git-dir.ts index 867bfb58599..e0f49253043 100644 --- a/src/main/git/source-control/resolve-git-dir.ts +++ b/src/main/git/source-control/resolve-git-dir.ts @@ -8,11 +8,7 @@ export async function resolveGitDir( worktreePath: string, options: Pick = {} ): Promise { - // Why: git in a WSL distro reports the worktree in the guest namespace, but this read and the - // pointer resolve below both run in the Windows main process, where that spelling names nothing. - // A relative pointer (`worktree.useRelativePaths`) resolves against it too, so a guest-spelled - // base would make Win32 resolve it drive-relative. - // Null only for an empty path; the caller's spelling keeps the pre-existing fallback. + // Translate guest paths before Node reads or resolves relative pointers in the Win32 namespace. const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) ?? worktreePath const dotGitPath = path.join(hostWorktreePath, '.git') @@ -21,9 +17,7 @@ export async function resolveGitDir( if (gitDir) { return resolveGitMetadataPath(hostWorktreePath, gitDir, options) ?? dotGitPath } - } catch { - // `.git` is likely a directory in a non-worktree checkout. - } + } catch {} return dotGitPath } diff --git a/src/main/git/source-control/resolved-upstream-name-cache.ts b/src/main/git/source-control/resolved-upstream-name-cache.ts index c83b97a6b87..1d30fb2291e 100644 --- a/src/main/git/source-control/resolved-upstream-name-cache.ts +++ b/src/main/git/source-control/resolved-upstream-name-cache.ts @@ -1,4 +1,4 @@ -// Why: cache the upstream name to skip its 4-5-spawn resolution chain each poll; revalidate via one rev-list (issue #7576). +// Cache the name to replace repeated resolution chains with one rev-list revalidation per poll. export const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 type ResolvedUpstreamNameCacheEntry = { diff --git a/src/main/git/source-control/settled-diff-cache.ts b/src/main/git/source-control/settled-diff-cache.ts index 5ffd7f03e76..4af76962699 100644 --- a/src/main/git/source-control/settled-diff-cache.ts +++ b/src/main/git/source-control/settled-diff-cache.ts @@ -6,21 +6,9 @@ import { type WorktreeDiffStamp } from './worktree-diff-stamp' -/** - * Diff results that survive their read, guarded by a stamp of the git state they - * were built from. - * - * Why this is not a TTL: nothing here expires on a clock, so no window exists in - * which a stale diff can be served. An entry is returned only when a freshly - * taken stamp equals the one captured *before* the read that produced it, which - * means no input moved from then until now. Everything else — an unprovable - * stamp, a write too recent for its mtime to be conclusive, a read that failed - * partway, a mutation that ran while the read was in flight — declines to cache - * rather than risk it. - */ +/** Reuse diffs only with matching input stamps; decline unknown, recent, failed or mutation-overlapped reads. */ -// A diff result carries whole file contents on both sides, so an entry count alone bounds -// nothing: the real budget is characters, and one entry can legitimately be huge. +// Bound retained characters as well as entries: each diff can contain two entire files. export const MAX_SETTLED_DIFF_CACHE_ENTRIES = 32 export const MAX_SETTLED_DIFF_CACHE_RESULT_CHARACTERS = 1_000_000 export const MAX_SETTLED_DIFF_CACHE_TOTAL_CHARACTERS = 8_000_000 @@ -34,11 +22,7 @@ export type SettledDiffCacheStats = { stores: number /** Store declined because a write was too recent for its mtime to be conclusive. */ racyWrites: number - /** - * Subset of `racyWrites` where a component's mtime was in this host's future, so - * the clocks disagree and the refusal will persist until they converge. A nonzero - * count here means the cache is off for a reason no amount of idling will fix. - */ + /** Future mtimes also increment racyWrites; count them separately to expose clock skew. */ clockSkewedWrites: number /** Store declined because a mutation invalidated the cache while the read ran. */ invalidatedDuringRead: number @@ -64,11 +48,7 @@ export class SettledDiffCache { private clockSkewedWrites = 0 private invalidatedDuringRead = 0 - /** - * Take before starting a read; hand back to `set`. A mutation that lands while - * the read is in flight bumps the generation, and the store is refused — the - * result describes pre-mutation state and must not outlive it. - */ + /** Capture before reading; a generation change prevents publishing across a mutation. */ beginRead(): number { return this.generation } @@ -78,7 +58,6 @@ export class SettledDiffCache { this.unprovable += 1 return undefined } - // BoundedMap.get() already refreshes the LRU position. const entry = this.entries.get(key) if (!entry || entry.stamp !== stamp.value) { this.misses += 1 @@ -116,15 +95,11 @@ export class SettledDiffCache { clear(): void { this.entries.clear() - // Why: bump so a read that started pre-mutation can't repopulate the invalidated cache. + // Prevent pre-mutation reads from repopulating the cleared cache. this.generation += 1 } - /** - * Why exposed: a stamp that can never match — an inode or mtime the filesystem - * reports unstably — looks exactly like having no cache at all. These counters - * are what tells a miss storm apart from a cold start. - */ + /** Expose counters to distinguish unstable stamps and miss storms from cold starts. */ stats(): SettledDiffCacheStats { return { hits: this.hits, diff --git a/src/main/git/source-control/stage-worktree-changes.test.ts b/src/main/git/source-control/stage-worktree-changes.test.ts index a6142f0b3c1..c7d6c47551b 100644 --- a/src/main/git/source-control/stage-worktree-changes.test.ts +++ b/src/main/git/source-control/stage-worktree-changes.test.ts @@ -57,9 +57,7 @@ async function createConflictRepo(): Promise { git(repo, ['commit', '-q', '-am', 'main']) try { git(repo, ['merge', '-q', 'side']) - } catch { - // Expected: the merge stops on conflict.txt. - } + } catch {} await writeFile(path.join(repo, 'tracked.txt'), 'changed\n') await writeFile(path.join(repo, 'untracked.txt'), 'new\n') return repo @@ -76,7 +74,7 @@ describe('stageWorktreeChanges', () => { expect(status.didHitLimit).toBe(true) const listed = status.entries.map((entry) => entry.path) - // The old Stage All: only the capped listing reaches git. + // Simulate staging only the capped UI listing. await bulkStageFiles(repo, listed) expect(names(repo, ['diff', '--name-only']).length).toBeGreaterThan(0) @@ -148,7 +146,7 @@ describe('stageWorktreeChanges', () => { const owner = await mkdtemp(path.join(tmpdir(), 'orca-stage-owner-')) tempRoots.push(owner) await mkdir(path.join(owner, 'node_modules')) - // Why directory-only: it matches the owner's real directory but never the worktree's link. + // Directory-only ignore rules do not match the worktree's symlink. await writeFile(path.join(repo, '.gitignore'), 'node_modules/\n') git(repo, ['add', '.']) git(repo, ['commit', '-q', '-m', 'initial']) @@ -180,7 +178,7 @@ describe('stageWorktreeChanges', () => { ]) }) - // Why these spellings: the Shared Paths setting saves them as typed, and git prints neither. + // Shared Paths preserves typed spellings; Git reports canonical relative names. describe.each(['./node_modules', 'node_modules/'])('saved as %s', (sharedPath) => { it('never stages the shared link', async () => { const { repo } = await createSharedLinkWorktree() diff --git a/src/main/git/source-control/staged-commit-context.ts b/src/main/git/source-control/staged-commit-context.ts index 085c19e71db..b55ed98a132 100644 --- a/src/main/git/source-control/staged-commit-context.ts +++ b/src/main/git/source-control/staged-commit-context.ts @@ -37,7 +37,7 @@ export async function getStagedCommitContext( if (!isMaxBufferOverflowError(error)) { throw error } - // Why: staged patch is optional context (truncated later anyway); degrade to file-name summary rather than fail. + // Oversized patches are optional context; retain the file summary instead of failing. console.warn( '[git] Staged patch too large to read; using file summary only:', describeMaxBufferOverflowError(error) diff --git a/src/main/git/source-control/staging.ts b/src/main/git/source-control/staging.ts index a14d0984173..88bb1b58087 100644 --- a/src/main/git/source-control/staging.ts +++ b/src/main/git/source-control/staging.ts @@ -14,9 +14,6 @@ import { redoWriteForDriveCheckoutSuspects } from './drive-checkout-suspect-writ const STAGE_WRITE = ['add', '--update'] as const -/** - * Stage a file. - */ export async function stageFile( worktreePath: string, filePath: string, @@ -34,9 +31,6 @@ export async function stageFile( } } -/** - * Unstage a file. - */ export async function unstageFile( worktreePath: string, filePath: string, @@ -56,9 +50,7 @@ export async function unstageFile( } } -/** - * Stage selected files through stdin to avoid argv limits and repeated index writes. - */ +// Use stdin to avoid argv limits and repeated index writes. export async function bulkStageFiles( worktreePath: string, filePaths: string[], @@ -81,9 +73,7 @@ export async function bulkStageFiles( } } -/** - * Stage every change in the worktree when the capped listing cannot name them all. - */ +// A capped status listing cannot enumerate every change for Stage All. export async function stageWorktreeChanges( worktreePath: string, scope: GitStageWorktreeScope, @@ -92,8 +82,7 @@ export async function stageWorktreeChanges( invalidateGitReadCaches() try { const gitOptions = gitOptionsForWorktree(worktreePath, options) - // Why: Git cannot ignore Orca's shared symlinks under a directory-only rule (`node_modules/`), - // so `add --all` would commit a machine-specific link the listing hides (see status-read). + // Directory-only ignore rules do not hide shared symlinks; exclude them from add --all. const excludedUntrackedPaths = scope === 'all' && options.sharedLinkPaths && options.sharedLinkPaths.length > 0 ? await findExistingWorktreeSymlinkPaths(worktreePath, options.sharedLinkPaths, { @@ -113,9 +102,7 @@ export async function stageWorktreeChanges( } } -/** - * Unstage selected files through stdin to avoid argv limits and repeated index writes. - */ +// Use stdin to avoid argv limits and repeated index writes. export async function bulkUnstageFiles( worktreePath: string, filePaths: string[], diff --git a/src/main/git/source-control/status-branch-line-total-input.ts b/src/main/git/source-control/status-branch-line-total-input.ts index b7eeaf06cf2..dc8c15d0810 100644 --- a/src/main/git/source-control/status-branch-line-total-input.ts +++ b/src/main/git/source-control/status-branch-line-total-input.ts @@ -11,7 +11,6 @@ import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' import { resolveWorktreeFilesystemPath } from './worktree-filesystem-path' import type { GetStatusOptions } from './get-status-options' -/** Undefined — and therefore zero extra work — unless status succeeded and the caller asked for a valid base. */ export function createBranchLineTotalInput( worktreePath: string, entries: GitStatusEntry[], @@ -21,8 +20,7 @@ export function createBranchLineTotalInput( | { mergeBase: string; compute: () => Promise } | undefined { const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) - // Why: a failed status scan leaves the untracked list untrustworthy, so the - // total would silently under-count rather than be absent. + // Failed status leaves the untracked list incomplete; omit rather than under-count the total. if (mergeBase === undefined || !statusSucceeded) { return undefined } @@ -31,9 +29,9 @@ export function createBranchLineTotalInput( compute: () => computeGitBranchLineTotal({ worktreePath, - // Why: the untracked tally reads files directly, so it needs Node's spelling, not git's. + // Direct file reads need Node's path spelling. filesystemWorktreePath: resolveWorktreeFilesystemPath(worktreePath, options), - // Why: the same path can be a different filesystem per WSL distro. + // Identical paths can name different filesystems in different WSL distros. hostKey: options.wslDistro ?? 'native', mergeBase, untrackedPaths: entries @@ -42,7 +40,7 @@ export function createBranchLineTotalInput( runDiffNumstat: (args, signal) => gitExecFileAsync(args, { ...gitReadOptionsForWorktree(worktreePath, options), - // Why: after the spread, so the shared lease signal wins over this caller's own. + // The shared lease owns cancellation, not this individual caller. signal, env: gitOptionalLocksDisabledEnv(), timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS @@ -56,6 +54,6 @@ export function getStatusLineStatsCacheKey( worktreePath: string, options: GitRuntimeOptions = {} ): string { - // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. + // Scope filesystem stats to Git's execution host, including the WSL distro. return `${options.wslDistro ?? 'native'}\0${worktreePath}` } diff --git a/src/main/git/source-control/status-line-stats.ts b/src/main/git/source-control/status-line-stats.ts index 46437ac3ef2..f1e7ad48039 100644 --- a/src/main/git/source-control/status-line-stats.ts +++ b/src/main/git/source-control/status-line-stats.ts @@ -36,11 +36,11 @@ async function runNumstat( ) return parseNumstat(stdout) } catch (error) { - // Why: an aborted pass must reject; only a genuine numstat failure degrades to uncounted rows. + // Propagate cancellation; ordinary numstat failures may degrade to uncounted rows. if (options.signal?.aborted) { throw error } - // Why: a numstat failure leaves rows uncounted; null (not empty map) flags the pass incomplete and uncacheable. + // null marks the pass incomplete and uncacheable; an empty map would imply success. return null } } @@ -59,7 +59,7 @@ export async function attachLineStats( const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), - // Why: git took the guest path, but this read goes straight through Node's own namespace. + // Node reads need host paths even when Git runs in WSL. collectUntrackedAdditions( resolveWorktreeFilesystemPath(worktreePath, options), untrackedPaths, diff --git a/src/main/git/source-control/status-read.ts b/src/main/git/source-control/status-read.ts index 5b126828155..b3a383099a1 100644 --- a/src/main/git/source-control/status-read.ts +++ b/src/main/git/source-control/status-read.ts @@ -35,17 +35,11 @@ import { } from './status-branch-line-total-input' import { reconcileDriveCheckoutSuspects } from './drive-checkout-suspect-status' -/** - * Parse `git status --porcelain=v2` output into structured entries. - */ export async function getStatus( worktreePath: string, options: GetStatusOptions = {} ): Promise { - // Why nothing is cleared here: a status poll is a read. Dropping the in-flight diff entry - // mid-read only made a concurrent identical request start duplicate git work, and the - // settled diff cache is keyed on stamped git state, which a read cannot change anyway. - // Mutations invalidate both, through invalidateGitReadCaches. + // Reads preserve diff caches; mutations invalidate them and settled entries also validate input stamps. const cacheKey = getStatusReadKey(worktreePath, options) return statusReadLeaseOwner.lease(cacheKey, options.signal, (sharedSignal) => runGetStatus(worktreePath, { ...options, signal: sharedSignal }) @@ -53,7 +47,6 @@ export async function getStatus( } function getStatusReadKey(worktreePath: string, options: GetStatusOptions): string { - // Why: each key part can change the output shape or runtime routing. const limit = resolveGitStatusLimit(options.limit) return stableInFlightKey([ worktreePath, @@ -62,34 +55,22 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.includeIgnored === true, options.includeLineStats !== false, options.reuseLineStats === true, - // Why: the result carries a total only for callers who asked, and only for - // this fork point, so a shared lease must never serve one to the other. + // Totals for different fork points or opt-in states cannot share a lease. options.branchLineTotalMergeBase ?? '', options.bypassEffectiveUpstreamNegativeCache === true, limit, - // Why: this changes which entries survive, so it must not share a cache slot. + // Filtering options affect which rows survive and must participate in the cache key. options.sharedLinkPaths ?? [] ]) } -/** Remove untracked entries that are shared symlinks Orca created. - * - * Why this can't be left to Git: a directory-only ignore rule (`node_modules/`) - * matches the primary checkout's real directory but never the worktree's - * symlink, so Git reports it untracked forever — a phantom row in the diff and - * a permanently "dirty" worktree. - * - * Tight on both axes: an entry must be configured as shared *and* actually be a - * symlink. A regular file the user created at a configured name still shows up, - * and so does a symlink at a path nobody declared shared. Mutates `entries`. */ +/** Hide only configured, real, untracked shared symlinks; directory-only Git ignore rules cannot hide them. */ async function dropSharedSymlinkUntrackedEntries( worktreePath: string, entries: GitStatusEntry[], options: GetStatusOptions ): Promise { const sharedLinkPaths = options.sharedLinkPaths ?? [] - // Why: a clean tree has no untracked entries, so this costs nothing on the - // common status-poll path — no syscall, no config read, no subprocess. if (sharedLinkPaths.length === 0 || !entries.some((entry) => entry.area === 'untracked')) { return } @@ -128,12 +109,10 @@ async function runGetStatus( options.includeLineStats === false ? null : beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) let effectiveUpstreamStatus: GitUpstreamStatus | undefined let statusSucceeded = false - // Why: a bad limit (negative/fractional/NaN) breaks early-stop; require a valid non-negative int (0 disables the cap). const limit = resolveGitStatusLimit(options.limit) - // Why: detectConflictOperation and git status are independent, so run them concurrently to save I/O latency. const conflictPromise = detectConflictOperation(worktreePath, options) - // Why: core.quotePath=false keeps non-ASCII paths as raw UTF-8, not octal escapes, so entry.path is readable and lookups match. + // Keep non-ASCII paths readable instead of Git's octal escapes. const statusArgs = [ '-c', 'core.quotePath=false', @@ -146,10 +125,10 @@ async function runGetStatus( statusArgs.push('--ignored=matching') } - // Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process. + // Bound streaming status so huge unignored folders cannot exhaust process memory. const parser = new StatusPorcelainParser() let didHitLimit = false - // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + // Own rejections before marker I/O so fast Git failures cannot become unhandled. const statusSettlementPromise = Promise.allSettled([ (async () => { const result = await gitStreamStdout(statusArgs, { @@ -157,7 +136,7 @@ async function runGetStatus( wslDistro: options.wslDistro, admissionTier: options.admissionTier, preferWslDirectGit: true, - // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. + // Read-only polls must not compete with terminal Git for index.lock. env: gitOptionalLocksDisabledEnv(), signal: options.signal, onStdout: (chunk) => parser.update(chunk, limit) @@ -178,20 +157,18 @@ async function runGetStatus( didHitLimit = statusResult.value.stoppedEarly statusSucceeded = true } catch (error) { - // Why: an aborted scan must reject, not resolve as an empty result. + // Cancellation must reject rather than look like an empty status. if (options.signal?.aborted) { throw error } - // Not a git repo or git not available } let entries: GitStatusEntry[] = [] const { head, branch, upstreamName, upstreamAheadBehind } = parser.branch - // Why: git runs in the distro and answers in its namespace; the working-tree probes below run here. + // Node filesystem probes need host paths even when Git reports guest spellings. const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) ?? worktreePath - // Why: resolve deferred conflicts in Git's output order so the cap cannot hide - // an early conflict behind ordinary rows that appeared later in the stream. + // Resolve conflicts in Git output order so the cap cannot hide early conflicts behind later ordinary rows. for (const record of parser.statusRecords) { if (didHitLimit && entries.length >= limit) { break @@ -221,7 +198,7 @@ async function runGetStatus( options ) try { - // Why: the shared probe/caches serve concurrent reads, so run it unbound from this signal — one abort mustn't reject it for others. + // Shared probes outlive individual callers; one abort must not reject other readers. const { signal: _requestSignal, ...sharedProbeOptions } = options effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus( cacheKey, @@ -231,12 +208,12 @@ async function runGetStatus( options.bypassEffectiveUpstreamNegativeCache === true ) } catch { - // Why: don't fail status polling on a transient upstream-probe error; the explicit upstream path surfaces those. + // Polling tolerates upstream probe failures; explicit upstream actions surface them. } } } - // Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set. + // Count only populated areas and skip truncated scans to avoid unbounded numstat work. let branchLineTotal: GitBranchLineTotal | undefined let branchLineTotalUnavailable: string | undefined if (!didHitLimit && lineStatsWriteToken !== null) { @@ -262,7 +239,7 @@ async function runGetStatus( clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } - // Why: an abort after the stream (unmerged/upstream/line-stats work) must still reject, not resolve. + // Cancellation during follow-up probes must still reject. if (options.signal?.aborted) { const error = new Error('The operation was aborted.') error.name = 'AbortError' diff --git a/src/main/git/source-control/submodule-diff.ts b/src/main/git/source-control/submodule-diff.ts index f3fd95485a5..d016cbe748e 100644 --- a/src/main/git/source-control/submodule-diff.ts +++ b/src/main/git/source-control/submodule-diff.ts @@ -9,17 +9,14 @@ import { readWorkingSubmoduleHead } from './submodule-gitlink-oid' -/** - * Synthesize a gitlink pointer diff: Git represents submodule commit changes as a - * one-line `Subproject commit ` swap, so the old/new oids feed the text differ. - */ +/** Feed gitlink OIDs to the text differ as Subproject commit lines. */ export async function buildSubmodulePointerDiff( worktreePath: string, submodulePath: string, staged: boolean, compareAgainstHead: boolean, options: GitRuntimeOptions, - // Why: default to the validated resolver so every caller is guarded against path escape. + // Default to the validated resolver to prevent submodule path escape. submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) ): Promise { let leftOid = '' @@ -45,10 +42,7 @@ export async function buildSubmodulePointerDiff( ) } -/** - * Diff a file inside a submodule across two of its commits — used when the parent - * gitlink moved but the submodule worktree is clean (change is committed). - */ +/** Show committed inner-file changes when a clean submodule's gitlink moved. */ export async function buildSubmoduleInnerCommitRangeDiff( submoduleWorktreePath: string, innerPath: string, @@ -67,9 +61,7 @@ export async function buildSubmoduleInnerCommitRangeDiff( const right = await readGitBlobAtOidPath(submoduleWorktreePath, toOid, innerPath, options) modifiedContent = right.content modifiedIsBinary = right.isBinary - } catch { - // Fallback to empty content; a missing blob (add/delete) reads as one side. - } + } catch {} return buildDiffResult( originalContent, modifiedContent, diff --git a/src/main/git/source-control/submodule-paths.ts b/src/main/git/source-control/submodule-paths.ts index b667110b6ae..b5383c63501 100644 --- a/src/main/git/source-control/submodule-paths.ts +++ b/src/main/git/source-control/submodule-paths.ts @@ -16,7 +16,7 @@ export function clearSubmodulePathsCacheForTests(): void { export function clearSubmodulePathsCache(): void { submodulePathsCache.clear() - // Why: bump the generation so a pre-mutation read can't repopulate the invalidated cache. + // Prevent pre-mutation reads from repopulating this cache. submodulePathsCacheGeneration += 1 } @@ -25,7 +25,7 @@ export function getSubmodulePathsCacheCountForTests(): number { } function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOptions): string { - // Why: the same path can map to different WSL-distro filesystems, so key the cache by runtime routing. + // Identical paths can name different filesystems across WSL distros. return [worktreePath, ...gitRuntimeOptionsKey(options)].join('\0') } @@ -67,10 +67,7 @@ function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): trimSubmodulePathsCache() } -/** - * Resolve a submodule's own worktree path from a parent worktree + relative - * submodule path, rejecting anything that escapes the parent. - */ +/** Reject submodule paths that escape the parent worktree. */ export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string { if (!submodulePath || submodulePath.includes('\0') || path.isAbsolute(submodulePath)) { throw new Error('Access denied: invalid submodule path') @@ -83,10 +80,7 @@ export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath return resolved } -/** - * List configured submodule paths (relative, forward-slash) for a worktree, cached - * briefly. Read from `.gitmodules` to avoid an index-wide `ls-files` scan. - */ +/** Read .gitmodules with a short cache to avoid scanning every index entry. */ export async function listSubmodulePaths( worktreePath: string, options: GitRuntimeOptions = {} @@ -97,7 +91,7 @@ export async function listSubmodulePaths( if (cached) { return cached } - // Why: prune on misses so removed worktrees don't accumulate; hot hits stay O(1). + // Prune on misses to bound removed-worktree entries while keeping hits O(1). pruneExpiredSubmodulePathsCache(now) const cacheGeneration = submodulePathsCacheGeneration let paths: string[] = [] @@ -119,7 +113,6 @@ export async function listSubmodulePaths( }) .filter((value) => value.length > 0) } catch { - // No .gitmodules (or git config failure) — treat as a repo without submodules. paths = [] } if (cacheGeneration === submodulePathsCacheGeneration) { @@ -128,16 +121,11 @@ export async function listSubmodulePaths( return paths } -/** - * Find the submodule whose root equals or contains `filePath`. Returns the - * submodule path (forward-slash) or null when the path is not in a submodule. - */ export function findContainingSubmodule(submodulePaths: string[], filePath: string): string | null { const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '') let best: string | null = null for (const sub of submodulePaths) { if (normalized === sub || normalized.startsWith(`${sub}/`)) { - // Prefer the longest match to support nested submodule roots. if (!best || sub.length > best.length) { best = sub } diff --git a/src/main/git/source-control/submodule-status.ts b/src/main/git/source-control/submodule-status.ts index a97152b488b..60f3c7e3763 100644 --- a/src/main/git/source-control/submodule-status.ts +++ b/src/main/git/source-control/submodule-status.ts @@ -13,10 +13,7 @@ import { readWorkingSubmoduleHead } from './submodule-gitlink-oid' -/** - * Run a plain status inside a submodule's own worktree (lazy "expand submodule" - * flow). Entry paths are relative to the submodule root; the renderer prefixes them. - */ +/** Return submodule-relative entries; the renderer supplies the parent prefix. */ export async function getSubmoduleStatus( worktreePath: string, submodulePath: string, @@ -24,13 +21,12 @@ export async function getSubmoduleStatus( ): Promise { const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) const limit = resolveGitStatusLimit(options.limit) - // Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work. - // These three reads are independent, so they run concurrently — on SSH/WSL each one is a real round trip. + // Read independent inputs concurrently; staged expansion needs HEAD/index only, not inner status. const [workingResult, fromOid, toOid] = await Promise.all([ options.staged ? Promise.resolve({ entries: [], conflictOperation: 'unknown' }) : getStatus(submoduleWorktreePath, options), - // Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows. + // A clean moved gitlink still needs committed range rows. options.staged ? readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) : readGitlinkOidFromIndex(worktreePath, submodulePath, options).then( @@ -68,10 +64,6 @@ export async function getSubmoduleStatus( return workingResult } -/** - * List files changed between two submodule commits as status rows — used when a - * gitlink pointer moved so the expanded submodule shows committed changes. - */ async function computeSubmoduleRangeEntries( submoduleWorktreePath: string, fromOid: string, diff --git a/src/main/git/source-control/worktree-diff-stamp.ts b/src/main/git/source-control/worktree-diff-stamp.ts index 3fdfa6b03e5..b4656b63b0c 100644 --- a/src/main/git/source-control/worktree-diff-stamp.ts +++ b/src/main/git/source-control/worktree-diff-stamp.ts @@ -4,19 +4,7 @@ import { resolveWorktreeHostPath } from '../../../shared/git-metadata-path' import type { GitRuntimeOptions } from '../git-runtime-options' import { resolveGitDir } from './resolve-git-dir' -/** - * Subprocess-free proof of every input one working-tree file diff is built from: - * the HEAD tree, the index, `.gitmodules` (which decides submodule routing) and - * the working-tree file itself. - * - * Two equal stamps mean `git show HEAD:`, `git show :` and the - * working-tree bytes would still return what they returned when the stamp was - * taken, so a caller may reuse a settled diff instead of respawning Git — on a - * WSL/UNC worktree that trades two `wsl.exe` spawns for a handful of 9p stats. - * - * `null` means "cannot prove unchanged" — not a repo, an unreadable layout, or a - * filesystem that reports no usable mtime — and callers must not cache. - */ +/** Stamp HEAD, index, routing metadata and working-file inputs without Git; null forbids caching. */ export type WorktreeDiffStamp = { /** Opaque; compare for equality only. */ value: string @@ -25,14 +13,7 @@ export type WorktreeDiffStamp = { capturedAtMs: number } -/** - * How long after a component's mtime the stamp has to be taken before a later - * write is guaranteed to move that mtime. - * - * Why 2s: FAT/exFAT truncate mtime to a 2s bucket, the coarsest granularity a - * repo can realistically sit on. Below the margin a second write inside the same - * bucket would leave the stamp identical, so the read stays uncached instead. - */ +// Wait beyond FAT/exFAT's 2s mtime bucket so another write cannot share a recent timestamp. export const DIFF_STAMP_RACY_WRITE_MARGIN_MS = 2_000 const MISSING = '-' @@ -40,33 +21,17 @@ const ABSENT: StampComponent = { text: MISSING, mtimeMs: Number.NEGATIVE_INFINIT type StampComponent = { text: string; mtimeMs: number } -/** - * A write that lands in the same timestamp bucket as the stamp is invisible, so - * only a stamp taken a full bucket after its newest component can be trusted. - * - * The margin compares two clocks: `capturedAtMs` is this host's, while the mtimes - * come from whatever wrote the files. On a `\\wsl.localhost` worktree the guest - * sets them, and a guest running ahead pushes every recently-touched file past the - * margin — silently, and for as long as the skew lasts. `isDiffStampClockSkewed` - * separates that from an honestly-too-fresh file so the caller can count it. - */ +/** Refuse stamps within the mtime bucket; separately count future mtimes to expose host/guest clock skew. */ export function canProveUnchangedByStamp(stamp: WorktreeDiffStamp): boolean { return stamp.capturedAtMs - stamp.newestMtimeMs >= DIFF_STAMP_RACY_WRITE_MARGIN_MS } -/** - * True when a component's mtime is in this host's future, which no local write can - * produce — so the margin above is measuring skew, not freshness, and will keep - * refusing to store until the clocks converge. - */ +/** Future component mtimes cannot satisfy the cache's freshness margin. */ export function isDiffStampClockSkewed(stamp: WorktreeDiffStamp): boolean { return Number.isFinite(stamp.newestMtimeMs) && stamp.newestMtimeMs > stamp.capturedAtMs } -/** - * Stamp the inputs of one file diff. Pass `includeWorkingTree: false` for a - * staged diff, which compares HEAD to the index and never reads the working tree. - */ +/** Staged diffs compare HEAD/index only; omit working-tree inputs for those reads. */ export async function readWorktreeDiffStamp( worktreePath: string, filePath: string, @@ -78,19 +43,13 @@ export async function readWorktreeDiffStamp( // Git can run in the distro against a raw Linux worktree path while Node stats it through Win32. const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) if (!hostWorktreePath) { - // Only an empty worktree path lands here, and nothing about it is provably unchanged. return null } - // Why still pass options: the host spelling above only encodes the distro when it lands on a - // UNC share, so a drvfs-spelled worktree needs it again to resolve a non-drvfs gitdir pointer. + // Retain distro options for non-drvfs gitdir pointers even after the worktree becomes a drive path. const gitDir = await resolveGitDir(hostWorktreePath, options) const [head, index, gitmodules, workingTree] = await Promise.all([ readHeadComponent(gitDir), - // Over-invalidates on purpose: git run outside Orca (a terminal `git status`/`git add`) - // can refresh a stat-dirty index and rewrite this file without changing a single blob, - // which costs one re-read. That is the safe direction — do not "fix" it by dropping the - // index from the stamp, because `git add` then becomes invisible and the cache serves a - // pre-staging diff. + // Keep index stamps even when only stat metadata changed: dropping them would make staging invisible. readFileStampComponent(path.join(gitDir, 'index')), readFileStampComponent(path.join(hostWorktreePath, '.gitmodules')), includeWorkingTree @@ -102,8 +61,7 @@ export async function readWorktreeDiffStamp( } const components = [head, index, gitmodules, workingTree] return { - // Why JSON: a path or a ref can contain any separator character, and an ambiguous - // join is a stamp collision — two different states that compare equal. + // Structural encoding avoids collisions when paths or refs contain separators. value: JSON.stringify([ worktreePath, filePath, @@ -117,15 +75,7 @@ export async function readWorktreeDiffStamp( } } -/** - * Identify the commit HEAD resolves to. Reading the tip is what makes a plain - * commit visible: it rewrites `refs/heads/` and leaves HEAD untouched. - * - * A loose tip is recorded by content, so it needs no mtime margin. Only when no - * loose ref exists at all — packed refs, the reftable backend, or an unborn - * branch — does this fall back to the coarser stamps of the files a packed tip - * moves, and the recorded "no loose ref" marker still catches the ref appearing. - */ +/** Read loose ref content to detect commits without HEAD changes; packed/reftable/unborn tips use file stamps. */ async function readHeadComponent(gitDir: string): Promise { const [head, commonDirEntry] = await Promise.all([ readTrimmedFile(path.join(gitDir, 'HEAD')), @@ -201,11 +151,7 @@ async function readFileStampComponent(filePath: string): Promise async function readWorkingTreeComponent(filePath: string): Promise { try { const stats = await stat(filePath) - // Why ino is optional: an atomic-rename save can keep both the size and the mtime bucket, - // so a changed inode is extra proof — but Windows reports 0 for it on the network - // redirector behind `\\wsl.localhost`, and requiring an unstable 0 to match would make - // the stamp never hit on exactly the host this cache exists for. Fold it in only when - // the filesystem gives a real one. + // Inodes detect atomic replacement, but zero/unstable network-redirector values cannot improve the stamp. const inode = isUsableInode(stats.ino) ? String(stats.ino) : MISSING return { text: `${requireMtimeMs(stats.mtimeMs)}:${stats.size}:${inode}`, diff --git a/src/main/git/source-control/worktree-filesystem-path.ts b/src/main/git/source-control/worktree-filesystem-path.ts index f028ae2602d..42a4babe8c8 100644 --- a/src/main/git/source-control/worktree-filesystem-path.ts +++ b/src/main/git/source-control/worktree-filesystem-path.ts @@ -3,35 +3,18 @@ import type { GitRuntimeOptions } from '../git-runtime-options' const GUEST_ROOTED_PATH = /^\/(?!\/)/ -/** - * Whether a worktree path is a guest spelling the resolver can safely re-spell. - * - * Single leading slash only: `//wsl.localhost/...` and `//wsl$/...` are UNC spellings that also - * start with `/`, and translating one prepends the distro root a second time. Trim-stable only: - * the resolver returns `rawPath.trim()`, which would silently point a worktree whose name has - * edge whitespace (legal on ext4) at a different directory. - */ +/** Reject UNC and edge-whitespace paths: translating or trimming them can identify another directory. */ function isRespellableGuestPath(worktreePath: string): boolean { return GUEST_ROOTED_PATH.test(worktreePath) && worktreePath === worktreePath.trim() } -/** - * The worktree path as this process must spell it to open files inside it. - * - * Why: git executing in a WSL distro takes and reports guest paths, but Node reads them back - * through Win32, where `/home/me/repo` is drive-relative and `/mnt/c/repo` means `C:\mnt\c\repo`. - * Only reads that bypass git — direct lstat/open on working-tree files — need this; anything - * handed to the git runner keeps the execution host's spelling. - */ +/** Convert WSL guest paths only for Node filesystem reads; Git keeps its execution-host spelling. */ export function resolveWorktreeFilesystemPath( worktreePath: string, options: GitRuntimeOptions = {} ): string { - // The resolver is already an identity off win32; short-circuiting makes that structural. if (process.platform !== 'win32' || !isRespellableGuestPath(worktreePath)) { return worktreePath } - // Unreachable: the resolver returns null only for an empty pointer, which the guard rejects. - // Kept so the never-null contract stays the resolver's to state, not ours to assert. return resolveGitMetadataPath('', worktreePath, options) ?? worktreePath } diff --git a/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts b/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts index e3a3cea4983..346df84f1cf 100644 --- a/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts +++ b/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts @@ -17,7 +17,6 @@ vi.mock('../../observability/instrumentation', () => ({ })) vi.mock('../../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) vi.mock('./git-read-cache-invalidation', () => ({ invalidateGitReadCaches: vi.fn() })) -// Stand in for the on-disk safety filter: every discard target here exists and is symlink-free. vi.mock('../../../shared/git-discard-path-safety', () => ({ removeSafeUntrackedDiscardTarget: vi.fn(), removeSafeUntrackedDiscardTargets: async ( @@ -42,7 +41,6 @@ afterEach(() => _resetGitAdmissionForTests()) const DISTRO = 'Ubuntu-24.04' const WSL_WORKTREE = `\\\\wsl$\\${DISTRO}\\home\\emilio\\projects\\orca` const TRACKED_PATHS = ['docs/architecture.md', 'src/main/git/runner.ts'] -// Stock Ubuntu writes this to *stdout* from the interactive login shell's rc. const BANNER = 'To run a command as administrator (user "root"), use "sudo ".\n\n' const stdinEndMock = vi.fn() @@ -66,7 +64,6 @@ function guestScript(args: unknown): string { return (args as string[] | undefined)?.join(' ') ?? '' } -/** Wrap the payload in the caller's own fence when it asked for one; otherwise hand it over raw. */ function loginShellStdout(script: string, payload: string): string { const nonce = /__ORCA_WSL_CAPTURE_BEGIN_ ([^_]+)__/.exec(script)?.[1] return nonce @@ -91,7 +88,7 @@ describe('WSL tracked-path listing behind a login-shell banner', () => { execFileMock.mockImplementation((_command, args, _options, callback) => { const script = guestScript(args) if (script.includes('_orca_git_path=')) { - // Distro exports GIT_* / XDG_CONFIG_HOME, so the direct-git read probe is rejected for good. + // Simulate guest-specific Git config permanently disabling direct reads. queueMicrotask(() => callback?.(Object.assign(new Error('probe rejected'), { code: 78 }))) return createMockChild() } diff --git a/src/main/git/status-branch-compare.test.ts b/src/main/git/status-branch-compare.test.ts index e690735b7ba..589ab4e034a 100644 --- a/src/main/git/status-branch-compare.test.ts +++ b/src/main/git/status-branch-compare.test.ts @@ -60,9 +60,7 @@ describe('getBranchCompare', () => { readFileMock.mockReset() }) - // Why dispatch on args instead of mockResolvedValueOnce chains: getBranchCompare now - // issues its head-of-chain reads concurrently, so a positional mock would encode call - // order rather than behaviour and break on any safe reordering. + // Route by argv so independent concurrent reads can reorder safely. type BranchCompareGitResponses = { branch?: string | Error probe?: Record @@ -219,8 +217,6 @@ describe('getBranchCompare', () => { ]) }) - // Why: a rebased branch is ahead AND behind; the symmetric --left-right range is the - // only form that reports both, and its columns are left=behind, right=ahead. it('counts both divergence directions from one symmetric rev-list', async () => { mockBranchCompareGit({ branch: 'main\n', @@ -264,7 +260,6 @@ describe('getBranchCompare', () => { it('returns unborn-head when HEAD cannot be resolved', async () => { mockBranchCompareGit({ branch: 'main\n', - // Why the probe fails here: a proven base ref would resolve, giving 'ready'. probe: { 'refs/remotes/origin/main^{commit}': new Error('missing base') }, headOid: new Error('unborn'), baseOid: new Error('missing base') @@ -340,8 +335,6 @@ describe('getBranchCompare', () => { ]) }) - // Why: the base oid now comes from the probe, so the probe must never report a ref it - // did not actually resolve -- an empty rev-parse --quiet stdout means "not found". it('treats an empty probe result as an unresolved base ref', async () => { mockBranchCompareGit({ branch: 'main\n', @@ -358,8 +351,7 @@ describe('getBranchCompare', () => { expect(result.summary.status).toBe('invalid-base') }) - // Why: the probe tries refs/remotes first, then refs/heads. Reusing "the last probed - // oid" rather than the oid for the ref that won would return the wrong commit. + // Keep distinct probe results to catch accidental reuse of the losing ref's OID. it('reuses only the oid of the ref the probe actually resolved', async () => { mockBranchCompareGit({ branch: 'feature\n', @@ -382,8 +374,6 @@ describe('getBranchCompare', () => { }) }) - // Why: an already-qualified base ref skips the probe entirely, so its oid must still - // come from rev-parse rather than from a stale or absent probe entry. it('resolves an already-qualified base ref without a probe', async () => { mockBranchCompareGit({ branch: 'main\n', diff --git a/src/main/git/status-branch-line-total-exec-contract.test.ts b/src/main/git/status-branch-line-total-exec-contract.test.ts index e67a9a6518b..ff5b347adb7 100644 --- a/src/main/git/status-branch-line-total-exec-contract.test.ts +++ b/src/main/git/status-branch-line-total-exec-contract.test.ts @@ -11,9 +11,7 @@ import { import type * as BranchLineTotal from '../../shared/git-branch-line-total' import type * as GitRunner from './runner' -// Why: real git, spied argv. Every exec is recorded so the performance contract -// ("no ranged diff unless asked", "one exec for concurrent callers") can be -// asserted on the actual command list rather than on a stubbed return value. +// Spy on real Git argv to validate opt-in and coalescing without stubbing results. const { gitExecCalls, execHooks, coalescerJoins } = vi.hoisted(() => ({ gitExecCalls: [] as string[][], execHooks: { beforeExec: undefined as undefined | ((args: string[]) => Promise | void) }, @@ -27,8 +25,7 @@ vi.mock('../../shared/git-branch-line-total', async (importOriginal) => { computeGitBranchLineTotal: ( input: Parameters[0] ) => { - // The lease is taken synchronously inside, so counting after the call - // means a join is observable the instant it has happened. + // Lease acquisition is synchronous, so a join is observable immediately after this call. const total = actual.computeGitBranchLineTotal(input) coalescerJoins.count += 1 coalescerJoins.onJoin?.() @@ -67,7 +64,6 @@ import { } from './status' const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) -// No fixture path here looks like test or generated code, so it is all source. const NO_LINES = { added: 0, removed: 0 } const tempRoots: string[] = [] @@ -169,7 +165,6 @@ describe('branch line total completeness', () => { expect(result.entries.length).toBeGreaterThan(0) expect(result.branchLineTotal).toBeUndefined() expect(result.branchLineTotalUnavailable).toBe(BOGUS_MERGE_BASE) - // The diff really was attempted, so this is a failure path and not a gate. expect(rangedDiffCalls()).toHaveLength(1) }) @@ -196,8 +191,6 @@ describe('branch line total completeness', () => { await expect( getStatus(repo, { branchLineTotalMergeBase: mergeBase, signal: controller.signal }) ).rejects.toMatchObject({ name: 'AbortError' }) - // The pass had already streamed status and reached the ranged diff, so this - // is a cancellation in flight rather than a pre-flight rejection. expect(rangedDiffCalls()).toHaveLength(1) }) }) @@ -275,11 +268,7 @@ describe('branch line total exec budget', () => { it('shares one ranged diff across status passes the status lease cannot merge', async () => { const { repo, mergeBase } = await createFixtureRepo() - // Why: `limit` is part of the status read key, so these two passes each run - // their own `git status`; only the branch-total coalescer can merge the diff. - // Hold the first diff exactly until the second pass has taken the lease: a - // fixed sleep would either release too early or, on a slow machine, outrun - // GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS and publish without an exact total. + // Different limits require separate status leases; hold the diff until both join its shared lease. execHooks.beforeExec = async (args) => { if (args.includes('--numstat') && args.at(-1) === '--') { await waitForCoalescerJoins(2) @@ -299,8 +288,6 @@ describe('branch line total exec budget', () => { generated: NO_LINES }) expect(second.branchLineTotal).toEqual(first.branchLineTotal) - // Proves the saving came from the branch-total coalescer: both passes really - // ran their own `git status`, so the status lease merged nothing. expect(gitExecCalls.filter((args) => args.includes('status'))).toHaveLength(2) expect(rangedDiffCalls()).toHaveLength(1) }) diff --git a/src/main/git/status-branch-line-total-real-git.test.ts b/src/main/git/status-branch-line-total-real-git.test.ts index 545464d8410..32ee97540d1 100644 --- a/src/main/git/status-branch-line-total-real-git.test.ts +++ b/src/main/git/status-branch-line-total-real-git.test.ts @@ -33,17 +33,14 @@ async function write(repo: string, relativePath: string, contents: string | Buff await writeFile(target, contents) } -/** The forked-from commit the chip measures against, as branch compare would resolve it. */ function commitAll(repo: string, message: string): string { git(repo, ['add', '-A']) git(repo, ['commit', '-q', '-m', message]) return git(repo, ['rev-parse', 'HEAD']) } -// No fixture path here looks like test or generated code, so it is all source. const NO_LINES = { added: 0, removed: 0 } -/** What a plain `git diff ` reports, i.e. the number the chip must not disagree with. */ function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } { let added = 0 let removed = 0 @@ -74,8 +71,7 @@ describe('branch line total against a real repository', () => { const repo = await createFixtureRepo() await write(repo, 'f.txt', 'line1\n') const mergeBase = commitAll(repo, 'base') - // Stage "+foo", then edit that same line to "bar" without staging it: the - // per-area rows read +1/-0 staged and +1/-1 unstaged, summing to +2/-1. + // Per-area counts sum to +2/-1 here, while the merge-base diff contains just +1/-0. await write(repo, 'f.txt', 'line1\nfoo\n') git(repo, ['add', 'f.txt']) await write(repo, 'f.txt', 'line1\nbar\n') diff --git a/src/main/git/status-conflict-operations.test.ts b/src/main/git/status-conflict-operations.test.ts index dc2c75afa12..1060779283f 100644 --- a/src/main/git/status-conflict-operations.test.ts +++ b/src/main/git/status-conflict-operations.test.ts @@ -92,7 +92,7 @@ describe('detectConflictOperation', () => { it('ignores a stale REBASE_HEAD when no rebase directory exists', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') accessMock.mockImplementation(async (target: string) => { - // Only REBASE_HEAD is present: the marker git leaves behind after a rebase finishes. + // A stale REBASE_HEAD alone must not report a live rebase. if (target.endsWith('REBASE_HEAD')) { return undefined } @@ -121,8 +121,6 @@ describe('detectConflictOperation', () => { await expect(detectConflictOperation('/repo')).resolves.toBe(expected) }) - // The four markers are independent, so serializing them costs four round trips - // on a UNC git dir for something one wave answers. it('probes every marker concurrently', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') let concurrent = 0 @@ -148,8 +146,7 @@ describe('detectConflictOperation', () => { await expect(detectConflictOperation('/repo')).resolves.toBe('unknown') }) - // Both cases below assert the probed prefix rather than the joined string so they exercise - // Win32 pointer resolution on every host, where `path.join` still uses the host separator. + // Compare normalized prefixes so Win32 pointer resolution is tested on every host. it('probes the drive spelling of a drvfs gitdir pointer on Windows', async () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') readFileMock.mockResolvedValue('gitdir: /mnt/c/Users/me/repo/.git/worktrees/feature\n') @@ -168,8 +165,6 @@ describe('detectConflictOperation', () => { } }) - // The worktree path itself can be guest-spelled (git in the distro reports it that way), so the - // gitfile read has to be translated too or it ENOENTs before any pointer resolution happens. it('reads the gitfile at the host spelling of a guest-spelled worktree', async () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') readFileMock.mockResolvedValue('gitdir: /mnt/c/Users/me/repo/.git/worktrees/feature\n') @@ -184,8 +179,6 @@ describe('detectConflictOperation', () => { } }) - // A plain clone inside the distro has a `.git` directory, so the gitfile read fails and the - // markers are probed under the worktree itself — that fallback needs the host spelling too. it('probes a directory .git under the distro share when the caller names one', async () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') readFileMock.mockRejectedValue(Object.assign(new Error('EISDIR'), { code: 'EISDIR' })) @@ -207,8 +200,7 @@ describe('detectConflictOperation', () => { } }) - // `git worktree repair --relative-paths` (2.48+) writes `gitdir: ../../..`, which the guest - // spelling of the worktree would resolve drive-relative on Win32. + // Relative Git 2.48+ pointers need a host-spelled base to avoid Win32 drive-relative resolution. it('resolves a relative gitdir pointer against the host spelling of the worktree', async () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') readFileMock.mockResolvedValue('gitdir: ../.git/worktrees/feature\n') diff --git a/src/main/git/status-diff-settled-cache.test.ts b/src/main/git/status-diff-settled-cache.test.ts index cd3c35832ab..afb2771a063 100644 --- a/src/main/git/status-diff-settled-cache.test.ts +++ b/src/main/git/status-diff-settled-cache.test.ts @@ -21,12 +21,7 @@ const { existsSyncMock: vi.fn() })) -/** - * A tiny in-memory filesystem, because every assertion here is about what the - * cache does when one specific path's mtime or bytes move. Sequenced - * `mockResolvedValueOnce` stacks cannot express that: the stamp and the diff - * read touch overlapping paths in an order the test should not have to know. - */ +// Key mocks by path: stamp and blob reads overlap without a fixed ordering. type FakeFile = { content: Buffer; mtimeMs: number; ino: number } const { files } = vi.hoisted(() => ({ files: new Map() })) @@ -99,8 +94,7 @@ const REF_PATH = `${REPO}/.git/refs/heads/main` const INDEX_PATH = `${REPO}/.git/index` const GITMODULES_PATH = `${REPO}/.gitmodules` -// Old enough that a further write is guaranteed to move the mtime, which is what -// lets the cache store at all. +// Old enough to satisfy the racy-write margin. const SETTLED_MTIME_MS = Date.now() - 60_000 let nextInode = 100 @@ -114,7 +108,6 @@ function blobReadCount(): number { function seedRepo(): void { files.clear() - // No `.git` file entry: `.git` is a directory, so reading it as a pointer misses. writeFile(HEAD_PATH, 'ref: refs/heads/main\n') writeFile(REF_PATH, `${'a'.repeat(40)}\n`) writeFile(INDEX_PATH, 'index-bytes') @@ -133,7 +126,6 @@ describe('settled diff cache', () => { invalidateGitReadCaches() settledDiffCache.resetStatsForTests() seedRepo() - // `.gitmodules` is absent, so submodule routing resolves to "no submodules". gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) gitExecFileAsyncBufferMock.mockResolvedValue({ stdout: Buffer.from('index-content\n') }) }) @@ -150,8 +142,6 @@ describe('settled diff cache', () => { expect(settledDiffCache.stats().hits).toBe(1) }) - // A WSL-routed read whose worktree path never went through UNC translation: git resolves - // `/home/...` inside the distro, but every Node read here has to be spelled for Win32. describe('on a Windows host reading a raw Linux WSL worktree path', () => { const WSL_WORKTREE = '/home/me/repo/feature' const WSL_OPTIONS = { wslDistro: 'Ubuntu' } @@ -162,7 +152,6 @@ describe('settled diff cache', () => { platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) files.clear() - // `.git` is a directory here, so the gitdir is the worktree path plus a segment. writeFile(path.join(HOST_WORKTREE, '.git/HEAD'), 'ref: refs/heads/main\n') writeFile(path.join(HOST_WORKTREE, '.git/refs/heads/main'), `${'a'.repeat(40)}\n`) writeFile(path.join(HOST_WORKTREE, '.git/index'), 'index-bytes') @@ -195,8 +184,7 @@ describe('settled diff cache', () => { expect(statMock).toHaveBeenCalledWith(path.join(HOST_WORKTREE, '.git/index')) }) - // Each stamp component has to be stat'd under the host spelling too: one that permanently - // misses is a constant, so the stamp stops moving and the cache serves a stale diff. + // A permanently absent component would stop the stamp moving and serve stale diffs. it('invalidates when the working tree file is edited under the host spelling', async () => { await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS) const spawnsAfterFirst = blobReadCount() @@ -220,8 +208,7 @@ describe('settled diff cache', () => { }) }) - // An empty worktree path has no host spelling, and `path.join('', x)` is a *relative* path: - // every fs read would land in the process cwd, which in dev is Orca's own checkout. + // Empty worktrees must not turn filesystem reads into paths relative to Orca's own checkout. it('reads nothing relative to the cwd when the worktree path has no host spelling', async () => { await getDiff('', FILE, false) @@ -230,8 +217,6 @@ describe('settled diff cache', () => { expect(settledDiffCache.stats().entries).toBe(0) }) - // The four invalidation axes, one per diff input. Each proves the stale result is - // never served, which matters more than any of the hits above. it.each([ [ 'the working tree file is edited', @@ -263,8 +248,6 @@ describe('settled diff cache', () => { expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) }) - // The dangerous ordering: the read observed pre-mutation state, so storing its - // result after the mutation would pin a diff that was already wrong. it('refuses to store a result for a read that a mutation overtook', async () => { let releaseBlob = (): void => {} const blocked = new Promise<{ stdout: Buffer }>((resolve) => { @@ -289,8 +272,7 @@ describe('settled diff cache', () => { expect(second).toMatchObject({ originalContent: 'post-mutation\n' }) }) - // The stamp is itself several awaited stats, so a mutation can begin and end entirely - // inside it — leaving a stamp torn across the mutation that no later stamp can match. + // Mutations may begin and end during the awaited stamp reads. it('refuses to store a result for a mutation that landed inside the stamp read', async () => { const baseStat = statMock.getMockImplementation() if (!baseStat) { @@ -315,8 +297,6 @@ describe('settled diff cache', () => { expect(settledDiffCache.stats().entries).toBe(0) }) - // A write inside the mtime granularity window could be overwritten again without - // moving the timestamp, so that read is not allowed to become a cache entry. it('refuses to store a diff of a file written moments ago', async () => { writeFile(WORKING_TREE_PATH, 'just-saved', Date.now()) @@ -329,7 +309,6 @@ describe('settled diff cache', () => { expect(settledDiffCache.stats().entries).toBe(0) }) - // A folder workspace, or any path that is not a git checkout, cannot be stamped. it('never caches when the repo layout cannot be stamped', async () => { files.delete(HEAD_PATH) @@ -342,8 +321,6 @@ describe('settled diff cache', () => { expect(settledDiffCache.stats().entries).toBe(0) }) - // A WSL relay that never reached git returns the same empty left side as a new - // file does, so persisting it would pin a wrong diff until something else moved. it('refuses to store a diff whose blob read failed rather than proved absence', async () => { gitExecFileAsyncBufferMock.mockRejectedValue( Object.assign(new Error('wsl.exe failed'), { code: 1 }) @@ -382,7 +359,6 @@ describe('settled diff cache', () => { expect(blobReadCount()).toBeGreaterThan(spawnsAfterUnstaged) }) - // A staged diff compares HEAD to the index, so a working-tree edit must not evict it. it('keeps a staged diff across a working-tree edit', async () => { await getDiff(REPO, FILE, true) const spawnsAfterFirst = blobReadCount() @@ -409,7 +385,6 @@ describe('settled diff cache', () => { releaseBlob() await Promise.all([first, second]) - // Why exactly equal: the second read must join the first, not start its own spawns. expect(blobReadCount()).toBe(spawnsBeforePoll) }) }) diff --git a/src/main/git/status-diff.test.ts b/src/main/git/status-diff.test.ts index 9eb4aee0b21..e9597d0f0f8 100644 --- a/src/main/git/status-diff.test.ts +++ b/src/main/git/status-diff.test.ts @@ -168,8 +168,7 @@ describe('getDiff', () => { it('does not read oversized working-tree files into memory', async () => { const workingTreePath = path.join('/repo', 'dist/large.log') gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') }) - // Why by path: the diff also stats git-dir entries to stamp its inputs, so a - // one-shot queue would hand the oversized size to whichever stat ran first. + // Route stats by path so stamp reads cannot consume the oversized-file response. statMock.mockImplementation(async (target: string) => target === workingTreePath ? { isFile: () => true, size: 10 * 1024 * 1024 + 1 } @@ -320,8 +319,7 @@ describe('getDiff', () => { const reads = Array.from({ length: 8 }, () => getDiff('/repo', 'src/file.ts', true)) - // Why both up front: the two sides are independent spawns issued concurrently, - // so 8 identical reads still collapse to exactly 2 — one per side, not per read. + // Eight coalesced reads should spawn only once for each independent side. await waitForMockCalls(gitExecFileAsyncBufferMock, 2) expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) diff --git a/src/main/git/status-discard-and-bulk-staging.test.ts b/src/main/git/status-discard-and-bulk-staging.test.ts index 8d92ff550a2..83bb6868c48 100644 --- a/src/main/git/status-discard-and-bulk-staging.test.ts +++ b/src/main/git/status-discard-and-bulk-staging.test.ts @@ -185,12 +185,11 @@ describe('bulk git helpers', () => { ], { cwd: '/repo', - // Why: this read buffers, so it fences the WSL login shell's rc banner off its first NUL record. + // Keep the WSL login banner out of the first buffered NUL record. captureWslLoginShellOutput: true } ) - // Why: a pathspec is tracked if git reports either the exact path or a - // tracked descendant, which keeps directory pathspecs on the restore path. + // A tracked descendant keeps directory pathspecs on the restore path. expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], diff --git a/src/main/git/status-line-stats-host-paths.test.ts b/src/main/git/status-line-stats-host-paths.test.ts index d32df023d4b..c0c541bd998 100644 --- a/src/main/git/status-line-stats-host-paths.test.ts +++ b/src/main/git/status-line-stats-host-paths.test.ts @@ -1,9 +1,4 @@ -/** - * Untracked line counts come from direct lstat/open calls, not from git, so on a Windows host - * driving a WSL distro they must be issued against the Win32 spelling of the worktree. These - * assert the exact path that reaches the filesystem: translated for a guest-rooted worktree, - * verbatim for one that is already spelled in this process's own namespace. - */ +// Untracked line counts use Node I/O, so WSL guest paths need Win32 spelling while host paths stay literal. import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as FsPromises from 'node:fs/promises' @@ -140,9 +135,6 @@ describe('untracked line stats on a WSL worktree read by a Windows host', () => expect(entries[0].added).toBe(1) }) - // One case per spelling in a single test: the guest row pins that re-spelling still happens, so - // the whole test dies if the production hunks are reverted, and each verbatim row pins one half - // of the guard that keeps a path this process can already open from being re-spelt onto nothing. it('re-spells the guest form only, leaving every host-openable spelling byte-identical', async () => { const cases: { worktree: string; expected: string }[] = [ { worktree: GUEST_WORKTREE, expected: UNC_WORKTREE }, diff --git a/src/main/git/status-porcelain-parser.test.ts b/src/main/git/status-porcelain-parser.test.ts index e3cd652e663..ee61e75233b 100644 --- a/src/main/git/status-porcelain-parser.test.ts +++ b/src/main/git/status-porcelain-parser.test.ts @@ -69,7 +69,6 @@ describe('StatusPorcelainParser', () => { it('carries a partial trailing line across chunk boundaries', () => { const parser = new StatusPorcelainParser() - // Split a single record across two chunks. parser.update('? partial', 0) parser.update('-name.txt\n', 0) parser.finish() @@ -90,7 +89,6 @@ describe('StatusPorcelainParser', () => { const lines = `${Array.from({ length: 5 }, (_, i) => `? f${i}.txt`).join('\n')}\n` const stopped = parser.update(lines, 3) expect(stopped).toBe(true) - // The fourth entry (index 3) is what pushed count past the limit of 3. expect(parser.entries.length).toBe(4) expect(parser.statusLength).toBe(4) }) diff --git a/src/main/git/status-read-coalescing.test.ts b/src/main/git/status-read-coalescing.test.ts index e7d755e7cfa..5dc78ac28b2 100644 --- a/src/main/git/status-read-coalescing.test.ts +++ b/src/main/git/status-read-coalescing.test.ts @@ -62,10 +62,7 @@ describe('getStatus', () => { lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() - // Why: after the status call, getStatus may issue `git diff --numstat` - // calls to attach per-entry line counts. Tests that don't care about counts - // set only a `mockResolvedValueOnce` for the status output; this default - // keeps those follow-up numstat calls from returning undefined. + // Supply empty numstat results after tests' one-shot status responses. gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) }) @@ -268,8 +265,7 @@ describe('getStatus', () => { const first = getStatus('/repo') await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) - // A mutation must invalidate the in-flight status read so the next getStatus - // starts fresh instead of joining a pre-mutation promise and going stale. + // Mutations retire the lease so post-mutation reads cannot join a stale status pass. await stageFile('/repo', 'src/file.ts') const second = getStatus('/repo') diff --git a/src/main/git/status-shared-symlinks.test.ts b/src/main/git/status-shared-symlinks.test.ts index dfe67919480..6f08dd125d9 100644 --- a/src/main/git/status-shared-symlinks.test.ts +++ b/src/main/git/status-shared-symlinks.test.ts @@ -5,10 +5,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { getStatus } from './status' -// Why: `node_modules/` is a directory-only ignore rule. It matches the primary -// checkout's real directory but never the worktree's symlink, so Git reports the -// link as untracked forever — a phantom row in the diff and a permanently dirty -// worktree. Status has to drop it; nothing else can. +// Directory-only ignore rules cannot hide shared symlinks; status must filter configured links. const git = (args: string[], cwd: string): string => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) @@ -40,9 +37,6 @@ describe('getStatus shared symlink exclusion', () => { rmSync(root, { recursive: true, force: true }) }) - // Why: guards the premise of the whole fix. If Git ever reported the symlink - // as `node_modules/` (as it does for a real untracked directory) the path - // comparison below would silently miss and the exclusion would do nothing. it('Git reports the shared symlink as untracked, without a trailing slash', async () => { const status = await getStatus(worktree) @@ -66,7 +60,6 @@ describe('getStatus shared symlink exclusion', () => { } ) - // The negative control that matters: real work must never be hidden. it('still reports a genuine untracked file alongside a shared symlink', async () => { writeFileSync(join(worktree, 'scratch.txt'), 'unsaved work\n') @@ -87,9 +80,6 @@ describe('getStatus shared symlink exclusion', () => { ]) }) - // Why: the configured name alone must not hide anything — only a real symlink. - // `vendor` is not gitignored, so Git genuinely reports it and the filter is - // the only thing that could wrongly drop it. it('keeps a regular directory the user created at a configured shared name', async () => { mkdirSync(join(worktree, 'vendor')) writeFileSync(join(worktree, 'vendor', 'real.txt'), 'user work\n') @@ -103,9 +93,6 @@ describe('getStatus shared symlink exclusion', () => { ]) }) - // Why: the exact discriminator for "configured AND really a symlink". The path - // matches a declared name exactly, so only the symlink check keeps the user's - // file visible. it('keeps a regular file the user created at a configured shared name', async () => { writeFileSync(join(worktree, 'notes'), 'user work\n') @@ -116,9 +103,6 @@ describe('getStatus shared symlink exclusion', () => { expect(status.entries).toEqual([expect.objectContaining({ path: 'notes', area: 'untracked' })]) }) - // Why: only *untracked* entries are Orca's artifacts. A symlink Git tracks is - // versioned content, so an edit to it is the user's work even when the path is - // declared shared — dropping it would hide a committable change. it('keeps a modified tracked symlink at a declared shared path', async () => { unlinkSync(join(worktree, 'tracked-link')) symlinkSync('OTHER.md', join(worktree, 'tracked-link')) @@ -132,9 +116,6 @@ describe('getStatus shared symlink exclusion', () => { ]) }) - // Why: these are the names a byte-for-byte path comparison is most likely to - // get wrong — a space breaks naive whitespace splitting, and non-ASCII is what - // Git C-quotes unless the reader opts out. it('drops shared symlinks whose names have a space or non-ASCII characters', async () => { const names = ['my shared dir', 'ライブラリ'] for (const name of names) { diff --git a/src/main/git/status-submodule.test.ts b/src/main/git/status-submodule.test.ts index 266ac4a399d..34576f211d8 100644 --- a/src/main/git/status-submodule.test.ts +++ b/src/main/git/status-submodule.test.ts @@ -76,12 +76,9 @@ describe('submodule diff routing', () => { statMock.mockReset() existsSyncMock.mockReset() statMock.mockResolvedValue({ isFile: () => true, size: 12 }) - // Route git invocations by argv/cwd so the routing logic can resolve the - // gitlink oids without touching a real repo. gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => { if (args[0] === 'config' && args.includes('.gitmodules')) { - // Only the parent worktree declares the submodule; the recursion into the - // submodule worktree must see no nested submodules. + // Declare the submodule only in its parent so recursion terminates. return Promise.resolve({ stdout: options?.cwd === PARENT ? 'submodule.flutter_mine.path flutter_mine\n' : '' }) @@ -179,8 +176,6 @@ describe('submodule diff routing', () => { }) it('reads inner files from the working tree when the commit is unchanged', async () => { - // Override the gitlink oids so recorded == checked-out (no pointer move), - // routing the inner diff back to the index/working-tree blob read. gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => { if (args[0] === 'config' && args.includes('.gitmodules')) { return Promise.resolve({ @@ -212,8 +207,6 @@ describe('submodule diff routing', () => { }) it('rejects inner submodule diffs whose .gitmodules path escapes the worktree', async () => { - // A crafted .gitmodules path must not let the inner diff read escape the - // selected worktree; loadDiff routes through resolveSubmoduleWorktreePath. gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => { if (args[0] === 'config' && args.includes('.gitmodules')) { return Promise.resolve({ @@ -318,7 +311,6 @@ describe('getSubmoduleStatus', () => { existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockImplementation((args: string[]) => { - // Clean worktree: the inner status stream returns nothing. if (args.includes('--raw')) { return Promise.resolve({ stdout: ':100644 100644 a b M\0lib/main.dart\0' }) } @@ -345,7 +337,6 @@ describe('getSubmoduleStatus', () => { existsSyncMock.mockReturnValue(false) gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockImplementation((args: string[]) => { - // Clean submodule worktree: the staged parent gitlink still has files to show. if (args.includes('--raw')) { return Promise.resolve({ stdout: ':100644 100644 a b M\0lib/main.dart\0' }) } @@ -409,7 +400,7 @@ describe('getSubmoduleStatus', () => { const name = args.includes('status') ? 'status' : args[0] events.push(`start:${name}`) if (name === 'status') { - // Hold the inner status open so a serialized caller could not have started the oid reads. + // Hold status open so the assertion detects serialization of the independent OID reads. await new Promise((resolve) => setTimeout(resolve, 5)) } events.push(`end:${name}`) @@ -424,7 +415,6 @@ describe('getSubmoduleStatus', () => { await getSubmoduleStatus('/repo', 'flutter_mine') - // Serialized reads only start ls-files/rev-parse once the inner status has resolved. expect(events.indexOf('start:ls-files')).toBeLessThan(events.indexOf('end:status')) expect(events.indexOf('start:rev-parse')).toBeLessThan(events.indexOf('end:status')) }) diff --git a/src/main/git/status-test-harness.ts b/src/main/git/status-test-harness.ts index 99925f90363..f3b829979ab 100644 --- a/src/main/git/status-test-harness.ts +++ b/src/main/git/status-test-harness.ts @@ -23,16 +23,11 @@ export function createGitRunnerModuleMock(mocks: GitRunnerMocks): Record boolean | void } ) => { - // Forward args so arg-routing mock implementations (e.g. `args.includes`) - // still match the status read. mocks.gitStreamOptionsMock(options) const { stdout } = await mocks.gitExecFileAsyncMock(args) const stoppedEarly = options.onStdout(stdout ?? '') === true diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index 49f6913e0af..f0a4fd60729 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -1,8 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// Repro command: -// pnpm exec vitest run --config config/vitest.config.ts src/main/git/status-upstream-probe-churn.test.ts -t "missing-upstream polling churn" - const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -11,8 +8,6 @@ const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, - // Why: getStatus streams status output; forward args to the same mock so this - // suite's arg-routing implementation still matches the status read. gitStreamStdout: async ( args: string[], options: { onStdout: (chunk: string) => boolean | void } @@ -186,9 +181,7 @@ describe('getStatus missing-upstream polling churn', () => { expect(sameNameOriginProbeCalls).toHaveLength(1) }) - // The pushed-but-untracked branch shape (issue #7576): the effective - // upstream resolves to the same-name origin ref, and before the resolved-name - // cache each 3s poll re-ran the whole resolution chain (~5 spawns/tick). + // Model a pushed branch with no configured upstream, resolved through same-name origin. function mockResolvedSameNameOrigin(revList: { failOnCall?: number } = {}): void { let revListCalls = 0 gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -231,7 +224,6 @@ describe('getStatus missing-upstream polling churn', () => { await getStatus('/repo') await getStatus('/repo') - // Resolution chain ran once; the two later polls paid one rev-list each. expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(1) expect(countCalls((args) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))).toBe(1) expect(countCalls(isConfigListSnapshotCommand)).toBe(1) @@ -257,13 +249,11 @@ describe('getStatus missing-upstream polling churn', () => { }) it('falls back to a full re-resolve when the cached upstream rev-list fails', async () => { - // Second rev-list (the first revalidation attempt) fails, e.g. deleted ref. mockResolvedSameNameOrigin({ failOnCall: 2 }) await getStatus('/repo') const status = await getStatus('/repo') - // Full chain ran twice: initial resolve + the fallback after the failure. expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(2) // rev-list: initial + failed revalidate + fallback's own rev-list. expect(countCalls((args) => args[0] === 'rev-list')).toBe(3) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index f0c841ea4c5..30ffdcb5a7f 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -66,14 +66,9 @@ describe('getStatus', () => { readFileMock.mockReset() accessMock.mockReset() accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) - // Why: untracked line counting stats a file before reading it; any - // under-limit size routes the read to readFileMock. statMock.mockReset() statMock.mockResolvedValue({ isFile: () => true, size: 12 }) - // Why: after the status call, getStatus may issue `git diff --numstat` - // calls to attach per-entry line counts. Tests that don't care about counts - // set only a `mockResolvedValueOnce` for the status output; this default - // keeps those follow-up numstat calls from returning undefined. + // Supply empty numstat results after tests' one-shot status responses. gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) }) @@ -147,8 +142,7 @@ describe('getStatus', () => { expect(conflictFileProbes()).toEqual([]) }) - // Why both cases normalize separators: git reports the worktree in the WSL guest namespace, and - // the assertion is about which path is probed, not which separator this host's `path` emits. + // Normalize separators to test probe targets independently of the current host. it('resolves a WSL conflict row without crossing the 9p share', async () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n') @@ -161,12 +155,10 @@ describe('getStatus', () => { const result = await getStatus('/home/me/repo/feature', { wslDistro: 'Ubuntu' }) const probed = accessMock.mock.calls.map(([target]) => String(target).replaceAll('\\', '/')) - // No `\\wsl.localhost` round trip per conflict row: the porcelain `mW` field already answered. expect(probed).not.toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts') expect(conflictFileProbes()).toEqual([]) expect(result.entries[0]?.status).toBe('modified') expect(result.entries[0]?.conflictKind).toBe('deleted_by_us') - // The conflict-marker probes still travel through the distro spelling. expect( probed.filter((target) => target.startsWith('//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/feature/') @@ -207,10 +199,7 @@ describe('getStatus', () => { const result = await getStatus('/repo') - // Why: without -c core.quotePath=false git would emit - // "docs/\346\227\245\346\234\254\350\252\236/sample.md" (octal-escaped, - // wrapped in double quotes) and the parser would store that literal - // string as entry.path, breaking sidebar display + downstream blob reads. + // Without quotePath=false, octal-escaped names would break display and subsequent blob reads. expect(gitExecFileAsyncMock).toHaveBeenCalledWith([ '-c', 'core.quotePath=false', @@ -650,7 +639,6 @@ describe('getStatus', () => { stdout: '1 .M N... 100644 100644 100644 cccc cccc assets/logo.png\n' }) } - // git reports binary files as '-' in both numstat columns. if (args.includes('--numstat')) { return Promise.resolve({ stdout: '-\t-\tassets/logo.png\n' }) } @@ -684,10 +672,7 @@ describe('getStatus', () => { expect(result.didHitLimit).toBe(true) expect(result.statusLength).toBeGreaterThan(10) - // First `limit` entries are kept; the rest are dropped. expect(result.entries.length).toBe(10) - // attachLineStats (numstat) must be skipped when the limit was hit — only - // the single streamed status read should have happened. expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 3954bd56a4b..20e41804f69 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,6 +1,4 @@ -// Entry point for the main-process source-control surface. The implementation lives in -// `./source-control/*`; this file stays the single import path because ~40 modules and tests -// (many via `vi.mock('../git/status')`) already resolve their git reads/writes through it. +// Preserve the source-control import/mocking entry point; implementation lives in source-control. export { getStatus } from './source-control/status-read' export type { GetStatusOptions } from './source-control/get-status-options' export { getSubmoduleStatus } from './source-control/submodule-status' diff --git a/src/main/git/upstream-deferred-fork-remote-real.test.ts b/src/main/git/upstream-deferred-fork-remote-real.test.ts index 451f7bed8b3..b4bd5c52afc 100644 --- a/src/main/git/upstream-deferred-fork-remote-real.test.ts +++ b/src/main/git/upstream-deferred-fork-remote-real.test.ts @@ -6,12 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { GitPushTarget } from '../../shared/worktree/types' import { getUpstreamStatus } from './upstream' -// Why: on-demand remote materialization (#17828) defers `git remote add` for a -// fork PR to first push/pull/fetch/fast-forward, so an unpublished review's -// status must be read against a pushTarget whose remote was never created. -// This exercises the real `rev-parse --verify --quiet` failure path -- a -// fake/mocked git can't reproduce its exact exit-code/stderr shape, which is -// exactly what `getPublishTargetStatus`'s missing-ref fallback depends on. +// Real Git verifies missing-ref exit/stderr handling before a fork remote is materialized. describe('getUpstreamStatus with a deferred (not-yet-materialized) fork remote', () => { const tempPaths: string[] = [] @@ -37,9 +32,7 @@ describe('getUpstreamStatus with a deferred (not-yet-materialized) fork remote', git('commit', '-m', 'base') git('branch', '-M', 'contributor/fix') - // Simulates a fork-PR review worktree right after create: pushTarget - // metadata is persisted, but `pr-contributor-orca` was never added as a - // remote because materialization is deferred to first use. + // Persist a push target without adding its deferred remote. const pushTarget: GitPushTarget = { remoteName: 'pr-contributor-orca', branchName: 'contributor/fix', diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index fab30418eca..fdf991eed0b 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -23,7 +23,6 @@ describe('getUpstreamStatus', () => { invalidateGitUpstreamStatusReads() }) - // Why: the CI-enforced guard that the native/WSL path actually coalesces rather than fanning out. it('shares one physical read across ten identical native callers', async () => { let resolveSymbolicRef = (): void => {} const symbolicRefGate = new Promise((resolve) => { @@ -51,7 +50,6 @@ describe('getUpstreamStatus', () => { gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref') ).toHaveLength(1) expect(new Set(results).size).toBe(1) - // A settled lease is dropped, so the next read must issue fresh Git work. await getUpstreamStatus('/repo') expect( gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref') diff --git a/src/main/git/upstream.ts b/src/main/git/upstream.ts index 6c3a51ba150..0397b524252 100644 --- a/src/main/git/upstream.ts +++ b/src/main/git/upstream.ts @@ -50,8 +50,7 @@ async function getBehindCommitsArePatchEquivalent( ) return upstreamOnlyCommitsArePatchEquivalent(stdout) } catch { - // Why: patch-equivalence is an optimization for the rebase case. If the - // probe fails, keep the conservative pull-first behavior. + // Failed patch-equivalence probes retain the conservative pull-first behavior. return false } } @@ -75,11 +74,7 @@ async function readUpstreamStatus( (upstreamName) => getBehindCommitsArePatchEquivalent(worktreePath, upstreamName, options) ) } catch (error) { - // Why: we only swallow clearly-no-upstream signals — that's an expected - // state, not a failure. Other errors (auth, corruption, "not a git - // repository", sparse-checkout) should surface to the user so they can - // act on them. The shared isNoUpstreamError helper intentionally omits - // broad phrases like "no such branch" to avoid masking real errors. + // Suppress only proven no-upstream errors; auth, corruption and unavailable repos must surface. if (isNoUpstreamError(error)) { return { hasUpstream: false, @@ -87,8 +82,7 @@ async function readUpstreamStatus( behind: 0 } } - // Why: parity with gitPush/gitPull/gitFetch — normalize before crossing - // the IPC boundary so renderers don't see execFile stderr preambles or local paths. + // Normalize before IPC so renderer errors omit subprocess preambles and local paths. throw new Error(normalizeGitErrorMessage(error, 'upstream')) } } diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts index 58b5202ed9b..6707ad538a4 100644 --- a/src/main/git/worktree-add-creation-config.test.ts +++ b/src/main/git/worktree-add-creation-config.test.ts @@ -1,4 +1,3 @@ -// addWorktree: checkout creation, branch-base/push.autoSetupRemote config writes, ref qualification. import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( @@ -22,15 +21,14 @@ registerWorktreeSuiteHooks() describe('addWorktree', () => { const resolveRemoteBase = () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) } const resolveCreationBaseConfigWrite = () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) } - // Why: argv now depends on the host OS, so pin a non-Windows default or every - // exact-argv assertion below would fail for a maintainer running vitest on Windows. + // Pin non-Windows argv; Windows creation adds platform-specific flags. let platformSpy: MockInstance<() => NodeJS.Platform> beforeEach(() => { @@ -46,10 +44,10 @@ describe('addWorktree', () => { it('creates the worktree without touching the local base ref by default', async () => { resolveRemoteBase() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') @@ -83,7 +81,7 @@ describe('addWorktree', () => { }) it('checks out a selected existing local branch without creating a new branch', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, { checkoutExistingBranch: true @@ -99,7 +97,7 @@ describe('addWorktree', () => { it('enables long paths for native Windows worktree creation', async () => { platformSpy.mockReturnValue('win32') - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree( 'C:\\repo', @@ -118,10 +116,9 @@ describe('addWorktree', () => { }) it('still enables long paths for a Windows-path repo that has a WSL distro configured', async () => { - // Why: a C:\ cwd can be served by host git.exe even with wslDistro set, and that - // is exactly the MAX_PATH-prone case; Linux git parses and ignores the key. + // Host Git may serve C: paths even with a WSL distro; Linux Git ignores core.longpaths. platformSpy.mockReturnValue('win32') - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree( 'C:\\repo', @@ -141,7 +138,7 @@ describe('addWorktree', () => { it('does not pass the Windows-only long-path option for a WSL UNC repo path', async () => { platformSpy.mockReturnValue('win32') - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) const repoPath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo' await addWorktree( @@ -164,7 +161,7 @@ describe('addWorktree', () => { 'does not pass the long-path option on %s', async (platform) => { platformSpy.mockReturnValue(platform) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, { checkoutExistingBranch: true @@ -178,10 +175,7 @@ describe('addWorktree', () => { ) it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => { - // Why: without a timeout, a OneDrive cloud-placeholder checkout can stall - // `git worktree add` for minutes. Assert the runner receives a non-zero - // timeout so a stuck create fails fast instead of hanging forever. - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, { checkoutExistingBranch: true @@ -196,7 +190,7 @@ describe('addWorktree', () => { it('raises the worktree add timeout from ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696)', async () => { vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', '600000') - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, { checkoutExistingBranch: true @@ -209,8 +203,8 @@ describe('addWorktree', () => { }) it('does not write branch base config when no base branch is provided', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) await addWorktree('/repo', '/repo-feature', 'feature/no-base') @@ -225,10 +219,10 @@ describe('addWorktree', () => { it('warns and unsets stale branch base config when persisting the base fails', async () => { resolveRemoteBase() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local --replace-all branch..base - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --unset-all branch..base - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect( @@ -250,10 +244,10 @@ describe('addWorktree', () => { it('warns but does not throw when push.autoSetupRemote config fails', async () => { resolveRemoteBase() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get (unset, expected) - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) // config --local set fails + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('config locked')) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect( @@ -268,12 +262,9 @@ describe('addWorktree', () => { }) it('warns and skips --local set when --get fails with non-unset error (e.g. corrupt config)', async () => { - // Why: exit 1 from `git config --get` means "key unset" — anything else - // is a real read failure (parse error, locked file). We must NOT fall - // through to `--local set true`, which would silently overwrite whatever - // value the user actually has. + // Only exit 1 means unset; other read failures must preserve the user's config. resolveRemoteBase() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) resolveCreationBaseConfigWrite() gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('parse error'), { code: 3 })) // --get fails non-unset const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -286,7 +277,6 @@ describe('addWorktree', () => { 'addWorktree: failed to set push.autoSetupRemote for /repo-feature', expect.any(Error) ) - // No --local set was attempted: only worktree add + the failing --get. expect(gitExecFileAsyncMock.mock.calls).toEqual([ [['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], [ @@ -318,13 +308,12 @@ describe('addWorktree', () => { it('preserves existing push.autoSetupRemote value (does not overwrite user-set false)', async () => { resolveRemoteBase() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'false\n' }) // config --get returns existing value + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'false\n' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') - // No --local set: --get succeeded so we preserve the user's value. expect(gitExecFileAsyncMock.mock.calls).toEqual([ [['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], [ @@ -354,13 +343,10 @@ describe('addWorktree', () => { }) it('treats --get success with empty stdout as "already set" (key present but blank)', async () => { - // Why: `git config --get key` exits 0 if the key has any value at any - // scope, including the unusual case of an explicitly empty string. We - // must not fall through to `--local set true` and overwrite that. resolveRemoteBase() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --get succeeds with empty value + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main') @@ -393,10 +379,6 @@ describe('addWorktree', () => { }) it('does not write config when worktree add itself fails', async () => { - // Why: a refactor that moves the config block earlier could try to write - // push.autoSetupRemote against a worktree directory that was never - // created. Pin the current ordering invariant: config calls happen only - // after worktree add succeeds. resolveRemoteBase() gitExecFileAsyncMock.mockRejectedValueOnce(new Error('worktree add failed')) @@ -422,14 +404,11 @@ describe('addWorktree', () => { }) it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => { - // Why: repos that fetch with --tags can end up with a local tag named 'main', - // making `git worktree add ... main` fail with "fatal: Ambiguous object name". - // Qualifying as refs/heads/main tells git exactly which object to use. - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/main^{commit} - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await addWorktree('/repo', '/repo-feature', 'feature/disambig', 'main') @@ -452,12 +431,12 @@ describe('addWorktree', () => { }) it('does not report a local base refresh for slash-containing local branch names', async () => { - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit} - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/release/main^{commit} - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref')) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) const result = await addWorktree( '/repo', @@ -496,14 +475,14 @@ describe('addWorktree', () => { const beforeRemoval = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' resolveRemoteBase() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('sparse setup failed')) // sparse-checkout init - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --unset-all branch..base - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // worktree list before remove - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -D (rollback force-deletes the fresh branch) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('sparse setup failed')) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await expect( addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['src'], 'origin/main') @@ -515,8 +494,7 @@ describe('addWorktree', () => { '--unset-all', 'branch.feature/test.base' ]) - // Why: rolling back a failed creation force-deletes the just-created branch - // (`-D`) — it has no user commits to protect, unlike a user-initiated delete. + // Rollback may force-delete its new branch; user-initiated deletion must protect commits. expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ 'branch', '-D', diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index 11bc2ffe0af..8732b1abcea 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -1,4 +1,3 @@ -// addWorktree: fast-forwarding the local base ref (merge --ff-only / update-ref) and its safety bailouts. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -31,8 +30,7 @@ describe('addWorktree', () => { beforeEach(() => { // These branch-safety assertions use POSIX argv; Windows flags have separate coverage. vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - // The refresh overlaps `worktree add`, so checkout calls get a fixed fake and the - // (sequential) base-resolution + refresh calls keep their own ordered queue. + // Separate the overlapping checkout from the ordered refresh probe queue. refreshGitMock.mockReset() checkoutGitMock.mockReset().mockImplementation(async (args: string[]) => { if (args[0] === 'config' && args[1] === '--get') { @@ -57,11 +55,11 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n' queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo) - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD (in /repo) - .mockResolvedValueOnce({ stdout: '' }) // merge --ff-only (in /repo) - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/heads/main after the merge + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -118,18 +116,17 @@ describe('addWorktree', () => { markMergeStarted = resolve }) queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) .mockImplementationOnce( () => new Promise((resolve) => { finishMerge = () => resolve({ stdout: '' }) markMergeStarted() }) - ) // merge --ff-only, held until worktree add is running - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/heads/main after the merge - // Would deadlock if the create awaited the refresh before starting the add. + ) + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) checkoutGitMock.mockImplementationOnce(async () => { await mergeStarted finishMerge() @@ -151,11 +148,11 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n' queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt) - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref (in /repo-main-wt) - .mockResolvedValueOnce({ stdout: '' }) // merge --ff-only (in /repo-main-wt) - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/heads/main after the merge + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -166,7 +163,6 @@ describe('addWorktree', () => { ], [['symbolic-ref', '-q', 'HEAD'], expect.objectContaining({ cwd: '/repo-main-wt' })], [ownerFastForwardArgs('remote-main'), expect.objectContaining({ cwd: '/repo-main-wt' })], - // Confirms local landed exactly on the target, read from the repo like the inspection. [ ['rev-parse', '--verify', 'refs/heads/main^{commit}'], expect.objectContaining({ cwd: '/repo' }) @@ -177,8 +173,8 @@ describe('addWorktree', () => { it('fast-forwards local base via update-ref when localBranch is not checked out', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n' queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // update-ref refs/heads/main remote-main old-main + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -187,7 +183,6 @@ describe('addWorktree', () => { baseRef: 'origin/main', localBranch: 'main' }) - // Compare-and-swap form (expected old OID) so a concurrent ref move is a no-op. expect(refreshGitMock.mock.calls.at(-1)).toEqual([ [ 'update-ref', @@ -199,7 +194,6 @@ describe('addWorktree', () => { ], { cwd: '/repo' } ]) - // No worktree owns the branch, so no working tree is touched. expect(refreshGitMock.mock.calls.map(([args]) => args)).not.toContainEqual( ownerFastForwardArgs('remote-main') ) @@ -208,15 +202,15 @@ describe('addWorktree', () => { it('reports the owner dirty when the fast-forward refuses to overwrite an edit made after inspection', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during inspection - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) .mockRejectedValueOnce( Object.assign(new Error('Command failed: git merge'), { stderr: 'error: Your local changes to the following files would be overwritten by merge:\n\tpackage.json' }) - ) // merge --ff-only refuses + ) .mockRejectedValueOnce(Object.assign(new Error('not ancestor'), { code: 1 })) // merge-base const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -238,9 +232,9 @@ describe('addWorktree', () => { it('skips local base refresh when the owner worktree switches branches before mutation', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list during inspection - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during inspection - .mockResolvedValueOnce({ stdout: 'refs/heads/develop\n' }) // symbolic-ref: owner switched + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/develop\n' }) .mockRejectedValueOnce(Object.assign(new Error('not ancestor'), { code: 1 })) // merge-base const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -279,8 +273,8 @@ describe('addWorktree', () => { it('skips update when the owning worktree is dirty', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty) + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: ' M package.json\n' }) const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -291,8 +285,6 @@ describe('addWorktree', () => { ownerWorktreePath: '/repo' }) - // No merge or update-ref: just base resolution, local/remote OIDs, drift count, worktree - // list and the owner status. expect(refreshGitMock.mock.calls).toHaveLength(6) expect(refreshGitMock.mock.calls[0]?.[0]).toEqual([ 'rev-parse', @@ -325,7 +317,7 @@ describe('addWorktree', () => { it('skips updating the local branch when its drift probe fails but the branch exists', async () => { queueBehindInspection({ counts: new Error('not a fast-forward') }).mockResolvedValueOnce({ stdout: '' - }) // show-ref refs/heads/main (exists) + }) const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -364,17 +356,16 @@ describe('addWorktree', () => { ]) }) - // #15331: `-b feature-x` proves there was no local feature-x to refresh. The add overlaps the - // refresh, so a probe could see the branch absent and then present (the add just wrote it). + // Creating the base's own local branch leaves nothing to refresh; probing would race the add. it('does not warn when worktree add itself creates the local base branch', async () => { refreshGitMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/feature-x^{commit} + .mockResolvedValueOnce({ stdout: 'abc123\n' }) .mockRejectedValueOnce( new Error( "fatal: ambiguous argument 'refs/heads/feature-x...refs/remotes/origin/feature-x': unknown revision or path not in the working tree." ) - ) // rev-list, if it ran: refs/heads/feature-x not written yet - .mockResolvedValueOnce({ stdout: '' }) // show-ref, if it ran: the concurrent add has written it + ) + .mockResolvedValueOnce({ stdout: '' }) const result = await addWorktree( '/repo', @@ -394,25 +385,22 @@ describe('addWorktree', () => { '/repo-feature-x', 'refs/remotes/origin/feature-x' ]) - // #26344: the branch made from its own remote branch tracks it. expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ 'branch', '--set-upstream-to', 'refs/remotes/origin/feature-x', 'feature-x' ]) - // No refresh probe raced the add, and nothing was mutated. expect(refreshGitMock.mock.calls.map((call) => call[0][0])).toEqual(['rev-parse']) expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('update-ref') expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('reset') }) - // #15331: same missing-local-branch class, but the new branch name differs from the base's. it('does not warn when the local base branch does not exist in a fetch-only clone', async () => { refreshGitMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} - .mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) // rev-parse: no local main - .mockRejectedValueOnce(Object.assign(new Error('missing ref'), { code: 1 })) // show-ref refs/heads/main (missing) + .mockResolvedValueOnce({ stdout: 'abc123\n' }) + .mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) + .mockRejectedValueOnce(Object.assign(new Error('missing ref'), { code: 1 })) const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) @@ -426,12 +414,11 @@ describe('addWorktree', () => { ]) }) - // A failed probe is not proof of absence, so the warning must survive it. it('keeps the warning when the local base ref probe itself fails', async () => { refreshGitMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: 'abc123\n' }) .mockRejectedValueOnce(new Error('rev-parse failed')) // local oid probe - .mockRejectedValueOnce(new Error('fatal: not a git repository')) // show-ref probe could not run + .mockRejectedValueOnce(new Error('fatal: not a git repository')) const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) @@ -443,11 +430,10 @@ describe('addWorktree', () => { }) it('still suggests nothing but keeps the warning when the local base ref exists and diverged', async () => { - queueBehindInspection({ counts: { stdout: '2\t3\n' } }) // rev-list: 2 local-only commits + queueBehindInspection({ counts: { stdout: '2\t3\n' } }) const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) - // Local main exists with local-only commits: real divergence must still warn. expect(result.localBaseRefRefresh).toEqual({ status: 'skipped_not_fast_forward', baseRef: 'origin/main', @@ -459,14 +445,14 @@ describe('addWorktree', () => { it('reports not-fast-forward when local gained a commit after the inspection', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' queueBehindInspection() - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) .mockRejectedValueOnce( Object.assign(new Error('Command failed: git merge'), { stderr: 'fatal: Not possible to fast-forward, aborting.' }) - ) // merge --ff-only refuses to drop the new commit + ) .mockRejectedValueOnce(Object.assign(new Error('not ancestor'), { code: 1 })) // merge-base const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -483,11 +469,11 @@ describe('addWorktree', () => { it('uses the remote name from the base ref instead of hardcoding origin', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' queueBehindInspection({ remoteOid: 'remote-upstream-main' }) - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain - .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD - .mockResolvedValueOnce({ stdout: '' }) // merge --ff-only - .mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/heads/main after the merge + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true) @@ -530,15 +516,14 @@ function ownerFastForwardArgs(remoteOid: string): string[] { ] } -/** Queues base resolution plus the inspection's local oid, remote oid and drift count. */ function queueBehindInspection( options: { remoteOid?: string; counts?: { stdout: string } | Error } = {} ) { const counts = options.counts ?? { stdout: '0\t3\n' } refreshGitMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet ^{commit} - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse --verify refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: `${options.remoteOid ?? 'remote-main'}\n` }) // rev-parse --verify ^{commit} + .mockResolvedValueOnce({ stdout: 'abc123\n' }) + .mockResolvedValueOnce({ stdout: 'old-main\n' }) + .mockResolvedValueOnce({ stdout: `${options.remoteOid ?? 'remote-main'}\n` }) return counts instanceof Error ? refreshGitMock.mockRejectedValueOnce(counts) : refreshGitMock.mockResolvedValueOnce(counts) diff --git a/src/main/git/worktree-add-local-base-suggestion.test.ts b/src/main/git/worktree-add-local-base-suggestion.test.ts index 3489a4d05b4..36ed5f485ab 100644 --- a/src/main/git/worktree-add-local-base-suggestion.test.ts +++ b/src/main/git/worktree-add-local-base-suggestion.test.ts @@ -1,4 +1,3 @@ -// addWorktree: advisory local-base-ref update suggestions when the refresh setting is off. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( @@ -33,16 +32,16 @@ describe('addWorktree', () => { it('suggests updating the local base ref when setting is off and the branch is safely behind', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + .mockResolvedValueOnce({ stdout: 'abc123\n' }) + .mockResolvedValueOnce({ stdout: 'old-main\n' }) + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) + .mockResolvedValueOnce({ stdout: '0\t2\n' }) + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) + .mockResolvedValueOnce({ stdout: '' }) const result = await addWorktree( '/repo', @@ -66,19 +65,18 @@ describe('addWorktree', () => { [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], [['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }] ]) - // Advisory only: nothing moves the local branch. const commands = gitExecFileAsyncMock.mock.calls.map(([args]) => args) expect(commands.some((args) => args.includes('merge') || args[0] === 'update-ref')).toBe(false) }) it('skips advisory owner probes when the local base is already current', async () => { gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // resolve creation base - .mockResolvedValueOnce({ stdout: 'same\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'same\n' }) // rev-parse refs/remotes/origin/main^{commit}: current - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // persist branch base - .mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set + .mockResolvedValueOnce({ stdout: 'abc123\n' }) + .mockResolvedValueOnce({ stdout: 'same\n' }) + .mockResolvedValueOnce({ stdout: 'same\n' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'true\n' }) await expect( addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', false, false, { @@ -113,16 +111,16 @@ describe('addWorktree', () => { it('uses normalized branch metadata for slash-containing remotes', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/foo/bar/main^{commit} - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/foo/bar/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + .mockResolvedValueOnce({ stdout: 'abc123\n' }) + .mockResolvedValueOnce({ stdout: 'old-main\n' }) + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) + .mockResolvedValueOnce({ stdout: '0\t2\n' }) + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) + .mockResolvedValueOnce({ stdout: '' }) const result = await addWorktree( '/repo', @@ -155,16 +153,16 @@ describe('addWorktree', () => { it('does not suggest updating the local base ref when its owner worktree is dirty', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + .mockResolvedValueOnce({ stdout: 'abc123\n' }) + .mockResolvedValueOnce({ stdout: 'old-main\n' }) + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) + .mockResolvedValueOnce({ stdout: '0\t2\n' }) + .mockResolvedValueOnce({ stdout: worktreeListOutput }) + .mockResolvedValueOnce({ stdout: ' M package.json\n' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: '' }) + .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) + .mockResolvedValueOnce({ stdout: '' }) const result = await addWorktree( '/repo', diff --git a/src/main/git/worktree-add-remote-branch-upstream-real-git.test.ts b/src/main/git/worktree-add-remote-branch-upstream-real-git.test.ts index 807ca9b858e..91a44d0a497 100644 --- a/src/main/git/worktree-add-remote-branch-upstream-real-git.test.ts +++ b/src/main/git/worktree-add-remote-branch-upstream-real-git.test.ts @@ -1,4 +1,3 @@ -// Issue #26344: a worktree created from a remote-only branch tracks that remote branch. import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -74,7 +73,6 @@ async function cloneWithRemoteOnlyBranch(): Promise<{ root: string; repo: string it('tracks the remote branch it was created from, and nothing else', async () => { const { root, repo } = await cloneWithRemoteOnlyBranch() - // Picking `origin/alex/fix` is not a collision even though `upstream/alex/fix` exists. await expect(getBranchConflictKind(repo, 'alex/fix', 'origin/alex/fix')).resolves.toBeNull() const fixPath = join(root, 'fix') @@ -84,7 +82,6 @@ it('tracks the remote branch it was created from, and nothing else', async () => await git(repo, 'rev-parse', 'refs/remotes/origin/alex/fix') ) - // A new branch off a differently named base keeps the untracked default. const otherPath = join(root, 'other') await addWorktree(repo, otherPath, 'feature/new', 'origin/main') expect(await upstreamOf(otherPath, 'feature/new')).toBeNull() diff --git a/src/main/git/worktree-add-timeout-override.test.ts b/src/main/git/worktree-add-timeout-override.test.ts index 2f37a212334..028935386bf 100644 --- a/src/main/git/worktree-add-timeout-override.test.ts +++ b/src/main/git/worktree-add-timeout-override.test.ts @@ -1,4 +1,3 @@ -// resolveWorktreeAddTimeoutMs: ORCA_WORKTREE_ADD_TIMEOUT_MS parsing, clamping, and warnings. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( @@ -49,8 +48,6 @@ describe('resolveWorktreeAddTimeoutMs', () => { ) }) - // Why: `=Infinity` is the natural way to say "stop killing my checkout"; collapsing it to the - // default would hand the operator back the exact failure they set the variable to escape. it('treats an infinite override as the maximum, not the default', () => { expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'Infinity' })).toBe( 1_800_000 @@ -59,14 +56,11 @@ describe('resolveWorktreeAddTimeoutMs', () => { expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-Infinity' })).toBe( WORKTREE_ADD_TIMEOUT_MS ) - // Why: `Infinity` is a number that got clamped, so reporting "is not a number" while naming the - // number it used would misdirect exactly the operator this override exists for. expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('"Infinity" is outside [180000, 1800000]ms; using 1800000ms') ) }) - // Why: trimming and fractional truncation alter the value but are not operator error. it('stays quiet when the value is unset, blank, or lands in range', () => { resolveWorktreeAddTimeoutMs({}) resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' }) @@ -76,18 +70,16 @@ describe('resolveWorktreeAddTimeoutMs', () => { expect(warnSpy).not.toHaveBeenCalled() }) - // Why: the seconds/ms mixup is the whole reason the floor exists — it must not be silent. + // A seconds-versus-milliseconds mistake should warn when clamped. it('warns with the range when it clamps an out-of-range value', () => { resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' }) expect(warnSpy).toHaveBeenCalledTimes(1) - // Why: pin the prefix and variable name too — a warning nobody can grep for is not a diagnostic. expect(warnSpy).toHaveBeenCalledWith( '[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="300" is outside [180000, 1800000]ms; using 180000ms' ) }) - // Why: `600_000` copied out of worktree.ts is NaN, not out of range — a range complaint would misdirect. it('warns that an unparseable value is not a number rather than out of range', () => { resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600_000' }) @@ -97,7 +89,6 @@ describe('resolveWorktreeAddTimeoutMs', () => { ) }) - // Why: `=300` means seconds to most operators; clamping it up keeps every create working instead of failing in 300ms. it('never lowers the timeout below the stall-guard default', () => { expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })).toBe( WORKTREE_ADD_TIMEOUT_MS diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 56b0e404caa..f4d3874d258 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -27,7 +27,7 @@ import { createWorktreeCheckoutProgressReader } from './worktree-checkout-progre export type WorktreeAddBaseContext = Pick & { effectiveBase: string effectiveBaseOid?: string - /** Started, not awaited: the worktree is created from the remote-tracking commit, so the refresh can overlap the checkout. Never rejects. */ + /** Never rejects; refresh can overlap creation from the remote-tracking commit. */ pendingLocalBaseRefRefresh?: Promise } @@ -43,7 +43,7 @@ export async function resolveWorktreeAddBaseContext( effectiveBaseOid = await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options) return effectiveBaseOid !== null }) - // Why: `-b` refuses an existing branch, so creating the base's own local branch leaves nothing to refresh; probing it would race the overlapped add's branch write into a false "not fast-forward" warning. + // -b rejects existing branches; refreshing the branch it creates would race its own ref write. const createsLocalBaseBranch = parseRemoteTrackingLocalBaseRef(baseBranch, effectiveBase, options.remoteTrackingBase) ?.localBranch === createdBranch @@ -56,7 +56,7 @@ export async function resolveWorktreeAddBaseContext( options.remoteTrackingBase, options ).catch((error: unknown) => { - // Why: the create may already have succeeded by the time this settles; a refresh bug must not fail it. + // An advisory refresh must not fail a checkout that may already have succeeded. console.warn('addWorktree: local base ref refresh failed unexpectedly', error) return undefined }) @@ -96,7 +96,7 @@ export async function persistWorktreeCreationBase( } catch (error) { console.warn(`addWorktree: failed to set ${configKey} for ${worktreePath}`, error) try { - // Why: reused branch names may carry stale base metadata; if replacement fails, unset it so consumers don't trust stale lineage. + // Clear stale lineage if replacing reused branch metadata fails. await gitExecFileAsync(['config', '--local', '--unset-all', configKey], { ...gitExecOptions(worktreePath, options) }) @@ -114,7 +114,7 @@ export async function configurePushAutoSetupRemote( options: GitWorktreeExecOptions ): Promise { try { - // Why: `--get` (not `--local --get`) treats a value at any scope as an explicit user choice. + // Honor explicit config at any scope. let alreadySet = false try { await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], { @@ -147,15 +147,10 @@ export async function unsetWorktreeCreationBase( await gitExecFileAsync(['config', '--local', '--unset-all', `branch.${branch}.base`], { ...gitExecOptions(worktreePath, options) }) - } catch { - // Best-effort cleanup; leave the original sparse-setup error as the actionable failure. - } + } catch {} } -/** - * Issue #26344: a branch created from its own remote branch (`origin/foo` -> `foo`) tracks it, as - * `git checkout foo` would; every other new branch stays untracked (see `--no-track` below). - */ +/** Track a same-name remote base; other newly created branches stay untracked. */ export async function trackSameNameRemoteBase( worktreePath: string, branch: string, @@ -175,8 +170,7 @@ export async function trackSameNameRemoteBase( return } try { - // Why: `branch --set-upstream-to` (Git 1.8) maps the ref to its remote through the fetch - // refspecs, so a remote name containing `/` resolves without a capability probe. + // Git 1.8+ resolves slash-containing remote names via fetch refspecs without another probe. await gitExecFileAsync(['branch', '--set-upstream-to', effectiveBase, branch], { ...gitExecOptions(worktreePath, options) }) @@ -185,17 +179,7 @@ export async function trackSameNameRemoteBase( } } -/** - * Create a new worktree. - * @param repoPath - Path to the main repo (or bare repo) - * @param worktreePath - Absolute path where the worktree will be created - * @param branch - Branch name for the new worktree - * @param baseBranch - Optional base branch to create from (defaults to HEAD) - * @remarks Side effects (best-effort, warn-only): passes `--no-track`, writes - * `branch..base` for new-branch worktrees with a base ref, tracks a - * same-named remote base, and may write `push.autoSetupRemote=true` to the - * repo's shared config. - */ +/** Create a worktree; new branches record base lineage and may enable repo-wide push.autoSetupRemote. */ export async function addWorktree( repoPath: string, worktreePath: string, @@ -233,22 +217,19 @@ async function performAddWorktree( noCheckout = false, options: AddWorktreeOptions = {} ): Promise { - // Why: Git still owns that path and branch until the background delete finishes; a create now - // would race it, and the branch cleanup that follows would find the branch checked out again. + // Wait for background removal ownership to end before reusing its path or branch. assertNoPendingWorktreeRemovalConflict(repoPath, { worktreePath, branch }) let pendingLocalBaseRefRefresh: Promise | undefined let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined - // Why: enable long paths for this Windows checkout without changing user Git config. const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add'] let effectiveBase: string | undefined if (noCheckout) { args.push('--no-checkout') } if (options.checkoutExistingBranch) { - // Why: -b would create a new branch instead of checking out the selected one. args.push(worktreePath, branch) } else { - // Why: --no-track avoids inheriting the base's upstream so `git status` won't misreport "behind by N" pre-publish; first push sets it (see push.autoSetupRemote below). + // Avoid inheriting the base's upstream; same-name remote bases are tracked explicitly afterward. args.push('--no-track', '-b', branch, worktreePath) if (baseBranch) { const baseContext = await resolveWorktreeAddBaseContext( @@ -264,7 +245,7 @@ async function performAddWorktree( args.push(effectiveBase) } } - // Why: git already writes its checkout meter into our stderr pipe; read it, don't request it. + // Read Git's existing stderr meter without requesting extra progress output. const checkoutProgress = options.onCheckoutProgress && !noCheckout ? createWorktreeCheckoutProgressReader(options.onCheckoutProgress) @@ -272,18 +253,17 @@ async function performAddWorktree( try { await gitExecFileAsync(args, { ...gitExecOptions(repoPath, options), - // Why: resolve per call — hoisting this to a module const would freeze the override at import. + // Resolve per call so later environment overrides take effect. timeout: resolveWorktreeAddTimeoutMs(), ...(checkoutProgress ? { onStderr: checkoutProgress.read } : {}) }) } catch (error) { - // Why: settle the overlapped refresh inside the caller's ref-maintenance pause before reporting the failure. + // Settle the refresh before leaving the caller's maintenance pause on failure. await pendingLocalBaseRefRefresh throw error } finally { checkoutProgress?.close() - // Git may have written the target's `.git` marker even when it reports a late - // failure, so drop any pre-create route before the follow-up commands route. + // Even late failures can rewrite .git; invalidate pre-create routing before follow-up commands. invalidateWslLinkedWorktreeGitRouting(worktreePath) } @@ -296,10 +276,7 @@ async function performAddWorktree( await trackSameNameRemoteBase(worktreePath, branch, baseBranch, effectiveBase, options) } - // Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain - // `git push` create+set origin/ (git >=2.37; older clients ignore it). `--local` on a - // linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent, - // so it's warn-only and not rolled back on failure. + // Git >= 2.37 can set an upstream on first push; --local intentionally writes shared repo config. await configurePushAutoSetupRemote(worktreePath, options) const localBaseRefRefresh = await pendingLocalBaseRefRefresh return { diff --git a/src/main/git/worktree-base-divergence-real-git.test.ts b/src/main/git/worktree-base-divergence-real-git.test.ts index 4aa1734c792..e6065351082 100644 --- a/src/main/git/worktree-base-divergence-real-git.test.ts +++ b/src/main/git/worktree-base-divergence-real-git.test.ts @@ -11,11 +11,7 @@ import { const tempRoots: string[] = [] -// Why the maintenance suppression: `git commit` detaches `git maintenance run --auto`, and its -// commit-graph task arms once a fixture crosses 100 new commits — which the cap-sized histories -// below always do. That detached process keeps writing `.git/objects/info/commit-graphs` after the -// synchronous exec has returned, so it re-creates entries under a `.git/objects` the temp-root -// teardown is midway through deleting, and the recursive remove dies with ENOTEMPTY. +// Disable detached maintenance so commit-graph writes cannot race fixture teardown. function git(cwd: string, args: string[]): string { return execFileSync('git', [...GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS, ...args], { cwd, @@ -38,11 +34,7 @@ async function createRepo(): Promise { return repoPath } -// Why unique across calls: an empty commit's hash covers only parent, tree, message and a -// one-second-granularity timestamp. On a fast runner the whole 100-commit build finishes inside -// one second, so a post-reset `commit 0` off the same fork point hashed identically to the first -// `commit 0` of the chain and Git handed back that same object — leaving the branch 99/0 apart -// instead of 100/1. +// Unique messages prevent same-second empty commits from reusing the same hash after reset. let emptyCommitSequence = 0 function commitEmpty(repoPath: string, count: number): void { @@ -77,7 +69,6 @@ describe('measureRetargetDivergence with real Git', () => { git(repoPath, ['reset', '--hard', '--quiet', forkPoint]) commitEmpty(repoPath, 1) - // 100 ahead + 1 behind is over the cap even though neither side alone exceeds it. await expect( measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main') ).resolves.toBe('exceeded') diff --git a/src/main/git/worktree-base-divergence.test.ts b/src/main/git/worktree-base-divergence.test.ts index fa03db0e654..f1b97b808f2 100644 --- a/src/main/git/worktree-base-divergence.test.ts +++ b/src/main/git/worktree-base-divergence.test.ts @@ -82,8 +82,7 @@ describe('measureRetargetDivergence deadlines', () => { expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base']) const signals = callOptions().map((options) => options.signal) - // One signal object across all three: the counts and merge-base are staged, so per-probe - // budgets would let the check cost the sum of them. + // One budget spans all staged probes; per-probe budgets would add together. expect(new Set(signals).size).toBe(1) expect(signals[0]).toBeInstanceOf(AbortSignal) }) @@ -93,8 +92,7 @@ describe('measureRetargetDivergence deadlines', () => { await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') - // The signal covers admission queueing and the WSL environment wait, which start before a - // command timeout exists; the timeout still covers a hung spawn. + // Signals cover pre-spawn waits; command timeouts still bound a hung spawn. for (const options of callOptions()) { expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS) } @@ -102,9 +100,6 @@ describe('measureRetargetDivergence deadlines', () => { }) it('really aborts the in-flight probes when the shared budget expires', async () => { - // A probe that behaves like a slow walk: it produces nothing on its own and only settles when - // its signal fires. If the budget never fired, or never reached the probe, this hangs and the - // test fails on its own timeout rather than passing on a signal that does nothing. mocks.gitExecFileAsync.mockImplementation( (_args: string[], options: ExecOptions) => new Promise((_resolve, reject) => { @@ -127,8 +122,7 @@ describe('measureRetargetDivergence deadlines', () => { }) it('clears the WSL read-environment wait, which starts before any command timeout', () => { - // Equal to it would make the first WSL-routed create of a session `unknown` by construction, - // and the WSL numbers meaningless. + // Leave walk headroom after the first WSL environment probe. expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS) expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS) }) @@ -166,14 +160,12 @@ describe('measureRetargetDivergence deadlines', () => { await expect( measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') ).resolves.toBe('unknown') - // A count that never answered must not go on to spend a merge-base walk. expect(subcommands()).not.toContain('merge-base') }) it('separates merge-base saying no from merge-base failing', async () => { mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { if (args[0] === 'merge-base') { - // Exit 1 is Git's answer for unrelated histories. throw exitCodeError(1) } return { stdout: '2\n' } @@ -185,7 +177,6 @@ describe('measureRetargetDivergence deadlines', () => { mocks.gitExecFileAsync.mockReset() mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { if (args[0] === 'merge-base') { - // A timeout carries no exit code and must not be read as "no common ancestor". throw new Error('git timed out.') } return { stdout: '2\n' } diff --git a/src/main/git/worktree-base-divergence.ts b/src/main/git/worktree-base-divergence.ts index ac30e418bed..9fd69048769 100644 --- a/src/main/git/worktree-base-divergence.ts +++ b/src/main/git/worktree-base-divergence.ts @@ -6,57 +6,22 @@ import type { GitAdmissionTier } from './command-runner/git-exec-options' export type RetargetDivergenceOptions = { wslDistro?: string admissionTier?: GitAdmissionTier - /** The create's own cancellation signal. Without it a cancelled create leaves these probes - * running until the budget expires. */ + /** Cancel probes when their owning create is cancelled. */ signal?: AbortSignal - /** Shortens only the end-to-end budget so a test can observe a real abort; production always - * uses the constant. Mirrors `timeoutMsForTest` on the git exec options. */ + /** Override the end-to-end budget in tests only. */ budgetMsForTest?: number } -/** `unknown` is deliberately not folded into `exceeded`: "the bound says no" and "the bound could - * not be evaluated" have different causes and different fixes, and only the second one means a - * retarget that would have been cheap was skipped. `unknown` covers a blown deadline, a - * cancelled create, and an ordinary Git failure alike — it is "no answer", not "slow". */ +/** unknown means no answer; exceeded means the commit cap was crossed or the histories are unrelated. */ export type RetargetDivergence = 'within' | 'exceeded' | 'unknown' -/** - * How far two bases may drift and still be worth retargeting a prepared checkout between. - * - * Measured on a 21,715-file repo: a local `main` and its `origin/main` were 5 commits and 74 - * files apart, while an abandoned fork's `main` — same branch name, so the same base family — - * was 8,173 commits and 21,708 files from `origin/main`, i.e. a whole-tree checkout. A commit - * count separates those by three orders of magnitude, so it is the cheap proxy for the tree diff - * the retarget reset would have to write. - */ +// Commit divergence is a cheap proxy for retarget writes; cap it to avoid whole-tree checkout costs. export const RETARGET_MAX_COMMIT_DIVERGENCE = 100 -/** - * Headroom for the walk itself, on top of the worst pre-spawn wait. - * - * ~3x the slowest walk measured on the 12GB/80k-ref repo (180ms to reject, 57ms to allow), so a - * cold WSL environment probe cannot eat the whole budget and make the answer `unknown` by - * construction. - */ +// Leave measured walk headroom after WSL setup so a cold environment does not consume the entire budget. const RETARGET_DIVERGENCE_WALK_HEADROOM_MS = 500 -/** - * End-to-end deadline for the whole check, not per probe. - * - * Derived from the WSL read-environment wait rather than picked: `git-exec-file` awaits that probe - * before a command timeout even exists, so a budget merely equal to it would guarantee `unknown` - * on the first WSL-routed create of a session and make the WSL numbers meaningless. Deriving it - * keeps that relationship explicit instead of coincidental. - * - * Sized against what it competes with: this exists only to decide whether to skip a ~4.1s p50 cold - * `worktree add`, so when it expires the create pays the budget and then does that add anyway. The - * total stays under that add even on Windows, where a killed probe also awaits `taskkill /t`. - * - * It must be a signal, not just a per-command timeout, because a per-command timeout starts only - * after `git-exec-file` has awaited admission and the WSL read-environment probe, and because the - * counts and `merge-base` are staged — two per-probe budgets in sequence would be twice the number - * written here. - */ +/** One signal covers staged probes and pre-spawn waits; size it beyond the WSL environment wait. */ export const RETARGET_DIVERGENCE_BUDGET_MS = WSL_GIT_READ_ENVIRONMENT_WAIT_MS + RETARGET_DIVERGENCE_WALK_HEADROOM_MS @@ -71,10 +36,7 @@ function probeOptions( signal: AbortSignal timeout: number } { - // Built field by field rather than spread: the caller's bag carries a test-only key that must - // never reach git's exec options. - // Both bounds: the signal covers the pre-spawn waits (admission queue, WSL environment) that a - // command timeout cannot see, and the timeout keeps the bounded tree-kill path for a hung spawn. + // Exclude test-only keys; signals cover pre-spawn waits and command timeouts bound hung children. return { cwd: repoPath, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), @@ -93,11 +55,7 @@ async function countCommitsAhead( signal: AbortSignal ): Promise { try { - // `--max-count` stops the walk, so an unrelated history costs a bounded number of commits - // rather than a full traversal. Both flags predate the Git 2.25 baseline. - // `--end-of-options` (Git 2.24) because a range whose left side began with `-` would - // otherwise parse as an option; callers only pass `refs/`-qualified names today, and this - // keeps that from being load-bearing. + // Bound history walks and reject option-like ranges; both flags support the Git 2.25 baseline. const { stdout } = await gitExecFileAsync( [ 'rev-list', @@ -136,25 +94,18 @@ async function hasCommonHistory( } } -/** - * Whether retargeting a checkout prepared at `preparedBase` onto `targetBase` stays cheap. - * - * Fails closed on error, slowness, and cancellation alike: only a positive `within` authorizes - * reusing the checkout, so every other outcome lands on the cold create path. - */ +/** Reuse prepared checkouts only on a proven within result; every other outcome uses cold creation. */ export async function measureRetargetDivergence( repoPath: string, preparedBase: string, targetBase: string, options: RetargetDivergenceOptions = {} ): Promise { - // Combined so cancelling the create stops the probes immediately rather than at the deadline. const signal = abortSignalWithTimeout( options.signal, options.budgetMsForTest ?? RETARGET_DIVERGENCE_BUDGET_MS ) - // Both directions: commits the target adds decide what the reset writes, commits only the - // preparation has decide what it must delete. + // Count both added and removed commits because either direction can require checkout writes. const [ahead, behind] = await Promise.all([ countCommitsAhead(repoPath, preparedBase, targetBase, options, signal), countCommitsAhead(repoPath, targetBase, preparedBase, options, signal) @@ -165,10 +116,7 @@ export async function measureRetargetDivergence( if (ahead + behind > RETARGET_MAX_COMMIT_DIVERGENCE) { return 'exceeded' } - // Only now: `merge-base` has no `--max-count`, so on unrelated histories it would walk both of - // them in full. Reaching here already proved neither side is more than the cap ahead of the - // other, which bounds that walk — and unrelated histories of any size fail the counts first. - // Required because unrelated histories replace the whole tree however few commits they carry. + // Check ancestry after capped counts: even small unrelated histories can replace the whole tree. const shareHistory = await hasCommonHistory(repoPath, preparedBase, targetBase, options, signal) if (shareHistory === null) { return 'unknown' diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 50e0e74f13a..cec608ad90f 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -10,12 +10,7 @@ type GitExecOptions = { admissionTier?: GitAdmissionTier } -/** - * Returns the probed commit oid, or null when the ref does not resolve. - * - * Why expose the oid: the probe already prints it, and callers that then need the - * same ref's oid were re-spawning `rev-parse` for a value this call threw away. - */ +/** Return the probe's commit OID so callers need not resolve it again. */ export async function resolveWorktreeBaseCommitOid( repoPath: string, qualifiedRef: string, @@ -44,11 +39,7 @@ export async function hasWorktreeBaseCommitRef( return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null } -/** - * The qualified ref a worktree base names in this repo, or the base unchanged when nothing - * matches. Callers that key on a base must compare this, not the raw string, or `main` and - * `refs/heads/main` look like different bases. - */ +/** Compare resolved qualified refs so aliases such as main and refs/heads/main share one base. */ export function resolveLocalWorktreeBaseRef( repoPath: string, baseRef: string, @@ -59,14 +50,7 @@ export function resolveLocalWorktreeBaseRef( ) } -/** - * Whether a worktree base — a qualified ref, a short branch or remote name, or a - * full commit id — already resolves in this repo's own object/ref store. - * - * Single copy on purpose: the create path, the speculative create prefetch and - * the remote-repo create path must agree on what counts as a local base, or the - * warm-up prepares a checkout create then rejects. - */ +/** Creation and prefetch share this presence check so they agree on reusable local bases. */ export async function hasLocalWorktreeBaseRef( repoPath: string, baseRef: string, @@ -89,20 +73,11 @@ export async function hasLocalWorktreeBaseRef( export type WorktreeBaseRefPresence = 'present' | 'absent' | 'unknown' -/** - * Distinguish "the ref does not exist" from "the probe itself failed". - * - * `show-ref --verify` is an exact lookup: exit 1 means a valid ref is absent, - * while other failures (for example a broken repo or dead SSH transport) stay - * inconclusive so callers can preserve their warning/error behavior. - * - * Executor-injected so the SSH path can route the same argv through the relay. - */ +/** Exact ref probes preserve unknown on execution failure; inject the runner to support relay hosts too. */ export async function probeWorktreeBaseRefPresence( runGit: (args: string[]) => Promise<{ stdout: string }>, qualifiedRef: string ): Promise { - // Reject malformed persisted metadata before passing it to Git. if (!isSafeGitRefName(qualifiedRef)) { return 'unknown' } diff --git a/src/main/git/worktree-base-refresh-contention.test.ts b/src/main/git/worktree-base-refresh-contention.test.ts index b71e13ba081..567782bce7a 100644 --- a/src/main/git/worktree-base-refresh-contention.test.ts +++ b/src/main/git/worktree-base-refresh-contention.test.ts @@ -117,7 +117,6 @@ describe('refreshLocalBaseRefForWorktreeCreate lock contention', () => { installGitFake({ owner: true, mutate: () => Promise.reject(INDEX_LOCK_ERROR), - // The user switches the owner checkout to another branch while the first attempt waits. ownerHead: () => (++symbolicRefReads >= 2 ? 'refs/heads/develop' : 'refs/heads/main') }) @@ -132,7 +131,6 @@ describe('refreshLocalBaseRefForWorktreeCreate lock contention', () => { installGitFake({ owner: true, mutate: () => Promise.reject(INDEX_LOCK_ERROR), - // The concurrent lock holder fast-forwarded local itself. localContainsTarget: () => true }) @@ -226,7 +224,6 @@ describe('refreshLocalBaseRefForWorktreeCreate runs one refresh at a time per br }) afterEach(() => vi.useRealTimers()) - // Local moves to the target once the (held) owner fast-forward finishes. function installRepoFake(repoPath: string) { let localOid = 'old-main' let finishMerge!: () => void @@ -274,7 +271,7 @@ describe('refreshLocalBaseRefForWorktreeCreate runs one refresh at a time per br await expect(first).resolves.toMatchObject({ status: 'updated' }) await expect(Promise.all(joiners)).resolves.toEqual([undefined, undefined]) const commands = gitExecFileAsyncMock.mock.calls.map(([args]) => commandOf(args)) - // Nothing ran for the joiners while the first held the checkout; after its post-move check, one inspection for all. + // Joiners share a follow-up inspection only after the first run releases the checkout. expect(commands.slice(0, callsWhileFirstMerges).filter((c) => c === 'rev-list')).toHaveLength(1) expect(commands.slice(callsWhileFirstMerges)).toEqual(['rev-parse', 'rev-parse', 'rev-parse']) expect(mutationCalls()).toHaveLength(1) @@ -312,7 +309,6 @@ describe('refreshLocalBaseRefForWorktreeCreate runs one refresh at a time per br expect(mutationCalls()).toHaveLength(1) expect(warn).toHaveBeenCalledWith(expect.stringContaining('stopped waiting')) - // Settle the wedged run so the follow-up finds local current and frees the slot. repo.finishMerge() await vi.runAllTimersAsync() await vi.waitFor(() => expect(gitExecFileAsyncMock.mock.calls.at(-1)?.[0][0]).toBe('rev-parse')) diff --git a/src/main/git/worktree-base-refresh-lock-real-git.test.ts b/src/main/git/worktree-base-refresh-lock-real-git.test.ts index aaf292810b4..8d67a26bb9c 100644 --- a/src/main/git/worktree-base-refresh-lock-real-git.test.ts +++ b/src/main/git/worktree-base-refresh-lock-real-git.test.ts @@ -106,7 +106,7 @@ async function createForkRepo(): Promise<{ } beforeEach(() => { - // Why: a developer's global config (e.g. merge.verifySignatures) must not change what these repos do. + // Isolate fixtures from developer Git settings such as merge.verifySignatures. vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') vi.stubEnv('GIT_CONFIG_GLOBAL', join(tmpdir(), `orca-no-global-gitconfig-${process.pid}`)) }) @@ -164,7 +164,6 @@ describe('concurrent local base refreshes of one repo with real Git', () => { const results = await Promise.all([refresh(repoPath), refresh(repoPath), refresh(repoPath)]) expect(results[0]).toMatchObject({ status: 'updated', ownerWorktreePath: expect.any(String) }) - // The joiners share one follow-up run, which finds local already current. expect(results.slice(1)).toEqual([undefined, undefined]) expect(spy.merges()).toBe(1) expect(warn).not.toHaveBeenCalled() @@ -185,7 +184,6 @@ describe('concurrent local base refreshes toward different remotes with real Git expect(results[0]).toMatchObject({ baseRef: 'origin/main', status: 'updated' }) expect(results[1]).toMatchObject({ baseRef: 'upstream/main', status: 'updated' }) - // Local is now ahead of origin/main: the existing ahead-of-requested-remote rule, not a sharing artifact. expect(results[2]).toMatchObject({ baseRef: 'origin/main', status: 'skipped_not_fast_forward' }) expect(git(repoPath, ['rev-parse', 'main'])).toBe(upstreamOid) expect(spy.maxConcurrent()).toBe(1) @@ -202,9 +200,7 @@ describe('concurrent local base refreshes toward different remotes with real Git ]) expect(results[0]).toMatchObject({ baseRef: 'upstream/main', status: 'updated' }) - // Local already equals upstream/main, so the second upstream create has nothing to report. expect(results[1]).toBeUndefined() - // Local is now ahead of origin/main: the existing ahead-of-requested-remote rule, not a sharing artifact. expect(results[2]).toMatchObject({ baseRef: 'origin/main', status: 'skipped_not_fast_forward' }) expect(git(repoPath, ['rev-parse', 'main'])).toBe(upstreamOid) expect(spy.maxConcurrent()).toBe(1) diff --git a/src/main/git/worktree-base-refresh.ts b/src/main/git/worktree-base-refresh.ts index ba0fe4ffc33..00b16b99413 100644 --- a/src/main/git/worktree-base-refresh.ts +++ b/src/main/git/worktree-base-refresh.ts @@ -16,12 +16,10 @@ import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { runWithGitReadCacheInvalidation } from './status' import type { AddWorktreeOptions, GitWorktreeExecOptions } from './worktree-operation-options' -// Why: the create reports the refresh result, and a mutating git process has no timeout; past this -// a create stops waiting and reports nothing, while the one in-flight refresh keeps its checkout. +// Bound the create's wait without cancelling the mutating refresh that still owns its checkout. export const LOCAL_BASE_REF_REFRESH_WAIT_MS = 30_000 -// Why: two runs moving one checkout at once collide on index.lock and misread each other's half-done -// merge; one run per branch at a time, shared only by creates toward the same target, leaves nothing to race. +// Serialize per branch to avoid index.lock races; coalesce only matching targets. const runPerLocalBaseBranch = createCoalescingKeyedRunner() export function parseRemoteTrackingLocalBaseRef( @@ -42,7 +40,7 @@ export function parseRemoteTrackingLocalBaseRef( return undefined } - // Why: only proven remote-tracking refs get refresh status; slash-containing local branches (release/2026) must not fake a "not refreshed" warning. + // Only proven remote-tracking refs warrant refresh warnings, not slash-named local branches. const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length) const slashIndex = shortRemoteRef.indexOf('/') if (slashIndex <= 0) { @@ -71,7 +69,7 @@ export async function refreshLocalBaseRefForWorktreeCreate( const key = `${options.wslDistro ?? ''}\0${normalizeRuntimePathForComparison(repoPath)}\0${parsed.fullRef}` const git = localBaseBranchGit(options) const outcome = await waitAtMost( - // Why: the run can outlive this create's wait, so it clears git read caches itself when it moves main. + // The shared run may outlive its create, so it owns cache invalidation. runPerLocalBaseBranch(key, remoteTrackingRef, () => runWithGitReadCacheInvalidation(() => fastForwardLocalBaseBranch(git, { repoPath, fullRef: parsed.fullRef, remoteTrackingRef }) @@ -109,7 +107,7 @@ export async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( } function localBaseBranchGit(options: GitWorktreeExecOptions): LocalBaseBranchGit { - // Why: the run is shared by every create that joins it, so no one create's abort signal or timeout may cut it short. + // Shared refreshes cannot use any individual create's cancellation signal or timeout. const execOptions = (cwd: string) => ({ cwd, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts index 2acb0dbff11..53c45c9753b 100644 --- a/src/main/git/worktree-branch-removal.ts +++ b/src/main/git/worktree-branch-removal.ts @@ -21,8 +21,7 @@ export async function deleteBranchAfterWorktreeRemoval( options: RemoveWorktreeOptions ): Promise { try { - // Why: also drop the now-orphaned branch so delete-worktree leaves none; `-d` (not `-D`) preserves - // unmerged work, and forceBranchDelete opts into `-D` for failed-creation rollback. + // -d protects unmerged commits; force is reserved for failed-creation rollback. const branchDeleteResult = await deleteLocalBranchAfterWorktreeRemoval( repoPath, branchName, @@ -47,7 +46,7 @@ export async function deleteBranchAfterWorktreeRemoval( return {} } } catch (alreadyMergedDeleteError) { - // Why: worktree is already gone; a raced branch cleanup should degrade to preserved-branch recovery, not fail delete. + // The worktree is gone; recover with a preserved branch if cleanup raced. console.warn( `[git] Failed to delete already-merged local branch "${branchName}" after removing worktree`, alreadyMergedDeleteError @@ -83,7 +82,7 @@ async function deleteLocalBranchAfterWorktreeRemoval( } try { - // Why: only pay for `worktree prune` when a stale admin record may be blocking `branch -d`. + // Prune only when stale admin data may block branch deletion. await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)) } catch (error) { console.warn(`[git] Failed to prune worktrees before deleting branch "${branchName}"`, error) @@ -121,7 +120,7 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( return execOptions?.discardWrittenObjects ? quarantine.run(run) : run(undefined) } const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) - // Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes. + // Squash merges change OIDs; delete only after proving no unmerged tree changes. const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution( { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, (capabilities) => @@ -161,10 +160,9 @@ export async function forceDeleteLocalBranch( if (await isLocalBranchCheckedOut(repoPath, branchName, runGit, options)) { throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) } - // Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead. + // Compare-and-delete prevents stale actions from deleting a branch that moved. try { - // `update-ref -d` needs the packed-refs lock a running idle pack holds while - // it rewrites; waits it out rather than cancelling the pack. + // Wait out packed-refs rewrites instead of killing the pack. await withRepoRefMaintenancePaused('branch-delete', () => runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) ) @@ -190,9 +188,7 @@ export async function forceDeleteLocalBranch( } try { await runGit(['config', '--remove-section', `branch.${branchName}`], repoPath) - } catch { - // Best-effort parity with `git branch -D`; stale config is harmless. - } + } catch {} } async function isLocalBranchCheckedOut( diff --git a/src/main/git/worktree-checkout-progress-real-git.test.ts b/src/main/git/worktree-checkout-progress-real-git.test.ts index f783b3205d0..8da208eb8dd 100644 --- a/src/main/git/worktree-checkout-progress-real-git.test.ts +++ b/src/main/git/worktree-checkout-progress-real-git.test.ts @@ -49,7 +49,7 @@ async function captureFailure( describe('checkout progress from a real `git worktree add`', () => { it('reports rising progress through 100% and then the end of the checkout', async () => { - // Why: without it git waits ~1 s before its first record, longer than this checkout. + // Emit progress before this small checkout finishes, bypassing Git's initial delay. vi.stubEnv('GIT_PROGRESS_DELAY', '0') const reports: (WorktreeCheckoutProgress | null)[] = [] @@ -78,7 +78,6 @@ describe('checkout progress from a real `git worktree add`', () => { const unobserved = await captureFailure(() => addWorktree(repo, occupied, 'blocked', 'main', false, false) ) - // Same branch name both times, so the texts are comparable byte for byte. await gitExecFileAsync(['branch', '-D', 'blocked'], { cwd: repo }).catch(() => {}) const observed = await captureFailure(() => addWorktree(repo, occupied, 'blocked', 'main', false, false, { onCheckoutProgress }) @@ -101,7 +100,6 @@ describe('checkout progress from a real `git worktree add`', () => { onCheckoutProgress }) - // The first throw detaches the observer; git's checkout is unaffected. expect(onCheckoutProgress).toHaveBeenCalledTimes(1) expect(warn).toHaveBeenCalledWith( 'git stderr observer failed; detaching it', diff --git a/src/main/git/worktree-checkout-progress.ts b/src/main/git/worktree-checkout-progress.ts index b2f92d9959d..6310bcfa191 100644 --- a/src/main/git/worktree-checkout-progress.ts +++ b/src/main/git/worktree-checkout-progress.ts @@ -10,11 +10,7 @@ export type WorktreeCheckoutProgressReader = { close: () => void } -/** - * Turns the `Updating files` meter that `git worktree add` already writes to - * stderr into progress reports. Reports synchronously with no timer, so nothing - * can report after the process ends. - */ +/** Parse Git's existing Updating files meter synchronously; close stops further reports. */ export function createWorktreeCheckoutProgressReader( onProgress: WorktreeCheckoutProgressListener ): WorktreeCheckoutProgressReader { @@ -29,9 +25,7 @@ export function createWorktreeCheckoutProgressReader( onProgress(null) return } - // Why: git prints only on a percent change plus at most one same-percent - // reprint after each one-second tick; increases only drops that reprint, - // so reports stay ~100. + // Suppress same-percent timer reprints to keep reports near 100. if (record.percent <= lastPercent) { return } diff --git a/src/main/git/worktree-common-dir-comparison.test.ts b/src/main/git/worktree-common-dir-comparison.test.ts index 40628a4da55..8a6083875c4 100644 --- a/src/main/git/worktree-common-dir-comparison.test.ts +++ b/src/main/git/worktree-common-dir-comparison.test.ts @@ -1,4 +1,3 @@ -// The common-dir check decides whether a create is confirmed, so a false accept adopts a foreign repo. import { describe, expect, it } from 'vitest' import { areWorktreePathsEqual } from './worktree-path-comparison' @@ -31,7 +30,6 @@ describe('comparing git common dirs', () => { expect(areWorktreePathsEqual('/repo/.GIT', '/repo/.git', 'linux')).toBe(false) }) - // macOS git reports a repo under /tmp by its /private/tmp realpath; it is the same object store. it('treats /tmp and /private/tmp as one directory on macOS only', () => { expect(areWorktreePathsEqual('/private/tmp/r/.git', '/tmp/r/.git', 'darwin')).toBe(true) expect(areWorktreePathsEqual('/private/tmp/r/.git', '/tmp/r/.git', 'linux')).toBe(false) diff --git a/src/main/git/worktree-create-admission-tier.test.ts b/src/main/git/worktree-create-admission-tier.test.ts index 84bcce00237..8289689dc89 100644 --- a/src/main/git/worktree-create-admission-tier.test.ts +++ b/src/main/git/worktree-create-admission-tier.test.ts @@ -22,7 +22,6 @@ import { finalizePreparedWorktree } from './worktree-create-preparation' const HEAD = 'a'.repeat(40) -/** Options every call to `git` carried, keyed by the subcommand the args name. */ function optionsForCommand(match: string): Record[] { return gitExecFileAsyncMock.mock.calls .filter((call) => call[0].join(' ').includes(match)) diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index c736a1774f0..eed42a543dc 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -641,7 +641,6 @@ describe('prepared worktree creation with real Git', () => { git(repoPath, ['commit', '--quiet', '-m', 'local main']) const localMainHead = git(repoPath, ['rev-parse', 'HEAD']) - // A remote-tracking `main` that diverged: different content, an extra file, and one deletion. git(repoPath, ['checkout', '--quiet', '-b', 'upstream-main']) await writeFile(join(repoPath, 'version.txt'), 'two\n') await writeFile(join(repoPath, 'only-upstream.txt'), 'upstream\n') @@ -672,7 +671,6 @@ describe('prepared worktree creation with real Git', () => { ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead) - // A retarget that left stale files behind would be a wrong checkout, not just a slow one. expect(git(finalPath, ['status', '--porcelain'])).toBe('') expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( 'one\n' diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index 96c9a6e054d..7744ceb8b22 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -71,7 +71,7 @@ export async function prepareWorktreeCreateCheckout( materializationBase = stdout.trim() await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) } - // Why: reset materializes files without running user post-checkout hooks before submit. + // Materialize with reset to defer user post-checkout hooks until submission. await gitExecFileAsync( [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', materializationBase], { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } @@ -127,7 +127,6 @@ export async function unlockPreparedWorktree( } export type FinalizedPreparedWorktree = AddWorktreeResult & { - /** The prepared checkout was not already at the requested commit, so it was reset onto it. */ preparedHeadReset: boolean } @@ -200,7 +199,7 @@ export async function finalizePreparedWorktree( try { try { await verifyOwnership() - // Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25). + // Git >= 2.25 needs two force flags to move a locked preparation without losing its lock reason. await gitExecFileAsync( [ ...windowsLongPathGitArgs(repoPath), @@ -265,7 +264,7 @@ export async function finalizePreparedWorktree( await baseContext.pendingLocalBaseRefRefresh throw error } - // Why: the refresh overlapped the finalize above; it has no bearing on the checkout's content. + // Advisory refresh may settle after finalization; it does not determine checkout contents. const localBaseRefRefresh = await baseContext.pendingLocalBaseRefRefresh return { preparedHeadReset, diff --git a/src/main/git/worktree-created-description-real-git.test.ts b/src/main/git/worktree-created-description-real-git.test.ts index e54da7e4783..cda6261e119 100644 --- a/src/main/git/worktree-created-description-real-git.test.ts +++ b/src/main/git/worktree-created-description-real-git.test.ts @@ -1,5 +1,4 @@ -// Real-binary coverage for the create-verification fallback (#16520): the mocked-runner suite cannot -// prove that the row rebuilt from `rev-parse`/`symbolic-ref` is the row `git worktree list` reports. +// Real Git verifies that fallback description matches worktree list output. import { execFile } from 'node:child_process' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -102,8 +101,7 @@ describe('describeCreatedWorktree against the real Git binary', () => { const linkedRepo = join(scratchDir, 'repo-link') await execFileAsync('ln', ['-s', repoPath, linkedRepo]) - // Why this case: comparing a single common-dir reading rejected every symlinked root, leaving - // the recovery inert exactly where the listing is most likely to have been the only witness. + // Canonicalize both common dirs so symlinked repo roots can confirm ownership. await expect( describeCreatedWorktree(linkedRepo, worktreePath, 'feature') ).resolves.toMatchObject({ branch: 'refs/heads/feature' }) @@ -119,8 +117,7 @@ describe('describeCreatedWorktree against the real Git binary', () => { const stalledDotGit = join(stalledRepo, '.git') await execFileAsync('mkfifo', [stalledDotGit]) try { - // Rejecting, not resolving undefined: undefined becomes a bare "created worktree not found", - // which claims Git put the worktree somewhere else. A stalled mount proves no such thing. + // Unread witnesses prove neither a foreign repo nor an absent worktree; retain the diagnostic. const settledBy = Date.now() + 5_000 await expect( describeCreatedWorktree(stalledRepo, worktreePath, 'feature', { timeout: 250 }) @@ -139,16 +136,14 @@ describe('describeCreatedWorktree against the real Git binary', () => { const bareWorktree = join(scratchDir, 'workspaces', 'from-bare') await git(['worktree', 'add', '-q', bareWorktree, '-b', 'from-bare'], bareRepo) - // Why this case: a bare repo has no `.git` file to walk, so the filesystem reading is absent - // and only Git's own `--git-common-dir` can confirm the worktree belongs to this repo. + // Bare repos lack .git markers, so only Git can confirm common-dir ownership. await expect( describeCreatedWorktree(bareRepo, bareWorktree, 'from-bare') ).resolves.toMatchObject({ branch: 'refs/heads/from-bare', isMainWorktree: false }) }) it('recovers on the Git 2.25 baseline, which ignores --path-format=absolute', async () => { - // Forces the no-`--path-format` fallback against the real binary: it answers with paths Git has - // not made absolute, so the readings only agree once they are canonicalized. + // Force relative-path capability fallback against the real binary. getLocalGitCapabilityCache({ cwd: repoPath }).rememberUnsupported('rev-parse-path-format') const listed = (await listWorktreesStrict(repoPath)).find( (worktree) => worktree.branch === 'refs/heads/feature' @@ -167,9 +162,7 @@ describe('describeCreatedWorktree against the real Git binary', () => { await execFileAsync('ln', ['-s', repoPath, linkedRepo]) getLocalGitCapabilityCache({ cwd: linkedRepo }).rememberUnsupported('rev-parse-path-format') - // Why this pair: without `--path-format=absolute` Git answers `.git`, which resolves against the - // symlink spelling the caller passed, while the worktree answers with the real root. Only - // canonicalizing both makes them the same object store. + // Canonicalize caller symlink and real-root spellings before comparing object stores. await expect( describeCreatedWorktree(linkedRepo, worktreePath, 'feature') ).resolves.toMatchObject({ branch: 'refs/heads/feature' }) diff --git a/src/main/git/worktree-created-disk-witness.test.ts b/src/main/git/worktree-created-disk-witness.test.ts index 1e0405a9bff..085404f70a2 100644 --- a/src/main/git/worktree-created-disk-witness.test.ts +++ b/src/main/git/worktree-created-disk-witness.test.ts @@ -28,14 +28,14 @@ const readRepoCommonDirFromGitMock = vi.mocked(readRepoCommonDirFromGit) const readCheckedOutBranchRefMock = vi.mocked(readCheckedOutBranchRef) const readWorktreeHeadOidMock = vi.mocked(readWorktreeHeadOid) -/** Repo convention: root bypasses the mode bits, so `chmod 000` denies nothing there. */ +// Root bypasses mode bits; chmod cannot simulate denied reads there. const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 let scratchDir = '' let repoPath = '' let worktreePath = '' -/** realpath: the witness canonicalizes, and macOS `tmpdir()` is a symlink (`/var` -> `/private/var`). */ +// Canonicalize macOS /var-to-/private/var temp paths just as the witness does. beforeEach(() => { scratchDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-created-witness-'))) repoPath = join(scratchDir, 'repo') @@ -43,11 +43,9 @@ beforeEach(() => { mkdirSync(repoPath, { recursive: true }) readRepoLocationMock.mockResolvedValue({ topLevel: worktreePath, - // Deliberately not the repo's store, so every case below reaches the disk witness. commonDir: join(scratchDir, 'elsewhere', '.git'), gitDir: join(scratchDir, 'elsewhere', '.git', 'worktrees', 'feature') }) - // Git's own reading disagrees; only the witness can break the tie. readRepoCommonDirFromGitMock.mockResolvedValue(join(scratchDir, 'other-repo', '.git')) readCheckedOutBranchRefMock.mockResolvedValue('refs/heads/feature') readWorktreeHeadOidMock.mockResolvedValue('a'.repeat(40)) @@ -61,7 +59,6 @@ afterEach(() => { describe('describeCreatedWorktree when Git and the repo disagree', () => { it('reports nothing when the witness proves a different object store', async () => { - // A real `.git` file pointing somewhere else: the worktree genuinely is not this repo's. const otherGitDir = join(scratchDir, 'other-repo', '.git') mkdirSync(otherGitDir, { recursive: true }) writeFileSync(join(repoPath, '.git'), `gitdir: ${otherGitDir}\n`) @@ -71,7 +68,6 @@ describe('describeCreatedWorktree when Git and the repo disagree', () => { }) it('throws when the .git marker points at a path that does not exist', async () => { - // Nothing is there to prove a store either way: a fabricated candidate would decide the create. writeFileSync(join(repoPath, '.git'), `gitdir: ${join(scratchDir, 'gone', '.git')}\n`) await expect(describeCreatedWorktree(repoPath, worktreePath, 'feature')).rejects.toMatchObject({ message: expect.stringContaining('gitdir marker target unreadable') @@ -88,14 +84,12 @@ describe('describeCreatedWorktree when Git and the repo disagree', () => { }) it('reports nothing for a bare repo, whose missing .git is a real answer', async () => { - // No `.git` at all is definitive absence, not an unreadable witness. await expect( describeCreatedWorktree(repoPath, worktreePath, 'feature') ).resolves.toBeUndefined() }) it('reports nothing when .git is a path under a file, not a directory', async () => { - // ENOTDIR, the other spelling of absence: `repo` is a file, so `repo/.git` cannot exist. const filePath = join(scratchDir, 'plain-file') writeFileSync(filePath, 'not a repo\n') await expect( @@ -132,9 +126,7 @@ describe('describeCreatedWorktree when Git and the repo disagree', () => { }) }) - // The other unverifiable branch -- the deadline firing on a `.git` that never answers -- needs a - // read that really blocks, so it lives in worktree-created-description-real-git.test.ts behind a - // fifo. A short timeout here would only race the filesystem. + // Blocking-witness deadline coverage uses a real FIFO in worktree-created-description-real-git.test.ts. it('accepts the create when the witness agrees with the worktree', async () => { const commonDir = join(repoPath, '.git') @@ -165,7 +157,6 @@ describe('describeCreatedWorktree before the witness is reached', () => { gitDir: join(commonDir, 'worktrees', 'feature') }) readRepoCommonDirFromGitMock.mockResolvedValue(commonDir) - // chmod 000 would make the witness unverifiable; agreement means it is never opened. if (CAN_DENY_READ) { chmodSync(repoPath, 0o000) } @@ -178,8 +169,7 @@ describe('describeCreatedWorktree before the witness is reached', () => { it('reports nothing when Git could not confirm the worktree at all', async () => { readRepoLocationMock.mockResolvedValue(undefined) - // An unconfirmed worktree is not an unverifiable common dir: resolving undefined under a repo - // whose witness cannot be read is how we know the witness was never consulted. + // Inaccessible witnesses distinguish no consultation from a failed witness read. if (CAN_DENY_READ) { chmodSync(repoPath, 0o000) } diff --git a/src/main/git/worktree-delete-limit.ts b/src/main/git/worktree-delete-limit.ts index 0304f6efe00..462ef4575b0 100644 --- a/src/main/git/worktree-delete-limit.ts +++ b/src/main/git/worktree-delete-limit.ts @@ -1,10 +1,4 @@ -/** - * Deletes `git worktree remove` runs at once on this host. - * - * Why its own limit instead of general git admission: a large delete holds its child for 20-35 s, - * and general admission can be as small as two slots, so two deletes there would stall every - * status read. Two concurrent deletes already saturate one disk; more only slow each other. - */ +// Use a separate two-delete queue: long disk-bound removals must not occupy general Git read slots. export const WORKTREE_DELETE_CONCURRENCY = 2 let running = 0 @@ -21,7 +15,7 @@ export async function runUnderWorktreeDeleteLimit(operation: () => Promise } finally { const next = waiting.shift() if (next) { - // Why: hand the slot over directly so a new arrival cannot overtake a queued delete. + // Hand off directly so new arrivals cannot overtake queued deletes. next() } else { running -= 1 diff --git a/src/main/git/worktree-diff-stamp-guest-gitdir.test.ts b/src/main/git/worktree-diff-stamp-guest-gitdir.test.ts index ba0308b0a12..bb4e99bff88 100644 --- a/src/main/git/worktree-diff-stamp-guest-gitdir.test.ts +++ b/src/main/git/worktree-diff-stamp-guest-gitdir.test.ts @@ -12,9 +12,7 @@ import { readWorktreeDiffStamp } from './source-control/worktree-diff-stamp' const slashed = (value: unknown): string => String(value).replaceAll('\\', '/') const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) -// A worktree on the Windows drive whose repo lives in the distro's own filesystem: the worktree -// resolves to a drive letter, so the gitdir pointer beside it — which is not drvfs — has no -// drive to derive and needs the distro the diff read already carries. +// A drive-spelled worktree cannot encode the distro needed by its non-drvfs gitdir pointer. const HOST_WORKTREE = 'C:/wt/x' const GUEST_GIT_DIR = '/home/me/repo/.git/worktrees/x' const HOST_GIT_DIR = '//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/x' @@ -28,7 +26,6 @@ describe('readWorktreeDiffStamp with a non-drvfs gitdir pointer', () => { if (value === `${HOST_WORKTREE}/.git`) { return `gitdir: ${GUEST_GIT_DIR}\n` } - // Detached HEAD, so the stamp needs no ref-store walk. if (value === `${HOST_GIT_DIR}/HEAD`) { return `${'a'.repeat(40)}\n` } @@ -49,8 +46,6 @@ describe('readWorktreeDiffStamp with a non-drvfs gitdir pointer', () => { wslDistro: 'Ubuntu' }) - // Null here means "cannot prove unchanged", which is what an unreadable HEAD produces — - // correct, but it retires the settled-diff cache for every file in the worktree. expect(stamp).not.toBeNull() expect(stamp?.newestMtimeMs).toBe(1_000) } finally { diff --git a/src/main/git/worktree-diff-stamp-host-paths.test.ts b/src/main/git/worktree-diff-stamp-host-paths.test.ts index 03fefe2dcf5..37414672494 100644 --- a/src/main/git/worktree-diff-stamp-host-paths.test.ts +++ b/src/main/git/worktree-diff-stamp-host-paths.test.ts @@ -15,9 +15,7 @@ import { readWorktreeDiffStamp } from './source-control/worktree-diff-stamp' const slashed = (value: unknown): string => String(value).replaceAll('\\', '/') const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) -// Why this file: git in a WSL distro reports the worktree as `/mnt/c/...`, and the gitdir resolve -// reaches it on its own. If the working-tree read did not travel the same way, the stamp would be -// built from a real HEAD and a permanently absent file — a settled diff that survives every edit. +// Stamp working files in host spelling too; an always-missing guest path would make edits invisible. describe('readWorktreeDiffStamp on a drvfs-spelled WSL worktree', () => { beforeEach(() => { readFileMock.mockReset() @@ -27,7 +25,6 @@ describe('readWorktreeDiffStamp on a drvfs-spelled WSL worktree', () => { if (value === 'C:/repo/wt/.git') { return 'gitdir: /mnt/c/repo/.git/worktrees/wt\n' } - // Detached HEAD, so the stamp needs no ref-store walk. if (value === 'C:/repo/.git/worktrees/wt/HEAD') { return `${'a'.repeat(40)}\n` } @@ -50,7 +47,6 @@ describe('readWorktreeDiffStamp on a drvfs-spelled WSL worktree', () => { expect(statMock.mock.calls.map(([target]) => slashed(target))).toContain( 'C:/repo/wt/src/a.ts' ) - // The working-tree component is what moves when the user edits, so it has to be present. expect(stamp?.newestMtimeMs).toBe(1_000) } finally { platformSpy.mockRestore() diff --git a/src/main/git/worktree-graph-listing.test.ts b/src/main/git/worktree-graph-listing.test.ts index 8a54b2beb5b..99928c92119 100644 --- a/src/main/git/worktree-graph-listing.test.ts +++ b/src/main/git/worktree-graph-listing.test.ts @@ -1,4 +1,3 @@ -// listWorktreeGraph: -z porcelain listing plus the older-Git usage-error fallback. import { beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( @@ -100,8 +99,7 @@ branch refs/heads/main-2 branch: 'refs/heads/main-2', isBare: false, isMainWorktree: false, - // The line-block fallback also probes linked worktree paths for - // existence (issue #8389), and this mocked path is absent on disk. + // The legacy listing probes existence; this mocked linked path is absent. prunable: true } ]) @@ -116,8 +114,6 @@ branch refs/heads/main-2 }) it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => { - // Git under a non-English locale translates the usage text, so stderr - // matching alone misses it; the 129 exit code must still trigger fallback. gitExecFileAsyncMock .mockRejectedValueOnce( Object.assign(new Error('git worktree list --porcelain -z'), { @@ -152,8 +148,6 @@ branch refs/heads/main }) it('does not retry without -z for non-usage Git failures', async () => { - // A fatal error (exit 128) is not an unsupported-flag signal, so the -z - // command must not be silently re-run without it. gitExecFileAsyncMock.mockRejectedValueOnce( Object.assign(new Error('git worktree list --porcelain -z'), { code: 128, diff --git a/src/main/git/worktree-include-file.test.ts b/src/main/git/worktree-include-file.test.ts index 98a743cd755..1fd2a7467ac 100644 --- a/src/main/git/worktree-include-file.test.ts +++ b/src/main/git/worktree-include-file.test.ts @@ -11,8 +11,7 @@ vi.mock('./runner', () => ({ const gitExecFileAsyncMock = vi.mocked(gitExecFileAsync) -/** check-ignore echoes back every stdin path present in `ignored` (all requested - * when unset); exit code 1 with empty stdout means "none ignored". */ +/** Unset input ignores every requested path; exit 1 with empty stdout means none ignored. */ function mockCheckIgnore(ignored?: string[]): void { gitExecFileAsyncMock.mockImplementation(async (args, execOptions) => { if (!args.includes('check-ignore')) { @@ -94,7 +93,6 @@ describe('resolveWorktreeIncludePaths', () => { writeFileSync(join(repo, '.env'), 'A=1') mockCheckIgnore(['.env']) - // node_modules absent (not installed yet) → not stat-able → not requested from git. await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env']) }) diff --git a/src/main/git/worktree-include-file.ts b/src/main/git/worktree-include-file.ts index edded1a1ba2..32f1b087970 100644 --- a/src/main/git/worktree-include-file.ts +++ b/src/main/git/worktree-include-file.ts @@ -4,27 +4,15 @@ import { checkIgnoredPaths } from './check-ignored-paths' import type { GitRuntimeOptions } from './git-runtime-options' import { mapWithConcurrency } from '../../shared/map-with-concurrency' -/** Project-level list of gitignored paths to copy into each new worktree. - * Cross-tool convention (see issue #7549). */ +/** Cross-tool convention for copying selected gitignored paths into new worktrees (#7549). */ export const WORKTREE_INCLUDE_FILE = '.worktreeinclude' -// Why: a fresh worktree misses gitignored files (.env, .vscode/, config -// secrets); a repo-root .worktreeinclude names the ones to carry over. - -// Why: this is the "safe for now" subset — literal files and directories only. -// Glob (`*`/`?`) and negation (`!`) lines are skipped with a warning rather than -// silently mishandled; they can be added later without changing this contract. const WORKTREE_INCLUDE_MAX_FILE_BYTES = 256 * 1024 -// Why: bound the work a single repo file can request; entries beyond this are ignored. const WORKTREE_INCLUDE_MAX_ENTRIES = 1000 -// Why: include files can name hundreds of independent paths; bound the local -// stat fan-out while avoiding one serial filesystem round trip per entry. +// Bound stat fan-out without serializing independent paths. const WORKTREE_INCLUDE_PATH_STAT_CONCURRENCY = 8 -/** Parse `.worktreeinclude` into deduped, repo-root-relative literal paths. - * Blank lines and `#` comments are skipped; `\` is normalized to `/`, a `./` - * prefix and trailing `/` are stripped. Each entry is anchored to the repo - * root (no implicit match-at-any-depth). */ +/** Entries are literal paths anchored to the repo root, without implicit depth matching. */ export function parseWorktreeIncludeFile(content: string): string[] { const seen = new Set() const entries: string[] = [] @@ -68,15 +56,7 @@ async function readWorktreeIncludeFile(repoPath: string): Promise } } -/** Resolve `.worktreeinclude` at the repo root to concrete repo-relative paths - * to copy into a new worktree. - * - * Only paths that exist in the primary checkout **and** are gitignored are - * returned — tracked files are already present in a fresh worktree, and - * copying untracked-but-unignored files would create spurious diffs. - * - * Never throws: any read/parse/git failure resolves to `[]` so worktree - * creation is never blocked by this file. */ +/** Copy only existing, gitignored paths; failures return [] so inclusion cannot block creation. */ export async function resolveWorktreeIncludePaths( repoPath: string, options: GitRuntimeOptions = {} @@ -96,7 +76,6 @@ export async function resolveWorktreeIncludePaths( break } if (isUnsupportedPattern(entry)) { - // Glob and negation are not supported yet; skip loudly so the entry isn't silently mis-copied. console.warn( `[worktree-include] Skipping unsupported ${WORKTREE_INCLUDE_FILE} pattern "${entry}" (only literal files and directories are supported)` ) @@ -112,9 +91,6 @@ export async function resolveWorktreeIncludePaths( return [] } - // Keep only entries present in the primary checkout — a listed but absent - // path (e.g. node_modules before install) has nothing to copy. The mapper - // retains candidate order for deterministic git-ignore input and output. const existence = await mapWithConcurrency( candidates, WORKTREE_INCLUDE_PATH_STAT_CONCURRENCY, @@ -123,7 +99,6 @@ export async function resolveWorktreeIncludePaths( await lstat(join(repoPath, relativePath)) return relativePath } catch { - // Absent in the primary checkout — nothing to copy. return null } } @@ -135,8 +110,7 @@ export async function resolveWorktreeIncludePaths( return [] } - // Why: enforce the gitignored-only contract (issue #7549) — never duplicate - // tracked files or surface unignored ones as spurious worktree diffs. + // Copying unignored paths would introduce spurious worktree diffs. const ignored = new Set(await checkIgnoredPaths(repoPath, existing, options)) return existing.filter((relativePath) => ignored.has(relativePath)).sort() } catch (error) { diff --git a/src/main/git/worktree-list-paths.test.ts b/src/main/git/worktree-list-paths.test.ts index b74b2b6f61b..ef4213fc90a 100644 --- a/src/main/git/worktree-list-paths.test.ts +++ b/src/main/git/worktree-list-paths.test.ts @@ -129,8 +129,6 @@ describe('git worktree paths', () => { ) it('annotates a worktree whose directory was deleted as prunable', async () => { - // Why: an un-pruned registration with a deleted directory must not surface - // as a live workspace (issue #8389). const { repoPath, worktreePath } = await createRepoWithPrunableWorktree() const worktrees = await listWorktrees(repoPath) diff --git a/src/main/git/worktree-list-porcelain.test.ts b/src/main/git/worktree-list-porcelain.test.ts index fb95bdcd7dd..b4c7d4bc232 100644 --- a/src/main/git/worktree-list-porcelain.test.ts +++ b/src/main/git/worktree-list-porcelain.test.ts @@ -92,9 +92,7 @@ describe('listWorktrees', () => { isMainWorktree: false } ]) - // Why: the non-sparse main worktree gets an fs probe of its sparse config - // file; the linked worktree short-circuits on the parsed `sparse` token and - // does not. Only one git subprocess runs regardless of worktree count. + // The parsed sparse token skips its filesystem probe; only the main worktree needs one. expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) expect(statMock).toHaveBeenCalledTimes(1) expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) @@ -231,16 +229,12 @@ describe('listWorktrees', () => { isMainWorktree: false } ]) - // The second argument is what carries the distro when the caller has one; this listing has - // none, and the UNC repo path names the distro on its own. + // The UNC repo path carries the distro without an explicit option. const gitDirCall = resolveGitDirMock.mock.calls.find( ([probed]) => probed === featureWorktreePath ) expect(gitDirCall).toBeDefined() expect(gitDirCall?.[1]?.wslDistro).toBeUndefined() - // Why: the detection path must not spawn a git subprocess per worktree — - // the perf regression in #1131 came from `git sparse-checkout list` firing - // on every poll. expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) }) @@ -284,8 +278,7 @@ describe('listWorktrees', () => { } expect(pendingProbeResolves).toHaveLength(8) - // Why: each probe may chain extra microtasks after stat (e.g. core.sparseCheckout - // config reads). Drain until the list settles, not a fixed microtask budget. + // Drain until settlement: probes may chain additional microtasks after stat. for (let attempt = 0; !completed && attempt < 100; attempt += 1) { pendingProbeResolves.splice(0).forEach((resolve) => resolve()) await Promise.resolve() @@ -317,8 +310,7 @@ describe('listWorktrees', () => { 'worktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' } }) - // Why: the fallback probes each linked worktree path for existence; keep - // the paths "present" so this test stays about parser selection. + // Keep linked paths present to isolate parser selection from stale-registration checks. statMock.mockImplementation(async (targetPath: string) => { if (String(targetPath).endsWith('sparse-checkout')) { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -349,9 +341,7 @@ describe('listWorktrees', () => { }) it('annotates missing linked worktrees as prunable via the line-block fallback', async () => { - // Why: Git <2.36 lacks the `prunable` porcelain field (issue #8389), so - // the fallback must probe each linked worktree path instead of treating a - // stale registration as a live workspace. + // Older Git omits prunable fields, so fallback must verify linked paths on disk. mockGitCommands({ 'git worktree list --porcelain -z': { error: Object.assign(new Error("unknown switch `z'"), { @@ -365,15 +355,13 @@ describe('listWorktrees', () => { 'worktree /repo-locked\nHEAD aaa789\nbranch refs/heads/agent\nlocked agent session\n' } }) - // statMock default (beforeEach): every path is missing (ENOENT). const worktrees = await listWorktrees('/repo') expect(worktrees.find((worktree) => worktree.path === '/repo-feature')).toMatchObject({ prunable: true }) - // Locked registrations are shielded, mirroring git's own prunable rules; - // the main worktree is covered by the repo-level missing-path handling. + // Locks shield missing registrations; repo-level handling covers the main worktree. expect(worktrees.find((worktree) => worktree.path === '/repo-locked')?.prunable).toBeUndefined() expect(worktrees.find((worktree) => worktree.path === '/repo')?.prunable).toBeUndefined() }) diff --git a/src/main/git/worktree-list-reader.ts b/src/main/git/worktree-list-reader.ts index d6bf5c2b2a3..2d9900fb9ba 100644 --- a/src/main/git/worktree-list-reader.ts +++ b/src/main/git/worktree-list-reader.ts @@ -28,8 +28,7 @@ const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 type RepoLocation = { topLevel: string; commonDir: string; gitDir: string } function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined { - // Old git echoes the unrecognized `--path-format` flag and exits 0, so drop `-`-prefixed lines and - // read the last three path lines (toplevel, git-common-dir, git-dir); strip only trailing CR — paths may have edge spaces. + // Older Git echoes unknown flags with exit 0; keep the last three path lines and preserve edge spaces. const lines = output .split('\n') .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) @@ -68,7 +67,7 @@ export async function readRepoLocation( gitExecOptions(repoPath, options) ) if (hasUnsupportedRevParsePathFormatEcho(stdout)) { - // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. + // Cache the unsupported-option signal even when parsing recovers. capabilities.rememberUnsupported('rev-parse-path-format') } return parseRepoLocation(resolveBasePath, stdout) @@ -88,14 +87,7 @@ export async function readRepoLocation( } } -/** - * The repo's common dir as Git reports it. - * - * Why `--git-common-dir` alone: adding `--show-toplevel` makes rev-parse fail outright on a bare repo. - * Why the Git-space base: Git < 2.31 (the 2.25 baseline) ignores `--path-format=absolute` and prints a - * relative `.git` at a main worktree root; resolving that against a UNC repoPath would compare a - * Windows path against the worktree-side Linux answer, leaving the create's recovery inert (#16520). - */ +/** Bare repos reject --show-toplevel; resolve older Git's relative common dir in Git execution space. */ export async function readRepoCommonDirFromGit( repoPath: string, options: GitWorktreeExecOptions = {} @@ -139,7 +131,7 @@ export async function readRepoCommonDirFromGit( } } -/** The branch ref checked out in `worktreePath`, or undefined when HEAD is detached. */ +/** Detached HEAD returns undefined. */ export async function readCheckedOutBranchRef( worktreePath: string, options: GitWorktreeExecOptions = {} @@ -177,8 +169,7 @@ async function normalizeMainWorktreePath( ): Promise { const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree) const mainWorktree = worktrees[mainIndex] - // Why: under WSL, porcelain/rev-parse paths are Linux but repoPath is UNC; compare in Git-output - // space so the early-return matches and we skip a needless rev-parse per poll (runner still gets repoPath). + // Compare WSL output and repo paths in the same execution space to avoid a needless rev-parse. const comparablePath = toWslExecutionSpace(repoPath) if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) { return worktrees @@ -189,13 +180,11 @@ async function normalizeMainWorktreePath( return worktrees } - // Why: only a separate-git-dir/submodule main worktree reports git-common-dir as its path; gate on - // that equality so we don't overwrite a linked worktree's real working root with its own toplevel. + // Only separate-git-dir/submodule main rows need their common-dir path replaced with the toplevel. if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) { return worktrees } - // Why: a linked worktree of a bare/separate-git-dir repo passes the gate above too, but its toplevel - // is its own folder; relabelling would give the main row that folder's path and repeat it (#23631). + // A linked caller's toplevel is its own folder; using it would duplicate the main row. if (!areWorktreePathsEqual(location.gitDir, location.commonDir)) { return worktrees } @@ -231,7 +220,7 @@ export async function readWorktreeList( ) }, async () => { - // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. + // Git <2.36 rejects -z, which preserves embedded newlines in paths. const { stdout } = await gitExecFileAsync( ['worktree', 'list', '--porcelain'], execOptions @@ -241,8 +230,7 @@ export async function readWorktreeList( parseWorktreeList(stdout), options ) - // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting - // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). + // Git <2.31 omits prunable fields; verify linked paths rather than trusting stale registrations. return annotatePrunableByExistence( await annotateWorktreeLocksFromAdmin(repoPath, normalized, options), repoPath, @@ -268,7 +256,7 @@ async function annotatePrunableByExistence( const index = nextIndex nextIndex += 1 const worktree = worktrees[index] - // Git only prunes linked worktrees, never locked ones (a lock shields a missing directory). A missing main worktree is handled by the repo-level ENOENT paths. + // Locked registrations are never prunable; missing main paths are handled at repo level. if ( !worktree || worktree.isMainWorktree || diff --git a/src/main/git/worktree-listing-created-sparse-distro.test.ts b/src/main/git/worktree-listing-created-sparse-distro.test.ts index ab0326dcee2..f647f2c38dd 100644 --- a/src/main/git/worktree-listing-created-sparse-distro.test.ts +++ b/src/main/git/worktree-listing-created-sparse-distro.test.ts @@ -23,7 +23,7 @@ vi.mock('node:fs/promises', () => ({ realpath: realpathMock, stat: statMock })) -// Only Git is stubbed; the sparse probe below runs for real so the distro has somewhere to matter. +// Keep the sparse probe real so namespace routing is exercised. vi.mock('./worktree-list-reader', () => ({ readCheckedOutBranchRef: readCheckedOutBranchRefMock, readRepoCommonDirFromGit: readRepoCommonDirFromGitMock, @@ -38,10 +38,7 @@ import { describeCreatedWorktree } from './worktree-listing' const slashed = (value: unknown): string => String(value).replaceAll('\\', '/') const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) -// The layout that needs the caller's distro: the repo lives in the distro, its worktrees on the -// Windows drive. `git worktree list` reports `/mnt/c/wt/x`, which translates to a drive letter that -// no longer names a distro, and the gitfile beside it points at a guest path with no drive to -// derive — so only the distro the create ran under can resolve it. +// A drive-spelled checkout loses the distro needed to resolve its non-drvfs gitdir pointer. const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' const GUEST_WORKTREE = '/mnt/c/wt/x' const HOST_WORKTREE = 'C:/wt/x' @@ -70,8 +67,7 @@ describe('describeCreatedWorktree on a drvfs-spelled WSL worktree', () => { if (value === `${HOST_WORKTREE}/.git`) { return `gitdir: ${GUEST_GIT_DIR}\n` } - // No `commondir`, so the gitdir is its own common dir and the config read stays in one - // namespace — the pointer resolve is the only thing under test. + // Omit commondir to isolate pointer resolution from common-dir traversal. if (value === `${HOST_GIT_DIR}/config`) { return '[core]\n\tsparseCheckout = true\n' } diff --git a/src/main/git/worktree-listing-sparse-distro.test.ts b/src/main/git/worktree-listing-sparse-distro.test.ts index d08a7245e24..de28c01e224 100644 --- a/src/main/git/worktree-listing-sparse-distro.test.ts +++ b/src/main/git/worktree-listing-sparse-distro.test.ts @@ -24,8 +24,7 @@ vi.mock('./worktree-list-reader', () => ({ import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache' import { listWorktreesStrict, listWorktreesUnshared } from './worktree-listing' -// A WSL repo's sparse probe is pure `fs`, so it only reaches the right namespace if the listing -// hands it the distro the git call already ran under. +// Filesystem sparse probes need the same distro as the Git listing. const ROW: GitWorktreeInfo = { path: 'C:\\wt\\x', head: 'a'.repeat(40), @@ -42,7 +41,7 @@ describe('worktree listing sparse annotation', () => { readWorktreeListMock.mockResolvedValue([ROW]) readTranslatedWorktreeGraphMock.mockReset() readTranslatedWorktreeGraphMock.mockResolvedValue([ROW]) - // The listing now reads through a repo-scoped cache; a warm entry would skip the probe. + // A warm sparse cache would bypass the probe under test. __resetSparseCheckoutStateCacheForTests() }) diff --git a/src/main/git/worktree-listing.ts b/src/main/git/worktree-listing.ts index 4063df14748..906745545d3 100644 --- a/src/main/git/worktree-listing.ts +++ b/src/main/git/worktree-listing.ts @@ -59,16 +59,12 @@ export async function listWorktreesUnshared( if (await isTrueEmptyWorktreeListing(repoPath, err)) { return [] } - // Why: don't swallow git-compat/repo-state failures — else they resurface as opaque "created but not found in listing" errors. console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, err) return [] } } -/** - * The two failures where an empty listing is the repo's true answer, not a broken scan: the repo - * path is gone, or it is not a Git repo. Every other failure means the scan could not read Git. - */ +/** Only a missing path or confirmed non-repo permits an authoritative empty listing. */ async function isTrueEmptyWorktreeListing(repoPath: string, err: unknown): Promise { if (getErrorCode(err) === 'ENOENT') { try { @@ -97,14 +93,7 @@ export async function listWorktreesStrict( return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options) } -/** - * Strict except for the two true empties above. - * - * Why: a Git or host failure (dead WSL distro, hung mount) softened to `[]` reaches the detected - * listing as an *authoritative* empty scan, which then permanently prunes the repo's worktrees and - * the agent tabs attached to them. Rejecting keeps that listing non-authoritative, while a deleted - * repo still reports empty so real removals prune. - */ +/** Reject scan failures to prevent pruning valid worktrees and tabs; confirmed missing/non-repos return []. */ export async function listWorktreesStrictAllowingTrueEmpty( repoPath: string, options: GitWorktreeExecOptions = {} @@ -144,7 +133,6 @@ export async function annotateSparseCheckoutStatus( } } - // Why: cap concurrency so status-poll refreshes don't fan out many sparse-checkout filesystem probes at once. const workerCount = Math.min(SPARSE_CHECKOUT_DETECTION_CONCURRENCY, worktrees.length) await waitForPromiseWithSignal( Promise.all(Array.from({ length: workerCount }, () => detectNext())), @@ -154,14 +142,7 @@ export async function annotateSparseCheckoutStatus( return annotated } -/** - * The repo's common dir from the filesystem, as a second opinion on Git's own reading. - * - * Deadlined because a `.git` on a hung mount (dead NFS/SSHFS, stalled WSL 9p) never rejects, and an - * unbounded read here would leave the whole create IPC pending instead of failing like it used to. - * - * A missing `.git` is a real "no candidate"; every other read failure is unverifiable and rejects. - */ +/** Bound the disk witness on hung mounts; absent .git returns undefined, other read failures reject. */ async function readRepoCommonDirFromDisk( repoPath: string, timeoutMs: number @@ -201,17 +182,13 @@ async function resolveRepoCommonDirFromDisk( return readCommonDirMarker(gitDir) } -/** - * A marker target that is missing or is not a directory is unverifiable, not an absent `.git`: - * without this, `commondir`'s own ENOENT/ENOTDIR would pass as absence and hand the caller the - * pointer target as a common dir it never proved exists. - */ +/** Reject unreadable marker targets before commondir absence can be mistaken for a bare repo. */ async function assertGitDirIsDirectory(gitDir: string): Promise { let gitDirStats try { gitDirStats = await stat(gitDir) } catch (error) { - // Rewrapped so the outer absence check cannot read this errno as a bare repo's missing `.git`. + // Hide this errno from the outer missing-.git check. throw new Error(`gitdir marker target unreadable: ${gitDir}`, { cause: error }) } if (!gitDirStats.isDirectory()) { @@ -249,18 +226,12 @@ async function canonicalizeLocalPath(pathValue: string): Promise { try { return await realpath(pathValue) } catch { - // A path Git reported from another execution space (WSL, SSH) has no local inode. + // Paths from another execution space may have no local inode. return pathValue } } -/** - * Whether the created worktree's common dir names the same object store as the repo. - * - * Why accept any candidate: the readings come from different spaces — Git resolves symlinks and, - * under WSL, answers in Linux paths, while the filesystem walk keeps the caller's (possibly UNC) - * spelling. Comparing only one rejected every symlinked-root, WSL, and bare-repo create (#16520). - */ +/** Git and disk witnesses may differ through symlinks or WSL path spelling; accept any matching candidate. */ async function isSameRepoCommonDir( createdCommonDir: string, candidates: readonly (string | undefined)[] @@ -275,12 +246,7 @@ async function isSameRepoCommonDir( return canonicalCandidates.some((candidate) => areWorktreePathsEqual(canonicalCreated, candidate)) } -/** - * Reconstruct the listing row for a worktree `git worktree add` just created, by asking Git about - * the worktree itself. Used when the listing fails or omits it, so a create does not abandon a - * worktree Git already wrote to disk (#16520). Returns undefined unless Git resolves the path into - * this repo's object store with the expected branch checked out. - */ +/** Recover an omitted listing row only when Git confirms the expected branch, HEAD and repo object store. */ export async function describeCreatedWorktree( repoPath: string, worktreePath: string, @@ -288,7 +254,7 @@ export async function describeCreatedWorktree( options: GitWorktreeExecOptions = {} ): Promise { const expectedRef = `refs/heads/${normalizeLocalBranchRef(branch)}` - // Bound Git recovery after the bounded listing failed; filesystem canonicalization stays best effort. + // Bound Git recovery separately; filesystem canonicalization remains best effort. const deadlined: GitWorktreeExecOptions = { ...options, timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS @@ -299,13 +265,11 @@ export async function describeCreatedWorktree( readCheckedOutBranchRef(worktreePath, deadlined), readWorktreeHeadOid(worktreePath, deadlined) ]) - // An unreadable HEAD means Git could not confirm the worktree, so report nothing rather than a blank OID. if (!created || checkedOutRef !== expectedRef || !head) { return undefined } if (!(await isSameRepoCommonDir(created.commonDir, [repoGitCommonDir]))) { - // Only now read the second opinion from disk: a `.git` on a hung mount pins a threadpool thread - // that no deadline can reclaim, so never pay that on the path where Git already agreed. + // Defer disk witnesses: a hung mount can pin a threadpool thread even after the deadline. const repoDiskCommonDir = await readRepoCommonDirFromDisk( repoPath, deadlined.timeout ?? WORKTREE_LIST_TIMEOUT_MS @@ -322,7 +286,6 @@ export async function describeCreatedWorktree( head, branch: expectedRef, isBare: false, - // `git worktree add` only ever produces a linked worktree. isMainWorktree: false } ], diff --git a/src/main/git/worktree-move.test.ts b/src/main/git/worktree-move.test.ts index 2832639c1f1..ccf0113f562 100644 --- a/src/main/git/worktree-move.test.ts +++ b/src/main/git/worktree-move.test.ts @@ -1,4 +1,3 @@ -// moveWorktree: relocating a checkout via `git worktree move`. import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/git/worktree-move.ts b/src/main/git/worktree-move.ts index efb7bb7157c..ad92717b231 100644 --- a/src/main/git/worktree-move.ts +++ b/src/main/git/worktree-move.ts @@ -4,12 +4,7 @@ import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git import { bumpWorktreeScanGeneration } from './worktree-scan-cache' import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' -/** - * Move a worktree with `git worktree move` (not `fs.rename`, which corrupts the - * `.git` file and the `.git/worktrees//gitdir` back-pointer). Local-only, - * so there is no relay parity handler. Caller owns migrating Orca's - * path-derived worktree identity and pre-checks that the destination is free. - */ +/** Git repairs marker back-pointers during a move; callers migrate Orca identity and check the destination. */ export async function moveWorktree( repoPath: string, oldPath: string, diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index 6b76727f8b2..591cb578d3c 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -50,34 +50,27 @@ export type RemoveWorktreeOptions = GitWorktreeExecOptions & { checkoutDeleteSignal?: AbortSignal } -// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696). -// Doubles as the floor for ORCA_WORKTREE_ADD_TIMEOUT_MS — lowering it to fail faster also lowers the minimum any override can request. +// Bound stalled checkouts; this default also sets the floor for ORCA_WORKTREE_ADD_TIMEOUT_MS. export const WORKTREE_ADD_TIMEOUT_MS = 180_000 -// Why: ceiling for ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696) — ~8x the slowest reported checkout (3.5 min). The cost is that a genuine stall now blocks a create for up to 30 min instead of 3. +// Allow slow content filters while bounding overrides to 30 minutes. export const WORKTREE_ADD_TIMEOUT_MAX_MS = 30 * 60_000 export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000 export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000 -// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list. +// A wedged shared scan would otherwise stall every later listing. export const WORKTREE_LIST_TIMEOUT_MS = 30_000 -/** - * `ORCA_WORKTREE_ADD_TIMEOUT_MS` clamped into [{@link WORKTREE_ADD_TIMEOUT_MS}, - * {@link WORKTREE_ADD_TIMEOUT_MAX_MS}]; unset, blank, or unparseable yields the default. - * Warns when a non-blank value is rejected or clamped; trimming and fractional truncation are silent. - * `env` is injectable for tests. - */ +/** Clamp ORCA_WORKTREE_ADD_TIMEOUT_MS; warn for nonblank rejected/clamped values, not fractional truncation. */ export function resolveWorktreeAddTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.ORCA_WORKTREE_ADD_TIMEOUT_MS?.trim() const requested = Math.floor(Number(raw)) - // Why: `=300` reads as seconds to most operators, so clamp rather than obey. + // Guard against values entered in seconds rather than milliseconds. const resolved = Number.isNaN(requested) ? WORKTREE_ADD_TIMEOUT_MS : Math.min(Math.max(requested, WORKTREE_ADD_TIMEOUT_MS), WORKTREE_ADD_TIMEOUT_MAX_MS) - // Why: an `isNaN` guard here would delete the unparseable-value warning — comparing against NaN is unequal, and that is what catches it. + // NaN compares unequal, so malformed nonblank values also warn. if (raw && resolved !== requested) { const problem = Number.isNaN(requested) - ? // Why: `600_000` copied out of this file is NaN, not out of range — say which. - 'is not a number' + ? 'is not a number' : `is outside [${WORKTREE_ADD_TIMEOUT_MS}, ${WORKTREE_ADD_TIMEOUT_MAX_MS}]ms` console.warn( `[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="${raw}" ${problem}; using ${resolved}ms` diff --git a/src/main/git/worktree-path-comparison.ts b/src/main/git/worktree-path-comparison.ts index 67d6ad49148..ace9f110cb3 100644 --- a/src/main/git/worktree-path-comparison.ts +++ b/src/main/git/worktree-path-comparison.ts @@ -4,22 +4,7 @@ import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' import type { GitWorktreeExecOptions } from './worktree-operation-options' import { translateWslOutputPaths } from './runner' -/** - * Normalize a worktree path for cross-platform comparison/keying: resolved, and case-folded on - * Windows syntax. - * - * Why the path's own syntax outranks `platform`: whose filesystem a path names is a property of the - * path, not of the desktop reading it, and folding a case-sensitive filesystem merges two real - * checkouts into one row — enough for `removeWorktree` to pick the twin and delete its branch. - * - * Two syntaxes name a case-sensitive filesystem. A POSIX-absolute path is one. The other is the WSL - * UNC alias, which is the shape that actually reaches removal: `listWorktreesStrict` runs every - * listed path through `translateWorktreePath`, so git-in-the-distro's `/home/alice/Feature` arrives - * as `\\wsl.localhost\Ubuntu\home\alice\Feature` and a plain `toLowerCase` folded the ext4 tail. - * `foldWslUncPathCaseInsensitiveParts` already draws that line — Windows folds the share, the distro - * and a drvfs `/mnt/` tail, and nothing else — and `git-fetch-head-lock` already relies on - * it. - */ +/** Path syntax determines case folding: preserve POSIX and WSL ext4 tails even on Windows to avoid merging checkouts. */ export function canonicalWorktreePath(pathValue: string, platform = process.platform): string { if (looksLikePosixAbsolutePath(pathValue)) { return canonicalPosixPath(pathValue, platform) @@ -33,7 +18,7 @@ export function canonicalWorktreePath(pathValue: string, platform = process.plat : canonicalPosixPath(pathValue, platform) } -// Why: macOS git reports a `/tmp` checkout as `/private/tmp`, the same directory. +// macOS Git reports /tmp checkouts as /private/tmp. function canonicalPosixPath(pathValue: string, platform: NodeJS.Platform): string { const normalized = posix.normalize(posix.resolve(pathValue)) if (platform !== 'darwin') { @@ -45,19 +30,12 @@ function canonicalPosixPath(pathValue: string, platform: NodeJS.Platform): strin return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized } -/** - * The comparison key for a WSL UNC path, or null when it is not one. - * - * Normalized through `win32` first so `..`/`.` segments and slash style collapse, then folded only - * where Windows really folds. The fold is unconditional on platform: a `\\wsl.localhost\...` string - * names the same distro filesystem whichever desktop is reading it. - */ +/** Normalize WSL UNC paths on every host, folding only the Windows-insensitive parts. */ function wslUncComparisonKey(pathValue: string): string | null { const folded = foldWslUncPathCaseInsensitiveParts(pathValue) if (!folded) { return null } - // A trailing separator names the same directory, as `resolve` already treats it elsewhere. return (foldWslUncPathCaseInsensitiveParts(win32.normalize(pathValue)) ?? folded).replace( /\/+$/, '' @@ -71,8 +49,7 @@ export function areWorktreePathsEqual( ): boolean { const leftIsPosix = looksLikePosixAbsolutePath(leftPath) if (leftIsPosix || looksLikePosixAbsolutePath(rightPath)) { - // Why not fall through: `win32.resolve` gives a POSIX path a drive root, manufacturing an - // equality with a Windows path that names a different filesystem. + // win32.resolve would attach a drive to POSIX paths and manufacture cross-filesystem equality. return ( leftIsPosix && looksLikePosixAbsolutePath(rightPath) && @@ -93,8 +70,7 @@ export function dedupeWorktreesByPath( worktrees: readonly T[], platform = process.platform ): T[] { - // Why: large Git/relay listings key each absolute path once, keeping the first row under - // `areWorktreePathsEqual`; only relative rows, whose flavor depends on the peer, compare pairwise. + // Key absolute paths once; only host-dependent relative paths require pairwise comparison. const unique: T[] = [] const posixKeys = new Set() const windowsKeys = new Set() diff --git a/src/main/git/worktree-path-deduplication.test.ts b/src/main/git/worktree-path-deduplication.test.ts index fdc5716606a..9a0a20752d2 100644 --- a/src/main/git/worktree-path-deduplication.test.ts +++ b/src/main/git/worktree-path-deduplication.test.ts @@ -47,7 +47,6 @@ describe('dedupeWorktreesByPath', () => { } ) - // An ext4 tail is case-sensitive: folding it merged two real checkouts into one row. it.each(['darwin', 'linux', 'win32'])( 'keeps WSL worktrees that differ only in tail case on %s', (platform) => { diff --git a/src/main/git/worktree-porcelain-parsing.test.ts b/src/main/git/worktree-porcelain-parsing.test.ts index f9009077f6c..98488bde974 100644 --- a/src/main/git/worktree-porcelain-parsing.test.ts +++ b/src/main/git/worktree-porcelain-parsing.test.ts @@ -1,4 +1,3 @@ -// parseWorktreeList: porcelain block parsing, lock/prunable markers, NUL-delimited output. import { describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( diff --git a/src/main/git/worktree-posix-path-case-sensitivity.test.ts b/src/main/git/worktree-posix-path-case-sensitivity.test.ts index 3c96acbadc3..dc27f68da89 100644 --- a/src/main/git/worktree-posix-path-case-sensitivity.test.ts +++ b/src/main/git/worktree-posix-path-case-sensitivity.test.ts @@ -1,13 +1,4 @@ -/** - * A case-sensitive filesystem stays case-sensitive whichever desktop is reading it. - * `git/worktree-path-comparison` decided case-folding from `process.platform`, so on Windows both - * spellings of a WSL checkout — the Linux one and the `\\wsl.localhost\...` alias the listing - * actually produces — folded, and two distinct checkouts read as one row. - * - * The removal suite mocks `translateWslOutputPaths` to identity. That mock is what hid this: in - * production `listWorktreesStrict` always runs the listing through it, so the paths that reach the - * comparison are UNC, never Linux. The end-to-end case below therefore drives the real translator. - */ +// Exercise the real Linux-to-UNC translator: an identity mock would hide ext4-tail case folding. import type * as FsPromises from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslPathTranslation from './command-runner/wsl-path-translation' @@ -21,8 +12,6 @@ const { gitExecFileAsyncMock, gitExecFileSyncMock, statMock, readFileMock, resol resolveGitDirMock: vi.fn() })) -// Why the real translator: an identity mock removes the Linux -> UNC rewrite that production -// always applies, which is the only reason the Linux spelling would ever reach the comparison. vi.mock('./runner', async () => { const translation = await vi.importActual( './command-runner/wsl-path-translation' @@ -142,8 +131,6 @@ describe('removeWorktree branch selection on a Windows desktop', () => { }) it('deletes the branch of the requested WSL worktree, not its case twin', async () => { - // Git-in-the-distro answers in Linux paths; the real translator rewrites them to UNC on the way - // out, so this is the exact listing the comparison sees on a Windows desktop. const listing = `worktree /home/alice/repo HEAD aaa111 branch refs/heads/main diff --git a/src/main/git/worktree-preparation-cancel-latency.bench.test.ts b/src/main/git/worktree-preparation-cancel-latency.bench.test.ts index c5f30b4788b..579d608115f 100644 --- a/src/main/git/worktree-preparation-cancel-latency.bench.test.ts +++ b/src/main/git/worktree-preparation-cancel-latency.bench.test.ts @@ -1,8 +1,6 @@ // Opt in: ORCA_WORKTREE_PREPARATION_CANCEL_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/git/worktree-preparation-cancel-latency.bench.test.ts // -// Measures the create-side cost of an obsolete preparation: the wall time of a fresh checkout -// (the next Create's critical path) while an evicted preparation's checkout is either left running -// (main before #18951) or aborted (after). Same code, same fixture; only the abort differs. +// Compare fresh-checkout latency while obsolete preparations keep running or are aborted. import { execFileSync } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' @@ -72,8 +70,7 @@ async function runTrial(variant: Variant, obsolete: number): Promise { checkout(path, controllers[index].signal).catch(() => {}) ) if (variant === 'aborted') { - // Eviction aborts in the same turn the incoming preparation is armed, so abort before the - // fresh checkout starts. + // Model eviction aborting before the next checkout starts. controllers.forEach((controller) => controller.abort()) } const freshPath = nextPreparedPath('fresh') @@ -123,7 +120,7 @@ describeBench('obsolete preparation cancellation latency', () => { }) it('reports fresh checkout wall time with obsolete checkouts running vs aborted', async () => { - // Warm the object store and page cache once so the first variant is not penalised. + // Warm the cache so the first variant is not penalized. const warm = nextPreparedPath('warm') await checkout(warm) await discard(warm) diff --git a/src/main/git/worktree-preparation-discard.ts b/src/main/git/worktree-preparation-discard.ts index 10d3c790c1f..902f098fc86 100644 --- a/src/main/git/worktree-preparation-discard.ts +++ b/src/main/git/worktree-preparation-discard.ts @@ -24,7 +24,7 @@ export function gitCleanupOptions( cwd: string, options: GitWorktreeExecOptions ): GitExecOptionsForWorktree { - // Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately. + // Cancellation must not strand a partial move; cleanup has its own bound. return gitExecOptions(cwd, { ...options, signal: undefined }) } diff --git a/src/main/git/worktree-removal-large-tree.bench.test.ts b/src/main/git/worktree-removal-large-tree.bench.test.ts index 9ea2aa3cf3d..10a791bd6ec 100644 --- a/src/main/git/worktree-removal-large-tree.bench.test.ts +++ b/src/main/git/worktree-removal-large-tree.bench.test.ts @@ -1,7 +1,5 @@ import { installRealWorkerThreadChildProcesses } from '../worker-thread-child-process/worker-thread-child-process-real.test-fixtures' -// Manual benchmark: how long removing a large checkout takes, and how slow a store-style write gets -// meanwhile. Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 ./node_modules/.bin/vitest \ -// run --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts +// Opt in (~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 pnpm test src/main/git/worktree-removal-large-tree.bench.test.ts import { execFile } from 'node:child_process' import { appendFileSync, existsSync, statSync } from 'node:fs' import { link, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises' @@ -64,8 +62,7 @@ describeBench('worktree removal on a large checkout', () => { }) it('reports removal time and store-write latency during the delete', async () => { - // Outside hard links drop to one link only once the checkout's copies are gone, wherever the - // delete runs, so the window covers the whole delete rather than just the call. + // Hard-link counts bound the actual delete window, independently of where the delete runs. const sentinelRoot = join(scratchDir, 'sentinels') await mkdir(sentinelRoot) const sentinels: string[] = [] @@ -78,7 +75,7 @@ describeBench('worktree removal on a large checkout', () => { const storePath = join(scratchDir, 'store.json') await writeFile(storePath, Buffer.alloc(STORE_FILE_BYTES, 1)) - // Mirrors a persisted-store write: read, write a temp file, fsync it, rename it into place. + // Model a persisted-store transaction, including fsync and atomic replacement. const storeTransaction = async (): Promise => { const startedAt = performance.now() const contents = await readFile(storePath) diff --git a/src/main/git/worktree-removal-preflight.ts b/src/main/git/worktree-removal-preflight.ts index 64ea39f8991..26a6a5629af 100644 --- a/src/main/git/worktree-removal-preflight.ts +++ b/src/main/git/worktree-removal-preflight.ts @@ -5,9 +5,6 @@ import { gitExecFileAsync } from './runner' import type { WorktreeRemovalPreflightOptions } from './worktree-operation-options' import { WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS, gitExecOptions } from './worktree-operation-options' -/** - * Assert a worktree is clean enough for non-force removal. - */ export async function assertWorktreeCleanForRemoval( worktreePath: string, force = false, @@ -25,7 +22,7 @@ export async function assertWorktreeCleanForRemoval( '--porcelain', ...(useNullTerminatedStatus ? ['-z'] : []), '--untracked-files=all', - // Why: a user's ignore-submodules setting would hide submodule edits from this dirty check. + // User ignore-submodules config must not hide edits from the dirty check. '--ignore-submodules=none' ], { @@ -33,8 +30,7 @@ export async function assertWorktreeCleanForRemoval( timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } ) - // Why one parse feeds both: the clean verdict and the error text must never - // disagree about which entries block removal. + // Use the same entries for the verdict and the error message. const blockingEntries = useNullTerminatedStatus ? getBlockingUntrackedStatusEntries(stdout, ignoredUntrackedPaths) : null @@ -44,16 +40,11 @@ export async function assertWorktreeCleanForRemoval( const blockingStatus = blockingEntries ? blockingEntries.join('\n') : stdout if (await hasPopulatedSubmodule(worktreePath, gitOptions)) { - // Why: Force Delete also destroys the submodules' never-pushed commits, which the parent's - // status cannot show, so the refusal must say submodules. Status lines go after the detail - // because the formatter prefers stdout over the message. + // Force Delete also destroys submodule-only commits; put that detail before stdout for the formatter. throw new Error(`${SUBMODULE_DIRTY_REMOVAL_DETAIL}\n${blockingStatus.trim()}`) } - // Why not the raw stdout: `-z` output is NUL-delimited and `.trim()` leaves - // interior NULs, so attaching it verbatim put raw control bytes into the - // user-facing removal error — and listed the tolerated shared link, the one - // entry that is not the user's work and cannot be committed away. + // Render filtered entries without raw NULs or tolerated shared links. throw Object.assign(new Error('Worktree has uncommitted or untracked changes.'), { stdout: blockingStatus }) @@ -91,8 +82,7 @@ async function hasPopulatedSubmodule( } } -/** The `git status --porcelain -z` entries that genuinely block removal: - * everything except the untracked shared links the caller tolerates. */ +/** Exclude only the untracked shared links tolerated by the caller. */ function getBlockingUntrackedStatusEntries( status: string, ignoredUntrackedPaths: readonly string[] diff --git a/src/main/git/worktree-removal-real-git.test.ts b/src/main/git/worktree-removal-real-git.test.ts index e126e593953..93fb6d01ed3 100644 --- a/src/main/git/worktree-removal-real-git.test.ts +++ b/src/main/git/worktree-removal-real-git.test.ts @@ -1,5 +1,4 @@ -// Real-binary coverage for worktree removal: the mocked-runner suite cannot prove what Git deletes, -// deregisters and refuses, or that deleting a checkout leaves Node's file pool free. +// Real Git verifies deletion/refusal behavior and file-pool responsiveness beyond mocked argv. import { execFile } from 'node:child_process' import { existsSync, statSync } from 'node:fs' import { link, mkdir, mkdtemp, readFile, realpath, stat, writeFile } from 'node:fs/promises' @@ -32,7 +31,7 @@ async function git(args: string[], cwd: string): Promise { return stdout } -// Why parsed: Git prints forward slashes on Windows, so raw text never contains a joined path. +// Git prints forward slashes on Windows, so compare parsed paths. async function isRegistered(path: string): Promise { return (await listWorktreesStrict(repoPath)).some((worktree) => areWorktreePathsEqual(worktree.path, path) @@ -40,8 +39,7 @@ async function isRegistered(path: string): Promise { } beforeEach(async () => { - // realpath: macOS hands out /var/... temp paths while Git reports /private/var/..., and Orca - // matches the worktree it is removing against Git's own list. + // Canonicalize macOS /var temp paths to match Git's /private/var output. scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-removal-'))) repoPath = join(scratchDir, 'repo') workspaceRoot = join(scratchDir, 'workspaces') @@ -51,7 +49,7 @@ beforeEach(async () => { await git(['init', '-q'], repoPath) await git(['config', 'user.email', 'removal@example.invalid'], repoPath) await git(['config', 'user.name', 'Worktree Removal'], repoPath) - // Why: a commit's detached auto-maintenance can still be writing packs when teardown deletes the repo. + // Disable detached maintenance that could race fixture teardown. await git(['config', 'maintenance.auto', 'false'], repoPath) await git(['config', 'gc.auto', '0'], repoPath) await writeFile(join(repoPath, 'seed.txt'), 'seed\n') @@ -100,7 +98,6 @@ describe('worktree removal against the real Git binary', () => { expect(await isRegistered(worktreePath)).toBe(true) }) - // A checkout whose initialized submodule holds a commit only it has; the parent does not record it. async function addSubmoduleCheckoutWithLocalCommit(): Promise<{ subWorktreePath: string vendorPath: string @@ -129,8 +126,7 @@ describe('worktree removal against the real Git binary', () => { } it('refuses a clean checkout with a submodule and offers Force Delete for it', async () => { - // Why: --force would delete submodule commits that exist only in this checkout, so Git's - // refusal must reach the user as a Force Delete choice rather than be retried away. + // Force deletion can destroy submodule-only commits; preserve Git's refusal. const { subWorktreePath, vendorPath, localCommit } = await addSubmoduleCheckoutWithLocalCommit() // Recorded by the parent so its status stays clean. await git(['commit', '-qam', 'bump vendor'], subWorktreePath) @@ -148,7 +144,7 @@ describe('worktree removal against the real Git binary', () => { }) it('names submodules when the dirty check refuses a checkout that has them', async () => { - // Why: the parent's status shows only its own file, yet Force Delete destroys the submodule commit. + // Parent status cannot show the submodule-only commit that Force Delete would destroy. const { subWorktreePath } = await addSubmoduleCheckoutWithLocalCommit() await git(['commit', '-qam', 'bump vendor'], subWorktreePath) await writeFile(join(subWorktreePath, 'notes.txt'), 'draft\n') @@ -250,8 +246,7 @@ describe('worktree removal and the Node file pool', () => { } await git(['add', '-A'], worktreePath) await git(['commit', '-qm', 'bulk'], worktreePath) - // Outside hard links drop to one link only once the checkout's copies are gone, wherever the - // delete runs, so the window below covers the whole delete rather than just the call. + // Hard-link counts bound the actual delete window independently of the call's lifetime. const sentinelRoot = join(scratchDir, 'sentinels') await mkdir(sentinelRoot) const sentinels: string[] = [] diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 7c1a01a58aa..0bc5a4d9128 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -20,21 +20,16 @@ import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' const branchCleanupQueueByRepo = new Map>() -/** - * Remove a worktree. - */ export async function removeWorktree( repoPath: string, worktreePath: string, force = false, - // forceBranchDelete: for failed-creation rollback (fresh branch, no user work); user deletes leave it false so unmerged commits survive. + // Reserve forceBranchDelete for failed-creation rollback so user deletes preserve unmerged commits. options: RemoveWorktreeOptions = {} ): Promise { options = removalWorkerOptions(options) try { - // Removal deletes branches, and a ref deletion needs the packed-refs lock a - // running idle pack holds while it rewrites. Waits that window out; the - // prune phase that follows it is concurrency-safe and is left to finish. + // Wait out ref packing before deleting branches; concurrent pruning can finish safely. return await withRepoRefMaintenancePaused('worktree-remove', () => runWithGitReadCacheInvalidation(() => performRemoveWorktree(repoPath, worktreePath, force, options) @@ -61,13 +56,10 @@ async function performRemoveWorktree( const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') const branchHead = removedWorktree?.head ?? '' - // Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects. + // Enforce locks even for callers that bypass IPC/runtime preflight. assertWorktreeUnlockedForRemoval(removedWorktree) - // Why no timeout: this is a write, so none applies by default, and Git deletes the whole checkout - // here (prod p90 29 s); a deadline would kill a legitimate large delete halfway through. - // Why long paths: creation checks out with them on Windows, so deleting without them fails with - // "Filename too long" (#6433) and leaves the branch behind via the Windows recovery. + // Avoid default deadlines during large deletes; preserve Windows long-path support used at creation. const longPathArgs = windowsLongPathGitArgs(repoPath) const execOptions = { ...gitExecOptions(repoPath, options), @@ -101,10 +93,7 @@ function deleteBranchOfRemovedWorktree( branchHead: string, options: RemoveWorktreeOptions ): Promise { - // Why its own span: branch cleanup can reach the network, so a stall here reads as - // `git worktree remove` being slow unless it is timed separately. - // Why serialized per repo: concurrent removals in one repo race `packed-refs.lock` and the - // remote-tracking ref locks of `fetch` (#2259); the checkout deletes above need not wait. + // Time network-capable branch cleanup separately and serialize per repo to avoid ref-lock races. return runKeyedSerializedOperation(branchCleanupQueueByRepo, repoPath, () => withSpan('worktree.remove.branch_delete', () => deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options) @@ -112,11 +101,7 @@ function deleteBranchOfRemovedWorktree( ) } -/** - * Finishes a removal whose checkout Git no longer registers and is gone from disk: stale admin - * records, then the branch. Deletes no files: Orca removes a checkout only through Git. - * `assertCheckoutGone` refuses while anything is still at the path. - */ +/** Prune stale registration and clean up the branch only after the caller verifies the checkout is gone. */ export async function finishUnregisteredWorktreeRemoval( repoPath: string, worktreePath: string, @@ -132,10 +117,7 @@ export async function finishUnregisteredWorktreeRemoval( return deleteBranchOfUnregisteredWorktree(repoPath, worktreePath, branch, options) } -/** - * The branch step of a removal once Git no longer registers the checkout, whatever is left on disk: - * merged only, as `removeWorktree` does. Prunes nothing and deletes no files. - */ +/** Clean up an unregistered checkout's branch using normal preservation rules, without deleting files. */ export async function deleteBranchOfUnregisteredWorktree( repoPath: string, worktreePath: string, @@ -177,7 +159,7 @@ async function localBranchExists( } } -// Why: Git for Windows runs $GIT_ASK_YESNO when a file stays locked mid-delete; no prompt program may run here. +// Git for Windows may invoke GIT_ASK_YESNO for locked files; removal must never launch a prompt. function removalGitEnv(): { env?: NodeJS.ProcessEnv } { const inherited = Object.keys(process.env).filter((key) => key.toUpperCase() === 'GIT_ASK_YESNO') if (inherited.length === 0) { @@ -190,13 +172,12 @@ function removalGitEnv(): { env?: NodeJS.ProcessEnv } { return { env } } -// Why: Git for Windows does not descend into junctions and exits 0 with them and their parent -// directories still on disk; finish the delete Git already accepted instead of leaving it behind. +// Git for Windows can exit 0 leaving junctions and parent directories; finish that accepted delete. async function removeCheckoutLeftByGit( worktreePath: string, options: RemoveWorktreeOptions ): Promise { - // Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not touch them. + // WSL-owned checkouts must be removed inside the distro. if (options.wslDistro || parseWslPath(worktreePath)) { return } diff --git a/src/main/git/worktree-remove-branch-deletion.test.ts b/src/main/git/worktree-remove-branch-deletion.test.ts index 6469d675e9f..b908c100e46 100644 --- a/src/main/git/worktree-remove-branch-deletion.test.ts +++ b/src/main/git/worktree-remove-branch-deletion.test.ts @@ -1,4 +1,3 @@ -// removeWorktree: branch deletion safety after the checkout is removed. import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( @@ -24,7 +23,7 @@ describe('removeWorktree', () => { const beforeRemoval = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' - // Why: removal argv carries core.longpaths on Windows; pin a non-Windows default for exact argv. + // Pin a non-Windows host to keep exact argv independent of core.longpaths. let platformSpy: MockInstance<() => NodeJS.Platform> beforeEach(() => { @@ -39,12 +38,10 @@ describe('removeWorktree', () => { }) it('uses safe `branch -d` and preserves a branch with unmerged commits', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove - // Git refuses to delete an unmerged branch with `-d`. - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) // branch -d + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) - // Should not throw — the unmerged branch is preserved, not force-deleted. await expect(removeWorktree('/repo', '/repo-feature', false)).resolves.toEqual({ preservedBranch: { branchName: 'feature/test', head: 'def456' } }) @@ -55,9 +52,9 @@ describe('removeWorktree', () => { }) it('deletes the branch when `branch -d` succeeds (fully merged)', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await removeWorktree('/repo', '/repo-feature', false) @@ -70,8 +67,8 @@ describe('removeWorktree', () => { }) it('reuses known removed worktree metadata instead of relisting before removal', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await removeWorktree('/repo', '/repo-feature', false, { knownRemovedWorktree: { @@ -87,13 +84,13 @@ describe('removeWorktree', () => { }) it('prunes and retries branch deletion only when Git reports a checked-out branch', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) gitExecFileAsyncMock.mockRejectedValueOnce( new Error("error: cannot delete branch 'feature/test' used by worktree at '/repo-stale'") - ) // branch -d hits stale worktree metadata - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree prune - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d retry succeeds + ) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) await expect(removeWorktree('/repo', '/repo-feature', false)).resolves.toEqual({}) diff --git a/src/main/git/worktree-scan-cache-annotation-reuse.test.ts b/src/main/git/worktree-scan-cache-annotation-reuse.test.ts index 0fabe5ba049..bfe11e6259c 100644 --- a/src/main/git/worktree-scan-cache-annotation-reuse.test.ts +++ b/src/main/git/worktree-scan-cache-annotation-reuse.test.ts @@ -1,5 +1,4 @@ -// The annotated listing is the graph listing plus a sparse probe: callers that read only -// `worktree.path` must skip the probe, without costing a second `git worktree list`. +// Probe-free graph readers share the Git listing without waiting for sparse annotation. import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo } from '../../shared/worktree/types' @@ -73,7 +72,6 @@ describe('graph and annotated worktree scans', () => { expect(annotatedRows[0]?.isSparse).toBe(true) }) - // Sharing the listing must not make the probe-free caller wait on the probe it opted out of. it('resolves a graph scan while the annotated scan is still probing', async () => { let releaseProbe!: () => void detectSparseCheckoutMock.mockImplementation( diff --git a/src/main/git/worktree-scan-cache-sharing.test.ts b/src/main/git/worktree-scan-cache-sharing.test.ts index eabe471a1c6..5b6dde77836 100644 --- a/src/main/git/worktree-scan-cache-sharing.test.ts +++ b/src/main/git/worktree-scan-cache-sharing.test.ts @@ -1,5 +1,4 @@ import { worktreeCreateGit } from './worktree-create-git-executor' -// Worktree scan sharing: in-flight coalescing and mutation-generation retirement. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( @@ -36,14 +35,12 @@ describe('listWorktrees in-flight sharing', () => { beforeEach(async () => { gitExecFileAsyncMock.mockReset() _resetWorktreeScanCacheForTests() - // Why: capability discovery serializes same-host callers; warming it lets - // these tests isolate the scan-generation overlap they are exercising. + // Warm capability discovery so its serialization cannot hide scan overlap. gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) await listWorktrees('/capability-warmup') gitExecFileAsyncMock.mockReset() }) - // Later describes in this file assume a pristine mock (no global reset). afterEach(() => { gitExecFileAsyncMock.mockReset() }) @@ -88,8 +85,6 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - // The annotated scan is the graph scan plus a sparse probe, so the two share one `git worktree - // list` and only the annotated caller pays the probe. They ran Git twice before. it('runs one git listing for concurrent graph and annotated scans', async () => { const resolvers: ((value: { stdout: string }) => void)[] = [] gitExecFileAsyncMock.mockImplementation( @@ -110,8 +105,7 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - // The create's listing is promoted to `interactive` precisely to skip the queue a status scan - // is already sitting in; joining that scan would hand it the wait back. + // Joining a queued status scan would defeat the create listing's interactive priority. it('does not let an interactive listing join a scan queued at another tier', async () => { const resolvers: ((value: { stdout: string }) => void)[] = [] gitExecFileAsyncMock.mockImplementation( @@ -132,7 +126,6 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - // Order must not matter: whichever runs first owns the listing and the other joins it. it('runs one git listing when the annotated scan starts first', async () => { const resolvers: ((value: { stdout: string }) => void)[] = [] gitExecFileAsyncMock.mockImplementation( @@ -211,9 +204,6 @@ describe('listWorktrees in-flight sharing', () => { expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) }) - // Why (#16520): create verification moved off the fail-soft listing so a Git failure stops being - // hidden behind "created but not found in listing". That must not cost the in-flight coalescing, - // and sharing must never hand a strict caller a softened result. it('coalesces concurrent strict scans for the same repo into one git call', async () => { let resolveScan!: () => void const scanOutput = 'worktree /repo\0HEAD abc123\0branch refs/heads/main\0\0' @@ -248,13 +238,11 @@ describe('listWorktrees in-flight sharing', () => { const settled = Promise.allSettled([first, second]) rejectScan(new Error('git timed out.')) - // A joiner silently receiving [] would be #16520 re-entering through the dedup path. expect((await settled).map((r) => r.status)).toEqual(['rejected', 'rejected']) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('never lets a strict caller join a fail-soft scan', async () => { - // Both variants reject; only the lenient one is allowed to soften that into []. gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.')) const lenient = listWorktrees('/repo') @@ -265,9 +253,7 @@ describe('listWorktrees in-flight sharing', () => { }) it('keeps listWorktreesStrict unshared for post-mutation verification', async () => { - // Why: a raw `git worktree prune` never bumps the scan generation, so a - // coalesced strict read could return the pre-prune row and report a - // successful removal as a stale registration. + // Raw prune does not bump generations; post-mutation verification needs a fresh subprocess. const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' const resolvers: (() => void)[] = [] gitExecFileAsyncMock.mockImplementation( @@ -484,8 +470,7 @@ describe('listWorktrees in-flight sharing', () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput }) const controller = new AbortController() - // Why: an aborted shared scan would reject for every caller, so signal - // callers must keep a private scan. + // An aborted shared scan would reject every joiner; signal callers require isolation. await Promise.all([ listWorktrees('/repo'), listWorktrees('/repo', { signal: controller.signal }) diff --git a/src/main/git/worktree-scan-cache.ts b/src/main/git/worktree-scan-cache.ts index 8027eafeddb..981d099a7ea 100644 --- a/src/main/git/worktree-scan-cache.ts +++ b/src/main/git/worktree-scan-cache.ts @@ -9,12 +9,10 @@ import type { GitWorktreeExecOptions } from './worktree-operation-options' import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options' import { resolveGitAdmissionTier } from './command-runner/git-operation-executor' -// Why: share concurrent `git worktree list` scans, which are expensive on Windows. const inFlightWorktreeScans = new Map>() type WorktreeScanKind = 'graph' | 'lenient' | 'strict' | 'strict-true-empty' -// Why: mutation generations prevent listings from joining stale scans. const worktreeScanGenerations = new Map() function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { @@ -28,7 +26,7 @@ function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { } export function bumpWorktreeScanGeneration(repoPath: string): void { - // Why: generations only prevent joining a pre-mutation scan; with no active scan, keeping the repo path just leaks completed mutation keys. + // Retain generations only while a pre-mutation scan can still be joined. if (!hasInFlightWorktreeScanForRepo(repoPath)) { return } @@ -36,7 +34,7 @@ export function bumpWorktreeScanGeneration(repoPath: string): void { } function pruneWorktreeScanGeneration(repoPath: string): void { - // Why: keep ordinary scan settlement O(1); only repos invalidated during an active scan need the cross-generation check. + // Avoid cross-generation scans on ordinary settlement. if (!worktreeScanGenerations.has(repoPath)) { return } @@ -57,13 +55,7 @@ export function _resetWorktreeScanCacheForTests(): void { worktreeScanGenerations.clear() } -/** - * Share one in-flight scan per (repo, distro, deadline, generation, kind). Coalescing keeps a - * sidebar refresh from spawning a second `git worktree list` (expensive on Windows), but only - * within one failure discipline: a strict joiner must never inherit a lenient scan's softened `[]`, - * so the strict and lenient runners scan separately by design. The explicit kind is intentional: - * function names can be rewritten by a production bundler and must not define cache identity. - */ +/** Explicit kinds preserve failure semantics and cache identity even when bundlers rename functions. */ function shareWorktreeScan( repoPath: string, options: GitWorktreeExecOptions, @@ -75,9 +67,7 @@ function shareWorktreeScan( } const generation = worktreeScanGenerations.get(repoPath) ?? 0 const timeout = options.timeout ?? WORKTREE_LIST_TIMEOUT_MS - // Why: callers with different deadlines cannot safely share which timeout wins the scan. - // Why `kind`: a strict joiner must never receive a softened `[]` from a lenient scan. - // Why the tier: an interactive listing joining a queued status scan inherits its wait. + // Separate deadlines, failure contracts and admission tiers so joiners cannot inherit weaker scans. const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${options.includeCreatePreparations === true}\0${generation}\0${kind}\0${resolveGitAdmissionTier(options.admissionTier)}` const inFlight = inFlightWorktreeScans.get(key) if (inFlight) { @@ -93,14 +83,7 @@ function shareWorktreeScan( return scan } -/** - * Sparse annotation layered over the shared graph scan rather than its own `git worktree list`. - * - * Both paths soften a Git failure to `[]`, so they can share one listing; only this one pays the - * per-worktree sparse probe. That lets a caller which reads just `worktree.path` skip the probes - * without costing a second subprocess when it overlaps a badge reader — the two ran Git twice - * before. Strict stays on its own scan because it must be able to reject. - */ +/** Add sparse annotations to the shared lenient graph scan without spawning another Git subprocess. */ async function runAnnotatedWorktreeScan( repoPath: string, options: GitWorktreeExecOptions @@ -109,11 +92,7 @@ async function runAnnotatedWorktreeScan( return annotateSparseCheckoutStatus(repoPath, worktrees, options) } -/** - * List all worktrees for a git repo at the given path. Concurrent calls for - * the same repo share one scan (unless the caller passes an AbortSignal, - * which must only cancel its own scan). Git failures soften to `[]`. - */ +/** Git failures return []; matching callers coalesce, while AbortSignal callers stay isolated. */ export function listWorktrees( repoPath: string, options: GitWorktreeExecOptions = {} @@ -121,10 +100,7 @@ export function listWorktrees( return shareWorktreeScan(repoPath, options, 'lenient', runAnnotatedWorktreeScan) } -/** - * List the worktree graph without sparse-checkout probes. Concurrent callers share the same - * generation-fenced scan, while callers with an AbortSignal retain an isolated subprocess. - */ +/** Skip sparse probes while retaining generation fencing and AbortSignal isolation. */ export function listWorktreeGraph( repoPath: string, options: GitWorktreeExecOptions = {} @@ -132,10 +108,7 @@ export function listWorktreeGraph( return shareWorktreeScan(repoPath, options, 'graph', listWorktreeGraphUnshared) } -/** - * `listWorktreesStrict` through the same in-flight map, so callers that must see a Git failure - * (worktree-create verification) still coalesce with a concurrent refresh (#16520). - */ +/** Coalesce strict reads separately so Git failures still reject. */ export function listWorktreesSharedStrict( repoPath: string, options: GitWorktreeExecOptions = {} @@ -143,11 +116,7 @@ export function listWorktreesSharedStrict( return shareWorktreeScan(repoPath, options, 'strict', listWorktreesStrictUnshared) } -/** - * The detected scan's discipline: reject a Git/host failure so it cannot publish as an - * authoritative empty listing, but still answer `[]` for a repo that is gone or not a repo. - * Its own kind because neither a strict nor a lenient joiner may inherit that middle contract. - */ +/** Isolate scans that reject failures but return [] for confirmed missing/non-repos. */ export function listWorktreesSharedStrictAllowingTrueEmpty( repoPath: string, options: GitWorktreeExecOptions = {} diff --git a/src/main/git/worktree-separate-git-dir.test.ts b/src/main/git/worktree-separate-git-dir.test.ts index e177580a24f..fa61818770d 100644 --- a/src/main/git/worktree-separate-git-dir.test.ts +++ b/src/main/git/worktree-separate-git-dir.test.ts @@ -5,9 +5,7 @@ import * as path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type * as GitRunner from './runner' -// Why: spy on the git runner so we can count rev-parse invocations while still -// running real git, proving the main-entry-equals-repoPath early return skips -// the extra subprocess for an ordinary repo scanned at its own root. +// Spy on real Git to count extra rev-parse calls without replacing path behavior. const revParseTopLevelCalls = { count: 0 } vi.mock('./runner', async () => { const actual = await vi.importActual('./runner') @@ -25,7 +23,6 @@ vi.mock('./runner', async () => { } }) -// Imported after vi.mock so worktree.ts binds to the spied runner. const { listWorktrees } = await import('./worktree') const tempRoots: string[] = [] @@ -133,9 +130,7 @@ describe('git worktree separate git dir paths', () => { it.skipIf(process.platform === 'win32')( 'leaves an ordinary repo reached via a symlinked path unchanged', async () => { - // A symlink alias of the checkout root defeats the path-string gate - // (git reports the realpath toplevel), so the git-common-dir gate must - // still skip the rewrite since the main entry is a real working root. + // A symlink defeats the path gate; the common-dir gate must still preserve the main row. const repoPath = await createNormalRepo() const linkRoot = await mkdtemp(path.join(tmpdir(), 'orca-symlink-worktree-')) tempRoots.push(linkRoot) @@ -155,11 +150,7 @@ describe('git worktree separate git dir paths', () => { it.skipIf(process.platform === 'win32')( 'leaves the main entry unchanged when scanned via a linked worktree', async () => { - // A linked worktree has a `.git` *pointer file* just like a - // separate-git-dir checkout, but its porcelain main entry is the real - // main working root — never the Git directory. The git-common-dir gate - // must skip the rewrite so the main entry is not overwritten with the - // linked worktree's own toplevel (which would collide / drop the main). + // Linked checkouts also use .git files; their toplevel must not replace the real main row. const repoPath = await createNormalRepo() const linkedWorktreePath = path.join(path.dirname(repoPath), 'linked') git(repoPath, ['worktree', 'add', '--quiet', linkedWorktreePath, '-b', 'feature']) @@ -172,7 +163,6 @@ describe('git worktree separate git dir paths', () => { path: repoPath, isMainWorktree: true }) - // The main entry must not be rewritten to the linked worktree's path. expect(mainWorktree?.path).not.toBe(resolvedLinked) } ) @@ -183,8 +173,7 @@ describe('git worktree separate git dir paths', () => { ] as const)( 'leaves the main entry unchanged when a %s repo is scanned via its linked worktree', async (_label, kind) => { - // Here the main entry IS the git-common-dir, so only the repo path's own git dir shows it is - // a linked worktree; relabelling would give two rows the linked folder's path (#23631). + // Bare/separate-git-dir main rows match the common dir, so the caller's own gitdir must distinguish them. let mainPath: string let repoForAdd: string if (kind === 'bare') { diff --git a/src/main/git/worktree-shared-directories.test.ts b/src/main/git/worktree-shared-directories.test.ts index 27e54042d54..155bcc49428 100644 --- a/src/main/git/worktree-shared-directories.test.ts +++ b/src/main/git/worktree-shared-directories.test.ts @@ -18,17 +18,7 @@ import { import { assertWorktreeCleanForRemoval } from './worktree' import { getStatus } from './status' -// Why an empty file and not `os.devNull`: that constant is `\\.\nul` on win32, which -// Git normalizes to `//./nul` and rejects — `fatal: unable to access '//./nul': Invalid -// argument`. Every git call in this file then threw. POSIX resolves it to /dev/null, -// which Git accepts, so CI never saw it. An empty file works on every platform and -// matches how skill-windows-workspace already isolates. -// -// Why process.env and not just this helper's env: resolveWorktreeSharedDirectories -// runs its own `git check-ignore` through the production runner, which inherits -// process.env. Overriding only the setup helper left the code under test reading the -// host's real config, so a developer with core.excludesFile set saw a fixture that is -// not gitignored come back as ignored. +// An empty config file works with Windows Git (os.devNull does not) and isolates the production runner too. let gitConfigRoot: string let previousGitConfigGlobal: string | undefined let previousGitConfigNosystem: string | undefined @@ -198,8 +188,7 @@ describe('getConfiguredWorktreeSharedDirectories', () => { }) it('returns the configured names without existence or gitignore filtering', () => { - // Why: neither directory exists, yet removal still needs both names to - // recognize and unlink the symlinks a previous creation left behind. + // Removal needs configured names even after the primary directory disappears. writeFileSync( join(repo, 'orca.yaml'), 'worktree:\n sharedDirectories:\n - node_modules\n - .cache\n' @@ -245,10 +234,7 @@ describe('getConfiguredWorktreeSharedDirectories', () => { }) }) -// Why: `node_modules/` is a directory-only ignore rule. It matches the primary's -// real directory, so the shared directory resolves, but never the worktree's -// symlink — Git reports that link as untracked and refuses a non-force removal -// unless deletion is told to tolerate it. +// Directory-only ignore rules hide the primary directory, but not its worktree symlink. describe('shared directories and worktree removal', () => { let root: string let primary: string @@ -296,10 +282,7 @@ describe('shared directories and worktree removal', () => { ).resolves.toBeUndefined() }) - // Why: the halves are tested apart — resolver output here, a hardcoded - // `['node_modules']` in the status tests. This pins the seam between them, so a - // resolver that ever returns a differently-spelled path can't leave the link - // showing as a phantom untracked row. + // Pass actual resolver output to status so path spelling cannot drift across the seam. it('leaves status clean when the resolved directory is fed back as a shared link', async () => { await createWorktreeSharedPaths( primary, @@ -314,10 +297,6 @@ describe('shared directories and worktree removal', () => { expect(status.entries).toEqual([]) }) - // Why: `-z` output is NUL-delimited and `.trim()` leaves interior NULs, so the - // raw stdout reached the user as `?? node_modules?? precious.txt` — - // raw control bytes in a message, listing the very link this feature exists to - // suppress. The error must name only what the user can actually act on. it('reports only genuine blockers in the removal error, with no NUL bytes', async () => { await createWorktreeSharedPaths( primary, @@ -334,15 +313,10 @@ describe('shared directories and worktree removal', () => { }) await expect(removal).rejects.toThrow('uncommitted or untracked') - // Why an exact match: it proves both halves at once — no interior NUL, and - // the tolerated `node_modules` link absent from what the user is told to fix. await expect(removal).rejects.toMatchObject({ stdout: '?? precious.txt' }) }) - // Why `-z` is used at all: Git C-quotes non-ASCII paths under `--porcelain`, - // so a byte-for-byte comparison against the configured entry would miss and the - // link would read as a blocker. A space alone is not quoted but is the case a - // naive whitespace split would break. + // NUL records preserve C-quoted Unicode names and names with spaces for exact matching. it('tolerates shared directories whose names have a space or non-ASCII characters', async () => { const names = ['my shared dir', 'ライブラリ'] for (const name of names) { diff --git a/src/main/git/worktree-shared-directories.ts b/src/main/git/worktree-shared-directories.ts index 5d633d89c88..4a10fe3da21 100644 --- a/src/main/git/worktree-shared-directories.ts +++ b/src/main/git/worktree-shared-directories.ts @@ -6,30 +6,16 @@ import { loadHooks } from '../hooks' import type { Repo } from '../../shared/repo-types' import { mapWithConcurrency } from '../../shared/map-with-concurrency' -// Why: a fresh worktree has no node_modules/.cache, and copying them is slow and -// duplicates disk; `orca.yaml` names the ones every worktree should share instead. - const CONFIGURED_SHARED_DIRECTORIES_CACHE_TTL_MS = 30_000 export const MAX_CONFIGURED_SHARED_DIRECTORIES_CACHE_ENTRIES = 512 -// Why: resolving a worktree may list many generated directories; overlap -// independent local probes without flooding the filesystem threadpool. +// Bound filesystem fan-out while overlapping independent probes. const SHARED_DIRECTORY_STAT_CONCURRENCY = 8 const configuredSharedDirectoriesByRepoPath = new Map< string, { directories: string[]; expiresAt: number } >() -/** The configured `worktree.sharedDirectories` names, before any existence or - * gitignore filtering. - * - * Why deletion can't reuse the resolver below: a directory-only ignore rule - * (`node_modules/`) matches the primary's real directory but never the - * worktree's symlink, so Git reports that symlink as untracked. Removal has to - * tolerate and unlink it, and the resolver would have already dropped it. - * - * `readonly` because this is the cached array itself: a mutating caller would - * corrupt every later read for the rest of the TTL. Copying on return would fix - * that too, but this runs on the status-polling path — the type costs nothing. */ +/** Return the cached readonly names; removal needs them before existence/ignore filtering drops shared links. */ export function getConfiguredWorktreeSharedDirectories(repoPath: string): readonly string[] { const cached = configuredSharedDirectoriesByRepoPath.get(repoPath) const now = Date.now() @@ -55,7 +41,6 @@ export function getConfiguredWorktreeSharedDirectories(repoPath: string): readon return configured } -/** Reset the process cache between tests. */ export function clearConfiguredWorktreeSharedDirectoriesCacheForTests(): void { configuredSharedDirectoriesByRepoPath.clear() } @@ -64,27 +49,14 @@ export function getConfiguredWorktreeSharedDirectoriesCacheSizeForTests(): numbe return configuredSharedDirectoriesByRepoPath.size } -/** Every path Orca may have symlinked into a worktree: the per-user Worktree - * Shared Paths setting plus the repo's `orca.yaml` shared directories. - * - * Callers pair this with `findExistingWorktreeSymlinkPaths`, which keeps only - * the entries that really are symlinks — so a configured name that the user - * happens to own as a regular file is never treated as one of ours. */ +/** Configured link candidates; pair with findExistingWorktreeSymlinkPaths to preserve user-owned regular files. */ export function getWorktreeSharedLinkPaths(repo: Pick): string[] { return Array.from( new Set([...(repo.symlinkPaths ?? []), ...getConfiguredWorktreeSharedDirectories(repo.path)]) ) } -/** Resolve `worktree.sharedDirectories` from the repo-root `orca.yaml` to - * concrete repo-relative directories to symlink into a new worktree. - * - * Only directories that exist in the primary checkout **and** are gitignored are - * returned: tracked directories are already materialized by the checkout, and - * sharing an unignored path would surface the link as a spurious worktree diff. - * - * Never throws — any read/parse/git failure resolves to `[]` so worktree - * creation is never blocked by this file. */ +/** Share only existing gitignored directories; failures return [] so sharing cannot block creation. */ export async function resolveWorktreeSharedDirectories( repoPath: string, options: GitRuntimeOptions = {} @@ -95,9 +67,7 @@ export async function resolveWorktreeSharedDirectories( return [] } - // Keep only entries that exist as directories; a listed but absent path - // (node_modules before install) has nothing to share. The mapper retains - // configured order; warnings are emitted below in that same order. + // Preserve configured order for deterministic warnings while probing concurrently. const probes = await mapWithConcurrency( configured, SHARED_DIRECTORY_STAT_CONCURRENCY, diff --git a/src/main/git/worktree-sparse-add.ts b/src/main/git/worktree-sparse-add.ts index 43d13a89fe6..4e5af558f00 100644 --- a/src/main/git/worktree-sparse-add.ts +++ b/src/main/git/worktree-sparse-add.ts @@ -31,8 +31,7 @@ export async function addSparseWorktree( options ) created = true - // Why: `worktree add --no-checkout` writes no files, so these are the calls that - // actually materialize the deep path and need the long-path escape hatch. + // --no-checkout wrote no files; these materialization steps need Windows long-path support. const longPathArgs = windowsLongPathGitArgs(worktreePath) await gitExecFileAsync( ['sparse-checkout', 'init', '--cone'], @@ -64,13 +63,12 @@ export async function addSparseWorktree( try { await removeWorktree(repoPath, worktreePath, true, { deleteBranch: !options.checkoutExistingBranch, - // Why: failed-creation rollback — the fresh branch has no user commits, so force-delete rather than orphan it. + // Roll back only the new branch, which has no user commits. forceBranchDelete: !options.checkoutExistingBranch, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) }) } catch { wrapped.cleanupFailed = true - // Why: surface that manual cleanup may be needed, else a half-created worktree lingers silently on disk. wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)` } } diff --git a/src/main/git/worktree-sparse-checkout-cache.test.ts b/src/main/git/worktree-sparse-checkout-cache.test.ts index 21c343aa5aa..91d45b3bf08 100644 --- a/src/main/git/worktree-sparse-checkout-cache.test.ts +++ b/src/main/git/worktree-sparse-checkout-cache.test.ts @@ -22,9 +22,7 @@ import { const RECONCILE_WINDOW_MS = 5 * 60_000 -// A real setTimeout tick, not a faked one, to flush the microtask chain a background -// stale-while-revalidate detect runs on without needing vi.useFakeTimers() (which would also -// have to fake Date.now(), the thing these tests drive manually via the Date.now spy below). +// Flush background microtasks with a real timer while Date.now remains controlled by the spy. async function flushBackgroundRevalidation(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } @@ -98,16 +96,11 @@ describe('detectSparseCheckoutCached', () => { detectSparseCheckoutMock.mockResolvedValueOnce(false) expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false) - // Just under the window: still trusts the cached value, no re-detect. nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS - 1) expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false) expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) - // Past the window: both calls return the (stale) cached value, and only one kicks a - // background re-detect. Read both without awaiting in between — the underlying function - // never suspends before returning the stale value, so two calls issued back-to-back in the - // same tick are the only reliable way to observe "both concurrent callers stay stale" without - // racing the background revalidation's own microtask. + // Issue both stale reads before yielding so background revalidation cannot win the second read. nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) detectSparseCheckoutMock.mockResolvedValueOnce(true) const firstPastWindow = detectSparseCheckoutCached('/repo', '/repo/wt-a') @@ -118,7 +111,6 @@ describe('detectSparseCheckoutCached', () => { await flushBackgroundRevalidation() - // The corrected value is now served without needing another window to elapse. expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2) } finally { @@ -134,18 +126,15 @@ describe('detectSparseCheckoutCached', () => { detectSparseCheckoutMock.mockResolvedValueOnce(false) await detectSparseCheckoutCached('/repo', '/repo/wt-a') - // Past the window: kicks a background re-detect that we hold open. nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) detectSparseCheckoutMock.mockImplementationOnce( () => new Promise((resolve) => (resolveDetect = resolve)) ) await detectSparseCheckoutCached('/repo', '/repo/wt-a') - // The worktree is removed (or the repo cache is cleared) while the detect above is in flight. invalidateSparseCheckoutState('/repo', '/repo/wt-a') expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0) - // The in-flight detect now resolves; it must not write the entry back. resolveDetect(true) await flushBackgroundRevalidation() expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0) @@ -162,23 +151,18 @@ describe('detectSparseCheckoutCached', () => { detectSparseCheckoutMock.mockResolvedValueOnce(false) await detectSparseCheckoutCached('/repo', '/repo/wt-a') - // Past the window: kicks a background re-detect that we hold open (simulates a slow probe - // racing a worktree removal + recreation at the same path). + // Hold a stale probe across removal and recreation at the same path. nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) detectSparseCheckoutMock.mockImplementationOnce( () => new Promise((resolve) => (resolveStaleDetect = resolve)) ) await detectSparseCheckoutCached('/repo', '/repo/wt-a') - // The worktree is removed (invalidate) and a new one is recreated at the exact same path, - // repopulating the key with a fresh, different value via a normal cold read. invalidateSparseCheckoutState('/repo', '/repo/wt-a') detectSparseCheckoutMock.mockResolvedValueOnce(true) expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) - // The stale in-flight detect from before the remove+recreate now resolves with the old - // answer. A presence-only guard would let this overwrite the fresh entry above; the fix - // must compare entry identity and refuse to write back over a value it didn't produce. + // A presence-only guard would overwrite the recreated entry; require the original entry identity. resolveStaleDetect(false) await flushBackgroundRevalidation() expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) @@ -262,13 +246,9 @@ describe('clearSparseCheckoutStateCache', () => { }) }) -// Regression coverage for the live Windows+WSL sequence: a distro-less listing (filesystem-auth -// root rebuild, worktree ownership checks) racing the real distro-carrying listing for the same -// repo. Before the distro joined the cache key they shared one entry, so whichever ran first -// decided the sparse badge for the whole reconcile window. +// Distro-less and distro-carrying reads of one checkout must retain separate sparse answers. describe('detectSparseCheckoutCached with a WSL distro', () => { - // Mirrors the real probe: without the distro the gitdir pointer resolves to a fabricated Win32 - // path, the sparse-checkout stat misses, and the worktree reads as non-sparse. + // Model a non-drvfs pointer that requires the caller's distro to resolve. function detectOnlyWithDistro(distro: string): void { detectSparseCheckoutMock.mockImplementation( async (_worktreePath: string, options?: { wslDistro?: string }) => @@ -324,8 +304,7 @@ describe('detectSparseCheckoutCached with a WSL distro', () => { await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' }) await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt') - // Past the window the distro-less caller re-probes its own entry, not the sparse one, so the - // badge cannot blink off and fire the change listener that clears the whole repo's cache. + // Revalidate only the distro-less entry, without flipping the other entry's sparse badge. nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) expect(await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')).toBe(false) await flushBackgroundRevalidation() diff --git a/src/main/git/worktree-sparse-checkout-cache.ts b/src/main/git/worktree-sparse-checkout-cache.ts index c87c2ecbfe0..b8453dbc0d5 100644 --- a/src/main/git/worktree-sparse-checkout-cache.ts +++ b/src/main/git/worktree-sparse-checkout-cache.ts @@ -2,34 +2,12 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { canonicalWorktreePath } from './worktree-path-comparison' import { detectSparseCheckout } from './worktree-sparse-state' -// Worktree porcelain omits sparse state, so cache the per-worktree filesystem detection. -// Keys isolate repositories and distros; one global budget bounds retained results. -// -// Invalidation coverage: -// - Orca-driven remove/move: explicit calls below (worktree-removal.ts, worktree-move.ts). -// - External `git sparse-checkout` toggle while extensions.worktreeConfig is on: it rewrites -// `config.worktree`, which the git-common-dir watcher already classifies as structural and -// routes through notifyWorktreesChanged -> the invalidator this module registers (repo-scoped). -// - External toggle with extensions.worktreeConfig off, or a bare pattern-file edit: unwitnessed -// by the watcher (same blind spot `readRepoWorktreeAdminFingerprint` already documents and -// accepts). Past the reconcile window below, a read still returns instantly from the stale entry -// but also kicks a deduplicated background re-detect; a flip fires the change listener (wired to -// the existing worktrees-changed notification) so the visible staleness window collapses from the -// interval to one refresh cycle instead of blocking the listing that noticed it. That notification -// itself runs the invalidator registered below, so a flip is immediately followed by a full clear -// of the repo's cache (not just the one entry) -- an intentionally forced one-time full re-detect -// on the rare edge that actually flipped, rather than partial state that could quietly diverge. -// - App cold start: the map starts empty, so the first read is always a fresh detect. +// Cache filesystem sparse probes; mutations and config.worktree changes invalidate entries. +// Unwatched pattern/shared-config edits reconcile in the background after five minutes; flips notify and clear the repo. const SPARSE_CHECKOUT_CACHE_RECONCILE_INTERVAL_MS = 5 * 60_000 export const MAX_SPARSE_CHECKOUT_CACHE_ENTRIES = 512 -// Part of the cache key, not just a probe argument. A distro-less read of a WSL-hosted repo -// resolves the gitdir pointer against a fabricated Win32 path and reports "not sparse"; several -// callers (filesystem-auth root rebuild, worktree ownership checks) list a repo with no options at -// all and would otherwise publish that wrong answer onto the entry the distro-carrying listing -// reads. Keying on it also pins each entry's revalidation to the options that produced it, so the -// background probe can never re-derive a warm entry under weaker options and flip it. Every field -// here must be in the key; widening this type means widening `cacheKey`. +// Every probe option must join cacheKey: distro-less probes can resolve a different gitdir. type SparseCheckoutProbeOptions = Pick type SparseCheckoutCacheEntry = { @@ -87,7 +65,6 @@ export function onSparseCheckoutStateChanged( changeListener = listener } -/** Cached wrapper around {@link detectSparseCheckout}; see module doc for invalidation coverage. */ export async function detectSparseCheckoutCached( repoPath: string, worktreePath: string, @@ -104,10 +81,7 @@ export async function detectSparseCheckoutCached( retainSparseCheckoutCacheEntry(key, cached) return cached.isSparse } - // Stale-while-revalidate: serve the still-cached value now and correct it in the background, - // deduplicated so concurrent readers past the window don't each start their own probe. Whichever - // reader wins the dedupe re-probes with the entry's own distro, because that distro is what - // routed it to this key. + // Serve stale results while one background probe revalidates using this key's own options. cached.revalidating ??= revalidateInBackground(key, repoPath, worktreePath, cached, options) return cached.isSparse } @@ -121,10 +95,7 @@ async function revalidateInBackground( ): Promise { try { const isSparse = await detectSparseCheckout(worktreePath, options) - // Identity guard against a race with an explicit invalidate/clear -- or a remove+recreate at - // the same path that repopulates the key with a fresh cold read -- while this was in flight. - // A `has()`/presence check can't tell "still mine" from "someone else's fresh value" sharing - // the key; comparing the map's current entry object to the one we started from can. + // An invalidate or remove/recreate can replace the entry while this probe is in flight. if (sparseCheckoutStateCache.get(key) === startingEntry) { retainSparseCheckoutCacheEntry(key, { isSparse, cachedAt: Date.now() }) } @@ -139,7 +110,7 @@ async function revalidateInBackground( } } -/** Drop one worktree's cached state; call when Orca itself removes or moves a worktree path. */ +/** Invalidate on Orca-driven removal or moves. */ export function invalidateSparseCheckoutState(repoPath: string, worktreePath: string): void { deleteKeysWithPrefix(worktreeKeyPrefix(repoPath, worktreePath)) } @@ -149,7 +120,7 @@ export function clearSparseCheckoutStateCacheForRepo(repoPath: string): void { deleteKeysWithPrefix(`${canonicalWorktreePath(repoPath)}\0`) } -/** Clear every cached entry; fallback for a change notification whose repo can't be resolved to a path. */ +/** Clear all entries when a change notification cannot be resolved to a repo path. */ export function clearSparseCheckoutStateCache(): void { sparseCheckoutStateCache.clear() } diff --git a/src/main/git/worktree-sparse-checkout.test.ts b/src/main/git/worktree-sparse-checkout.test.ts index c9584f9481c..f428063f5d3 100644 --- a/src/main/git/worktree-sparse-checkout.test.ts +++ b/src/main/git/worktree-sparse-checkout.test.ts @@ -63,10 +63,7 @@ describe('sparse-checkout detection', () => { git(repoPath, ['sparse-checkout', 'set', 'keep']) git(repoPath, ['sparse-checkout', 'disable']) - // Regression guard: `git sparse-checkout disable` restores the full - // working tree but deliberately keeps /info/sparse-checkout so the - // checkout can be re-enabled. Detection must not treat the leftover file - // as "still sparse" (that produced a false "files are not on disk" badge). + // Disabling sparse checkout deliberately retains its pattern file for later re-enabling. const patternFile = path.join(repoPath, '.git', 'info', 'sparse-checkout') await expect(stat(patternFile)).resolves.toMatchObject({}) @@ -125,8 +122,7 @@ describe('sparse-checkout cache invalidation across the worktree lifecycle', () const sparseRow = beforeRemoval.find((worktree) => worktree.branch.endsWith('sparse-branch')) expect(sparseRow?.isSparse).toBe(true) - // Removing through Orca's own removeWorktree (not a raw `git worktree remove`) exercises the - // cache-invalidation hook this listing now relies on instead of a fresh stat every time. + // Exercise Orca's invalidation hook rather than removing through raw Git. await removeWorktree(repoPath, linkedPath, true) git(repoPath, ['worktree', 'add', '-b', 'full-branch', linkedPath]) @@ -177,8 +173,7 @@ describe('parseCoreSparseCheckoutFlag', () => { expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true # on\n')).toBe(true) }) - // Git's config parser is character- not line-based, so a header may be followed on the same line - // by the assignment. Every expectation below was confirmed against `git config --file --get`. + // Git accepts an assignment immediately after its section header. it('reads an assignment on the same line as the section header', () => { expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = true\n')).toBe(true) expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = false\n')).toBe(false) @@ -202,9 +197,7 @@ describe('parseCoreSparseCheckoutFlag', () => { }) it('leaves [core] when a subsection header carries its own same-line assignment', () => { - // A `[section "sub"]key = value` line matched neither branch of the old anchored regex, so the - // parser never left `[core]` and credited the next indented line to it — a bogus sparse badge. - // Git reports core.sparseCheckout as unset here. + // A subsection must end [core] even when its header carries an assignment. expect( parseCoreSparseCheckoutFlag( '[core]\n[core "sub"]worktreeConfig = x\n\tsparseCheckout = true\n' @@ -231,10 +224,7 @@ describe('parseCoreSparseCheckoutFlag', () => { }) it('does not treat trailing junk as a second assignment', () => { - // Git parses this line fine and takes the whole tail as one value (`git config --list` reports - // `core.sparsecheckout=true bogus = false`) — a value runs to end of line, so only one - // assignment can share a line. Git then fails the boolean coercion outright, so reading the - // whole tail as the value keeps us on the conservative "not sparse" side. + // Git treats the full tail as one value; invalid boolean coercion must remain non-sparse. expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = true bogus = false\n')).toBe(false) }) }) diff --git a/src/main/git/worktree-sparse-state-host-paths.test.ts b/src/main/git/worktree-sparse-state-host-paths.test.ts index afa7e1babf7..d3fc16a3682 100644 --- a/src/main/git/worktree-sparse-state-host-paths.test.ts +++ b/src/main/git/worktree-sparse-state-host-paths.test.ts @@ -12,9 +12,7 @@ import { detectSparseCheckout } from './worktree-sparse-state' const slashed = (value: unknown): string => String(value).replaceAll('\\', '/') const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) -// The layout that needs the caller's distro: `git worktree list` reports a drvfs worktree, which -// translates to a drive letter, so the base path no longer names the distro that wrote the gitdir -// pointer — and that pointer is not itself drvfs, so there is nothing to derive a drive from. +// A drive-spelled checkout needs the caller's distro to resolve its non-drvfs gitdir pointer. const HOST_WORKTREE = 'C:/wt/x' const GUEST_GIT_DIR = '/home/me/repo/.git/worktrees/x' const HOST_GIT_DIR = '//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/x' @@ -28,8 +26,7 @@ describe('detectSparseCheckout on a drvfs-spelled WSL worktree', () => { if (value === `${HOST_WORKTREE}/.git`) { return `gitdir: ${GUEST_GIT_DIR}\n` } - // No `commondir`, so the gitdir is its own common dir and the config read stays in one - // namespace — the pointer resolve above is the only thing under test. + // Omit commondir to isolate gitdir-pointer resolution. if (value === `${HOST_GIT_DIR}/config`) { return '[core]\n\tsparseCheckout = true\n' } diff --git a/src/main/git/worktree-sparse-state.ts b/src/main/git/worktree-sparse-state.ts index 38a085d9a5f..ed6aeca07f2 100644 --- a/src/main/git/worktree-sparse-state.ts +++ b/src/main/git/worktree-sparse-state.ts @@ -5,36 +5,24 @@ import { resolveGitDir } from './status' export async function detectSparseCheckout( worktreePath: string, - // Why: git in a WSL distro reports the worktree, and writes its gitdir pointer, in the guest - // namespace; without the distro this stats a path Win32 fabricates and reads "not sparse". + // WSL writes guest-spelled gitdir pointers that require the caller's distro for Windows I/O. options: Pick = {} ): Promise { - // Why: fs.stat the per-worktree gitdir's sparse-checkout pattern file instead of a per-poll `git sparse-checkout list` subprocess that regressed responsiveness (PR #1290); - // this is the cheap fast-path gate before the enabled check below. + // Filesystem probes avoid a Git subprocess per worktree on each poll. try { const gitDir = await resolveGitDir(worktreePath, options) const stats = await stat(join(gitDir, 'info', 'sparse-checkout')) if (!stats.isFile() || stats.size === 0) { return false } - // Why the extra config read: `git sparse-checkout disable` restores every file to the - // working tree and sets core.sparseCheckout=false, but it deliberately LEAVES - // /info/sparse-checkout in place so the checkout can be re-enabled with the same - // patterns. A non-empty pattern file is therefore necessary but not sufficient — without - // confirming core.sparseCheckout is actually on we would flag a fully-populated worktree as - // sparse and show a misleading "files are not on disk" badge. This runs only for the rare - // worktree that still has a non-empty pattern file, so it does not reintroduce the per-poll - // subprocess fan-out PR #1290 removed, and it reads git's config files directly (no - // subprocess) so it stays cheap and needs no exec options. + // Disable retains the pattern file; confirm core.sparseCheckout through direct config reads. return await isSparseCheckoutEnabled(gitDir) } catch { return false } } -// Resolve the shared common gitdir for a (possibly linked) worktree gitdir. A linked worktree's -// gitdir holds a `commondir` file pointing at the repo's main `.git`; the main worktree's gitdir -// is itself the common dir. +/** Linked gitdirs point to the shared common dir; missing/unreadable pointers fall back to this gitdir. */ export async function resolveGitCommonDir(gitDir: string): Promise { try { const raw = (await readFile(join(gitDir, 'commondir'), 'utf-8')).trim() @@ -42,20 +30,17 @@ export async function resolveGitCommonDir(gitDir: string): Promise { return isAbsolute(raw) ? raw : resolve(gitDir, raw) } } catch { - // No `commondir` file: this gitdir is already the common dir. + // Best effort: no readable commondir pointer. } return gitDir } -// Whether core.sparseCheckout is actually enabled for this worktree. The value can live in the -// shared repo config or, when extensions.worktreeConfig is on, in the worktree-local -// `config.worktree`; later files override earlier ones, matching git's config precedence. +// Worktree config overrides shared config only while extensions.worktreeConfig is enabled. async function isSparseCheckoutEnabled(gitDir: string): Promise { const commonDir = await resolveGitCommonDir(gitDir) const sharedConfig = await readGitConfigText(join(commonDir, 'config')) const sharedFlag = parseCoreSparseCheckoutFlag(sharedConfig) - // Git reads `config.worktree` only while extensions.worktreeConfig is on; without that gate a - // stale worktree config left behind by an earlier sparse checkout overrides the real repo value. + // Stale worktree config must not override a disabled extensions.worktreeConfig setting. if (parseGitConfigFlag(sharedConfig, 'extensions', 'worktreeconfig') !== true) { return sharedFlag ?? false } @@ -71,17 +56,11 @@ async function readGitConfigText(configPath: string): Promise { } } -// Read the effective `core.sparseCheckout` boolean from one git config file's text, or `undefined` -// when the plain `[core]` section does not set it. Kept as a pure, exported function so the -// git-config parsing edge cases can be unit tested without touching the filesystem. Only the last -// assignment wins, and a `[core "subsection"]` header is intentionally not treated as `[core]`. export function parseCoreSparseCheckoutFlag(configContent: string): boolean | undefined { return parseGitConfigFlag(configContent, 'core', 'sparsecheckout') } -// A section header may be followed on the same line by further headers and then one assignment -// (`[core] sparseCheckout = true` is legal git config); the value runs to end of line, so at most -// one assignment can share a line and the last header before it decides the section. +// Headers can share a line with one assignment; its value extends to end of line. const GIT_CONFIG_SECTION_HEADER = /^\[\s*([A-Za-z0-9.-]+)(\s+"(?:[^"\\]|\\.)*")?\s*\]/ const GIT_CONFIG_ASSIGNMENT = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/ @@ -115,7 +94,6 @@ function parseGitConfigFlag( return value } -// Drop a trailing `#`/`;` comment that is not inside a double-quoted value. function stripGitConfigComment(line: string): string { let inQuotes = false for (let index = 0; index < line.length; index += 1) { @@ -129,8 +107,7 @@ function stripGitConfigComment(line: string): string { return line } -// Git treats a valueless boolean (`sparseCheckout` with no `=`) as true and only true/yes/on/1 as -// true otherwise; everything else (including the disable-written `false`) is false. +// Valueless booleans are true; explicit values accept Git's true/yes/on/1 forms. function parseGitConfigBoolean(raw: string | undefined): boolean { if (raw === undefined) { return true diff --git a/src/main/git/worktree-symlink-detection.test.ts b/src/main/git/worktree-symlink-detection.test.ts index 4083c55ef2e..be21bc36c18 100644 --- a/src/main/git/worktree-symlink-detection.test.ts +++ b/src/main/git/worktree-symlink-detection.test.ts @@ -5,15 +5,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { findExistingWorktreeSymlinkPaths, getSafeRelativePath } from './worktree-symlink-detection' describe('getSafeRelativePath', () => { - // The only case in this file that binds the production change: every one of - // these is admitted by at least one host's `path.isAbsolute` — the - // drive-relative spellings are admitted by *every* host's, Windows included. it('rejects Windows drive-qualified entries, including drive-relative, on any host', () => { expect(getSafeRelativePath('C:\\Windows\\Temp')).toEqual({ safe: false }) expect(getSafeRelativePath('C:/Windows/Temp')).toEqual({ safe: false }) expect(getSafeRelativePath(' d:\\payload ')).toEqual({ safe: false }) - // Drive-RELATIVE: `win32.resolve('D:\\wt', 'C:payload')` discards the - // worktree root and lands under C:'s current directory. + // A drive-relative path can escape the worktree through that drive's current directory. expect(getSafeRelativePath('C:payload')).toEqual({ safe: false }) expect(getSafeRelativePath('c:')).toEqual({ safe: false }) }) @@ -26,9 +22,6 @@ describe('getSafeRelativePath', () => { }) }) - // These pin the rest of the guard expression, which this commit rewrote: - // both `isAbsolute` calls were deleted as provably subsumed by the strip - // above plus the drive check, so their inputs must still be judged the same. it('still strips leading separators of either flavour and keeps the remainder relative', () => { expect(getSafeRelativePath('node_modules')).toEqual({ safe: true, rel: 'node_modules' }) expect(getSafeRelativePath(' .env ')).toEqual({ safe: true, rel: '.env' }) @@ -80,10 +73,7 @@ describe('findExistingWorktreeSymlinkPaths', () => { rmSync(root, { recursive: true, force: true }) }) - // Why skipped on Windows rather than made platform-independent: the fixture - // needs a real on-disk directory named `C:`, which only POSIX allows. The - // Windows half of this guard is bound by the unit tests above, which run - // everywhere because they never touch the filesystem. + // The fixture needs a literal C: directory, which Windows forbids; pure path cases run everywhere. const posixIt = process.platform === 'win32' ? it.skip : it posixIt('does not report a drive-qualified entry even when the literal path exists', async () => { diff --git a/src/main/git/worktree-symlink-detection.ts b/src/main/git/worktree-symlink-detection.ts index 663a6b56606..3235496b529 100644 --- a/src/main/git/worktree-symlink-detection.ts +++ b/src/main/git/worktree-symlink-detection.ts @@ -2,31 +2,17 @@ import { lstat } from 'node:fs/promises' import { resolve, sep } from 'node:path' import { resolveWorktreeHostPath } from '../../shared/git-metadata-path' -// Why this is a leaf module rather than part of ipc/worktree-symlinks: status -// and review-creation need only the read-only "is this a symlink" question, and -// importing the materialization module would pull APFS cloning — and its -// child_process dependency — into their graph. +// Keep read-only symlink detection independent of APFS materialization and child-process imports. export type SafeRelativePathResult = { safe: true; rel: string } | { safe: false } -// Why a regex rather than `path.isAbsolute`: the strip below already removed -// every leading `/` and `\`, so the only rooted spelling that can still reach -// the guard is a Windows drive designator — and `win32.isAbsolute` misses the -// drive-RELATIVE form (`C:foo`), which `win32.resolve` still resolves against -// that drive's current directory instead of the worktree root. +// isAbsolute misses drive-relative C:foo, which still escapes the worktree on Windows. const WINDOWS_DRIVE_DESIGNATOR = /^[a-zA-Z]:/ export function getSafeRelativePath(rawPath: string): SafeRelativePathResult { - // Why: strip leading separators (both `/` and `\`) before the guard so - // Windows-style input like `\foo` is normalized the same way POSIX `/foo` - // is, and the traversal check below sees the already-relative form. + // Strip either separator style before validating so host syntax cannot change the verdict. const rel = rawPath.trim().replace(/^[\\/]+/, '') - // Why: split on both separators so a Windows-authored `..\escape` is - // rejected the same way POSIX `../escape` is; the split catches relative - // backslash traversal that `.split('/')` would otherwise miss. - // Why the drive check runs on every host, past `.` segments: the same entry — per-user - // Shared Paths setting or repo `orca.yaml` — is evaluated on every host Orca runs on, so - // the verdict must not depend on which one is asking, and `./C:x` is still `C:x`. + // Validate both separator styles and drive prefixes after ./ normalization on every host. const segments = rel.split(/[\\/]/).filter((segment) => segment !== '' && segment !== '.') if ( segments.length === 0 || @@ -35,8 +21,7 @@ export function getSafeRelativePath(rawPath: string): SafeRelativePathResult { ) { return { safe: false } } - // Why: callers match `rel` against git's output, which never spells `./x`, `x/` or `a//b`. - // Split only on separators this host's `resolve` honours, so `rel` names what lstat probes. + // Match Git's normalized spelling, using only separators the host filesystem resolves. const gitRel = sep === '\\' ? segments.join('/') @@ -57,8 +42,7 @@ export async function findExistingWorktreeSymlinkPaths( paths: readonly string[], options: WorktreeSymlinkDetectionOptions = {} ): Promise { - // Why: git in a WSL distro reports the worktree in the guest namespace, but this lstat runs in - // the Windows main process, where that spelling names nothing. + // Node filesystem reads need host spelling even when WSL Git reported guest paths. const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) ?? worktreePath const symlinkPaths: string[] = [] for (const rawPath of paths) { diff --git a/src/main/git/worktree-test-harness.ts b/src/main/git/worktree-test-harness.ts index a72dacc9ec6..e202f2434ae 100644 --- a/src/main/git/worktree-test-harness.ts +++ b/src/main/git/worktree-test-harness.ts @@ -3,13 +3,11 @@ import { afterEach, beforeEach, vi } from 'vitest' import { clearGitCapabilityStateForTests } from './git-capability-state' import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache' -/** Root hooks every worktree suite shares: pristine capability cache, no ambient add-timeout override. */ export function registerWorktreeSuiteHooks(): void { beforeEach(() => { clearGitCapabilityStateForTests() __resetSparseCheckoutStateCacheForTests() - // Why: addWorktree reads the override at call time, so a developer's ambient value must not leak in. - // `undefined` deletes the key, matching production's unset case rather than an empty string. + // Remove ambient timeout overrides; undefined matches an absent environment key. vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', undefined) }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 9e501875d0b..da319217747 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -7,9 +7,7 @@ export { } from './worktree-add' export { forceDeleteLocalBranch } from './worktree-branch-removal' export { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' -// Unshared by design: verification-after-mutation callers must not join an -// in-flight scan that predates a raw `git worktree prune` or an external client. -// Opt into coalescing with `listWorktreesSharedStrict`. +// Post-mutation verification must not join pre-prune scans; opt into coalescing via listWorktreesSharedStrict. export { describeCreatedWorktree, listWorktreesStrict } from './worktree-listing' export { moveWorktree } from './worktree-move' export { diff --git a/src/main/git/wsl-direct-git-read-commands.test.ts b/src/main/git/wsl-direct-git-read-commands.test.ts index 98f2fb64ccd..0bbd9b994af 100644 --- a/src/main/git/wsl-direct-git-read-commands.test.ts +++ b/src/main/git/wsl-direct-git-read-commands.test.ts @@ -20,7 +20,6 @@ describe('isWslDirectGitReadCommand', () => { [['worktree', 'list', '--porcelain']], [['branch', '--list']], [['branch', '--show-current']], - // Global options precede the subcommand. [['-c', 'core.quotepath=off', 'log', '--oneline']], [['-C', '/repo', 'rev-parse', 'HEAD']], [['--git-dir=/repo/.git', 'ls-tree', 'HEAD']], @@ -30,8 +29,7 @@ describe('isWslDirectGitReadCommand', () => { expect(isWslDirectGitReadCommand(args)).toBe(true) }) - // Why these matter most: a write misrouted to the shell-free path loses the - // credential helpers and ssh-agent the user's login shell sets up. + // Writes need credential helpers and ssh-agent from the login shell. it.each([ [['commit', '-m', 'msg']], [['push', 'origin', 'main']], @@ -42,7 +40,6 @@ describe('isWslDirectGitReadCommand', () => { [['add', '--', 'file.ts']], [['worktree', 'add', '/tmp/wt']], [['submodule', 'update', '--init']], - // Same subcommands as above, in their writing form. [['config', 'user.email', 'me@example.com']], [['config', '--unset', 'core.sshCommand']], [['remote', 'add', 'origin', 'https://example.com/r.git']], @@ -59,9 +56,7 @@ describe('isWslDirectGitReadCommand', () => { expect(isWslDirectGitReadCommand(args)).toBe(false) }) - // Why positional: these read markers are matched as the action, not anywhere in - // argv. Matching loosely routed a write shell-free whenever a positional - // happened to share a read marker's name. + // Read markers count only in the action position; positional arguments can have the same names. it.each([[['worktree', 'remove', 'list']], [['submodule', 'foreach', 'status']]])( 'keeps %j on the login shell despite a read-shaped positional', (args) => { @@ -76,9 +71,7 @@ describe('isWslDirectGitReadCommand', () => { } ) - // Why: a write hidden behind global options must never reach the shell-free - // route, and an unparsed global form must fall back to the login shell rather - // than guess. Both directions fail safe. + // Unparsed global options must fall back to the login shell rather than risk misrouting a write. it.each([ [['-c', 'k=v', 'push', 'origin', 'main']], [['-C', '/repo', 'commit', '-m', 'x']], diff --git a/src/main/git/wsl-drive-checkout-git-routing.ts b/src/main/git/wsl-drive-checkout-git-routing.ts index d6af4687b08..b922697d778 100644 --- a/src/main/git/wsl-drive-checkout-git-routing.ts +++ b/src/main/git/wsl-drive-checkout-git-routing.ts @@ -6,11 +6,7 @@ import { } from './wsl-linked-worktree-git-routing' import { pendingWslWindowsCheckoutGitConfig } from './wsl-windows-checkout-git-probe' -/** - * Settles the per-checkout routing a WSL git command on a Windows drive reads synchronously at - * spawn — which Git owns a linked worktree, and whether Windows Git wrote the checkout — or null - * when the command is not one. - */ +/** Settle checkout ownership and Windows-Git config before synchronous routing; null means no drive-checkout command. */ export function pendingWslDriveCheckoutGitRouting( args: readonly string[], options: { diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts index f2307495b59..f46b6975238 100644 --- a/src/main/git/wsl-git-read-environment.ts +++ b/src/main/git/wsl-git-read-environment.ts @@ -8,18 +8,13 @@ import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' export type WslGitReadEnvironment = { gitPath: string; home: string; path: string } const PROBE_TIMEOUT_MS = 10_000 -/** - * How long a read may wait for a cold probe before taking the login shell. - * Short enough that a wedged distro cannot stall the panel, long enough that a - * healthy one resolves and every later read runs shell-free. - */ +/** Bound cold-probe waits; reads fall back to the login shell while a wedged distro remains unresolved. */ export const WSL_GIT_READ_ENVIRONMENT_WAIT_MS = 1_500 const PROBE_MAX_BUFFER = 64 * 1024 const TRANSIENT_PROBE_RETRY_MS = 30_000 const MAX_WSL_GIT_READ_ENVIRONMENT_DISTROS = 128 const environmentByDistro = new Map>() -// Why the null entries matter: a settled "no direct route" answer is what lets a read skip the -// bounded probe wait entirely instead of racing an already-decided promise on every call. +// Cache settled nulls so later reads skip the probe wait. const settledEnvironmentByDistro = new Map() const transientRetryAfterByDistro = new Map() @@ -142,17 +137,13 @@ export function getWslGitReadEnvironment(distro: string): Promise { await expect(probe(sibling)).resolves.toBe(true) invalidateWslLinkedWorktreeGitRouting(CWD) - // Prefix match must not reach `C:\repo-other`, which shares the string prefix. + // Sibling prefixes must not be mistaken for descendants. await expect(probe(submodule)).resolves.toBe(true) await expect(probe(sibling)).resolves.toBe(true) expect(fileSystem.stat).toHaveBeenCalledTimes(3) }) it('leaves a probe that is already in flight alone', async () => { - // Scope control: invalidation must not retire in-flight probes. Doing so leaves - // callers waiting on them with no cached route, which `resolveGitCommand` reads - // as "route through wsl.exe git" — the misroute this module exists to prevent. + // In-flight probes must still cache their route before synchronous command resolution reads it. let releaseMarker: ((marker: typeof fileMarker) => void) | undefined const inFlight = new Promise((resolve) => { releaseMarker = resolve diff --git a/src/main/git/wsl-linked-worktree-git-route-probe.ts b/src/main/git/wsl-linked-worktree-git-route-probe.ts index 51a07cfcb7a..6094c213370 100644 --- a/src/main/git/wsl-linked-worktree-git-route-probe.ts +++ b/src/main/git/wsl-linked-worktree-git-route-probe.ts @@ -7,7 +7,7 @@ export type WslLinkedWorktreeRoutingFileSystem = { readFile(path: string): Promise } -/** `known: false` means the marker exists but cannot be classified — what a half-written `.git` file looks like mid `worktree add`. */ +/** known: false includes half-written or unclassifiable .git files. */ export type WslLinkedWorktreeGitRouteProbeResult = { usesHostGit: boolean known: boolean @@ -30,7 +30,6 @@ export const defaultWslLinkedWorktreeRoutingFileSystem: WslLinkedWorktreeRouting readFile: (path) => fsPromises.readFile(path, 'utf8') } -/** Walk parent directories until Git's worktree marker identifies which Git owns this checkout. */ export async function probeWslLinkedWorktreeGitRoute( cwd: string, fileSystem: WslLinkedWorktreeRoutingFileSystem @@ -42,11 +41,11 @@ export async function probeWslLinkedWorktreeGitRoute( try { const marker = await fileSystem.stat(markerPath) if (!marker.isFile()) { - // A `.git` directory (or anything that is not a file) is a normal main checkout. + // A non-file .git marker does not require linked-worktree routing. return { usesHostGit: false, known: true } } const gitdir = parseLinkedGitdir(await fileSystem.readFile(markerPath)) - // A POSIX gitdir is a settled answer too: the distro owns this checkout. + // POSIX gitdirs settle on distro Git too. return gitdir === null ? { usesHostGit: false, known: false } : { usesHostGit: WINDOWS_DRIVE_PATH.test(gitdir), known: true } @@ -57,7 +56,7 @@ export async function probeWslLinkedWorktreeGitRoute( } } if (candidate === driveRoot) { - // No marker up to the drive root: not a worktree at all, and stable enough to cache. + // No marker up to the drive root is a stable negative answer. return { usesHostGit: false, known: true } } candidate = win32.dirname(candidate) diff --git a/src/main/git/wsl-linked-worktree-git-routing.ts b/src/main/git/wsl-linked-worktree-git-routing.ts index 0550ca5ca3b..8ae73d072bc 100644 --- a/src/main/git/wsl-linked-worktree-git-routing.ts +++ b/src/main/git/wsl-linked-worktree-git-routing.ts @@ -91,15 +91,7 @@ function forgetPathAndDescendants(entries: Map, root: string): voi } } -/** - * Drop the cached route and retry backoff after Git adds, moves, or removes a - * worktree at `cwd`, so the next command re-reads the rewritten `.git` marker. - * Descendants go too: a submodule under the worktree resolved its route from the - * same marker walk. - * - * A probe already in flight is deliberately left alone: it still answers and still - * caches, so a mutation that lands mid-probe is no worse off than the 30s TTL. - */ +/** Invalidate this path and descendants after marker rewrites; in-flight probes still cache until the TTL. */ export function invalidateWslLinkedWorktreeGitRouting(cwd: string): void { const normalizedCwd = normalize(cwd) forgetPathAndDescendants(routeByCwd, normalizedCwd) diff --git a/src/main/git/wsl-process-group-termination.ts b/src/main/git/wsl-process-group-termination.ts index 2f030877673..8a696cdb600 100644 --- a/src/main/git/wsl-process-group-termination.ts +++ b/src/main/git/wsl-process-group-termination.ts @@ -42,8 +42,7 @@ export function createWslProcessGroupTermination(distro: string): WslProcessGrou ` sleep ${GUEST_TERMINATION_INTERVAL_SECONDS}`, 'done' ].join('\n') - // loginPath 'none': the payload is builtins and coreutils on the default - // PATH, so a login probe would only add latency to a kill. + // Builtins/coreutils use the default PATH; a login probe only delays termination. const result = await runWslProcess({ script, args: [String(processGroupId)], @@ -64,10 +63,7 @@ export function createWslProcessGroupTermination(distro: string): WslProcessGrou `printf '%s%s\\n' ${quotePosixShell(marker)} "$$" >&2`, 'exec "$@"' ].join('\n') - // Why probe rather than assume: BusyBox `setsid` has no `--wait`, so there - // the wrapper would fail the Git command outright. Without a new session the - // reported group is not ours to kill, so the fallback reports no identity - // and the caller falls back to waiting for the root exit. + // BusyBox setsid lacks --wait; without a new session, report no group to avoid killing another owner. const script = [ 'if setsid --wait true 2>/dev/null; then', ` exec setsid --wait sh -c ${quotePosixShell(reportGroup)} orca-wsl-process-group "$@"`, diff --git a/src/main/git/wsl-windows-checkout-git-config.ts b/src/main/git/wsl-windows-checkout-git-config.ts index 0e157ef3705..09d2e412d38 100644 --- a/src/main/git/wsl-windows-checkout-git-config.ts +++ b/src/main/git/wsl-windows-checkout-git-config.ts @@ -1,17 +1,7 @@ import { win32 } from 'node:path' import { findGitSubcommandIndex, isWslDirectGitReadCommand } from './wsl-direct-git-read-commands' -/** - * Per-command config that makes Linux Git read a Windows Git checkout the way Windows Git does. - * - * - core.autocrlf=true: CRLF working files compare equal to LF blobs, and staging writes LF; - * without it every rehashed file reads as modified and `add` commits CRLF. - * - core.checkStat=minimal: an index Windows Git wrote carries no inode/uid/ctime Linux Git - * agrees with, so every entry otherwise rehashes across drvfs (~70 s on 11k files). It also - * skips the ctime check, so `core.trustctime` is moot here. - * - * Both predate the Git 2.25 baseline, so no capability probe is needed. - */ +/** autocrlf normalizes CRLF; minimal stat avoids drvfs rehashes from Windows index metadata (Git >=2.25). */ export const WINDOWS_CHECKOUT_GIT_CONFIG_ARGS: readonly string[] = [ '-c', 'core.autocrlf=true', @@ -19,17 +9,13 @@ export const WINDOWS_CHECKOUT_GIT_CONFIG_ARGS: readonly string[] = [ 'core.checkStat=minimal' ] -/** - * Writes while the sample has not proven which Git wrote the checkout: `input` stages LF like - * Windows Git would, yet never writes CRLF into what may be a Linux checkout. - */ +/** Normalize staged bytes to LF without writing CRLF until checkout ownership is proven. */ export const UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS: readonly string[] = [ '-c', 'core.autocrlf=input' ] -// Why: these write a new checkout whose `.git` points at /mnt/..., which Windows Git cannot open, -// so its probe answers not-repo and CRLF files written here would read as modified forever. +// New WSL checkouts have guest gitdir pointers; writing CRLF would leave files permanently modified. const OTHER_CHECKOUT_SUBCOMMANDS = new Set(['clone', 'worktree']) export function writesAnotherCheckout(args: readonly string[]): boolean { @@ -37,14 +23,12 @@ export function writesAnotherCheckout(args: readonly string[]): boolean { return subcommandIndex !== -1 && OTHER_CHECKOUT_SUBCOMMANDS.has(args[subcommandIndex]) } -/** Deliberate cap: ~200 eligible files decide the checkout. */ export const LINE_ENDING_SAMPLE_LIMIT = 200 -// Why: `ls-files --eol` reads every file it lists over drvfs, so capping matches alone still reads -// the whole tree when few files qualify (`* text=auto`); the scan stops after this many entries. +// Bound scanned entries too: few matching text files would otherwise traverse the entire drvfs tree. export const LINE_ENDING_SCAN_LIMIT = 10 * LINE_ENDING_SAMPLE_LIMIT -// Fewer eligible files than this is no evidence either way. +// Smaller samples cannot establish checkout ownership. export const LINE_ENDING_MIN_EVIDENCE = 5 -// Why a TTL: a re-clone at the same path can change the answer. +// Re-cloning at the same path can change the answer. export const WSL_WINDOWS_CHECKOUT_TTL_MS = 10 * 60_000 const CACHE_LIMIT = 256 @@ -111,13 +95,7 @@ export function isFresh(entry: { at: number } | undefined, now: number): boolean return entry !== undefined && now - entry.at < WSL_WINDOWS_CHECKOUT_TTL_MS } -/** - * Effective `core.autocrlf` from `git config --show-origin --get-regexp`, the way Windows Git - * resolves it (last value wins). Only `true` makes Windows Git write CRLF. - * - * Why core.eol is ignored: with autocrlf=true Windows Git checks out CRLF regardless of core.eol - * (measured: local core.eol=lf still leaves Windows Git and the flagged WSL Git both clean). - */ +/** Last autocrlf value wins; true forces CRLF regardless of core.eol. */ export function parseHostAutocrlf(showOriginOutput: string): 'true' | 'other' { let value: string | null = null for (const line of showOriginOutput.split(/\r?\n/)) { @@ -129,13 +107,7 @@ export function parseHostAutocrlf(showOriginOutput: string): 'true' | 'other' { return value !== null && ['true', 'yes', 'on', '1'].includes(value) ? 'true' : 'other' } -/** - * Classifies a bounded `ls-files --eol` sample of LF blobs with no eol attribute. - * - * The first index entries, not just top-level files: root files are the ones formatters re-save - * as LF, the ones attributes pin, and absent in some repos. Majority of CRLF working files means Windows Git - * (autocrlf=true) wrote this checkout; one CRLF README in a Linux checkout does not. - */ +/** Sample nested index entries: root files may be formatted as LF or have pinned eol attributes. */ export function classifyLineEndingSample( output: string, outputTruncated: boolean @@ -163,14 +135,7 @@ export function classifyLineEndingSample( return crlf * 2 > crlf + lf ? 'windows' : 'not-windows' } -/** - * Whether this command runs with Windows Git's settings. - * - * Reads need only the host's autocrlf=true: the flags never make a read less correct, even on an - * LF checkout, so an unfinished sample must not leave status flashing every file as modified. - * Writes change bytes on disk or in the index, so they need a fresh sample proving CRLF; until then - * they only normalize what they stage. - */ +/** Reads can use host autocrlf immediately; writes need fresh CRLF evidence before changing checkout bytes. */ export function windowsCheckoutGitConfigArgs( args: readonly string[], state: Readonly | undefined, @@ -190,7 +155,6 @@ export function windowsCheckoutGitConfigArgs( : UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS } -/** Whether this command runs with Windows Git's full settings, including minimal stat. */ export function usesWindowsCheckoutGitConfig( args: readonly string[], state: Readonly | undefined, @@ -199,7 +163,6 @@ export function usesWindowsCheckoutGitConfig( return windowsCheckoutGitConfigArgs(args, state, now) === WINDOWS_CHECKOUT_GIT_CONFIG_ARGS } -/** `args` with Windows Git's settings prepended when this WSL command needs them. */ export function withWslWindowsCheckoutGitConfig( args: string[], cwd: string | undefined, diff --git a/src/main/git/wsl-windows-checkout-git-probe.ts b/src/main/git/wsl-windows-checkout-git-probe.ts index 954beb1fe63..d73c177c0bc 100644 --- a/src/main/git/wsl-windows-checkout-git-probe.ts +++ b/src/main/git/wsl-windows-checkout-git-probe.ts @@ -30,8 +30,7 @@ const SAMPLE_PROBE_TIMEOUT_MS = 15_000 const RETRY_BASE_MS = 30_000 const RETRY_MAX_MS = 10 * 60_000 const HOST_CONFIG_ARGS = ['config', '--show-origin', '--get-regexp', '^core\\.(autocrlf|eol)$'] -// Exit 3 carries rev-parse's own diagnostic. The first head bounds what git reads even when grep -// matches nothing; the second bounds the sample. +// Exit 3 preserves rev-parse failure; cap scanned entries even when no sample lines match. export const SAMPLE_SCRIPT = [ 'git -C "$1" rev-parse --is-inside-work-tree >/dev/null || exit 3', "tab=$(printf '\\t')", @@ -104,7 +103,7 @@ async function admitted( request: { args: string[]; cwd: string; wslDistro?: string; tier?: GitAdmissionTier }, work: () => Promise ): Promise { - // Why: hundreds of cold worktrees must queue behind the git scheduler, not spawn at once. + // Queue cold probes behind Git admission rather than spawning every checkout at once. const grant = await (deps.admit ?? acquireGitAdmission)(request) try { return await work() @@ -134,7 +133,7 @@ async function probeHost( } } if (answer === null) { - // Why: no host git, dubious ownership, or a timeout is "unknown", never "not Windows". + // Missing host Git, dubious ownership and timeouts leave ownership unknown. rememberFailure(`host\0${key}`, now()) return } @@ -216,7 +215,7 @@ function waitUnlessAborted( if (!work || !signal) { return work ?? Promise.resolve() } - // Why: an aborted command stops waiting; the shared probe keeps running for the next caller. + // Aborting a command stops only its wait; shared probes serve later callers. return new Promise((resolve) => { const done = (): void => { signal.removeEventListener('abort', done) @@ -254,7 +253,7 @@ async function settle( return } if (isRead) { - // Reads are already correct with the flags; the sample only has to catch up for later writes. + // Reads need no sample wait; refresh evidence for later writes in the background. if (!isFresh(state.sample, now())) { void runStage('sample', key, options, deps) } @@ -274,10 +273,7 @@ async function settle( } } -/** - * Settles what a WSL git command on a Windows drive needs to know about its checkout, or null when - * the command is not one. Never rejects; an unknown answer runs the command unflagged. - */ +/** Never rejects; unresolved ownership falls back to conservative config, null means no applicable command. */ export function pendingWslWindowsCheckoutGitConfig( args: readonly string[], options: { diff --git a/src/main/git/wsl-windows-checkout-sample-script.test.ts b/src/main/git/wsl-windows-checkout-sample-script.test.ts index 5ee646b205f..1d451668d7d 100644 --- a/src/main/git/wsl-windows-checkout-sample-script.test.ts +++ b/src/main/git/wsl-windows-checkout-sample-script.test.ts @@ -10,8 +10,7 @@ const TAB = '\t' const TEXT_AUTO_LINE = `i/lf w/lf attr/text=auto ${TAB}file.txt` const ELIGIBLE_LINE = `i/lf w/crlf attr/ ${TAB}file.txt` -// Runs the probe's real shell pipeline against a stand-in git whose `ls-files --eol` never ends, -// like a huge drvfs tree: only a bounded scan lets the pipeline finish. +// An endless stand-in ls-files stream verifies that the real sample pipeline bounds its input. describe.skipIf(process.platform === 'win32')('line-ending sample pipeline', () => { let binDir: string diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index ab581ab3942..06efc4079e2 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -25,7 +25,6 @@ import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-de const OLD_ENV = process.env -/** Serve the remote URL plus the #9171 default-branch resolver probes. */ function primeGitExecWithDefaultBranch(defaultRef = 'refs/remotes/origin/main'): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'remote') { @@ -255,10 +254,8 @@ describe('Gitea client', () => { const fetchMock = vi.fn(async () => Response.json({ message: 'unauthorized' }, { status: 403 })) vi.stubGlobal('fetch', fetchMock) - // The swallowing variant returns null — indistinguishable from "no PR". await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toBeNull() - // The throwing variant makes the failure visible so eligibility records - // `unavailable` rather than a false "No pull request found". + // Strict lookup must distinguish unavailable transport from a confirmed missing review. await expect(getGiteaPullRequestForBranchOrThrow('/repo', 'feature/gitea')).rejects.toThrow( /Gitea request failed/ ) diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index ba768bb8131..90415dc0279 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -16,9 +16,7 @@ import { import { cancelUnreadResponseBody } from '../lib/unread-response-body' const REQUEST_TIMEOUT_MS = 5000 -// Why: self-hosted Forgejo can take ~5s to serve one /pulls page (it loads -// reviewer data per PR). The default 5s cap aborted responses right as they -// completed, so the work was discarded and retried on the next refresh (#8807). +// Forgejo may spend several seconds loading reviewers for one pulls page. const PULL_REQUEST_LIST_TIMEOUT_MS = 15_000 const PULL_REQUEST_PAGE_LIMIT = 50 const MAX_PULL_REQUEST_PAGES = 5 @@ -81,9 +79,7 @@ async function requestJsonAtBase( baseUrl: string, path: string, options: RequestOptions = {}, - // Why: the existing-review lookup behind Create must distinguish a real - // transport/auth failure from an accepted "no PR". When true, a failed request - // throws instead of collapsing to null so callers never report false not_found. + // Creation needs transport/auth failures distinguished from confirmed absence. throwOnFailure = false ): Promise { const config = getAuthConfig() @@ -128,8 +124,7 @@ function giteaPullRequestScanKey(repo: GiteaRepoRef): string { return `${configuredApiBaseUrl(repo)}/${encodedRepoPath(repo)}` } -/** Invalidate the shared /pulls scan after Orca itself creates a PR so the - * next worktree-card refresh sees it instead of a cached miss. */ +/** Invalidate after creation so card refreshes cannot reuse a cached miss. */ export function invalidateGiteaPullRequestScanForRepo(repo: GiteaRepoRef): void { const repoKey = giteaPullRequestScanKey(repo) invalidateGiteaPullRequestScan(repoKey) @@ -261,9 +256,7 @@ export async function getGiteaPullRequestForBranch( if (branchName) { const pullRequests = await scanGiteaPullRequests( - // Why: throwing scans keep a separate cache namespace so they never reuse a - // prior swallowing scan's cached-empty from a failed page fetch, which would - // otherwise let a real lookup failure masquerade as "no PR". + // Strict scans must not inherit an empty result produced by a failed lenient scan. throwOnFailure ? `${giteaPullRequestScanKey(repo)}::strict` : giteaPullRequestScanKey(repo), (page) => requestJson( @@ -285,8 +278,6 @@ export async function getGiteaPullRequestForBranch( ) const raw = pullRequests.find((item) => matchesBranch(item, branchName)) if (raw) { - // Why (#9171): discard a non-open implicit branch match on the repo - // default branch and fall through to the linked-number fallback below. const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ state: mapGiteaPullRequestState(raw), reviewNumber: raw.number ?? null, @@ -314,11 +305,7 @@ export async function getGiteaPullRequestForBranch( return raw ? normalizePullRequest(repo, raw) : null } -/** - * Existing-review lookup that surfaces transport/auth failures instead of - * collapsing them to null, so a failed lookup becomes - * `reviewLookupOutcome: 'unavailable'` rather than a false "No pull request found". - */ +/** Surface lookup failures as unavailable rather than a false missing review. */ export function getGiteaPullRequestForBranchOrThrow( repoPath: string, branch: string, diff --git a/src/main/gitea/pull-request-creation.ts b/src/main/gitea/pull-request-creation.ts index 6fe76d2734b..7761a59dbc1 100644 --- a/src/main/gitea/pull-request-creation.ts +++ b/src/main/gitea/pull-request-creation.ts @@ -107,8 +107,7 @@ async function findExistingPullRequest( head: string, connectionId?: string | null ): Promise<{ number: number; url: string } | null> { - // Why: only called after a create attempt, which may have just mutated the - // remote — a cached /pulls scan from before the POST would miss the new PR. + // A create attempt may already have mutated the remote; discard pre-POST listings. const repo = await getGiteaRepoRef(repoPath, connectionId) if (repo) { invalidateGiteaPullRequestScanForRepo(repo) diff --git a/src/main/gitea/pull-request-mappers.ts b/src/main/gitea/pull-request-mappers.ts index 9e877f14bb8..f48097dc183 100644 --- a/src/main/gitea/pull-request-mappers.ts +++ b/src/main/gitea/pull-request-mappers.ts @@ -93,8 +93,7 @@ export function mapGiteaPullRequestState( if (raw.merged) { return 'merged' } - // Closed Gitea PRs can still carry the draft flag; terminal state should - // win so review summaries do not show closed PRs as active drafts. + // Closed PRs can retain their draft flag; terminal state takes precedence. if (raw.state?.trim().toLowerCase() === 'closed') { return 'closed' } diff --git a/src/main/gitea/pull-request-scan-cache.ts b/src/main/gitea/pull-request-scan-cache.ts index 8864f087d6d..72a3c9a9aa2 100644 --- a/src/main/gitea/pull-request-scan-cache.ts +++ b/src/main/gitea/pull-request-scan-cache.ts @@ -8,21 +8,16 @@ type GiteaPullRequestScanEntry = { pullRequests: RawGiteaPullRequest[] } -// Why: long enough to absorb a push-event burst that refreshes every worktree -// card at once, short enough that a PR opened outside Orca shows up promptly. +// Absorb card-refresh bursts while promptly discovering externally created PRs. const SCAN_TTL_MS = 30_000 -// Why: a short failure cooldown still coalesces rapid card retries without -// turning a transient outage into a 30-second authoritative "no PR" result. +// Retry failures sooner than successful listings so outages do not look like durable misses. const FAILED_SCAN_RETRY_MS = 3_000 -// Why: each entry can retain hundreds of full PR payloads, so TTL alone is not -// enough protection when many repositories are opened during one app session. +// Each payload can retain hundreds of PRs; TTL alone does not bound session memory. const MAX_SCAN_CACHE_ENTRIES = 32 const scanCache = new Map() const inFlightScans = new Map>() -// Why: an invalidation (PR just created) must also defeat a scan already in -// flight — otherwise that scan finishes afterwards and re-caches a listing -// from before the mutation, hiding the new PR for a full TTL. +// Fence in-flight scans so invalidation cannot recache pre-create listings. const scanGenerations = new Map() const activeScanCounts = new Map() @@ -68,21 +63,13 @@ function reusableScanCacheEntry(repoKey: string): GiteaPullRequestScanEntry | nu removeScanCacheEntry(repoKey, entry) return null } - // Keep the cap useful for users actively switching among several repositories. + // Refresh recency for users switching among repositories. scanCache.delete(repoKey) scanCache.set(repoKey, entry) return entry } -/** - * Why: every worktree card resolves its branch by paginating the same - * /repos/{repo}/pulls listing — Gitea/Forgejo have no head-branch filter. - * Self-hosted forges serve that endpoint slowly, and a push event refreshes - * all cards at once, so per-card scans multiplied one page walk into hundreds - * of requests and OOM-killed a small Forgejo pod (#8807). All concurrent - * callers share one in-flight scan per repo, and the result is cached briefly - * so a burst costs a single page walk. - */ +/** Gitea/Forgejo lacks head-branch filtering; share pagination so card bursts cannot overload self-hosted servers. */ export async function scanGiteaPullRequests( repoKey: string, fetchPage: GiteaPullRequestPageFetcher, @@ -135,8 +122,7 @@ export async function scanGiteaPullRequests( } } -/** Drop the cached scan after a mutation Orca itself performed (PR create), - * so the next card refresh sees the new PR instead of a stale miss. */ +/** Invalidate after mutations to prevent stale missing-review results. */ export function invalidateGiteaPullRequestScan(repoKey: string): void { removeScanCacheEntry(repoKey) inFlightScans.delete(repoKey) diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index ea12698f09b..cebdea882e3 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -81,8 +81,7 @@ function makeRepoRef(host: string, path: string, webOrigin: string): GiteaRepoRe return null } - // Why: Gitea/Forgejo can be hosted below a URL subpath. SSH-style remotes - // carry that base path in the repo path, so derive the web/API base here. + // Self-hosted Gitea/Forgejo SSH paths can include the web/API base subpath. const webBaseUrl = parsed.basePath ? `${webOrigin.replace(/\/+$/, '')}/${parsed.basePath}` : webOrigin diff --git a/src/main/github/account-binding-required-host.ts b/src/main/github/account-binding-required-host.ts index f5ce37dced1..7fd38261f8c 100644 --- a/src/main/github/account-binding-required-host.ts +++ b/src/main/github/account-binding-required-host.ts @@ -14,10 +14,7 @@ export type SshAliasHostnameResolver = ( context: SshConfigResolutionContext ) => Promise<{ hostname: string | null }> -/** - * Host filter for Settings GitHub account binding. - * `undefined` means do not filter. - */ +/** undefined leaves account binding unfiltered. */ export async function resolveGhAccountRequiredHost( repo: { path: string @@ -33,9 +30,7 @@ export async function resolveGhAccountRequiredHost( if (!host || !isUnresolvedSshHostAlias(host) || !remoteUrlUsesSshTransport(remoteUrl)) { return host } - // Why: `git@github-personal:owner/repo` keeps the OpenSSH alias in the identity key. - // No gh login is named after that alias, so filtering to it empties the dropdown. - // Resolve in the runtime that runs gh (WSL distro vs host) so both see the same ssh config. + // Expand SSH aliases in the runtime that runs gh; aliases themselves are not gh login hosts. const context: SshConfigResolutionContext = { repoPath: repo.path, connectionId: repo.connectionId ?? null, diff --git a/src/main/github/auth-diagnose.test.ts b/src/main/github/auth-diagnose.test.ts index 7735910dc83..98b537f9cd6 100644 --- a/src/main/github/auth-diagnose.test.ts +++ b/src/main/github/auth-diagnose.test.ts @@ -101,9 +101,6 @@ ghe.acme.io }) it('recovers host from the Logged-in line when the section header is missing', () => { - // gh prints a colon after the host on some versions; we tolerate it, - // but if the regex ever fails to match the header we still want - // accounts attributed to the host from the inline message. const text = ` ✓ Logged in to github.acme.io account carol (keyring) - Active account: true - Token scopes: 'project' @@ -143,8 +140,6 @@ ghe.acme.io expect(diag.requiredHost).toBe('ghe.acme.io') expect(diag.requiredHostAuthenticated).toBe(true) expect(diag.activeAccount?.user).toBe('bob') - // Why: bob's GHES token lacks project/read:org even though alice's - // github.com token has them — advice must reflect the required host. expect(diag.missingScopes).toEqual(['project', 'read:org']) }) diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts index e5eda3b9f82..a07e9a362ed 100644 --- a/src/main/github/auth-diagnose.ts +++ b/src/main/github/auth-diagnose.ts @@ -1,44 +1,18 @@ -/** - * gh CLI auth diagnostics. - * - * Why: when project queries fail with "missing scope", the canned - * remediation `gh auth refresh -s project ...` silently no-ops if the user - * has `GITHUB_TOKEN` (or `GH_TOKEN`) exported in their shell — gh prefers - * env tokens over keyring credentials and refuses to refresh env-supplied - * tokens. Users follow the instructions, see no error, retry, and stay - * stuck. This probe makes that failure mode legible in the UI. - * - * Output is parsed from `gh auth status`, which prints free-form text but - * uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.). - */ +// Environment tokens override keyring credentials and cannot be refreshed through gh auth refresh. import { ghExecFileAsync } from '../git/runner' import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github/auth-types' -// Required scopes for ProjectV2 GraphQL access in Orca. `project` is the -// scope that gates ProjectV2 reads/writes; the others are needed for the -// surrounding repo/org queries we already run. +// ProjectV2 and surrounding repository/organization queries require these scopes. const REQUIRED_SCOPES = ['project', 'read:org', 'repo'] as const -/** - * Parse `gh auth status` stderr/stdout. gh writes to stderr by default but - * has used stdout in some versions; we accept either. Format (per host): - * - * github.com - * ✓ Logged in to github.com account NAME (GITHUB_TOKEN) - * - Active account: true - * - Token scopes: 'gist', 'read:org', 'repo' - */ +/** gh auth status has used both streams; accept either supplied diagnostic text. */ export function parseAuthStatus(text: string): GhAuthAccount[] { const accounts: GhAuthAccount[] = [] let currentHost: string | null = null let current: GhAuthAccount | null = null for (const rawLine of text.split('\n')) { const line = rawLine.replace(/\r$/, '') - // Host header: a non-indented hostname token, with an optional - // trailing colon some gh versions emit. Permits single-label hostnames - // (internal GHES like `github` or `ghe-internal`); we also recover the - // host from the `Logged in to ` line below if this header was - // missed, so a parser miss never silently drops every account. + // Accept single-label Enterprise hosts and optional trailing colons. const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*(?::\d+)?)\s*:?\s*$/i) if (hostMatch && !/^logged\b/i.test(line)) { currentHost = hostMatch[1] @@ -49,13 +23,9 @@ export function parseAuthStatus(text: string): GhAuthAccount[] { if (current) { accounts.push(current) } - // Prefer the host from the `Logged in to ` line itself — it's - // always present, whereas the section header above can be skipped - // by the regex on unfamiliar gh output. + // Inline host labels survive unfamiliar section-header formats. const host = loggedIn[1] || currentHost || 'github.com' const sourceLabel = (loggedIn[3] ?? '').trim() - // gh emits "(keyring)" for stored creds and "(GITHUB_TOKEN)" / - // "(GH_TOKEN)" when an env var is shadowing the keyring. const envToken = sourceLabel === 'GITHUB_TOKEN' || sourceLabel === 'GH_TOKEN' ? sourceLabel : null current = { @@ -97,10 +67,7 @@ export async function diagnoseGhAuth( let raw = '' let ghAvailable = true try { - // `gh auth status` exits non-zero when no host is logged in but still - // prints the same diagnostic text we want, so capture both streams. - // Why: auth is ambient (no Repo.ghAccount) but must still run on the - // repo's WSL/native execution host so inventory matches token resolution. + // Capture diagnostics even on nonzero exit; use the repo runtime so inventory matches token resolution. const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'], { ...(execOptions.cwd ? { cwd: execOptions.cwd } : {}), ...(execOptions.wslDistro ? { wslDistro: execOptions.wslDistro } : {}) @@ -118,7 +85,6 @@ export async function diagnoseGhAuth( raw = `${stdout}\n${stderr}` if (!raw.trim()) { const message = err instanceof Error ? err.message : String(err) - // Most likely cause: gh CLI not installed or not on PATH. if (/ENOENT|not found|command not found/i.test(message)) { ghAvailable = false } @@ -126,8 +92,7 @@ export async function diagnoseGhAuth( } } const accounts = parseAuthStatus(raw) - // Why: when the caller names a host (a GHES origin), scope the diagnosis to - // that host's account — the github.com account's scopes are irrelevant to it. + // Diagnose only the requested host's account; other hosts' scopes do not apply. const normalizedRequiredHost = requiredHost?.trim().toLowerCase() || null const hostAccounts = normalizedRequiredHost ? accounts.filter((a) => a.host.toLowerCase() === normalizedRequiredHost) @@ -144,10 +109,7 @@ export async function diagnoseGhAuth( const missingScopes = active ? REQUIRED_SCOPES.filter((s) => !active.scopes.includes(s)) : [...REQUIRED_SCOPES] - // Is there a non-env (keyring) account we could fall back to by unsetting - // the env var? Only meaningful if the active account is env-shadowed, and - // only if the keyring login is on the SAME host — otherwise unsetting the - // env var leaves the user with no credential for the host that was active. + // Unsetting an environment token helps only when keyring credentials exist for the same host. const keyringFallback = active ? (accounts.find((a) => a.source === 'keyring' && a.host === active.host) ?? null) : null diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 6abfa5908cf..20b342a80bc 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -58,8 +58,6 @@ import { createGitHubPullRequest } from './client/create/create-github-pull-requ import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) @@ -69,9 +67,7 @@ describe('createGitHubPullRequest', () => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() - // Why: createGitHubPullRequest resolves its target via the explicit origin - // remote (getOwnerRepo became upstream-first in #7331). Delegate the origin - // probe to getOwnerRepoMock so existing tests keep defining it there. + // Bridge explicit-origin creation to the fixtures that define getOwnerRepoMock. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null @@ -139,9 +135,7 @@ describe('createGitHubPullRequest', () => { }) it('targets the origin fork (not the upstream parent) on a fork checkout (#7331)', async () => { - // Fork checkout: origin is the personal fork, upstream is the parent. The - // head branch is unqualified and lives on the fork, so `gh pr create` must - // run with --repo even though PR reads prefer upstream since #7331. + // Creation targets the fork holding the unqualified head branch; reads can prefer upstream. getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => remoteName === 'origin' ? { owner: 'fsdwen', repo: 'orca' } @@ -177,15 +171,12 @@ describe('createGitHubPullRequest', () => { }) it('routes --repo to the Enterprise server via options.host for a GHES remote (#8312)', async () => { - // github.com-only slug parsing misses GHES, so creation comes from the - // enterprise resolver, which carries the host. getOwnerRepoMock.mockResolvedValueOnce(null) getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({ owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }) - // gh prints the PR URL (not JSON); the GHES host must still parse directly. ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'https://github.acme-corp.com/team/orca/pull/7\n' }) @@ -208,8 +199,7 @@ describe('createGitHubPullRequest', () => { }) const [args, options] = ghExecFileAsyncMock.mock.calls[0] - // The runner host-qualifies argv at spawn time from options.host, so the - // mocked call sees a bare owner/repo plus the host in exec options. + // The real runner qualifies argv from options.host after this mocked boundary. expect(args[args.indexOf('--repo') + 1]).toBe('team/orca') expect(options).toMatchObject({ host: 'github.acme-corp.com' }) }) @@ -221,7 +211,6 @@ describe('createGitHubPullRequest', () => { repo: 'orca', host: 'github.acme-corp.com' }) - // Create reports "already exists", forcing the pr-list fallback. ghExecFileAsyncMock .mockRejectedValueOnce( Object.assign(new Error('exists'), { @@ -421,8 +410,7 @@ describe('createGitHubPullRequest', () => { '--json', 'number,url' ], - // Why: dotcom slugs resolve with host:'github.com' so creation stays - // pinned against a process-level GH_HOST. + // Pin dotcom against process-level GH_HOST. { cwd: '/repo-root', host: 'github.com' } ]) }) diff --git a/src/main/github/client-file-viewed.test.ts b/src/main/github/client-file-viewed.test.ts index ba0a7fb291c..feeb477985a 100644 --- a/src/main/github/client-file-viewed.test.ts +++ b/src/main/github/client-file-viewed.test.ts @@ -20,7 +20,6 @@ vi.mock('./gh-utils', () => ({ gitExecFileAsync: vi.fn(), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), - // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. getOwnerRepoForRemote: ( repoPath: string, remoteName: string, @@ -57,8 +56,6 @@ import { setPRFileViewed } from './client/update/pr-file-viewed' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) @@ -156,8 +153,7 @@ describe('setPRFileViewed', () => { }) ).resolves.toBe(true) - // The runner injects --hostname at spawn time from options.host, so the - // mocked call sees unqualified argv plus the Enterprise host in options. + // Hostname injection happens inside the real runner, after this mocked boundary. expect(ghExecFileAsyncMock).toHaveBeenCalledWith( expect.arrayContaining(['api', 'graphql', 'pullRequestId=PR_enterprise']), { host: 'github.acme-corp.com' } diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 57ad87387e2..cb65529b507 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -60,8 +60,7 @@ vi.mock('./github-api-repository', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - // Why: these suites drive source resolution through the legacy gh-utils - // mocks; bridge the hosted seams onto the same mocks. + // Bridge hosted source resolution to the existing gh-utils fixtures. resolveIssueGitHubApiRepositorySource: ( repoPath: string, preference: unknown, @@ -138,9 +137,7 @@ function decodedIssueSearchPath(callIndex: number): string { return decodeURIComponent(apiPath ?? '') } -// Route resolvePrWorkItemSource's per-remote probes: origin delegates to -// getOwnerRepoMock (so existing tests keep defining origin through it) and -// upstream returns the given candidate. +// Share origin fixtures while letting candidate tests opt into upstream. function mockUpstreamCandidate(upstream: { owner: string; repo: string } | null): void { getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => @@ -163,17 +160,11 @@ describe('GitHub issue source split', () => { acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) - // Why: default the preference-aware resolver to 'auto' semantics so the - // pre-existing test cases (which don't think about preference at all) - // still pass. `listWorkItems` now calls `resolveIssueSource` instead of - // `getIssueOwnerRepo` directly — we delegate back to the single-call - // mock to preserve the one-fetch-per-test invariant each test sets up. + // Preserve one source lookup per test while exercising the preference-aware resolver. resolveIssueSourceMock.mockImplementation(async () => ({ source: await getIssueOwnerRepoMock(), fellBack: false })) - // Why: keep origin on the legacy mock while hosted candidate tests opt in - // to upstream behavior explicitly. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null @@ -305,7 +296,6 @@ describe('GitHub issue source split', () => { 'HTTP 503: Service Unavailable' ) - // The outage signal reuses the failed request; it must not add retry subprocesses. expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) } ) @@ -321,7 +311,6 @@ describe('GitHub issue source split', () => { 'HTTP 503: Service Unavailable' ) - // Classification must reuse the issue and PR requests, not retry the outage. expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) @@ -392,8 +381,6 @@ describe('GitHub issue source split', () => { fellBack: true }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - // beforeEach default: upstream probe resolves null, origin delegates to - // getOwnerRepoMock. ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) const result = await listWorkItems('/repo-root', 10, 'is:pr', undefined, 'upstream') @@ -589,8 +576,7 @@ describe('GitHub issue source split', () => { await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toBeNull() - // The first candidate uses `pr view` and its REST compatibility fallback; - // neither failure is a 404, so a same-number PR on origin is never queried. + // Only a confirmed 404 permits trying a same-number PR on another repository. expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect( ghExecFileAsyncMock.mock.calls.some(([args]) => @@ -601,9 +587,7 @@ describe('GitHub issue source split', () => { it('raw number lookup tries upstream issue before origin PR', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - // Why: simulate a real gh 404 (the only error type that should fall through). - // Non-404 errors re-throw so transient upstream failures don't misroute to an - // unrelated origin PR with the same number. + // Non-404 upstream failures must not route to an unrelated origin PR with the same number. ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ @@ -643,10 +627,7 @@ describe('GitHub issue source split', () => { }) it('surfaces a 403 from upstream issues through the listWorkItems envelope', async () => { - // Why: parent design doc §3 / acceptance criterion 2 — the IPC envelope - // must carry a classified error for the failing side so the renderer can - // swap the empty-state for a retryable banner. `sources` must stay - // populated so the banner copy can name the repo that failed. + // Preserve source metadata with classified failures so the retry banner can name its repository. getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) ghExecFileAsyncMock @@ -664,10 +645,6 @@ describe('GitHub issue source split', () => { }) it('returns partial results when upstream issues fail but origin PRs succeed', async () => { - // Why: parent design doc §2 partial-failure rule — a failing source must - // not zero out the succeeding source. The UI renders origin PRs with a - // banner above the list, not an empty state. Ensures the IPC shape - // carries both the successful items and the error for the failing side. getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) ghExecFileAsyncMock @@ -696,8 +673,6 @@ describe('GitHub issue source split', () => { }) it('raw number lookup does not fall through on transient upstream errors', async () => { - // Why: with issue source split, a non-404 upstream failure must not silently - // route to origin's PR #N — that would return an unrelated item. getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 500: server error')) @@ -709,11 +684,6 @@ describe('GitHub issue source split', () => { }) describe('per-repo issue-source preference', () => { - // Why: 3 preference states × 2 remote-topology states = 6 cases per the - // design doc §9. These tests isolate `listWorkItems` against a mocked - // `resolveIssueSource` to verify the preference is threaded all the way - // to the gh call and that `fellBack` propagates into the envelope. - it("preference='auto' + upstream exists → queries upstream", async () => { resolveIssueSourceMock.mockResolvedValueOnce({ source: { owner: 'stablyai', repo: 'orca' }, @@ -734,9 +704,7 @@ describe('GitHub issue source split', () => { }) it("preference='auto' + upstream exists → PRs query upstream too", async () => { - // Why: fork-contribution PRs live on the upstream repo — the fork's own - // PR list is almost always empty. 'auto' must resolve PRs upstream-first - // like issues, or the PRs tab renders "No matching GitHub work" on forks. + // Fork contribution PRs live upstream; automatic selection must query that namespace. resolveIssueSourceMock.mockResolvedValueOnce({ source: { owner: 'stablyai', repo: 'orca' }, fellBack: false @@ -802,9 +770,7 @@ describe('GitHub issue source split', () => { }) it('surfaces upstreamCandidate in sources regardless of effective preference', async () => { - // Why: the renderer selector needs to keep rendering after the user picks - // 'origin'. That requires the envelope to carry the raw upstream even - // when `sources.issues` has collapsed onto origin. + // Preserve raw upstream metadata so the selector can still switch back after choosing origin. resolveIssueSourceMock.mockResolvedValueOnce({ source: { owner: 'fork', repo: 'orca' }, fellBack: false diff --git a/src/main/github/client-merged-pr-visibility.test.ts b/src/main/github/client-merged-pr-visibility.test.ts index 6ec9cd5242a..0ac3e7ab418 100644 --- a/src/main/github/client-merged-pr-visibility.test.ts +++ b/src/main/github/client-merged-pr-visibility.test.ts @@ -213,8 +213,6 @@ describe('getPRForBranch', () => { expect(pr).toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) - // A definitive "not part of this PR" answer is immutable for a merged PR: - // repeated polls must not re-probe GitHub. mockMergedBranchPRLookupBehindHead() const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake') @@ -258,7 +256,6 @@ describe('getPRForBranch', () => { const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(second).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' }) - // No fourth membership call: the confirmed answer is immutable and cached. expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(5) }) @@ -312,9 +309,7 @@ describe('getPRForBranch', () => { } ) - // Why: the merged-implicit head probe must use the supplied oid — no - // `rev-parse HEAD` shell-out. (The #9171 guard may still probe the repo - // default branch for this non-open implicit result; that is unrelated.) + // Use the supplied head without rev-parse; independent default-branch probes remain allowed. expect( gitExecFileAsyncMock.mock.calls.some( (call) => call[0][0] === 'rev-parse' && call[0][1] === 'HEAD' @@ -398,8 +393,7 @@ describe('getPRForBranch', () => { it('stamps linked merged divergence when a later membership page proves absence', async () => { mockMergedLinkedPRLookup() - // Page 1 is full and omits the linked PR (truncated), but page 2 is short and - // still omits it — that pair definitively proves the head is not contained. + // A short second page proves absence after a full first page. const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) @@ -417,8 +411,7 @@ describe('getPRForBranch', () => { it('leaves linked merged divergence unset when membership pages stay full to the cap', async () => { mockMergedLinkedPRLookup() - // Every page up to the cap is full and omits the linked PR, so absence can - // never be proven — the probe must stay unknown rather than clear the link. + // Full pages through the cap cannot prove absence, so preserve the durable link. const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) for (let page = 0; page < 5; page += 1) { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) @@ -435,9 +428,7 @@ describe('getPRForBranch', () => { }) it('stamps linked merged divergence via the PR url when no repo candidates resolve', async () => { - // Fallback path: no resolved candidates, so `gh pr view` returns the PR with - // dataRepo=null. The membership probe must still run against the repo derived - // from the PR's own URL so a diverged merged linked PR can clear. + // Without resolved candidates, derive the membership-probe repository from the PR URL. resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) ghExecFileAsyncMock .mockResolvedValueOnce({ diff --git a/src/main/github/client-pr-branch-discovery.test.ts b/src/main/github/client-pr-branch-discovery.test.ts index 916a6320844..e9571266979 100644 --- a/src/main/github/client-pr-branch-discovery.test.ts +++ b/src/main/github/client-pr-branch-discovery.test.ts @@ -294,7 +294,6 @@ describe('getPRForBranch', () => { it('returns null for empty branch (e.g. during rebase with detached HEAD)', async () => { const pr = await getPRForBranch('/repo-root', '') expect(pr).toBeNull() - // Should not call gh at all expect(execFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index 9972bedc4f2..baae0fcd171 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -17,7 +17,6 @@ vi.mock('./gh-utils', () => ({ ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: vi.fn(), - // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. getOwnerRepoForRemote: ( repoPath: string, remoteName: string, diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index aa8b31a476c..1b78ce5380f 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -57,7 +57,6 @@ vi.mock('./gh-utils', () => ({ }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, - // Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo. getOwnerRepoForRemote: ( repoPath: string, remoteName: string, @@ -90,8 +89,6 @@ import { _resetOwnerRepoCache } from './gh-utils' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) diff --git a/src/main/github/client-pr-conflict-summary.test.ts b/src/main/github/client-pr-conflict-summary.test.ts index ed15023968f..024e80031c3 100644 --- a/src/main/github/client-pr-conflict-summary.test.ts +++ b/src/main/github/client-pr-conflict-summary.test.ts @@ -322,9 +322,7 @@ describe('getPRForBranch', () => { baseRefOid: 'base-oid', headRefOid: 'head-oid' } - // Why a second head OID: identical inputs now hit the summary result - // cache outright; a pushed head re-derives and must still skip the - // unsupported --merge-base retry via the capability cache. + // Change head OID to bypass the result cache while retaining the learned Git capability. const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } } const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' } ghExecFileAsyncMock diff --git a/src/main/github/client-pr-fallback-number.test.ts b/src/main/github/client-pr-fallback-number.test.ts index c3e6282347d..7be9fe18490 100644 --- a/src/main/github/client-pr-fallback-number.test.ts +++ b/src/main/github/client-pr-fallback-number.test.ts @@ -193,7 +193,6 @@ describe('getPRForBranch', () => { throw new Error('expected upstream-error') } expect(outcome.errorType).toBe('rate_limited') - // ~120s cooldown surfaced as both the manual gate and the auto-retry time. expect(outcome.retryDisabledUntil).toBeDefined() expect(outcome.nextAutoRetryAt).toBe(outcome.retryDisabledUntil) expect(outcome.retryDisabledUntil ?? 0).toBeGreaterThanOrEqual(before + 119_000) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index f9d7eb91111..70d26d26d39 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -122,8 +122,6 @@ import { import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) @@ -135,7 +133,6 @@ describe('GitHub PR local runtime routing', () => { getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() - // Why: host-less prRepo backfill and origin resolution use getOwnerRepoForRemote. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null @@ -578,8 +575,7 @@ describe('GitHub PR local runtime routing', () => { const prViewCall = ghExecFileAsyncMock.mock.calls.find( ([args]) => args[0] === 'pr' && args[1] === 'view' ) - // The runner host-qualifies argv at spawn time from options.host, so the - // mocked call sees the unqualified --repo plus the host in exec options. + // Host qualification happens inside the real runner, after this mocked boundary. expect(prViewCall?.[0]).toEqual(expect.arrayContaining(['--repo', 'team/orca'])) expect(prViewCall?.[1]).toEqual({ host: 'github.acme-corp.com:8443' }) const prCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr') diff --git a/src/main/github/client-pr-push-target.test.ts b/src/main/github/client-pr-push-target.test.ts index 1e13e41c3ec..7b193541e57 100644 --- a/src/main/github/client-pr-push-target.test.ts +++ b/src/main/github/client-pr-push-target.test.ts @@ -173,15 +173,12 @@ describe('getPRForBranch', () => { }) it('keeps getRepoSlug origin-based on a fork checkout (#7331)', async () => { - // The slug is the checkout's own identity (renderer display, icon - // autodetect); it must not flip to the upstream parent. getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => remoteName === 'origin' ? { owner: 'fsdwen', repo: 'orca' } : { owner: 'stablyai', repo: 'orca' } ) - // Why: getRepoSlug imports getOriginGitHubApiRepository; the suite bridge - // prefers getOwnerRepoForRemote for origin, so set both seams. + // Set both fixture seams because the bridge prefers the explicit origin probe. getOwnerRepoMock.mockResolvedValue({ owner: 'fsdwen', repo: 'orca' }) await expect(getRepoSlug('/repo-root')).resolves.toEqual({ @@ -192,7 +189,6 @@ describe('getPRForBranch', () => { }) it('resolves a distinct upstream remote as the repo upstream', async () => { - // getRepoUpstream probes origin then upstream via getOwnerRepoForRemote (#7331). getOwnerRepoMock.mockResolvedValue({ owner: 'tmchow', repo: 'orca' }) getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => remoteName === 'origin' @@ -200,7 +196,6 @@ describe('getPRForBranch', () => { : { owner: 'stablyai', repo: 'orca' } ) - // Why: the suite bridge returns getOwnerRepoForRemote fixtures as-is (no host pin). await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' @@ -223,15 +218,13 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - // Why: positional slugs are explicit about github.com too, so GH_HOST - // cannot redirect them. + // Explicit positional host prevents ambient GH_HOST redirection. ['repo', 'view', 'github.com/StablyAI/Orca', '--json', 'isFork,parent'], { cwd: '/repo-root', host: 'github.com', timeout: 10_000 } ) }) it('does not mark an upstream-only GitHub remote as a fork', async () => { - // Missing origin short-circuits before the upstream probe. getOwnerRepoForRemoteMock.mockResolvedValueOnce(null) await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() @@ -255,7 +248,6 @@ describe('getPRForBranch', () => { await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', repo: 'orca', - // Why: fork parents live on the same server as the fork's origin. host: 'github.com' }) }) @@ -285,8 +277,6 @@ describe('getPRForBranch', () => { await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ pushTarget: { remoteName: 'origin', branchName: 'feature' } }) - // Why: the candidate probe must pin options.host so the runner targets the - // Enterprise server instead of gh's default host. expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/team/orca/pulls/7'], expect.objectContaining({ host: 'github.acme-corp.com' }) diff --git a/src/main/github/client-pr-state.test.ts b/src/main/github/client-pr-state.test.ts index 50a7fc04f41..71bb32716e9 100644 --- a/src/main/github/client-pr-state.test.ts +++ b/src/main/github/client-pr-state.test.ts @@ -46,7 +46,6 @@ describe('pull request state mutations', () => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() - // Why: updatePRState resolves origin through getOwnerRepoForRemote. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null diff --git a/src/main/github/client-ssh-provider-execution-boundary.test.ts b/src/main/github/client-ssh-provider-execution-boundary.test.ts index 875fd10406d..b3ede4bbe85 100644 --- a/src/main/github/client-ssh-provider-execution-boundary.test.ts +++ b/src/main/github/client-ssh-provider-execution-boundary.test.ts @@ -111,9 +111,7 @@ function mockUpstreamOnlyPRLookup(): void { }) } -// The strict execution boundary: for an SSH-hosted repoPath, an unregistered -// provider means "we could not ask" — it must never degrade into a client-side -// git run, which on a same-named local path answers for the wrong repository. +// Missing SSH providers must not probe a same-named local path and report the wrong repository. describe('getPRForBranch SSH execution boundary', () => { beforeEach(() => { resetPRForBranchMocks(clientMocks) diff --git a/src/main/github/client-stack-merge-guard.test.ts b/src/main/github/client-stack-merge-guard.test.ts index 859ef902d98..7078e9933ae 100644 --- a/src/main/github/client-stack-merge-guard.test.ts +++ b/src/main/github/client-stack-merge-guard.test.ts @@ -538,7 +538,7 @@ describe('GitHub GraphQL rate-limit guard', () => { } else { ghExecFileAsyncMock.mockResolvedValueOnce(scenario.probeResponse) } - // Why: disabling the guard must expose the legacy merge fallthrough, not fail on an unstubbed call. + // Stub legacy merge fallthrough so disabling the guard cannot fail on an unrelated missing fixture. ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ diff --git a/src/main/github/client-starred.test.ts b/src/main/github/client-starred.test.ts index 3e3bf4357f3..d1d8ced3830 100644 --- a/src/main/github/client-starred.test.ts +++ b/src/main/github/client-starred.test.ts @@ -30,7 +30,6 @@ import { resetOriginRepositoryCache } from './client-test-harness' const { execFileAsyncMock, ghExecFileAsyncMock, acquireMock, releaseMock } = clientMocks -/** Let the coalesced check reach its `await acquire()` continuation and spawn gh. */ async function flushMicrotasks(): Promise { for (let i = 0; i < 5; i += 1) { await Promise.resolve() @@ -77,16 +76,11 @@ describe('checkOrcaStarred', () => { await expect(checkOrcaStarred()).resolves.toBe(null) }) - // ── #18234: an unbounded, unreaped, un-deduped star check ────────────── - it('never spawns gh directly, so the spawn carries a deadline and a tree kill', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) await checkOrcaStarred() - // Why: the raw execFileAsync has no timeout, so a `gh` that never exits ran - // forever at 100% CPU and was never reaped. ghExecFileAsync bounds the child - // and kills its process tree on the deadline. expect(execFileAsyncMock).not.toHaveBeenCalled() const [, options] = ghExecFileAsyncMock.mock.calls[0] expect(typeof options.timeout).toBe('number') @@ -107,8 +101,6 @@ describe('checkOrcaStarred', () => { const third = checkOrcaStarred() await flushMicrotasks() - // Why: five call sites can ask at once; without coalescing each forked its - // own `gh` and four stuck children exhausted the GitHub semaphore. expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(acquireMock).toHaveBeenCalledTimes(1) @@ -131,8 +123,6 @@ describe('checkOrcaStarred', () => { await expect(checkOrcaStarred()).resolves.toBe(null) - // Why: a leaked slot is permanent — four of them wedge every GitHub feature - // in the app for the rest of the session. expect(releaseMock).toHaveBeenCalledTimes(1) expect(acquireMock).toHaveBeenCalledTimes(1) }) diff --git a/src/main/github/client-test-harness.ts b/src/main/github/client-test-harness.ts index ae8e3c8a692..c7985f2a5da 100644 --- a/src/main/github/client-test-harness.ts +++ b/src/main/github/client-test-harness.ts @@ -9,8 +9,6 @@ import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-de import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' import { _resetGitHubPRStackCacheForTests } from './github-pr-stack' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. export function resetOriginRepositoryCache(): void { _resetOriginGitHubApiRepositoryCache() } @@ -22,7 +20,7 @@ export function resetPRForBranchMocks(mocks: GitHubClientMocks): void { mocks.getOwnerRepoMock.mockReset() mocks.getIssueOwnerRepoMock.mockReset() mocks.getOwnerRepoForRemoteMock.mockReset() - // Why: resolveGitHubRepoExecution probes origin via getOwnerRepoForRemote. + // Route origin probes through the suite's repository fixture. mocks.getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? mocks.getOwnerRepoMock(repoPath, connectionId, opts) : null @@ -53,8 +51,6 @@ export function resetPRForBranchMocks(mocks: GitHubClientMocks): void { __resetTrackedUpstreamBranchCacheForTests() __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() - // Why: the #9171 guard caches default-branch resolutions per repoPath; - // reset so non-open implicit lookups stay order-independent across tests. __resetRepoDefaultBranchCacheForTests() } @@ -65,7 +61,6 @@ export function resetGraphQLRateLimitGuardMocks(mocks: GitHubClientMocks): void mocks.getOwnerRepoMock.mockReset() mocks.getIssueOwnerRepoMock.mockReset() mocks.getOwnerRepoForRemoteMock.mockReset() - // Why: getPRComments and mutations resolve origin via getOwnerRepoForRemote. mocks.getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? mocks.getOwnerRepoMock(repoPath, connectionId, opts) : null diff --git a/src/main/github/client-test-mocks.ts b/src/main/github/client-test-mocks.ts index 11762e87b7c..df940c3f12e 100644 --- a/src/main/github/client-test-mocks.ts +++ b/src/main/github/client-test-mocks.ts @@ -28,9 +28,7 @@ export type GitHubClientMocks = { releaseMock: Mock } -// Why: every client suite registers its own `vi.mock` factories (hoisting is -// per-file), so the mock set and the factory bodies live here and the test file -// only wires them together. +// Vitest hoists per file; suites register these shared factories locally. export function createGitHubClientMocks(): GitHubClientMocks { return { execFileAsyncMock: vi.fn(), @@ -135,7 +133,7 @@ export type LocalGitObjectQuarantineModuleMock = { } } -// Why: client tests script git calls in order; the scratch object store adds a common-dir read. +// Isolate scripted Git queues from quarantine's additional common-dir probe. export function localGitObjectQuarantineModuleMock(): LocalGitObjectQuarantineModuleMock { return { createLocalGitObjectQuarantine: () => ({ run: (command) => command(undefined) }) @@ -175,9 +173,7 @@ export function rateLimitModuleMock(mocks: GitHubClientMocks): RateLimitModuleMo getRateLimit: mocks.getRateLimitMock, rateLimitGuard: mocks.rateLimitGuardMock, noteRateLimitSpend: mocks.noteRateLimitSpendMock, - // Why: the repository-scoped guards share the same bucket-keyed budget as the - // legacy ones, so delegate to the existing mocks to keep per-bucket blocking - // and spend assertions working unchanged. + // Keep repository guards on the same bucket fixtures as the legacy entry points. repositoryRateLimitGuard: (_repository: unknown, bucket: string) => mocks.rateLimitGuardMock(bucket), noteRepositoryRateLimitSpend: (_repository: unknown, bucket: string) => @@ -192,9 +188,7 @@ export function githubApiRepositoryModuleMock( ): typeof GithubApiRepositoryModule { return { ...actual, - // Why: these suites inject repo identities through the legacy gh-utils - // mocks; bridge the hosted seams onto the same mocks so per-test setups - // keep driving resolution without real enterprise probes. + // Resolve hosted seams through the suite's repository fixtures, avoiding real Enterprise probes. resolveGitHubApiRepositoryCandidates: ( repoPath: string, connectionId?: string | null, @@ -210,8 +204,7 @@ export function githubApiRepositoryModuleMock( connectionId?: string | null, localGitOptions?: unknown ) => { - // Prefer the remote-specific mock (production origin path); fall back for - // suites that only configure getOwnerRepo. + // Prefer explicit-origin fixtures; older suites define only getOwnerRepo. const fromRemote = await mocks.getOwnerRepoForRemoteMock( repoPath, 'origin', @@ -220,12 +213,10 @@ export function githubApiRepositoryModuleMock( ) const slug = fromRemote ?? (await mocks.getOwnerRepoMock(repoPath, connectionId, localGitOptions)) - // Mirror production: dotcom origin slugs come back pinned to github.com. return slug ? { host: 'github.com', ...slug } : slug }, getIssueGitHubApiRepository: async (repoPath: string, connectionId?: string | null) => { const slug = await mocks.getIssueOwnerRepoMock(repoPath, connectionId) - // Mirror production: issue slugs come back host-qualified to github.com. return slug ? { host: 'github.com', ...slug } : slug }, resolveIssueGitHubApiRepositorySource: async ( diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts index 3cf563fb61d..6e81ed28677 100644 --- a/src/main/github/client-tracked-upstream-default-branch.test.ts +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -1,4 +1,3 @@ -// #26948: tracking a base branch must not attach its integration PR to a feature branch. import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' @@ -44,7 +43,6 @@ const ACME = { owner: 'acme', repo: 'widgets' } const UPSTREAM = { owner: 'stablyai', repo: 'widgets' } const CONTRIBUTOR = { owner: 'contributor', repo: 'widgets' } -/** remoteHeads maps a remote to the branch its `refs/remotes//HEAD` points at. */ function primeGit( trackedUpstream: string, remoteHeads: Record = { origin: 'develop' } @@ -154,7 +152,6 @@ function primeGh( ) } -/** A fork clone: `origin` is the fork, `upstream` is the repo PRs target. */ function primeForkWithUpstream(): void { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [UPSTREAM, ACME], diff --git a/src/main/github/client-work-item-check-summary.test.ts b/src/main/github/client-work-item-check-summary.test.ts index bdcb1cd5866..1698eae16e4 100644 --- a/src/main/github/client-work-item-check-summary.test.ts +++ b/src/main/github/client-work-item-check-summary.test.ts @@ -52,7 +52,7 @@ import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge- import { _resetOwnerRepoCache } from './gh-utils' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// Why: only `pr view` is fixtured; the merge-metadata fan-out is best-effort and must not mask the summary. +// Only fixture pr view; best-effort merge metadata failures must not hide its summary. function mockPullRequestDetail(payload: Record): void { ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] !== 'pr' || args[1] !== 'view') { @@ -83,7 +83,7 @@ describe('work item checksSummary', () => { updatedAt: '2026-04-01T00:00:00Z', statusCheckRollup: [ { __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SKIPPED' }, - // Why: StatusContext carries no status/conclusion — only `state`. + // StatusContext supplies state instead of CheckRun's status/conclusion. { __typename: 'StatusContext', state: 'SUCCESS' }, { __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'NEUTRAL' } ] diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index eb8237586ef..8433f7f7e87 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -78,7 +78,6 @@ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), - // Mirror production: shared-scope calls delegate to the global guard/spend. repositoryRateLimitGuard: repositoryRateLimitGuardMock, noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock @@ -88,8 +87,7 @@ vi.mock('./github-api-repository', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - // Why: these suites drive source resolution through the legacy gh-utils - // mocks; bridge the hosted seams onto the same mocks. + // Bridge hosted source resolution to the suite's gh-utils fixtures. resolveIssueGitHubApiRepositorySource: ( repoPath: string, preference: unknown, @@ -123,13 +121,9 @@ import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-item import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) -// Why: split from client-work-items.test.ts to keep both suites under the -// max-lines cap; this file owns query/paging request-shaping cases. describe('listWorkItems query paging', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -149,9 +143,7 @@ describe('listWorkItems query paging', () => { acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) - // Why: preference-aware `listWorkItems` calls `resolveIssueSource`. - // Route through the same `getIssueOwnerRepoMock` so existing tests that - // only set up `getIssueOwnerRepoMock` continue to work. + // Preserve one source lookup through the preference-aware resolver. resolveIssueSourceMock.mockImplementation(async () => ({ source: await getIssueOwnerRepoMock(), fellBack: false @@ -271,7 +263,7 @@ describe('listWorkItems query paging', () => { it('lifts a swallowed PR-side failure onto errors.prs instead of reading as end-of-data', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - // Non-availability failure (plain 403): swallowed into [], must still surface. + // A forbidden source must remain distinguishable from an empty listing. ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403: Forbidden')) const envelope = await listWorkItems('/repo-root', 10, 'is:pr is:open', 2) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index f49738d6a56..948526e1354 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -70,7 +70,6 @@ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), - // Mirror production: shared-scope calls delegate to the global guard/spend. repositoryRateLimitGuard: vi.fn((_repo: unknown, bucket: string) => rateLimitGuardMock(bucket)), noteRepositoryRateLimitSpend: vi.fn((_repo: unknown, bucket: string, cost?: number) => noteRateLimitSpendMock(bucket, cost) @@ -82,8 +81,7 @@ vi.mock('./github-api-repository', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - // Why: these suites drive source resolution through the legacy gh-utils - // mocks; bridge the hosted seams onto the same mocks. + // Bridge hosted source resolution to the suite's gh-utils fixtures. resolveIssueGitHubApiRepositorySource: ( repoPath: string, preference: unknown, @@ -118,8 +116,6 @@ import { _resetRemoteNameListingCache } from '../git/remote-name-listing' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) @@ -139,17 +135,12 @@ describe('listWorkItems', () => { acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) - // Why: preference-aware `listWorkItems` calls `resolveIssueSource`. - // Route through the same `getIssueOwnerRepoMock` so existing tests that - // only set up `getIssueOwnerRepoMock` continue to work. + // Preserve one source lookup through the preference-aware resolver. resolveIssueSourceMock.mockImplementation(async () => ({ source: await getIssueOwnerRepoMock(), fellBack: false })) - // Why: since #7331 `resolvePrWorkItemSource` fetches origin through - // `getOwnerRepoForRemote` (getOwnerRepo became upstream-first). Delegate - // the origin probe to `getOwnerRepoMock` so existing tests keep defining - // the PR-side origin through it; default upstream to null. + // Bridge explicit origin to getOwnerRepoMock; upstream candidates require opt-in fixtures. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null @@ -169,8 +160,6 @@ describe('listWorkItems', () => { const calls = ghExecFileAsyncMock.mock.calls expect(calls.length).toBeGreaterThan(0) - // Why: every list spawn must pin options.host so the runner targets the - // Enterprise server instead of gh's default host. expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true) }) @@ -178,8 +167,7 @@ describe('listWorkItems', () => { getIssueOwnerRepoMock.mockResolvedValue(null) getOwnerRepoMock.mockResolvedValue(null) - // Why: unresolved sources must return empty null-source envelopes (not throw, not query) so the renderer can - // distinguish "no GitHub source detected" from genuine zero via `sources` (#9660 follow-up). + // Null-source envelopes distinguish unavailable repository identity from successful empty work. await expect(listWorkItems('/private-repo', 36)).resolves.toMatchObject({ items: [], sources: { issues: null, prs: null } @@ -369,8 +357,6 @@ describe('listWorkItems', () => { null, localGitOptions ) - // Why: the suite bridge routes origin through getOwnerRepoMock and non-origin - // remotes through getOwnerRepoForRemoteMock. expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( '/repo-root', diff --git a/src/main/github/client/check/get-pr-checks.ts b/src/main/github/client/check/get-pr-checks.ts index d9ff4097ee0..2c04bc0b828 100644 --- a/src/main/github/client/check/get-pr-checks.ts +++ b/src/main/github/client/check/get-pr-checks.ts @@ -77,7 +77,7 @@ export async function getPRChecksViaRestFallback( .map(mapRestCommitStatus) .filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name)) } catch (err) { - // Why: REST fallback is already degraded; keep the richer check-run rows if legacy-status enrichment fails. + // Keep richer check-run rows when best-effort legacy-status enrichment fails. console.warn('getPRChecks REST status fallback failed:', err) } @@ -117,11 +117,7 @@ export async function getPRChecksViaRestFallback( } } -/** - * Get detailed check statuses for a PR. - * Uses GitHub's combined GraphQL rollup so check runs and legacy commit statuses - * arrive in one cached request; suite-only approval blockers are included too. - */ +/** Read the combined GraphQL rollup, including legacy statuses and suite-only approval blockers. */ async function readPRChecks( repoPath: string, prNumber: number, @@ -151,7 +147,7 @@ async function readPRChecks( } const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => { const { stderr } = extractExecError(err) - // Why: `gh pr checks` exits non-zero when a PR has no check runs yet; treat that as empty, not a load failure. + // gh pr checks signals an empty run set with nonzero exit. if (stderr.toLowerCase().includes('no checks reported')) { return { stdout: '[]', stderr } } @@ -182,7 +178,7 @@ async function readPRChecks( if (canUseGraphQLRollup) { await acquire() try { - // Why: --cache 60s saves rate-limit budget during polling; explicit refresh skips it for fresh data. + // Explicit refresh bypasses the polling cache. const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] const { stdout } = await ghExecFileAsync( [ @@ -209,7 +205,7 @@ async function readPRChecks( return checks } } catch (err) { - // Why: fall back to older `gh pr checks` when GitHub's richer rollup query is unavailable. + // Older CLI/API schemas may require the legacy gh pr checks path. console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err) } finally { release() diff --git a/src/main/github/client/check/pr-checks-response-mapping.ts b/src/main/github/client/check/pr-checks-response-mapping.ts index 362bbf6e137..6b8dda7279d 100644 --- a/src/main/github/client/check/pr-checks-response-mapping.ts +++ b/src/main/github/client/check/pr-checks-response-mapping.ts @@ -100,7 +100,7 @@ export function mapGraphQLPendingApprovalCheckSuite( name: getPendingApprovalCheckSuiteName(suite, headSha, index), status: 'completed', conclusion: 'action_required', - // Why: suite-only approval blockers have no check run; link the suite page when GraphQL exposes one. + // Approval blockers may have a suite URL without any check run. url: nullableString(suite.url) ?? (headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null) @@ -131,7 +131,7 @@ export function mapGraphQLPRChecksResponse( .map((context) => nullableNumber(context.checkSuite?.databaseId)) .filter((id): id is number => id !== null) ) - // Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts in the same rollup; keep check-run metadata on name collisions. + // Include legacy statuses, but preserve richer check-run metadata on name collisions. const legacyStatuses = contexts .filter(isGraphQLStatusContext) .map(mapGraphQLStatusContext) diff --git a/src/main/github/client/check/rerun-pr-checks.ts b/src/main/github/client/check/rerun-pr-checks.ts index 82059bab664..f2bcea83a64 100644 --- a/src/main/github/client/check/rerun-pr-checks.ts +++ b/src/main/github/client/check/rerun-pr-checks.ts @@ -1,6 +1,6 @@ import type { GitHubRerunPRChecksResult } from '../../../../shared/github/check-types' import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' -// Why: pure error helpers come from their own modules so tests that mock gh-utils still classify for real. +// Keep real error parsing available when tests mock gh-utils. import { extractExecError } from '../../../git/exec-error' import { classifyRerunChecksError } from '../../gh-error-classification' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' @@ -91,7 +91,7 @@ export async function rerunPRChecks( } catch (err) { const { stderr } = extractExecError(err) const classified = classifyRerunChecksError(stderr).message - // Why: these POSTs are not idempotent — say how many reruns already started so a retry isn't blind. + // Rerun POSTs are not idempotent; report partial success before the user retries. return { ok: false, error: diff --git a/src/main/github/client/create/add-pr-review-comment.ts b/src/main/github/client/create/add-pr-review-comment.ts index b3c51440a6a..6096bd05b26 100644 --- a/src/main/github/client/create/add-pr-review-comment.ts +++ b/src/main/github/client/create/add-pr-review-comment.ts @@ -54,7 +54,7 @@ export async function addPRReviewComment( } const { stdout } = await ghExecFileAsync(fields, ghOptions) const data = JSON.parse(stdout) as Parameters[0] - // Why: mapReviewCommentResponse substitutes Date.now() for a missing id, which later replies/reactions would target. + // Reject synthetic fallback IDs that replies and reactions could otherwise target. if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) { return { ok: false, error: 'Unexpected response from GitHub' } } diff --git a/src/main/github/client/create/create-github-pull-request.ts b/src/main/github/client/create/create-github-pull-request.ts index db201c88e76..915451b8f58 100644 --- a/src/main/github/client/create/create-github-pull-request.ts +++ b/src/main/github/client/create/create-github-pull-request.ts @@ -42,7 +42,7 @@ export async function createGitHubPullRequest( // `gh` runs on this client whatever the host; only the git reads under it are routed. const connectionId = hostedReviewSshConnectionId(executionHostId) - // Why: creation targets the origin owning the unqualified head branch; the shared resolver preserves its host (#7331, #8312). + // The unqualified head branch belongs to origin; retain its resolved host. const ownerRepo = await getOriginGitHubApiRepository( repoPath, connectionId, @@ -55,7 +55,7 @@ export async function createGitHubPullRequest( error: 'Creating pull requests requires a GitHub remote.' } } - // The runner host-qualifies --repo from options.host for GHES (#8312). + // The runner qualifies --repo using options.host. const repoArg = `${ownerRepo.owner}/${ownerRepo.repo}` const base = normalizeHostedReviewBaseRef(input.base) diff --git a/src/main/github/client/create/create-pr-error-classification.ts b/src/main/github/client/create/create-pr-error-classification.ts index a8dfd571368..18ae0553ce3 100644 --- a/src/main/github/client/create/create-pr-error-classification.ts +++ b/src/main/github/client/create/create-pr-error-classification.ts @@ -65,7 +65,7 @@ export function parseCreatePRPayload(stdout: string): { number: number; url: str } catch { // Fall through to URL parsing for older gh versions without --json support. } - // Why: match any host (not just github.com) so a GHES PR URL still parses (#8312). + // Enterprise PR URLs use the same path shape on custom hosts. const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/) if (!urlMatch) { return null diff --git a/src/main/github/client/detect/hydrate-work-item-merge-metadata.ts b/src/main/github/client/detect/hydrate-work-item-merge-metadata.ts index c1505d80d55..f856603cc48 100644 --- a/src/main/github/client/detect/hydrate-work-item-merge-metadata.ts +++ b/src/main/github/client/detect/hydrate-work-item-merge-metadata.ts @@ -12,7 +12,7 @@ export async function hydrateWorkItemRepositoryMergeMetadata( if (!ownerRepo || !hasPullRequest) { return items } - // Why: merge settings are repo-level, so one cached probe keeps Tasks rows accurate without per-PR GraphQL fan-out. + // Repository-scoped settings share one probe instead of per-PR GraphQL fan-out. const mergeMetadata = await detectRepositoryMergeMetadata( ownerRepo, undefined, diff --git a/src/main/github/client/detect/repository-merge-metadata-cache.ts b/src/main/github/client/detect/repository-merge-metadata-cache.ts index 15d74740ca8..718fed6280a 100644 --- a/src/main/github/client/detect/repository-merge-metadata-cache.ts +++ b/src/main/github/client/detect/repository-merge-metadata-cache.ts @@ -38,7 +38,7 @@ export function cacheRepositoryMergeMetadata( ): void { const now = Date.now() pruneRepositoryMergeMetadataCache(now) - // Why: merge metadata is keyed by user-controlled branch names; keep the cache bounded across many short-lived branches. + // User-controlled branch names require a bound independent of expiry. repositoryMergeMetadataCache.delete(cacheKey) repositoryMergeMetadataCache.set(cacheKey, { value, diff --git a/src/main/github/client/detect/repository-merge-metadata.ts b/src/main/github/client/detect/repository-merge-metadata.ts index a7f8f8179d4..446d8cb1e08 100644 --- a/src/main/github/client/detect/repository-merge-metadata.ts +++ b/src/main/github/client/detect/repository-merge-metadata.ts @@ -102,7 +102,7 @@ export async function detectRepositoryMergeMetadata( typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null, ...(mergeMethodSettings ? { mergeMethodSettings } : {}) } - // Why: a payload without `repository` is all-unknown; keep it on the short TTL so it retries once the condition clears. + // Missing repository data remains unknown and retries on the short TTL. cacheRepositoryMergeMetadata( cacheKey, value, @@ -110,7 +110,7 @@ export async function detectRepositoryMergeMetadata( ) return value } catch { - // Why: cache a conservative result for failed merge-queue probes so we don't retry GraphQL on every poll while GitHub/network is unhappy. + // Cool down failed probes without treating unknown merge-queue state as false. const value: GitHubRepositoryMergeMetadata = { mergeQueueRequired: null, autoMergeAllowed: null diff --git a/src/main/github/client/fetch/authenticated-viewer.ts b/src/main/github/client/fetch/authenticated-viewer.ts index 551ffe81377..b6d6521a4b6 100644 --- a/src/main/github/client/fetch/authenticated-viewer.ts +++ b/src/main/github/client/fetch/authenticated-viewer.ts @@ -1,13 +1,6 @@ import type { GitHubViewer } from '../../../../shared/github/pull-request-types' import { ghExecFileAsync, acquire, release } from '../../gh-utils' -/** - * Get the authenticated GitHub viewer when gh is available and logged in. - * Returns null when gh is unavailable, unauthenticated, or the lookup fails. - * - * Runs through `ghExecFileAsync` for its deadline and tree kill: a `gh` that - * never exits would otherwise hold one of the four GitHub concurrency slots - * forever (#18234). - */ +/** Use the bounded runner so failed viewer probes return null without stranding a GitHub admission slot. */ export async function getAuthenticatedViewer(): Promise { await acquire() try { diff --git a/src/main/github/client/fetch/get-pr-comments.ts b/src/main/github/client/fetch/get-pr-comments.ts index 06df95e29fb..e2f3e216354 100644 --- a/src/main/github/client/fetch/get-pr-comments.ts +++ b/src/main/github/client/fetch/get-pr-comments.ts @@ -6,11 +6,7 @@ import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from '../.. import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit' import { REVIEW_THREADS_QUERY } from './pr-review-threads-query' -/** - * Get all comments on a PR — both top-level conversation comments and inline - * review comments (including suggestions). Uses GraphQL for review threads - * to get resolution status, REST for issue-level comments. - */ +/** Combine conversation comments, review summaries and GraphQL threads with resolution metadata. */ export async function getPRComments( repoPath: string, prNumber: number, @@ -33,11 +29,11 @@ export async function getPRComments( await acquire() try { if (ownerRepo) { - // Why: --cache 60s saves rate-limit budget on normal loads; explicit refresh skips it for fresh data. + // Explicit refresh bypasses the polling cache. const cacheArgs = options?.noCache ? [] : ['--cache', '60s'] const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}` - // Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero. + // Retain successful comment sources when another endpoint fails. const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null> if (reviewThreadsGuard.blocked) { @@ -66,7 +62,7 @@ export async function getPRComments( ghOptions ), reviewThreadsFetch, - // Why: review summaries (approve/request-changes/general) live under pulls/{n}/reviews, not issue comments or threads. + // Review summaries live outside issue comments and inline threads. ghExecFileAsync( ['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`], ghOptions @@ -74,7 +70,6 @@ export async function getPRComments( ]) noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) - // Parse issue comments (REST) type RESTComment = { id: number user: { login: string; avatar_url: string; type?: string } | null @@ -99,7 +94,6 @@ export async function getPRComments( console.warn('Failed to fetch issue comments:', issueResult.reason) } - // Parse review threads (GraphQL) type GQLThread = { id: string isResolved: boolean @@ -143,8 +137,7 @@ export async function getPRComments( } | null } | null } - // Why: graphql can exit 0 with data.repository null plus an errors array (scopes, field-level denial); - // dereferencing it would throw and drop the REST halves fetched alongside it. + // GraphQL can exit 0 with a null repository and errors; retain the successful REST halves. const pullRequest = threadsData.data?.repository?.pullRequest if (!pullRequest) { console.warn('Review threads response missing pullRequest; keeping REST results') @@ -163,7 +156,7 @@ export async function getPRComments( if (graphQLIssueComments.length > 0) { issueComments = graphQLIssueComments } - // Why: leave undefined when the payload is incomplete so the REST review summaries stay in use. + // Incomplete GraphQL payloads must preserve REST review summaries. graphQLReviewSummaries = pullRequest ? (pullRequest.reviews?.nodes ?? []) .filter((review) => review.body?.trim()) @@ -197,7 +190,7 @@ export async function getPRComments( threadId: thread.id, isResolved: thread.isResolved, isOutdated: thread.line == null, - // Why: GitHub nulls line/startLine when the commented code is outdated (e.g. force-push); originalLine preserves the original numbers. + // Outdated code nulls current line numbers; retain original positions. line: thread.line ?? thread.originalLine ?? undefined, startLine: thread.startLine ?? thread.originalStartLine ?? undefined }) @@ -209,7 +202,7 @@ export async function getPRComments( } } - // Review summaries (REST); skip empty-body reviews (e.g. approvals with no comment) as noise. + // Empty-body approvals add no conversation text. type RESTReview = { id: number user: { login: string; avatar_url: string; type?: string } | null @@ -242,7 +235,7 @@ export async function getPRComments( return all } - // Fallback: non-GitHub remote — use gh pr view (only returns issue-level comments) + // Legacy local fallback returns issue-level comments only. const { stdout } = await ghExecFileAsync( ['pr', 'view', String(prNumber), '--json', 'comments'], ghOptions diff --git a/src/main/github/client/fetch/get-work-item.ts b/src/main/github/client/fetch/get-work-item.ts index 5224061dfa3..cf4583e33c5 100644 --- a/src/main/github/client/fetch/get-work-item.ts +++ b/src/main/github/client/fetch/get-work-item.ts @@ -47,8 +47,7 @@ export async function getWorkItemWithRepository( ): Promise { await acquire() try { - // Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number - // must share that preference so origin/upstream toggles cannot disagree. + // Number-based issue lookup must honor the same selected source as its list. if (type === 'issue') { const { source } = await resolveIssueGitHubApiRepositorySource( repoPath, @@ -56,8 +55,7 @@ export async function getWorkItemWithRepository( connectionId, localGitOptions ) - // Why: explicit origin with no origin identity must not bare-lookup ambient gh - // (same fail-closed rule as origin-pinned PR candidate resolution). + // Explicit origin cannot fall back to ambient gh repository discovery. if (!source && preference === 'origin') { return { item: null, repository: null } } @@ -99,7 +97,7 @@ export async function getWorkItemWithRepository( } } } catch (err) { - // Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR. + // Only genuine issue 404s permit trying a same-number PR; preserve transient failures. const stderr = err instanceof Error ? err.message : String(err) if (classifyGhError(stderr).type !== 'not_found') { throw err @@ -146,8 +144,7 @@ export async function getWorkItemByOwnerRepo( const matchedRepository = candidates.find( (candidate) => githubRepoIdentityKey(candidate) === requestedKey ) - // Why: this lookup is reachable from pasted links. Restricting it to a - // configured remote prevents gh from sending credentials to an arbitrary host. + // Pasted hosts must match configured remotes before receiving credentials. if (!matchedRepository) { return null } diff --git a/src/main/github/client/fetch/orca-star.ts b/src/main/github/client/fetch/orca-star.ts index a9006ed544a..398ad4bc858 100644 --- a/src/main/github/client/fetch/orca-star.ts +++ b/src/main/github/client/fetch/orca-star.ts @@ -1,28 +1,14 @@ import { ghExecFileAsync, acquire, release } from '../../gh-utils' export const ORCA_REPO = 'stablyai/orca' -/** - * Deadline for the two star-nag gh calls. - * - * Why bounded at all: these are the only gh call sites that used the raw - * `execFileAsync`, so a `gh` that never exits blocked forever, never released - * its GitHub concurrency slot, and left the child running (#18234). Why shorter - * than the 30s gh default: nothing here is user-visible work — the nag falls - * back to the browser button — so a slow answer is worth less than a bounded one. - */ +// Bound optional star prompts more tightly than user work; failures can fall back to the browser. const STAR_GH_TIMEOUT_MS = 15_000 let inFlightStarCheck: Promise | null = null -/** - * Check if the authenticated user has starred the Orca repo. - * Returns true if starred, false if not, null if unable to determine (gh unavailable). - */ +/** null means the star state could not be determined. */ export function checkOrcaStarred(): Promise { - // Why: five independent callers (landing button, settings section, threshold - // nag, agent-value moment, force-show) can ask at once and none of them knows - // about the others. Without coalescing, each forks its own `gh`, and four - // stuck children exhaust the 4-wide GitHub semaphore for the app's lifetime. + // Independent UI callers share one probe so optional star prompts cannot exhaust GitHub admission. inFlightStarCheck ??= runOrcaStarredCheck().finally(() => { inFlightStarCheck = null }) @@ -48,20 +34,16 @@ async function runOrcaStarredCheck(): Promise { return null } catch (err) { const message = err instanceof Error ? err.message : String(err) - // 404 means the user hasn't starred — the only expected "no" answer + // Only a confirmed 404 establishes that the user has not starred. if (message.includes('HTTP 404')) { return false } - // Anything else (gh not installed, not authenticated, network issue, timeout) return null } finally { release() } } -/** - * Star the Orca repo for the authenticated user. - */ export async function starOrca(): Promise { await acquire() try { diff --git a/src/main/github/client/fetch/pr-review-threads-query.ts b/src/main/github/client/fetch/pr-review-threads-query.ts index c0eb18f53ee..f4a9eaa6451 100644 --- a/src/main/github/client/fetch/pr-review-threads-query.ts +++ b/src/main/github/client/fetch/pr-review-threads-query.ts @@ -1,4 +1,4 @@ -// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them). +// REST review comments omit thread IDs and resolution status; fetch them through GraphQL. export const REVIEW_THREADS_QUERY = ` query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { diff --git a/src/main/github/client/fetch/repo-slug-upstream.ts b/src/main/github/client/fetch/repo-slug-upstream.ts index 0e67b06b4e3..4cbf567f238 100644 --- a/src/main/github/client/fetch/repo-slug-upstream.ts +++ b/src/main/github/client/fetch/repo-slug-upstream.ts @@ -23,12 +23,7 @@ export async function getRepoSlug( ) } -/** - * Resolve a fork's upstream/parent owner/repo, or null when not a fork. - * Why: drives the fork indicator, and a same-name fork's avatar prefers the - * upstream owner (a renamed fork keeps its own owner). - * Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null. - */ +/** Best-effort fork parent for indicators/avatar selection; failures return null. */ export async function getRepoUpstream( repoPath: string, connectionId?: string | null, @@ -56,11 +51,10 @@ export async function getRepoUpstream( } await acquire() try { - // Why: positional slugs bypass the runner's --repo qualifier, so the slug - // itself must carry the Enterprise host. + // Positional slugs bypass --repo qualification, so include the Enterprise host directly. const { stdout } = await ghExecFileAsync( ['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'], - // Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation. + // Do not let optional metadata stall repository creation. { ...ghOptions, timeout: 10_000 @@ -72,7 +66,6 @@ export async function getRepoUpstream( } const owner = data.parent?.owner?.login const repo = data.parent?.name - // Why: a fork parent lives on the same server as the fork. return data.isFork && owner && repo ? { owner, repo, ...(origin.host ? { host: origin.host } : {}) } : null diff --git a/src/main/github/client/fetch/work-item-fetch.ts b/src/main/github/client/fetch/work-item-fetch.ts index f0c227d05e7..0f4579a2d1b 100644 --- a/src/main/github/client/fetch/work-item-fetch.ts +++ b/src/main/github/client/fetch/work-item-fetch.ts @@ -43,8 +43,7 @@ export async function fetchIssueWorkItem( } if (connectionId) { - // Why: SSH-backed gh has no repository cwd. A bare lookup could honor the - // local process GH_REPO/GH_HOST and return an unrelated repository item. + // SSH-backed gh has no local cwd; ambient GH_REPO/GH_HOST could identify unrelated work. return null } @@ -55,7 +54,7 @@ export async function fetchIssueWorkItem( return mapIssueWorkItem(JSON.parse(stdout) as Record) } -// Why: REST /pulls/{n} lacks latestReviews, so pull review fields from gh so reviewer lists aren't silently empty. +// REST pull data omits latestReviews; retain gh's reviewer fields. export const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' export async function fetchPullRequestReviewFields( @@ -117,7 +116,7 @@ export async function fetchPullRequestWorkItem( ) const item = JSON.parse(stdout) as Record const mapped = mapPullRequestWorkItem(item, ownerRepo) - // Why: merge-metadata GraphQL is best-effort — don't fall through to REST, which drops latestReviews and blanks bot-only reviewer lists. + // Best-effort merge metadata must not trigger a REST fallback that drops latestReviews. const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined try { const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) @@ -149,8 +148,7 @@ export async function fetchPullRequestWorkItem( } if (connectionId) { - // Why: connection-backed gh cannot infer a repository from cwd. Refuse a - // bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup. + // Refuse unresolved connection-backed identities instead of following ambient gh repository settings. return null } diff --git a/src/main/github/client/gh-error-predicates.ts b/src/main/github/client/gh-error-predicates.ts index 20ffae19241..32118172ea6 100644 --- a/src/main/github/client/gh-error-predicates.ts +++ b/src/main/github/client/gh-error-predicates.ts @@ -4,7 +4,7 @@ import { safePRRefreshErrorMessage } from '../pr-refresh-error-classification' import { classifyGhError } from '../gh-utils' -// Why: import from the lightweight module (not ./gh-utils) so tests mocking gh-utils still get the real functions. +// Keep real error parsing available when tests mock gh-utils. import { extractExecError, parseRetryAfterMs } from '../../git/exec-error' export function isNoPullRequestError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err) @@ -12,7 +12,7 @@ export function isNoPullRequestError(err: unknown): boolean { } export function isNotFoundGhError(err: unknown): boolean { - // Why: execFile keeps the API diagnostic in stderr; err.message is only "Command failed". + // Runner diagnostics can exist only on stderr, independently of the exit message. return classifyGhError(extractExecError(err).stderr).type === 'not_found' } @@ -30,7 +30,7 @@ export function prRefreshUpstreamError( message: safePRRefreshErrorMessage(errorType), fetchedAt: Date.now() } - // Why: a Retry-After is a real cooldown — surface it as the retry schedule so the renderer doesn't retry into another 429. + // Honor server cooldowns so UI retries cannot immediately hit the same limit. if (errorType === 'rate_limited') { const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr) if (retryAfterMs !== null && retryAfterMs > 0) { diff --git a/src/main/github/client/github-exec-scope.ts b/src/main/github/client/github-exec-scope.ts index fbe3cd78c7a..44683ecea6a 100644 --- a/src/main/github/client/github-exec-scope.ts +++ b/src/main/github/client/github-exec-scope.ts @@ -24,11 +24,11 @@ export function githubPRStackExecutionScope( } export function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean { - // Why: casing does not distinguish GitHub repos, but the same slug on different hosts does. + // Repository casing is insensitive; host identity still distinguishes equal slugs. return Boolean(left && right && githubRepoIdentityKey(left) === githubRepoIdentityKey(right)) } -// Why: exact-linked fallback has no dataRepo; derive its host-aware identity from the web URL for merged-PR membership checks. +// Exact-linked fallback has no dataRepo; use its URL for host-aware merged-commit membership. export function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null { const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) return match ? { owner: match[2], repo: match[3], host: match[1] } : null diff --git a/src/main/github/client/list/count-work-items.ts b/src/main/github/client/list/count-work-items.ts index 570220586f9..4d8c2183d46 100644 --- a/src/main/github/client/list/count-work-items.ts +++ b/src/main/github/client/list/count-work-items.ts @@ -61,12 +61,12 @@ export async function countWorkItemsForQuery( ], ghOptions ) - // Why: over-counting cache hits is the safe direction — the next probe corrects the estimate. + // Overcount cached hits conservatively until the next budget probe. noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions) return Number.parseInt(stdout.trim(), 10) || 0 } -// Why: cached 120s to avoid burning the 30/min search rate limit that backs the pagination total. +// Cache decorative totals to conserve the 30/minute search budget. export async function countWorkItems( repoPath: string, query?: string, @@ -96,7 +96,7 @@ export async function countWorkItems( ...githubHostExecOptions(ownerRepo) } - // Why: counts are decorative, so stop when the 30/min search budget is gone rather than spawn into 403s (getRateLimit is 30s-cached). + // Decorative counts must stop at the search budget floor. if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) { await getRateLimit() } @@ -120,7 +120,7 @@ export async function countWorkItems( } const counts: Promise[] = [] - // Why: draft/reviewRequested/reviewedBy are PR-only, so the issue half would always return 0 — skip it to save a search call. + // PR-only filters cannot match issues; avoid spending a search call on that half. const hasPrOnlyFilter = effectiveQuery.draft || effectiveQuery.reviewRequested !== null || @@ -152,7 +152,7 @@ export async function countWorkItems( ) ) } - // Why: allSettled so one failing search side doesn't zero the total; sum only fulfilled halves. + // Preserve counts from successful sources when another search fails. const results = await Promise.allSettled(counts) let total = 0 for (const r of results) { diff --git a/src/main/github/client/list/list-work-items.ts b/src/main/github/client/list/list-work-items.ts index 10192262f6a..2d4308cdeb1 100644 --- a/src/main/github/client/list/list-work-items.ts +++ b/src/main/github/client/list/list-work-items.ts @@ -38,7 +38,7 @@ export async function listWorkItems( const prOwnerRepo = prResolved.source await acquire() try { - // Why: let errors propagate to IPC — a catch-all would make failure indistinguishable from empty and under-report per-repo failures. + // Preserve fetch failures for IPC; an empty fallback would hide per-repo outages. const partial = !trimmedQuery ? await listRecentWorkItems( repoPath, diff --git a/src/main/github/client/list/work-item-list-request.ts b/src/main/github/client/list/work-item-list-request.ts index 20c1d482f1a..13a8de16854 100644 --- a/src/main/github/client/list/work-item-list-request.ts +++ b/src/main/github/client/list/work-item-list-request.ts @@ -84,7 +84,7 @@ export function buildWorkItemListRequest(args: { } } - // Why: search/issues omits the PR fields the Tasks columns need; use gh's rich PR list on a stable created sort. + // Rich PR fields require gh pr list; align its creation sort with numbered issue searches. searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER) const out = [ 'pr', @@ -101,7 +101,7 @@ export function buildWorkItemListRequest(args: { return { args: out, offset: (page - 1) * limit } } -// Why: shared shape so listWorkItems can lift per-side errors (#1076 silent wrongness) into the IPC envelope — a swallowed side reads as end-of-data to pagination (#11485). +// Carry per-source failures through IPC so pagination cannot mistake them for end-of-data. export type PartialWorkItemsResult = { items: MainWorkItem[] issuesError?: ClassifiedError @@ -116,7 +116,7 @@ export function assertSshRepoHasResolvedGitHubSource(args: { if (!args.connectionId || args.issueOwnerRepo || args.prOwnerRepo) { return } - // Why: SSH repo paths are remote-only, so without a resolved owner/repo gh would query local state. + // SSH-only paths cannot let gh infer a local repository. throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) } @@ -126,9 +126,7 @@ export type ResolvedPrWorkItemSource = { upstreamCandidate: OwnerRepo | null } -// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/ -// undefined keep the multi-candidate probe ordered upstream-first, matching -// resolvePrWorkItemSource list semantics. +/** Only explicit origin is origin-only; other preferences retain upstream-first candidate resolution. */ export async function resolvePrWorkItemSource( repoPath: string, preference: IssueSourcePreference | undefined, @@ -140,9 +138,7 @@ export async function resolvePrWorkItemSource( connectionId, localGitOptions ) - // Why: PR list/count polling must not spawn a failing upstream lookup on - // origin-only clones, while still preserving upstream-first resolution when - // the remote is configured or remote discovery fails open. + // Skip known-absent upstreams; unknown remote discovery remains fail-open. const upstreamCandidatePromise = shouldProbeGitRemote( repoPath, 'upstream', @@ -157,9 +153,7 @@ export async function resolvePrWorkItemSource( originCandidatePromise, upstreamCandidatePromise ]) - // Why: fork-contribution PRs live on the upstream repo (the fork's own PR - // list is almost always empty), so 'auto' resolves upstream-first exactly - // like the issue side. Only an explicit 'origin' pick pins PRs to the fork. + // Fork contribution PRs live upstream; explicit origin alone selects the fork namespace. const source = preference === 'origin' ? originCandidate : (upstreamCandidate ?? originCandidate) return { source, originCandidate, upstreamCandidate } } diff --git a/src/main/github/client/list/work-item-pages.ts b/src/main/github/client/list/work-item-pages.ts index 73871ae1a45..90163bfa7a5 100644 --- a/src/main/github/client/list/work-item-pages.ts +++ b/src/main/github/client/list/work-item-pages.ts @@ -44,8 +44,7 @@ export async function listRecentWorkItems( page }) : null - // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660). - // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner). + // Isolate missing sources to avoid global searches, and retain successful halves on partial failure. const [issuesSettled, prsSettled] = await Promise.allSettled([ issueOwnerRepo ? listIssueWorkItemPage({ @@ -92,8 +91,7 @@ export async function listRecentWorkItems( githubPRStackExecutionScope(connectionId, localGitOptions) ) } else { - // Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076). - // Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop. + // Log the issue error before propagating the PR failure to the cross-repo aggregator. if (issuesError) { console.warn( 'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:', @@ -135,7 +133,7 @@ export async function listQueriedWorkItems( let availabilityError: unknown let prsError: ClassifiedError | undefined - // Why: surface the issue-side error separately for the IPC envelope; PR-side keeps prior swallow-and-log (parent doc §6). + // Preserve classified issue-side failure alongside partial PR results. const issueFetch = (async (): Promise => { if (!issueScope) { return { items: [] } @@ -216,7 +214,7 @@ export async function listQueriedWorkItems( const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) if (availabilityError && successfulRequestCount === 0 && nonAvailabilityFailureCount === 0) { - // Why: when every half hit the same availability failure, propagate it so Tasks can distinguish an outage from no data. + // Shared availability failure across both halves means an outage, not an empty result. throw availabilityError } return { diff --git a/src/main/github/client/list/work-item-search-query.ts b/src/main/github/client/list/work-item-search-query.ts index 979b9a124eb..dfc009337e2 100644 --- a/src/main/github/client/list/work-item-search-query.ts +++ b/src/main/github/client/list/work-item-search-query.ts @@ -1,5 +1,5 @@ import type { ParsedTaskQuery } from '../../../../shared/task-query' -// Why: issue numbers follow creation order, so this sort aligns gh's PR rows with numbered Search API issue pages. +// Creation order aligns rich PR rows with numbered Search API issue pages. export const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc' export function buildSearchQueryString( @@ -15,7 +15,7 @@ export function buildSearchQueryString( if (query.state === 'open') { parts.push('is:open') } else if (query.state === 'closed') { - // Why: GitHub search treats merged PRs as closed; exclude merged so "Closed" means closed-without-merge. + // GitHub considers merged PRs closed; exclude them from the closed-without-merge view. parts.push('is:closed') if (query.scope !== 'issue') { parts.push('-is:merged') diff --git a/src/main/github/client/lookup/branch-lookup-resolution.ts b/src/main/github/client/lookup/branch-lookup-resolution.ts index 4c30a4dad71..fc114b72847 100644 --- a/src/main/github/client/lookup/branch-lookup-resolution.ts +++ b/src/main/github/client/lookup/branch-lookup-resolution.ts @@ -61,15 +61,11 @@ export async function resolvePRForBranchOutcome(input: { connectionId, localGitOptions ) - // Why: connection-backed gh runs without a repository cwd. A bare lookup - // here can honor process GH_REPO/GH_HOST and return an unrelated PR. + // Without local cwd, unresolved connection-backed lookups could follow ambient GH_REPO/GH_HOST. if (connectionId && candidates.length === 0) { return { kind: 'no-pr', fetchedAt: Date.now() } } - // Why (#11532): account every lookup, not just the coordinator's queue — - // `hostedReview:forBranch` reaches this directly from renderer polling and - // was spending the shared quota invisibly. headRepo is `origin`, the same - // identity the coordinator guards on. + // Renderer-direct lookups bypass coordinator accounting; debit both paths here. for (const bucket of PR_BRANCH_LOOKUP_BUCKETS) { noteRepositoryRateLimitSpend(headRepo ?? candidates[0], bucket, 1, ghOptions) } @@ -123,7 +119,7 @@ export async function resolvePRForBranchOutcome(input: { if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) { return false } - // Why: prefer the caller's worktree HEAD; only shell out (main repo path) when no explicit oid, keeping merged-at-head PRs visible for secondary worktrees. + // Prefer the actual worktree head; main-repo HEAD can differ for secondary worktrees. currentHeadOidForMergedImplicit ??= explicitCurrentHeadOid !== null ? explicitCurrentHeadOid @@ -131,7 +127,7 @@ export async function resolvePRForBranchOutcome(input: { if (!shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)) { return false } - // Why: a head that is one of the PR's own commits (update-branch/web commits) is the same work, not a reused branch name — keep the merged PR visible. + // PR-history membership preserves merged work after web commits or update-branch merges. return ( (await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !== 'contained' @@ -165,7 +161,7 @@ export async function resolvePRForBranchOutcome(input: { hasPendingBranchLookupError = true } if (!data) { - // Why: the tracked upstream identifies the real PR head by branch name or fork owner even when local branch names match. + // Tracked upstream disambiguates head branch and fork owner beyond local branch names. const upstreamBranch = await getTrackedUpstreamBranch( repoPath, branchName, @@ -260,18 +256,15 @@ export async function resolvePRForBranchOutcome(input: { explicitCurrentHeadOid !== null && shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) && (await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained' - // Why no lazy-HEAD re-check: fallback numbers were already gated on head equality/containment; re-hiding would blank kept deleted-head merged PRs. + // Fallback preservation already checked head identity; repeating lazy-HEAD checks could hide valid deleted heads. const shouldPreserveMergedFallback = !explicitHeadHidesMergedImplicitPR && (fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true) - // Why: a visible PR can be merged outside Orca; keep a caller-marked fallback fresh even when GitHub no longer reports it by branch (e.g. deleted heads). + // Refresh caller-preserved fallbacks after external merges or head-branch deletion. if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) { return { kind: 'no-pr', fetchedAt: Date.now() } } - // Why (#9171): on the default branch an implicit branch/fallback match must - // never surface a non-open PR — it overrides the merged-fallback - // preservation and merged-at-head carve-out on the trunk only. An exact - // linked lookup returns the linked number, so linked PRs are exempt. + // Default-branch non-open matches stay hidden even at merged HEAD; exact linked lookups remain exempt. if ( await shouldHideNonOpenReviewOnDefaultBranch({ state: mapPRState(data.state, data.isDraft), diff --git a/src/main/github/client/lookup/get-pr-for-branch.ts b/src/main/github/client/lookup/get-pr-for-branch.ts index 58b53f0b435..0d4a98197b8 100644 --- a/src/main/github/client/lookup/get-pr-for-branch.ts +++ b/src/main/github/client/lookup/get-pr-for-branch.ts @@ -1,17 +1,7 @@ import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { GitHubPRBranchLookupOptions } from './pull-request-lookup-data' import { getPRForBranchOutcome } from './pr-for-branch-outcome' -/** - * Get PR info for a given branch using gh CLI. - * Returns null if gh is not installed, or no PR exists for the branch. - * - * When `linkedPRNumber` is provided, it is the source of truth. This handles - * "create from PR" worktrees whose local branch differs from the PR head ref, - * and prevents a coalesced linked-PR refresh from fanning out an unrelated - * branch lookup result to sibling aliases. - * `fallbackPRNumber` is weaker: branch lookup still wins, and exact lookup is - * used only after branch lookup misses. - */ +/** Linked PR numbers are authoritative; fallback numbers apply only after branch lookup misses. */ export async function getPRForBranch( repoPath: string, branch: string, diff --git a/src/main/github/client/lookup/implicit-default-branch-pr.ts b/src/main/github/client/lookup/implicit-default-branch-pr.ts index fb50608d4d7..330f7d86fae 100644 --- a/src/main/github/client/lookup/implicit-default-branch-pr.ts +++ b/src/main/github/client/lookup/implicit-default-branch-pr.ts @@ -68,7 +68,7 @@ function isForeignSameNamedHead( } const headRepo = pullRequestHeadRepository(data, prRepo) const baseRepo = data.url ? ownerRepoFromPullRequestUrl(data.url) : null - // Why: a same-repo PR lives in a checkout remote; its canonical URL survives stale renamed-remote URLs. + // Canonical PR URLs remain usable when same-repo remote URLs are stale. if (headRepo && baseRepo && githubRepoIdentityKey(headRepo) === githubRepoIdentityKey(baseRepo)) { return false } diff --git a/src/main/github/client/lookup/pr-branch-lookup.ts b/src/main/github/client/lookup/pr-branch-lookup.ts index 82fc4494a7c..0a1a24cb419 100644 --- a/src/main/github/client/lookup/pr-branch-lookup.ts +++ b/src/main/github/client/lookup/pr-branch-lookup.ts @@ -111,7 +111,7 @@ export async function lookupPRByBranchName(args: { args.ghOptions ) : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) - // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state. + // Cheap branch discovery needs exact hydration for review, queue and auto-merge metadata. const data = await hydrateBranchLookupWithExactPR( candidate, branchData, @@ -161,7 +161,7 @@ export async function lookupPRByBranchName(args: { } } } - // Why: branch-list failures are ambiguous for fork discovery; give exact fallback-number recovery a chance before surfacing the error. + // Ambiguous fork-list failures still permit exact fallback-number recovery. return hasPendingError ? { data: null, dataRepo: null, pendingError } : { data: null, dataRepo: null } diff --git a/src/main/github/client/lookup/pr-for-branch-outcome.ts b/src/main/github/client/lookup/pr-for-branch-outcome.ts index 33e741b4c24..e9b80e9e4c4 100644 --- a/src/main/github/client/lookup/pr-for-branch-outcome.ts +++ b/src/main/github/client/lookup/pr-for-branch-outcome.ts @@ -22,7 +22,7 @@ export async function getPRForBranchOutcome( options: GitHubPRBranchLookupOptions = {} ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') - // Why: detached HEAD can't use branch lookup, but an exact linked/fallback PR number is still safe to query and keeps review state visible. + // Detached HEAD cannot discover by branch, but explicit PR numbers remain safe. if (!branchName && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber !== 'number') { return { kind: 'no-pr', fetchedAt: Date.now() } } diff --git a/src/main/github/client/lookup/pr-lookup-rate-limit.ts b/src/main/github/client/lookup/pr-lookup-rate-limit.ts index 3905d27622b..60d9c30d403 100644 --- a/src/main/github/client/lookup/pr-lookup-rate-limit.ts +++ b/src/main/github/client/lookup/pr-lookup-rate-limit.ts @@ -7,21 +7,10 @@ import { type RateLimitBucketKind } from '../../rate-limit' import type { GhExecOptions } from './../github-exec-scope' -// Why: a branch lookup prefers REST but can fall back to `gh pr list` and -// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh -// coordinator's own estimate. +// REST branch lookup can fall back to GraphQL-backed gh commands; guard and debit both buckets. export const PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const -/** - * Rate-limit floor for GitHub PR lookups that do not run through the PR refresh - * coordinator's queue (#11532). - * - * The coordinator guards and paces its own background refreshes, but - * `hostedReview:forBranch` polls the same lookup straight from the renderer. - * Ungated, the two paths together could spend the user's entire hourly quota — - * which is per user and shared with their own `gh` and CLI agents. - * Returns the reset time when the caller must not spend, else `null`. - */ +/** Guard direct renderer lookups as well as coordinator work; return the latest blocked reset or null. */ export async function getGitHubPRLookupRateLimitBlock( repoPath: string, connectionId?: string | null, @@ -30,8 +19,7 @@ export async function getGitHubPRLookupRateLimitBlock( const executionOptions = ghRepoExecOptions( githubRepoContext(repoPath, connectionId, localGitOptions) ) - // Why: identity resolution runs local git, which can fail for reasons that - // have nothing to do with the budget; let the lookup itself classify those. + // Identity-probe failures belong to lookup classification, not budget gating. const repository = await getOriginGitHubApiRepository( repoPath, connectionId, @@ -41,12 +29,10 @@ export async function getGitHubPRLookupRateLimitBlock( return null } if (spendsSharedGitHubComQuota(repository, executionOptions)) { - // Why: the probe only warms the snapshot and is exempt from limits, so a - // failure must fail open rather than block the lookup (#7553). + // Exempt budget-probe failures must not block otherwise usable lookups. await getRateLimit().catch(() => undefined) } - // Why: retrying at the earlier reset would fail again on the bucket that has - // not reset yet, so the latest blocked reset is the only honest retry time. + // Retrying before every blocked bucket resets would immediately fail again. const resets = PR_BRANCH_LOOKUP_BUCKETS.map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions) ).flatMap((guard) => (guard.blocked ? [guard.resetAt] : [])) diff --git a/src/main/github/client/lookup/pr-number-lookup.ts b/src/main/github/client/lookup/pr-number-lookup.ts index 3df2bdeaf5b..9aa18ef1cf8 100644 --- a/src/main/github/client/lookup/pr-number-lookup.ts +++ b/src/main/github/client/lookup/pr-number-lookup.ts @@ -37,7 +37,7 @@ export async function getRestPRByNumber( restData.stack !== undefined && restData.stack !== null ) { - // Why: GitHub omits stack for ordinary PRs; only unusable non-null metadata is unsafe. + // Absent stack metadata is valid; malformed non-null metadata is unsafe. if (!isUsableRestStackMetadata(restData.stack) || !mapped.stack) { throw new Error('malformed stack') } @@ -80,7 +80,7 @@ export async function getPRByNumber( executionScope ) } catch (err) { - // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. + // Missing links return to branch discovery; other failures get one cheaper REST recovery lookup. if (isNotFoundGhError(err)) { return null } @@ -152,7 +152,7 @@ export async function lookupPRByNumber(args: { : { data: null, dataRepo: null } } catch (err) { if (isNoPullRequestError(err)) { - // Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo. + // Stale fallback numbers can refer to deleted PRs or another repository. return { data: null, dataRepo: null } } throw err diff --git a/src/main/github/client/lookup/pr-stack-summary-cache.ts b/src/main/github/client/lookup/pr-stack-summary-cache.ts index 0c48915c1c4..e39ec4b686e 100644 --- a/src/main/github/client/lookup/pr-stack-summary-cache.ts +++ b/src/main/github/client/lookup/pr-stack-summary-cache.ts @@ -5,7 +5,7 @@ import { githubRepoIdentityKey } from '../../../../shared/github/repository-iden import { getRestPRByNumber } from './pr-number-lookup' export const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000 -// Why: a failed probe must not masquerade as "no stack" for a full minute; retry it sooner. +// Retry failed probes sooner without presenting cached failures as confirmed no-stack results. export const PR_STACK_SUMMARY_FAILURE_CACHE_TTL_MS = 5_000 export const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512 @@ -47,7 +47,6 @@ export async function getCachedGitHubPRStackSummary( prunePRStackSummaryCache(now) const cached = prStackSummaryCache.get(key) if (cached && cached.expiresAt > now) { - // Why: a cached failure must stay a failure — returning undefined would read as "this PR has no stack". if (!cached.ok) { throw cached.error } @@ -70,7 +69,7 @@ export async function getCachedGitHubPRStackSummary( prunePRStackSummaryCache() return value } catch (err) { - // Why: avoid repeating a failed REST probe on every review poll, on a short cooldown. + // Briefly cache failures to avoid a REST retry on every poll. prStackSummaryCache.delete(key) prStackSummaryCache.set(key, { ok: false, diff --git a/src/main/github/client/lookup/pull-request-lookup-data.ts b/src/main/github/client/lookup/pull-request-lookup-data.ts index 2f69e404a2e..ac0a34d21bb 100644 --- a/src/main/github/client/lookup/pull-request-lookup-data.ts +++ b/src/main/github/client/lookup/pull-request-lookup-data.ts @@ -85,7 +85,7 @@ export const PR_BRANCH_LIST_JSON_FIELDS = export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & { acceptMergedFallbackPR?: boolean - // Why: compare merged implicit PRs against the worktree HEAD, not main repo HEAD, without a worktree-scoped git call. + // Supply the actual worktree head without probing a different main-repo checkout. currentHeadOid?: string | null } @@ -173,7 +173,7 @@ export function isMergedImplicitPR( data: PullRequestLookupData, linkedPRNumber?: number | null ): boolean { - // Why: a merged PR without an explicit link is just a historical branch match, not implicit review context. + // Historical merged branch matches alone do not establish current review context. return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged' } @@ -185,7 +185,7 @@ export function shouldHideMergedImplicitPR( if (!data || !isMergedImplicitPR(data, linkedPRNumber)) { return false } - // Why: keep hiding historical merged branch matches, but preserve the merged PR for the exact commit currently checked out. + // Preserve historical merged reviews only when their head matches the current checkout. return !currentHeadOid || data.headRefOid !== currentHeadOid } diff --git a/src/main/github/client/lookup/pull-request-push-target.ts b/src/main/github/client/lookup/pull-request-push-target.ts index 3dc19733c6c..13fdc1f6ea0 100644 --- a/src/main/github/client/lookup/pull-request-push-target.ts +++ b/src/main/github/client/lookup/pull-request-push-target.ts @@ -23,7 +23,7 @@ export function pickPushRemoteUrl(args: { sshUrl: string }): string { const { originUrl, cloneUrl, sshUrl } = args - // Why: GHES port-443 SSH uses `ssh.`, not just ssh.github.com. + // Enterprise SSH-over-443 hosts can use ssh., not just ssh.github.com. if (originUrl && (/^(git@|ssh:)/.test(originUrl) || /:\/\/(?:[^@/]+@)?ssh\./.test(originUrl))) { return sshUrl } @@ -39,10 +39,7 @@ export function sanitizeRemoteName(owner: string, repo: string): string { return slug ? `pr-${slug}` : 'pr-head' } -/** - * A fork push target plus the PR's `maintainer_can_modify` flag, kept outside - * {@link GitPushTarget} so it never leaks into the persisted push-target shape. - */ +/** Keep maintainer permissions outside the persisted GitPushTarget shape. */ export type PullRequestPushTarget = { pushTarget: GitPushTarget /** false when the PR has "Allow edits from maintainers" off; a push may be rejected. */ @@ -82,7 +79,7 @@ export async function getPullRequestPushTarget( matchedRepository = candidate break } catch (error) { - // Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up. + // Fork origin may differ from the upstream PR namespace; exhaust candidate repositories. if (isNotFoundGhError(error)) { continue } diff --git a/src/main/github/client/lookup/tracked-upstream-branch.ts b/src/main/github/client/lookup/tracked-upstream-branch.ts index edfc57934e4..884ba4be3f6 100644 --- a/src/main/github/client/lookup/tracked-upstream-branch.ts +++ b/src/main/github/client/lookup/tracked-upstream-branch.ts @@ -54,7 +54,7 @@ export async function getTrackedUpstreamBranch( if (result.upstreamsByBranchName.has(branchName)) { return result.upstreamsByBranchName.get(branchName) ?? null } - // Why: a concurrent snapshot may finish before this branch exists in git; re-probe instead of returning a synthetic null. + // A concurrent snapshot can predate branch creation; reprobe instead of fabricating absence. const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey) if (retryInFlight) { const retryResult = await retryInFlight @@ -62,7 +62,7 @@ export async function getTrackedUpstreamBranch( } } - // Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch. + // Share one repo/runtime upstream scan across hundreds of polled branches. const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey) const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions) trackedUpstreamSnapshotInFlight.set(cacheKey, probe) @@ -117,7 +117,7 @@ export async function probeTrackedUpstreamSnapshot( const gitConfigSignature = startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined return { - // Why: don't cache an empty snapshot after a transient git failure, or every branch lookup re-probes on the next refresh tick. + // Do not cache failed snapshots; later lookups must be able to retry. cacheable: !configSignatureChanged && !probeFailed, probeFailed, ...(gitConfigSignature ? { gitConfigSignature } : {}), diff --git a/src/main/github/client/lookup/tracked-upstream-cache.ts b/src/main/github/client/lookup/tracked-upstream-cache.ts index 167bdad435d..c7e8bf0d792 100644 --- a/src/main/github/client/lookup/tracked-upstream-cache.ts +++ b/src/main/github/client/lookup/tracked-upstream-cache.ts @@ -40,7 +40,7 @@ export function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { } export function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void { - // Why: generations only guard an active probe; retaining completed keys leaks worktree/runtime identities past the snapshot TTL. + // Generation guards matter only for active probes; completed keys must not leak identities. if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) { trackedUpstreamSnapshotGenerations.delete(cacheKey) } @@ -52,7 +52,7 @@ export function pruneTrackedUpstreamSnapshotCache(now: number): void { trackedUpstreamSnapshotCache.delete(cacheKey) } } - // Why: workspace/runtime churn can create unbounded unique keys within one TTL window, so expiry sweeping alone isn't a memory bound. + // Workspace/runtime churn can exceed a memory budget before any TTL expires. while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { const oldestKey = trackedUpstreamSnapshotCache.keys().next().value if (oldestKey === undefined) { @@ -106,7 +106,7 @@ export function shouldRetryTrackedUpstreamBranch( export function getCacheableTrackedUpstreamSnapshot( upstreamsByBranchName: Map ): Map { - // Why: SSH/WSL can't cheaply inspect remote .git/config here; the short TTL bounds stale positives while refreshes share one scan. + // Remote config cannot be inspected cheaply here; short TTL bounds stale results. return upstreamsByBranchName } diff --git a/src/main/github/client/map/work-item-field-coercion.ts b/src/main/github/client/map/work-item-field-coercion.ts index 3f81041bc8a..914e8d95361 100644 --- a/src/main/github/client/map/work-item-field-coercion.ts +++ b/src/main/github/client/map/work-item-field-coercion.ts @@ -4,22 +4,17 @@ import type { } from '../../../../shared/github/pull-request-types' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import { summarizeProviderChecks } from '../../../../shared/provider-check-summary' -// Why: omit repoId — the main process only has the path; the renderer stamps repoId after IPC. +// The renderer supplies repoId after IPC; main knows only the repository path. export type MainWorkItem = Omit export const WORK_ITEM_PR_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' -// Requested reviewers stay in the list payload because Tasks renders that column on first paint. -// Why: kept out of `gh pr list` — statusCheckRollup/reviewDecision/merge metadata fan out into expensive per-row GraphQL. -// Requested reviewers stay in the list payload because Tasks renders that column on first paint. +// Keep first-paint reviewers in list payloads; defer costly checks, review decisions and merge metadata. export const WORK_ITEM_PR_DETAIL_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify' -/** - * Derive author login + avatar_url together so GHE avatars render — the login-only - * `{login}.png` URL 404s on GHE. REST uses `user.avatar_url`, gh/GraphQL `author.avatarUrl` (#8784). - */ +/** Preserve API-provided avatars with logins; dotcom login-based URLs do not identify Enterprise avatars. */ export function authorFieldsFromUnknown( item: Record ): Pick { @@ -54,7 +49,7 @@ export function extractHeadOwnerLogin(item: Record): string | n } } } - // Why: a deleted/inaccessible fork returns head.repo = null but still has head.user/head.label. + // Deleted/inaccessible forks may retain head.user/head.label while head.repo is null. const user = head.user if (typeof user === 'object' && user !== null) { const login = (user as { login?: unknown }).login @@ -152,7 +147,7 @@ export function latestReviewsFromUnknown( } export function numberFromUnknown(value: unknown): number | undefined { - // Why: Number(null) is 0 — an explicit null must stay "unknown", not become a real count or user id. + // Preserve null as unknown: Number(null) would invent a zero count or user ID. if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined } @@ -203,7 +198,7 @@ export function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['chec return { status: '', conclusion: '' } } const raw = entry as Record - // Why: StatusContext reports `state` and carries no `status`; CheckRun reports both. + // StatusContext supplies state without CheckRun's status/conclusion. return { status: String(raw.status ?? ''), conclusion: String(raw.conclusion ?? raw.state ?? '') diff --git a/src/main/github/client/map/work-item.ts b/src/main/github/client/map/work-item.ts index 5916119f330..d03b555e6e6 100644 --- a/src/main/github/client/map/work-item.ts +++ b/src/main/github/client/map/work-item.ts @@ -38,9 +38,8 @@ export function mapPullRequestWorkItem( item: Record, baseOwnerRepo: OwnerRepo | null = null ): MainWorkItem { - // Why: fork PRs are disabled in the Start-from picker; compare head owner to the selected repo's owner. const headOwnerLogin = extractHeadOwnerLogin(item) - // Why: leave isCrossRepository undefined when the head owner is unknown, rather than falsely claiming "not a fork". + // Unknown head ownership must not be presented as proven same-repository. const isCrossRepository = headOwnerLogin !== null && baseOwnerRepo !== null ? headOwnerLogin !== baseOwnerRepo.owner diff --git a/src/main/github/client/merge/merge-pr.ts b/src/main/github/client/merge/merge-pr.ts index d54b04a6a73..d534b85b109 100644 --- a/src/main/github/client/merge/merge-pr.ts +++ b/src/main/github/client/merge/merge-pr.ts @@ -9,10 +9,6 @@ import { detectRepositoryMergeMetadata } from './../detect/repository-merge-meta import type { PullRequestLookupData } from './../lookup/pull-request-lookup-data' import { getRestPRByNumber, getPRByNumber } from './../lookup/pr-number-lookup' import { STACK_METADATA_UNAVAILABLE_ERROR } from './../lookup/pr-stack-summary-cache' -/** - * Merge a PR by number using gh CLI. - * method: 'merge' | 'squash' | 'rebase' (default: 'squash') - */ export async function mergePR( repoPath: string, prNumber: number, @@ -85,7 +81,7 @@ export async function mergePR( return { ok: false, error: mergeBlocker } } - // Don't use --delete-branch: it deletes the local branch, which fails while the worktree is checked out on it. + // Deleting the local branch fails while its worktree still has it checked out. const args = ['pr', 'merge', String(prNumber), `--${method}`] if (ownerRepo) { args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) @@ -138,7 +134,7 @@ export async function getPRMergeBlocker( if (pr.mergeQueueRequired === true) { return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' } - // Why: conflict summaries shell out to local git; skip for SSH repos until that helper routes through the SSH provider. + // Conflict derivation uses local Git and cannot inspect SSH-backed repositories. if ( connectionId || pr.mergeable !== 'CONFLICTING' || @@ -158,7 +154,7 @@ export async function getPRMergeBlocker( ) return formatMergeConflictBlocker(pr.baseRefName, summary) } catch { - // Why: conflict preflight should improve stale UI diagnostics, not block merge on a transient lookup failure. + // Advisory conflict diagnostics must not block merging on transient failures. return null } } diff --git a/src/main/github/client/merge/pr-auto-merge.ts b/src/main/github/client/merge/pr-auto-merge.ts index eb314cc963c..cc8efb56146 100644 --- a/src/main/github/client/merge/pr-auto-merge.ts +++ b/src/main/github/client/merge/pr-auto-merge.ts @@ -15,7 +15,7 @@ export const GITHUB_AUTO_MERGE_METHODS: Record() const inFlightBaseOidResolves = new Map>() const inFlightSummaryDerivations = new Map>() -// Why: WSL distros have their own git binary, filesystem view, and remote -// access, so cached state must never leak across the host/distro boundary. +// Git binaries, filesystems and remote access differ across native/WSL runtimes. export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string { return wslDistro ? `wsl:${wslDistro}` : 'local:host' } -// Why JSON: repo paths and git ref names may contain any printable joiner -// character (git allows `|` in branch names), so a delimiter-joined key could -// alias distinct identities onto one cache entry. +// Paths and refs may contain printable delimiters; structural encoding prevents key collisions. export function buildConflictSummaryCacheKey(...parts: string[]): string { return JSON.stringify(parts) } diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts index 160623c20c2..a6c15d80710 100644 --- a/src/main/github/conflict-summary.test.ts +++ b/src/main/github/conflict-summary.test.ts @@ -4,12 +4,7 @@ import { tmpdir } from 'node:os' import { isAbsolute, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// Why these tests exist: the conflict-summary derivation used to re-run a -// network `git fetch` plus a four-subprocess chain on every PR refresh tick -// (measured ~490 full derivations in 2.1h on one machine). They pin the -// regression contract by counting subprocess spawns: unchanged inputs must -// cost zero subprocesses, and the base fetch must run at most once per -// throttle window. +// Count subprocesses: unchanged OIDs reuse summaries and base fetches share a throttle window. const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) const scratchFolderPrefixes = vi.hoisted((): string[] => []) @@ -145,7 +140,6 @@ describe('getPRConflictSummary caching', () => { expect(afterExpiry).toEqual(expectedSummary) expect(spawnCount('fetch')).toBe(2) expect(spawnCount('rev-parse')).toBe(2) - // Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits. expect(spawnCount('merge-base')).toBe(1) expect(spawnCount('rev-list')).toBe(1) expect(spawnCount('merge-tree')).toBe(1) @@ -296,8 +290,7 @@ describe('getPRConflictSummary caching', () => { expect(summary).toEqual(expectedSummary) expect(spawnCount('fetch')).toBe(1) - // Why: a failed fetch attempt must also be throttled — offline machines - // were the worst case, paying the 10s fetch timeout on every tick. + // Failed fetches need the same throttle or offline polling repeatedly pays the timeout. const repeat = await deriveSummary() expect(repeat).toEqual(expectedSummary) expect(spawnCount('fetch')).toBe(1) @@ -367,8 +360,7 @@ describe('getPRConflictSummary caching', () => { it('keeps identities distinct when paths or ref names contain a joiner character', async () => { mockGitDispatch() - // Why: these two calls alias under naive pipe-joined keys — the segment - // boundary shifts between repoPath and baseRefName. + // Pipe joining would alias these distinct repo/ref segment boundaries. await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {}) await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {}) @@ -384,11 +376,9 @@ describe('getPRConflictSummary caching', () => { await expect(deriveSummary()).resolves.toBeUndefined() const failureSpawns = spawnCount() - // Within the window the failure is negative-cached: zero subprocesses. await expect(deriveSummary()).resolves.toBeUndefined() expect(spawnCount()).toBe(failureSpawns) - // After expiry the derivation retries (a fetch may have brought objects in). vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) mockGitDispatch() await expect(deriveSummary()).resolves.toEqual(expectedSummary) @@ -447,7 +437,6 @@ describe('getPRConflictSummary caching', () => { } }) - // The first check learns that this Git writes trees; the next one is quarantined. await expect(deriveSummary('head-oid-1')).resolves.toEqual(expectedSummary) await expect(deriveSummary('head-oid-2')).resolves.toEqual(expectedSummary) diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index ce88a26e00b..e5635c9afd8 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -33,7 +33,6 @@ type BaseRefreshController = Pick< let remoteFetches: BaseRefreshController | null = null -/** The runtime's controller, so a base refresh queues and dedupes with create and drift fetches. */ export function setPRConflictSummaryRemoteFetches(controller: BaseRefreshController): void { remoteFetches = controller } @@ -52,22 +51,14 @@ export async function getPRConflictSummary( headRefOid: string, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: the renderer only needs a read-only merge-conflict snapshot. We - // derive it from local git state so the PR card can show GitHub-style - // detail without spending additional gh API calls on every refresh. We use - // GitHub's head OID directly because the registered repo path may not have - // a matching local branch name for the PR head. For the base side, prefer a - // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, - // which compares against the latest base branch tip rather than the PR's - // older pinned baseRefOid snapshot. + // Use GitHub's head OID when no local branch exists, and refresh the live base to match current mergeability. const latestBaseOid = await resolveLatestBaseOidThrottled( repoPath, baseRefName, baseRefOid, localGitOptions ) - // Why: the summary is a pure function of the two commit OIDs, so a key hit - // can skip the whole merge-base/rev-list/merge-tree subprocess chain. + // Immutable commit pairs let cache hits skip the entire Git derivation. const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) const summaryKey = buildConflictSummaryCacheKey( runtimeKey, @@ -81,9 +72,7 @@ export async function getPRConflictSummary( return cached.value } - // Why: different GitHub reads can report different pinned baseRefOid values - // while still resolving to the same live base tip; dedupe the expensive - // local derivation on the actual summary identity. + // Distinct pinned base snapshots can resolve to one live tip; dedupe by the actual commit pair. return dedupeSummaryDerivation(summaryKey, () => derivePRConflictSummary( repoPath, @@ -144,8 +133,7 @@ async function resolveLatestBaseOidThrottled( return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid } return dedupeBaseOidResolve(baseKey, async () => { - // Why re-check inside the dedupe slot: a sibling caller may have finished - // resolving between our cache read and this factory starting. + // Another caller can resolve the base between the first cache read and this factory. const freshResolution = readFreshBaseTipResolution(baseKey) if (freshResolution) { return freshResolution @@ -155,9 +143,7 @@ async function resolveLatestBaseOidThrottled( storeResolvedBaseTip(baseKey, oid) return { kind: 'resolved', oid } } - // Why cache the unresolved probe, not the caller fallback: the fetch - // attempt is branch-wide expensive work, but GitHub's baseRefOid is - // PR-specific and must not leak to sibling PRs on the same base branch. + // Cache unresolved branch-wide probes, not each PR's distinct fallback base OID. rememberUnresolvedBaseTip(baseKey) return { kind: 'fallback-unresolved' } }).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid)) @@ -171,9 +157,7 @@ async function resolveLatestBaseOid( const remoteName = 'origin' const trackingRef = `refs/remotes/${remoteName}/${baseRefName}` remoteFetches ??= new RuntimeRemoteFetchController() - // Why: fetching the base ref keeps the conflict list aligned with GitHub's - // live mergeability view, but the card must still render offline. A failed - // or slow fetch falls back to the local ref, then to GitHub's base OID. + // Keep cards available offline: failed refreshes fall back to local refs, then GitHub's base OID. await remoteFetches.refreshRemoteTrackingBaseForPreview( repoPath, { @@ -195,7 +179,7 @@ async function resolveLatestBaseOid( return oid } } catch { - // Try the next ref form before falling back to GitHub's baseRefOid. + // Continue through ref forms before falling back to GitHub's base OID. } } @@ -252,7 +236,7 @@ async function loadConflictingFiles( headOid, baseOid ] - // Why: only the file list is read; the merged tree it writes would pile up as loose objects. + // Quarantine the unused merged tree so preview reads cannot accumulate loose objects. const quarantine = createLocalGitObjectQuarantine(repoPath, localGitOptions) return withLocalGitCapabilityCacheForExecution( @@ -264,7 +248,7 @@ async function loadConflictingFiles( ...gitOptionsForWorktree(repoPath, localGitOptions), ...(env ? { env } : {}) }) - // Why only once supported: an old Git still gets the folder, and Git < 2.35 can't prune a leftover. + // Probe support before creating a quarantine that older Git cannot prune. return capabilities.isKnownSupported('merge-tree-write-tree') ? quarantine.run(run) : run(undefined) @@ -282,8 +266,7 @@ async function loadConflictingFiles( if (isUnsupportedMergeTreeWriteTreeError(error)) { throw error } - // Why: `git merge-tree --write-tree` exits 1 for conflicts but still - // writes the useful file list; only option rejection reaches fallback. + // Conflicts exit 1 but still provide the file list; option rejection alone triggers fallback. const stdoutFromError = getGitErrorOutput(error, 'stdout') if (stdoutFromError) { return parseMergeTreeNameOnlyOutput(stdoutFromError) @@ -295,8 +278,7 @@ async function loadConflictingFiles( isUnsupportedMergeTreeMergeBaseError ), async () => { - // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; - // fail closed without respawning the same rejected command every refresh. + // Older Git cannot derive a reliable merge-conflict list; cache the unsupported capability. throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') }, isUnsupportedMergeTreeWriteTreeError diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index 803d02d3f72..11aefed1a77 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -1,19 +1,4 @@ -/* - * Issue #9171 — "Wrong PR diffs displayed when checking out the default branch". - * - * A repo's default branch is `master`. In the past someone opened a PR whose - * HEAD was `master` (accidentally opening a PR *from* the default branch); it - * was closed long ago. getPRForBranch looks a PR up purely by head branch name - * with `state=all`, so without a guard that stale closed PR gets attached to - * the default-branch checkout, surfacing its wrong diffs and checks. - * - * These tests assert the CORRECT behavior: an implicit (non-linked) branch or - * fallback-number match on the repo default branch never surfaces a non-open - * PR, while open PRs on the trunk and closed/merged PRs on feature branches - * keep today's behavior. The default branch is resolved by the REAL - * resolveDefaultBaseRefViaExec over the mocked git exec layer, so the - * symbolic-ref / rev-parse plumbing and the `origin/` strip are exercised. - */ +// Implicit non-open PRs must not attach to the default branch; exercise real default-ref resolution over mocked Git. import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -127,9 +112,6 @@ import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-de const DEFAULT_BRANCH_REF = 'refs/remotes/origin/master' -/** - * Answer the default-base and tracked-upstream snapshots and merged-at-head probe. - */ function primeGitExecForDefaultBranch({ defaultRef = DEFAULT_BRANCH_REF, headOid = 'checkout-head-oid' @@ -177,8 +159,7 @@ function restPR({ } } -/** Route the REST head-branch lookup; every other gh call fails so branch data - * is kept as-is (hydration falls back to the branch payload). */ +// Fixture REST branch lookup only; hydration failures retain its payload. function primeGhExecWithBranchList(list: Record[]): void { ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'api' && args[1]?.includes('pulls?head=')) { @@ -196,7 +177,6 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() - // Why: getPRForBranch resolves origin through getOwnerRepoForRemote. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null @@ -234,12 +214,10 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open const pr = await getPRForBranch('/repo-root', 'master') - // The head-branch lookup still runs (state=all) and sees the closed PR… expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/acme/widgets/pulls?head=acme%3Amaster&state=all&per_page=1'], { cwd: '/repo-root', host: 'github.com' } ) - // …but the default-branch guard discards it: no PR on the trunk (#9171). expect(pr).toBeNull() }) @@ -260,9 +238,7 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open expect(pr?.number).toBe(8) expect(pr?.state).toBe('open') - // Open results never consult git for the default branch (lazy resolution). - // Remote-name listing is a separate concern from default-branch resolution, - // so allow it and keep every other git command forbidden here. + // Default-ref resolution stays lazy for open PRs; unrelated remote-name enumeration remains allowed. expect( gitExecFileAsyncMock.mock.calls .map(([args]) => args[0]) @@ -283,7 +259,6 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open }) it('preserves the merged-at-head carve-out on a feature branch', async () => { - // Worktree HEAD is exactly the merged PR's final head commit. primeGitExecForDefaultBranch({ headOid: 'merged-head-oid' }) primeGhExecWithBranchList([ restPR({ @@ -368,7 +343,6 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open }) it('fails open when the default branch cannot be resolved (stale PR still returned)', async () => { - // Every git probe fails: no origin/HEAD, no main/master refs. gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'for-each-ref') { return { stdout: '', stderr: '' } diff --git a/src/main/github/gh-account-binding-inventory.ts b/src/main/github/gh-account-binding-inventory.ts index c4e2af43f79..4e10b59ec8c 100644 --- a/src/main/github/gh-account-binding-inventory.ts +++ b/src/main/github/gh-account-binding-inventory.ts @@ -1,6 +1,3 @@ -/** - * Repo-scoped inventory + validation for Settings GitHub account binding. - */ import { diagnoseGhAuth } from './auth-diagnose' import { getGhMultiAccountCapability, diff --git a/src/main/github/gh-account-token.ts b/src/main/github/gh-account-token.ts index 605df86ef21..965653cdaaf 100644 --- a/src/main/github/gh-account-token.ts +++ b/src/main/github/gh-account-token.ts @@ -1,9 +1,4 @@ -/** - * Resolve a short-lived gh token for a bound account and prepare child env. - * - * Why: Part A injects tokens only into the gh child — never process-wide — - * via `gh auth token --user --hostname`. Tokens are never logged. - */ +// Resolve bound credentials into child environments only; never mutate process-wide auth or log tokens. import { ghExecFileAsync } from '../git/runner' import type { GhCaptureLifetimeHooks } from '../git/command-runner/gh-capture-lifetime' import { addWslEnvKeys } from '../../shared/wsl-env' @@ -145,8 +140,7 @@ export async function resolveGhAccountToken( try { const resolveEnv = stripAmbientGhTokenEnv(process.env) resolveEnv.GH_PROMPT_DISABLED = resolveEnv.GH_PROMPT_DISABLED ?? '1' - // Why: the command runner is the only sanctioned gh spawn (deadline, tree kill); - // `auth` argv is exempt from binding, so this cannot recurse. + // Auth commands bypass binding, preventing recursion through the bounded runner. const { stdout } = await ghExecFileAsync( ['auth', 'token', '--user', binding.user, '--hostname', binding.host], { @@ -159,7 +153,7 @@ export async function resolveGhAccountToken( ) const token = stdout.replace(/\r?\n/g, '').trim() if (!token) { - // Why: empty token is a stable auth miss — negative-cache to avoid hammering keyring. + // Cache stable empty-token misses to avoid hammering the keyring. tokenCache.set(key, { kind: 'negative', expiresAt: nowMs + NEGATIVE_TTL_MS }) pruneTokenCache(nowMs) throw createGhBoundAccountUnavailableError(binding) @@ -190,7 +184,7 @@ export async function resolveGhAccountToken( if (stderr) { unavailable.stderr = stderr } - // Why: timeouts / missing gh / spawn blips must not poison the cache for 30s. + // Transient spawn and timeout failures must remain retryable. if (!isTransientTokenResolveFailure(error)) { tokenCache.set(key, { kind: 'negative', expiresAt: nowMs + NEGATIVE_TTL_MS }) pruneTokenCache(nowMs) diff --git a/src/main/github/gh-capability-state.ts b/src/main/github/gh-capability-state.ts index ed74d8e109b..af62d831ebc 100644 --- a/src/main/github/gh-capability-state.ts +++ b/src/main/github/gh-capability-state.ts @@ -1,9 +1,4 @@ -/** - * Per-runtime cache for `gh auth token --user` support (gh ≥ 2.40). - * - * Why: binding creation must not offer accounts when the execution host's gh - * is too old; probe once per local/WSL runtime and recheck after fallbacks. - */ +// Require gh >=2.40 multi-account support per execution runtime before offering bindings. import { ghExecFileAsync } from '../git/runner' import { parseWslUncPath } from '../../shared/wsl-paths' import type { GhMultiAccountCapability } from '../../shared/github/auth-types' @@ -35,7 +30,7 @@ const probesByExecutionHost = new Map() /** Bumped on invalidate so in-flight probes cannot commit stale results. */ const generationByExecutionHost = new Map() -/** Infers the WSL distro from a UNC `cwd` so capability and token probes run on the same host they are cached under. */ +/** Infer UNC distros so probe execution matches cache ownership. */ export function normalizeGhCapabilityTarget(target: GhCapabilityTarget = {}): GhCapabilityTarget { const wslDistro = target.wslDistro ?? (target.cwd ? parseWslUncPath(target.cwd)?.distro : undefined) @@ -56,8 +51,7 @@ function isNarrowMultiAccountUnsupported(error: unknown): boolean { const stderr = error && typeof error === 'object' && 'stderr' in error ? String(error.stderr ?? '') : '' const text = `${message}\n${stderr}`.toLowerCase() - // Why: only treat option-echo / unknown-flag shapes as unsupported — missing - // gh, auth failures, and timeouts stay unknown so Settings can Retry. + // Unsupported-option errors are definitive; missing gh/auth/timeouts remain retryable. return ( text.includes('unknown flag: --user') || text.includes('unknown command') || diff --git a/src/main/github/gh-error-classification.ts b/src/main/github/gh-error-classification.ts index d8b809621cf..cffdef92542 100644 --- a/src/main/github/gh-error-classification.ts +++ b/src/main/github/gh-error-classification.ts @@ -1,11 +1,8 @@ import type { ClassifiedError } from '../../shared/classified-error' -// Why: gh CLI surfaces API errors as unstructured stderr. Map known -// patterns to typed errors so callers can show user-friendly messages. export function classifyGhError(stderr: string): ClassifiedError { const s = stderr.toLowerCase() - // Why: primary rate-limit errors also carry "HTTP 403" — check rate limit - // first so they don't misclassify as a token-scope problem. + // Rate limits also carry HTTP 403; classify them before permission failures. if (s.includes('rate limit')) { return { type: 'rate_limited', @@ -38,8 +35,7 @@ export function classifyGhError(stderr: string): ClassifiedError { return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } } -// Why: classifyGhError's copy is phrased for edit/update operations, but -// listIssues is a read op and renderer banners interpolate the message. +// Shared classification uses mutation copy; list banners need read-specific wording. export function classifyListIssuesError(stderr: string): ClassifiedError { const c = classifyGhError(stderr) const trimmed = stderr.trim() @@ -56,8 +52,7 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { return { type: c.type, message: readMessages[c.type] } } -// Why: classifyGhError's copy names an "issue"; PR mutations reuse the same -// classification but must not tell the user their pull request is an issue. +// Preserve classification while using PR-specific wording. export function classifyPullRequestUpdateError( stderr: string, action = 'update pull request' @@ -77,16 +72,13 @@ export function classifyPullRequestUpdateError( return { type: c.type, message: pullRequestMessages[c.type] } } -// Why: PR-side list failures need the same read-op classification — pagination -// decisions key on the type, and swallowing them made failures look like -// end-of-data (#11485). +// Pagination needs typed failures so an outage cannot masquerade as end-of-data. export function classifyListPrsError(stderr: string): ClassifiedError { const c = classifyGhError(stderr) return { type: c.type, message: `Failed to load pull requests: ${stderr.trim()}` } } -// Why: classifyGhError's copy names an "issue"; a failed check rerun must name -// the operation the user actually triggered. +// Preserve classification while naming the rerun operation. export function classifyRerunChecksError(stderr: string): ClassifiedError { const c = classifyGhError(stderr) const trimmed = stderr.trim() diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index 5ccddbd57fe..9c97ff3fb31 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -730,7 +730,6 @@ describe('resolveIssueSource', () => { }) it("'upstream' + no upstream remote → origin, fellBack=true", async () => { - // No upstream remote configured — the first call fails. gitExecFileAsyncMock .mockRejectedValueOnce(new Error('fatal: No such remote')) .mockResolvedValueOnce({ stdout: 'git@github.com:solo/orca.git\n' }) @@ -742,7 +741,6 @@ describe('resolveIssueSource', () => { }) it("'origin' + upstream exists → origin (ignores upstream), fellBack=false", async () => { - // Only one gh call should happen — origin. Upstream is never consulted. gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' }) @@ -783,11 +781,6 @@ describe('resolveIssueSource', () => { }) describe('gh error classification', () => { - // Why: a fork with Issues turned off triggers `gh issue list` stderr - // "the '' repository has disabled issues". Without a dedicated branch - // the raw "Command failed: gh issue list …" line leaks into the Tasks banner - // via the `unknown` fallback — which is what users see when they flip the - // per-repo selector to an origin fork that has issues disabled. it('classifies "has disabled issues" stderr as issues_disabled', () => { const stderr = "Command failed: gh issue list --limit 36 --json number,title,state --repo brennanb2025/orca --state open\nthe 'brennanb2025/orca' repository has disabled issues" @@ -801,11 +794,7 @@ describe('gh error classification', () => { }) }) - // Why: the renderer detects the Search API 1000-result window by matching - // GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN against this message (#11485) — - // trimming the raw stderr out of the validation_error copy, or drifting the - // pattern off GitHub's real wording, would silently downgrade every window - // 422 to a generic failure. The stderr stays verbatim so this pins both ends. + // Renderer window detection matches this phrase; preserve raw stderr in validation errors. it('keeps the search-window phrase in validation_error list messages', () => { const stderr = 'Command failed: gh api --hostname github.com search/issues\nValidation Failed: Only the first 1000 search results are available (HTTP 422)' diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 61b3c2d877d..883ddc0b80b 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -1,12 +1,10 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { gitExecFileAsync, ghExecFileAsync } from '../git/runner' -// Pure error-parsing helpers come from the lightweight module (not `runner`) so -// tests that mock `../git/runner` still resolve the real implementations. +// Import pure parsing outside runner so runner-mocked tests retain the real implementation. import { extractExecError, parseRetryAfterMs } from '../git/exec-error' -// Why: legacy generic execFile wrapper - only used by callers that don't need -// WSL-aware routing. Repo-scoped callers should use the runner exports below. +// Repo-scoped operations need the WSL-aware runner; this legacy wrapper serves generic commands. export const execFileAsync = promisify(execFile) export { ghExecFileAsync, gitExecFileAsync, extractExecError, parseRetryAfterMs } export { diff --git a/src/main/github/github-api-repository-remote-probe.ts b/src/main/github/github-api-repository-remote-probe.ts index 31159a4a8c4..823287d4e92 100644 --- a/src/main/github/github-api-repository-remote-probe.ts +++ b/src/main/github/github-api-repository-remote-probe.ts @@ -13,7 +13,6 @@ import { resolveGitHubApiRepositoryProbe } from './github-api-repository-probe' -// Why: cache the uncached Enterprise remote probe used by hot paths. const ORIGIN_REPO_CACHE_TTL_MS = 30_000 const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512 const originRepoCache = new Map() @@ -40,11 +39,7 @@ function pruneOriginRepoCache(now: number): void { } } -/** - * Host-qualified repository identity for one remote: github.com remotes come - * from the cached slug parser; any other GitHub-shaped host is auth-gated so a - * non-GitHub forge never routes to the GitHub provider. - */ +/** Resolve this remote exactly; auth-gate Enterprise hosts so other forges cannot route to GitHub. */ export async function getGitHubApiRepositoryForRemote( repoPath: string, remoteName: string, @@ -52,8 +47,7 @@ export async function getGitHubApiRepositoryForRemote( localGitOptions: LocalGitExecOptions = {}, probeOptions: GitHubRemoteIdentityProbeOptions = {} ): Promise { - // Why: generic PR resolution prefers upstream, but this API represents the - // caller-selected remote exactly (#7331). + // Generic PR resolution prefers upstream; this entry point must honor the selected remote. const requireVerifiedSshProbe = probeOptions.requireVerifiedSshProbe === true const verifiedIdentityArgs = requireVerifiedSshProbe ? ([probeOptions] as const) : [] const ownerRepo = await getOwnerRepoForRemote( @@ -102,8 +96,7 @@ export async function getGitHubApiRepositoryForRemote( enterpriseOptions, ...verifiedEnterpriseArgs ) - // Why: undefined means the gh auth inventory could not be read. Caching it - // as a negative would turn a transient spawn failure into a 30-second miss. + // Undefined means auth inventory was unreadable; do not cache transient probe failures. if (slug !== undefined) { originRepoCache.set(cacheKey, { value: slug, diff --git a/src/main/github/github-api-repository-validation.test.ts b/src/main/github/github-api-repository-validation.test.ts index 7f8eec06602..92cfe982586 100644 --- a/src/main/github/github-api-repository-validation.test.ts +++ b/src/main/github/github-api-repository-validation.test.ts @@ -1,6 +1,4 @@ -// Why: owner/repo overrides become authenticated REST paths, so the slug gate -// must keep rejecting path-shaped input while accepting every real login shape — -// including Enterprise Managed User logins, which end in `_`. +// Authenticated REST overrides must reject path-shaped slugs while accepting Enterprise managed-user suffixes. import { describe, expect, it } from 'vitest' import { isValidGitHubApiRepository } from './github-api-repository-validation' diff --git a/src/main/github/github-api-repository-validation.ts b/src/main/github/github-api-repository-validation.ts index 4031e64db13..f0edbafb865 100644 --- a/src/main/github/github-api-repository-validation.ts +++ b/src/main/github/github-api-repository-validation.ts @@ -7,7 +7,7 @@ export type GitHubApiRepositoryResolution = | undefined | (() => Promise) -// Why: renderer/RPC overrides reach authenticated REST paths. +// Untrusted renderer/RPC slugs become authenticated REST paths. const OWNER_SLUG_RE = GITHUB_OWNER_SLUG_RE const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/ diff --git a/src/main/github/github-api-repository.test.ts b/src/main/github/github-api-repository.test.ts index a660ff71c93..9ce5f9cb4eb 100644 --- a/src/main/github/github-api-repository.test.ts +++ b/src/main/github/github-api-repository.test.ts @@ -19,7 +19,6 @@ const { vi.mock('./gh-utils', async (importOriginal) => ({ ...(await importOriginal()), getOwnerRepo: getOwnerRepoMock, - // Why: origin resolution uses getOwnerRepoForRemote, not getOwnerRepo. getOwnerRepoForRemote: getOwnerRepoForRemoteMock })) diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index db3eccbc106..aefbcb99359 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -129,7 +129,6 @@ export type ResolvedGitHubApiRepositorySource = { fellBack: boolean } -/** Hosted mirror of resolveIssueSource — same preference semantics. */ export async function resolveIssueGitHubApiRepositorySource( repoPath: string, preference: IssueSourcePreference | undefined, @@ -188,8 +187,7 @@ export async function resolveGitHubApiRepository( if (isDefaultGitHubHost(host)) { return { ...repository, host } } - // Why: client-supplied hosts must match gh's local auth inventory before - // they can receive ambient Enterprise credentials from a host-pinned call. + // Verify supplied hosts against local auth inventory before giving them ambient Enterprise credentials. const authenticated = await isGitHubHostAuthenticated( host, repoPath, @@ -206,13 +204,11 @@ export async function resolveGitHubApiRepository( if (!repository) { return originRepository } - // Why: older clients only send owner/repo. The origin still supplies the - // execution host for fork-base slugs on the same GitHub Enterprise server. + // Older clients omit host; resolved origin supplies it for fork-base slugs on the same server. if (originRepository?.host) { return { ...repository, host: originRepository.host } } - // Why: a host-less identity can honor ambient GH_HOST even with a local cwd. - // Only a resolved origin may supply the execution host for legacy clients. + // Host-less calls could follow ambient GH_HOST; only resolved origin can supply the legacy host. return null } @@ -222,11 +218,9 @@ export async function resolveGitHubRepoExecution( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: issue-scoped paths retain their upstream-first resolver while sharing - // the same repo-scoped and host-scoped gh execution option construction. + // Retain upstream-first issue resolution with the same host-pinned execution options. const requestedRepository = typeof repository === 'function' ? await repository() : repository - // Why: normalize host-less resolver results without replacing an - // authoritative null with the generic origin fallback. + // Preserve authoritative nulls instead of replacing them with origin. const ownerRepo = typeof repository === 'function' && !requestedRepository ? null diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index 1d0be831340..37d0bafa9db 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -8,8 +8,7 @@ const { commandExecFileAsyncMock, ghExecFileAsyncMock, gitExecFileAsyncMock, res resolveWithSshGMock: vi.fn() })) -// Mock only the exec boundary so the real remote-identity parsing, runtime -// option resolution, and `gh auth status` parsing run against controlled output. +// Mock only execution so identity, runtime and auth-status parsing remain real. vi.mock('../git/runner', () => ({ commandExecFileAsync: commandExecFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, @@ -50,7 +49,6 @@ function sshConfig(hostname: string, port = 22) { } } -// gh auth status inventory entries represent hosts with configured credentials. function mockHostAuthenticated(host = 'github.acme-corp.com'): void { mockAuthenticatedHosts([host]) } @@ -64,7 +62,6 @@ function mockAuthenticatedHosts(hosts: string[]): void { }) } -// gh exits non-zero and reports no matching host when not logged in. function mockHostNotAuthenticated(): void { ghExecFileAsyncMock.mockRejectedValue( Object.assign(new Error('exit 1'), { @@ -94,8 +91,7 @@ describe('getEnterpriseGitHubRepoSlug', () => { repo: 'orca', host: 'github.acme-corp.com' }) - // Why: inventory configured gh hosts without targeting the untrusted remote; - // passing it as --hostname could expose an ambient enterprise token. + // Inventory configured hosts without sending ambient credentials to an untrusted remote. expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { cwd: '/repo' }) }) @@ -436,9 +432,7 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - // The probe answers differ by runtime: a local repo gets a cwd, a - // connection-backed repo does not. Sharing one entry attributes whichever - // resolved first to both hosts. + // Same-path repositories on separate runtimes must retain separate auth answers. it.each([ ['local resolves first', [null, 'ssh-1'] as const, [true, false]], ['connection resolves first', ['ssh-1', null] as const, [false, true]] diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 83bd264baee..d9cb599c531 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -25,10 +25,7 @@ import { getConnectionExecutionHostId } from '../../shared/execution-host' export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } -// Why: `gh` only ever manages github.com / GitHub Enterprise credentials, so a -// host `gh auth status` reports as logged-in is definitively a GitHub host. This -// mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a -// GHES remote is not left to fall through to Gitea (#8312). +// Authenticated gh inventory identifies GitHub Enterprise hosts without claiming another forge's remote. const HOST_AUTH_TTL_MS = 15 * 60_000 const HOST_AUTH_MISS_TTL_MS = 60_000 const HOST_AUTH_CACHE_MAX_ENTRIES = 512 @@ -41,10 +38,7 @@ type HostAuthCacheEntry = { const hostAuthCache = new Map() const hostAuthInFlight = new Map>() -// Why: gh intentionally executes on the native host even for connection-backed -// repos, but the answer still describes that connection's runtime (it is probed -// without the repository cwd). Keying it per connection stops a local repo and -// an SSH-hosted repo at the same path from adopting each other's auth state. +// Key connection-backed auth separately from same-path local repositories, even though gh runs locally. function runtimeCacheKey( repoPath: string, connectionId?: string | null, @@ -78,10 +72,7 @@ function pruneHostAuthCache(now: number): void { } } -// Only gh's own stdout/stderr — not the Error.message — counts as an -// authoritative answer. A spawn failure (gh missing, ENOENT) carries just a -// message and no command output, and must stay indeterminate rather than be -// read as "host not authenticated". +// Only command streams are authoritative; message-only spawn failures leave auth indeterminate. function ghCommandOutput(error: unknown): string { const execErr = error as { stdout?: unknown; stderr?: unknown } return [execErr?.stdout, execErr?.stderr] @@ -104,7 +95,7 @@ function normalizeGitHubHost(host: string): NormalizedGitHubHost | null { return null } const hostname = match[1] - // Why: remote URL parsing already removes protocol-default ports; any port left here identifies the endpoint. + // URL parsing already stripped default ports; remaining ports identify the API endpoint. const port = match[2] ?? null return { hostname, port, authority: port ? `${hostname}:${port}` : hostname } } @@ -126,13 +117,11 @@ function authenticatedHostFromInventory(host: string, output: string): string | if (exact) { return exact.authority } - // Why: a non-default web port identifies the API endpoint; portless credentials target a different server. if (requested.port) { return null } const compatible = inventory.filter((candidate) => candidate.hostname === requested.hostname) - // Why: an SSH remote has no API port. Only a unique auth-inventory host can - // safely supply it; multiple ported endpoints on one hostname are ambiguous. + // SSH omits the API port; infer one only from an unambiguous authenticated endpoint. return compatible.length === 1 ? compatible[0].authority : null } @@ -154,8 +143,6 @@ async function resolveAuthenticatedGitHubHost( if (inFlight) { return inFlight } - // Why: provider detection and review loading can probe the same runtime at - // once; coalesce them so one host never spawns duplicate auth subprocesses. const probe = (async () => { const execOptions = { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) @@ -167,12 +154,10 @@ async function resolveAuthenticatedGitHubHost( } catch (error) { const output = ghCommandOutput(error) if (!output) { - // Indeterminate (gh missing / spawn failure) — do not cache so a later - // probe (gh installed, tunnel ready, token added) can recover. + // Leave spawn failures uncached so installation or credential changes can recover. return undefined } - // gh exits non-zero when a host has a token problem but still prints the - // per-host status; trust only hosts that are actually listed. + // Nonzero auth status may still list hosts with token problems; inspect the inventory. authenticatedHost = authenticatedHostFromInventory(host, output) } hostAuthCache.set(cacheKey, { @@ -192,14 +177,7 @@ async function resolveAuthenticatedGitHubHost( } } -/** - * Whether `gh` is authenticated to `host` from the repository's own runtime. - * - * The probe inventories gh's configured hosts and matches `host` locally. It - * deliberately does not pass an untrusted remote host to gh because ambient - * enterprise tokens could otherwise be sent to that host during validation. - * Cached briefly per runtime+host so provider-detection polling stays cheap. - */ +/** Match untrusted hosts against local auth inventory without passing them to gh or exposing ambient tokens. */ export async function isGitHubHostAuthenticated( host: string, repoPath: string, @@ -217,13 +195,7 @@ export function isGitHubHostAuthenticatedForGlobalCli(host: string): Promise { - // Why: on a fork checkout PRs live on the upstream parent, not origin (#7331). + // Fork contribution PRs usually live on upstream. const originPromise = getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) if (await shouldProbeGitRemote(repoPath, 'upstream', connectionId, localGitOptions)) { const upstream = await getOwnerRepoForRemote( diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts index d65ea534d8a..6369deda5d8 100644 --- a/src/main/github/github-remote-identity-parsing.test.ts +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -28,8 +28,7 @@ describe('parseGitHubRemoteIdentity', () => { }) it('preserves a custom port on a GHES https remote', () => { - // The port IS the Enterprise web/API endpoint, so gh must target - // ghe.acme.com:8443, not the portless hostname. + // HTTPS ports identify the Enterprise API endpoint. expect(parseGitHubRemoteIdentity('https://ghe.acme.com:8443/team/orca.git')).toEqual({ host: 'ghe.acme.com:8443', owner: 'team', @@ -46,7 +45,6 @@ describe('parseGitHubRemoteIdentity', () => { }) it('drops the default https port so github.com stays bare', () => { - // WHATWG URL omits default ports, so :443 never leaks into the host. expect(parseGitHubRemoteIdentity('https://github.com:443/team/orca.git')?.host).toBe( 'github.com' ) @@ -59,7 +57,7 @@ describe('parseGitHubRemoteIdentity', () => { }) it('normalizes ssh.github.com:443 (SSH-over-HTTPS) to github.com without a port', () => { - // :443 here is the ssh transport port, not an endpoint — it must not survive. + // SSH ports select transport rather than the API endpoint. expect(parseGitHubRemoteIdentity('ssh://git@ssh.github.com:443/team/orca.git')).toEqual({ host: 'github.com', owner: 'team', @@ -68,7 +66,6 @@ describe('parseGitHubRemoteIdentity', () => { }) it('drops a custom ssh transport port on a GHES ssh remote', () => { - // ssh://…:2222 is a transport port; gh routes by hostname, so keep only the host. expect(parseGitHubRemoteIdentity('ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe( 'ghe.acme.com' ) @@ -103,8 +100,6 @@ describe('parseGitHubOwnerRepo', () => { }) it('returns null for a custom-port GHES remote (not github.com)', () => { - // GHES is handled by the enterprise resolver, not the github.com fast path, - // and the port must not make a github.com remote look like GHES either. expect(parseGitHubOwnerRepo('https://ghe.acme.com:8443/team/orca.git')).toBeNull() }) diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index c9ec59575f1..af9ca3b6254 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -3,8 +3,7 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } -// Why: HTTP ports identify the GHES web/API endpoint; SSH and git ports are -// transport-only and must not leak into gh's host identity. +// HTTP ports identify API endpoints; SSH/git ports are transport-only. function hostFromRemoteUrl(url: URL): string { const protocol = url.protocol.toLowerCase() return protocol === 'http:' || protocol === 'https:' ? url.host : url.hostname @@ -31,10 +30,7 @@ export function remoteUrlUsesSshTransport(remoteUrl: string): boolean { ) } -/** - * Why: GitHub SSH certificate organizations hand out `org-@github.com:/`, - * so the SCP user is not always `git`. - */ +/** GitHub SSH certificate users can be org-, not just git. */ function isScpStyleRemote(remoteUrl: string): boolean { return /^[^@\s/:]+@[^:\s/]+:[^\s]+$/.test(remoteUrl) } diff --git a/src/main/github/github-repository-identity.fork-owner-repo.test.ts b/src/main/github/github-repository-identity.fork-owner-repo.test.ts index 67657046933..22ebb9342fe 100644 --- a/src/main/github/github-repository-identity.fork-owner-repo.test.ts +++ b/src/main/github/github-repository-identity.fork-owner-repo.test.ts @@ -1,10 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GitRunner from '../git/runner' -// Fix for issue #7331: PR details failed to load on fork checkouts because -// getOwnerRepo() (the PR-lookup resolver) only consulted the `origin` remote -// (the fork), while the PRs live on the upstream parent. getOwnerRepo now -// prefers `upstream` when present, mirroring getIssueOwnerRepo. +// Fork PR reads prefer upstream while origin-only probes retain the checkout identity. const { gitExecFileAsyncMock, ghExecFileAsyncMock, readLocalGitConfigSignatureMock } = vi.hoisted( () => ({ @@ -33,7 +30,6 @@ const FORK_PATH = '/tmp/fork-checkout' const NON_FORK_PATH = '/tmp/plain-checkout' const SSH_FORK_PATH = '/tmp/ssh-fork-checkout' -// origin -> personal fork, upstream -> parent (the classic fork checkout). const REMOTE_URLS_BY_REPO: Record> = { [FORK_PATH]: { origin: 'https://github.com/fsdwen/orca.git', @@ -75,8 +71,6 @@ describe('issue #7331: fork PR owner/repo resolution', () => { it('getOwnerRepo prefers the upstream parent on a fork checkout', async () => { const prRepo = await getOwnerRepo(FORK_PATH) - // PRs live on the parent, so PR lookups must target it (matches - // getIssueOwnerRepo). expect(prRepo).toEqual({ owner: 'stablyai', repo: 'orca' }) }) @@ -111,20 +105,15 @@ describe('issue #7331: fork PR owner/repo resolution', () => { }) it('getOwnerRepoForRemote(origin) still resolves the fork itself', async () => { - // Callers that combine origin+upstream themselves (getRepoUpstream, - // resolvePrWorkItemSource) depend on an origin-only primitive. const origin = await getOwnerRepoForRemote(FORK_PATH, 'origin') expect(origin).toEqual({ owner: 'fsdwen', repo: 'orca' }) }) it('getRepoUpstream still resolves the fork parent offline (regression)', async () => { - // If getRepoUpstream compared upstream against an upstream-first - // getOwnerRepo, the remotes would look identical and the offline fork - // fast-path would be skipped. + // An upstream-first origin probe would hide the fork distinction and defeat the offline fast path. const upstream = await getRepoUpstream(FORK_PATH) - // Why: origin resolution pins github.com so host-scoped execution is explicit. expect(upstream).toEqual({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/github/github-repository-identity.signed-cache.test.ts b/src/main/github/github-repository-identity.signed-cache.test.ts index 862b0673308..44553fab99f 100644 --- a/src/main/github/github-repository-identity.signed-cache.test.ts +++ b/src/main/github/github-repository-identity.signed-cache.test.ts @@ -1,8 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GitRunner from '../git/runner' -// Signed identities revalidate Git config instead of re-spawning Git every 30 seconds. - const { gitExecFileAsyncMock, readLocalGitConfigSignatureMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), readLocalGitConfigSignatureMock: vi.fn<() => Promise>(async () => 'sig-1') @@ -47,7 +45,7 @@ describe('owner/repo identity cache', () => { }) expect(remoteGetUrlCalls()).toBe(1) - // Past the unsigned TTL, inside the signed TTL. + // Advance beyond unsigned TTL while remaining within the signed entry lifetime. vi.setSystemTime(Date.now() + FOUR_MINUTES) await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ owner: 'stablyai', @@ -66,7 +64,6 @@ describe('owner/repo identity cache', () => { readLocalGitConfigSignatureMock.mockImplementation(async () => 'sig-2') vi.setSystemTime(Date.now() + THIRTY_SECONDS) - // A signature change must invalidate before the TTL. await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ owner: 'other-org', repo: 'orca' @@ -81,7 +78,6 @@ describe('owner/repo identity cache', () => { await getOwnerRepoForRemote(REPO, 'origin') expect(remoteGetUrlCalls()).toBe(1) - // Unsigned entries need a bounded lifetime. vi.setSystemTime(Date.now() + THIRTY_SECONDS + 1) await getOwnerRepoForRemote(REPO, 'origin') expect(remoteGetUrlCalls()).toBe(2) diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index 98d835568cf..5d56c2ba536 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -167,10 +167,7 @@ export async function getOwnerRepoForRemote( return refreshedCached.value } - // Why: startup can resolve issue sources, PR candidates, and repo metadata - // for the same repo concurrently. Coalesce missing-remote probes — but only - // onto one young enough to still answer, so a wedged probe cannot pin the - // repo's identity for the life of the process (P1-D). + // Coalesce concurrent identity probes, but retire wedged work so it cannot pin identity forever. const inFlightKey = `${cacheKey}\0${probeOptions.requireVerifiedSshProbe ? 'verified' : 'tolerant'}` return runCoalescedProbe(ownerRepoInFlight, inFlightKey, () => resolveOwnerRepoForRemote( @@ -206,10 +203,9 @@ async function resolveOwnerRepoForRemote( pruneOwnerRepoCache(now) return null } - // Why: PR mutations need the effective host behind an SSH alias. const classification = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context) if (classification.kind === 'github') { - // Signed identities stay valid until Git config changes. + // Signed entries still expire and revalidate Git config on reuse. ownerRepoCache.set(cacheKey, { value: classification.ownerRepo, expiresAt: now + getOwnerRepoCacheTtl(classification.ownerRepo, configSignature), @@ -236,8 +232,7 @@ async function resolveOwnerRepoForRemote( pruneOwnerRepoCache(now) return null } catch (error) { - // Why: only stable "no such remote" misses are safe to hold for minutes. - // Transient git lock/IO failures must retry on the next lookup. + // Cache stable missing remotes only; transient lock/I/O failures must retry. if (!isStableMissingGitRemoteError(error)) { if (requireVerifiedSshProbe && context.connectionId) { throw error @@ -245,8 +240,7 @@ async function resolveOwnerRepoForRemote( return null } } - // Why: a missing remote is stable until `.git/config` changes. - // Holding that negative longer avoids Git process churn across PR polling. + // Config changes invalidate stable negative remote results before their longer TTL. ownerRepoCache.set(cacheKey, { value: null, expiresAt: now + getOwnerRepoCacheTtl(null, configSignature), diff --git a/src/main/github/github-ssh-host-alias-resolution.ts b/src/main/github/github-ssh-host-alias-resolution.ts index 8aa033a6b4d..44b7abdd798 100644 --- a/src/main/github/github-ssh-host-alias-resolution.ts +++ b/src/main/github/github-ssh-host-alias-resolution.ts @@ -164,7 +164,6 @@ export async function classifyGitHubOwnerRepoFromRemoteUrl( : { kind: 'not-github', cacheWithGitConfigSignature: false } } -/** Convenience wrapper for callers that only need owner/repo or null. */ export async function resolveGitHubOwnerRepoFromRemoteUrl( remoteUrl: string, context: SshConfigResolutionContext = { repoPath: '' } diff --git a/src/main/github/issue-create.ts b/src/main/github/issue-create.ts index 3b8af028706..136d47da525 100644 --- a/src/main/github/issue-create.ts +++ b/src/main/github/issue-create.ts @@ -15,11 +15,7 @@ function githubIssueErrorMessage(error: unknown): string { return stderr.trim() || stdout.trim() } -/** - * Create a new GitHub issue. Uses `gh api` with explicit owner/repo so the - * call does not depend on the current working directory having a remote that - * matches the repo the user picked in the tasks page. - */ +/** Explicit owner/repo preserves the Tasks-selected source independently of cwd remotes. */ export async function createIssue( repoPath: string, title: string, @@ -85,8 +81,7 @@ export async function createIssue( return { ok: false, error: message } } - // Why: GitHub rejects oversized bodies on create but accepts the same body - // on update, so establish the issue before attaching its body. + // GitHub can accept a body on update that it rejects during creation; establish the issue first. const { stdout } = await ghExecFileAsync(createArgs(''), ghOptions) data = parseIssue(stdout) if (typeof data.number !== 'number') { diff --git a/src/main/github/issue-field-options.ts b/src/main/github/issue-field-options.ts index 77e2337ae8a..c8f92bf99b1 100644 --- a/src/main/github/issue-field-options.ts +++ b/src/main/github/issue-field-options.ts @@ -82,11 +82,7 @@ export async function listAssignableUsers( } await acquire() try { - // Why: paginate through all assignable users — GraphQL's assignableUsers - // maxes out at 100 per page and large orgs/repos silently lose assignees - // beyond the first page. REST /assignees with --paginate walks every page; - // --jq collapses per-page arrays into NDJSON so we don't have to stitch - // JSON arrays that gh concatenates back-to-back. + // REST pagination covers all assignees; jq emits NDJSON instead of concatenated page arrays. const { stdout } = await ghExecFileAsync( [ 'api', diff --git a/src/main/github/issue-update.ts b/src/main/github/issue-update.ts index 8b00b24a148..fa0db817b35 100644 --- a/src/main/github/issue-update.ts +++ b/src/main/github/issue-update.ts @@ -8,7 +8,6 @@ import { } from './github-api-repository' import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils' -// Use the selected source for issue-number edits. export async function updateIssue( repoPath: string, issueNumber: number, @@ -40,7 +39,6 @@ export async function updateIssue( const repo = `${ownerRepo.owner}/${ownerRepo.repo}` const errors: string[] = [] - // State change requires a separate command if (updates.state) { await acquire() try { @@ -59,7 +57,7 @@ export async function updateIssue( } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) - // Treat "already closed/open" as a no-op + // Repeated state transitions are idempotent. if (!stderr.toLowerCase().includes('already')) { errors.push(classifyGhError(stderr).message) } @@ -90,7 +88,6 @@ export async function updateIssue( } } - // Field edits (labels, assignees, title) via gh issue edit const editArgs: string[] = ['issue', 'edit', String(issueNumber), '--repo', repo] let hasEditArgs = false diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index f70443dea0e..50011f4e735 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -40,9 +40,7 @@ vi.mock('./github-api-repository', async (importOriginal) => { repo ? { host: 'github.com' as const, ...repo } : repo return { ...actual, - // Why: these suites drive source resolution through the legacy gh-utils - // mocks; bridge the hosted seams onto the same mocks and pin github.com so - // host-less fixtures still pass resolveGitHubApiRepository's host gate. + // Bridge hosted source resolution to the suite fixtures and retain explicit github.com host pinning. getIssueGitHubApiRepository: async ( repoPath: string, connectionId?: string | null, @@ -76,8 +74,6 @@ import { listAssignableUsers, listLabels } from './issue-field-options' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) @@ -90,9 +86,7 @@ describe('issue source operations', () => { acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) - // Why: preference-aware paths call resolveIssueSource instead of - // getIssueOwnerRepo. Route through the same mock so existing tests that - // set up getIssueOwnerRepoMock continue to work. + // Share the preference-aware resolver with the issue-owner fixtures. resolveIssueSourceMock.mockImplementation(async () => ({ source: await getIssueOwnerRepoMock(), fellBack: false @@ -241,9 +235,7 @@ describe('issue source operations', () => { }) it('surfaces a classified permission_denied error instead of collapsing to empty', async () => { - // Why: parent design doc §3 — a 403 on a private upstream must not - // masquerade as "No issues". The envelope carries an error the UI can - // render as a banner with retry, not a silent empty list. + // A private-upstream 403 must retain a retryable error instead of becoming an empty backlog. getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockRejectedValueOnce( new Error('HTTP 403: Resource not accessible by integration') diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index a9f24107393..02c6ead5c5e 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -11,31 +11,13 @@ import { // prettier-ignore import { ghExecFileAsync, acquire, release, classifyListIssuesError } from './gh-utils' -// Why: distinguishes a successful-empty listing from a failed fetch. The -// previous `catch { return [] }` conflated a 403 on a private upstream with an -// empty backlog. Callers decide how to surface `error`. -// -// Why no `fellBack` here: the fell-back signal for the renderer toast rides on -// `ListWorkItemsResult.issueSourceFellBack` (the Tasks list's envelope). The -// only consumer of `listIssues` — the `gh:listIssues` IPC handler — unwraps -// to `.items` and has no UI hook to surface a fallback toast. Adding a dead -// `fellBack` field here invited drift between the JSDoc promise and reality. +// Keep successful-empty and failed listings distinguishable; fallback toasts belong to the Tasks envelope. export type IssueListResult = { items: IssueInfo[] error?: ClassifiedError } -/** - * Get a single issue by number. - * Uses gh api --cache so 304 Not Modified responses don't count against the rate limit. - * - * Why this path doesn't take a preference: linked-issue lookups persist a - * number to a worktree at creation time. Routing detail lookups through the - * live per-repo preference would silently flip an existing link to a - * different repo after the user toggled the selector — the opposite of what - * #1186 / the parent design doc guard against. List and create paths honor - * preference; number-resolution stays on the heuristic. - */ +/** Linked issue numbers keep heuristic source resolution; changing list preference must not retarget existing links. */ export async function getIssue( repoPath: string, issueNumber: number, @@ -48,8 +30,7 @@ export async function getIssue( connectionId, localGitOptions ) - // Why: a connection-backed request has no local cwd, so the non-GitHub - // fallback below would let gh target its default repository. Refuse instead. + // Without local cwd, unresolved connection-backed requests could target gh's default repository. if (connectionId && !ownerRepo) { return null } @@ -68,7 +49,7 @@ export async function getIssue( const data = JSON.parse(stdout) return mapIssueInfo(data) } - // Fallback for non-GitHub remotes + // Local compatibility path relies on gh repository discovery. const { stdout } = await ghExecFileAsync( ['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels,body'], ghOptions @@ -82,17 +63,7 @@ export async function getIssue( } } -/** - * List issues for a repo. - * Uses gh api --cache so 304 Not Modified responses don't count against the rate limit. - * - * Why: returns a structured result so a 403 (e.g. fork contributor without - * read access to a private upstream) surfaces as an error the UI can render - * instead of collapsing to "No issues". The empty-list-on-error behavior this - * replaces was explicitly flagged as a merge-blocker in the parent design doc - * (§3) — silently hiding failures re-creates the same silent-source-switch - * class of wrongness #1186 warned against, one level deeper. - */ +/** Return classified failures separately so unavailable repositories cannot look like empty backlogs. */ export async function listIssues( repoPath: string, limit = 20, @@ -114,8 +85,7 @@ export async function listIssues( connectionId, localGitOptions ) - // Why: a connection-backed request has no local cwd, so the non-GitHub - // fallback below would let gh list its default repository. Refuse instead. + // Refuse unresolved remote identities rather than listing gh's default repository. if (connectionId && !ownerRepo) { return { items: [], @@ -138,17 +108,14 @@ export async function listIssues( ghOptions ) const data = JSON.parse(stdout) as Record[] - // Why: the GitHub REST `/repos/{owner}/{repo}/issues` endpoint returns - // pull requests alongside issues (PRs carry a `pull_request` key). - // Strip them here so `listIssues` only returns true issues, matching the - // filter applied in `listRecentWorkItems` (src/main/github/client.ts). + // GitHub REST issues includes PRs; exclude them from this issue-only result. return { items: data .filter((d) => !('pull_request' in d)) .map((d) => mapIssueInfo(d as Parameters[0])) } } - // Fallback for non-GitHub remotes + // Local compatibility path relies on gh repository discovery. const { stdout } = await ghExecFileAsync( ['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)], ghOptions diff --git a/src/main/github/local-git-config-signature.ts b/src/main/github/local-git-config-signature.ts index 79c9da1d3bd..655bc99a050 100644 --- a/src/main/github/local-git-config-signature.ts +++ b/src/main/github/local-git-config-signature.ts @@ -9,12 +9,7 @@ type LocalGitConfigPaths = { worktreeConfigPath: string } -/** - * Why: `stat` on a dead network mount is uninterruptible, and this read runs - * *before* the bounded remote probe — unbounded, it reintroduces the hang that - * probe timeout removes (P1-D). Abandoning the read only costs the shorter - * negative-cache TTL that a missing signature already implies. - */ +// A dead mount can stall stat before the bounded Git probe; missing signatures use shorter cache lifetimes. const CONFIG_SIGNATURE_DEADLINE_MS = 2_000 const localGitConfigSignatureInFlight: CoalescedProbes = new Map() @@ -23,14 +18,10 @@ export async function readLocalGitConfigSignature( context: GitHubRepoContext ): Promise { if (context.connectionId || context.wslDistro) { - // Why: this signature only covers host filesystem config files; remote - // runtimes are already separated by cache key and probed through git. + // Remote runtimes have separate cache keys; this signature covers host filesystem config only. return undefined } - // Why: the deadline is caller-facing only. Bounding the coalesced probe itself - // would drop its map entry after 2s while the read runs on, so every later - // call would start another unbounded read instead of joining the one already - // out — the coalescing that keeps a wedged mount to one read (P1-D). + // Deadline only the caller: keep the in-flight read shared so a hung mount cannot accumulate more reads. return withConfigSignatureDeadline( runCoalescedProbe(localGitConfigSignatureInFlight, context.repoPath, () => readUncachedLocalGitConfigSignature(context.repoPath) @@ -38,7 +29,6 @@ export async function readLocalGitConfigSignature( ) } -/** Resolves undefined rather than waiting on a read the filesystem may never finish. */ function withConfigSignatureDeadline( read: Promise ): Promise { @@ -252,7 +242,7 @@ async function resolveLocalGitConfigPaths(repoPath: string): Promise[1] -// Why: a merged PR's commit set is immutable, so definitive answers — member -// or not — never change; that TTL only bounds memory. Errors (a commit not on -// GitHub yet, network) get a short TTL so a future push can flip the answer -// without the checks-panel poll re-probing every cycle. +// Definitive merged-commit membership is immutable; errors expire sooner so later pushes can resolve unknowns. const MEMBERSHIP_CACHE_MAX_ENTRIES = 200 const MEMBERSHIP_DEFINITIVE_TTL_MS = 6 * 60 * 60 * 1000 const MEMBERSHIP_ERROR_TTL_MS = 5 * 60 * 1000 const COMMIT_PULLS_PAGE_SIZE = 100 -// Why: a worktree HEAD is associated with ~1 PR, so page 1 is short in practice -// and this cap is never reached; it only bounds the pathological case of a commit -// linked to hundreds of PRs, where staying 'unknown' is the safe answer. +// Full pages through the cap cannot prove absence; keep pathological commit fan-out unknown. const COMMIT_PULLS_MAX_PAGES = 5 export type MergedPRCommitMembership = 'contained' | 'not-contained' | 'unknown' @@ -42,13 +37,7 @@ export function resetMergedPRCommitMembershipCacheForTest(): void { membershipCache.clear() } -/** - * Whether `commitOid` is part of pull request `prNumber` on GitHub — i.e. the - * commit belongs to that PR's history rather than merely sharing a branch name. - * A worktree sitting on such a commit is on the PR's own line of work (for - * example behind web-committed suggestions or an update-branch merge), not a - * reused branch name. Conservative on any failure: returns unknown. - */ +/** Check PR-history membership rather than branch-name reuse; failures return unknown. */ export async function isCommitPartOfMergedPR(args: { ownerRepo: OwnerRepo prNumber: number @@ -69,18 +58,12 @@ export async function isCommitPartOfMergedPR(args: { if (cached && cached.expiresAt > now) { return cached.value } - // Why blocked stays unknown: hiding a transient branch match is safe, but - // callers must not clear a durable linked PR when the probe never ran. + // An unrun probe cannot justify clearing a durable linked PR. if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { return 'unknown' } try { - // Why paginate: a full page that omits the target PR may just be truncated (a - // commit can belong to many PRs). Reading only page 1 and calling it - // 'not-contained' would wrongly clear a durable link; calling it 'unknown' - // would never clear one that genuinely diverged. Walk pages until the PR is - // found (contained) or a short page proves absence (not-contained); only the - // pathological all-full case up to the cap stays 'unknown'. + // Paginate until found or a short page proves absence; a full final page leaves membership unknown. for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) { if (page > 1 && repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { membershipCache.set(cacheKey, { @@ -98,8 +81,7 @@ export async function isCommitPartOfMergedPR(args: { ghOptions ) const parsed = JSON.parse(stdout) as unknown - // Why: a non-array success payload is a shape mismatch, not an empty page; - // caching it as definitive not-contained could wrongly clear a durable link. + // Non-array success is unverified, not evidence that a durable link should be cleared. if (!Array.isArray(parsed)) { membershipCache.set(cacheKey, { value: 'unknown', @@ -135,8 +117,7 @@ export async function isCommitPartOfMergedPR(args: { }) return 'unknown' } catch { - // Why: 422 often means "new local work" today, but a later push can make - // the answer knowable; preserve durable links until a probe succeeds. + // A local commit can become queryable after push; preserve links until verification succeeds. membershipCache.set(cacheKey, { value: 'unknown', expiresAt: now + MEMBERSHIP_ERROR_TTL_MS diff --git a/src/main/github/pr-head-tracking-ref.test.ts b/src/main/github/pr-head-tracking-ref.test.ts index f01c487ea70..fa9e7a9b53e 100644 --- a/src/main/github/pr-head-tracking-ref.test.ts +++ b/src/main/github/pr-head-tracking-ref.test.ts @@ -54,7 +54,6 @@ describe('fetchPrHeadTrackingRef', () => { 42 ) - // The fetch is bounded so a stalled remote can't hang PR resolution. expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['fetch', '--no-tags', 'origin', `+refs/pull/42/head:refs/orca/pull/${ORIGIN_COMPONENT}/42`], { cwd: '/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } diff --git a/src/main/github/pr-head-tracking-ref.ts b/src/main/github/pr-head-tracking-ref.ts index b6147de4989..31e4dce5ab2 100644 --- a/src/main/github/pr-head-tracking-ref.ts +++ b/src/main/github/pr-head-tracking-ref.ts @@ -52,7 +52,7 @@ export async function fetchGitHubPullRequestHeadRef( } const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) - // Why: return the same path the fetch wrote so callers don't re-resolve identity. + // Return the written ref so callers do not recompute its remote identity. const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) await gitExecFileAsync( ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], diff --git a/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts b/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts index 8f5a5cf5b32..43e691c19b3 100644 --- a/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts +++ b/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts @@ -123,9 +123,7 @@ describe('pr-refresh-coordinator', () => { currentHeadOid: 'head-a' }) - // Enqueue the survivor first, then the representative (active coalescing - // promotes the latter to representative), then invalidate the representative - // so the still-queued entry rebinds to the survivor before draining. + // Promote the second alias, then invalidate it to force rebinding to the queued survivor. enqueuePRRefresh(survivor, 'active', 80, 1) enqueuePRRefresh(representative, 'active', 80, 1) enqueuePRRefresh({ ...representative, isArchived: true }, 'active', 80, 1) @@ -178,8 +176,7 @@ describe('pr-refresh-coordinator', () => { fetchedAt: Date.now() }) - // Same worktree/branch, moved head, coalescing visible→visible (no promote): - // the representative head must track the newest report before the drain. + // Coalescing without promotion must still update the representative head. reportVisiblePRRefreshCandidates( [makeCandidate({ linkedPRNumber: 12, worktreeId: 'wt-a', currentHeadOid: 'head-a' })], 1, @@ -341,8 +338,7 @@ describe('pr-refresh-coordinator', () => { }) describe('pruneWorktreePRRefreshAliases', () => { - // Several local worktrees tracking the same linked PR coalesce into one - // queue entry (same refreshKey) whose alias map keeps one entry each. + // Linked-PR keys share one queue entry while retaining each worktree alias. const LINKED_PR_KEY = 'local::runtime:host::/repo::pr::42' function makeLinkedCandidate(worktreeId: string): GitHubPRRefreshCandidate { @@ -368,7 +364,6 @@ describe('pr-refresh-coordinator', () => { pruneWorktreePRRefreshAliases('wt-1') pruneWorktreePRRefreshAliases('wt-3') - // Last alias gone -> the whole queue entry is dropped. expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(0) }) @@ -380,12 +375,10 @@ describe('pr-refresh-coordinator', () => { _getPRRefreshQueueSizeForTests } = await import('./pr-refresh-coordinator') - // wt-1 becomes the entry's representative candidate (enqueued first). enqueuePRRefresh(makeLinkedCandidate('wt-1'), 'visible', 40, 1) enqueuePRRefresh(makeLinkedCandidate('wt-2'), 'visible', 40, 1) expect(_getPRRefreshQueueSizeForTests()).toBe(1) - // Removing the representative worktree must not orphan the entry. pruneWorktreePRRefreshAliases('wt-1') expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1) expect(_getPRRefreshQueueSizeForTests()).toBe(1) diff --git a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts index 68096706290..4afbe911faa 100644 --- a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts +++ b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts @@ -48,8 +48,7 @@ describe('pr-refresh-coordinator', () => { it('proceeds with background refreshes when the budget probe fails (fail open)', async () => { const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - // Why: regression for #7553 — GHES with rate limiting disabled 404s every - // probe; an unreadable budget must not pause refreshes. + // GHES can disable rate limits and return 404; an unreadable budget must not pause refreshes. getRateLimitMock.mockResolvedValue({ ok: false, error: 'HTTP 404: Rate limiting is not enabled.' @@ -124,17 +123,14 @@ describe('pr-refresh-coordinator', () => { 'graphql', executionOptions ) - // Why (#11532): the lookup itself debits the snapshot now, so every caller - // is accounted for; the coordinator must not double-charge on top. + // Lookup owns budget debits; the coordinator must not charge twice. expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) }) it('does not consume active burst slots for rate-limit pauses', async () => { const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - // Why: keyed on drained items (one getRateLimit call each) rather than - // guard-call counts, so the guard's per-bucket evaluation order stays free - // to change without breaking the slot-accounting assertion. + // Count drained requests so per-bucket guard order cannot affect slot accounting. let drainedItems = 0 getRateLimitMock.mockImplementation(async () => { drainedItems += 1 diff --git a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts index 5645133ae0a..e49d368542d 100644 --- a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts +++ b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts @@ -80,8 +80,7 @@ describe('pr-refresh-coordinator', () => { reportVisiblePRRefreshCandidates([makeCandidate({ currentHeadOid: 'worktree-head-oid' })], 1, 1) await vi.runOnlyPendingTimersAsync() - // Why: without the head, a panel-supplied fallback number preserves a - // merged PR head-blind after the branch moves on to new work. + // Without the current head, fallback PR numbers could preserve a diverged merged review. expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( '/repo', 'feature/test', @@ -103,8 +102,7 @@ describe('pr-refresh-coordinator', () => { reportVisiblePRRefreshCandidates([makeCandidate({ currentHeadOid: 'worktree-head-oid' })], 1, 1) await vi.runOnlyPendingTimersAsync() - // Why: the renderer clear of a diverged merged linked PR is head-scoped, so - // the broadcast alias must carry the request-time head it was probed against. + // Diverged-link clears are head-scoped; broadcast the head used by this request. const outcomeEvent = sendMock.mock.calls .map(([, event]) => event) .find((event) => event.outcome) @@ -127,7 +125,7 @@ describe('pr-refresh-coordinator', () => { expect(inFlight?.requestStartedAt).toBe(1_000) expect(outcome?.requestStartedAt).toBe(1_000) expect(outcome?.sequence).toBe(inFlight?.sequence) - // The renderer skips the reply as a repeat of the broadcast only if both carry this same outcome. + // Reply/broadcast deduplication needs identical outcomes. expect(result).toEqual(outcome?.outcome) }) diff --git a/src/main/github/pr-refresh-coordinator-test-mocks.ts b/src/main/github/pr-refresh-coordinator-test-mocks.ts index a52a2812b6d..5c674077c8a 100644 --- a/src/main/github/pr-refresh-coordinator-test-mocks.ts +++ b/src/main/github/pr-refresh-coordinator-test-mocks.ts @@ -13,9 +13,7 @@ export type PRRefreshCoordinatorMocks = { spendsSharedGitHubComQuotaMock: Mock } -// Why: every coordinator suite registers its own `vi.mock` factories (hoisting -// is per-file), so the mock set and the factory bodies live here and each test -// file only wires them together. +// Vitest hoists per file; suites register these shared factories locally. export function createPRRefreshCoordinatorMocks(): PRRefreshCoordinatorMocks { return { sendMock: vi.fn(), @@ -82,8 +80,7 @@ export function ipcUiModuleMock(mocks: PRRefreshCoordinatorMocks): IpcUiModuleMo } } -// The coordinator keeps module-level queue state; reset modules and timers so -// one suite's pending work cannot leak into the next test. +// Module queues and timers must not leak pending work between tests. export function resetPRRefreshCoordinatorMocks(mocks: PRRefreshCoordinatorMocks): void { const { sendMock, diff --git a/src/main/github/pr-refresh-error-classification.test.ts b/src/main/github/pr-refresh-error-classification.test.ts index 549fc10fdc8..ef55c96577d 100644 --- a/src/main/github/pr-refresh-error-classification.test.ts +++ b/src/main/github/pr-refresh-error-classification.test.ts @@ -78,15 +78,12 @@ describe('classifyPRRefreshError', () => { }) it('does not classify a repository name containing "network" as a network failure', () => { - // A repo/branch called "network-*" is not a connectivity failure; without a - // structured code or a real connectivity phrase this must stay unknown. expect(classifyPRRefreshError(new Error('operation failed for repo acme/network-tools'))).toBe( 'unknown' ) }) it('classifies a 404 repository error even when the message mentions network', () => { - // 404 ranks before the network branch so the repo error is not misread. expect( classifyPRRefreshError( new Error('Could not resolve to a Repository named acme/network-proxy (HTTP 404)') @@ -99,9 +96,7 @@ describe('classifyPRRefreshError', () => { }) it('classifies from the real runner error shape where the signal is on .stderr', () => { - // Why: the runner rejects with a generic message and puts the gh diagnostic - // on `.stderr` (string or Buffer). Reading `.message` alone would misclassify - // these hard errors as `unknown` and treat them as transient. + // The runner stores diagnostics on stderr even when message is only a generic exit error. const permission = Object.assign(new Error('gh exited with 1.'), { stderr: 'HTTP 403: Resource not accessible by integration' }) diff --git a/src/main/github/pr-refresh-error-classification.ts b/src/main/github/pr-refresh-error-classification.ts index 894ddc729fd..4755cd0bfe6 100644 --- a/src/main/github/pr-refresh-error-classification.ts +++ b/src/main/github/pr-refresh-error-classification.ts @@ -2,27 +2,9 @@ import type { PRRefreshErrorType } from '../../shared/github/pull-request-refres import { classifyGitHubUnavailable } from '../../shared/github/api-availability' import { extractExecError } from '../git/exec-error' -/** - * Sanitization boundary for GitHub PR-refresh failures. Maps a raw runner/CLI - * error to a stable {@link PRRefreshErrorType}; the renderer turns that into - * classified copy so raw stderr / env values never reach the UI. - * - * Classification order (see docs/reference/pr-panel-refresh-guidance.md): - * HTTP 429 / secondary rate limit → primary rate limit → repo_unavailable (404) → - * network → permission (403) → gh_unavailable → auth → unknown. GitHub returns - * 403 OR 429 for both primary and secondary limits, so the http-403 permission - * branch must run only after the rate-limit checks. 404 is matched before the - * network branch so a repository error whose message happens to contain a - * substring like "network" is not misread as a connectivity failure. Auth and - * network detection avoid broad substrings (e.g. "auth" inside "author", or a - * repo name containing "network") in favor of structured codes and full phrases. - */ +/** Classify runner failures before UI rendering; rate-limit 403s precede permissions, and repo 404s precede network heuristics. */ export function classifyPRRefreshError(err: unknown): PRRefreshErrorType { - // Why: the git/gh runner keeps diagnostics on `.stderr`/`.stdout` separate - // from `.message` (which can be just "gh exited with 1."). Reading message - // alone would misclassify a real "HTTP 403" / "HTTP 404" as `unknown`, so a - // hard error would be treated as transient. Combine all three for substring - // detection while still preferring the structured spawn `code`. + // Runner diagnostics may live only on stdout/stderr; retain structured spawn codes too. const { stderr, stdout } = extractExecError(err) const message = err instanceof Error ? err.message : String(err) const lower = `${message}\n${stderr}\n${stdout}`.toLowerCase() @@ -31,9 +13,7 @@ export function classifyPRRefreshError(err: unknown): PRRefreshErrorType { ? String((err as { code?: unknown }).code ?? '').toLowerCase() : '' - // Rate limits first: a 429 is always a rate-limit signal; secondary limits also - // arrive as abuse-mechanism phrasing or a 403/429 carrying Retry-After, none of - // which contain "rate limit". + // Secondary limits may mention abuse or Retry-After without the phrase rate limit. const isHttp429 = lower.includes('http 429') || lower.includes('429 too many requests') const isHttp403 = lower.includes('http 403') const hasRetryAfter = lower.includes('retry-after') @@ -49,20 +29,15 @@ export function classifyPRRefreshError(err: unknown): PRRefreshErrorType { ) { return 'rate_limited' } - // Repository resolution failures (404) rank before the network branch: a "could - // not resolve to a Repository" message must not be captured by a connectivity - // heuristic, and matching 404 first isolates it from any incidental substring. + // Match repository failures before incidental connectivity wording. if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) { return 'repo_unavailable' } - // Why: keep GitHub 5xx/network attribution aligned with other GitHub surfaces. const unavailable = classifyGitHubUnavailable(lower) if (unavailable) { return unavailable } - // Network: structured error codes and full connectivity phrases only. Never a - // bare "network" substring — a repo/branch/message containing "network" is not - // evidence of a connectivity failure. + // Require structured codes or full phrases; a repository named network-* is not a network failure. const networkCodes = new Set([ 'etimedout', 'econnreset', @@ -94,8 +69,7 @@ export function classifyPRRefreshError(err: unknown): PRRefreshErrorType { if (isHttp403 || lower.includes('resource not accessible')) { return 'permission' } - // gh CLI launch failure: prefer the structured spawn error code over a broad - // substring so a repo path merely containing "gh" is not misclassified. + // Prefer structured launch codes so paths containing gh cannot imply a missing executable. if ( code === 'enoent' || lower.includes('spawn gh enoent') || @@ -104,9 +78,7 @@ export function classifyPRRefreshError(err: unknown): PRRefreshErrorType { ) { return 'gh_unavailable' } - // Auth last: match full auth phrases and a 401, never a bare "auth" substring - // that also fires on "author"/"authored". "unauthorized"/"authorization" are - // long enough not to collide with "author". + // Full auth phrases avoid matching author/authored. if ( lower.includes('http 401') || lower.includes('unauthorized') || @@ -121,7 +93,6 @@ export function classifyPRRefreshError(err: unknown): PRRefreshErrorType { return 'unknown' } -/** Stable, non-destructive fallback message for a classified refresh error. */ export function safePRRefreshErrorMessage(errorType: PRRefreshErrorType): string { switch (errorType) { case 'rate_limited': diff --git a/src/main/github/pr-refresh-queue-growth-bound.test.ts b/src/main/github/pr-refresh-queue-growth-bound.test.ts index 759fa4c2fe7..35c7040c53e 100644 --- a/src/main/github/pr-refresh-queue-growth-bound.test.ts +++ b/src/main/github/pr-refresh-queue-growth-bound.test.ts @@ -89,11 +89,7 @@ describe('pr-refresh queue growth bounds', () => { _getPRRefreshQueueSizeForTests } = await import('./pr-refresh-coordinator') - // A linked-PR refreshKey ignores the branch, so one entry survives every - // branch switch, and each switch used to add an alias that only the next - // drain could shed — so the fan-out tracked branch churn instead of the one - // live branch. (Draining between switches caps the unfixed count in the low - // single digits here; the parked-entry case below is where it really piles up.) + // A linked-PR key survives branch changes; coalescing must retire stale branch aliases. for (let i = 0; i < 300; i += 1) { reportVisiblePRRefreshCandidates( [ @@ -136,8 +132,7 @@ describe('pr-refresh queue growth bounds', () => { getPRForBranchOutcomeMock.mockImplementation(async () => { if (!switched) { switched = true - // The entry is already out of the queue here, so this re-enqueue creates the - // entry the in-flight request's stale follow-up aliases merge back into. + // Re-enqueue after dequeue so the older in-flight snapshot merges into a newer queued entry. reportVisiblePRRefreshCandidates([churnCandidate(2)], 2, 1) } return { @@ -227,8 +222,7 @@ describe('pr-refresh queue growth bounds', () => { const { enqueuePRRefresh, _getPRRefreshAliasCountForTests, _getPRRefreshQueueSizeForTests } = await import('./pr-refresh-coordinator') - // No timer advance: the entry stays queued, so every enqueue lands on the same - // alias map and the coalescing bound is what keeps it from growing. + // Keep one entry parked so every enqueue exercises its alias bound. for (let i = 0; i < 200; i += 1) { enqueuePRRefresh( makeCandidate({ @@ -267,8 +261,7 @@ describe('pr-refresh queue growth bounds', () => { cachedFetchedAt: null }) - // A manual refresh merges its alias into its own copy of the entry's map and - // writes that map back, so the coalescing bound has to hold on re-entry too. + // Manual refresh writes a copied alias map back, so re-entry must enforce the same bound. reportVisiblePRRefreshCandidates([churnCandidate(0)], 1, 1) await vi.advanceTimersByTimeAsync(1_000) for (let i = 1; i <= 30; i += 1) { diff --git a/src/main/github/pr-refresh-queue.ts b/src/main/github/pr-refresh-queue.ts index 85b7422ec42..09144ccf664 100644 --- a/src/main/github/pr-refresh-queue.ts +++ b/src/main/github/pr-refresh-queue.ts @@ -38,12 +38,7 @@ export type PRRefreshEnqueue = { type PRRefreshRequestState = { until: number; requestSequence?: number } -/** A worktree has one live branch at a time, so a second cacheKey for it is a - * branch it moved off. Drop those: a linked-PR key survives every branch - * switch, so while its entry is parked (rate-limit pause, error backoff, or - * just waiting to drain) every switch used to add a cacheKey that the drain - * snapshot then carries forward, and each dead one costs IPC payload and a - * renderer cache write on every later broadcast. */ +/** Retire old-branch aliases when a linked-PR key survives worktree branch changes. */ function setLiveAlias( aliases: Map, alias: GitHubPRRefreshAlias @@ -58,9 +53,7 @@ function setLiveAlias( aliases.set(alias.cacheKey, alias) } -/** Follow-up aliases were captured before the request ran, so anything enqueued - * while it was in flight is newer. Return that preserved alias so callers do - * not pair it with the stale request candidate. */ +/** Queued aliases are newer than request-time follow-ups; preserve them with their matching candidate. */ function mergeFollowUpAlias( aliases: Map, alias: GitHubPRRefreshAlias @@ -76,9 +69,7 @@ function mergeFollowUpAlias( return undefined } -/** A manual refresh merges its alias into its own copy of the map and writes it - * back, so re-entry through `set` has to re-apply the same bound; later - * insertions are the newer branch and win. */ +/** Manual refresh writes copied maps back; keep the latest branch per worktree on re-entry too. */ function dropSupersededWorktreeAliases(aliases: Map): void { const liveCacheKeyByWorktree = new Map() for (const [cacheKey, alias] of aliases) { diff --git a/src/main/github/pr-review-comment-lines.test.ts b/src/main/github/pr-review-comment-lines.test.ts index 4871ee88b11..975d2146dd6 100644 --- a/src/main/github/pr-review-comment-lines.test.ts +++ b/src/main/github/pr-review-comment-lines.test.ts @@ -25,18 +25,14 @@ describe('getPRReviewCommentLineNumbersFromPatch', () => { }) it('counts added lines whose content begins with ++', () => { - // Inside a hunk, GitHub's per-file patch never carries a `+++ b/file` header - // (those precede the first @@), so `+++count` is an added line of content `++count` - // and must stay comment-eligible. + // Inside a hunk +++count is added ++count content, not a file header. const patch = ['@@ -1,1 +1,2 @@', ' const a = 1', '+++count'].join('\n') expect(getPRReviewCommentLineNumbersFromPatch(patch)).toEqual([1, 2]) }) it('does not treat removed lines whose content begins with -- as commentable', () => { - // Asymmetry guard: removed lines never advance the new-side counter, so `--count` - // (diff line `---count`) must not become commentable. Locks the intended difference - // from the added-line case so a future "symmetric" edit can't regress it. + // Removed --count content must not advance the new-side line counter. const patch = ['@@ -1,2 +1,1 @@', ' const a = 1', '---count'].join('\n') expect(getPRReviewCommentLineNumbersFromPatch(patch)).toEqual([1]) diff --git a/src/main/github/pr-review-comment-lines.ts b/src/main/github/pr-review-comment-lines.ts index 2c8d747297b..384f68e036b 100644 --- a/src/main/github/pr-review-comment-lines.ts +++ b/src/main/github/pr-review-comment-lines.ts @@ -21,9 +21,7 @@ export function getPRReviewCommentLineNumbersFromPatch(patch: string | undefined continue } - // Inside a hunk every `+` line is added content. GitHub's per-file patch keeps the - // `+++ b/file` header before the first @@, so a `+++…` line here is `++…` content, - // not a header, and must stay comment-eligible. + // Per-file headers precede the hunk; +++ inside a hunk is added content. if (line.startsWith('+')) { lineNumbers.push(nextModifiedLine) nextModifiedLine++ diff --git a/src/main/github/pr-start-point-compare-base.test.ts b/src/main/github/pr-start-point-compare-base.test.ts index 7a6b4e458a7..4c2c50cfca0 100644 --- a/src/main/github/pr-start-point-compare-base.test.ts +++ b/src/main/github/pr-start-point-compare-base.test.ts @@ -29,7 +29,6 @@ describe('resolveGitHubPrStartPoint compare base', () => { throw new Error("fatal: couldn't find remote ref refs/heads/main") }) const fetchPullRequestHeadRef = vi.fn(async () => durablePrLocalRef) - // Why: durable ref for the head resolves; the compare base does not exist. const gitExec = vi.fn(async (args: string[]) => { if (args[2] === durablePrRev) { return { stdout: 'fork-head-sha\n', stderr: '' } @@ -49,7 +48,6 @@ describe('resolveGitHubPrStartPoint compare base', () => { resolveRemote: async () => 'origin' }) - // Why: guards against a FETCH_HEAD regression — the head must resolve via the durable ref. expect(gitExec).toHaveBeenCalledWith(['rev-parse', '--verify', durablePrRev]) expect(result).toEqual({ baseBranch: 'fork-head-sha', @@ -64,7 +62,6 @@ describe('resolveGitHubPrStartPoint compare base', () => { it('keeps the compare base for a fork PR when the local tracking ref still resolves', async () => { const fetchRemoteTrackingRef = vi.fn(async () => { - // Why: transient network failure — the previously-fetched base is still on disk. throw new Error('fatal: unable to access repo: Could not resolve host: github.com') }) const fetchPullRequestHeadRef = vi.fn(async () => durablePrLocalRef) @@ -90,7 +87,6 @@ describe('resolveGitHubPrStartPoint compare base', () => { resolveRemote: async () => 'origin' }) - // Why: create-time baseRef metadata must be the compare base, not the PR head SHA. expect(result).toEqual({ baseBranch: 'fork-head-sha', compareBaseRef: 'refs/remotes/origin/main', diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts index 562cdfcebb8..13fda6f6d0e 100644 --- a/src/main/github/pr-start-point.test.ts +++ b/src/main/github/pr-start-point.test.ts @@ -28,7 +28,6 @@ describe('resolveGitHubPrStartPoint', () => { getPullRequestPushTargetMock.mockReset() getWorkItemMock.mockReset() fetchPullRequestHeadRefMock.mockReset() - // Why: success path rev-parses the path the fetch returns (writer-authoritative). fetchPullRequestHeadRefMock.mockImplementation(async (_remote: string, prNumber: number) => durablePrLocalRef(prNumber) ) @@ -166,8 +165,7 @@ describe('resolveGitHubPrStartPoint', () => { }) it('prefers the pull-head error when the branch miss triggered a failing fallback', async () => { - // Why: the branch fetch missed and we fell back to refs/pull//head; the - // fallback failure is the actionable one, not the original branch miss. + // Surface the failed pull-head fallback, rather than the earlier branch miss. const fetchRemoteTrackingRef = vi.fn(async () => { throw new Error('fatal: could not find remote ref refs/heads/feature/fix') }) @@ -197,8 +195,7 @@ describe('resolveGitHubPrStartPoint', () => { it('captures the fork PR head from a dedicated ref, not the shared FETCH_HEAD', async () => { getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable')) const fetchRemoteTrackingRef = vi.fn(async () => {}) - // Why: simulate a concurrent `git fetch origin` clobbering FETCH_HEAD with the - // default-branch tip. The resolved start-point must come from the durable Orca ref. + // Concurrent fetches can clobber FETCH_HEAD; the durable Orca ref must remain authoritative. const gitExec = vi.fn(async (args: string[]) => { if (args[0] === 'rev-parse') { const ref = args.at(-1) @@ -225,7 +222,6 @@ describe('resolveGitHubPrStartPoint', () => { }) expect(fetchPullRequestHeadRefMock).toHaveBeenCalledWith('origin', 1849) - // Success path must not re-hash remote identity after the fetch returns a path. expect(gitExec).not.toHaveBeenCalledWith(['remote', 'get-url', 'origin']) expect(gitExec).not.toHaveBeenCalledWith(['rev-parse', '--verify', 'FETCH_HEAD']) expect(result).toEqual({ @@ -236,8 +232,7 @@ describe('resolveGitHubPrStartPoint', () => { }) it('keeps the durable PR head when the head fetch fails but the local ref resolves', async () => { - // Why: mirror compare-base soft-keep — a transient fetch failure must not - // fail the resolve when a prior fetch already pinned refs/orca/pull/. + // A prior durable fetch can survive transient transport failure. getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable')) fetchPullRequestHeadRefMock.mockRejectedValue( new Error('fatal: unable to access repo: Could not resolve host: github.com') @@ -290,8 +285,7 @@ describe('resolveGitHubPrStartPoint', () => { 'stale relay' ] ])('fails hard instead of soft-keeping the durable PR head on: %s', async (message) => { - // Why: soft-keep on a non-transient failure would check out a dead or - // unauthorized tip (or mask the reconnect prompt) with a success UX. + // Auth/deleted-ref/stale-relay failures must not return a dead or unauthorized cached tip. getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable')) fetchPullRequestHeadRefMock.mockRejectedValue(new Error(message)) const fetchRemoteTrackingRef = vi.fn(async () => {}) diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index 0174ac7cbb1..3391e10b4c8 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -25,8 +25,7 @@ type ResolveGitHubPrStartPointArgs = { localGitOptions?: { wslDistro?: string; ghAccount?: GhAccountBinding } gitExec: GitExec fetchRemoteTrackingRef: (remote: string, branch: string) => Promise - // Why: returns the durable local ref the fetch wrote so resolve can rev-parse - // that exact path instead of re-hashing remote identity. + // Return the written durable ref so success never recomputes its remote identity. fetchPullRequestHeadRef: (remote: string, prNumber: number) => Promise resolveRemote: () => Promise } @@ -57,8 +56,7 @@ export async function resolveGitHubPrStartPoint( pushTarget = resolved?.pushTarget maintainerCanModify = resolved?.maintainerCanModify } catch { - // Why: deleted/inaccessible fork metadata can prevent push-target - // discovery, but GitHub still exposes the PR head ref for checkout. + // Missing fork metadata can block push-target discovery while the PR head ref remains fetchable. pushTarget = undefined } } @@ -109,8 +107,7 @@ export async function resolveGitHubPrStartPoint( const fetchPullRequestHeadSha = async (): Promise<{ baseBranch: string } | { error: string }> => { const pullRef = `refs/pull/${args.prNumber}/head` - // Why: soft-keep needs identity when the fetch throws before returning a path. - // Success uses the path returned by the fetch itself (writer-authoritative). + // Failed fetches return no ref path, so only soft-keep must independently resolve its identity. let softKeepLocalRefPromise: Promise | undefined const resolveSoftKeepLocalRef = (): Promise => { softKeepLocalRefPromise ??= (async () => { @@ -150,11 +147,7 @@ export async function resolveGitHubPrStartPoint( return { baseBranch: sha } } catch (error) { const message = error instanceof Error ? error.message : String(error) - // Why: mirror compare-base — a transient transport failure must not fail - // the resolve when a prior fetch already pinned the durable head ref. A - // missing remote ref (deleted PR/fork), auth failure, or stale-relay - // error must fail hard: serving the durable ref there would check out a - // dead or unauthorized tip and mask the actionable error. + // Reuse durable heads only for transient transport failures; deleted/auth/stale-relay errors stay actionable. if (isTransientReviewHeadFetchError(error)) { const localSha = await resolveDurableHeadSha(await resolveSoftKeepLocalRef()) if (localSha) { @@ -175,18 +168,14 @@ export async function resolveGitHubPrStartPoint( } } - // Why: fork PR heads live on a remote we don't have configured, so - // `git fetch ` would fail. GitHub exposes every - // PR head (fork or same-repo) as refs/pull//head on the upstream repo. + // Upstream exposes fork heads through refs/pull//head even without a configured fork remote. if (isCrossRepository) { const result = await fetchPullRequestHeadSha() if ('error' in result) { return result } const compareBaseFetched = await fetchCompareBaseRef() - // Why: adopt the contributor's branch name locally (mirroring the same-repo - // return below) so fork-PR worktrees aren't renamed with the maintainer's - // branch prefix (e.g. `me/866`). The push refspec still targets the fork. + // Keep the contributor's branch name locally; push still targets the fork. return { ...result, ...(compareBaseFetched && compareBaseRef ? { compareBaseRef } : {}), @@ -201,8 +190,7 @@ export async function resolveGitHubPrStartPoint( await args.fetchRemoteTrackingRef(remote, headRefName) } catch (error) { const message = error instanceof Error ? error.message : String(error) - // Why: missing fork metadata can make a fork PR look like a same-repo - // branch. Only that missing-ref case should fall back to refs/pull. + // Missing fork metadata can mimic a same-repo branch; only missing-ref errors allow pull-head fallback. if (isMissingRemoteRefGitError(error)) { const result = await fetchPullRequestHeadSha() if (!('error' in result)) { @@ -217,8 +205,7 @@ export async function resolveGitHubPrStartPoint( ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) } } - // Why: the branch fetch missed and the pull-head fallback is what actually - // failed, so surface its (more actionable) error rather than the branch miss. + // Return the actionable pull-head fallback failure instead of the earlier branch miss. return result } return { diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index cfa45726760..a9343b2f474 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -1,12 +1,3 @@ -// Why: covers the recent fixes — -// (a) network errors must NOT be misclassified as not_found ("could not -// resolve host" partially overlaps "could not resolve to a"), -// (b) repo slug validation must accept names with leading underscore -// (GitHub allows them, e.g. `_internal`), -// (c) owner slug validation must reject `.` and a leading `_`/`-`, but accept -// the `_` suffix GitHub appends to Enterprise Managed User logins, -// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer, -// (e) project owner/capability caches stay bounded in long sessions. import { beforeEach, describe, expect, it } from 'vitest' import { GITHUB_PROJECT_REF_INPUT_MAX_BYTES, @@ -41,9 +32,7 @@ describe('classifyProjectError', () => { }) it('classifies "could not resolve host" as network_error, NOT not_found', () => { - // Why: this was the bug — substring "could not resolve" overlaps. The - // network branch must run before not_found, and the not_found check - // must require "to a " to disambiguate. + // DNS host failures overlap could-not-resolve text; distinguish them from unresolved GraphQL objects. expect(classifyProjectError('could not resolve host: api.github.com', '').type).toBe( 'network_error' ) @@ -140,8 +129,7 @@ describe('parseProjectPaste', () => { }) it('accepts shorthand with an Enterprise Managed User owner (renderer parity)', () => { - // Why: the renderer's parser uses `[A-Za-z0-9][A-Za-z0-9_-]*` for owner - // (matches OWNER_SLUG_RE). Both sides must accept and reject the same inputs. + // Keep shorthand owner validation aligned with the renderer. expect(parseProjectPaste('octocat_acme/1')).toEqual({ kind: 'bare', owner: 'octocat_acme', @@ -194,7 +182,6 @@ describe('parseProjectPaste', () => { viewNumber: 2 }) expect(parseProjectPaste(url)).toBeNull() - // github.com URLs still parse when a GHES host is supplied. expect( parseProjectPaste('https://github.com/orgs/acme/projects/7', 'github.corp.example') ).toEqual({ kind: 'org', owner: 'acme', number: 7, host: 'github.com' }) diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 71566110b36..3abe1bcebdd 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -1,4 +1,4 @@ -// Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. +// Preserve the public import entry point across the internal module split. export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals' export { classifyProjectError } from './project-view/project-error-classification' export { updateIssueBySlug } from './project-view/mutations' diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index e9853eb7538..fb7d76c0b13 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -1,7 +1,3 @@ -// Why: `ghExecFileAsync` (WSL-aware, retry-enabled) is the single spawn site -// for gh calls. The legacy plain `execFileAsync` is NOT used here — routing -// every gh call through the runner gives us transient-5xx retry, WSL path -// translation, and a single hook point for future quota tracking. import { acquire, release } from '../gh-utils' import { isGitHubOwnerSlug } from '../../../shared/github/owner-slug' import { extractExecError, ghExecFileAsync } from '../../git/runner' @@ -31,18 +27,10 @@ export { } export type { RateLimitBucketKind } -/** - * gh exec routing shared by the project-view read/write paths. `host` pins - * GHES requests to the enterprise server via the runner's host qualifier. - * - * Intentionally ambient: Project View does not honor `Repo.ghAccount` (Part A). - * Work-items drawer / create-worktree search use bound injection; Project View - * keeps the process login so host-scoped project boards stay stable. - */ +/** Project View uses ambient login, independently of Repo.ghAccount; pin Enterprise hosts explicitly. */ export type ProjectGhExecOptions = { cwd?: string; host?: string } -// Why: owner/project-addressed calls carry only a host (no repo slug), so they -// can't reuse githubHostExecOptions — mirror its host pinning for that shape. +// Owner/project calls have no repository slug, so pin their host directly. export function projectGhExecOptions(host: string | undefined): ProjectGhExecOptions { return { host: githubProjectHost(host) } } @@ -57,21 +45,14 @@ export async function projectHostAuthenticationError( ) { return null } - // Why: never target an unconfigured pasted host with gh; ambient - // GH_ENTERPRISE_TOKEN credentials could otherwise be sent to that server. + // Verify pasted hosts before sending ambient Enterprise credentials. return { type: 'auth_required', message: `GitHub CLI is not authenticated to ${selectedHost}. Run gh auth login --hostname ${selectedHost}.` } } -// ─── Slug validation ────────────────────────────────────────────────── - -// Why: the owner check lives in shared/github/owner-slug (main, renderer and -// mobile all parse it). Repo names are looser — they allow leading `_`, `.`, `-` -// (`.` and `..` reserved). We validate each separately so untrusted Project row -// data (`nameWithOwner`) can't become an arbitrary REST path while still -// accepting realistic repo names like `_internal` or `.github`. +// Shared owner rules differ from repo names; validate both before interpolating authenticated REST paths. const REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ const REPO_SLUG_RESERVED = new Set(['.', '..']) @@ -136,8 +117,6 @@ export function validateSlugArgs( return { ok: true } } -// ─── Low-level gh api graphql invocation ─────────────────────────────── - export type GraphqlVars = Record export async function runGraphql( @@ -152,15 +131,12 @@ export async function runGraphql( if (authError) { return { ok: false, error: authError, raw: { stderr: '', stdout: '' } } } - // Why: GHES traffic runs against its own quota — only github.com requests - // consult/debit the shared snapshot. + // Enterprise traffic has separate quota from the shared github.com snapshot. const guard = repositoryRateLimitGuard({ host: exec?.host }, 'graphql') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } } } - // Why: build argv as an array. `-f` for strings (including numbers passed - // as strings), `-F` coerces to typed. We use `-f` uniformly and coerce in - // the query via Int! casts, because `gh` can confuse empty strings. + // Use typed -F for numbers/booleans and literal -f for strings, including empty strings. const args: string[] = ['api', 'graphql', '-f', `query=${query}`] for (const [k, v] of Object.entries(vars)) { if (typeof v === 'number' || typeof v === 'boolean') { @@ -204,8 +180,7 @@ export async function runGraphql( } } } catch (err) { - // gh executable failures (non-zero exit). Read stderr/stdout from the - // exec rejection's explicit fields — `err.message` may truncate stderr. + // Explicit streams preserve diagnostics that Error.message may truncate. const { stderr, stdout: maybeStdout } = extractExecError(err) return { ok: false, @@ -227,8 +202,7 @@ export async function runRest( if (authError) { return { ok: false, error: authError } } - // Why: GHES traffic runs against its own quota — only github.com requests - // consult/debit the shared snapshot. + // Enterprise traffic has separate quota from the shared github.com snapshot. const guard = repositoryRateLimitGuard({ host: options?.host }, bucket) if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } @@ -241,11 +215,7 @@ export async function runRest( ...(cwd ? { cwd } : {}), ...(options?.host ? { host: options.host } : {}) }) - // Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no - // body. Treat empty stdout as success rather than misclassifying the - // unparseable response as 'unknown' — which the caller would otherwise - // need to special-case and risks masking real failures whose stderr the - // classifier also tags as 'unknown'. + // Successful DELETE endpoints can return no body; do not misclassify empty stdout as a parse failure. if (options?.expectEmpty && stdout.trim() === '') { return { ok: true, data: undefined as T } } diff --git a/src/main/github/project-view/project-error-classification.ts b/src/main/github/project-view/project-error-classification.ts index a9d12fa44f1..ca65f6d59db 100644 --- a/src/main/github/project-view/project-error-classification.ts +++ b/src/main/github/project-view/project-error-classification.ts @@ -1,6 +1,3 @@ -// Why: turns raw gh stderr/stdout into the typed GitHubProjectViewError the -// renderer can act on (auth vs scope vs rate limit vs drift), shared by the -// project-view read and mutation paths. import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' import { githubProjectHost } from '../../../shared/github/project-identity' @@ -12,9 +9,7 @@ export type GhGraphqlError = { } export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlError[] { - // `gh api graphql` prints the response JSON to stdout even on GraphQL - // errors, and the stderr carries a summary. Try stdout first; if parsing - // fails, fall back to stderr. + // GraphQL errors can arrive as JSON on stdout even with nonzero exit; try it before stderr. const sources = [stdout, stderr] for (const src of sources) { if (!src) { @@ -26,7 +21,7 @@ export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlE return parsed.errors } } catch { - // not JSON — continue + // Try the next diagnostic stream. } } return [] @@ -34,7 +29,6 @@ export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlE export function errorsIndicateParentField(errors: GhGraphqlError[], stderr: string): boolean { const lower = stderr.toLowerCase() - // Preview-header shape: gh returns a 4xx with "preview" in the message. if (lower.includes('preview') && lower.includes('parent')) { return true } @@ -63,7 +57,6 @@ export function classifyProjectError( const s = stderr.toLowerCase() const selectedHost = githubProjectHost(host) - // Auth if ( s.includes('authentication required') || s.includes('not logged in') || @@ -74,7 +67,6 @@ export function classifyProjectError( message: `Sign in to GitHub to load project tasks. Run \`gh auth login --hostname ${selectedHost}\`.` } } - // Scope if ( s.includes('missing required scope') || s.includes('your token has not been granted') || @@ -85,16 +77,10 @@ export function classifyProjectError( message: `GitHub project access needs additional scopes. Run \`gh auth refresh --hostname ${selectedHost} -s project -s read:org -s repo\`.` } } - // Rate limit if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } } - // Network — checked BEFORE not_found because DNS failures surface as - // "could not resolve host", which would otherwise be partially matched by - // the not_found branch's "could not resolve" check. Substring matching here - // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" - // error always contains "to a", so we tighten the not_found check below to - // require that token. + // DNS could-not-resolve-host overlaps missing-object wording; classify it before not-found. if ( s.includes('timeout') || s.includes('no such host') || @@ -104,14 +90,10 @@ export function classifyProjectError( ) { return { type: 'network_error', message: 'Network error — check your connection.' } } - // Not found if ( s.includes('http 404') || errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || - // Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to - // an Organization") and "to a" otherwise. The previous singular-only - // check missed the "an" variants when gh emits only the stderr summary - // without a structured GraphQL error array. See bug-scan finding 3. + // GitHub uses to an for vowel-leading object types such as Issue and Organization. /could not resolve to an? /.test(s) ) { const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') @@ -123,11 +105,9 @@ export function classifyProjectError( : undefined } } - // Validation if (s.includes('http 422') || s.includes('validation failed')) { return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } } - // GraphQL error with structured info if (errors.length > 0) { const first = errors[0] return { @@ -136,9 +116,7 @@ export function classifyProjectError( details: { path: first.path, code: first.extensions?.code } } } - // Why: don't leak full stderr to the UI — it can include verbose request - // dumps with header diagnostics. Truncate to the first non-empty line and - // cap length so unexpected diagnostics stay readable but bounded. + // Bound unexpected diagnostics instead of exposing full request dumps. const firstLine = stderr .split('\n') @@ -158,11 +136,7 @@ export function driftError( return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } } -// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` -// when the cached snapshot says we're below the safety floor. Synthesize the -// same `rate_limited` error shape as the post-hoc classifier so the UI path -// is unchanged. We DO NOT fail open here when there's no cached snapshot — -// rateLimitGuard already handles that case (returns `blocked:false`). +// Shape pre-spawn breaker failures like classified rate limits; the guard already handles missing snapshots. export function rateLimitedError(blocked: { remaining: number limit: number diff --git a/src/main/github/project-view/project-view-cache.ts b/src/main/github/project-view/project-view-cache.ts index 571e5318dc8..36110d9e5ae 100644 --- a/src/main/github/project-view/project-view-cache.ts +++ b/src/main/github/project-view/project-view-cache.ts @@ -3,9 +3,6 @@ import { githubProjectHost } from '../../../shared/github/project-identity' export const PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES = 512 -// ─── Module-scope caches (reset on HMR — intentional) ────────────────── - -// Why: owners are user-controlled over a long session; bound cache entries to avoid unbounded retention while keeping the hot-owner fast path. function rememberProjectViewCacheEntry( cache: Map, key: K, @@ -34,18 +31,15 @@ function getProjectViewCacheEntry(cache: Map, key: K): V | undefined return value } -// Why: plain module locals so HMR code swaps re-run capability probes instead of carrying a stale "unsupported" flag. +// Module reloads must discard stale capability results. const ownerTypeCache = new Map() -// Why: keyed per owner (not a process-global flag) so one owner's capability gap doesn't poison others that DO support Issue.parent (bug-scan finding 2). +// One owner's unsupported parent field must not poison another owner's capability. const parentFieldRetriedByOwner = new Map() const parentFieldWarningLoggedByOwner = new Map() -// Why: in-flight promise per owner so concurrent fetchAllItems callers share one probe instead of each racing a duplicate first-page probe. +// Share first-page capability probes per owner. export const parentFieldProbeInFlight = new Map>() -// Why: GHES owners are a separate namespace and capability surface from -// github.com owners with the same login — scope cache keys by host so one -// host's probe result can't leak into another. Normalize github.com so -// host-less callers share the same probe state as explicitly pinned calls. +// Isolate same-name owners across hosts; normalize implicit and explicit github.com to one scope. export function ownerScopeKey( owner: string, ownerType: GitHubProjectOwnerType, diff --git a/src/main/github/project-view/project-view-config.test.ts b/src/main/github/project-view/project-view-config.test.ts index 67022c23e3d..4fcc57c00d8 100644 --- a/src/main/github/project-view/project-view-config.test.ts +++ b/src/main/github/project-view/project-view-config.test.ts @@ -74,7 +74,6 @@ describe('verticalGroupByFields capability fallback', () => { vi.mocked(runGraphql).mockResolvedValueOnce(unknownFieldFailure()).mockResolvedValue(okPage()) await fetchProjectViewsPage(args) await fetchProjectViewsPage(args) - // Third call overall = first call of the second fetch: no failed probe repeated. expect(vi.mocked(runGraphql).mock.calls[2]?.[0]).not.toContain('verticalGroupByFields') await fetchProjectViewsPage({ ...args, host: 'github.com' }) expect(vi.mocked(runGraphql).mock.calls[3]?.[0]).toContain('verticalGroupByFields') @@ -119,8 +118,7 @@ describe('verticalGroupByFields capability fallback', () => { }) it('does not treat the field name inside partial-error DATA as incapability', async () => { - // Why: partial errors echo the whole body, where the field name appears as - // a plain data key on healthy schemas — that must not degrade the host. + // Partial-error data can echo a healthy field name; only schema errors prove it unsupported. vi.mocked(runGraphql).mockResolvedValueOnce({ ok: false, error: { type: 'schema_drift', message: 'Could not read this project view.' }, diff --git a/src/main/github/project-view/project-view-config.ts b/src/main/github/project-view/project-view-config.ts index 5b226a9e02b..5f6d58d514f 100644 --- a/src/main/github/project-view/project-view-config.ts +++ b/src/main/github/project-view/project-view-config.ts @@ -15,8 +15,6 @@ import { FIELD_CONFIG_FRAGMENT } from './project-view-query-fragments' const VIEWS_PAGE_SIZE = 20 const FIELDS_PAGE_SIZE = 50 -// ─── Project config fetch (views + fields, paginated) ────────────────── - type RawProjectConfig = { id?: string title?: string @@ -47,7 +45,6 @@ export type RawProjectView = { // Older GHES hosts omit vertical grouping so one missing field cannot break all views. const hostsWithoutVerticalGroupBy = new Set() -/** Test-only: capability state is module-level so real runs memoize per host. */ export function resetVerticalGroupByCapabilityForTests(): void { hostsWithoutVerticalGroupBy.clear() } @@ -160,7 +157,7 @@ export async function fetchViewFieldsContinuation( ): Promise< { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } > { - // Why: address the view directly via node(id:) instead of re-walking all views each page — one round-trip per field page. + // Address view ID directly to avoid rewalking every view for each field page. const query = ` query($after:String!, $viewId:ID!) { node(id:$viewId) { @@ -249,7 +246,6 @@ export function finalizeView( number: typeof raw.number === 'number' ? raw.number : 0, name: typeof raw.name === 'string' ? raw.name : '', layout, - // Why: `ProjectV2View.filter` is nullable — normalize to ''. filter: typeof raw.filter === 'string' ? raw.filter : '', fields, groupByFields, @@ -260,8 +256,6 @@ export function finalizeView( } } -// ─── View selection ─────────────────────────────────────────────────── - export function matchesSelector( raw: RawProjectView, sel: { viewId?: string; viewNumber?: number; viewName?: string } diff --git a/src/main/github/project-view/project-view-discovery.ts b/src/main/github/project-view/project-view-discovery.ts index 8fdd47c97ac..cfb43588b39 100644 --- a/src/main/github/project-view/project-view-discovery.ts +++ b/src/main/github/project-view/project-view-discovery.ts @@ -9,9 +9,7 @@ import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' import { driftError } from './project-error-classification' import { rememberOwnerType } from './project-view-cache' -// ─── Constants ───────────────────────────────────────────────────────── - -// Why: defaults deliberately shrunk to cut quota spend in discovery — the org loop dominates and produced the HTTP 504; overflow owners can paste a URL. +// Bound organization discovery to avoid 504s/quota drain; overflow projects remain accessible by URL. const DISCOVERY_PROJECTS_PER_OWNER = 40 const DISCOVERY_MAX_ORGS = 20 const DISCOVERY_ORG_PAGE_SIZE = 20 @@ -43,19 +41,15 @@ type RawViewerDiscovery = { } } -// ─── listAccessibleProjects ──────────────────────────────────────────── - export async function listAccessibleProjects( args?: ListAccessibleProjectsArgs ): Promise { const host = githubProjectHost(args?.host) const viewerProjects: GitHubProjectSummary[] = [] const orgProjects: GitHubProjectSummary[] = [] - // Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504. const partialFailures: { owner: string; message: string }[] = [] let viewerLogin: string | null = null - // 1) Viewer projects (paginated, single owner so cap at DISCOVERY_PROJECTS_PER_OWNER total). let viewerCursor: string | null = null let viewerMore = true let viewerFetched = 0 @@ -82,7 +76,7 @@ export async function listAccessibleProjects( } const res = await runGraphql(query, vars, projectGhExecOptions(host)) if (!res.ok) { - // Why: viewer-level failure is structural (no projects to build on), so propagate hard; org-level errors below are non-fatal. + // Viewer discovery failure is fatal; organization failures can preserve partial results. return { ok: false, error: res.error } } if (!res.data.viewer) { @@ -119,8 +113,7 @@ export async function listAccessibleProjects( viewerCursor = viewerMore ? (pi?.endCursor ?? null) : null } - // 2) Organizations the viewer belongs to, each with its projectsV2. - // Why: no per-org projectsV2 continuation loop — it was the dominant 504 cost; users past the cap can paste a URL instead. + // Skip per-org continuation, the dominant 504 cost; overflow projects can be pasted by URL. let orgCursor: string | null = null let orgMore = true let orgsSeen = 0 @@ -149,7 +142,7 @@ export async function listAccessibleProjects( } const res = await runGraphql(query, vars, projectGhExecOptions(host)) if (!res.ok) { - // Why: org-listing failed; record a synthetic '*' partial failure so the banner explains it, but keep collected viewer projects (the reported 504 path). + // Preserve viewer projects and mark the organization-list failure for the partial-results banner. partialFailures.push({ owner: '*', message: res.error.message }) break } diff --git a/src/main/github/project-view/project-view-field-normalization.ts b/src/main/github/project-view/project-view-field-normalization.ts index ab41b812e08..7b783ac3505 100644 --- a/src/main/github/project-view/project-view-field-normalization.ts +++ b/src/main/github/project-view/project-view-field-normalization.ts @@ -7,8 +7,6 @@ import type { GitHubProjectUser } from '../../../shared/github/project-types' -// ─── Normalizers ─────────────────────────────────────────────────────── - export type RawProjectV2Field = { __typename?: string id?: string @@ -176,7 +174,7 @@ export function normalizeFieldValue( } case undefined: default: - // Unknown __typename → forward-compat: drop silently, don't classify as drift (see design §Error Handling). + // Ignore future field types rather than treating compatible schema additions as drift. return null } } diff --git a/src/main/github/project-view/project-view-item-page.ts b/src/main/github/project-view/project-view-item-page.ts index e0ea53d059b..5d59427d627 100644 --- a/src/main/github/project-view/project-view-item-page.ts +++ b/src/main/github/project-view/project-view-item-page.ts @@ -24,15 +24,13 @@ import { itemContentSelection } from './project-view-query-fragments' -// ─── Items fetch (paginated) ────────────────────────────────────────── - export type RawItemsPage = { totalCount?: number pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } nodes?: (RawItem | null)[] } -// Why: unlike runGraphql, return the raw GraphQL error envelope so the parent-field retry decision can re-inspect it. +// Preserve raw GraphQL errors for parent-field capability retry decisions. export async function fetchItemsPageWithRaw(args: { owner: string ownerType: GitHubProjectOwnerType @@ -87,8 +85,7 @@ export async function fetchItemsPageWithRaw(args: { argsArr.push('-f', `after=${args.after}`) } - // Why: GHES traffic runs against its own quota — only github.com requests - // consult/debit the shared snapshot. + // Enterprise traffic has separate quota from the shared github.com snapshot. const guard = repositoryRateLimitGuard(args, 'graphql') if (guard.blocked) { return { @@ -121,7 +118,7 @@ export async function fetchItemsPageWithRaw(args: { try { parsed = JSON.parse(stdout) } catch { - // Why: gh exited non-zero with unparseable stdout; classify against stderr so callers see the real cause, not a synthesized drift/not-found. + // Unparseable failed stdout must retain stderr's error rather than become missing data. if (execFailed) { return { ok: false, @@ -137,7 +134,7 @@ export async function fetchItemsPageWithRaw(args: { stderr } } - // Why: gh rejected but stdout parsed; fall through to parsed.errors below, else surface the stderr classification rather than not_found. + // Prefer parsed GraphQL errors, otherwise classify failed execution from stderr. if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { return { ok: false, diff --git a/src/main/github/project-view/project-view-items.ts b/src/main/github/project-view/project-view-items.ts index 3ecdb55bd9a..a99f7943f83 100644 --- a/src/main/github/project-view/project-view-items.ts +++ b/src/main/github/project-view/project-view-items.ts @@ -27,16 +27,14 @@ export async function fetchAllItems(args: { | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } | { ok: false; error: GitHubProjectViewError; totalCount?: number } > { - // Why: isolate missing Issue.parent capability by owner, type, and host. const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host) - // Why: await the same-scope probe, then re-read state because it may have changed. const inFlight = parentFieldProbeInFlight.get(scopeKey) if (inFlight) { await inFlight.catch(() => {}) } let includeParent = !hasParentFieldRetried(scopeKey) let parentFieldDropped = !includeParent - // Single-flight the with-parent probe per owner; assign the in-flight promise synchronously (no await between get() and set()) so callers share one probe. + // Register the shared parent probe synchronously so concurrent callers cannot duplicate it. let first: Awaited> let probePromise: Promise>> | null = null if (includeParent && !parentFieldProbeInFlight.has(scopeKey)) { @@ -57,7 +55,7 @@ export async function fetchAllItems(args: { includeParent: true, host: args.host }) - // Why: set the retried flag BEFORE resolving/clearing the probe so siblings awoken on inFlight.catch() see it and don't fire duplicate with-parent probes. + // Publish unsupported capability before releasing probe waiters. if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { markParentFieldRetried(scopeKey) } @@ -81,7 +79,7 @@ export async function fetchAllItems(args: { }) } if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { - // Retry the whole table without parent; mark this owner retried so later fetches skip the probe (other owners unaffected). + // Retry only this owner without parent; other owner scopes retain capability. markParentFieldRetried(scopeKey) includeParent = false parentFieldDropped = true @@ -106,7 +104,6 @@ export async function fetchAllItems(args: { return { ok: false, error: first.error } } - // Drift guards if (first.page.totalCount === undefined || first.page.totalCount === null) { return { ok: false, error: driftError('items.totalCount missing') } } @@ -118,7 +115,6 @@ export async function fetchAllItems(args: { return { ok: false, error: driftError('items.nodes missing'), totalCount } } - // Size cap if (totalCount > MAX_ITEMS) { return { ok: false, @@ -148,7 +144,6 @@ export async function fetchAllItems(args: { return { ok: false, error: e1, totalCount } } - // Paginate let hasNext = first.page.pageInfo.hasNextPage === true let cursor: string | null | undefined = first.page.pageInfo.endCursor if (hasNext && typeof cursor !== 'string') { @@ -199,8 +194,6 @@ export async function fetchAllItems(args: { return { ok: true, rows, totalCount, parentFieldDropped } } -// ─── Cheap count-only query (for unsupported_layout) ────────────────── - export async function fetchItemsCountOnly(args: { owner: string ownerType: GitHubProjectOwnerType diff --git a/src/main/github/project-view/project-view-listing.ts b/src/main/github/project-view/project-view-listing.ts index f6784110428..14fdca24d53 100644 --- a/src/main/github/project-view/project-view-listing.ts +++ b/src/main/github/project-view/project-view-listing.ts @@ -7,8 +7,6 @@ import type { import { assertPositiveInt, assertSlug } from './internals' import { fetchProjectViewsPage } from './project-view-config' -// ─── listProjectViews ────────────────────────────────────────────────── - export async function listProjectViews( args: ListProjectViewsArgs ): Promise { diff --git a/src/main/github/project-view/project-view-query-fragments.ts b/src/main/github/project-view/project-view-query-fragments.ts index aca32a0b2ae..dc339442c51 100644 --- a/src/main/github/project-view/project-view-query-fragments.ts +++ b/src/main/github/project-view/project-view-query-fragments.ts @@ -1,7 +1,5 @@ const FIELD_VALUES_PAGE_SIZE = 100 -// ─── GraphQL query fragments ─────────────────────────────────────────── - export const FIELD_CONFIG_FRAGMENT = ` fragment FieldConfig on ProjectV2FieldConfiguration { __typename diff --git a/src/main/github/project-view/project-view-reference.ts b/src/main/github/project-view/project-view-reference.ts index 831967e0ecf..942d8eeb5f2 100644 --- a/src/main/github/project-view/project-view-reference.ts +++ b/src/main/github/project-view/project-view-reference.ts @@ -14,8 +14,6 @@ import { isValidOwnerSlug, projectGhExecOptions, runGraphql, type GraphqlVars } import { getCachedOwnerType, rememberOwnerType } from './project-view-cache' import { ownerQueryRoot } from './project-view-config' -// ─── resolveProjectRef ───────────────────────────────────────────────── - type ParsedPaste = | { kind: 'org'; owner: string; number: number; host: string; viewNumber?: number } | { kind: 'user'; owner: string; number: number; host: string; viewNumber?: number } @@ -29,8 +27,7 @@ export function parseProjectPaste(input: string, host?: string): ParsedPaste | n if (isGitHubProjectRefInputTooLarge(trimmed)) { return null } - // Why: URL parsing enforces an exact Project path and rejects credentials; - // a prefix regex could silently turn `/projects/1evil` into Project 1. + // Parse exact project URLs and reject credentials; prefix matching could accept projects/1evil. try { const url = new URL(trimmed) const allowedHosts = new Set(['github.com', ...(host ? [host.trim().toLowerCase()] : [])]) @@ -65,7 +62,7 @@ export function parseProjectPaste(input: string, host?: string): ParsedPaste | n ...(viewNumber !== undefined ? { viewNumber } : {}) } } catch { - // Shorthand parsing below remains available for non-URL input. + // Non-URL input may still match shorthand. } const sm = trimmed.match(GITHUB_OWNER_NUMBER_SHORTHAND_RE) if (sm) { @@ -91,7 +88,6 @@ async function resolveOwnerType( num: number | null ): Promise<{ ok: true; title: string } | { ok: false; error: GitHubProjectViewError }> => { const root = ownerQueryRoot(ot) - // If number is provided, fetch the project title; else just confirm owner exists. const query = num ? ` query($owner:String!, $num:Int!) { @@ -189,10 +185,8 @@ export async function resolveProjectRef( } const preferred: GitHubProjectOwnerType | null = parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null - // Why: a pasted URL is authoritative. The ambient host only applies to - // owner/number shorthand; otherwise same-number Projects can cross hosts. + // URL hosts are authoritative; ambient host applies only to owner/number shorthand. const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host - // Verify by fetching project title. const ownerRes = await resolveOwnerType(parsed.owner, preferred, executionHost) if (!ownerRes.ok) { return { ok: false, error: ownerRes.error } @@ -221,7 +215,6 @@ export async function resolveProjectRef( number: parsed.number, title: p.title ?? '', host: executionHost, - // Why: forward URL view numbers so the renderer can skip view selection; bare shorthand has none. ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined ? { viewNumber: parsed.viewNumber } : {}) diff --git a/src/main/github/project-view/project-view-table.ts b/src/main/github/project-view/project-view-table.ts index 0a5f992d9fe..3c050e3bf53 100644 --- a/src/main/github/project-view/project-view-table.ts +++ b/src/main/github/project-view/project-view-table.ts @@ -14,8 +14,6 @@ import { import type { RawProjectV2Field } from './project-view-field-normalization' import { fetchAllItems, fetchItemsCountOnly } from './project-view-items' -// ─── Public: getProjectViewTable ────────────────────────────────────── - export async function getProjectViewTable( args: GetProjectViewTableArgs ): Promise { @@ -34,7 +32,6 @@ export async function getProjectViewTable( } } - // Paginate views until a match is found. let cursor: string | null = null let project: { id: string; title: string; url: string } | null = null let selectedRaw: RawProjectView | null = null @@ -62,7 +59,6 @@ export async function getProjectViewTable( if (m === 'none') { continue } - // Precedence: id > number > name > default. const rank: Record = { id: 4, number: 3, name: 2, default: 1 } const currentRank = matchStrength ? rank[matchStrength] : 0 if (!selectedRaw || rank[m] > currentRank) { @@ -70,7 +66,7 @@ export async function getProjectViewTable( matchStrength = m } } - // Why: stop on ANY match (incl. 'default' = first table view); walking further pages costs a GraphQL call per page with no re-ranking upside. + // Stop on the first match; further pagination spends quota without reranking it. if (selectedRaw) { break } @@ -88,9 +84,7 @@ export async function getProjectViewTable( const noSelector = args.viewId === undefined && args.viewNumber === undefined && args.viewName === undefined if (!selectedRaw && noSelector) { - // Why: `matchesSelector` only defaults to a table view, so a project whose - // views are all roadmaps resolved to nothing even though we can now render - // one. Table stays the preferred default; this is the empty-handed case. + // Prefer tables, but allow roadmap/board defaults when no table view matched. selectedRaw = viewsSeen.find((v) => v.layout === 'ROADMAP_LAYOUT') ?? viewsSeen.find((v) => v.layout === 'BOARD_LAYOUT') ?? @@ -100,7 +94,6 @@ export async function getProjectViewTable( return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } } } - // Paginate view fields if necessary. let extraFields: RawProjectV2Field[] = [] const fieldsPi = selectedRaw.fields?.pageInfo if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) { @@ -117,13 +110,11 @@ export async function getProjectViewTable( } const selectedView = finalized.view - // Why: empty-string override means "no filter"; undefined means "use the view's stored filter". The override is ephemeral, never persisted. + // Empty override clears the filter; undefined retains the stored view filter. Neither persists. const effectiveQuery = typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter - // Why: boards and roadmaps read the same item stream as a table — only the - // renderer differs. Unknown future layouts must reject cleanly, not render - // as a table. + // Layouts share item data, but future unsupported layouts need explicit rejection. if (!isRenderableProjectViewLayout(selectedView.layout)) { const count = await fetchItemsCountOnly({ owner: args.owner, @@ -136,15 +127,13 @@ export async function getProjectViewTable( ok: false, error: { type: 'unsupported_layout', - // Why: the branch is type-unreachable (closed union) but runtime-real — - // raw.layout is cast unchecked, so an unknown value lands here. + // Unchecked runtime layout values can reach this branch despite the closed type union. message: `Orca renders table, board, and roadmap views. This is a ${String(selectedView.layout).replace('_LAYOUT', '').toLowerCase()} view.` }, ...(typeof count === 'number' ? { totalCount: count } : {}) } } - // Fetch items. const items = await fetchAllItems({ owner: args.owner, ownerType: args.ownerType, diff --git a/src/main/github/pull-request-file-contents.ts b/src/main/github/pull-request-file-contents.ts index fa367e1c68b..a018e8572a1 100644 --- a/src/main/github/pull-request-file-contents.ts +++ b/src/main/github/pull-request-file-contents.ts @@ -86,7 +86,6 @@ export async function getPRFileContents(args: { await acquire() try { - // Added and removed files have no content on one side of the comparison. const needsOriginal = args.status !== 'added' const needsModified = args.status !== 'removed' const originalPath = args.oldPath ?? args.path diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts index 323f729ea26..a94cf0ce9dd 100644 --- a/src/main/github/rate-limit.test.ts +++ b/src/main/github/rate-limit.test.ts @@ -63,8 +63,7 @@ describe('getRateLimit', () => { fetchedAt: 1_000 } }) - // The runner injects --hostname at spawn from the `host` option, so the - // probe passes bare argv and pins the host to github.com through options. + // The real runner adds --hostname after this mocked boundary. expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], { encoding: 'utf-8', host: 'github.com' @@ -72,8 +71,7 @@ describe('getRateLimit', () => { }) it('caches a failed probe for the TTL instead of re-spawning gh', async () => { - // Why: refreshes fail open past a probe failure, so without a negative cache - // an auth or transport failure would spawn the same doomed gh call repeatedly. + // Negative-cache failed probes so repeated refreshes do not respawn doomed gh calls. ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) const first = await getRateLimit() @@ -145,9 +143,7 @@ describe('rateLimitGuard', () => { }) it('fails open once the blocking bucket resetAt has elapsed', async () => { - // Why: probes can fail indefinitely (sleep past resetAt, network blip), so - // a stale exhausted snapshot must not block once GitHub has already reset - // the budget — a past-due pause would spin the coordinator drain loop. + // Exhausted snapshots from an ended window must not keep the refresh loop paused. await cacheExhaustedSnapshot(61) vi.setSystemTime(61_000) @@ -156,9 +152,7 @@ describe('rateLimitGuard', () => { }) }) -// Why: only default github.com traffic on the native runtime shares this -// snapshot's budget. GHES hosts and WSL distros run against separate quotas, -// so the shared guard must bypass them entirely. +// Only native github.com calls share this snapshot; Enterprise and WSL have separate quotas. describe('shared-quota host scoping', () => { beforeEach(() => { vi.useFakeTimers() @@ -198,8 +192,7 @@ describe('shared-quota host scoping', () => { }) it('treats a WSL UNC cwd as non-shared even without an explicit wslDistro', () => { - // Why: the runner derives the WSL distro from a \\wsl.localhost\... cwd, so - // gh runs inside WSL and spends that distro's quota — not the native one. + // UNC cwd routes execution into the distro and spends its quota. expect( spendsSharedGitHubComQuota( { host: 'github.com' }, @@ -212,7 +205,6 @@ describe('shared-quota host scoping', () => { { cwd: '\\\\wsl$\\Ubuntu\\home\\me\\repo' } ) ).toBe(false) - // Regular Windows/POSIX cwds keep sharing the native github.com budget. expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: 'C:\\repos\\a' })).toBe(true) expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: '/Users/me/repo' })).toBe(true) }) @@ -267,9 +259,7 @@ describe('shared-quota host scoping', () => { }) }) -// Why: the breaker's reset probe must query the tripping scope's runtime/host -// (WSL distro, GHES host), record into that scope only, and never touch the -// shared native-github.com snapshot. +// Refine only the tripping host/runtime's breaker; preserve the shared native snapshot. describe('scope-aware breaker reset probe', () => { const WSL_SCOPE = 'wsl:ubuntu:github.com' const GHES_SCOPE = 'native:github.acme-corp.com' @@ -283,8 +273,7 @@ describe('scope-aware breaker reset probe', () => { }) afterEach(() => { - // Clear per-scope breaker state without unregistering the module-load - // probe (which _resetGhRateLimitBreaker would drop for later tests). + // Preserve the module-load probe while clearing scoped breaker state. for (const scope of [WSL_SCOPE, GHES_SCOPE]) { for (const bucket of ALL_BUCKETS) { clearGhRateLimitBlock(bucket, scope) @@ -300,7 +289,7 @@ describe('scope-aware breaker reset probe', () => { } it('refines a WSL-scope trip via a probe matching that scope', async () => { - // resetAt 601s > the 5-minute fallback so the refinement is observable. + // Choose a reset beyond the fallback window so refinement is observable. ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify({ resources: { @@ -312,7 +301,6 @@ describe('scope-aware breaker reset probe', () => { }) notifyGhPrimaryRateLimit('core', WSL_SCOPE) - // Single-flight: a concurrent second trip must not spawn a second probe. notifyGhPrimaryRateLimit('core', WSL_SCOPE) await flushProbe() @@ -322,12 +310,8 @@ describe('scope-aware breaker reset probe', () => { host: 'github.com', wslDistro: 'ubuntu' }) - // The exhausted core bucket is refined to the real reset for THAT scope. expect(getGhRateLimitBlockedUntilMs('core', 1_000, WSL_SCOPE)).toBe(601_000) - // Non-exhausted buckets in the same scope stay clear. expect(getGhRateLimitBlockedUntilMs('search', 1_000, WSL_SCOPE)).toBeNull() - // The shared native-github.com snapshot was not written: the guard has no - // cached data and fails open. expect(rateLimitGuard('core')).toEqual({ blocked: false }) expect(getGhRateLimitBlockedUntilMs('core', 1_000)).toBeNull() }) @@ -337,15 +321,13 @@ describe('scope-aware breaker reset probe', () => { notifyGhPrimaryRateLimit('core', GHES_SCOPE) await flushProbe() - // A second trip inside the failure TTL must not spawn another probe. notifyGhPrimaryRateLimit('core', GHES_SCOPE) await flushProbe() expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - // Fail open: the blunt fallback block stands (5 minutes from trip time). + // Failed refinement leaves the existing fallback block in force. expect(getGhRateLimitBlockedUntilMs('core', 1_000, GHES_SCOPE)).toBe(301_000) - // Past the TTL the scope is probed again (and can recover on success). vi.setSystemTime(1_000 + 30_000) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify({ @@ -360,7 +342,6 @@ describe('scope-aware breaker reset probe', () => { encoding: 'utf-8', host: 'github.acme-corp.com' }) - // Budget is actually available in that scope — the block is cleared. expect(getGhRateLimitBlockedUntilMs('core', 31_000, GHES_SCOPE)).toBeNull() }) }) diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index 4e1a515ab89..5e5714a196a 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -1,13 +1,4 @@ -/** - * GitHub API rate-limit probe. - * - * Why: heavy fan-out (listWorkItems × repos, org-walks) can drain the core/search buckets; surfacing remaining budget lets users self-regulate rather than throttle. - * The probe itself is exempt from rate-limit accounting per GitHub docs. - * - * Part A: breaker stays host/runtime-scoped (not account-scoped). Bound-account - * calls can trip a host breaker that ambient reset probes later clear — known - * cross-account bleed, accepted for Part A. - */ +// Breakers are host/runtime-scoped; bound accounts share a scope with ambient reset probes. import type { GetRateLimitResult, GitHubRateLimitBucket, @@ -27,10 +18,10 @@ import { type GhRateLimitBucket } from '../git/gh-rate-limit-breaker' -// Why: GET /rate_limit is exempt from limits, so caching only avoids a gh subprocess per render; 30s stays live while absorbing 1/s polling. +// GET /rate_limit is quota-exempt; caching avoids a subprocess per refresh. const RATE_LIMIT_CACHE_TTL_MS = 30_000 let cached: GitHubRateLimitSnapshot | null = null -// Why: cache failures too — a host that 404s every probe (GHES with rate limiting off) would otherwise spawn a gh subprocess per refresh. +// Negative-cache failures, including Enterprise hosts with rate limiting disabled. let probeFailure: { at: number; error: string } | null = null type GhRateLimitPayload = { @@ -50,7 +41,7 @@ function parseBucket( } | undefined ): GitHubRateLimitBucket { - // Why: absent bucket → 0/0/now so the UI reads "unknown" rather than a misleading "plenty left". + // Missing buckets represent unknown budget, not available capacity. return { limit: typeof raw?.limit === 'number' ? raw.limit : 0, remaining: typeof raw?.remaining === 'number' ? raw.remaining : 0, @@ -73,10 +64,7 @@ const MIN_REMAINING_SEARCH = 2 export type RateLimitBucketKind = 'core' | 'graphql' | 'search' -/** - * Return a "soft" stop reason if we should refuse a new gh request for the bucket; `{ blocked: false }` when there's budget or no snapshot yet (fail open). - * Why: advisory (returns a reason, doesn't throw) so callers format the error envelope in their own shape. - */ +/** Advisory failure envelope; absent snapshots fail open so callers can choose their own UI copy. */ export function rateLimitGuard(bucket: RateLimitBucketKind): | { blocked: false } | { @@ -85,7 +73,7 @@ export function rateLimitGuard(bucket: RateLimitBucketKind): limit: number resetAt: number } { - // Why: the breaker learns exhaustion from real 403s, which can precede a probe (e.g. quota burned by another tool on the account). + // Real 403s can exhaust quota before any snapshot exists. const breakerBlockedUntilMs = getGhRateLimitBlockedUntilMs(bucket) if (breakerBlockedUntilMs !== null) { return { @@ -105,21 +93,18 @@ export function rateLimitGuard(bucket: RateLimitBucketKind): : bucket === 'graphql' ? MIN_REMAINING_GRAPHQL : MIN_REMAINING_SEARCH - // Why: a snapshot from before reset describes an ended window — fail open rather than block on stale data. + // An expired budget window cannot justify blocking new work. if (b.resetAt * 1000 <= Date.now()) { return { blocked: false } } - // Why: limit:0 means "unknown" (parseBucket fallback) — don't block on missing data or a single bad response bricks the app. + // limit 0 means unknown; malformed snapshots must not block the app. if (b.limit > 0 && b.remaining < floor) { return { blocked: true, remaining: b.remaining, limit: b.limit, resetAt: b.resetAt } } return { blocked: false } } -/** - * Decrement the cached `remaining` for a bucket after a successful spawn. - * Why: between probes the snapshot would over-report budget, so this keeps the breaker honest during a burst instead of waiting for the TTL. - */ +/** Debit successful spawns between probes so bursts cannot overrun the cached budget. */ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void { if (!cached) { return @@ -130,7 +115,7 @@ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void } } -// Why: this snapshot covers native github.com only; GHES and WSL use quotas protected by the runner's scoped breaker. +// This snapshot covers native github.com only; Enterprise and WSL use scoped breakers. export function spendsSharedGitHubComQuota( repository: { host?: string } | null | undefined, executionOptions?: { wslDistro?: string; cwd?: string } @@ -138,9 +123,7 @@ export function spendsSharedGitHubComQuota( if (!isDefaultGitHubHost(repository?.host) || executionOptions?.wslDistro) { return false } - // Why: a \\wsl.localhost\... (or \\wsl$\...) cwd makes the runner spawn gh - // inside WSL, so it spends that distro's quota. The UNC shape is - // platform-unambiguous, so no win32 gate is needed. + // UNC cwd selects distro execution on every host; exclude its distinct quota. return !(executionOptions?.cwd && isWslUncPath(executionOptions.cwd)) } @@ -165,7 +148,7 @@ export function noteRepositoryRateLimitSpend( } } -// Why: the breaker only knows "blocked", not the reset time; one exempt probe refines it to the real reset or clears a stale block (single-flight so a 403 burst probes once). +// Share one exempt probe to refine reset time or clear an obsolete block after a 403 burst. let resetRefinementInFlight: Promise | null = null function refineBreakerFromSnapshot(): void { @@ -193,10 +176,7 @@ function refineBreakerFromSnapshot(): void { } const DEFAULT_BREAKER_SCOPE = ghRateLimitScopeKey('native', 'github.com') -// Why: non-default scopes (GHES hosts, WSL runtimes) have their own quota and -// their own probe lifecycle — single-flight and failure negative-cache are -// keyed per scope so a GHES 403 storm can't starve a WSL refinement (or vice -// versa). +// Isolate probe lifetimes and failure cooldowns by host/runtime so one scope cannot starve another. const scopeRefinementInFlight = new Map>() const scopeProbeFailureAtMs = new Map() const SCOPE_PROBE_FAILURE_MAX_ENTRIES = 512 @@ -219,7 +199,6 @@ function rememberScopeProbeFailure(scope: string, failedAt: number): void { } function refineBreakerForScope(scope: string): void { - // Default scope keeps the existing shared-snapshot refinement path. if (scope === DEFAULT_BREAKER_SCOPE) { refineBreakerFromSnapshot() return @@ -228,9 +207,7 @@ function refineBreakerForScope(scope: string): void { if (!parts || scopeRefinementInFlight.has(scope)) { return } - // Why: GHES with rate limiting disabled 404s every probe. Fail open (the - // fallback block stands) and don't re-probe in a tight loop while the - // breaker keeps tripping. + // Failed refinement retains the fallback block; negative-cache disabled-limit Enterprise probes. const failedAt = scopeProbeFailureAtMs.get(scope) if (failedAt !== undefined && Date.now() - failedAt < RATE_LIMIT_CACHE_TTL_MS) { return @@ -239,8 +216,7 @@ function refineBreakerForScope(scope: string): void { try { await acquire() try { - // Run the probe on the tripping scope's runtime/host so it describes - // that account's buckets, not the native github.com ones. + // Query the tripping runtime/host, independently of the shared native snapshot. const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8', host: parts.host, @@ -248,9 +224,7 @@ function refineBreakerForScope(scope: string): void { }) const parsed = JSON.parse(stdout) as GhRateLimitPayload scopeProbeFailureAtMs.delete(scope) - // Why: mirrors the default-scope refinement, but records into the - // per-scope breaker only — the shared snapshot must keep describing - // native github.com exclusively. + // Refine only this scope's buckets; never overwrite the native github.com snapshot. for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) { const b = parseBucket(parsed.resources?.[bucket]) if (b.limit > 0 && b.remaining <= 0) { @@ -263,8 +237,7 @@ function refineBreakerForScope(scope: string): void { release() } } catch { - // Why: GHES hosts can be supplied through pasted project URLs. Bound the - // negative cache so repeated failing hosts cannot accumulate forever. + // Pasted hosts are user-controlled; bound retained failures across them. rememberScopeProbeFailure(scope, Date.now()) } finally { scopeRefinementInFlight.delete(scope) @@ -275,7 +248,7 @@ function refineBreakerForScope(scope: string): void { registerGhRateLimitResetProbe((_bucket, scope) => refineBreakerForScope(scope)) -// Why: single-flight so a concurrent fan-out resolves to one probe — the TTL cache can't dedupe calls that start before the first lands. +// Share cold probes; TTL caching cannot dedupe calls before the first result. let probeInFlight: Promise | null = null export async function getRateLimit(options?: { force?: boolean }): Promise { @@ -302,8 +275,7 @@ export async function getRateLimit(options?: { force?: boolean }): Promise { await acquire() try { - // Why: this singleton snapshot guards native github.com traffic. Pin the - // host so a process-level GH_HOST cannot make it describe a GHES account. + // Pin the shared native snapshot against ambient GH_HOST redirection. const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8', host: 'github.com' diff --git a/src/main/github/review-head-remote.test.ts b/src/main/github/review-head-remote.test.ts index 1af2bec6cf1..e2853ada0d7 100644 --- a/src/main/github/review-head-remote.test.ts +++ b/src/main/github/review-head-remote.test.ts @@ -28,8 +28,7 @@ describe('resolveGitHubReviewHeadRemote', () => { }) it('prefers upstream on a contributor clone whose origin is a fork', async () => { - // Why: PR work-item resolution probes upstream first; refs/pull//head for - // an upstream PR does not exist on the fork origin. + // Upstream PR head refs are unavailable on the fork's origin. getGitHubApiRepositoryForRemoteMock.mockImplementation(async (_path, remote) => remote === 'upstream' ? { owner: 'org', repo: 'project' } diff --git a/src/main/github/review-head-remote.ts b/src/main/github/review-head-remote.ts index e5b204812ee..e06fa2948f6 100644 --- a/src/main/github/review-head-remote.ts +++ b/src/main/github/review-head-remote.ts @@ -6,8 +6,7 @@ import { getGitHubApiRepositoryForRemote } from './github-api-repository' type GitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }> -// Why: explicit origin must match issue listing; otherwise hosting identity -// keeps contributor clones on the upstream project's PR namespace. +// Explicit origin matches issue listing; otherwise honor the upstream project's PR namespace. export async function resolveGitHubReviewHeadRemote(args: { repoPath: string issueSourcePreference?: IssueSourcePreference @@ -26,8 +25,7 @@ export async function resolveGitHubReviewHeadRemote(args: { } throw new Error('Repo has no configured origin remote.') } - // Why: identity probes cost a `remote get-url` (plus a possible gh auth - // lookup) each; only multi-remote clones are ambiguous enough to need them. + // Avoid per-remote identity/auth probes unless multiple remotes make selection ambiguous. if (remotes.length > 1) { for (const remote of ['upstream', 'origin']) { if (!remotes.includes(remote)) { @@ -44,8 +42,7 @@ export async function resolveGitHubReviewHeadRemote(args: { } } } - // Why: when no remote maps to a GitHub project the hosting identity cannot - // guide the choice; keep the legacy per-transport fallback. + // Without GitHub identity, retain transport-specific remote selection. if (args.connectionId) { return pickPreferredGitRemote(remotes) } diff --git a/src/main/github/stacked-pr-creation.test.ts b/src/main/github/stacked-pr-creation.test.ts index 307281e51ef..df0a407a279 100644 --- a/src/main/github/stacked-pr-creation.test.ts +++ b/src/main/github/stacked-pr-creation.test.ts @@ -254,8 +254,7 @@ describe('registerGitHubStackedPullRequest', () => { }) it('does not claim registration when the stack no longer holds the parent', async () => { - // A concurrent stack edit can drop the parent while the child sits at index 0. - // Reading index 0 off a findIndex miss would report that pair as registered. + // A missing parent gives -1; reading its successor would falsely identify the first entry as its child. ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) }) diff --git a/src/main/github/stacked-pr-creation.ts b/src/main/github/stacked-pr-creation.ts index 588348913a1..6bf6100cbac 100644 --- a/src/main/github/stacked-pr-creation.ts +++ b/src/main/github/stacked-pr-creation.ts @@ -211,8 +211,7 @@ function registeredStackNumber( const parentPosition = parentStack.pull_requests.findIndex( (pullRequest) => pullRequest.number === parentReview.number ) - // Why: a miss is -1, and -1 + 1 reads the first entry — which reports "already - // registered" whenever the current PR heads a stack the parent has left. + // A missing parent gives -1; its successor index 0 must not look like an existing pair. if (parentPosition === -1) { return null } diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts index d7a0671470e..10568ec0bf9 100644 --- a/src/main/github/work-item-details-concurrency.test.ts +++ b/src/main/github/work-item-details-concurrency.test.ts @@ -42,7 +42,6 @@ vi.mock('./github-api-repository', () => ({ getOriginGitHubApiRepository: vi.fn(), githubHostExecOptions: (repository?: { host?: string } | null) => repository?.host ? { host: repository.host } : {}, - // Why: getWorkItemDetails awaits resolveGitHubRepoExecution and reads .ownerRepo. resolveGitHubRepoExecution: vi.fn( async ( _repoPath: string, diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts index b615f4e566b..93b1b9f691c 100644 --- a/src/main/github/work-item-details-enterprise-host.test.ts +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -73,8 +73,6 @@ import { getWorkItemDetails } from './work-item-details' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) @@ -290,9 +288,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { }) it('surfaces a found SSH PR without any default-host gh call when the repository is unresolved', async () => { - // Why: this is the regression under test — a *found* PR whose owner/repo - // cannot be resolved to an Enterprise host must not silently fall back to - // github.com. With no prRepo, origin resolution runs and returns null. + // A found PR without a resolved Enterprise host must not fall back to github.com. getWorkItemMock.mockResolvedValueOnce({ id: 'pr:7', type: 'pr', @@ -308,8 +304,6 @@ describe('getWorkItemDetails Enterprise host routing', () => { const details = await getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1') - // The item is still surfaced, but no host-qualified (or default-host) gh - // request is made because the execution repository never resolved. expect(details?.item.id).toBe('pr:7') expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).not.toHaveBeenCalled() diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts index 7cbfa041608..dc0774e359a 100644 --- a/src/main/github/work-item-details-file-viewed.test.ts +++ b/src/main/github/work-item-details-file-viewed.test.ts @@ -81,8 +81,6 @@ import { getWorkItemDetails } from './work-item-details' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index c7af8abac85..91010374ab8 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -79,8 +79,6 @@ import { getPRFileContents, getWorkItemDetails } from './work-item-details' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) @@ -187,8 +185,6 @@ describe('getWorkItemDetails PR file listing', () => { ).toHaveLength(3) }) - // Why: a rate-limited/auth-failed file fetch must not render as an empty PR; - // the Files tab keys its retry state off details.filesUnavailable. it('flags filesUnavailable when the file fetch fails', async () => { getWorkItemMock.mockResolvedValueOnce(pullRequestItem(8305, 'Files fetch fails')) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index ef2d3649818..5126549df5e 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -88,8 +88,6 @@ import { getWorkItemDetails } from './work-item-details' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' -// The origin-repository cache is module-level state; reset it so slugs -// resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) @@ -257,7 +255,6 @@ describe('getWorkItemDetails', () => { } ]) expect(details?.participants?.[0]?.login).toBe('issue-author') - // Why: issue identities resolve hosted, pinned to github.com for dotcom. expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( { owner: 'acme', repo: 'widgets', host: 'github.com' }, 'graphql', @@ -317,7 +314,6 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails(repoPath, 923, 'issue') expect(details?.body).toBe('WSL issue body') - // Why: issue identities resolve hosted, pinned to github.com for dotcom. expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith( { ...repository, host: 'github.com' }, 'graphql', @@ -333,9 +329,7 @@ describe('getWorkItemDetails', () => { }) it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => { - // Why: a GHE assignee who never commented is absent from `participants`, so - // the enrichment must also draw avatars from the `assignees` connection or - // item.assignees keeps the blank avatar `gh` returns. + // Assignees can be absent from participants; enrich their avatars from the assignees connection too. getWorkItemMock.mockResolvedValueOnce({ id: 'issue:924', type: 'issue', @@ -471,7 +465,6 @@ describe('getWorkItemDetails', () => { author: 'issue-author' }) getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - // Collapsed GraphQL throws → fallback path picks up. ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL error')) .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) }) @@ -621,7 +614,6 @@ describe('getWorkItemDetails', () => { updatedAt: '2026-04-01T00:00:00Z', author: 'pr-author' }) - // Why: PR detail resolution uses getOwnerRepoForRemote via origin repository lookup. getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) @@ -702,8 +694,7 @@ describe('getWorkItemDetails', () => { ) }) - // Why: details open by number, so it must pin the same source as the list; - // otherwise a fork and its upstream sharing PR #42 render different PRs. + // Number-based detail lookups must retain the list's source, avoiding same-number fork/upstream collisions. it('forwards the explicit origin source preference to the work item lookup', async () => { getWorkItemMock.mockResolvedValueOnce(null) @@ -712,9 +703,7 @@ describe('getWorkItemDetails', () => { expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, {}, 'origin') }) - // Why: `gh pr view` omits avatar_url, so the login-based github.com URL 404s on - // GHE. getWorkItemDetails must resolve author/reviewer/assignee avatars via the - // GraphQL user(login:) batch and stamp them onto the returned item. See #8784. + // gh pr view omits avatar_url; Enterprise identities need GraphQL enrichment instead of dotcom fallbacks. it('enriches PR author, reviewer, and assignee avatars from the GraphQL user lookup', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'pr:1102', @@ -728,7 +717,6 @@ describe('getWorkItemDetails', () => { author: 'seah', reviewRequests: [{ login: 'ludi', name: null, avatarUrl: '' }], latestReviews: [{ login: 'inho', state: 'APPROVED', avatarUrl: '' }], - // A default `u/0` placeholder must be replaced by the resolved avatar. assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }] }) getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) @@ -751,7 +739,6 @@ describe('getWorkItemDetails', () => { } const query = args.find((arg) => arg.startsWith('query=')) ?? '' if (query.includes('user(login:')) { - // Return the aliased users the batch asked for, keyed by their login. const data: Record = {} let index = 0 for (const login of Object.keys(avatars)) { @@ -770,7 +757,6 @@ describe('getWorkItemDetails', () => { expect(details?.item.authorAvatarUrl).toBe(avatars.seah) expect(details?.item.reviewRequests?.[0]?.avatarUrl).toBe(avatars.ludi) expect(details?.item.latestReviews?.[0]?.avatarUrl).toBe(avatars.inho) - // The default u/0 placeholder is overridden by the resolved avatar. expect(details?.item.assignees?.[0]?.avatarUrl).toBe(avatars.seah) }) }) diff --git a/src/main/gitlab/client-mr-branch-lookup.test.ts b/src/main/gitlab/client-mr-branch-lookup.test.ts index b311882da3b..36e64984c96 100644 --- a/src/main/gitlab/client-mr-branch-lookup.test.ts +++ b/src/main/gitlab/client-mr-branch-lookup.test.ts @@ -66,10 +66,8 @@ describe('gitlab client — MR operations', () => { getProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' }) glabExecFileAsyncMock.mockRejectedValue(new Error('glab: connection refused')) - // The swallowing variant collapses a real failure into a false "no MR". await expect(getMergeRequestForBranch('/repo', 'feature/x')).resolves.toBeNull() - // The throwing variant makes the failure visible so eligibility records - // `unavailable` rather than a false "No merge request found". + // Strict lookup preserves unavailable transport as distinct from a confirmed missing review. await expect(getMergeRequestForBranchOrThrow('/repo', 'feature/x')).rejects.toThrow( /connection refused/ ) @@ -166,9 +164,7 @@ describe('gitlab client — MR operations', () => { ) }) - // Why: GitLab does not proactively recompute merge status on list endpoints, so without the - // recheck request the row can sit at `unchecked` forever and the sidebar merge button — which - // gates on MERGEABLE — never becomes available. + // List endpoints require explicit asynchronous recheck before merge status can converge. it('asks GitLab to recheck merge status on the branch lookup', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) @@ -379,7 +375,6 @@ describe('gitlab client — MR operations', () => { const mr = await getMergeRequestForBranch('/repo', 'main') expect(mr?.number).toBe(8) - // Open results never consult git for the default branch (lazy resolution). expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -443,8 +438,6 @@ describe('gitlab client — MR operations', () => { }) it('returns null for an empty / detached-HEAD branch arg', async () => { - // Why: during a rebase the branch is empty — mirror github/getPRForBranch's - // early return without calling glab. await expect(getMergeRequestForBranch('/repo', '')).resolves.toBeNull() expect(glabExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/gitlab/client-mr-job-ci.test.ts b/src/main/gitlab/client-mr-job-ci.test.ts index dcc27aaeee9..bbca5467564 100644 --- a/src/main/gitlab/client-mr-job-ci.test.ts +++ b/src/main/gitlab/client-mr-job-ci.test.ts @@ -76,13 +76,11 @@ describe('gitlab client — MR operations', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledWith( ['api', '--hostname', 'git.internal', 'projects/g%2Fp/jobs/99/trace'], - // Why: a trace outlives the runner's 30s default, so the fetch raises it. { timeout: 60_000 } ) }) - // #7732: a job canceled before it started 404s; the Checks panel must show its - // benign empty state, not the issue-edit copy classifyGlabError would produce. + // Canceled-before-start jobs have no trace; show an empty log rather than issue-edit error copy. it('reports a 404 trace as an empty log rather than an error', async () => { glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404 Not Found')) diff --git a/src/main/gitlab/client-mr-listing.test.ts b/src/main/gitlab/client-mr-listing.test.ts index 63eaa0ca512..426a7ebae9c 100644 --- a/src/main/gitlab/client-mr-listing.test.ts +++ b/src/main/gitlab/client-mr-listing.test.ts @@ -256,7 +256,6 @@ describe('gitlab client — MR operations', () => { await listMergeRequests('/repo', 'opened', 1, 20, undefined, 'fix login') expect(glabApiWithHeadersMock).not.toHaveBeenCalled() const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] - // Why (#6263): the cwd-inferred fallback must honor the typed query too. const searchIdx = callArgs.indexOf('--search') expect(searchIdx).toBeGreaterThanOrEqual(0) expect(callArgs[searchIdx + 1]).toBe('fix login') @@ -312,8 +311,7 @@ describe('gitlab client — MR operations', () => { expect(result.items).toEqual([]) }) - // Why: the title carries a classifier keyword, so this also pins that a wrapped payload stays - // out of the substring matcher — classifying it would swap the body for "check your connection". + // A classifier keyword in a content title must not replace the opaque response diagnostic. it('reports the body instead of ".map is not a function" when the API returns a non-array', async () => { glabApiWithHeadersMock.mockResolvedValueOnce({ body: JSON.stringify({ data: [{ iid: 7, title: 'fix network timeout' }] }), @@ -337,8 +335,6 @@ describe('gitlab client — MR operations', () => { expect(result.error?.message).not.toContain('is not a function') }) - // Why: the whole point of surfacing the body — a GitLab error envelope now - // classifies like any other glab failure instead of collapsing to 'unknown'. it('classifies a GitLab error envelope returned on exit 0', async () => { glabApiWithHeadersMock.mockResolvedValueOnce({ body: JSON.stringify({ message: '403 Forbidden' }), @@ -349,8 +345,7 @@ describe('gitlab client — MR operations', () => { expect(result.error?.type).toBe('permission_denied') }) - // Why: the sibling title matches an earlier classifier branch than the envelope does, so this - // fails if the payload leaks into classification instead of only the envelope's own message. + // Only the envelope message is diagnostic; sibling payload keywords must not change classification. it('classifies an error envelope by its message, not its sibling payload', async () => { glabApiWithHeadersMock.mockResolvedValueOnce({ body: JSON.stringify({ diff --git a/src/main/gitlab/client-mr-review-actions.test.ts b/src/main/gitlab/client-mr-review-actions.test.ts index 37c5b49fa5f..29b4210aba2 100644 --- a/src/main/gitlab/client-mr-review-actions.test.ts +++ b/src/main/gitlab/client-mr-review-actions.test.ts @@ -451,8 +451,7 @@ describe('gitlab client — MR operations', () => { ) }) }) - // Why: only `api`/`auth` define --hostname, so the `mr` subcommands must take - // the self-hosted host through GITLAB_HOST or glab exits on an unknown flag. + // mr subcommands reject --hostname; route self-hosted instances through GITLAB_HOST. describe('mr subcommands against a self-hosted SSH host', () => { const projectRef = { host: 'gitlab.example.internal', path: 'g/p' } diff --git a/src/main/gitlab/client-mr-test-harness.ts b/src/main/gitlab/client-mr-test-harness.ts index bcf40affb09..d8ba8e4e0a0 100644 --- a/src/main/gitlab/client-mr-test-harness.ts +++ b/src/main/gitlab/client-mr-test-harness.ts @@ -3,7 +3,6 @@ import { _resetGitLabRateLimitCache } from './client' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' import { _resetKnownHostsCache } from './gitlab-known-host-probe' -/** The hoisted gl-utils / git-runner mocks every GitLab MR test file installs. */ export type GitLabMrMocks = { glabExecFileAsyncMock: Mock glabApiWithHeadersMock: Mock @@ -15,7 +14,6 @@ export type GitLabMrMocks = { gitExecFileAsyncMock: Mock } -/** Answer the real default-branch resolver probes (#9171 guard). */ export function primeGitDefaultBranch( gitExecFileAsyncMock: Mock, defaultRef = 'refs/remotes/origin/main' @@ -28,7 +26,6 @@ export function primeGitDefaultBranch( }) } -/** Shared per-test reset: mocks, module caches, and the default host/source stubs. */ export function resetGitLabMrMocks(mocks: GitLabMrMocks): void { mocks.glabExecFileAsyncMock.mockReset() mocks.glabApiWithHeadersMock.mockReset() diff --git a/src/main/gitlab/client-work-items.test.ts b/src/main/gitlab/client-work-items.test.ts index 5cc48966a3d..ff7f59203ea 100644 --- a/src/main/gitlab/client-work-items.test.ts +++ b/src/main/gitlab/client-work-items.test.ts @@ -91,9 +91,6 @@ describe('gitlab client — combined listWorkItems', () => { glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) await listWorkItems('/repo', 'merged', 1, 20) - // Why: the merged-state filter doesn't apply to issues (issues - // don't have a merged lifecycle), so the IPC must not even spawn - // the issues read. Verifies the listIssues path was not taken. expect(glabExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/gitlab/client.test.ts b/src/main/gitlab/client.test.ts index 1d932d5c5fa..8e661d6e73a 100644 --- a/src/main/gitlab/client.test.ts +++ b/src/main/gitlab/client.test.ts @@ -247,10 +247,7 @@ describe('gitlab client — viewer & paste-URL lookup', () => { }) it('coerces non-pending state values to pending (defensive)', async () => { - // Why: we filter to state=pending in the request, but if a future - // glab change leaks a different state through, the type's narrow - // 'pending' | 'done' union should still hold — anything not 'done' - // collapses to 'pending' rather than violating the type. + // Preserve the pending/done union even if future API output ignores the pending filter. glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { id: 2, action_name: 'mentioned', target_type: 'Issue', state: 'weird' } @@ -266,9 +263,7 @@ describe('gitlab client — viewer & paste-URL lookup', () => { }) it('handles missing target / project / author fields gracefully', async () => { - // Why: GitLab Todos for Commit / Note targets sometimes omit - // `target` entirely — defaults must keep the record well-formed - // so the renderer doesn't choke on .title access. + // Commit/Note todos can omit target; retain a well-formed display record. glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 3, action_name: 'build_failed', target_type: 'Commit' }]) }) diff --git a/src/main/gitlab/gitlab-auth-and-rate-limit.ts b/src/main/gitlab/gitlab-auth-and-rate-limit.ts index 58947d4b307..d5ff57ee3e4 100644 --- a/src/main/gitlab/gitlab-auth-and-rate-limit.ts +++ b/src/main/gitlab/gitlab-auth-and-rate-limit.ts @@ -43,13 +43,13 @@ export async function diagnoseAuth(): Promise { ? 'GLAB_TOKEN' : null try { - // Why: a host-global diagnostic must not wake an unrelated default WSL distro. + // A host-global auth diagnostic must not wake the default WSL distro. const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { allowDefaultWslFallback: false }) const output = `${stdout}\n${stderr}` const hosts = parseGlabAuthStatusHosts(output) - // Why: refreshing auth must advance the provider cache key past a stale null result. + // Advance provider cache identity after auth refresh so stale negative results cannot persist. rememberGlabKnownHosts(hosts) return { glabAvailable: true, @@ -150,7 +150,7 @@ function rememberGitLabRateLimitSnapshot( snapshot: GitLabRateLimitSnapshot ): void { pruneGitLabRateLimitCache() - // Why: self-managed hostnames come from repo config; keep this cache bounded across many transient hosts. + // Self-hosted names are user-controlled; bound retention across transient hosts. gitLabRateLimitCache.delete(cacheKey) gitLabRateLimitCache.set(cacheKey, snapshot) pruneGitLabRateLimitCache() @@ -170,7 +170,7 @@ export async function getRateLimit(options?: { await acquire() try { - // Why: GitLab exposes REST budget headers inconsistently; a null bucket means this host omitted them. + // Hosts can omit REST budget headers; absent buckets remain unknown. const args = host ? ['--hostname', host, 'user'] : ['user'] const { headers } = await glabApiWithHeaders(args) const snapshot = parseGitLabRateLimitSnapshot(headers, host) @@ -183,5 +183,3 @@ export async function getRateLimit(options?: { release() } } - -/** Resolve a project's full GitLab project ref (host + path); null for non-GitLab remotes. */ diff --git a/src/main/gitlab/gitlab-issue-source-resolution.ts b/src/main/gitlab/gitlab-issue-source-resolution.ts index baa6a0e4e8d..e4ec90fa6a6 100644 --- a/src/main/gitlab/gitlab-issue-source-resolution.ts +++ b/src/main/gitlab/gitlab-issue-source-resolution.ts @@ -37,7 +37,7 @@ export async function getIssueProjectRef( ) } -// Why: glab infers a project from any GitLab remote, e.g. one named `gitlab`; the issue source should too. +// Match glab project inference even when remotes use nonstandard names. async function getProjectRefForOtherRemote( repoPath: string, knownHosts?: readonly string[], @@ -46,7 +46,7 @@ async function getProjectRefForOtherRemote( ): Promise { const remotes = await listCachedRemoteNames(repoPath, connectionId, localGitOptions) const otherRemotes = (remotes ?? []).filter((name) => name !== 'origin' && name !== 'upstream') - // Why: glab ranks a remote named `gitlab` above the rest. + // glab prioritizes a remote named gitlab. otherRemotes.sort((a, b) => Number(b === 'gitlab') - Number(a === 'gitlab')) for (const remoteName of otherRemotes) { const projectRef = await getProjectRefForRemote( diff --git a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts index e742281b579..6a89e83f093 100644 --- a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts @@ -63,9 +63,7 @@ describe('glab known-hosts probe on Windows', () => { ) }) - // Why: glab has no SSH/relay dispatch, so a connection-keyed probe and the - // `glab api` calls it gates both run the local CLI with no cwd. Suppressing the - // fallback here would make the probe disagree with those calls. + // Connection-keyed auth and API calls both use local glab, including its default-distro fallback. it('keeps the default-distro fallback for a connection execution key', async () => { hostGlabMissingWslLoggedIn() @@ -74,10 +72,7 @@ describe('glab known-hosts probe on Windows', () => { expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"], - // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit - // Orca's own cwd, a deletable WSL UNC path when it was launched from a - // worktree. This probe has no repo directory at all, so nothing about where - // it runs changes. The native `glab` assertion above keeps `undefined`. + // A concrete safe cwd avoids inheriting a deletable worktree's UNC directory during Windows spawn. expect.objectContaining({ cwd: expect.any(String) }) ) }) diff --git a/src/main/gitlab/gitlab-known-host-probe.test.ts b/src/main/gitlab/gitlab-known-host-probe.test.ts index ec999ab2d03..ef1b18b2bec 100644 --- a/src/main/gitlab/gitlab-known-host-probe.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe.test.ts @@ -227,7 +227,6 @@ describe('getGlabKnownHosts', () => { 'gitlab.com', 'gitlab.example.com:8080' ]) - // A second probe for the same connection is served from cache. await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([ 'gitlab.com', 'gitlab.example.com:8080' @@ -243,9 +242,8 @@ describe('getGlabKnownHosts', () => { stderr: '' }) - // First probe fails → canonical default, NOT cached. + // Failed probes leave defaults uncached so recovery can discover the actual host. await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com']) - // Re-probe (e.g. after tunnel comes up) discovers the real host. await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([ 'gitlab.com', 'gitlab.example.com:8080' diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 0376ed62b2b..5cbc24d6483 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -61,14 +61,7 @@ function unauthenticatedHostKey( return `${knownHostsExecutionKey(connectionId, localGitOptions)}\0${normalizeGitLabHost(host)}` } -/** - * Why: `glab auth status --hostname` is how a self-hosted instance that plain - * `glab auth status` did not list gets discovered, so a remote that is not - * GitLab at all runs it too. Project-ref negatives expire now, and without this - * that becomes one `glab` spawn per repo per interval on the hosted-review poll. - * The answer is per host, not per repo, and expires on the same clock so a - * login still lands within an interval. - */ +/** Share negative host-auth probes across repositories until expiry, avoiding a subprocess per repo on each poll. */ export function isGlabHostKnownUnauthenticated( host: string, connectionId?: string | null, @@ -150,8 +143,7 @@ export async function getGlabKnownHosts( knownHostsCacheByExecutionContext.set(key, cached) return cached } - // Why: only join a probe still young enough to answer, so a wedged one cannot - // pin every later retry for the life of the process (P1-D). + // Retire wedged probes so later retries can discover recovered auth. return runCoalescedProbe(knownHostsInFlightByExecutionContext, key, (ownsKey) => probeGlabKnownHosts(key, ownsKey, connectionId, localGitOptions) ) @@ -164,13 +156,10 @@ async function probeGlabKnownHosts( localGitOptions: LocalGitExecOptions = {} ): Promise { try { - // Why: auth config belongs to the executing host; do not share native, WSL or - // connection results, and bound an otherwise global probe. + // Isolate native, WSL and connection probe state; bound each auth subprocess. const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, - // Why: a local probe would cache the default distro's auth under 'native' - // and wake an idle VM. A connection-keyed probe keeps the retry — glab never - // runs over SSH/relay, so the calls it gates take that same fallback. + // Native probes must not cache default-distro auth; connection-keyed API calls retain that same fallback. ...(connectionId ? {} : { allowDefaultWslFallback: false }), ...(!connectionId && localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } @@ -193,8 +182,7 @@ async function probeGlabKnownHosts( export function parseGlabAuthStatusHosts(output: string): string[] { const hosts = new Set() - // Why: self-hosted GitLab can run on a non-default port; preserve it so - // services on the same hostname remain distinct downstream. + // Preserve non-default web ports so same-host services retain distinct identity. for (const match of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) { hosts.add(match[1].toLowerCase()) } diff --git a/src/main/gitlab/gitlab-preload-args.ts b/src/main/gitlab/gitlab-preload-args.ts index fa2844f6659..7aaca022f60 100644 --- a/src/main/gitlab/gitlab-preload-args.ts +++ b/src/main/gitlab/gitlab-preload-args.ts @@ -21,10 +21,7 @@ export function normalizeGitLabIssueListState(value: unknown): GitLabIssueListSt return value === 'closed' || value === 'all' ? value : 'opened' } -// Why: cap the free-text MR search at the same byte budget the renderer -// enforces (SMART_WORKSPACE_SOURCE_QUERY_MAX_BYTES) so the RPC/SSH path — -// which can be driven by callers other than the desktop input — can't push -// an unbounded string into the glab `&search=` query. +// RPC/SSH callers bypass renderer limits; bound search bytes at this boundary too. const GITLAB_SEARCH_QUERY_MAX_BYTES = 2048 export function normalizeGitLabSearchQuery(value: unknown): string | undefined { @@ -39,8 +36,7 @@ export function normalizeGitLabSearchQuery(value: unknown): string | undefined { } export function normalizeGitLabIssueAssignee(value: unknown): '@me' | undefined { - // Why: the renderer only exposes "Assigned to me"; accepting arbitrary - // values would turn preload/RPC boundaries into a generic glab flag surface. + // Accept only the renderer-supported assignment filter, not arbitrary CLI flags. return value === '@me' ? '@me' : undefined } @@ -59,7 +55,7 @@ export function normalizeGitLabIssueListArgs(args: { state: normalizeGitLabIssueListState(args.state), assignee: normalizeGitLabIssueAssignee(args.assignee), limit: normalizeGitLabPositiveInteger(args.limit, 20, 100), - // Why: GitLab is 1-based; TaskPage maps 0-based UI pages onto this (#13357). + // GitLab pages are 1-based; the renderer translates its 0-based UI index. page: normalizeGitLabPositiveInteger(args.page, 1, 10_000) } } diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 8b35419f1ab..60185905711 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -48,10 +48,7 @@ export function _getProjectRefCacheSize(): number { } function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { - // Why: "not GitLab" only holds until someone configures `origin` or logs into - // `glab` — a repo probed before either kept hosted-review detection stale for - // the life of the process. Negatives expire the way every other forge's do; - // positives still stay (see `createRemoteRefProbeCache`). + // Negative forge identity expires so remote/auth configuration changes can be discovered. projectRefCache.set(cacheKey, { value, expiresAt: value === null ? Date.now() + NEGATIVE_ENTRY_TTL_MS : Number.POSITIVE_INFINITY @@ -72,8 +69,7 @@ export async function getProjectRefForRemote( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: `knownHosts` carries the glab auth state, so logging into a self-hosted - // instance re-asks rather than reusing a ref resolved while that host was unknown. + // Authenticated host inventory participates in cache identity so new logins invalidate old misses. const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` const cached = projectRefCache.get(cacheKey) @@ -106,9 +102,7 @@ async function resolveProjectRefForRemote( ownsKey: () => boolean, localGitOptions: LocalGitExecOptions ): Promise { - // Why: a probe abandoned as stale still runs, and the repo state it read is - // older than whatever its successor already published. It may answer its own - // callers; it may not overwrite the cache. + // Abandoned probes may answer their callers but must not overwrite newer cached identity. const publish = (value: ProjectRef | null): void => { if (ownsKey()) { rememberProjectRefCacheEntry(cacheKey, value) @@ -147,11 +141,7 @@ async function resolveProjectRefForRemote( return remoteCandidate } } catch (error) { - // Why: a wedged or killed probe is not evidence the remote is not GitLab — - // caching it would misdetect the forge until the negative expires (P1-D). - // SSH failures stay uncached outright rather than adopting the generic - // cache's stable-missing-remote exception: keeping a connected host's - // detection fresh is worth the extra probe. + // Failed SSH and transient probes do not establish a stable non-GitLab remote. if (connectionId || isTransientGitProbeError(error)) { return null } @@ -190,7 +180,7 @@ export function glabHostnameArgs( return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : [] } -/** Why: only `api`/`auth` define `--hostname`; `mr` and `issue` subcommands reject it (#12193). */ +/** mr/issue commands reject --hostname; use GITLAB_HOST instead. */ export function glabHostEnvOptions( projectRef: Pick | null | undefined, connectionId?: string | null @@ -200,7 +190,7 @@ export function glabHostEnvOptions( } const env: NodeJS.ProcessEnv = { ...process.env, GITLAB_HOST: projectRef.host } if (process.platform === 'win32') { - // Why: spawn env stops at the wsl.exe boundary unless WSLENV names the var. + // Environment values cross wsl.exe only when named in WSLENV. addWslEnvKeys(env, ['GITLAB_HOST']) } return { env } @@ -212,9 +202,7 @@ async function isGlabConfiguredForRemoteHost( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: this probe is per host, but the project-ref miss that reaches it is per - // repo — without the memo, every non-GitLab repo re-spawns `glab` each time - // its negative expires. + // Share negative auth results per host across all repository identity misses. if (isGlabHostKnownUnauthenticated(projectRef.host, connectionId, localGitOptions)) { return false } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 88f32dadb68..64d3572c128 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -268,8 +268,7 @@ describe('gitlab project ref resolution', () => { await expect(getProjectRef('/repo')).resolves.toBeNull() expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) - // Nothing watches `.git/config`, and SSH/WSL repos have no file to watch, so - // a remote configured after the miss is only visible once the negative ages out. + // Negative expiry reveals newly configured remotes without requiring local config watchers. gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:fork/orca.git\n' }) vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) @@ -314,8 +313,7 @@ describe('gitlab project ref resolution', () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@github.com:team/orca.git\n' }) glabExecFileAsyncMock.mockRejectedValue(new Error('not authenticated')) - // Expiring project-ref negatives must not turn the hosted-review poll into a - // `glab auth status` spawn per repo per interval — the answer is per host. + // Per-repo identity expiry must still share one negative auth probe per host. for (const repoPath of ['/repo-a', '/repo-b', '/repo-c']) { await expect(getProjectRef(repoPath)).resolves.toBeNull() } @@ -332,7 +330,6 @@ describe('gitlab project ref resolution', () => { describe('GitLab operation admission', () => { afterEach(() => { vi.useRealTimers() - // Drain any slots held by the saturation test before the next test. for (let i = 0; i < 4; i += 1) { release() } @@ -548,7 +545,7 @@ describe('parseGlabJsonList', () => { expect(() => parseGlabJsonList(payload)).toThrow(payload) }) - // Why: glab allows a 10MB body, and the renderer's error banner has no length guard of its own. + // Bound opaque bodies before a proxy's multi-megabyte response reaches the error banner. it.each([ ['an opaque body', `{"data":"${'x'.repeat(50_000)}"}`], ['an error envelope', `{"message":"${'x'.repeat(50_000)}"}`] @@ -562,14 +559,12 @@ describe('parseGlabJsonList', () => { ['message', '{"message":"403 Forbidden"}', '403 Forbidden'], ['error', '{"error":"insufficient_scope"}', 'insufficient_scope'], ['error when message is blank', '{"message":" ","error":"real_error"}', 'real_error'], - // Why: GitLab sends both on some endpoints; `message` is the human-facing one. [ 'message when both are set', '{"message":"404 Project Not Found","error":"insufficient_scope"}', '404 Project Not Found' ] ])('reports a GitLab error envelope by its %s field', (_label, payload, reported) => { - // Why: an envelope is GitLab's own diagnostic, so it stays classifiable — unlike a raw body. expect(() => parseGlabJsonList(payload)).toThrow(`GitLab returned an error: ${reported}`) expect(() => parseGlabJsonList(payload)).not.toThrow(GlabNonListResponseError) }) @@ -577,7 +572,7 @@ describe('parseGlabJsonList', () => { describe('classifyListFetchError', () => { it('keeps opaque payload text away from the classifier', () => { - // Why: the title would otherwise substring-match as a network failure and replace the payload. + // Content keywords must not turn opaque payloads into classified connectivity errors. const payload = '{"data":[{"title":"fix network timeout"}]}' let thrown: unknown try { @@ -644,8 +639,7 @@ describe('parseGlabPaginationHeader', () => { expect(parseGlabPaginationHeader('abc', 0)).toBeUndefined() }) - // Why: the minimum is what lets issues.ts tell "x-total: 0" (derive one page) apart from an - // absent header (probe for a next page), and what makes x-total-pages: 0 fall through. + // Distinguish total 0 from missing totals while rejecting total-pages 0. it('rejects values below the minimum', () => { expect(parseGlabPaginationHeader('0', 1)).toBeUndefined() expect(parseGlabPaginationHeader('0', 0)).toBe(0) @@ -677,8 +671,7 @@ describe('glabHostEnvOptions', () => { expect(glabHostEnvOptions({ host: '' }, 'conn-1')).toEqual({}) }) - // Why: spawn env stops at the wsl.exe boundary, so Windows has to name the - // variable in WSLENV or glab in the distro never sees the host. + // GITLAB_HOST must cross wsl.exe through WSLENV. it('forwards GITLAB_HOST through WSLENV on Windows', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') diff --git a/src/main/gitlab/glab-api-response.ts b/src/main/gitlab/glab-api-response.ts index 03e1bb9a14e..390732710b3 100644 --- a/src/main/gitlab/glab-api-response.ts +++ b/src/main/gitlab/glab-api-response.ts @@ -5,8 +5,7 @@ export type GlabApiResponse = { /** @internal - exported for tests through gl-utils. */ export function parseGlabApiResponse(stdout: string): GlabApiResponse { - // Why: response is HTTP status, headers, blank line, then body. - // Find the first blank line (CRLF or LF) as the boundary. + // HTTP headers end at the first blank line, accepting either CRLF or LF. const separator = findHeaderBodySeparator(stdout) if (!separator) { return { body: stdout, headers: {} } @@ -14,7 +13,6 @@ export function parseGlabApiResponse(stdout: string): GlabApiResponse { const headerBlock = stdout.slice(0, separator.index) const body = stdout.slice(separator.bodyStart) const headers: Record = {} - // Skip the status line and parse the rest as key: value. const lines = headerBlock.split(/\r?\n/) for (const line of lines) { const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/) @@ -40,15 +38,10 @@ export function parseGlabPaginationHeader( /** A non-list body carrying no GitLab error text — opaque data, so there is nothing to classify. */ export class GlabNonListResponseError extends Error {} -// Why: glab allows a 10MB body; this is what keeps a proxy's whole response out of the error banner. +// Bound diagnostics before multi-megabyte proxy bodies reach the error banner. const REPORTED_PAYLOAD_LIMIT = 300 -/** - * Parse a glab list response, failing readably when GitLab answers with a JSON object. - * - * Why: glab exits 0 on error envelopes and proxy wrappers, so `JSON.parse(...).map` threw an - * opaque `.map is not a function` that the caller's classifier could only report as "unknown". - */ +/** glab can exit 0 with an error object or proxy wrapper; distinguish it from a valid list before mapping. */ export function parseGlabJsonList(payload: string): T[] { const parsed: unknown = JSON.parse(payload) if (Array.isArray(parsed)) { @@ -58,8 +51,7 @@ export function parseGlabJsonList(payload: string): T[] { if (reported) { throw new Error(`GitLab returned an error: ${reported}`) } - // Why: slice the raw payload rather than re-serializing `parsed` — same text, without - // stringifying a multi-megabyte body just to keep the preview. + // Slice original bytes without serializing a multi-megabyte parsed object for its preview. throw new GlabNonListResponseError( `GitLab returned a non-list response: ${payload.trim().slice(0, REPORTED_PAYLOAD_LIMIT)}` ) diff --git a/src/main/gitlab/glab-error-classification.ts b/src/main/gitlab/glab-error-classification.ts index 1c8eb6212b8..1d75ad388e1 100644 --- a/src/main/gitlab/glab-error-classification.ts +++ b/src/main/gitlab/glab-error-classification.ts @@ -1,8 +1,6 @@ import type { ClassifiedError } from '../../shared/classified-error' import { GlabNonListResponseError } from './glab-api-response' -// Why: glab CLI surfaces API errors as unstructured stderr. Map known -// patterns to typed errors so callers can show user-friendly messages. export function classifyGlabError(stderr: string): ClassifiedError { const s = stderr.toLowerCase() if (s.includes('http 403') || s.includes('forbidden') || s.includes('insufficient_scope')) { @@ -36,8 +34,7 @@ export function classifyGlabError(stderr: string): ClassifiedError { const LIST_READ_FAILURE = 'Failed to load issues' -// Why: classifyGlabError's copy is phrased for edit/update operations; list -// issues is a read op, so rewrite messages for read-context banners. +// Reuse classification with read-specific copy for listing banners. export function classifyListIssuesError(stderr: string): ClassifiedError { const c = classifyGlabError(stderr) const trimmed = stderr.trim() @@ -54,8 +51,7 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { return { type: c.type, message: readMessages[c.type] } } -// Why: an opaque response body is content, not a diagnostic — substring-matching it would render -// an MR titled "fix network timeout" as "check your connection" and discard the body. +// Opaque content is not diagnostic; a title mentioning network timeout must not replace its body. export function classifyListFetchError(err: unknown): ClassifiedError { if (err instanceof GlabNonListResponseError) { return { type: 'unknown', message: `${LIST_READ_FAILURE}: ${err.message}` } @@ -63,8 +59,7 @@ export function classifyListFetchError(err: unknown): ClassifiedError { return classifyListIssuesError(err instanceof Error ? err.message : String(err)) } -// Why: a job trace is a read on a pipeline job, so classifyGlabError's issue-edit -// copy ("permission to edit this issue") would land verbatim on a Checks row. +// Job-trace errors need Checks copy rather than issue-edit wording. export function classifyJobLogError(stderr: string): ClassifiedError { const c = classifyGlabError(stderr) const trimmed = stderr.trim() @@ -83,17 +78,7 @@ export function classifyJobLogError(stderr: string): ClassifiedError { return { type: c.type, message: logMessages[c.type] } } -/** - * Whether a failed trace fetch means "this job has no log", not "the fetch broke". - * - * GitLab 404s the trace endpoint for a job canceled before it started and for a log - * that was erased or expired. A missing *project* is a real failure, and GitLab also - * masks unauthorized projects as 404, so keep that one an error. - * - * Deliberately broad: GitLab returns the same bare 404 for an unknown job id, so a - * stale/foreign id reads as an empty log rather than an error. Losing that diagnostic - * beats pinning "not found" on every job that was canceled before it produced a log. - */ +/** Missing/erased traces return empty logs, including ambiguous job 404s; explicit project failures remain errors. */ export function isMissingJobLogError(stderr: string): boolean { return classifyGlabError(stderr).type === 'not_found' && !/project\s+not\s+found/i.test(stderr) } diff --git a/src/main/gitlab/issue-update.ts b/src/main/gitlab/issue-update.ts index 4e98dfd8260..4de18cd7efa 100644 --- a/src/main/gitlab/issue-update.ts +++ b/src/main/gitlab/issue-update.ts @@ -15,13 +15,7 @@ import { } from './gl-utils' import { encodedProject } from './project-path-encoding' -/** - * Update an existing GitLab issue. - * - * Why: callers that list through a per-repo issue source preference must - * mutate the same GitLab project, or identical IIDs on origin/upstream can - * silently edit the wrong issue. - */ +/** Mutate the preference-selected project so equal origin/upstream issue numbers cannot retarget edits. */ export async function updateIssue( repoPath: string, issueNumber: number, @@ -52,7 +46,6 @@ export async function updateIssue( const repoFlag = projectRef.path const errors: string[] = [] - // State change requires a separate command (parallel to github's split). if (updates.state) { await acquire() try { @@ -62,7 +55,7 @@ export async function updateIssue( ...glabHostEnvOptions(projectRef, connectionId) }) } catch (err) { - // Why: glab exits 0 for an issue already in the target state, so every failure is real. + // glab already-state transitions exit 0; any rejected command remains a real failure. const stderr = err instanceof Error ? err.message : String(err) errors.push(classifyGlabError(stderr).message) } finally { @@ -93,7 +86,6 @@ export async function updateIssue( } } - // Field edits via `glab issue update`. const editArgs: string[] = ['issue', 'update', String(issueNumber), '-R', repoFlag] let hasEditArgs = false @@ -109,8 +101,7 @@ export async function updateIssue( editArgs.push('--unlabel', label) hasEditArgs = true } - // Why: an unprefixed --assignee replaces every assignee and --unassign clears them all; glab - // reads a `+`/`-` prefix as add/remove (since at least v1.22). + // Bare --assignee replaces everyone; + and - prefixes perform incremental edits (glab >=1.22). for (const assignee of updates.addAssignees ?? []) { editArgs.push(`--assignee=+${assignee}`) hasEditArgs = true diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index 1d4e77a6947..ad9b4bdbaaa 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -38,7 +38,6 @@ import { addIssueComment, createIssue, getIssue, listIssues } from './issues' import { updateIssue } from './issue-update' import { listAssignableUsers, listLabels } from './project-label-and-member-lookup' -/** Raw `glab api -i` stdout: status line, CRLF header lines, blank line, then the JSON body. */ function glabIncludeOutput(headers: Record, body: unknown): string { const headerLines = Object.entries(headers) .map(([name, value]) => `${name}: ${value}\r\n`) @@ -46,7 +45,6 @@ function glabIncludeOutput(headers: Record, body: unknown): stri return `HTTP/2.0 200 OK\r\nContent-Type: application/json\r\n${headerLines}\r\n${JSON.stringify(body)}\n` } -/** Answers each `glab api -i` call by endpoint through the real header/body parser. */ function serveGlabApiPages(stdoutByEndpoint: Map): void { glabApiWithHeadersMock.mockImplementation(async (args: string[]) => { const stdout = stdoutByEndpoint.get(args.at(-1) ?? '') @@ -57,7 +55,7 @@ function serveGlabApiPages(stdoutByEndpoint: Map): void { }) } -/** Mirrors glab's cmdutils.ParseAssignees: `+` adds, `-` or `!` removes, anything else replaces all. */ +/** Model glab assignment parsing: + adds, -/! removes, unprefixed names replace everyone. */ function glabAssigneeEdits(args: string[]): { add: string[]; remove: string[]; replace: string[] } { const add: string[] = [] const remove: string[] = [] @@ -316,10 +314,7 @@ describe('gitlab issue operations', () => { }) it('returns an isolated not_found error (never a cwd-inferred glab call) when the project is unresolved', async () => { - // Why: a cwd-inferred `glab issue list` would hit `git: exit status 128` - // on an SSH connection and, in an "All projects" aggregate, sink the - // whole panel. The unresolvable project must isolate to a structured - // error instead, and must not spawn any glab subprocess. + // Unresolved SSH projects must return an isolated error without attempting local cwd inference. getIssueProjectRefMock.mockResolvedValueOnce(null) const result = await listIssues('/repo-root', 5, undefined, 'opened', '@me') @@ -394,8 +389,7 @@ describe('gitlab issue operations', () => { ) }) - // Why: `glab issue update` has no --hostname flag either, so field edits on an - // SSH workspace must carry the host in GITLAB_HOST (#12193). + // issue update rejects --hostname; pass the instance through GITLAB_HOST. it('updateIssue edits fields through GITLAB_HOST on an SSH workspace', async () => { const projectRef = { host: 'gitlab.example.internal', path: 'stablyai/orca' } glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) @@ -410,8 +404,6 @@ describe('gitlab issue operations', () => { expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') }) - // Why: `glab issue close` has no --hostname flag; a self-hosted instance is - // only reachable through GITLAB_HOST (#12193). it('updateIssue closes through GITLAB_HOST on an SSH workspace', async () => { const projectRef = { host: 'gitlab.example.internal', path: 'stablyai/orca' } glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) @@ -425,7 +417,7 @@ describe('gitlab issue operations', () => { expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') }) - // Why: `glab issue close` exits 0 for a closed issue, so a failure mentioning "already" is real. + // glab already-closed success exits 0; an already substring must not hide a real rejection. it('updateIssue reports a failed close even when its text says "already"', async () => { getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) glabExecFileAsyncMock.mockRejectedValueOnce(new Error('Issue is already closed')) @@ -484,7 +476,6 @@ describe('gitlab issue operations', () => { remove: ['bob'], replace: [] }) - // Why: glab has no --unassignee, and its --unassign clears every assignee. expect(args.filter((arg: string) => arg.startsWith('--unassign'))).toEqual([]) }) @@ -659,9 +650,7 @@ describe('gitlab issue operations', () => { it('returns null from getIssue when project ref cannot be resolved', async () => { getIssueProjectRefMock.mockResolvedValueOnce(null) - // Why: when there's no GitLab project ref the fallback path - // (`glab issue view` from cwd) runs — simulate a glab failure to ensure - // we surface null cleanly. + // An unresolved project returns null without invoking cwd-dependent glab. glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not a glab repo')) await expect(getIssue('/repo-root', 1)).resolves.toBeNull() diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index 41d9228bcae..5d6f274793c 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -6,8 +6,7 @@ import { mapGitLabIssueInfo } from './mappers' import { glabApiWithHeaders, glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, parseGlabPaginationHeader, type LocalGitExecOptions, type ProjectRef } from './gl-utils' import { encodedProject } from './project-path-encoding' -// Why: parallel to GitHub's IssueListResult — distinguishes a successful- -// empty listing from a failed fetch. +// Distinguish a confirmed empty list from fetch failure. export type IssueListResult = { items: GitLabIssueInfo[] /** 0 when the listing failed — the caller keeps its current pager instead of collapsing it. */ @@ -15,15 +14,7 @@ export type IssueListResult = { error?: ClassifiedError } -/** - * Get a single issue by number. - * - * Why this path doesn't take a preference — mirrors the GitHub issues.ts - * commentary: linked-issue lookups persist a number to a worktree at - * creation time. Routing detail lookups through the live per-repo - * preference would silently flip an existing link to a different project - * after the user toggled the selector. - */ +/** Keep heuristic source resolution for durable issue-number links when live list preferences change. */ export async function getIssue( repoPath: string, issueNumber: number, @@ -32,11 +23,7 @@ export async function getIssue( ): Promise { const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions) - // Why: don't fall back to a cwd-inferred `glab issue view` when the project - // can't be resolved — on an SSH connection cwd is not the repo dir, so glab - // hits a non-repo dir and fails with `git: exit status 128`. Return null - // (the caller already treats a missing project as "no issue") instead of - // spawning a doomed cwd-dependent call. + // Unresolved SSH projects have no usable local cwd; do not attempt glab repository inference. if (!projectRef) { return null } @@ -59,14 +46,7 @@ export async function getIssue( } } -/** - * List issues for a project. - * - * Mirrors github/listIssues — returns a structured IssueListResult so - * permission errors surface in the UI instead of collapsing to "No issues". - */ -// Why: GitLab issues only have 'opened' / 'closed' lifecycle states. -// 'all' maps to no state param so the API returns both. +// GitLab issues have no merged state; all omits the API state parameter. export type IssueListState = 'opened' | 'closed' | 'all' export async function listIssues( @@ -89,13 +69,7 @@ export async function listIssues( connectionId, localGitOptions ) - // Why: when the project can't be resolved we must NOT fall back to an - // unscoped `glab issue list` that infers the project from cwd. For a repo - // on an SSH connection there is no local cwd matching the repo, so glab - // runs git resolution in a non-repo dir and fails with `git: exit status - // 128`. In an "All projects" aggregate one such failure must not sink the - // whole panel — return a structured, isolated result so the resolvable - // projects still load. + // Isolate unresolved projects instead of letting cwd inference fail the cross-project aggregate. if (!projectRef) { return { items: [], @@ -119,12 +93,8 @@ export async function listIssues( ) const data = parseGlabJsonList>(body) const headerTotalCount = parseGlabPaginationHeader(headers['x-total'], 0) - // Why: a proxy can strip both headers, so a full page advertises one more to probe (#13357); - // TaskPage retreats if that probe comes back empty. + // Missing pagination headers may be proxy-stripped; a full page advertises one more probe. const probedTotalPages = data.length < perPage ? currentPage : currentPage + 1 - // Why: GitLab's project issues endpoint returns true issues only - // (MRs are a separate endpoint), so no equivalent of GitHub's - // pull_request filter is needed here. return { items: data.map((d) => mapGitLabIssueInfo(d as Parameters[0])), totalPages: @@ -144,10 +114,7 @@ export async function listIssues( } } -/** - * Create a new GitLab issue. Uses `glab api` with explicit project path so - * the call doesn't depend on cwd matching the project the user picked. - */ +/** Explicit project paths retain the selected source independently of cwd remotes. */ export async function createIssue( repoPath: string, title: string, @@ -186,7 +153,6 @@ export async function createIssue( '-f', `title=${trimmedTitle}`, '-f', - // Why: GitLab uses `description` (not `body`) for issue text. `description=${body}` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) @@ -208,10 +174,6 @@ export async function createIssue( } } -/** - * Add a comment (note) to an existing GitLab issue. Mirrors - * github/addIssueComment. - */ export async function addIssueComment( repoPath: string, issueNumber: number, diff --git a/src/main/gitlab/mappers-workitem.test.ts b/src/main/gitlab/mappers-workitem.test.ts index 0bbcfec3bca..c70aa4d74ae 100644 --- a/src/main/gitlab/mappers-workitem.test.ts +++ b/src/main/gitlab/mappers-workitem.test.ts @@ -75,10 +75,7 @@ describe('mapMRToWorkItem', () => { }) it('falls back to a deterministic id when GitLab omits global id', () => { - // Why: the GitLab list endpoint always returns id, but the per-MR - // detail endpoint occasionally omits it on older instances. The - // fallback keeps unique-per-(repo,iid) without colliding with other - // MRs in the picker. + // Older detail payloads can omit id; the repository/IID fallback must still distinguish records. const item = mapMRToWorkItem({ iid: 5, title: 't', state: 'opened' }, 'g/p') expect(item.id).toBe('gitlab-mr-g/p-5') }) @@ -116,8 +113,6 @@ describe('mapIssueToWorkItem', () => { it("collapses any non-'opened' state to 'closed'", () => { expect(mapIssueToWorkItem({ iid: 1, title: 't', state: 'closed' }, 'g/p').state).toBe('closed') - // Defensive: a future state we don't recognize must not leak - // through as a 'merged' or 'draft' value. expect(mapIssueToWorkItem({ iid: 1, title: 't', state: 'weird' }, 'g/p').state).toBe('closed') }) }) diff --git a/src/main/gitlab/mappers.test.ts b/src/main/gitlab/mappers.test.ts index ac0f2fdd468..8626b085181 100644 --- a/src/main/gitlab/mappers.test.ts +++ b/src/main/gitlab/mappers.test.ts @@ -183,8 +183,7 @@ describe('mapMRInfo', () => { expect(info.mergeStateStatus).toBe('checking') }) - // Why: GitLab < 15.6 has no detailed_merge_status. Without the legacy fallback these MRs stay - // UNKNOWN, and the merge UI (which gates on MERGEABLE) shows "Checking" with no merge button. + // GitLab <15.6 lacks detailed_merge_status; legacy positive mergeability keeps actions usable. it('falls back to legacy merge_status when detailed_merge_status is absent', () => { const info = mapMRInfo( { iid: 1, title: 't', state: 'opened', merge_status: 'can_be_merged' }, @@ -238,9 +237,7 @@ describe('mapMRInfo', () => { }) it('omits description / author when absent (distinguishes from list payloads)', () => { - // Why: detail vs list endpoints differ — a `description` of '' on the - // type would be ambiguous with "list payload that stripped the body". - // Prefer absent over default '' so callers can tell them apart. + // Missing description means unloaded list data; empty string means a known blank body. const info = mapMRInfo({ iid: 5, title: 't', state: 'opened' }, 'success') expect('description' in info).toBe(false) expect('author' in info).toBe(false) @@ -248,11 +245,6 @@ describe('mapMRInfo', () => { }) }) -// Why: mapMRToWorkItem / mapIssueToWorkItem tests live in -// mappers-workitem.test.ts so this file stays under the oxlint -// max-lines budget. Same import surface, same describe-per-export -// shape — split is mechanical, not behavioral. - describe('derivePipelineStatus', () => { it('returns neutral for null/undefined/empty', () => { expect(derivePipelineStatus(null)).toBe('neutral') @@ -264,17 +256,12 @@ describe('derivePipelineStatus', () => { expect(derivePipelineStatus('success')).toBe('success') expect(derivePipelineStatus('failed')).toBe('failure') expect(derivePipelineStatus('running')).toBe('pending') - // Why: pipeline-level `manual` means GitLab blocked the pipeline on a human trigger — it is - // outstanding, not broken (red) and not resolved (which would paint the MR card green while - // "Pipelines must succeed" still refuses the merge). + // Pipeline manual means blocked on human action, not failed or complete. expect(derivePipelineStatus('manual')).toBe('pending') expect(derivePipelineStatus({ status: 'manual' })).toBe('pending') }) - // Why: pins the two remaining string/array divergences. The job rollup calls skipped jobs - // passing and canceled jobs failing, but `head_pipeline.status` is the only production entry - // point and GitLab paints both pipeline states grey — flipping either card tone needs product - // sign-off, so these assertions exist so neither flip can land silently. + // Preserve pipeline-level neutral skipped/canceled states independently of job-level rollup tones. it('keeps skipped and canceled pipeline strings neutral (deferred tone changes)', () => { expect(derivePipelineStatus('skipped')).toBe('neutral') expect(derivePipelineStatus({ status: 'skipped' })).toBe('neutral') @@ -318,8 +305,6 @@ describe('derivePipelineStatus', () => { expect(derivePipelineStatus([{}])).toBe('neutral') }) - // Why: matches the shared rollup rule — one unresolved job must not demote a pipeline that has - // a passing job, or the same MR reads green in the Checks tab and grey on the card. it('lets a passing job outweigh an unknown one', () => { expect(derivePipelineStatus([{ status: 'success' }, { status: 'future_status' }])).toBe( 'success' diff --git a/src/main/gitlab/mappers.ts b/src/main/gitlab/mappers.ts index 645392ef2cb..62deccf3da8 100644 --- a/src/main/gitlab/mappers.ts +++ b/src/main/gitlab/mappers.ts @@ -6,20 +6,11 @@ import { } from '../../shared/gitlab-pipeline-checks' import { summarizeProviderChecks } from '../../shared/provider-check-summary' -// ── Pipeline job mapping (GitLab REST `/pipelines/:id/jobs`) ──────── -// Why: GitLab pipeline jobs roughly map to GitHub check-runs, but use a -// single `status` field that combines lifecycle + outcome. We split it -// into PRCheckDetail's status + conclusion shape so the renderer can -// share a row with the GitHub side. +// Split GitLab's combined job lifecycle/outcome into the common check-row shape. export const mapPipelineJobStatusToCheckStatus = mapGitLabPipelineJobStatusToCheckStatus export const mapPipelineJobStatusToConclusion = mapGitLabPipelineJobStatusToConclusion -// ── MR state mapping ──────────────────────────────────────────────── -// Why: glab returns the API state directly. Apply the draft flag (or a -// `Draft:` title prefix, which is GitLab's title-based draft convention) -// so the UI sees a single discriminator. - export function mapMRState(state: string, isDraft?: boolean, title?: string): MRState { const s = state?.toLowerCase() if (s === 'merged') { @@ -31,19 +22,13 @@ export function mapMRState(state: string, isDraft?: boolean, title?: string): MR if (s === 'locked') { return 'locked' } - // Why: GitLab supports drafts via either a boolean field (newer API) or - // a `Draft:` / `WIP:` title prefix (legacy). Either signal counts. + // Accept modern draft flags and legacy Draft:/WIP: title prefixes. if (isDraft || (title && /^(draft|wip):\s*/i.test(title))) { return 'draft' } return 'opened' } -// ── Issue mapping ──────────────────────────────────────────────────── -// glab issue view returns: { iid, title, state, web_url, labels: [{name}] | string[] } -// `state` is already lowercase 'opened' | 'closed' so the mapping is -// mostly a normalization shim. - export function mapGitLabIssueInfo(data: { iid?: number number?: number @@ -56,8 +41,7 @@ export function mapGitLabIssueInfo(data: { description?: string | null author?: { username?: string | null; avatar_url?: string | null } | null }): GitLabIssueInfo { - // Why: glab CLI flips between exposing `iid` and `number` depending on - // command + --output flag combination. Accept both. + // glab commands vary between iid and number field names. const number = data.iid ?? data.number ?? 0 const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)) return { @@ -67,18 +51,13 @@ export function mapGitLabIssueInfo(data: { url: data.web_url ?? data.url ?? '', labels, ...(data.updated_at ? { updatedAt: data.updated_at } : {}), - // Why: same description / author optional plumbing as mapMRInfo — - // list payloads strip these so callers can tell "absent" from "blank". + // Preserve absent list metadata separately from known blank detail fields. ...(typeof data.description === 'string' ? { description: data.description } : {}), ...(data.author?.username ? { author: data.author.username } : {}), ...(data.author?.avatar_url ? { authorAvatarUrl: data.author.avatar_url } : {}) } } -// ── MR info mapping ────────────────────────────────────────────────── -// Why: parallel to mapPRState's role for GitHub. glab returns iid + -// web_url + state + draft + sha + has_conflicts. - type GitLabMRRaw = { iid?: number number?: number @@ -113,10 +92,7 @@ export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInf ...(mergeStateStatus ? { mergeStateStatus } : {}), headSha: data.sha, baseRefName: data.target_branch, - // Why: detail-endpoint payloads include `description`; list endpoints - // strip it. Pass through what's present rather than coercing missing - // values to '' so downstream UIs can distinguish "no body authored" - // from "this came from a list and the body is unknown". + // Preserve unknown list bodies as absent, distinct from known empty descriptions. ...(typeof data.description === 'string' ? { description: data.description } : {}), ...(data.author?.username ? { author: data.author.username } : {}), ...(data.author?.avatar_url ? { authorAvatarUrl: data.author.avatar_url } : {}) @@ -127,27 +103,20 @@ function deriveMergeable(data: GitLabMRRaw): MRInfo['mergeable'] { if (data.has_conflicts === true) { return 'CONFLICTING' } - // Why: detailed_merge_status is GitLab's richest signal. Treat - // 'mergeable' as the only positive value — every other state - // (checking, ci_must_pass, draft_status, etc.) is an unknown from the - // user's POV because it may flip without warning. + // Only positive mergeable is stable enough to enable merging; pending prerequisites remain unknown. if (data.detailed_merge_status === 'mergeable') { return 'MERGEABLE' } if (data.detailed_merge_status === 'broken_status' || data.detailed_merge_status === 'conflict') { return 'CONFLICTING' } - // Why: `detailed_merge_status` only exists from GitLab 15.6. Without this fallback an older - // instance reports UNKNOWN forever, and the merge UI (which now gates on MERGEABLE) would - // permanently show "Checking" with no merge button. Only the positive legacy value is trusted; - // `cannot_be_merged` stays UNKNOWN because `has_conflicts` above already owns the conflict case. + // GitLab <15.6 supplies only legacy merge_status; trust its positive value while conflicts use has_conflicts. if (data.detailed_merge_status === undefined && data.merge_status === 'can_be_merged') { return 'MERGEABLE' } return 'UNKNOWN' } -/** Project GitLab detailed_merge_status onto a short reason the merge UI can label. */ function deriveMergeStateStatus( data: GitLabMRRaw, mergeable: MRInfo['mergeable'] @@ -165,11 +134,6 @@ function deriveMergeStateStatus( return status } -// ── Pipeline rollup (parallel to GitHub deriveCheckStatus) ────────── -// Why: GitLab returns a single pipeline `status` for the head commit; we -// can also receive an array of jobs and roll them up the same way the -// GitHub side does. Accept either shape. - export function derivePipelineStatus( rollup: { status?: string }[] | { status?: string } | string | null | undefined ): CheckStatus { @@ -182,9 +146,7 @@ export function derivePipelineStatus( if (!Array.isArray(rollup)) { return classifyPipelineString(rollup.status ?? '') } - // Why: a job array is just a check list, so route it through the shared classifier instead of - // re-deriving the rules here — a local copy drifted (manual-only read green, an unrecognized - // status demoted a passing pipeline to neutral). + // Share rollup rules so pipeline cards cannot diverge from Checks rows. const { state } = summarizeProviderChecks( rollup.map((job) => { const s = job.status?.toLowerCase() ?? '' @@ -194,15 +156,10 @@ export function derivePipelineStatus( } }) ) - // Why: CheckStatus has no 'none'; an empty job list carries the same "nothing to report" meaning. + // The public status type uses neutral for no check results. return state === 'none' ? 'neutral' : state } -// ── Raw → GitLabWorkItem mapping ──────────────────────────────────── -// Why: list endpoints return MR / issue records; the picker consumes a -// unified GitLabWorkItem. Mirrors the GitHub side where MainWorkItem is -// produced from PR / issue REST + GraphQL responses. - type GitLabMRRawForWorkItem = { id?: number iid?: number @@ -216,10 +173,7 @@ type GitLabMRRawForWorkItem = { target_branch?: string author?: { username?: string | null } | null labels?: ({ name: string } | string)[] - /** Why: source_project_id !== target_project_id signals a fork MR. - * GitLab list endpoints include both — the picker uses this flag the - * same way GitHub's isCrossRepository disables fork-MR start points - * when the workspace flow can't safely resolve the head. */ + /** Different source/target projects identify fork MRs whose start points may need special routing. */ source_project_id?: number target_project_id?: number has_conflicts?: boolean @@ -234,8 +188,7 @@ export function mapMRToWorkItem( const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)) const number = data.iid ?? 0 return { - // Why: id needs to be unique across providers in the picker. Prefix - // 'gitlab-mr-' so a GitHub PR #5 and a GitLab MR !5 don't collide. + // Provider prefix prevents equal GitHub PR and GitLab MR numbers from colliding. id: `gitlab-mr-${data.id ?? `${repoId}-${number}`}`, type: 'mr', number, @@ -278,8 +231,6 @@ export function mapIssueToWorkItem( ): GitLabWorkItem { const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)) const number = data.iid ?? 0 - // Issues only ever resolve to 'opened' or 'closed' (issue state space is - // narrower than MRs); coerce defensively without inventing values. const state = data.state?.toLowerCase() === 'opened' ? 'opened' : 'closed' return { id: `gitlab-issue-${data.id ?? `${repoId}-${number}`}`, @@ -304,18 +255,11 @@ function classifyPipelineString(status: string): CheckStatus { if (s === 'failed' || s === 'action_required') { return 'failure' } - // Why: GitLab only reports pipeline-level `manual` when the pipeline is *blocked* on a human - // trigger (a manual job with allow_failure: false), so it is outstanding rather than broken or - // done. Red overstated it; neutral would drop the cue entirely and paint the worktree card's MR - // icon green (worktree-review-helpers.tsx defaults `open` to emerald), which is worse — with - // "Pipelines must succeed" on, GitLab rejects this merge. + // Pipeline manual means a required human trigger; report pending because merge can still be blocked. if (s === 'manual') { return 'pending' } - // Why: `skipped` and `canceled` pipelines stay neutral (the fall-through below) even though the - // job rollup calls the same jobs passing/failing. GitLab's own MR widget paints both grey, and - // `allow_merge_on_skipped_pipeline` defaults to false, so a skipped pipeline still blocks merge — - // flipping either tone is a product decision that belongs in its own change, not this one. + // Preserve neutral skipped/canceled pipeline tones, independently of job-rollup classification. if ( s === 'created' || s === 'pending' || diff --git a/src/main/gitlab/merge-request-creation.test.ts b/src/main/gitlab/merge-request-creation.test.ts index b57406c1b58..fea76d9bae9 100644 --- a/src/main/gitlab/merge-request-creation.test.ts +++ b/src/main/gitlab/merge-request-creation.test.ts @@ -93,8 +93,6 @@ describe('createGitLabMergeRequest', () => { '--draft' ]) ) - // Why: a local workspace resolves the host from cwd, so neither the flag - // nor the env override belongs on the command. expect(args).not.toContain('--hostname') expect(options).toMatchObject({ cwd: '/repo-root', @@ -232,8 +230,7 @@ describe('createGitLabMergeRequest', () => { ) }) - // Why: `glab mr create` has no --hostname flag either, so an SSH workspace - // has to reach its instance through GITLAB_HOST (#12193). + // mr create rejects --hostname; self-hosted SSH workspaces need GITLAB_HOST. it('creates a merge request through GITLAB_HOST on an SSH workspace', async () => { getProjectSlugMock.mockResolvedValue({ host: 'gitlab.example.internal', path: 'acme/widgets' }) glabExecFileAsyncMock.mockResolvedValueOnce({ @@ -263,8 +260,7 @@ describe('createGitLabMergeRequest', () => { expect(options.env?.GITLAB_HOST).toBe('gitlab.example.internal') }) - // Why: `glab mr list` has no --hostname flag, so the duplicate lookup has to - // reach a self-hosted instance through GITLAB_HOST instead (#12193). + // Duplicate lookup uses mr list, which also requires GITLAB_HOST instead of --hostname. it('looks up an existing merge request through GITLAB_HOST on an SSH workspace', async () => { glabExecFileAsyncMock .mockRejectedValueOnce(new Error('merge request already exists')) diff --git a/src/main/gitlab/merge-request-list.ts b/src/main/gitlab/merge-request-list.ts index 82587413b1a..f9ec7d3c827 100644 --- a/src/main/gitlab/merge-request-list.ts +++ b/src/main/gitlab/merge-request-list.ts @@ -30,7 +30,6 @@ function mrListStateFlags(state: MRListState): string[] { } } -/** List merge requests for a project via glab CLI, which handles self-hosted auth and project selection. */ export async function listMergeRequests( repoPath: string, state: MRListState = 'opened', @@ -42,7 +41,7 @@ export async function listMergeRequests( localGitOptions: LocalGitExecOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - // Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing. + // Resolve the same preference-selected project as issue listings. const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -52,7 +51,7 @@ export async function listMergeRequests( ) if (!projectRef) { if (connectionId) { - // Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project. + // Without local cwd, an unresolved SSH project could become an unrelated glab repository. return { items: [], page, @@ -65,9 +64,8 @@ export async function listMergeRequests( } } } - // Why: fallback — let glab infer the project from cwd when the host isn't in getGlabKnownHosts. + // Local compatibility can infer a project omitted from authenticated known hosts. const stateFlag = mrListStateFlags(state) - // Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263). const searchFlag = query?.trim() ? ['--search', query.trim()] : [] await acquire() try { @@ -95,7 +93,7 @@ export async function listMergeRequests( items: data.map((d) => mapMRToWorkItem(d, 'unknown')), page, perPage, - // Why: the CLI omits x-total headers, so totals are approximate. + // CLI listings omit total headers; infer whether another page may exist. totalCount: data.length, totalPages: data.length < perPage ? page : page + 1 } @@ -112,7 +110,6 @@ export async function listMergeRequests( release() } } - // Why: GitLab's API uses an absent state param to mean "any"; drop it for 'all'. const stateParam = state === 'all' ? '' : `&state=${state}` const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' const path = @@ -132,7 +129,7 @@ export async function listMergeRequests( page, perPage, totalCount: parseGlabPaginationHeader(headers['x-total'], 0) ?? 0, - // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). + // Large/all-state listings may omit total-pages; derive it from total count when available. totalPages: parseGlabPaginationHeader(headers['x-total-pages'], 1) ?? Math.max(1, Math.ceil((parseGlabPaginationHeader(headers['x-total'], 0) ?? 0) / perPage)) @@ -150,8 +147,3 @@ export async function listMergeRequests( release() } } - -/** - * Fetch a work item (MR or issue) by explicit project ref + iid + type. - * Used by the paste-URL flow, where the URL determines the project directly. - */ diff --git a/src/main/gitlab/merge-request-lookup.ts b/src/main/gitlab/merge-request-lookup.ts index 96b6f7bad28..aa54316f5f2 100644 --- a/src/main/gitlab/merge-request-lookup.ts +++ b/src/main/gitlab/merge-request-lookup.ts @@ -36,10 +36,7 @@ export async function getProjectSlug( return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) } -/** - * Fetch a single merge request with pipeline status rolled up. - * Returns null when the MR doesn't exist or glab fails. - */ +/** Failures and confirmed absence return null. */ export async function getMergeRequest( repoPath: string, iid: number, @@ -77,17 +74,13 @@ export async function getMergeRequest( } } -/** - * Find the explicitly linked merge request, or the newest MR whose source branch matches. - * Returns null when neither exists. - */ export async function getMergeRequestForBranch( repoPath: string, branch: string, linkedMRIid?: number | null, connectionId?: string | null, options: HostedReviewExecutionOptions = {}, - // Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found. + // Strict callers need failures distinguished from confirmed missing reviews. throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') @@ -124,10 +117,7 @@ export async function getMergeRequestForBranch( [ 'api', ...glabHostnameArgs(projectRef, connectionId), - // Why: GitLab does not proactively recompute merge status on list endpoints, so this row - // can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask - // for the async recalculation (best-effort; ignored for non-Developers when - // `restrict_merge_status_recheck` is on) so polling converges instead of stalling. + // List endpoints need asynchronous merge-status recheck; GitLab may restrict this to Developers. `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) @@ -138,10 +128,9 @@ export async function getMergeRequestForBranch( })[] if (Array.isArray(data) && data.length > 0) { const raw = data[0] - // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. + // Older GitLab uses pipeline instead of head_pipeline. const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) const info = mapMRInfo(raw, pipelineStatus) - // Why (#9171): discard a non-open implicit branch match on the repo default branch. const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ state: info.state, reviewNumber: info.number, @@ -166,9 +155,7 @@ export async function getMergeRequestForBranch( } } -/** - * Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found". - */ +/** Preserve lookup failures as unavailable rather than confirmed missing reviews. */ export function getMergeRequestForBranchOrThrow( repoPath: string, branch: string, diff --git a/src/main/gitlab/merge-request-review-mutations.ts b/src/main/gitlab/merge-request-review-mutations.ts index 0b27e0026c4..1325d7ed31c 100644 --- a/src/main/gitlab/merge-request-review-mutations.ts +++ b/src/main/gitlab/merge-request-review-mutations.ts @@ -186,7 +186,7 @@ export async function resolveMRDiscussion( } await acquire() try { - // Why: GitLab resolves/reopens the whole discussion thread, not a single note. + // GitLab resolves the entire discussion, not an individual note. await glabExecFileAsync( [ 'api', diff --git a/src/main/gitlab/merge-request-state-mutations.ts b/src/main/gitlab/merge-request-state-mutations.ts index 7aa70ceb5b0..2260874bbfe 100644 --- a/src/main/gitlab/merge-request-state-mutations.ts +++ b/src/main/gitlab/merge-request-state-mutations.ts @@ -33,7 +33,7 @@ export async function closeMR( return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) - // Why: glab exits non-zero when the MR is already closed — treat as success. + // glab signals already-closed with nonzero exit; treat the repeated transition as success. if (msg.toLowerCase().includes('already')) { return { ok: true } } @@ -100,7 +100,6 @@ export async function mergeMR( async (projectRef, repoFlag) => { await acquire() try { - // Why: omitting both --squash and --rebase yields a regular merge commit. const methodFlag = method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] await glabExecFileAsync( diff --git a/src/main/gitlab/merge-request-update.ts b/src/main/gitlab/merge-request-update.ts index 5fe1584ce2d..38f73237866 100644 --- a/src/main/gitlab/merge-request-update.ts +++ b/src/main/gitlab/merge-request-update.ts @@ -105,5 +105,3 @@ export async function updateMR( localGitOptions ) } - -/** Re-export so callers don't need to know the gl-utils module split. */ diff --git a/src/main/gitlab/mr-discussion-notes.ts b/src/main/gitlab/mr-discussion-notes.ts index fa3aac2dc6a..7a75af76bbc 100644 --- a/src/main/gitlab/mr-discussion-notes.ts +++ b/src/main/gitlab/mr-discussion-notes.ts @@ -8,10 +8,7 @@ import { type ProjectRef } from './gl-utils' -// ── Discussion → MRComment flattening ────────────────────────────── -// GitLab returns discussions with nested notes; the dialog renders a -// flat conversation. We drop system notes ("X assigned the MR", auto- -// generated changelog entries) since they aren't user-authored content. +// Flatten discussion notes for the conversation, excluding system-generated activity. type GitLabRawNote = { id?: number @@ -36,8 +33,6 @@ export function flattenDiscussions(discussions: GitLabRawDiscussion[]): MRCommen const notes = discussion.notes ?? [] for (const note of notes) { if (note.system === true) { - // Why: skip GitLab's auto-generated activity entries — they - // would dominate a busy MR's conversation tab if rendered. continue } out.push({ @@ -55,8 +50,6 @@ export function flattenDiscussions(discussions: GitLabRawDiscussion[]): MRCommen }) } } - // Why: oldest-first matches gitlab.com's conversation rendering and - // makes "what's new" intuitive when polling for updates later. return out.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? '')) } @@ -73,8 +66,7 @@ export async function fetchDiscussions( [ 'api', ...glabHostnameArgs(projectRef, connectionId), - // Why: detail drawers need a bounded recent conversation snapshot. - // Walking every historic discussion can retain and render huge note sets. + // Bound detail loads to a recent page rather than retaining every historic discussion. `projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) diff --git a/src/main/gitlab/mr-file-diffs.ts b/src/main/gitlab/mr-file-diffs.ts index 8ba6dcafb57..4c50ec56ddb 100644 --- a/src/main/gitlab/mr-file-diffs.ts +++ b/src/main/gitlab/mr-file-diffs.ts @@ -8,20 +8,11 @@ import { type ProjectRef } from './gl-utils' -/** - * Counts the added/removed lines in a single GitLab MR file's unified diff, - * feeding the +N/-N shown in the MR file list. `---`/`+++` are file headers - * only before the first `@@` hunk; every `+`/`-` line inside a hunk is content. - * Requires hunk headers: a diff with no `@@` counts zero, so do not reuse this - * for header-less agent-tool diffs (see `diffFromText` in shared/native-chat-diff). - */ +/** Count unified hunks only; header-less agent diffs need the separate shared/native-chat-diff parser. */ function countDiffLines(diff: string): { additions: number; deletions: number } { let additions = 0 let deletions = 0 - // Why: `---`/`+++` are file headers only before the first hunk. A removed line - // whose original text began with `--` (SQL/Lua/Haskell `-- comment`) becomes a - // diff line `---`, colliding with the `--- a/file` header — so it must - // be counted once inside a hunk, not skipped. + // Inside a hunk ---/+++ are content prefixes, not file headers. let inHunk = false let cursor = 0 while (cursor < diff.length) { @@ -85,8 +76,7 @@ export async function fetchMRFiles( [ 'api', ...glabHostnameArgs(projectRef, connectionId), - // Why: GitLab deprecated the all-in-one `changes` endpoint in favor of - // the paginated diffs endpoint; cap the file snapshot at one visible page. + // GitLab replaces changes with paginated diffs; bound the visible snapshot to one page. `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/diffs?per_page=100` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) diff --git a/src/main/gitlab/mr-head-tracking-ref.test.ts b/src/main/gitlab/mr-head-tracking-ref.test.ts index 6d646d1ee28..61ce5d96dc0 100644 --- a/src/main/gitlab/mr-head-tracking-ref.test.ts +++ b/src/main/gitlab/mr-head-tracking-ref.test.ts @@ -31,7 +31,6 @@ describe('fetchGitLabMergeRequestHeadRef', () => { 42 ) - // The fetch is bounded so a stalled remote can't hang MR resolution. expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ 'fetch', diff --git a/src/main/gitlab/mr-head-tracking-ref.ts b/src/main/gitlab/mr-head-tracking-ref.ts index 7b9dc97bdb1..18eb7573b75 100644 --- a/src/main/gitlab/mr-head-tracking-ref.ts +++ b/src/main/gitlab/mr-head-tracking-ref.ts @@ -18,7 +18,6 @@ type LocalGitExecOptions = { wslDistro?: string } -// Why: mirrors fetchGitHubPullRequestHeadRef so both forges pin the durable head ref the same way. export async function fetchGitLabMergeRequestHeadRef( repo: { path: string } & Pick, remote: string, @@ -37,7 +36,7 @@ export async function fetchGitLabMergeRequestHeadRef( } const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) - // Why: return the same path the fetch wrote so callers don't re-resolve identity. + // Return the written ref so callers do not recompute its identity. const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mrIid) await gitExecFileAsync( ['fetch', '--no-tags', remote, `+refs/merge-requests/${mrIid}/head:${localRef}`], diff --git a/src/main/gitlab/pipeline-job-graph.ts b/src/main/gitlab/pipeline-job-graph.ts index 46de918f49a..c0f3e003e36 100644 --- a/src/main/gitlab/pipeline-job-graph.ts +++ b/src/main/gitlab/pipeline-job-graph.ts @@ -8,18 +8,12 @@ import { type ProjectRef } from './gl-utils' -// ── Pipeline jobs ────────────────────────────────────────────────── -// Why: GitLab's `/pipelines/:id/jobs` only returns jobs owned by that pipeline. -// Trigger/include bridges live under `/bridges` and their real CI jobs live on -// the child pipeline — Orca used to show only the parent (often just SAST), so -// Checks looked empty next to gitlab.com's full graph. +// Child CI jobs live under bridge pipelines, not the parent pipeline's jobs endpoint. const PIPELINE_JOB_PAGE_SIZE = 100 -/** Cap expanded child-pipeline fan-out so one MR details load stays bounded. */ +// Bound child-pipeline expansion per details load. const MAX_CHILD_PIPELINES_TO_EXPAND = 20 -// Why: every fetch spawns a `glab` binary (a remote exec over SSH), and this runs on -// the Checks poll timer. Match gl-utils' MAX_CONCURRENT so a bridge-heavy MR trickles -// its children instead of bursting 20 processes at once. +// Each fetch launches glab; cap polling fan-out to match the shared CLI admission limit. const MAX_CONCURRENT_CHILD_FETCHES = 4 type GitLabRawJob = { @@ -61,14 +55,12 @@ function mapPipelineJob(raw: GitLabRawJob, pipelineId: number): GitLabPipelineJo function mapBridgeAsJob(raw: GitLabRawBridge, pipelineId: number): GitLabPipelineJob { const childStatus = raw.downstream_pipeline?.status return { - // Why: bridges are not real jobs — omit a positive id so Checks won't try - // job-trace/retry APIs on them. Rows still render via name + webUrl. + // Bridges have no real job ID; prevent Checks from calling trace/retry APIs on them. id: 0, pipelineId, name: raw.name ?? 'bridge', stage: raw.stage ?? '', - // Prefer the child pipeline's rollup when present — the bridge job itself - // often stays `success` while the downstream graph is still running/failed. + // Bridge status may remain successful while its downstream pipeline is running or failed. status: childStatus || raw.status || '', webUrl: raw.downstream_pipeline?.web_url ?? raw.web_url ?? '', duration: typeof raw.duration === 'number' ? raw.duration : null @@ -124,14 +116,11 @@ function childPipelineTarget( if (typeof childId !== 'number') { return null } - // Why: prefer path from web_url so same- and cross-project children both work - // without a project-id lookup. If the URL is missing/unparseable, fall back to - // the parent project (same-project triggers); wrong-project calls fail soft. + // Derive cross-project child paths from web URLs; missing URLs fall back to same-project lookup. const webUrl = bridge.downstream_pipeline?.web_url if (webUrl) { try { const url = new URL(webUrl) - // web_url shape: https://host/group/project/-/pipelines/123 const marker = url.pathname.indexOf('/-/pipelines/') if (marker > 0) { const path = url.pathname.slice(1, marker).replace(/\/$/, '') @@ -143,7 +132,7 @@ function childPipelineTarget( } } } catch { - // fall through to parent project + // Fall back to the parent project. } } return { projectRef: parentProjectRef, pipelineId: childId } @@ -211,8 +200,7 @@ export async function fetchPipelineJobs( ).catch(() => [] as GitLabPipelineJob[]) ) - // Parent jobs first, then each child's jobs. Bridge rollup rows last and only - // when no expanded child job already carries the same name (avoid duplicates). + // Keep parent/child job order, and add bridge rollups only when no expanded job shares their name. const seenJobIds = new Set() const seenNames = new Set() const out: GitLabPipelineJob[] = [] diff --git a/src/main/gitlab/pipeline-job-mutations.ts b/src/main/gitlab/pipeline-job-mutations.ts index eb59e80d45d..394bbc1e368 100644 --- a/src/main/gitlab/pipeline-job-mutations.ts +++ b/src/main/gitlab/pipeline-job-mutations.ts @@ -19,7 +19,7 @@ import { import { encodedProject } from './project-path-encoding' import { withProjectRef } from './merge-request-project-resolution' -// Why: a large or slow job log outlives the runner's 30s default; the renderer bounds the call. +// Large traces need more than the runner default; the renderer also bounds the call. const JOB_TRACE_EXEC_TIMEOUT_MS = 60_000 function mapRetriedPipelineJob( @@ -75,8 +75,7 @@ export async function getJobTrace( return { ok: true, trace: stdout } } catch (err) { const msg = err instanceof Error ? err.message : String(err) - // A job with no log is an empty log, not a failure: surfacing the 404 would - // pin an error on the Checks row for a job canceled before it started. + // Jobs canceled before starting can 404 with no trace; present an empty log. if (isMissingJobLogError(msg)) { return { ok: true, trace: '' } } diff --git a/src/main/gitlab/project-label-and-member-lookup.ts b/src/main/gitlab/project-label-and-member-lookup.ts index f223aafccd3..ba9fa5ff0d4 100644 --- a/src/main/gitlab/project-label-and-member-lookup.ts +++ b/src/main/gitlab/project-label-and-member-lookup.ts @@ -16,9 +16,8 @@ import { } from './gl-utils' import { encodedProject } from './project-path-encoding' -// GitLab's per_page maximum. const PAGE_SIZE = 100 -// Why: bounds the walk (5,000 entries) when a proxy strips x-next-page or it never advances. +// Bound pagination when a proxy strips or fails to advance next-page headers. const MAX_PAGES = 50 type GitLabRawLabel = { name?: string | null } | null @@ -76,9 +75,7 @@ export async function listAssignableUsers( } await acquire() try { - // Why: `members/all` returns project members including those inherited - // from parent groups — important for projects under a top-level group - // where assignable users typically come from the group, not the project. + // Include members inherited from parent groups, where assignable project users commonly originate. const members = await fetchAllPages( repoPath, projectRef, @@ -94,8 +91,7 @@ export async function listAssignableUsers( } } -// Why: `glab api` has no --jq and older glab lacks `--output ndjson`, so walk the pages here -// and parse each one as a JSON list. +// Older glab lacks NDJSON output and --jq; parse each API page separately. async function fetchAllPages( repoPath: string, projectRef: ProjectRef, @@ -125,8 +121,7 @@ function nextPageNumber( page: number, pageLength: number ): number | undefined { - // Why: GitLab sends an empty x-next-page on the last page, so an absent one means a proxy - // stripped it; a full page then probes one more. + // GitLab ends with an empty header; an absent header may be proxy-stripped, so probe after full pages. if (nextPageHeader === undefined) { return pageLength < PAGE_SIZE ? undefined : page + 1 } diff --git a/src/main/gitlab/project-path-encoding.ts b/src/main/gitlab/project-path-encoding.ts index da6700273f6..71c9e817d4c 100644 --- a/src/main/gitlab/project-path-encoding.ts +++ b/src/main/gitlab/project-path-encoding.ts @@ -1,6 +1,4 @@ -// Why: GitLab REST API addresses projects by URL-encoded path. Centralize -// the encoding so a future call site can't forget it (the slash escapes -// are easy to miss). +// GitLab REST project paths require encoded slashes. export function encodedProject(projectPath: string): string { return encodeURIComponent(projectPath) } diff --git a/src/main/gitlab/project-ref-inflight.ts b/src/main/gitlab/project-ref-inflight.ts index cea4173c48f..5def629a595 100644 --- a/src/main/gitlab/project-ref-inflight.ts +++ b/src/main/gitlab/project-ref-inflight.ts @@ -11,7 +11,6 @@ export async function runProjectRefProbeOnce( cacheKey: string, createProbe: (ownsKey: () => boolean) => Promise ): Promise { - // Why: joining only a probe that is still young keeps a wedged host's dead - // promise from pinning every later retry for the process lifetime (P1-D). + // Retire wedged probes so later retries can discover recovered hosts. return runCoalescedProbe(projectRefInFlight, cacheKey, createProbe) } diff --git a/src/main/gitlab/project-ref-parser.test.ts b/src/main/gitlab/project-ref-parser.test.ts index 6021d3f455f..502ce04b0b9 100644 --- a/src/main/gitlab/project-ref-parser.test.ts +++ b/src/main/gitlab/project-ref-parser.test.ts @@ -39,8 +39,7 @@ describe('gitlab project ref parsing', () => { }) it('drops the SSH transport port from the GitLab host identity', () => { - // Why: for ssh remotes the port is a transport port (e.g. :2222), not the - // web/API endpoint, so it must not become part of the recognized host. + // SSH ports select transport, not the web/API endpoint. expect( parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [ 'gitlab.com', @@ -50,8 +49,7 @@ describe('gitlab project ref parsing', () => { }) it('keeps the HTTP(S) port as part of the self-hosted GitLab host identity', () => { - // Why: a self-hosted GitLab served on a non-default web port (e.g. :8443) - // is identified by host:port end-to-end so `glab --hostname` targets it. + // Non-default HTTPS ports identify the self-hosted API endpoint. expect( parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [ 'gitlab.com', @@ -61,8 +59,7 @@ describe('gitlab project ref parsing', () => { }) it('matches a port-bearing http remote against a port-less legacy known host', () => { - // Why: a known host recorded without a port (legacy/bare entry) still - // recognizes a remote on any port of the same hostname. + // Legacy portless inventory entries recognize any port on that hostname. expect( parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [ 'gitlab.com', @@ -72,9 +69,7 @@ describe('gitlab project ref parsing', () => { }) it('distinguishes two services on the same host by port — only the GitLab one matches', () => { - // Why: a GitLab on :8443 and a Gitea on :3030 share a hostname but are - // different services. With only the GitLab port in known hosts, the Gitea - // remote must NOT be classified as GitLab. + // Same-host services on different ports must retain distinct forge identities. const knownHosts = ['gitlab.com', 'gitea.example.com:8443'] expect(parseGitLabProjectRef('http://gitea.example.com:8443/team/api.git', knownHosts)).toEqual( { host: 'gitea.example.com:8443', path: 'team/api' } diff --git a/src/main/gitlab/project-ref-parser.ts b/src/main/gitlab/project-ref-parser.ts index 575e6093179..faaa69b4fa5 100644 --- a/src/main/gitlab/project-ref-parser.ts +++ b/src/main/gitlab/project-ref-parser.ts @@ -2,22 +2,14 @@ import type { GitLabProjectRef } from '../../shared/gitlab-types' export type ProjectRef = GitLabProjectRef -/** - * Hosts always treated as GitLab. Self-hosted instances are added at - * runtime via `getGlabKnownHosts()`, which inspects `glab auth status`. - */ +/** Authenticated custom hosts extend the built-in GitLab host inventory at runtime. */ export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const export function normalizeGitLabHost(value: string): string { return value.trim().toLowerCase() } -// Why: host recognition is port-aware so two services on the same hostname -// but different ports (e.g. a GitLab on :8080 and a Gitea on :3030) are not -// conflated. The hostname (port-less) part is kept for legacy known-host -// entries that were recorded without a port. function hostnameOf(host: string): string { - // `host` may be `name` or `name:port`. Strip a trailing `:digits` port. return host.replace(/:\d+$/, '') } @@ -25,12 +17,7 @@ function stripGitSuffix(path: string): string { return path.replace(/\/+$/, '').replace(/\.git$/i, '') } -// Why: the GitLab host identity is the web/API endpoint, which is what `glab -// --hostname` and the known-hosts list speak in terms of. For http(s) -// remotes the URL port IS that endpoint port (e.g. self-hosted on :8080), -// so it must be kept. For ssh/git remotes the port is a transport port -// (e.g. ssh on :2222) that does not identify the GitLab instance, so it is -// dropped and only the hostname is used. +// HTTP ports identify API endpoints; SSH/git ports are transport-only. function hostIdentityFromUrl(url: URL): string { const protocol = url.protocol.toLowerCase() if (protocol === 'http:' || protocol === 'https:') { @@ -42,30 +29,18 @@ function hostIdentityFromUrl(url: URL): string { function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null { const normalizedHost = normalizeGitLabHost(host) const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim() - // Reject paths without at least one group segment — `gitlab.com:foo` - // alone is not a project reference. if (!normalizedPath.includes('/')) { return null } return { host: normalizedHost, path: normalizedPath } } -/** - * Does `urlHost` (which may include a `:port`) match a known-host entry? - * - An exact match (including any port) always counts. - * - A known entry WITHOUT a port also matches a URL host on the same - * hostname regardless of the URL's port — this preserves recognition for - * legacy `gitlab.com` / bare-hostname known entries. - * - A known entry WITH a port only matches a URL host with the exact same - * port, so `gitlab.example.com:8443` does not accept a - * `gitea.example.com:3000` (or same-host different-port) remote. - */ +/** Match ports exactly unless the known-host entry is legacy/portless, which matches any port on that hostname. */ function knownHostMatches(urlHost: string, knownHost: string): boolean { if (urlHost === knownHost) { return true } if (hostnameOf(knownHost) === knownHost) { - // Known entry has no port — match on hostname alone. return hostnameOf(urlHost) === knownHost } return false diff --git a/src/main/gitlab/work-item-details.test.ts b/src/main/gitlab/work-item-details.test.ts index 1b26decc7f7..d03c7d5067f 100644 --- a/src/main/gitlab/work-item-details.test.ts +++ b/src/main/gitlab/work-item-details.test.ts @@ -380,8 +380,7 @@ describe('getWorkItemDetails', () => { expect(fullCalls).toHaveLength(2) }) - // Why: each fetch spawns a `glab` binary (a remote exec over SSH) and this runs on the Checks - // poll timer, so a bridge-heavy MR must trickle its children rather than burst them. + // Every child fetch launches glab; bound the pool during Checks polling. it('bounds concurrent child-pipeline fetches', async () => { const BRIDGE_COUNT = 25 let inFlightJobPages = 0 @@ -438,9 +437,8 @@ describe('getWorkItemDetails', () => { }) const details = await getWorkItemDetails('/repo', 12, 'mr') - // Parent page can overlap the capped child pool, so allow one above the child limit. + // The parent page can overlap the capped child pool. expect(peakJobPages).toBeLessThanOrEqual(5) - // Every bridge still gets a rollup row even past the 20-child expansion cap. expect(details?.pipelineJobs).toHaveLength(BRIDGE_COUNT) }) diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 025e6ca1949..ae98d65ebe2 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -1,7 +1,3 @@ -// Why: aggregated detail-fetch for GitLabItemDialog. Parallel of -// src/main/github/work-item-details.ts but scoped to v1 surface — -// description body, flattened discussion notes, MR pipeline jobs/reviewers. -// Files / inline review-comment positioning are deferred. import type { GitLabAssignableUser, GitLabWorkItem, @@ -27,8 +23,6 @@ import { type ProjectRef } from './gl-utils' -// ── Top-level aggregator ─────────────────────────────────────────── - type GitLabRawIssue = Parameters[0] & { description?: string | null assignees?: { username?: string | null }[] | null @@ -42,14 +36,7 @@ type GitLabRawMR = Parameters[0] & { reviewers?: GitLabRawUser[] | null } -/** - * Fetch full details for a GitLab MR or issue: the work item itself, - * description body, discussion notes flattened to MRComment[], and (for - * MRs only) per-job pipeline status. - * - * Returns null when the project ref can't be resolved or the item - * can't be loaded — callers render a "not found" / error state. - */ +/** Return null for unresolved/unloadable items; include discussion, pipeline and optional review metadata. */ export async function getWorkItemDetails( repoPath: string, iid: number, @@ -60,9 +47,7 @@ export async function getWorkItemDetails( localGitOptions: LocalGitExecOptions = {}, options: { includeReviewDetails?: boolean } = {} ): Promise { - // Why: detail fetches must use the same project source as the list row - // that opened them, otherwise forked repos can show a row from one remote - // and a detail sheet from another. + // Retain the list row's selected project to avoid same-number fork/upstream collisions. const projectRef = projectRefOverride ?? ( @@ -97,8 +82,6 @@ async function fetchIssueDetails( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: fan out the two reads. Issues don't have a pipeline so this - // pair covers everything the dialog renders. const [issueRes, discussions] = await Promise.all([ glabExecFileAsync( [ @@ -113,9 +96,7 @@ async function fetchIssueDetails( const issueRaw = JSON.parse(issueRes.stdout) as GitLabRawIssue const item: Omit = (() => { const full = mapIssueToWorkItem(issueRaw, projectRef.path, projectRef) - // Why: omit repoId from the returned shape — the renderer stamps - // it from the dialog's caller (TaskPage / picker) so the main - // process doesn't need to know Orca's Repo.id. + // The renderer supplies Orca repoId; main knows only the repository path. const { repoId: _repoId, ...rest } = full return rest })() @@ -137,9 +118,7 @@ async function fetchMRDetails( localGitOptions: LocalGitExecOptions = {}, options: { includeReviewDetails?: boolean } = {} ): Promise { - // Why: MR detail + discussions in parallel. The pipeline jobs fetch - // depends on `head_pipeline.id` from the MR payload, so it has to - // wait — but it's a single follow-up call rather than a serial chain. + // Discussion can overlap MR details; child jobs need its head_pipeline ID. const [mrRes, discussions] = await Promise.all([ glabExecFileAsync( [ @@ -168,7 +147,7 @@ async function fetchMRDetails( localGitOptions ).catch(() => []) : undefined - // Checks consumes the pipeline and discussions without the dialog's review enrichment. + // Checks needs pipeline/discussion data without the dialog's optional review enrichment. const includeReviewDetails = options.includeReviewDetails !== false const [reviewers, approvalState, files] = await Promise.all([ includeReviewDetails diff --git a/src/main/gitlab/work-item-queries.ts b/src/main/gitlab/work-item-queries.ts index b8bf0450987..3b5a4fe2d76 100644 --- a/src/main/gitlab/work-item-queries.ts +++ b/src/main/gitlab/work-item-queries.ts @@ -39,7 +39,7 @@ export async function getWorkItemByProjectRef( const { stdout } = await glabExecFileAsync( [ 'api', - // Why: preserve the pasted URL's explicit host so cwd remotes can't redirect the lookup. + // Pasted hosts are authoritative; cwd remotes must not redirect them. ...(projectRef.host ? ['--hostname', projectRef.host] : []), `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` ], @@ -58,7 +58,7 @@ export async function getWorkItemByProjectRef( } function mrStateToIssueState(state: MRListState): IssueListState | null { - // Why: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped. + // Issues have no merged state; null omits the issue half entirely. switch (state) { case 'opened': return 'opened' @@ -103,8 +103,7 @@ export async function listWorkItems( } } } - // Why: fan out both reads so latency is the slower of the two, not the sum. - // Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs. + // Read raw issue API data to preserve updated_at for the combined sort; fetch both halves concurrently. const [mrs, issues] = await Promise.all([ listMergeRequests( repoPath, @@ -135,13 +134,13 @@ export async function listWorkItems( const merged = [...mrs.items, ...issues.items].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') ) - // Why: MR-side error wins — issues can be disabled per project, making its error less informative. + // Prefer MR failures because projects can legitimately disable issues. const error: ClassifiedError | undefined = mrs.error ?? issues.error return { items: merged, page, perPage, - // Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in. + // Cross-type totals are approximate because GitLab cannot paginate MR/issue unions. totalCount: mrs.totalCount, totalPages: mrs.totalPages, ...(error ? { error } : {}) @@ -153,7 +152,7 @@ function issueSearchParam(query?: string): string { if (!trimmed) { return '' } - // Why: GitLab's search matches text only, so `#23` (its issue reference) needs the iid filter. + // GitLab text search does not interpret issue-reference syntax; use the IID filter for #N. const iid = /^#(\d+)$/.exec(trimmed)?.[1] return iid ? `&iids[]=${iid}` : `&search=${encodeURIComponent(trimmed)}` } @@ -195,10 +194,7 @@ export async function fetchIssuesAsWorkItems( } } -/** - * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). - * User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard. - */ +/** Todos are user-scoped; repoPath satisfies IPC validation rather than selecting their project. */ export async function listTodos( repoPath: string, connectionId?: string | null, @@ -215,7 +211,7 @@ export async function listTodos( } await acquire() try { - // Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos. + // Bound the cross-project snapshot to the highest-priority todos. const { stdout } = await glabExecFileAsync( [ 'api', @@ -254,7 +250,7 @@ export async function listTodos( state: t.state === 'done' ? 'done' : 'pending' })) } catch { - // Why: silent empty-list on auth/network failure matches the read-side surface; caller UI signals connectivity. + // Compatibility behavior returns []; callers own connectivity presentation. return [] } finally { release() diff --git a/src/main/grok/grok-hook-config-cleanup.ts b/src/main/grok/grok-hook-config-cleanup.ts index 2356ed088ff..bdb770c421d 100644 --- a/src/main/grok/grok-hook-config-cleanup.ts +++ b/src/main/grok/grok-hook-config-cleanup.ts @@ -49,10 +49,7 @@ export function removeManagedGrokHookEntries( return { config: nextConfig, removedAny: true } } -// Why not "the whole object is empty": orca-status.json is Orca-owned, so once no hook entries -// remain the file has no reason to exist. Keying off total emptiness leaves a stray non-hook key -// (a `$schema`, say) behind, and the user-cleared install guard then reads that remnant as a -// deliberate opt-out and never reinstalls. +// Remove Orca-owned hookless remnants even with schema keys, or they look like a deliberate user opt-out. export function isOrcaOwnedRemnant(config: HooksConfig): boolean { return Object.keys(config.hooks ?? {}).length === 0 } diff --git a/src/main/grok/grok-hook-config-file.test.ts b/src/main/grok/grok-hook-config-file.test.ts index 446fa427b81..592bb21c485 100644 --- a/src/main/grok/grok-hook-config-file.test.ts +++ b/src/main/grok/grok-hook-config-file.test.ts @@ -152,9 +152,7 @@ describe('guarded Grok hook config mutation', () => { expect(statSync(configPath).mode & 0o777).toBe(0o600) }) - // Why this is pinned: Grok stats every global hook JSON and refuses to build a sandbox profile - // for one whose st_nlink != 1, failing the whole session. A publish-by-hard-link swap leaves the - // config at nlink 2 for the width of the write, so any Grok session starting in that window dies. + // Grok rejects global hook files with nlink !=1; publishing through a hard link creates a session-failure window. it('never publishes the config through a second hard link', async () => { const configPath = makeConfigPath() const installed = '{"hooks":{"SessionStart":[]}}\n' diff --git a/src/main/grok/grok-hook-config-file.ts b/src/main/grok/grok-hook-config-file.ts index b9a51f8836f..975b9834938 100644 --- a/src/main/grok/grok-hook-config-file.ts +++ b/src/main/grok/grok-hook-config-file.ts @@ -25,10 +25,7 @@ export async function writeGrokHookConfigIfUnchanged( return await mutateGrokHookConfigIfUnchanged(targetPath, expectedContents, contents, options) } -/** - * Removes the managed config. Returns false when the caller must write through instead: a config - * the user has symlinked belongs to them, so we strip our entries rather than unlink their link. - */ +/** Remove managed configs only; symlinked user configs require stripping entries and writing through. */ export async function removeGrokHookConfigIfUnchanged( targetPath: string, expectedContents: string, @@ -37,7 +34,6 @@ export async function removeGrokHookConfigIfUnchanged( return await mutateGrokHookConfigIfUnchanged(targetPath, expectedContents, null, options) } -/** True when the config is a symlink, so it must be written through and never unlinked. */ export async function isGrokHookConfigSymlink(targetPath: string): Promise { try { return (await lstat(targetPath)).isSymbolicLink() @@ -59,7 +55,7 @@ async function mutateGrokHookConfigIfUnchanged( if (options?.beforeHold && !(await options.beforeHold())) { return false } - // Why resolve first: moving the link path would detach a config kept in the user's dotfiles. + // Resolve symlinks before moving so dotfile-managed configs stay connected. const writePath = await resolveWriteTarget(targetPath) const heldPath = `${writePath}.${process.pid}.${randomUUID()}.held` try { @@ -125,8 +121,7 @@ async function mutateGrokHookConfigIfUnchanged( async function restoreHeldFile(heldPath: string, targetPath: string): Promise { try { - // Why exclusive copy: it never overwrites a concurrent writer and works on Windows/network - // filesystems that do not support hard links. Keep heldPath until the complete copy succeeds. + // Exclusive copy preserves concurrent writers and supports filesystems without hard links; retain heldPath until complete. await copyFile(heldPath, targetPath, constants.COPYFILE_EXCL) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { diff --git a/src/main/grok/grok-hook-config.ts b/src/main/grok/grok-hook-config.ts index 5482ba0accd..e20a860b7ad 100644 --- a/src/main/grok/grok-hook-config.ts +++ b/src/main/grok/grok-hook-config.ts @@ -16,8 +16,7 @@ export const GROK_EVENTS = [ { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, { - // Why: Orca needs the pre-event to show in-flight tools and detect ask_user_question waits; - // PostToolUse arrives only after both states have ended. + // PreToolUse exposes in-flight tools and question waits that PostToolUse cannot observe. eventName: 'PreToolUse', definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } }, diff --git a/src/main/grok/grok-hook-remnant-removal.test.ts b/src/main/grok/grok-hook-remnant-removal.test.ts index 037d8969c60..0bb40563649 100644 --- a/src/main/grok/grok-hook-remnant-removal.test.ts +++ b/src/main/grok/grok-hook-remnant-removal.test.ts @@ -10,10 +10,7 @@ vi.mock('os', async () => { }) import { GrokHookService } from './hook-service' -// Why one file covers both local removal paths: nothing exercised this rule more than once. Removal -// used to unlink only when the WHOLE config object was empty, but it strips just the `hooks` key -- -// so any other top-level key left a remnant, and the user-cleared install guard then read that -// remnant as a deliberate opt-out and never reinstalled. Silently. Forever. +// Hookless Orca-owned remnants must not masquerade as deliberate user opt-outs and block reinstall. const SCHEMA = 'https://grok.example/hooks.schema.json' describe('Grok hook removal leaves no remnant that blocks reinstall', () => { diff --git a/src/main/grok/grok-hook-stdin-no-eof.test.ts b/src/main/grok/grok-hook-stdin-no-eof.test.ts index c8b34ed3164..1a9b3aab305 100644 --- a/src/main/grok/grok-hook-stdin-no-eof.test.ts +++ b/src/main/grok/grok-hook-stdin-no-eof.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getGrokManagedScript } from './grok-hook-script' -// Grok's own hook timeout; the budget these cases have to stay well inside. const GROK_HOOK_TIMEOUT_MS = 10_000 const SESSION_START_BUDGET_MS = 1_500 @@ -18,7 +17,6 @@ describe.skipIf(process.platform === 'win32')('Grok POSIX hook stdin without EOF } }) - /** Writes the payload and leaves the pipe open, which is what Grok SessionStart does. */ async function runHookWithoutEof( chunks: readonly Buffer[] ): Promise<{ exitCode: number | null; durationMs: number; stderr: string }> { @@ -76,8 +74,7 @@ describe.skipIf(process.platform === 'win32')('Grok POSIX hook stdin without EOF expect(result.durationMs).toBeLessThan(SESSION_START_BUDGET_MS) }) - // Why: a non-ASCII payload arriving in pieces used to crash the reader, which - // fell through to `cat` and reinstated the very 10s timeout this hook avoids. + // Split UTF-8 must not trigger a fallback reader that blocks until Grok's timeout. it('returns just as fast when a multi-byte payload is split across writes', async () => { const bytes = Buffer.from( '{"hook_event_name":"session_start","cwd":"/tmp/漢字","tool":"🚀"}\n', diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index fe6d16a4664..0afe93619c6 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -38,9 +38,7 @@ const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : ' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ -// Why (#14828): Windows registers the bare script path when it is cmd-safe and only falls back -// to the encoded launcher for a profile path that is not (#6078). windows-hook-launcher-chain -// .test.ts pins which branch applies; these cases only care that Orca's hook is present. +// Bare cmd-safe paths and encoded fallback launchers both identify the managed Windows script. function registersManagedGrokScript(command: string): boolean { return command.includes(GROK_SCRIPT_FILE_NAME) || WINDOWS_POWERSHELL_LAUNCHER.test(command) } @@ -131,8 +129,6 @@ describe('GrokHookService', () => { rmSync(homeDir, { recursive: true, force: true }) }) - // Why: #9358 / #9941 — empty GROK_HOME + parse-time %VAR:~n,m% / `"\"` broke - // every SessionStart/UserPromptSubmit on Windows outside Orca terminals. it('guards Windows GROK_HOME substring checks when empty (#9358)', () => { const script = buildWindowsGrokHookScript() expect(script).toContain('set "ORCA_GROK_HOME="') @@ -144,7 +140,7 @@ describe('GrokHookService', () => { expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') expect(script).toContain('if not "%GROK_HOME:~4096,1%"=="" goto :orca_grok_home_ready') - // Why: parenthesized `if defined (...)` still parse-expands the body early. + // Parenthesized blocks still expand percent variables before their guard executes. expect(script).not.toMatch(/if defined GROK_HOME \(/) }) @@ -153,7 +149,7 @@ describe('GrokHookService', () => { async () => { const scriptPath = join(homeDir, 'grok-hook-unset.cmd') writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') - // Why: delete GROK_HOME rather than set '' so cmd sees "not defined". + // Delete rather than empty so cmd observes the undefined-variable path. const result = await runWindowsGrokHook( scriptPath, createWindowsGrokHookEnvironment(), @@ -259,31 +255,25 @@ describe('GrokHookService', () => { 'UserPromptSubmit' ].sort() ) - // Why: PreToolUse drives in-flight tool state and ask_user_question waits. The pane guard below - // keeps it inert outside Orca; PostToolUse cannot replace a state that has already ended. + // PreToolUse is needed for in-flight tools and question waits. expect(config.hooks.PreToolUse[0].matcher).toBe('.*') expect(registersManagedGrokScript(config.hooks.PreToolUse[0].hooks[0].command)).toBe(true) - // Why: Grok matchers are real regexes; bare `*` does not match-all. + // Grok uses real regex matchers; bare * is invalid. expect(config.hooks.PostToolUseFailure[0].matcher).toBe('.*') expect(config.hooks.PostToolUse[0].matcher).toBe('.*') - // Why: cancellation/failure are lifecycle-only events and must not inherit a tool matcher. expect(config.hooks.StopCancelled[0].matcher).toBeUndefined() expect(config.hooks.StopFailure[0].matcher).toBeUndefined() expect(config.hooks.Notification[0].matcher).toBeUndefined() - // Why: assert the shipped helper still matches what install wrote (regression - // guard if GROK_TOOL_EVENT_MATCHER drifts from install). expect(getGrokToolEventMatcherForTests()).toBe('.*') expect(getGrokToolEventMatcherForTests()).not.toBe('*') expect(new RegExp(getGrokToolEventMatcherForTests()).test('run_terminal_command')).toBe(true) - // Why: build the invalid pattern at runtime so static lint does not flag it. + // Construct invalid regex dynamically to avoid static lint rejection. const bareStar = ['*', ''].join('') expect(() => new RegExp(bareStar)).toThrow() expect(registersManagedGrokScript(config.hooks.PostToolUse[0].hooks[0].command)).toBe(true) if (process.platform !== 'win32') { const command = config.hooks.PostToolUse[0].hooks[0].command expect(command).toContain(join(homeDir, '.orca')) - // Why: with no Orca pane in the environment the guard short-circuits, so a standalone Grok - // session never spawns a shell for the managed script at all. expect(command).toMatch(/^if \[ -n "\$\{ORCA_PANE_KEY-\}" \] && /) } @@ -294,16 +284,10 @@ describe('GrokHookService', () => { expect(script).toContain('/hook/grok') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') - // Why: windows-grok-hook-script.test.ts pins the GROK_HOME guard shape itself, - // and does so on every platform rather than only on Windows runners. expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME:"=%"') expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive). - // Why the ordering: the reader chain dereferences the prelude's variable, so a - // prelude emitted after the capture would silently run `python -c ""` and - // hand back an empty payload. + // Stream payload to curl stdin; reader variables must be initialized before capture. const prelude = POSIX_HOOK_JSON_STDIN_PRELUDE.join('\n') expect(script).toContain(prelude) expect(script.indexOf(prelude)).toBeLessThan( @@ -318,10 +302,7 @@ describe('GrokHookService', () => { } }) - // Why: #6078 — a Windows user profile path with a space used to be written - // verbatim as the hook command, so the agent split it at the space. The - // managed command must use an encoded launcher so the path never appears raw - // on the cmd.exe command line. + // Profile-path spaces need the encoded fallback so Grok cannot split the managed path. it.skipIf(process.platform !== 'win32')( 'wraps the managed hook command to survive spaces in the profile path (#6078)', () => { @@ -356,7 +337,6 @@ describe('GrokHookService', () => { expect(readFileSync(join(grokHome, 'hooks', 'orca-status.json'), 'utf8')).toContain( 'SessionStart' ) - // Why: must not also write into the mocked ~/.grok when GROK_HOME wins. expect(() => readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8') ).toThrow() @@ -404,9 +384,6 @@ describe('GrokHookService', () => { expect(() => readFileSync(configPath, 'utf8')).toThrow() }) - // Why: remove() used to unlink only when the whole config object was empty, so any non-hook key - // left a remnant behind — and install()'s user-cleared guard then read that remnant as a - // deliberate opt-out and never reinstalled again. it('removes the managed config even when a non-hook key remains', () => { const service = new GrokHookService() const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') @@ -422,9 +399,7 @@ describe('GrokHookService', () => { expect(service.install().state).toBe('installed') }) - // Why: the reported workaround for #15518 was emptying this file by hand, so Orca must not - // rewrite it on startup. But then turning the setting back on has to work, or the toggle - // silently lies and the only way back is deleting a file in a hidden directory. + // Respect startup opt-out while allowing explicit re-enabling to override it. it('leaves a user-cleared config alone on a startup install', () => { const service = new GrokHookService() const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') @@ -452,9 +427,7 @@ describe('GrokHookService', () => { expect(service.getStatus().managedHooksPresent).toBe(true) }) - // Why: hook-config-write-path.ts exists because users symlink these configs into dotfiles repos. - // Unlinking or renaming onto the link path destroys that link and silently detaches the file they - // version-control. Their file is theirs -- strip our entries and write through it. + // Strip managed entries through symlinks so user dotfile links stay connected. it('writes through a symlinked config instead of destroying the link', async () => { const service = new GrokHookService() const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') @@ -504,9 +477,7 @@ describe('GrokHookService', () => { expect(service.getStatus().managedHooksPresent).toBe(false) }) - // Why: a symlinked config is written THROUGH on removal rather than unlinked, so after a quit it - // is an empty file WE emptied -- byte-identical to one a user cleared. Letting the user-cleared - // heuristic see it meant a symlinked config was silently never reinstalled after the first quit. + // Identify Orca's prior emptying separately from a deliberate user opt-out after restart. it('reinstalls into a symlinked config on the launch after a quit', async () => { const service = new GrokHookService() const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index f6ccd9fccaa..abe2b6135e3 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -37,20 +37,15 @@ import { recordGrokSymlinkCleanup } from './grok-hook-symlink-cleanup-marker' -/** Test seam: the matcher string written for Pre/Post tool lifecycle hooks. */ export function getGrokToolEventMatcherForTests(): string { return GROK_TOOL_EVENT_MATCHER } function getConfigPath(): string { - // Why: Grok loads trusted global hook files from $GROK_HOME/hooks/*.json - // (or ~/.grok when unset). Honor GROK_HOME so install/status match the same - // home Grok and transcript lookup use; keep Orca entries in a dedicated file - // so user-authored hook files stay untouched. + // Use Grok's selected home and a dedicated managed file so unrelated user hook files stay untouched. return join(resolveGrokHomeDir(), 'hooks', 'orca-status.json') } -/** Test seam: the command registered for `scriptPath` on the current platform. */ export function getManagedCommandForTests(scriptPath: string): string { return getGrokManagedCommand(scriptPath) } @@ -154,12 +149,7 @@ export class GrokHookService { } } - // Why: an existing empty file is an explicit user choice, not a missing config -- the reported - // workaround for #15518 was emptying it by hand. Why userInitiated overrides: turning the - // setting back on is an equally explicit choice, and the later one. Without this the toggle - // silently does nothing forever and the only way back is deleting a file in a hidden directory. - // A symlinked empty config is also respected unless its content and file identity match the - // marker written by Orca's own prior cleanup. + // Empty configs mean user opt-out unless explicitly re-enabled or matched to Orca's own symlink-cleanup marker. const configIsSymlink = isSymbolicLinkSync(configPath) const reinstallsOwnSymlinkCleanup = configIsSymlink && @@ -236,9 +226,7 @@ export class GrokHookService { if (readGrokHookConfigRawSync(configPath) !== snapshot.raw) { return notInstalledStatus(configPath, 'Grok hook config changed during cleanup') } - // Why the symlink check: unlinking would delete the user's link, not our file. A config they - // symlinked into a dotfiles repo is theirs -- strip our entries and write through it instead. - // writeHooksJson already resolves the link, so the file they version-control stays connected. + // Preserve user-owned symlinks: strip entries through their target instead of unlinking the link. if (isOrcaOwnedRemnant(cleanup.config) && !isSymbolicLinkSync(configPath)) { rmSync(configPath, { force: true }) } else { diff --git a/src/main/grok/windows-grok-hook-script.ts b/src/main/grok/windows-grok-hook-script.ts index e68c8914eeb..369c0811234 100644 --- a/src/main/grok/windows-grok-hook-script.ts +++ b/src/main/grok/windows-grok-hook-script.ts @@ -8,41 +8,25 @@ import { export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok', [ - // Why: attach grokHome before payload@- without string-replacing the shared template. ' --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^' ]) -/** - * Windows `grok-hook.cmd` body. - * - * Why (#9358 / #9941): cmd expands `%VAR:~n,m%` at parse time. When `GROK_HOME` - * is unset (the default outside an Orca-managed terminal), length/trailing - * guards become a syntax error and every Grok hook event fails with exit 255. - * - * - Guard substring ops behind `if defined` + goto (not a parenthesized block). - * - Reject oversized values before copying them onto a cmd input line. - * - Strip `"` before the value reaches an `if` operand or the curl argument. - */ +/** Guard cmd substring operations with goto when GROK_HOME is unset; bound length and strip quotes before expansion. */ export function buildWindowsGrokHookScript(): string { return [ '@echo off', - // Why: a bare `setlocal` inherits delayed expansion from the caller (`cmd /v:on` - // or the Command Processor registry default), and every `!` in a percent-expanded - // value on the curl line is then eaten as a delayed reference — silently mangling - // paneKey and dropping worktreeId. `!` is legal in a Windows path. + // Inherited delayed expansion would consume literal ! characters in Windows paths and pane identity. 'setlocal DisableDelayedExpansion', 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), 'set "ORCA_GROK_HOME="', 'if not defined GROK_HOME goto :orca_grok_home_ready', `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`, - // Why (#14221): `setx GROK_HOME "C:\path\"` stores a literal trailing quote, which - // unbalances the trailing-backslash `if` below (exit 255) and truncates the curl - // argument. `"` is illegal in a Windows path, so strip it rather than preserve it. + // setx can store a trailing quote, which is illegal in paths and breaks cmd parsing; strip it first. 'set "ORCA_GROK_HOME=%GROK_HOME:"=%"', 'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready', 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', - // Why: the trailing-backslash safety sentinel counts toward the relay envelope. + // Leave room for the trailing-backslash sentinel in the relay envelope. `if not "%ORCA_GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, ':orca_grok_home_ready', WINDOWS_GROK_HOOK_POST_COMMAND, diff --git a/src/main/grok/windows-hook-launcher-chain.test.ts b/src/main/grok/windows-hook-launcher-chain.test.ts index 70c9b0c78fa..9ecd9248eee 100644 --- a/src/main/grok/windows-hook-launcher-chain.test.ts +++ b/src/main/grok/windows-hook-launcher-chain.test.ts @@ -1,9 +1,4 @@ -// Why (#14828): Grok runs a hook `command` containing a space as `pwsh -Command `, so the -// encoded PowerShell launcher put two interpreters between the agent and the script — -// `grok.exe -> pwsh.exe -> powershell.exe -> cmd.exe` — and the console Grok allocates for a -// hook stays up for the whole chain. Measured against Grok 1.0.5's own dispatcher timing, the -// same script costs ~610ms behind the launcher and ~110ms as a bare path. These assertions pin -// the launch shape; the payload/exit-code contract stays covered by hook-service.test.ts. +// Prefer one cmd-safe token to avoid Grok's extra pwsh wrapper; use encoded fallback for unsafe paths. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -42,7 +37,7 @@ type InstalledConfig = { hooks: Record } -// Why: generate under a mocked win32 so the POSIX CI legs guard this too (#15117). +// Stub Windows so command-shape coverage runs on every CI platform. function withWin32(run: () => T): T { const original = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -70,21 +65,16 @@ function registeredCommands(config: InstalledConfig): string[] { } describe('Windows Grok managed hook launch shape', () => { - // Why: only `process.platform` can be mocked, not `path.join`, so an install on a POSIX - // runner produces a `/`-separated path that is cmd-safe by no definition and routes to the - // #6078 fallback by design. These synthetic-path cases carry the invariant on every CI leg; - // the install-based ones below re-check it against a real generated path on Windows. + // POSIX path.join cannot produce a real cmd-safe install path; synthetic Windows paths cover that branch everywhere. describe('registered command, from a synthetic Windows path', () => { const scriptPath = 'C:\\Users\\dev\\.orca\\agent-hooks\\grok-hook.cmd' it('registers a cmd-safe script path as the command itself (#14828)', () => { const command = withWin32(() => getManagedCommandForTests(scriptPath)) - // Why (#8430): Grok spawns this as argv[0], so it must be exactly one spawnable token. - // Forward slashes because Git Bash strips the backslashes out of a bare path. + // Grok needs one spawnable token; forward slashes survive Git Bash's backslash stripping. expect(command).toBe('C:/Users/dev/.orca/agent-hooks/grok-hook.cmd') - // Why: whitespace is the sole trigger for Grok's `pwsh -Command` wrapper, which is the - // interpreter this fix removes. Assert the trigger, not just the absence of the name. + // Any whitespace reintroduces Grok's pwsh wrapper. expect(command, 'a space would reintroduce the pwsh wrapper').not.toMatch(/\s/) expect(command).not.toMatch(/powershell|pwsh/i) expect(command).not.toMatch(/-EncodedCommand/i) @@ -95,11 +85,10 @@ describe('Windows Grok managed hook launch shape', () => { const command = withWin32(() => getManagedCommandForTests(spaced)) expect(command).toMatch(/-EncodedCommand \S+$/) - // Why: the raw path must never reach a cmd.exe command line unquoted. expect(command).not.toContain(spaced) }) - // Platform-stubbed so the legacy sweep runs on every CI leg; the installed-config legs are win32-only. + // Stub Windows so legacy hook cleanup also runs on POSIX CI. it('replaces a legacy backslash entry and keeps user hooks', () => { const config: HooksConfig = { hooks: { @@ -137,7 +126,6 @@ describe('Windows Grok managed hook launch shape', () => { home = '' }) - // win32-only: a real cmd-safe managed path needs backslashes; see the note above. it.skipIf(process.platform !== 'win32')( 'writes the generated script path itself to every managed event', () => { @@ -167,7 +155,6 @@ describe('Windows Grok managed hook launch shape', () => { `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand, timeout: 10 }] }], - // Why: a retired event must be swept too, or the old launcher keeps firing there. SubagentStop: [{ hooks: [{ type: 'command', command: staleCommand, timeout: 10 }] }], Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }] } @@ -186,7 +173,6 @@ describe('Windows Grok managed hook launch shape', () => { expect(all).not.toContain(staleCommand) expect(all.filter((command) => /-EncodedCommand/i.test(command))).toEqual([]) expect(config.hooks.SubagentStop).toBeUndefined() - // Why: sweeping stale Orca entries must not touch hooks the user wrote. expect(all).toContain('/usr/local/bin/user-hook') expect(all.filter((command) => command === scriptPath.replaceAll('\\', '/'))).toHaveLength( GROK_EVENT_NAMES.length diff --git a/src/main/hang-watchdog/hang-detection-marker.test.ts b/src/main/hang-watchdog/hang-detection-marker.test.ts index dd23445d0ba..50fffa4327a 100644 --- a/src/main/hang-watchdog/hang-detection-marker.test.ts +++ b/src/main/hang-watchdog/hang-detection-marker.test.ts @@ -89,8 +89,7 @@ describe('hang detection marker', () => { expect(existsSync(markerPath)).toBe(false) }) - // Why: a marker written by the detect leg has no selfRecovered field until the resolve leg - // rewrites it, and "never resolved" is the conservative reading of its absence. + // Detection-only markers lack selfRecovered; preserve the conservative unresolved reading. it('treats a missing selfRecovered flag as an unresolved hang', () => { const markerPath = hangDetectionMarkerPath(dir) writeFileSync( diff --git a/src/main/hang-watchdog/hang-detection-marker.ts b/src/main/hang-watchdog/hang-detection-marker.ts index c2be64d960a..c01dc4dc948 100644 --- a/src/main/hang-watchdog/hang-detection-marker.ts +++ b/src/main/hang-watchdog/hang-detection-marker.ts @@ -1,10 +1,7 @@ import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -// Why: written by the watchdog worker when main-thread heartbeats stop, and rewritten if -// they resume; consumed on the next launch to report how long the stall lasted and whether it ever -// cleared. `selfRecovered` separates a real deadlock from a long-but-survivable stall — the two are -// indistinguishable at detection time, and only the latter would have been a destructive kill. +// The next launch consumes detection/resolution markers; selfRecovered distinguishes cleared stalls from unresolved hangs. export type HangDetectionMarker = { detectedAt: number parentPid: number @@ -55,7 +52,7 @@ function readAvailableHangDetectionMarker( detectedAt: parsed.detectedAt, parentPid: parsed.parentPid, unresponsiveMs: parsed.unresponsiveMs, - // Why: a marker left by the detect leg and never rewritten means the stall never cleared. + // Missing recovery evidence leaves the stall unresolved. selfRecovered: 'selfRecovered' in parsed && parsed.selfRecovered === true } } catch { diff --git a/src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts b/src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts index 8d1c3ebfcd1..318bca7c851 100644 --- a/src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts +++ b/src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts @@ -42,8 +42,6 @@ describe('createHangWatchdogDetectionLoop', () => { expect(onHangDetected).toHaveBeenCalledTimes(1) }) - // Why: the breadcrumb reports observed silence, so it must be the measured gap at the firing - // tick (the first one strictly past the timeout), not the timeout constant. it('reports the measured stall duration, not the timeout', () => { const { loop, onHangDetected, advance } = loopWithClock() loop.recordHeartbeat() @@ -67,11 +65,11 @@ describe('createHangWatchdogDetectionLoop', () => { it('treats a large tick gap as system sleep and restarts the wait', () => { const { loop, onHangDetected, advance } = loopWithClock() loop.recordHeartbeat() - // Simulate suspension: the check timer did not run for far longer than the timeout. + // Model system suspension: the watchdog itself missed multiple timer ticks. advance(TIMEOUT_MS * 4) loop.tick() expect(onHangDetected).not.toHaveBeenCalled() - // A responsive parent resumes heartbeats after wake; the loop must fire only after a fresh full timeout of silence. + // After wake, require a fresh timeout of silence rather than counting suspended time. for (let i = 0; i < 9; i++) { advance(CHECK_INTERVAL_MS) loop.tick() @@ -84,8 +82,6 @@ describe('createHangWatchdogDetectionLoop', () => { expect(onHangDetected).toHaveBeenCalledTimes(1) }) - // Why: this is the measurement the whole PR exists for — a stall that clears would have been a - // destructive kill under the SIGKILL design, so it has to be counted separately. it('reports resolution when heartbeats resume after a detected hang', () => { const { loop, onHangDetected, onHangResolved, advance } = loopWithClock() loop.recordHeartbeat() @@ -120,8 +116,7 @@ describe('createHangWatchdogDetectionLoop', () => { advance(CHECK_INTERVAL_MS) loop.recordHeartbeat() expect(onHangResolved).toHaveBeenCalledTimes(1) - // Why: the tick clock must keep advancing during the first hang, or this first tick reads as a - // sleep gap and silently restarts the wait instead of arming it. + // Keep the tick clock advancing during a hang so recovery cannot look like system suspension. for (let i = 0; i < 12; i++) { advance(CHECK_INTERVAL_MS) loop.tick() diff --git a/src/main/hang-watchdog/hang-watchdog-detection-loop.ts b/src/main/hang-watchdog/hang-watchdog-detection-loop.ts index 9d45cba7797..a91b8297532 100644 --- a/src/main/hang-watchdog/hang-watchdog-detection-loop.ts +++ b/src/main/hang-watchdog/hang-watchdog-detection-loop.ts @@ -30,13 +30,12 @@ export function createHangWatchdogDetectionLoop( tick: () => { const now = config.now() const tickGap = now - lastTickAt - // Why: advance the tick clock even while a hang is outstanding, or the first tick after the - // stall clears reads as a huge gap and gets misread as system sleep. + // Advance while hung so recovery cannot be misclassified as a long system-sleep gap. lastTickAt = now if (detected) { return } - // Why: system sleep suspends this process too; a huge tick gap means suspension, not a parent hang, so restart the wait from scratch. + // System sleep suspends the watchdog too; restart the silence window after large tick gaps. if (tickGap > config.checkIntervalMs * 3) { lastHeartbeatAt = now return diff --git a/src/main/hang-watchdog/main-thread-hang-telemetry.test.ts b/src/main/hang-watchdog/main-thread-hang-telemetry.test.ts index 1ef4d0536c4..c49a80d22b0 100644 --- a/src/main/hang-watchdog/main-thread-hang-telemetry.test.ts +++ b/src/main/hang-watchdog/main-thread-hang-telemetry.test.ts @@ -20,7 +20,7 @@ describe('main_thread_hang_detected telemetry schema', () => { ).toBe(true) }) - // Why: strict() keeps unplanned fields (paths, pids, window titles) off the wire. + // Strict schema excludes paths, PIDs and window titles from telemetry. it('rejects unknown fields and non-integer durations', () => { expect( eventSchemas.main_thread_hang_detected.safeParse({ diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts index a1ececdadb4..a6e2efa02ed 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts @@ -72,7 +72,6 @@ describe('recordHangObservation', () => { }) it('never spawns, signals, or exits', () => { - // Why: a false positive must never kill a live main thread while writes may be in flight. recordHangObservation({ parentPid: 4242, markerPath: join(dir, 'marker.json'), diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts index 2c60b17e9ff..0d95d3ac6f9 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts @@ -29,7 +29,7 @@ export function recordHangObservation(options: { selfRecovered: options.selfRecovered }) } catch { - // Why: telemetry is best-effort; a marker that cannot be written must not take down the watchdog. + // Diagnostic write failures must not terminate the watchdog. } } @@ -51,7 +51,7 @@ export function runWatchdog( unresponsiveMs, selfRecovered: false }), - // Why: rewriting the marker keeps one observation per stall rather than two rows to reconcile. + // Update one marker per stall so resolution does not require reconciling a second record. onHangResolved: (unresponsiveMs) => recordHangObservation({ parentPid: config.parentPid, diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog.ts b/src/main/hang-watchdog/main-thread-hang-watchdog.ts index bef40f991fe..de4dd63331a 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog.ts @@ -30,7 +30,7 @@ export function installMainThreadHangWatchdog(options: { if (!resolveObservabilityConsent().localFileEnabled) { return null } - // Why: dev main threads pause in debuggers routinely; watch packaged builds only unless forced. + // Debugger pauses are normal in development; enable only when packaged or explicitly forced. if (!app.isPackaged && process.env.ORCA_HANG_WATCHDOG_FORCE !== '1') { return null } @@ -46,7 +46,6 @@ export function installMainThreadHangWatchdog(options: { } let worker: Worker try { - // The worker observes a blocked main thread without another Electron process. worker = new Worker(workerPath, { name: 'orca-main-thread-hang-watchdog', workerData @@ -74,8 +73,7 @@ export function installMainThreadHangWatchdog(options: { return } stopped = true - // Drop the app-level callback as soon as this watchdog is retired so a - // closed worker cannot keep its closure (and worker handle) alive. + // Drop the app listener immediately so retired workers are not retained by its closure. app.off('will-quit', stop) clearInterval(heartbeatTimer) postMessage({ type: 'shutdown' }) diff --git a/src/main/hang-watchdog/previous-hang-detection.test.ts b/src/main/hang-watchdog/previous-hang-detection.test.ts index 66672e3a22b..59825f93704 100644 --- a/src/main/hang-watchdog/previous-hang-detection.test.ts +++ b/src/main/hang-watchdog/previous-hang-detection.test.ts @@ -50,7 +50,6 @@ describe('previous hang detection', () => { expect(existsSync(markerPath)).toBe(false) expect(reportPreviousHangDetection(dir)).toBeNull() expect(existsSync(`${markerPath}.previous`)).toBe(true) - // A new startup stall cannot replace the preserved previous run. writeHangDetectionMarker(markerPath, { ...marker, parentPid: 789 }) const records: unknown[] = [] const flush = vi.fn() diff --git a/src/main/hermes/hermes-config-yaml.ts b/src/main/hermes/hermes-config-yaml.ts index c1fbdea9886..22d93e59723 100644 --- a/src/main/hermes/hermes-config-yaml.ts +++ b/src/main/hermes/hermes-config-yaml.ts @@ -52,8 +52,7 @@ export function enablePlugin(config: HermesConfig): HermesConfig { ? enabled : [...enabled, HERMES_PLUGIN_NAME] if (disabled === null) { - // Why: Hermes treats a malformed disabled list as empty. Normalize it here - // so Orca's install status matches what the real Hermes loader will do. + // Match Hermes' loader: malformed disabled lists behave as empty. plugins.disabled = [] } else if (disabled.includes(HERMES_PLUGIN_NAME)) { const filtered = disabled.filter((name) => name !== HERMES_PLUGIN_NAME) diff --git a/src/main/hermes/hook-service.test.ts b/src/main/hermes/hook-service.test.ts index 3158b551480..29922745417 100644 --- a/src/main/hermes/hook-service.test.ts +++ b/src/main/hermes/hook-service.test.ts @@ -180,8 +180,7 @@ describe('HermesHookService', () => { ' platform="cli",', ')' ].join('\n') - // Why: the Python hook POSTs back into this process. A synchronous - // child process blocks the HTTP server from replying, deadlocking the test. + // The Python hook calls this HTTP server; synchronous execution would deadlock its response. execFile( 'python3', ['-c', script], diff --git a/src/main/host/deferred-secret-protection-report.test.ts b/src/main/host/deferred-secret-protection-report.test.ts index 5c6336bec3d..fa0896c8217 100644 --- a/src/main/host/deferred-secret-protection-report.test.ts +++ b/src/main/host/deferred-secret-protection-report.test.ts @@ -20,7 +20,6 @@ vi.mock('electron', () => ({ const { scheduleSecretProtectionGapReport } = await import('./deferred-secret-protection-report') -/** Stands in for the BrowserWindow the app creates first. */ function fakeWindow(): { once: (event: string, listener: Listener) => void; reveal: () => void } { const listeners: Listener[] = [] return { @@ -60,7 +59,7 @@ describe('scheduleSecretProtectionGapReport', () => { encryptString: (plainText) => Buffer.from(plainText), decryptString: (cipher) => cipher.toString(), describeProtectionGap: () => { - // Why count here: this is the call that blocks on the OS keyring. + // Count the call that blocks on the OS keyring. probes += 1 return 'The OS keyring is unavailable.' } @@ -108,17 +107,14 @@ describe('scheduleSecretProtectionGapReport', () => { expect(logged).toEqual(['[secrets] The OS keyring is unavailable.']) }) - /** Runs an already-queued setImmediate; the 1ms is slack, not a delay under test. */ + /** Drain queued setImmediate callbacks; the 1ms is slack. */ const drain = (): void => void vi.advanceTimersByTime(1) it('does not probe the keyring while the first window is still being created', () => { - // Why this is the regression: probing here blocked the window for 76s on a locked - // Linux keyring, which reads to the user as "the app will not open" (STA-5765). + // A locked Linux keyring once delayed the first window by 76s (STA-5765). schedule() createWindow() - // Why drain: creation alone must arm nothing. Asserting before the queue drains - // would also pass if the probe were merely queued off `browser-window-created`, - // which on the real path still lands before the window paints. + // Drain the queue to catch probes scheduled before the first paint. drain() expect(probes).toBe(0) expect(logged).toEqual([]) @@ -128,8 +124,7 @@ describe('scheduleSecretProtectionGapReport', () => { schedule() const window = createWindow() window.reveal() - // Why: the reveal handler must return before the blocking probe runs, or the paint - // it is waiting on is the thing being blocked. + // The reveal handler must return before the blocking probe. expect(probes).toBe(0) drain() expect(probes).toBe(1) @@ -137,12 +132,10 @@ describe('scheduleSecretProtectionGapReport', () => { }) it('still reports when ready-to-show never fires, so a failed reveal is not silent', () => { - // Why: a GPU/driver failure can keep ready-to-show from ever firing, and headless - // serve has no window at all. + // GPU failures can prevent ready-to-show; serve has no window. schedule() createWindow() - // Why bracket the wait instead of running every timer: an unbounded flush passes for - // any fallback delay, including one long enough to never arrive in a real session. + // Bracket the fallback deadline so an excessive delay cannot pass. vi.advanceTimersByTime(14_000) drain() expect(probes).toBe(0) @@ -156,9 +149,7 @@ describe('scheduleSecretProtectionGapReport', () => { const window = createWindow() window.reveal() drain() - // Why assert the timer is gone and not just the probe count: the once-guard alone makes - // the count right, so without this the fallback could stay armed for 15s past the reveal - // and nothing here would notice. + // A once-guard could hide a fallback timer left armed after reveal. expect(vi.getTimerCount()).toBe(0) vi.runAllTimers() vi.advanceTimersByTime(60_000) @@ -167,8 +158,7 @@ describe('scheduleSecretProtectionGapReport', () => { }) it('does not let the fallback timer hold the process open', () => { - // Why: the report is diagnostics; a referenced 15s timer would keep a quitting app alive - // for up to 15s after its last window closed. + // A diagnostic timer must not keep a quitting app alive. const timers: ReturnType[] = [] const original = globalThis.setTimeout const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation((( @@ -188,9 +178,7 @@ describe('scheduleSecretProtectionGapReport', () => { }) it('does not probe again when the window reveals after the fallback already reported', () => { - // Why this order and not the reverse: a GPU that presents late reveals the window - // after the fallback has already reported, and a second blocking keyring probe would - // freeze the main thread exactly as the user starts interacting. + // A late reveal after fallback must not trigger another blocking probe. schedule() const window = createWindow() vi.advanceTimersByTime(15_100) @@ -202,9 +190,7 @@ describe('scheduleSecretProtectionGapReport', () => { }) it('does not turn a throwing report into a fatal main-process error', () => { - // Why: deferred, this runs off whenReady's promise chain, where a throw is an - // uncaughtException the main-process guard re-throws fatally (#9441) — not the - // unhandled rejection the app survives. A diagnostic must never end the process. + // Deferred throws become fatal uncaught exceptions instead of promise rejections (#9441). let thrown = 0 scheduleSecretProtectionGapReport({ dataFile, @@ -222,8 +208,7 @@ describe('scheduleSecretProtectionGapReport', () => { }) it('reports inline in headless serve, before the runtime is advertised as ready', () => { - // Why not deferred: serve opens no window, so the fallback would fire only after - // clients could already have paired, and a frozen main thread reads as a dead host. + // Serve must probe before advertising readiness to clients. scheduleSecretProtectionGapReport({ dataFile, log: (m) => void logged.push(m), diff --git a/src/main/host/deferred-secret-protection-report.ts b/src/main/host/deferred-secret-protection-report.ts index 7b4ea14367c..4ba4e1dcf56 100644 --- a/src/main/host/deferred-secret-protection-report.ts +++ b/src/main/host/deferred-secret-protection-report.ts @@ -1,28 +1,9 @@ import { runAfterFirstWindowShown } from '../startup/first-window-deferral' import { reportSecretProtectionGap } from './secret-protection-report' -/** - * Run the at-rest secret protection report once the app is up, never before. - * - * Why deferred: `describeProtectionGap()` asks Electron `safeStorage` whether the OS - * keyring is usable, and on Linux that is a blocking D-Bus round trip to - * `org.freedesktop.secrets`. A keyring that is present but locked with no unlock - * prompter never answers, so the call sits until D-Bus times it out — measured at 76s - * to first window on Ubuntu 24.04, against 1s on the build before the report existed - * (STA-5765). Run before the first window, that reads to the user as "the app will not - * open"; the window is gated behind a diagnostic whose result nothing on the startup - * path consumes. - * - * Why every platform and not just Linux: deferring costs nothing here — no caller reads the - * result, so the only thing that moves is when a console line appears. macOS pays the same - * shape against the Keychain, and a probe that has to answer before a window exists is wrong - * on any host. A deferral that withholds a real secret has to be scoped to the platform that - * needs it; this one withholds nothing. - */ +/** Defer diagnostic keyring probes: locked Linux keyrings can block startup for 76s (STA-5765). */ -// Why a fallback as well as the window event: `ready-to-show` can fail to fire at all -// when the GPU/driver cannot present (see main-window-state-lifecycle), and headless -// serve has no window to wait on. +// GPU failures can prevent ready-to-show; bound the diagnostic deferral. const REPORT_FALLBACK_MS = 15_000 export function scheduleSecretProtectionGapReport({ @@ -33,13 +14,7 @@ export function scheduleSecretProtectionGapReport({ dataFile: string force?: boolean log?: (message: string) => void - /** - * Why headless serve reports inline instead: it never opens a window, so the fallback - * timer is the only path — and by then the runtime has been advertised as ready and - * clients may have paired. Freezing the main thread under a live client stalls pings - * and PTY pumps, which reads as a dead host. Blocking before anything is advertised is - * the timing serve already had, and the safer of the two. - */ + /** Serve probes inline before readiness so a blocking keyring cannot stall paired clients. */ deferUntilFirstWindow: boolean /** Development profiles are disposable/isolated and must not trigger OS keychain UI. */ skipInDevelopment?: boolean @@ -53,11 +28,7 @@ export function scheduleSecretProtectionGapReport({ return } - // Why swallow here and not in reportSecretProtectionGap: deferred, this no longer runs on - // whenReady's promise chain, where a throw was an unhandled rejection the app survives - // (#9441). Off that chain it is an uncaughtException, and installUncaughtPipeErrorGuard - // re-throws those fatally — killing the app over a diagnostic the module documents as - // deliberately not fatal. Serve still reports inline and keeps the old posture. + // Deferred throws become fatal uncaught exceptions instead of promise rejections (#9441). const report = (): void => { try { reportSecretProtectionGap(options) @@ -67,7 +38,7 @@ export function scheduleSecretProtectionGapReport({ `[secrets] could not run the deferred protection report: ${String(error)}` ) } catch { - // A throwing log sink must not be what ends the process either. + // Diagnostics must survive a throwing log sink. } } } diff --git a/src/main/host/electron-app-environment.ts b/src/main/host/electron-app-environment.ts index 1e8b8c6965c..f2689fd0ad8 100644 --- a/src/main/host/electron-app-environment.ts +++ b/src/main/host/electron-app-environment.ts @@ -1,10 +1,6 @@ import { app } from 'electron' import type { AppEnvironment, AppPathName, AppProcessMetric } from '../../shared/app-environment' -/** - * Electron-backed AppEnvironment for the desktop app: a pass-through to - * `electron.app`, so desktop path, version and lifecycle behaviour is unchanged. - */ export class ElectronAppEnvironment implements AppEnvironment { getPath(name: AppPathName): string { return app.getPath(name) diff --git a/src/main/host/electron-browser-commands.ts b/src/main/host/electron-browser-commands.ts index bffdc6e9452..e3c91f7f202 100644 --- a/src/main/host/electron-browser-commands.ts +++ b/src/main/host/electron-browser-commands.ts @@ -5,7 +5,7 @@ import { browserSessionRegistry } from '../browser/browser-session-registry' import { setManagedBrowserProfileDeleter } from '../runtime/browser-host/managed-browser-profile-removal' import { deleteManagedBrowserProfile } from '../browser/browser-managed-session-profiles' -/** The desktop factory. Importing this file is what pulls in the Chromium browser cluster. */ +/** Importing this desktop factory loads the Chromium browser cluster. */ export function installElectronBrowserCommands(): void { setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host), { clientHosting: true diff --git a/src/main/host/electron-http-client.ts b/src/main/host/electron-http-client.ts index 9f4cb8eac5a..97985455e84 100644 --- a/src/main/host/electron-http-client.ts +++ b/src/main/host/electron-http-client.ts @@ -37,13 +37,7 @@ async function fetchFromDefaultSession(url: string, init?: RequestInit): Promise return net.fetch(url, init) } -/** - * The desktop HTTP client: Chromium's network stack, which follows session and proxy - * state and sends a Chrome user agent. - * - * `session.defaultSession` throws before the app is ready, so it is read per call - * rather than captured at install time. - */ +/** Chromium honors session/proxy state; read defaultSession per call because it throws before app readiness. */ export const electronHttpClient: MainHttpClient = { fetch: fetchFromDefaultSession, proxySession: () => session.defaultSession, diff --git a/src/main/host/electron-proxy-login-fetch.ts b/src/main/host/electron-proxy-login-fetch.ts index 72e69c17f34..10b94981b23 100644 --- a/src/main/host/electron-proxy-login-fetch.ts +++ b/src/main/host/electron-proxy-login-fetch.ts @@ -27,11 +27,7 @@ function abortReason(signal: AbortSignal): unknown { return signal.reason ?? new DOMException('The operation was aborted.', 'AbortError') } -/** - * Electron's net.fetch with a proxy `login` listener. Its internal request has none, so - * Electron cancels every proxy challenge and app 'login' never sees main-process requests. - * Everything else mirrors Electron 43's net.fetch. - */ +/** Mirror Electron 43 net.fetch with proxy login support; its internal request cancels challenges without a listener. */ export function fetchWithProxyLogin(request: Request, proxySession: Session): Promise { const { signal } = request if (signal.aborted) { diff --git a/src/main/host/electron-repo-icon-fetch.ts b/src/main/host/electron-repo-icon-fetch.ts index cdc0805d212..db5eba5a014 100644 --- a/src/main/host/electron-repo-icon-fetch.ts +++ b/src/main/host/electron-repo-icon-fetch.ts @@ -14,7 +14,7 @@ function webBody(response: Readable, request: ClientRequest): ReadableStream { response.destroy() request.abort() @@ -22,11 +22,7 @@ function webBody(response: Readable, request: ClientRequest): ReadableStream response.destroy(signal.reason), { once: true }) request.on('error', (error) => response.destroy(error)) resolve(new Response(body, { headers, status: response.statusCode })) diff --git a/src/main/host/electron-repo-icon-png.ts b/src/main/host/electron-repo-icon-png.ts index 965f771b3ad..874d1fb450c 100644 --- a/src/main/host/electron-repo-icon-png.ts +++ b/src/main/host/electron-repo-icon-png.ts @@ -1,6 +1,6 @@ import { nativeImage } from 'electron' -/** Decodes with Chromium's image stack and re-encodes as a PNG fitting `maxEdge`, never upscaling. */ +/** Re-encode as PNG within maxEdge without upscaling. */ export function encodeRepoIconPng(bytes: Uint8Array, maxEdge: number): Uint8Array | null { const image = nativeImage.createFromBuffer(Buffer.from(bytes)) const { width, height } = image.getSize() diff --git a/src/main/host/electron-runtime-desktop-surface.ts b/src/main/host/electron-runtime-desktop-surface.ts index 2bce4cd9ecf..d64c6fea8d9 100644 --- a/src/main/host/electron-runtime-desktop-surface.ts +++ b/src/main/host/electron-runtime-desktop-surface.ts @@ -7,7 +7,6 @@ import { encodeRepoIconPng } from './electron-repo-icon-png' import { fetchRepoIconWithManualRedirects } from './electron-repo-icon-fetch' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch' -/** The desktop implementation of the runtime's optional desktop facilities. */ export const electronRuntimeDesktopSurface: RuntimeDesktopSurface = { translateNotification: translateMain, isAwayForMobileNotifications: () => readDesktopAwayState(powerMonitor), @@ -26,7 +25,7 @@ export const electronRuntimeDesktopSurface: RuntimeDesktopSurface = { removeIpcListener: (channel, listener) => { ipcMain.removeListener(channel, listener as Parameters[1]) }, - // Switch point: only the phone's icon source encodes and fetches through these. + // Only the phone icon source needs these codecs and fetcher. ...(ORCA_SIDEBAR_INPUTS_IS_MAIN ? { encodeRepoIconPng, fetchRepoIcon: fetchRepoIconWithManualRedirects } : {}) diff --git a/src/main/host/electron-secret-store.test.ts b/src/main/host/electron-secret-store.test.ts index 44c9dd8d08c..e4b420266ad 100644 --- a/src/main/host/electron-secret-store.test.ts +++ b/src/main/host/electron-secret-store.test.ts @@ -63,9 +63,7 @@ describe('ElectronSecretStore', () => { }) it('keeps sealing AVAILABLE on basic_text so stored credentials still decrypt', () => { - // Why this matters more than the warning: flipping isEncryptionAvailable() would - // make decryptWithStatus() stop attempting and return every stored secret as - // empty. The gap is a trust signal, never a capability switch. + // A protection gap must not disable decrypting existing credentials. safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') withPlatform('linux', () => { const store = new ElectronSecretStore() @@ -119,9 +117,7 @@ describe('ElectronSecretStore', () => { }) }) - // Why these three: on Hyprland/sway/river/niri the desktop is unrecognised, so the - // backend is basic_text AND sealing is unavailable — and the old text told those users - // to install a keyring that gnome-keyring was already serving the whole time. + // Unrecognized tiling desktops may leave an installed keyring unselected. it('does not blame a missing keyring when the desktop was simply not recognised', () => { safeStorageMock.isEncryptionAvailable.mockReturnValue(false) safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text') @@ -166,9 +162,7 @@ describe('ElectronSecretStore', () => { }) }) - // Why this shape: the whole safety argument for the SecretStore refactor is that the - // desktop byte path did not change. That is only true if this adapter forwards - // verbatim — same argument, same return value, no re-encoding. + // Preserve adapter bytes and arguments exactly. it('forwards encryptString to safeStorage and returns its buffer unchanged', () => { const sealed = new ElectronSecretStore().encryptString('token') expect(safeStorageMock.encryptString).toHaveBeenCalledExactlyOnceWith('token') @@ -181,9 +175,7 @@ describe('ElectronSecretStore', () => { expect(safeStorageMock.decryptString).toHaveBeenCalledExactlyOnceWith(cipher) }) - // Why the narrower claim: safeStorage is mocked here, so this proves the adapter - // pairs encrypt/decrypt without mangling the buffer — NOT that credentials sealed by - // a previous build still open. Real-ciphertext compatibility needs a captured fixture. + // Mock round-trips cover adapter forwarding; real-ciphertext compatibility needs a captured fixture. it('pairs encryptString and decryptString without altering the payload', () => { const store = new ElectronSecretStore() expect(store.decryptString(store.encryptString('linear-token'))).toBe('linear-token') diff --git a/src/main/host/electron-secret-store.ts b/src/main/host/electron-secret-store.ts index bf9961a9f93..923e7d77293 100644 --- a/src/main/host/electron-secret-store.ts +++ b/src/main/host/electron-secret-store.ts @@ -2,10 +2,6 @@ import { safeStorage } from 'electron' import { guardedSafeStorage, safeStorageWithheldReason } from './safe-storage-availability' import type { SecretStore } from '../../shared/secret-store' -/** - * Electron-backed SecretStore for the desktop app: a pass-through to - * `electron.safeStorage`, which seals against the OS keychain. - */ export class ElectronSecretStore implements SecretStore { isEncryptionAvailable(): boolean { return guardedSafeStorage.isEncryptionAvailable() @@ -24,34 +20,22 @@ export class ElectronSecretStore implements SecretStore { if (withheld !== null) { return `The OS keychain is unavailable for this run (${withheld}), so secrets are stored unencrypted. Start orca serve from a logged-in desktop session, or unlock the login keychain, to seal them.` } - // Why availability first: it is the call that actually probes the keyring, so every - // backend read below is free and cannot change which call blocks. + // Availability probes the keyring; backend reads below do not. if (!safeStorage.isEncryptionAvailable()) { - // Why platform-specific: the fix differs, and "encryption unavailable" alone - // sends users looking in the wrong place. + // Remediation differs by platform. if (process.platform !== 'linux') { return 'The OS keychain is unavailable, so secrets are stored unencrypted.' } return readLinuxBackend() === 'basic_text' - ? // Chromium picks the backend from XDG_CURRENT_DESKTOP and recognises none of the - // tiling compositors, so it never asks the secret service that is usually running - // the whole time. Telling these users to install a keyring sends them after one - // they already have. + ? // Chromium may leave an installed secret service unselected on unrecognized tiling desktops. `Orca could not tell which keyring service this desktop uses${describeDesktop()}, so secrets are stored unencrypted — even if a secret service is already running. Start Orca with --password-store=gnome-libsecret (or --password-store=kwallet6 on KDE) to name one.` : 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.' } - // Why this is not folded into isEncryptionAvailable(): `basic_text` "encrypts" with a - // hardcoded password, and Electron makes that key available only after an explicit - // `setUsePlainTextEncryption(true)` (or `--password-store=basic`) — which this app never - // asks for, so the branch is currently unreachable on Linux and stays for the day it is - // not. Where it does apply, sealing round-trips and must keep working: reporting it - // unavailable would strand every credential already stored that way. But it protects - // nothing, and reporting it as sealed is the actual lie. + // basic_text uses a built-in password: report the gap without stranding credentials by disabling sealing. return describeLinuxBackendGap() } } -/** The active desktop, as a parenthetical for support triage, or '' when unset. */ function describeDesktop(): string { const desktop = process.env.XDG_CURRENT_DESKTOP?.trim() return desktop ? ` (XDG_CURRENT_DESKTOP=${desktop})` : '' diff --git a/src/main/host/electron-speech-services.ts b/src/main/host/electron-speech-services.ts index 7a87f2c51af..7a3ad5bc8c4 100644 --- a/src/main/host/electron-speech-services.ts +++ b/src/main/host/electron-speech-services.ts @@ -7,7 +7,7 @@ import type { OpenSpeechDownloadRequest } from '../speech/speech-model-download-response' -/** Why Chromium's stack: it honors the app's proxy settings, which Node's `https` does not. */ +/** Chromium honors the app's proxy settings; Node https does not. */ export const openElectronSpeechDownloadRequest: OpenSpeechDownloadRequest = (url) => { const request = net.request({ method: 'GET', url }) return { @@ -36,7 +36,7 @@ export const openElectronSpeechDownloadRequest: OpenSpeechDownloadRequest = (url } } -/** The desktop speech factories. Importing this file is what pulls Electron's net in. */ +/** Importing these factories loads Electron net. */ export const electronSpeechServiceFactories: SpeechServiceFactories = { createModelManager: (customModelsDir) => new ModelManager(customModelsDir, openElectronSpeechDownloadRequest), diff --git a/src/main/host/macos-keychain-reachability.ts b/src/main/host/macos-keychain-reachability.ts index 3c9cf5d621a..d18049ccc1a 100644 --- a/src/main/host/macos-keychain-reachability.ts +++ b/src/main/host/macos-keychain-reachability.ts @@ -3,21 +3,12 @@ import { runProcess } from '@orca/process-host' export type KeychainReachability = | { kind: 'reachable' } | { kind: 'unavailable'; reason: 'no_default_keychain' | 'interaction_not_allowed' | 'timeout' } - /** The probe could not tell; callers keep today's behavior. */ + /** Inconclusive: preserve existing behavior. */ | { kind: 'unknown' } const PROBE_TIMEOUT_MS = 5_000 -/** - * Ask the macOS keychain, out of process, whether this login has a usable default keychain. - * - * Why out of process: the in-process question is `safeStorage`, which is synchronous and - * can wait forever on an authorization prompt that a session without a GUI cannot show - * (#25453). A child can be killed on a timeout. - * - * Why `show-keychain-info`: it reads settings of the default keychain and never reads a - * secret, so it cannot raise the access prompt it is meant to detect. - */ +/** Probe settings out of process so a blocked keychain can time out without accessing secrets or freezing safeStorage (#25453). */ export async function probeMacosKeychainReachability( run: typeof runProcess = runProcess ): Promise { @@ -39,8 +30,7 @@ export async function probeMacosKeychainReachability( if (result.code === 0) { return { kind: 'reachable' } } - // Why exit codes too: security(1) exits with the OSStatus low byte, so a localized message - // still classifies (errSecNoDefaultKeychain -25307 = 37, errSecInteractionNotAllowed -25308 = 36). + // security(1) returns OSStatus low bytes even when its message is localized. const output = `${result.stderr}\n${result.stdout}` if (result.code === 37 || /default keychain could not be found/i.test(output)) { return { kind: 'unavailable', reason: 'no_default_keychain' } diff --git a/src/main/host/safe-storage-availability.ts b/src/main/host/safe-storage-availability.ts index e769b68b62b..a1494d589c5 100644 --- a/src/main/host/safe-storage-availability.ts +++ b/src/main/host/safe-storage-availability.ts @@ -2,13 +2,7 @@ import { safeStorage } from 'electron' let withheldReason: string | null = null -/** - * Stop every in-process keychain call for the rest of this run. - * - * Why: `safeStorage` is synchronous, and against a keychain that needs a prompt nobody can - * answer (headless serve with no GUI session) it blocks the main thread indefinitely. Once - * an out-of-process probe has shown that, callers get "unavailable" instead of a frozen host. - */ +/** Withhold synchronous safeStorage calls after a probe finds an unreachable keychain, avoiding headless prompt hangs. */ export function withholdSafeStorage(reason: string): void { withheldReason ??= reason } @@ -43,7 +37,6 @@ export const guardedSafeStorage = { } } -/** Test seam: restore the unwithheld state. */ export function resetSafeStorageWithholdForTests(): void { withheldReason = null } diff --git a/src/main/host/safe-storage-import-ratchet.test.ts b/src/main/host/safe-storage-import-ratchet.test.ts index a008a0e9091..85bef05489b 100644 --- a/src/main/host/safe-storage-import-ratchet.test.ts +++ b/src/main/host/safe-storage-import-ratchet.test.ts @@ -3,8 +3,7 @@ import { join, relative } from 'node:path' import { describe, expect, it } from 'vitest' const MAIN_ROOT = join(__dirname, '..') -// Why: only these may touch electron.safeStorage directly; every keychain read or write must -// go through guardedSafeStorage so a withheld keychain can never block the main thread. +// Route keychain access through the run-wide guard so withheld keychains cannot block the main thread. const ALLOWED = new Set(['host/safe-storage-availability.ts', 'host/electron-secret-store.ts']) function listSources(dir: string): string[] { diff --git a/src/main/host/secret-protection-report.test.ts b/src/main/host/secret-protection-report.test.ts index d5aefe664c4..524017af653 100644 --- a/src/main/host/secret-protection-report.test.ts +++ b/src/main/host/secret-protection-report.test.ts @@ -35,8 +35,7 @@ describe('reportSecretProtectionGap', () => { reportSecretProtectionGap({ dataFile, log: (m) => void logged.push(m), force }) it('reports a new gap once, then stays quiet on every later launch', () => { - // Why this is the point: the gap usually needs a keyring install to fix, so - // repeating it every start is nagging the user cannot act on. + // Repeat only on change: keyring remediation may not be immediately possible. installStore(WEAK) expect(report()).toBe(WEAK) expect(logged).toHaveLength(1) diff --git a/src/main/host/secret-protection-report.ts b/src/main/host/secret-protection-report.ts index 8bdc59b57b3..742c684a202 100644 --- a/src/main/host/secret-protection-report.ts +++ b/src/main/host/secret-protection-report.ts @@ -2,22 +2,7 @@ import { dirname, join } from 'node:path' import { readFileSync, writeFileSync } from 'node:fs' import { getSecretStore } from '../../shared/secret-store' -/** - * Report at-rest secret protection, but only when the answer changes. - * - * Why this exists: the port has always been able to describe a protection gap, and - * nothing read it — so a Linux user whose secrets are obfuscated with a built-in key - * was told nothing at all. - * - * Why not every launch: the gap is usually not something the user can fix right now - * (it needs a keyring installed and unlocked), so repeating it every start is nagging - * they cannot act on and will learn to ignore. Reporting on change means they hear it - * when it starts being true, hear it again if it becomes true for a different reason, - * and are told once when it is fixed. - * - * Deliberately not fatal and not a dialog: sealing still works, so blocking startup - * would be worse than the gap it reports. - */ +/** Report protection gaps and recovery only on change; repeated startup warnings would obscure an unresolved keyring problem. */ type ReportOptions = { /** Profile data file; the report state lives beside it. */ @@ -52,8 +37,7 @@ export function reportSecretProtectionGap(options: ReportOptions): string | null try { gap = getSecretStore().describeProtectionGap() } catch (error) { - // Why swallow: this is diagnostics. An uninstalled store is already a hard failure - // at the first real read, and that error is the useful one. + // Diagnostics must not preempt the useful error from the first real secret read. log(`[secrets] could not determine at-rest protection: ${String(error)}`) return null } @@ -66,8 +50,7 @@ export function reportSecretProtectionGap(options: ReportOptions): string | null if (gap) { log(`[secrets] ${gap}`) } else if (previous?.lastReportedGap) { - // Why announce the recovery: the previous warning said secrets were not protected, - // so silence would leave the user assuming that is still true. + // Silence would leave the previous unprotected warning unresolved. log('[secrets] At-rest protection is now provided by the OS keyring.') } } @@ -76,7 +59,7 @@ export function reportSecretProtectionGap(options: ReportOptions): string | null try { writeFileSync(path, JSON.stringify({ lastReportedGap: gap }), 'utf-8') } catch (error) { - // Best effort: re-reporting next launch is strictly better than failing startup. + // Re-reporting next launch is preferable to failing startup. log(`[secrets] could not persist the protection report state: ${String(error)}`) } } diff --git a/src/main/host/serve-keychain-guard.ts b/src/main/host/serve-keychain-guard.ts index 22d26dcaf70..94638efbf58 100644 --- a/src/main/host/serve-keychain-guard.ts +++ b/src/main/host/serve-keychain-guard.ts @@ -4,14 +4,7 @@ import { } from './macos-keychain-reachability' import { withholdSafeStorage } from './safe-storage-availability' -/** - * Before headless serve touches a secret, prove the keychain can answer without a prompt; - * if it cannot, report secrets as unavailable for this run instead of hanging readiness. - * - * Why serve only: the desktop has a GUI session that can show the prompt. Why the probe is - * bounded: it runs in a child process that is killed after 5 s, so the worst case is a - * 5 s delay, never the indefinite in-process block it replaces (#25453). - */ +/** Probe headless macOS keychains in a bounded child before secret access; desktop sessions can answer prompts (#25453). */ export async function withholdUnreachableKeychainForServe( deps: { platform?: NodeJS.Platform diff --git a/src/main/i18n/main-i18n-lazy-locale.test.ts b/src/main/i18n/main-i18n-lazy-locale.test.ts index d1a712cd0da..5982ca405db 100644 --- a/src/main/i18n/main-i18n-lazy-locale.test.ts +++ b/src/main/i18n/main-i18n-lazy-locale.test.ts @@ -1,8 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// Why: main-i18n avoids bundling locale catalogs on the cold-start path. -// English comes from translateMain() fallbacks; non-English catalogs load -// through the backend before awaited menu/tray/dialog rendering reads them. +// English uses fallbacks; awaited non-English catalogs must load without entering the cold-start bundle. vi.mock('electron', () => ({ app: { @@ -44,8 +42,7 @@ describe('main-i18n lazy locale loading', () => { it('lazy-loads the Spanish catalog before changeLanguage resolves', async () => { const locale = await setMainUiLanguage(UI_LANGUAGE_SPANISH) expect(locale).toBe('es') - // If the lazy backend had not loaded es before changeLanguage settled, these - // would fall back to the English defaults. + // Missing the awaited backend load would return English fallbacks. expect(translateMain('menu.file', 'File')).toBe('Archivo') expect(translateMain('menu.settings', 'Settings')).toBe('Ajustes') }) diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index 8a01f9d5b75..ed325a3bf7f 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -18,9 +18,7 @@ let languageGeneration = 0 let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] const registeredPluginLanguages = new Set() -// Why: main-process callers pass English fallbacks to translateMain(), so the -// main bundle does not need to parse any locale catalog at cold start. Only -// non-English users pay for their selected catalog, after i18n is awaited. +// English uses caller fallbacks; load only the selected non-English catalog after startup. const LAZY_LOCALE_LOADERS: Record< Exclude, () => Promise<{ default: Record }> @@ -39,8 +37,7 @@ const lazyLocaleBackend: BackendModule = { read: (language: string, _namespace: string, callback: ReadCallback) => { const loader = LAZY_LOCALE_LOADERS[language as Exclude] if (!loader) { - // English is intentionally represented by the empty bundled resource; its - // user-visible copy comes from translateMain() defaultValue fallbacks. + // English comes from translateMain defaultValue fallbacks. callback(null, false) return } @@ -60,9 +57,7 @@ export async function ensureMainI18n(): Promise { await mainI18n.use(lazyLocaleBackend).init({ fallbackLng: DEFAULT_UI_LOCALE, lng: DEFAULT_UI_LOCALE, - // Why: mark the default locale loaded with an empty resource bundle. Main - // process English strings come from translateMain() fallbacks, and - // partialBundledLanguages lets the backend supply non-English catalogs. + // Seed English as loaded; partialBundledLanguages allows lazy non-English catalogs. partialBundledLanguages: true, resources: { en: { @@ -124,8 +119,7 @@ export function setMainPluginLanguagePacks( } export function translateMain(key: string, fallback: string, options?: TOptions): string { - // Why: menu registration can run before async init finishes in tests; fall back - // to the English default instead of returning undefined from an uninitialized i18n. + // Menu registration can precede async initialization; use the English fallback then. const raw = initialized ? mainI18n.t(key, { defaultValue: fallback, ...options }) : fallback const value = typeof raw === 'string' && raw.length > 0 ? raw : fallback return isPseudoLocalizationLocale(mainI18n.language) ? pseudoLocalizeString(value) : value diff --git a/src/main/ipc/account-sign-in-link-opener.ts b/src/main/ipc/account-sign-in-link-opener.ts index 8fa29f62a4d..f38835d7418 100644 --- a/src/main/ipc/account-sign-in-link-opener.ts +++ b/src/main/ipc/account-sign-in-link-opener.ts @@ -4,7 +4,7 @@ import { isCodexLoginAuthUrl } from '../codex-accounts/codex-login-auth-url' export type AccountSignInProvider = 'claude' | 'codex' -/** A provider's own https sign-in page, the only thing a WSL login's link may open. */ +/** Allow only the provider's own HTTPS sign-in page. */ export function isOpenableAccountSignInLink( provider: AccountSignInProvider, signInLink: string @@ -23,10 +23,9 @@ export function isOpenableAccountSignInLink( : isCodexLoginAuthUrl(signInLink) } -/** Opens a WSL login's sign-in link in the user's browser; anything else is dropped. */ export function openAccountSignInLink(provider: AccountSignInProvider, signInLink: string): void { if (!isOpenableAccountSignInLink(provider, signInLink)) { - // Why the host only: the query carries OAuth state; the host is what a provider change moves. + // Log only the host: query parameters carry OAuth state. console.warn( `[${provider}-accounts] Refused to open a sign-in link on host ${linkHost(signInLink)}.` ) diff --git a/src/main/ipc/agent-detection-shell-path.ts b/src/main/ipc/agent-detection-shell-path.ts index 1fc75f505bb..541105b1d84 100644 --- a/src/main/ipc/agent-detection-shell-path.ts +++ b/src/main/ipc/agent-detection-shell-path.ts @@ -8,8 +8,7 @@ export async function hydrateShellPathForAgentDetection( if (getPreflightWslTarget(context)) { return } - // Why: remote runtime servers may inherit a sparse daemon/SSH PATH even - // though the user's shell can run the agents. + // Daemon/SSH PATH may omit agents available in the user's shell. const hydration = await (force ? hydrateShellPath({ force: true }) : hydrateShellPath()) if (hydration.ok) { mergePathSegments(hydration.segments) diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index fe488b087c5..70b5a3d13ea 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -3,9 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentHookServerModule from '../agent-hooks/server' import { makePaneKey } from '../../shared/stable-pane-id' -// Why: cover the agentStatus:drop IPC handler — it must propagate the -// renderer dismissal to dropStatusEntry so the on-disk last-status file -// evicts the entry. +// Renderer dismissals must evict persisted status as well as the live row. const dropStatusEntry = vi.fn() const dropPersistedStatusEntry = vi.fn() @@ -39,8 +37,7 @@ vi.mock('electron', () => ({ })) vi.mock('../agent-hooks/server', async () => { - // Why: import the real isValidPaneKey so this test stays in sync with any - // tightening of the validator (length cap, character allow-list, etc). + // Use the real pane-key validator so invalid-input coverage tracks its constraints. const actual = await vi.importActual('../agent-hooks/server') return { ...actual, @@ -152,8 +149,7 @@ describe('agentStatus:getSnapshot IPC', () => { expect(handler!({})).toEqual(snapshot) }) - // The half-migration seam: until PR 2 retires the renderer's own feed bridge, main must not - // publish structured rows to the renderer at all — one pane key, one writer. + // Keep one writer per pane while the renderer feed bridge owns structured rows. it('omits structured rows the renderer feed bridge still owns', async () => { getStatusSnapshot.mockReturnValue([ { diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index e6fecfc06ee..1094cbb2a16 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -19,20 +19,12 @@ type AgentHookHandlerDependencies = { getPtyIdForPaneKey?: (paneKey: string) => string | undefined } -// Why: install/remove are intentionally not exposed to the renderer. Orca -// auto-installs managed hooks at app startup (see src/main/index.ts), so a -// renderer-triggered remove would be silently reverted on the next launch -// and mislead the user. +// Startup auto-installs hooks; renderer removal would misleadingly revert on the next launch. export function registerAgentHookHandlers( runtime?: AgentStatusRuntimeEnrichment, dependencies: AgentHookHandlerDependencies = {} ): void { - // Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration - // never throws "Attempted to register a second handler..." if this function is - // ever invoked more than once (e.g. the macOS app re-activation path that - // recreates the main window). Today the module-level `registered` guard in - // register-core-handlers.ts prevents re-entry, but decoupling from that guard - // future-proofs this file. + // Allow safe re-registration independently of the outer module guard. ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:inferQuestionAnswered') @@ -47,9 +39,7 @@ export function registerAgentHookHandlers( }) }) ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => { - // Why: the renderer pulls this after workspace hydration, so startup cannot - // lose replayed statuses while its local store is still empty. Match the - // live push enrichment in main/index.ts so parent/child rows survive replay. + // Pull after workspace hydration to replay enriched statuses without losing startup rows. return ( agentHookServer .getStatusSnapshot() diff --git a/src/main/ipc/agent-session-attachment-upload-ipc.ts b/src/main/ipc/agent-session-attachment-upload-ipc.ts index 9fcf1ba357d..fe84bf27eee 100644 --- a/src/main/ipc/agent-session-attachment-upload-ipc.ts +++ b/src/main/ipc/agent-session-attachment-upload-ipc.ts @@ -15,7 +15,7 @@ export function registerAgentSessionAttachmentUploadHandlers(): void { args: AgentSessionAttachmentUploadTarget & { paths: string[] } ): Promise => { const userDataPath = app.getPath('userData') - // Why: a reload or close must stop the transfer; the server sweeps the part file it leaves. + // Abort on reload/close; the server sweeps abandoned part files. const lifetime = abortWhenRendererGone(event.sender) try { return await uploadExternalPathsToAgentSessionAttachments( diff --git a/src/main/ipc/agent-session-attachment-upload.test.ts b/src/main/ipc/agent-session-attachment-upload.test.ts index c7308d66ab4..25405e76d84 100644 --- a/src/main/ipc/agent-session-attachment-upload.test.ts +++ b/src/main/ipc/agent-session-attachment-upload.test.ts @@ -16,7 +16,7 @@ const calls: Call[] = [] let serverStore: AgentSessionAttachmentStore let failMethod: string | null = null -// The paired server, played by a real store: every call answers as the RPC handler would. +// Use the real paired-server store behind the RPC seam. vi.mock('./runtime-environments/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: async ( _userDataPath: string, @@ -139,7 +139,7 @@ describe('uploadExternalPathsToAgentSessionAttachments', () => { it('skips a folder as unsupported without walking into it', async () => { await mkdir(join(workDir, 'folder', 'nested'), { recursive: true }) await writeFile(join(workDir, 'folder', 'inside.txt'), 'x') - // A link inside would make the stager skip the folder as a symlink: never reached. + // Linking the directory itself would skip staging before the nested-link case. await symlink(join(workDir, 'folder', 'inside.txt'), join(workDir, 'folder', 'nested', 'link')) const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ join(workDir, 'folder') @@ -168,7 +168,6 @@ describe('uploadBufferToAgentSessionAttachments', () => { const stored = await uploadBufferToAgentSessionAttachments(context(), 'orca-paste-1.png', png) expect(stored.name).toBe('orca-paste-1.png') expect((await readFile(stored.path)).equals(png)).toBe(true) - // 900 KiB in 384 KiB slices. expect( calls.filter((call) => call.method === 'agentSessionAttachment.uploadAppend') ).toHaveLength(3) diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts index c7b8cca2ca2..54451acd6c8 100644 --- a/src/main/ipc/agent-session-attachment-upload.ts +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -1,6 +1,4 @@ -// Client side of chat attachments on a paired server: main owns the file handles, the clipboard and -// the runtime socket, so it pumps the bytes into the server's attachment store and hands the -// renderer only the server path. +// Main pumps client-local attachment bytes into the paired server store; the renderer receives only server paths. import { randomUUID } from 'node:crypto' import { lstat } from 'node:fs/promises' @@ -69,7 +67,7 @@ async function callAttachmentMethod( return response.result as TResult } -/** start → body → commit; any failure aborts, so the server frees the slot and its part file. */ +/** Abort failed transfers so the server frees their slots and part files. */ async function uploadAttachment( context: UploadContext, file: { name: string; byteLength: number }, @@ -127,7 +125,6 @@ export function uploadBufferToAgentSessionAttachments( }) } -/** A paste for a structured chat on a paired server, stored in that server's store. */ export async function uploadPasteToAgentSessionAttachments( target: AgentSessionAttachmentClipboardTarget, runtimeEnvironmentId: string, @@ -147,10 +144,7 @@ export async function uploadPasteToAgentSessionAttachments( return stored.path } -/** - * Upload dropped or picked client-local files, in input order. A folder is skipped as - * unsupported: a chat attachment is a file. - */ +/** Upload files in input order; folders are unsupported. */ export async function uploadExternalPathsToAgentSessionAttachments( context: UploadContext, paths: readonly string[] @@ -158,7 +152,7 @@ export async function uploadExternalPathsToAgentSessionAttachments( const result: AgentSessionAttachmentPathUploadResult = { uploaded: [], skipped: [], failed: [] } for (const sourcePath of paths) { context.signal?.throwIfAborted() - // Before staging, which would walk and open everything inside a folder only to skip it. + // Reject folders before staging walks and opens their contents. if (await isDirectory(sourcePath)) { result.skipped.push({ sourcePath, reason: 'unsupported' }) continue diff --git a/src/main/ipc/agent-status-ipc-boundary.ts b/src/main/ipc/agent-status-ipc-boundary.ts index 1323a8474df..36492844960 100644 --- a/src/main/ipc/agent-status-ipc-boundary.ts +++ b/src/main/ipc/agent-status-ipc-boundary.ts @@ -16,8 +16,7 @@ export type AgentStatusRuntimeEnrichment = Pick< const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160 -/** What the runtime resolved for a pane at the moment a status row was ingested. Captured by - * `AgentStatusObservedPaneIdentities`, which is where the arms are documented. */ +/** Identity at ingestion, captured by AgentStatusObservedPaneIdentities. */ export type ObservedAgentStatusPaneIdentity = | { kind: 'observed' @@ -26,12 +25,10 @@ export type ObservedAgentStatusPaneIdentity = /** The orchestration dispatch that owned the pane then, not whichever owns it now. */ dispatchId: string | null } - /** No status arrival was seen for this pane in this runtime: a hydrated replay row, or one - * reconciled from it. Those carry `restoredUnconfirmed` and never project `live`. */ + /** Hydrated replay rows stay restoredUnconfirmed and cannot project live. */ | { kind: 'unobserved' } -/** The one place a pane key becomes terminal identity. Both the IPC payload the renderer - * decodes and the fleet evidence the orchestration path reads are derived from this. */ +/** Resolve terminal identity once for renderer IPC and orchestration fleet evidence. */ export function resolveAgentStatusBinding( paneKey: string, runtime: AgentStatusRuntimeEnrichment | undefined @@ -50,14 +47,7 @@ export function resolveAgentStatusBinding( : { kind: 'pane', terminalHandle, paneKey, processIncarnation } } -/** - * The identity the row was observed under, fenced against the pane's identity now. - * - * The fleet path reads cached rows, so resolving identity here would describe whatever process - * and dispatch the pane owns at read time rather than the one the agent reported from. A row - * this runtime never observed keeps the current resolution: it is a hydrated replay, already - * held off `live` by `restoredUnconfirmed`, and inventing an observation for it would be worse. - */ +/** Fence ingestion identity against the current process; replay rows remain unconfirmed without inventing observations. */ export function resolveObservedAgentStatusBinding( paneKey: string, runtime: AgentStatusRuntimeEnrichment | undefined, @@ -94,8 +84,7 @@ export function mintAgentStatusFleetEvidence( ) } -/** Unchanged wire shape: `agentStatus:set` and `agentStatus:getSnapshot` still publish the - * same optional fields an older renderer decodes. Only the identity lookup is shared. */ +/** Share identity lookup while preserving optional wire fields understood by older renderers. */ export function enrichAgentStatusIpcPayload( data: AgentStatusIpcPayload, runtime: AgentStatusRuntimeEnrichment | undefined diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts index 5e42312ec20..2e4393e7707 100644 --- a/src/main/ipc/agent-status-row-teardown-ipc.ts +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -8,16 +8,8 @@ import { } from '../agent-hooks/migration-unsupported-pty-state' import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary' -/** - * The renderer-initiated ways a status row goes away. All fire-and-forget - * (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the - * listeners first keeps re-registration safe. - * - * They are NOT interchangeable. A dismissal keeps the pane's per-pane caches because its agent may - * still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves - * the pane's next event straight back to `working`. - */ -// Why a cap: the renderer sends one batch per Clear-completed click, bounded by visible rows. +/** Dismissal preserves live pane caches; confirmed exits must clear latches that could revive a stale working row. */ +// Bound renderer-supplied batches to the visible-row scale. const MAX_DROP_PERSISTED_BATCH = 5_000 export function registerAgentStatusRowTeardownIpcHandlers(): void { @@ -35,10 +27,7 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { return } try { - // Why: dropStatusEntry (not clearPaneState) is correct here — the user is - // dismissing a status row, not tearing down a PTY. clearPaneState would also - // wipe the per-pane prompt/tool caches, which the next hook event for that - // (still-alive) pane needs to render a coherent row. + // Dismiss the row without wiping prompt/tool caches needed by the still-live pane. agentHookServer.dropStatusEntry(paneKey) clearMigrationUnsupportedPtysForPaneKey(paneKey) } catch (err) { @@ -81,12 +70,9 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { return } try { - // Why: a process-table-confirmed agent exit is exactly the case the dismissal above excludes - // — the pane's agent is NOT still alive — so its latches must go with the row (STA-4612). + // Confirmed process exit must clear latches with the row (STA-4612). agentHookServer.reconcileEndedProcessForPaneKeys([paneKey], { - // Why: this route only fires on a confirmed shell foreground, so the PTY outlived the - // agent. The row's resume identity is still usable in that very pane — only its live - // claims are dead. + // The shell still owns the PTY, so keep its usable resume identity. preserveResumeIdentity: true }) clearMigrationUnsupportedPtysForPaneKey(paneKey) diff --git a/src/main/ipc/ai-vault-all-host-timeouts.ts b/src/main/ipc/ai-vault-all-host-timeouts.ts index a9fff53725f..c1f63f82ffc 100644 --- a/src/main/ipc/ai-vault-all-host-timeouts.ts +++ b/src/main/ipc/ai-vault-all-host-timeouts.ts @@ -3,13 +3,9 @@ import type { RuntimeAiVaultHostInfo } from './ai-vault-runtime-scan' // Per-leg bounds for the all-hosts fan-outs, so one slow host cannot hold a merge open. export const AI_VAULT_ALL_HOST_TIMEOUT_MS = { runtimeScan: 3_000, - // Why: a remote home with many agent roots routinely needs seconds to walk, - // stat and parse. The old shared 3s bound emptied healthy SSH hosts in the - // all-hosts view; a server reached over an SSH tunnel walks the same home - // plus the tunnel's latency, so it gets a real scan budget. + // SSH/tunneled scans walk large remote homes; the shorter local budget hid healthy hosts. sshTunneledRuntimeScan: 15_000, - // A search reads an index rather than walking a home, but it shares the - // connection with the scans, so it gets the SSH scan budget. + // Indexed search shares the connection with scans, so use the same SSH budget. search: 15_000 } as const diff --git a/src/main/ipc/ai-vault-delete.ts b/src/main/ipc/ai-vault-delete.ts index 110786d11d7..7990271b033 100644 --- a/src/main/ipc/ai-vault-delete.ts +++ b/src/main/ipc/ai-vault-delete.ts @@ -12,29 +12,24 @@ import type { AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion' -// Which cache backs the multi-host list is ai-vault.ts's concern, so its -// invalidation is injected rather than reached into from here. +// Inject invalidation because ai-vault.ts owns the multi-host cache choice. type AiVaultDeleteDeps = { invalidateMultiHostListCache: () => void invalidateBackgroundCache?: (paths: string[]) => Promise } -// Binds the delete orchestration to the caller's cache-invalidation seam. export function registerAiVaultDeleteHandler(deps: AiVaultDeleteDeps): void { ipcMain.handle('aiVault:deleteSession', (_event, args?: AiVaultDeleteSessionArgs) => deleteAiVaultSession(args, deps) ) } -// Adapts the untyped IPC payload for the executor (which owns re-validation and -// the trash), then invalidates on a real delete — otherwise the caches keep -// serving the deleted session for up to the scan TTL. +/** Adapt IPC input for the validating executor and invalidate caches only after deletion. */ export async function deleteAiVaultSession( args: AiVaultDeleteSessionArgs | undefined, deps: AiVaultDeleteDeps ): Promise { - // The validator tolerates a malformed agent/filePath but destructures `args`, - // so an absent payload is defaulted here to keep the never-throws boundary. + // Default absent payload fields because the validator destructures its input. const wslHomeDirs = await getAiVaultWslHomeDirs() const result = await deleteAiVaultSessionFile({ agent: args?.agent as AiVaultAgent, @@ -45,14 +40,10 @@ export async function deleteAiVaultSession( }) if (result.outcome === 'deleted') { - // Three caches could otherwise resurrect it: the desktop per-host leg - // cache, the shared local-scope cache (also the runtime/mobile path), and - // this file's parse-cache entry. + // Invalidate desktop, shared runtime/mobile and raw-path parse caches to prevent resurrection. deps.invalidateMultiHostListCache() invalidateAiVaultSessionListCache() - // The parse cache is keyed by the raw path the scanner discovered (which is - // what the renderer echoes back as filePath), so invalidate with that exact - // key — resolve() could normalise it away from the stored key and miss. + // Use the scanner's raw key; path normalization could miss its parse-cache entry. invalidateSessionParseCacheEntry(args?.filePath ?? '') await (deps.invalidateBackgroundCache ?? invalidateAiVaultBackgroundCache)([ args?.filePath ?? '' diff --git a/src/main/ipc/ai-vault-host-discovery.ts b/src/main/ipc/ai-vault-host-discovery.ts index e8f1f51fb23..c55c7e3b011 100644 --- a/src/main/ipc/ai-vault-host-discovery.ts +++ b/src/main/ipc/ai-vault-host-discovery.ts @@ -6,11 +6,7 @@ export type AiVaultHostDiscoveryResult = { issue?: AiVaultListResult } -/** - * Why: enumerating runtime/SSH hosts reads live session state and can throw. In - * 'all' scope that rejection would take down the whole Promise.all and drop the - * local sessions too, so a broken enumerator degrades to one issue row instead. - */ +/** Degrade throwing host enumeration to an issue so all-hosts reads retain healthy local sessions. */ export function discoverAiVaultHosts( enumerate: () => readonly T[], args: { path: string; fallbackMessage: string } diff --git a/src/main/ipc/ai-vault-host-leg-cache.ts b/src/main/ipc/ai-vault-host-leg-cache.ts index 387e2072e56..6c1eb575db8 100644 --- a/src/main/ipc/ai-vault-host-leg-cache.ts +++ b/src/main/ipc/ai-vault-host-leg-cache.ts @@ -14,19 +14,11 @@ type CachedHostLeg = { expiresAt: number } -// Why: the merged result is only cacheable when every host answered, so one -// flaky host used to force a full rescan of every healthy host on each panel -// open. Legs are cached individually and the failing host is the only one -// rescanned. +// Cache healthy legs independently so one failing host cannot force every host to rescan. const cachedHostLegs = new Map() -// Bumped on every invalidation. A leg scan that started before an invalidation -// carries the old generation and must not write its (now stale) result back — -// otherwise a delete's invalidation is silently undone by a scan that resolves -// just after it. +// Fence scans begun before invalidation so late results cannot resurrect deleted sessions. let cacheGeneration = 0 -// Why: an `unchanged` reply carries no rows, so without the last full list the -// TTL cache could only hold the empty marker and fingerprint-less readers (tab -// session lookup, other windows) would pay a full remote scan. +// Retain rows behind unchanged replies for readers without fingerprints. const lastFullHostLegs = new Map< string, { depth: AiVaultSessionDepth; result: AiVaultListResult } @@ -53,9 +45,7 @@ function scheduleHostLegExpiry(): void { expiryTimer.unref?.() } -/** Serves one host's leg from the per-host TTL cache, and stores it only when - * that host answered without a host issue — a failing host is retried on the - * next open while its healthy neighbours stay cached. */ +/** Cache only healthy legs so failing hosts retry without rescanning their neighbors. */ export async function scanHostLegWithCache(args: { cacheKey: string depth: AiVaultSessionDepth @@ -78,8 +68,7 @@ export async function scanHostLegWithCache(args: { return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths) } const result = await args.scan() - // A delete's invalidation landed while this leg was scanning; caching a - // pre-delete result would resurrect the deleted session for the TTL. + // A pre-delete scan must not repopulate the invalidated cache. if (startGeneration !== cacheGeneration) { return result } @@ -111,7 +100,6 @@ export async function scanHostLegWithCache(args: { return result } -/** Returns the full list an `unchanged` reply stands for when one is known. */ function rememberFullHostLeg( cacheKey: string, depth: AiVaultSessionDepth, @@ -145,10 +133,7 @@ function pruneExpiredHostLegs(now: number): void { } } -// Drops every cached host leg after a session is deleted so a non-force list -// call within the TTL can't still serve the trashed session. Scan discovery -// walks disk first, so a deleted file is never rediscovered — this only guards -// the cached RESULT. +// Drop cached results after deletion; disk discovery already excludes the deleted file. export function invalidateAiVaultHostLegCache(): void { cacheGeneration++ cachedHostLegs.clear() diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts index 1cb6863366b..721de555dba 100644 --- a/src/main/ipc/ai-vault-runtime-scan.ts +++ b/src/main/ipc/ai-vault-runtime-scan.ts @@ -26,10 +26,7 @@ export type RuntimeAiVaultScanner = ( options?: RuntimeAiVaultScanOptions ) => Promise -/** - * Why: an unreachable Orca server must cost this host's row, not the whole - * multi-host list, so every failure except cancellation degrades to an issue. - */ +/** Degrade host failures to issues; cancellation still propagates. */ export async function scanRuntimeAiVaultSessions(args: { hostInfo: RuntimeAiVaultHostInfo scanner: RuntimeAiVaultScanner | undefined @@ -63,8 +60,7 @@ export async function scanRuntimeAiVaultSessions(args: { } } -// Optional keys are copied only when present so the RPC schema never sees an -// explicit undefined for limit/force/scopePaths. +// Omit absent optional keys rather than sending undefined through the RPC schema. function runtimeScanArgs( hostInfo: RuntimeAiVaultHostInfo, listArgs: AiVaultListArgs | undefined diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index 49e86a7f9a8..cb810d60821 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -108,8 +108,7 @@ describe('Agent Session History scan coalescing', () => { registerAiVaultHandlers() const list = ipcHandler('aiVault:listSessions') - // The local leg degrades like the SSH legs above: a rejection reaches the - // renderer as a raw string painted over the list instead of an issue row. + // Like SSH failures, local failures must become issue rows instead of rejected IPC calls. const result = await list( { sender: Object.assign(new EventEmitter(), { id: 1 }) }, { executionHostScope: 'local', requestToken: 'scan' } diff --git a/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts b/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts index 1d26150e520..0484e633125 100644 --- a/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts +++ b/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts @@ -13,11 +13,7 @@ import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import type { ExecutionHostId } from '../../shared/execution-host' import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' -/** - * Three real indexes over real transcripts, wired as three legs. Everything a - * merged page claims — every hit exactly once, a purge fencing one host, an - * unreachable host retried — is checked against the hit sets the indexes hold. - */ +/** Verify merged hit sets, purge fencing and retries against three independent real transcript indexes. */ const HOST_IDS = ['local', 'ssh:alpha', 'ssh:beta'] as const const SESSIONS_PER_HOST = [14, 13, 13] as const @@ -39,8 +35,7 @@ async function writeSession(harness: SessionSearchIndexerHarness, index: number) const sessionId = sessionIdFor(index) const path = join(harness.claudeProjectDir, `${sessionId}.jsonl`) await mkdir(harness.claudeProjectDir, { recursive: true }) - // A distinct start index per session gives every hit in the fixture its own - // `updatedAt`, so the newest-first order across hosts is total. + // Give every hit a distinct timestamp to make cross-host ordering total. const lines = claudeLines([`needle transcript ${index}`], sessionId, index * 2) await writeFile(path, `${lines.join('\n')}\n`) return sessionId @@ -64,8 +59,7 @@ beforeEach(async () => { throw error } }) - // One process holds one index, so the transcript reader publishes every read - // to every live consumer. Three machines means three indexes built alone. + // Build each host index alone: transcript reads publish to every live consumer in one process. instance.apply({ enabled: true, historyDays: null }) await instance.settled() instance.close() @@ -154,7 +148,6 @@ it('orders a newest merge by recency across hosts, newest first', async () => { ) const updated = response.hits.map((hit) => hit.updatedAt) expect(updated).toEqual([...updated].sort().toReversed()) - // The 20 newest of the 40 are the 20 highest session indexes, which span hosts. expect(new Set(response.hits.map((hit) => hit.executionHostId)).size).toBeGreaterThan(1) }) @@ -175,7 +168,6 @@ it('fences the purged host and keeps the other two paginating', async () => { const first = resultsOf(await searchAllExecutionHosts(request, legs())) expect(first.hosts?.every((host) => host.outcome === 'searched')).toBe(true) - // A real purge: the transcript is gone and a full reconcile publishes that. const purged = hosts[2]! await rm(join(purged.harness.claudeProjectDir, `${purged.sessionIds[0]!}.jsonl`)) await purged.instance.reconcile() @@ -196,11 +188,10 @@ it('fences the purged host and keeps the other two paginating', async () => { const page = resultsOf(await searchAllExecutionHosts({ ...request, cursor }, legs())) seen.push(...keysOf(page)) cursor = page.page.cursor - // A merge that never retires a host would page for ever; fail instead. + // Fail boundedly if a host is never retired. expect((pages += 1)).toBeLessThan(40) } - // Beta contributed only what it handed out before the purge; nothing repeats, - // and both healthy hosts finished their own hit sets. + // Purged beta retains only previously emitted hits; healthy hosts finish without duplicates. expect(new Set(seen).size).toBe(seen.length) const betaEmitted = keysOf(first).filter((key) => key.startsWith('ssh:beta/')) expect([...seen].sort()).toEqual( @@ -248,7 +239,7 @@ it('reports an unreachable host, keeps hasMore, and picks it up on the retry', a const page = resultsOf(await searchAllExecutionHosts({ ...request, cursor }, legs())) seen.push(...keysOf(page)) cursor = page.page.cursor - // A merge that never retires a host would page for ever; fail instead. + // Fail boundedly if a host is never retired. expect((pages += 1)).toBeLessThan(40) } expect(new Set(seen).size).toBe(seen.length) diff --git a/src/main/ipc/ai-vault-search-all-hosts.test.ts b/src/main/ipc/ai-vault-search-all-hosts.test.ts index 387bdf1df3e..d4e470fb457 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.test.ts +++ b/src/main/ipc/ai-vault-search-all-hosts.test.ts @@ -229,7 +229,6 @@ it('names an unavailable host by its reason without losing the other hosts', asy { executionHostId: 'ssh:off', outcome: 'disabled' } ]) expect(response.hits).toHaveLength(2) - // Nothing more is owed, so a disabled host does not hold the page open. expect(response.page.hasMore).toBe(false) }) @@ -258,7 +257,6 @@ it('calls a leg that times out unreachable and retries it on the next page', asy recovered.leg() ]) ) - // The healthy host finished, so only the retried host is still in the walk. expect(second.hosts).toEqual([{ executionHostId: 'ssh:slow', outcome: 'searched' }]) expect(second.hits.map((hit) => hit.sessionId)).toEqual(['s-0', 's-1']) } finally { @@ -267,8 +265,7 @@ it('calls a leg that times out unreachable and retries it on the next page', asy }) it('reads at most three pages from one host per merged request', async () => { - // Twelve hits behind pages of two is six host pages; the bound stops at three - // and the cursor carries the unread page so nothing is lost. + // Three-page budget stops a six-page host walk; retain its unread cursor for the next merged page. const host = new StubHost('local', sessions('a', 12), 2) const first = resultsOf( await searchAllExecutionHosts({ query: 'needle', limit: 10 }, [host.leg()]) diff --git a/src/main/ipc/ai-vault-search-all-hosts.ts b/src/main/ipc/ai-vault-search-all-hosts.ts index a2cddc72f05..3ee0d6ac746 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.ts +++ b/src/main/ipc/ai-vault-search-all-hosts.ts @@ -47,15 +47,7 @@ type HostWalk = { truncated: Truncation } -/** - * Fans one query out to every execution host and merges the pages into one. - * - * Relevance scores come from independent indexes and are not comparable, so the - * two orders are the only two that mean anything across hosts: recency, which - * every host can be asked for directly, and round-robin over each host's own - * ranking. Legs are walked page by page, so a hit that lost the cut on one page - * is emitted on the next instead of being dropped. - */ +/** Independent relevance scores cannot be compared: merge by recency or host round-robin, retaining page overflow. */ export async function searchAllExecutionHosts( request: AiVaultSearchRequest, legs: readonly SessionSearchHostLeg[] @@ -76,8 +68,7 @@ export async function searchAllExecutionHosts( return { kind: 'malformed-cursor' } } } - // A host absent from the cursor either finished or joined mid-walk; either way - // it contributes nothing to this page. Host-id order fixes every tiebreak. + // Finished or newly joined hosts contribute nothing to resumed walks; host ID fixes ties. const walks = legs .filter((leg) => !resumed || resumed.hosts[leg.executionHostId] !== undefined) .map((leg) => newHostWalk(leg, legRequest(request, sort))) @@ -139,14 +130,12 @@ async function fetchHostPage(walk: HostWalk, entry: MergedSearchCursorEntry | nu endHostWalk(walk, response.reason, false) return } - // A cursor this merge minted can only be refused because the host's index - // moved, so both refusals mean the same thing: this host is done for now. + // A refused minted cursor means the host index moved; retire its leg. if (response.kind !== 'results') { endHostWalk(walk, 'stale', false) return } - // `e` is an offset into one generation's ranked page, so it is only - // meaningful while that generation stands. Nothing emitted, nothing to fence. + // Emitted offsets are generation-specific; fence only after emitting hits. if (walk.emitted > 0 && walk.generation !== response.generation) { endHostWalk(walk, 'stale', false) return diff --git a/src/main/ipc/ai-vault-search-merged-cursor.ts b/src/main/ipc/ai-vault-search-merged-cursor.ts index 40f8aa88d40..e5c1efabd80 100644 --- a/src/main/ipc/ai-vault-search-merged-cursor.ts +++ b/src/main/ipc/ai-vault-search-merged-cursor.ts @@ -1,11 +1,6 @@ import { z } from 'zod' -/** - * One host's place in a merged walk. `c` is the host cursor that produced the - * page being consumed (null for that host's first page), `e` is how many of that - * page's hits the merge already emitted, and `g` is the host generation `e` - * counts into. Refetch with `c`, skip `e`, and no hit is skipped or repeated. - */ +/** Refetch host cursor c, skip e emitted hits within generation g; null c starts that host's walk. */ export type MergedSearchCursorEntry = { c: string | null e: number @@ -37,7 +32,7 @@ export function encodeMergedSearchCursor(cursor: MergedSearchCursor): string { return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') } -/** Null for anything that is not a cursor this module minted. */ +/** Reject malformed cursor shapes. */ export function decodeMergedSearchCursor(raw: string): MergedSearchCursor | null { let parsed: unknown try { diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index baa335e85cd..9f1a1a87873 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -46,7 +46,6 @@ export type AiVaultSearchHandlerOptions = { ensureStructuredSessionOwnership?: () => Promise } -// One wording with the session list, which refuses the same unroutable scope. const UNROUTABLE_HOST_MESSAGE = 'Agent Session History is not available for this execution host.' // Consent is written where the index lives: locally through settings, never here. const LOCAL_ENABLE_MESSAGE = @@ -83,10 +82,7 @@ export function registerAiVaultSearchHandlers(options: AiVaultSearchHandlerOptio ipcMain.handle('aiVault:clearSearchIndex', () => clearSessionSearchInService()) } -/** - * Only a paired runtime host can be toggled from here. The local index answers to this - * desktop's own settings write, and an SSH host has no method to carry the change. - */ +/** Only paired runtimes support toggling here; local consent belongs to Settings and SSH has no setter. */ async function setSearchEnabledByExecutionHost( scope: ParsedExecutionHost, enabled: boolean @@ -115,11 +111,7 @@ async function setSearchEnabledByExecutionHost( } } -/** - * Why not the list's `requestedExecutionHostScope`: it normalizes an unparseable - * id to `all`, which would answer an unroutable request by searching every host. - * Same parser, same omitted-means-this-host rule, but garbage is refused. - */ +/** Reject malformed host IDs; list-scope normalization to all would search unintended hosts. */ function requestedSearchScope(raw: unknown): ParsedExecutionHost { const value = scopeSchema.parse(raw) if (value === undefined) { @@ -157,11 +149,7 @@ async function searchLocalSessions(request: AiVaultSearchRequest): Promise { const call = handlerOptions.callRuntimeSearch if (!call) { @@ -222,7 +207,7 @@ async function runtimeHostStatus(environmentId: string): Promise { - // IPC payloads are untyped at runtime; malformed input resolves empty like - // every other rejected input instead of throwing. + // Untyped malformed IPC input follows the empty-result rejection contract. if ( !args || (args.agent !== 'claude' && args.agent !== 'omp') || @@ -24,17 +22,11 @@ export async function listAiVaultSubagentSessions( ) { return { sessions: [], issues: [] } } - // Why: subagent transcripts are read from the local filesystem. The UI - // skips remote sessions (their transcripts live on the remote host); return - // empty defensively rather than reading local paths for a remote session. + // Remote transcripts must never resolve through the local filesystem. if ((args.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !== LOCAL_EXECUTION_HOST_ID) { return { sessions: [], issues: [] } } - // Why: the path is renderer-supplied; only list files under the agent's - // known sessions roots so a crafted path can't readdir/preview arbitrary - // dirs. - // resolve() collapses `..` segments first — isPathInsideOrEqual compares - // textually and would otherwise pass `/../../etc/x.jsonl`. + // Resolve .. before textual root checks so renderer paths cannot escape the agent's session roots. const parentFilePath = resolve(args.parentFilePath) const wslHomeDirs = await getAiVaultWslHomeDirs() const roots = diff --git a/src/main/ipc/ai-vault-unresolved-owner.test.ts b/src/main/ipc/ai-vault-unresolved-owner.test.ts index 1afd37af3bf..32bffa935f9 100644 --- a/src/main/ipc/ai-vault-unresolved-owner.test.ts +++ b/src/main/ipc/ai-vault-unresolved-owner.test.ts @@ -1,8 +1,4 @@ -/** - * #18097: conflicting owner publications leave a worktree on the `runtime:unresolved-owner` - * sentinel. It parses as a runtime host, but no environment has that id, so AI Vault must answer - * it as unavailable instead of dialing a paired runtime. - */ +/** Unresolved-owner sentinels have no environment: return unavailable instead of dialing a paired runtime (#18097). */ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 4f669e82a78..66ba9af292d 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -33,9 +33,7 @@ vi.mock('../ai-vault/session-delete', () => ({ deleteAiVaultSessionFile: mocks.deleteAiVaultSessionFile })) -// Why: only the invalidation seam is replaced — everything else (cachedList, -// listAiVaultSessions, ...) keeps its real implementation so the existing -// host-routing/caching tests below stay exercising real behavior. +// Mock only invalidation so routing and cache tests retain the real list implementation. vi.mock('../ai-vault/cached-session-list', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 53dddff9daa..9c222bbcf7a 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -72,8 +72,7 @@ type AiVaultHandlerOptions = AiVaultSessionSources & let scanCoordinator = new AiVaultScanCoordinator() let handlerOptions: AiVaultHandlerOptions = {} const listCancellations = createSenderScopedRequestCancellations() -// Shared by the IPC registration and the test internals: a delete must drop -// the multi-host leg cache, which this module owns the only caller of. +// Keep IPC and test deletion wired to this module's multi-host cache. const aiVaultDeleteDeps = { invalidateMultiHostListCache: invalidateAiVaultHostLegCache } @@ -103,9 +102,7 @@ async function listAiVaultSessions( parseRoutableExecutionHostId(executionHostScope)?.kind === 'runtime' ? args?.afterFingerprint : undefined - // Why: every renderer request carries its own cancellation signal, so - // coalescing has to survive them — the coordinator hands all same-key callers - // one scan and only aborts it once every one of them has cancelled. + // Coalesced scans abort only after every same-key caller cancels. return scanCoordinator.run({ key: JSON.stringify({ key, depth, afterFingerprint }), force: args?.force, @@ -197,11 +194,7 @@ export function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryRe }) } -// Why: the SSH legs already degrade to an issue row so one bad host can't take -// the shared Promise.all down; the local leg can throw too (parse-cache load, -// WSL home resolution, scanner service supervision) and would otherwise discard -// every host's sessions under 'all', or replace the list with a raw error string -// under single-host scope. +// Like remote failures, local scanner/cache/WSL failures must degrade to issues without discarding healthy hosts. async function scanLocalAiVaultSessionsAsIssue( args: AiVaultListArgs | undefined, signal: AbortSignal | undefined @@ -212,8 +205,7 @@ async function scanLocalAiVaultSessionsAsIssue( if (isAiVaultScanCancelledError(error)) { throw error } - // Raw supervision text ("restart circuit is open") means nothing to a user, - // so the row carries actionable copy and the log keeps the original. + // Keep raw supervision details in logs; show an actionable host issue. const raw = error instanceof Error ? error.message : 'Local session scan failed.' console.error('[ai-vault] local session scan failed:', raw) const result = aiVaultScanIssueResult({ @@ -229,18 +221,13 @@ async function scanLocalAiVaultSessions( args?: AiVaultListArgs, signal?: AbortSignal ): Promise { - // Why: the shared cache module owns codex-home/WSL sourcing and the local - // scan cache, so the desktop IPC path and the runtime RPC method (mobile) - // share one cache instance and one source of managed-Codex homes. + // Share runtime/mobile cache and Codex/WSL source injection with desktop IPC. return listCachedLocalAiVaultSessions(args, { signal }) } export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options - // Why: configure the SAME shared cache module the runtime RPC method uses so - // there is exactly one cache instance and neither caller drops codex-home or - // WSL injection. The runtime also configures these sources from its deps - // (serve-mode reachable); this desktop path supplies the same source. + // Configure the same source/cache instance used by runtime RPC and headless serve. configureAiVaultSessionSources(options) ipcMain.handle('aiVault:listSessions', async (event, args?: AiVaultListArgs) => { const requestToken = @@ -255,8 +242,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo }) return projectStructuredAiVaultSessions(result, OPENS_EVERY_STRUCTURED_CHAT) } catch (error) { - // Why: superseding a scan is normal control flow, but Electron logs every - // rejected handler — report it as a result so the log stays truthful. + // Expected cancellation is a result so Electron does not log rejected IPC as an error. if (!isAiVaultScanCancelledError(error)) { throw error } diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index c0b249a0670..7904456e2ca 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -43,8 +43,7 @@ vi.mock('node:child_process', () => ({ spawn: spawnMock })) -// Fakes the detached `spawn` child: a stdout EventEmitter plus close/error -// events, so tests drive the async command lifecycle readCommandStdout expects. +// Fake detached child lets tests drive readCommandStdout's stdout and close/error lifecycle. function createFakeSpawnChild(options: { stdout?: string code?: number @@ -158,8 +157,7 @@ import { registerAppHandlers } from './app' describe('registerAppHandlers', () => { const originalPlatform = process.platform - // Why: readCommandStdout process-group-kills on timeout; stub the real signal - // so a fake child pid can never target a live process group during tests. + // Fake child PIDs must never target a live process group on timeout. let processKillSpy: ReturnType beforeEach(() => { @@ -455,11 +453,7 @@ describe('registerAppHandlers', () => { 'com.apple.inputmethod.SCIM.ITABC' ) expect(spawnMock).toHaveBeenCalledTimes(1) - // Why: macOS 15's `plutil -extract json` aborts on the input-source - // array, so the probe reads live cfprefsd via `defaults export` and dodges - // the bug with an xml1 extract before converting the clean subtree to JSON. - // Pin the exact pipeline (absolute paths, stdin markers) so dropping any - // stage silently regressing CJK detection to the fallback fails the test. + // macOS 15 plutil crashes extracting the array as JSON; pin defaults export, xml1 extraction and final JSON conversion. expect(spawnMock).toHaveBeenCalledWith( '/bin/sh', [ @@ -571,8 +565,7 @@ describe('registerAppHandlers', () => { expect(trustFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') }) - // Why: the renderer reads these on every Windows capability refresh; the sync probes - // execFileSync wsl.exe/pwsh.exe and would stall the main event loop for up to 5s each. + // Renderer capability refreshes must not block the main loop on synchronous wsl.exe/pwsh.exe probes. it('answers the Windows shell capability channels without a blocking spawn', async () => { registerAppHandlers({} as never) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 43678fc24c2..dbfe1049ac5 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -77,7 +77,7 @@ async function pickFloatingWorkspaceDirectory( ): Promise { const parentWindow = BrowserWindow.fromWebContents(event.sender) const options = { - // Why: this picker only chooses an existing directory; creation belongs to explicit file actions. + // Creation belongs to explicit file actions; this picker selects existing directories. properties: ['openDirectory'] } satisfies Electron.OpenDialogOptions const result = parentWindow @@ -87,7 +87,7 @@ async function pickFloatingWorkspaceDirectory( return null } const selectedDir = result.filePaths[0] - // Why: only a user-approved picker selection may become the floating terminal's cwd, unlike typed settings text. + // Only picker-approved paths may become the floating terminal cwd. await trustFloatingWorkspaceDirectory(store, selectedDir) return selectedDir } @@ -100,7 +100,7 @@ function getFeatureWallAssetBaseUrl(): string { if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) { const vitePath = assetDir.split(path.sep).join('/') const absoluteVitePath = vitePath.startsWith('/') ? vitePath : `/${vitePath}` - // Why: Chromium blocks file:// image loads from the http dev origin; Vite's /@fs route serves the same local media. + // Dev HTTP origins cannot load file images; serve media through Vite /@fs. return new URL(`/@fs${absoluteVitePath}/`, process.env.ELECTRON_RENDERER_URL).toString() } @@ -115,7 +115,7 @@ function resolveDevFeatureWallAssetDir(): string { path.join(process.cwd(), relativeDir) ] - // Why: E2E launches out/main, so app.getAppPath() can point there while dev resources live at the repo root. + // E2E app paths can be out/main while resources remain at the repository root. return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] } @@ -161,8 +161,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp } }) - // Why: these probes spawn wsl.exe/pwsh.exe; the sync variants would block the main event - // loop — every PTY message, window IPC and watchdog beat — for up to 5s per renderer read. + // Async probes avoid blocking PTY traffic and IPC for up to 5s per capability read. ipcMain.handle('wsl:isAvailable', (): Promise => isWslAvailableAsync()) ipcMain.handle('wsl:listDistros', (): Promise => listWslDistrosAsync()) ipcMain.handle('pwsh:isAvailable', (): Promise => isPwshAvailableAsync()) @@ -174,7 +173,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp return null } try { - // Why: async so the focus-in probe (see option-as-alt-probe.ts) never blocks the main event loop. + // Focus-in probes must not block the main loop. const stdout = await readKeyboardInputSourceId() const trimmed = stdout?.trim() ?? '' return trimmed.length > 0 ? trimmed : null @@ -187,11 +186,11 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('app:getKeyboardLayoutSnapshot', () => readMacKeyboardLayoutSnapshot()) ipcMain.handle('app:relaunch', async () => { - // Why: brief delay lets the renderer paint "Restarting…" before the window tears down. + // Delay teardown so the renderer can paint Restarting after cleanup. await runBeforeRelaunchCleanup(options.onBeforeRelaunch) await runBeforeRelaunchCleanup(options.onBeforeRelaunchExit) setTimeout(() => { - // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. + // app.exit skips before-quit; remove the Windows tray to avoid a stale icon. destroySystemTray() relaunchApp('renderer-request') app.exit(0) @@ -199,7 +198,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp }) ipcMain.handle('app:restart', async () => { - // Why: use the normal quit pipeline so daemon checkpoints and telemetry flush before exit. + // Normal quit flushes daemon checkpoints and telemetry. await runBeforeRelaunchCleanup(options.onBeforeRelaunch) setTimeout(() => { relaunchApp('admin-restart') diff --git a/src/main/ipc/automatic-load-path-text.ts b/src/main/ipc/automatic-load-path-text.ts index 9dd43c6e1bd..fb548190c2b 100644 --- a/src/main/ipc/automatic-load-path-text.ts +++ b/src/main/ipc/automatic-load-path-text.ts @@ -1,8 +1,7 @@ import { basename, sep } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' -// Why the path flavour, not the OS: these are Windows path rules, and tests exercise them with -// path.win32 on any host. +// Follow the injected path flavor so Windows rules are testable on every host. function usesWindowsPaths(): boolean { return sep === '\\' } @@ -10,10 +9,7 @@ function usesWindowsPaths(): boolean { const WINDOWS_RESERVED_DEVICE_STEM = /^(?:con|prn|aux|nul|conin\$|conout\$|clock\$|com[0-9\u00B9\u00B2\u00B3]|lpt[0-9\u00B9\u00B2\u00B3])$/i -/** - * `NUL.png`, `com1 .jpg`, `Aux.`, `NUL:stream.png` name a Windows device, not a file, whatever the - * extension or alternate data stream (`:`). - */ +/** Windows device names remain devices through extensions, trailing spaces/dots and alternate streams. */ export function isWindowsReservedDeviceName(filePath: string): boolean { if (!usesWindowsPaths()) { return false @@ -34,10 +30,7 @@ export function isDeviceNamespacePath(filePath: string): boolean { return usesWindowsPaths() && /^\\\\[?.]\\/.test(toBackslashes(filePath)) } -/** - * A network share (`\\host\share`). WSL paths are UNC in form but stay on this machine, so they - * are not network paths. - */ +/** WSL UNC paths stay on this machine and are excluded from network shares. */ export function isNetworkSharePath(filePath: string): boolean { if (!usesWindowsPaths() || isDeviceNamespacePath(filePath)) { return false diff --git a/src/main/ipc/automations-external-scope.test.ts b/src/main/ipc/automations-external-scope.test.ts index ce283ae7f7d..5914d32042e 100644 --- a/src/main/ipc/automations-external-scope.test.ts +++ b/src/main/ipc/automations-external-scope.test.ts @@ -22,12 +22,12 @@ vi.mock('electron', () => ({ } })) -// This computer's `which` lookup is the one process a scoped probe starts, so it counts probes. +// Each scoped probe performs one local which lookup. const probeCommand = vi.hoisted(() => vi.fn()) const fileExists = vi.hoisted(() => vi.fn(() => false)) const readRegistrations = vi.fn(() => state.targets) vi.mock('@orca/process-host', () => ({ runProcess: probeCommand })) -// Why: keep the local job read off the developer's real ~/.openclaw and ~/.openclaw. +// Isolate external job reads from the developer's real home. vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()), existsSync: fileExists @@ -148,7 +148,7 @@ describe('scoped external-manager IPC', () => { target: { type: 'local' } }) - // Why: an SSH owner is never served here, and a payload target must not reroute it locally. + // An SSH owner cannot be rerouted locally by a payload target. expect(entry).toMatchObject({ manager: null, error: SSH_HOST_NOT_SERVED_MESSAGE }) expect(probeCommand).not.toHaveBeenCalled() }) @@ -249,8 +249,7 @@ describe('probe scope retention', () => { describe('Orca automation traffic priority', () => { it('parks queued probes while Orca automation work holds the installed lease', async () => { - // Orca CRUD and dispatch arrive through the runtime methods, which take the - // lease through the hook this registration installed on the service. + // Runtime CRUD/dispatch takes the lease installed on the service by registration. const lease = state.service.externalProbePriority expect(lease).not.toBeNull() let finish: () => void = () => undefined @@ -266,8 +265,7 @@ describe('Orca automation traffic priority', () => { provider: 'openclaw' }) await Promise.resolve() - // Why: the probe is queued, not started — this is the only thing keeping - // external discovery from competing with the work the user is waiting on. + // Queued probes must not compete with pending user-facing work. expect(probeCommand).not.toHaveBeenCalled() finish() diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index 4e0559b4bae..1814e7f88e1 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -21,11 +21,7 @@ import type { ScopedExternalManagerListRequest } from '../../shared/external-automation-scope' -/** - * Fail closed: an external request with no captured owner is refused rather than - * resolved against whichever host happens to be active. Malformed input keeps - * the engine's plain-Error convention; it is not an ownership *conflict*. - */ +/** Refuse uncaptured owners instead of using the active host; malformed input remains a plain Error, not an ownership conflict. */ function requireCapturedOwner( request: T | null | undefined ): T { @@ -36,11 +32,7 @@ function requireCapturedOwner( return request } -/** - * Holds the probe pool's priority lease for the duration of Orca's own automation - * work. Without this, a queued external probe competes with the list and mutation - * traffic the user is actually waiting on. - */ +/** Prioritize Orca work over queued external probes for the full operation lifetime. */ function underOrcaPriority(scheduler: ExternalAutomationProbeScheduler, run: () => T): T { const release = scheduler.beginPriorityWork() let pending = false @@ -59,22 +51,18 @@ function underOrcaPriority(scheduler: ExternalAutomationProbeScheduler, run: } export function registerAutomationHandlers(store: Store, service: AutomationService): void { - // One long-lived pair per registration: the cache TTL and the probe pool's - // concurrency ceiling only mean anything if they outlive a single request. + // Cache TTL and probe concurrency need registration-long state, not per-request instances. const probeScheduler = new ExternalAutomationProbeScheduler() const managerCache = new ExternalAutomationManagerCache() const scopedExternal = createScopedExternalAutomations({ - // Hidden runtime-owned targets are passed through on purpose: the guard - // rejects them itself, so pre-filtering here would leak a different error. + // Let the guard reject hidden targets so filtering cannot change its error contract. registry: { getSshTargets: () => store.getSshTargets() }, scheduler: probeScheduler, cache: managerCache }) - // Why: Orca automation CRUD now arrives over the local runtime RPC surface, - // so the runtime methods take the lease through this hook instead of an arm here. + // Runtime RPC CRUD takes the service-installed priority lease. service.externalProbePriority = (run) => underOrcaPriority(probeScheduler, run) - // Scoped external-manager surface: one captured desktop owner in, one host's - // managers out. The target and manager ID are derived inside the guard. + // Guard derives one host and manager ID from the captured owner. ipcMain.handle( 'automations:listExternalManagerForOwner', ( @@ -88,9 +76,7 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ (_event, request: ScopedExternalManagerActionRequest): Promise => scopedExternal.runAction(requireCapturedOwner(request)) ) - // Why: probes for hosts the user is no longer viewing are cancelled, not left - // to finish. An empty list retains nothing, which is the correct fail-closed - // state for a renderer with no desktop scope selected. + // Cancel scopes no longer viewed; an empty retained set cancels all. ipcMain.handle( 'automations:retainExternalScopes', (_event, request?: { owners?: readonly AutomationOwnerRef[] } | null): void => { diff --git a/src/main/ipc/bitbucket.ts b/src/main/ipc/bitbucket.ts index 0a9d07838f5..34b306c298d 100644 --- a/src/main/ipc/bitbucket.ts +++ b/src/main/ipc/bitbucket.ts @@ -40,8 +40,7 @@ export function registerBitbucketHandlers(): void { } const result = await connectBitbucket(input) if (result.ok) { - // Preflight caches source-control status per session; reset so the card - // reflects the new connection without a relaunch. + // Refresh cached source-control preflight so reconnection needs no relaunch. _resetPreflightCache() } return result diff --git a/src/main/ipc/bounded-warning-dedupe.ts b/src/main/ipc/bounded-warning-dedupe.ts index 244edcaa0ab..57fc4027c2b 100644 --- a/src/main/ipc/bounded-warning-dedupe.ts +++ b/src/main/ipc/bounded-warning-dedupe.ts @@ -1,7 +1,6 @@ export const DEFAULT_WARNING_DEDUPE_MAX_KEYS = 256 -// Why: diagnostic "warn once" keys often include dynamic paths/providers; keep -// repeated warnings quiet without retaining every stale key forever. +// Bound dynamic path/provider keys while suppressing repeated diagnostics. export function shouldEmitBoundedWarning( warningKeys: Set, key: string, @@ -10,7 +9,7 @@ export function shouldEmitBoundedWarning( if (warningKeys.has(key)) { return false } - // Why: evicting during a stable max+1 scan cascades into re-emitting every warning. + // Eviction during repeated max+1 scans would re-emit every warning. if (warningKeys.size >= maxKeys) { return true } diff --git a/src/main/ipc/browser-client-page-metadata-ipc.test.ts b/src/main/ipc/browser-client-page-metadata-ipc.test.ts index 71be159bd19..c12fcad8f52 100644 --- a/src/main/ipc/browser-client-page-metadata-ipc.test.ts +++ b/src/main/ipc/browser-client-page-metadata-ipc.test.ts @@ -76,8 +76,7 @@ describe('browser:publishClientPageMetadata', () => { ).resolves.toEqual({ status: 'published', accepted: false }) }) - // Why the trust check earns a test: this hands a renderer-supplied page identity straight to a - // paired runtime's host lease, so an untrusted sender must not reach the lease at all. + // Untrusted renderer identities must not reach the paired runtime lease. it('refuses an untrusted renderer before touching the lease', async () => { mocks.isTrusted.mockReturnValue(false) diff --git a/src/main/ipc/browser-grab-ipc.ts b/src/main/ipc/browser-grab-ipc.ts index 04531acd986..2d23f6e20d2 100644 --- a/src/main/ipc/browser-grab-ipc.ts +++ b/src/main/ipc/browser-grab-ipc.ts @@ -39,28 +39,23 @@ function queueGrabModeOperation( export function resetGrabModeState(): void { grabModeIntentByPageId.clear() - // Why: a stale in-flight chain from a prior registration would block new operations forever. + // Retired registrations must not leave chains blocking new operations. grabModeOperationByPageId.clear() } export function disposeGrabModeStateForPage(browserPageId: string): void { grabModeIntentByPageId.delete(browserPageId) - // Why: don't let a reused browserPageId queue behind the destroyed guest's pending chain. + // Reused page IDs must not queue behind destroyed guests. grabModeOperationByPageId.delete(browserPageId) } -/** - * Why previews need their own disposal path: a browser page announces its own death on - * `browser:unregisterGuest`, and a preview never does — it withdraws by revoking its grant, which - * is also what a re-mint does. Without this, every grant leaves an intent entry behind forever. - */ +// Previews withdraw through grant revocation, not unregisterGuest; dispose their retained intent then. let disposePreviewGrantSubscription: (() => void) | null = null function subscribeToPreviewGrantRevocation(): void { disposePreviewGrantSubscription?.() disposePreviewGrantSubscription = onDocPreviewGrantRevoked((grant) => { - // Why cancel first: a grab still armed on that guest would otherwise leave the renderer's - // await hanging on a surface the reader has already closed. + // Settle pending grabs before disposing the guest's intent. browserManager.cancelGrabOp(grant.browserPageId, 'evicted') disposeGrabModeStateForPage(grant.browserPageId) }) @@ -89,7 +84,7 @@ export function registerBrowserGrabHandlers(): void { grabModeIntentByPageId.get(args.browserPageId) === intent let guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) if (!guest && args.enabled) { - // Why: fast file:// pages can expose the toolbar before did-attach registration reaches main. + // Fast file pages can expose tools before guest registration reaches main. await waitForNextTabRegistration(args.browserPageId, GRAB_REGISTRATION_WAIT_MS).catch( () => {} ) @@ -131,10 +126,7 @@ export function registerBrowserGrabHandlers(): void { if (!guest) { return { opId: args.opId, kind: 'error', reason: 'Guest not ready' } } - // Why: no hasActiveGrabOp guard here — awaitGrabSelection already handles - // the conflict by cancelling the previous op. Blocking at the IPC layer - // would create a race window where rearm() fails if the previous IPC call - // hasn't fully resolved yet. + // awaitGrabSelection cancels previous grabs; an extra active-op guard would race rearming. return browserManager.awaitGrabSelection(args.browserPageId, args.opId, guest) } ) @@ -143,8 +135,7 @@ export function registerBrowserGrabHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return false } - // Why: verify the sender actually owns this tab, consistent with the - // authorization check in setGrabMode/awaitGrabSelection/captureScreenshot. + // Resolve through sender ownership, as the other grab channels do. const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) if (!guest) { return false diff --git a/src/main/ipc/browser-guest-view-ipc.ts b/src/main/ipc/browser-guest-view-ipc.ts index 9401e5284e9..a5c628e289d 100644 --- a/src/main/ipc/browser-guest-view-ipc.ts +++ b/src/main/ipc/browser-guest-view-ipc.ts @@ -45,7 +45,7 @@ export function registerBrowserGuestViewHandlers(): void { ipcMain.removeHandler('browser:zoomPage') ipcMain.removeHandler('browser:setPageSiteColorScheme') - // Why the page registry only: these act on a browser page, never on a document preview. + // Page/session operations exclude document previews. const pageGuestId = (event: IpcMainInvokeEvent, browserPageId: unknown): number | null => isTrustedBrowserRenderer(event.sender) && typeof browserPageId === 'string' ? browserManager.getGuestWebContentsId(browserPageId) @@ -143,10 +143,7 @@ export function registerBrowserGuestViewHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return false } - // Why: CDP misbehaves on non-finite/negative metrics (NaN/Infinity can - // wedge Emulation.setDeviceMetricsOverride and leave the page in a - // broken state). Validate at the main-process trust boundary so a buggy - // or compromised renderer cannot corrupt CDP state. + // Reject non-finite or negative renderer metrics before they can wedge CDP emulation. if (args.override !== null) { const { width, height, deviceScaleFactor, mobile } = args.override const isFinitePositive = (n: unknown): n is number => @@ -219,16 +216,13 @@ export function registerBrowserGuestViewHandlers(): void { ) { return false } - // Why resolve here: this is a tool acting on a guest the reader is looking at, so it answers - // for a workspace document too — and routing through the authority also pins the request to - // the renderer that owns the target, which page-id-only resolution never checked. + // Tools may target documents too; authority resolution binds either guest to its owning renderer. const resolveGuest = (): Electron.WebContents | null => browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) if (!resolveGuest()) { return false } - // Why hand over the resolver rather than that guest: the op is serialized per tab, and the - // one it finally runs against must be the one on screen then, not the one this request saw. + // Queued operations must resolve the current guest when they run. return browserManager.setAnnotationViewportBridge( args.browserPageId, { @@ -243,9 +237,7 @@ export function registerBrowserGuestViewHandlers(): void { } ) - // Why this is not an ordinary runtime call: the runtime accepts page traffic only on the - // connection its browser-host lease attached on, and that lease lives here in main. A renderer - // publishing straight to the runtime is refused as a stale lease and the page's URL never moves. + // Publish through main's attached browser-host lease; direct renderer traffic would be refused as stale. ipcMain.handle( 'browser:publishClientPageMetadata', async ( diff --git a/src/main/ipc/browser-preview-tool-authorization.test.ts b/src/main/ipc/browser-preview-tool-authorization.test.ts index be37d5b8380..2602c755cf2 100644 --- a/src/main/ipc/browser-preview-tool-authorization.test.ts +++ b/src/main/ipc/browser-preview-tool-authorization.test.ts @@ -60,8 +60,7 @@ vi.mock('../browser/browser-manager', () => ({ } })) -// Why the real policy module behind a spy: the point of these tests is which half of the page -// registry a channel reads, so a stub of it would prove nothing about what was consulted. +// Spy on the real policy so registry selection remains observable. vi.mock('../browser/doc-preview-guest-policy', async (importOriginal) => { const actual = await importOriginal() return { @@ -73,8 +72,7 @@ vi.mock('../browser/doc-preview-guest-policy', async (importOriginal) => { } }) -// Why spy rather than stub: the wait is real machinery the browser-page path still depends on; -// only whether a preview target enters it is under test. +// Keep registration waits real; observe only which targets enter them. vi.mock('./browser-tab-registration-wait', async (importOriginal) => { const actual = await importOriginal() return { @@ -101,14 +99,10 @@ import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' const HOST_RENDERER_ID = 91 const OTHER_RENDERER_ID = 92 -/** Mirrors browser-grab-ipc's own wait, so elapsing it here settles the same parked request. */ +// Match browser-grab-ipc's registration timeout. const GRAB_REGISTRATION_WAIT_MS = 1_000 -/** - * Every `browser:*` invoke channel, split by whether it acts on a guest the reader is looking at - * (a tool) or manages browser-page, session and profile state. The split is asserted to be total, - * so a new channel cannot be added without deciding which side of the preview seam it belongs on. - */ +/** Exhaustively classify invoke channels as guest tools or browser/session/profile management. */ const TOOL_CHANNELS = [ 'browser:setGrabMode', 'browser:awaitGrabSelection', @@ -192,13 +186,11 @@ function grantForNewDocPage(): { id: string; browserPageId: string } { return { id: grant.id, browserPageId } } -/** The fake WebContents a preview's policy installs onto; tools are matched against its identity. */ type PreviewGuestContents = { isDestroyed: () => boolean getURL: () => string } -/** A preview guest already showing its document, which is the only state a tool can act in. */ function renderPreviewForGrant( grant: { id: string; browserPageId: string }, hostId: number = HOST_RENDERER_ID @@ -213,8 +205,7 @@ function renderPreviewForGrant( const register = (event: string, handler: (...args: never[]) => void): void => { handlers[event] = handler } - // Why the guest already reports its URL: the embedder hands a preview over mid-load, so this is - // the state the policy really installs into. + // Policy attaches mid-load, when the guest already reports its document URL. const documentUrl = buildDocPreviewUrl(grant.id, 'index.html') let contentsDestroyed = false const guest = { @@ -232,8 +223,7 @@ function renderPreviewForGrant( grantId: grant.id, browserPageId, contents: guest, - // Why without the `destroyed` event: Chromium tears the contents down before main runs that - // listener, so this is the window the authority has to answer for on its own. + // Contents can be destroyed before Chromium delivers the destroyed event. markContentsDestroyed: () => { contentsDestroyed = true } @@ -268,7 +258,7 @@ function toolArgs(channel: string, browserPageId: string): Record { vi.stubEnv('ELECTRON_RENDERER_URL', '') - // Why before clearing: revoking the previous test's grants disposes their page state through the - // mocked manager, and those calls belong to that test, not this one. + // Revoke before clearing mocks so disposal calls belong to the previous test. revokeAllDocPreviewGrants() vi.clearAllMocks() - // Why the mocked manager still answers for documents: this file's subject is which channels may - // reach a document guest, not how the manager splits its registry — that lives in - // browser-manager-guest-policy-profile.test.ts, against the real manager. + // This suite checks channel authorization; real manager registry splitting is covered separately. getAuthorizedGuestMock.mockImplementation((browserPageId: string, senderWebContentsId: number) => getWorkspaceDocPageGuest(browserPageId, senderWebContentsId) ) @@ -299,9 +286,7 @@ beforeEach(() => { extractHoverPayloadMock.mockResolvedValue(null) setAnnotationViewportBridgeMock.mockResolvedValue(true) registerBrowserHandlers() - // Why fake timers for the whole file: a tool asking for a page whose guest has not attached parks - // in the registration wait, and several cases here are exactly the ones that never attach. Real - // time would spend that wait for each of them. + // Fake timers settle registration waits for guests that never attach. vi.useFakeTimers() }) @@ -309,7 +294,6 @@ afterEach(() => { vi.useRealTimers() }) -/** Settles a tool request, elapsing the registration wait it may be parked in. */ async function settle(pending: Promise | T | undefined): Promise { await vi.advanceTimersByTimeAsync(GRAB_REGISTRATION_WAIT_MS) return pending @@ -329,17 +313,14 @@ describe('doc preview tool authorization', () => { await handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId)) - // Why assert on the guest and not on a return value: the whole point of the seam is which - // WebContents the tool ends up acting on. + // Assert the actual WebContents receiving the tool operation. const receivedGuest = GUEST_RECEIVING_MOCKS.some((mock) => mock.mock.calls.some((args) => args.some((arg) => resolvesToGuest(arg, preview.contents))) ) expect(receivedGuest || cancelGrabOpMock.mock.calls.length > 0).toBe(true) }) - // The load-bearing containment claim: page and session management never reads the document half - // of the registry, so a document page can never be resolved as, or managed like, a browsing one. - // Nothing here is a per-channel guard — a document guest is simply not in the map these read. + // Browser/session management must never consult the document registry. it.each(BROWSER_PAGE_CHANNELS)('never reads the document registry from %s', async (channel) => { const preview = liveRenderedPreview() const handler = registeredHandlers().get(channel) @@ -385,8 +366,7 @@ describe('doc preview tool authorization', () => { } }) - // Why the contents check has to be its own condition: the grant is still live and the sender is - // still the host, so nothing else in the authority notices that the guest is gone. + // A live grant and trusted sender are insufficient once contents are destroyed. it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => { const preview = liveRenderedPreview() preview.markContentsDestroyed() @@ -401,9 +381,7 @@ describe('doc preview tool authorization', () => { } }) - // Why this inverts what the split registries used to assert: a document page now registers under - // its own id, so the wait is reachable and useful — a tool opened while the guest is still - // attaching parks here instead of answering not-ready at the reader. + // Document tools wait for their own attaching guest instead of returning not-ready immediately. it('waits for a document page whose guest has not attached yet, and arms when it does', async () => { const grant = grantForNewDocPage() const handler = registeredHandlers().get('browser:setGrabMode') @@ -421,8 +399,7 @@ describe('doc preview tool authorization', () => { expect(setGrabModeMock).toHaveBeenCalledWith(grant.browserPageId, true, preview.contents) }) - // The absence half, with the same presence precondition: a page nothing ever renders still gives - // the reader an answer rather than hanging on the full timeout. + // Unrendered documents must eventually answer not-ready. it('answers not-ready once the wait for an unrendered page elapses', async () => { const handler = registeredHandlers().get('browser:setGrabMode') @@ -449,8 +426,7 @@ describe('doc preview tool authorization', () => { expect(registrationWaitSpy).toHaveBeenCalledWith('browser-page-1') }) - // Why the grant and not the guest: a preview withdraws by revoking, which is also what a - // re-mint does, and nothing else tells main that this tool target will never be used again. + // Revocation, including re-minting, is how previews withdraw and release retained tool state. it('disposes the grab state a preview target accumulated when its grant is revoked', async () => { const preview = liveRenderedPreview() const handler = registeredHandlers().get('browser:setGrabMode') @@ -465,9 +441,7 @@ describe('doc preview tool authorization', () => { expect(cancelGrabOpMock).toHaveBeenCalledWith(preview.browserPageId, 'evicted') }) - // Why both halves of this door: the manager refuses a preview id on its own, but the grab - // disposal beside it takes a renderer-supplied id, and the intent it drops is compared by - // identity — dropping it makes the grab settle ok without ever arming the guest. + // unregisterGuest must not drop preview intent and silently settle its grab without arming. it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => { const preview = liveRenderedPreview() const handlers = registeredHandlers() @@ -476,22 +450,20 @@ describe('doc preview tool authorization', () => { enabled: true }) - // Why synchronously here: the intent is recorded before the queued operation runs, so this is - // the exact window in which a disposal at this door would be invisible to the caller. + // Intent exists before the queued operation runs; test disposal in that gap. const unregistered = handlers.get('browser:unregisterGuest')?.( trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId } ) - // Why the guest and not the result: a dropped intent settles as ok either way, so only the - // guest actually being driven separates an armed grab from a silent no-op. + // A dropped intent also returns ok; only the driven guest proves the grab armed. await expect(pending).resolves.toEqual({ ok: true }) expect(setGrabModeMock).toHaveBeenCalledWith(preview.browserPageId, true, preview.contents) expect(unregistered).toBe(false) expect(vi.mocked(browserManager.unregisterGuest)).not.toHaveBeenCalled() }) - // The converse, so the guard above cannot be widened into a door that stops closing tabs. + // The same guard must still allow browser tabs to dispose their grabs. it('still disposes a browser page grab through the same door', async () => { getAuthorizedGuestMock.mockReturnValue({ isDestroyed: () => false }) const handlers = registeredHandlers() @@ -511,8 +483,7 @@ describe('doc preview tool authorization', () => { expect(vi.mocked(browserManager.unregisterGuest)).toHaveBeenCalledWith('browser-page-1') }) - // Why with a live preview standing beside it: the halves are looked up in one door now, so the - // thing to prove is that a browsing id is not answered by whatever document happens to be open. + // An open document must not answer for a browsing page ID. it('never hands a browsing page id the guest of a document that is open', async () => { const preview = liveRenderedPreview() const handler = registeredHandlers().get('browser:extractHoverPayload') @@ -522,7 +493,6 @@ describe('doc preview tool authorization', () => { expect(getAuthorizedGuestMock).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID) expect(previewAuthoritySpy).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID) expect(extractHoverPayloadMock).not.toHaveBeenCalled() - // The presence half: the same channel does reach that guest under the page it really renders. await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId }) expect(extractHoverPayloadMock).toHaveBeenCalledWith(preview.browserPageId, preview.contents) }) diff --git a/src/main/ipc/browser-session-profile-ipc.test.ts b/src/main/ipc/browser-session-profile-ipc.test.ts index 0ae2a9f5181..1e261a56f97 100644 --- a/src/main/ipc/browser-session-profile-ipc.test.ts +++ b/src/main/ipc/browser-session-profile-ipc.test.ts @@ -85,9 +85,7 @@ describe('browser session profile IPC', () => { return handleMock.mock.calls.find(([channel]) => channel === 'browser:identity:set')?.[1] } - // Why reject rather than coerce: the RPC door validates mode against z.enum(['clean','native']) - // and rejects. Coercing an unrecognized value to 'clean' here made one concept answer an unknown - // value two different ways, and reported success for a mode that was quietly replaced. + // Unknown modes must reject consistently with RPC instead of silently selecting clean. it('refuses an unrecognized identity mode instead of silently selecting Cleaned', async () => { setTrustedBrowserRendererWebContentsId(91) const handler = identitySetHandler() @@ -114,8 +112,7 @@ describe('browser session profile IPC', () => { )?.[1] } - // Why: the import runs wherever the pages are hosted, so the picker must be sourced from the same - // machine — a remote-sourced list is either empty (headless) or names profiles this desktop lacks. + // Client-hosted import must list browsers on this desktop, not the headless remote. it('detects this desktop’s browsers only while the environment is client-hosted', () => { detectBrowsersMock.mockReturnValue([ { diff --git a/src/main/ipc/browser-session-profile-ipc.ts b/src/main/ipc/browser-session-profile-ipc.ts index 78725c749c7..6bdea4842de 100644 --- a/src/main/ipc/browser-session-profile-ipc.ts +++ b/src/main/ipc/browser-session-profile-ipc.ts @@ -83,10 +83,7 @@ export function registerBrowserSessionProfileHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return null } - // Why reject rather than coerce: the RPC door validates against z.enum(['clean', 'native']) - // and rejects. Coercing an unrecognized value to 'clean' made one concept answer an unknown - // value two different ways, and reported success for a mode that was quietly replaced — - // silently downgrading a future mode name the caller believed was honoured. + // Match RPC rejection: silently coercing future modes would report false success. if (mode !== 'clean' && mode !== 'native') { throw new Error(`Unsupported browser identity mode: ${String(mode)}`) } @@ -197,8 +194,7 @@ export function registerBrowserSessionProfileHandlers(): void { ipcMain.removeHandler('browser:session:importFromBrowser') ipcMain.removeHandler('browser:session:importFromBrowserForClientHost') - // Why: client-hosted pages render on this desktop, so their logins must be - // detected and imported here -- the remote runtime is usually headless. + // Client-hosted pages import this desktop's logins; the remote runtime is usually headless. ipcMain.handle( 'browser:session:importFromBrowserForClientHost', async ( @@ -224,8 +220,7 @@ export function registerBrowserSessionProfileHandlers(): void { ipcMain.removeHandler('browser:session:clientRouteImportSources') - // Why: the server's profile records can't know what this desktop imported into - // its client-hosted jars; the settings view overlays these onto the RPC list. + // Overlay desktop imports unavailable to the server's profile records. ipcMain.handle( 'browser:session:clientRouteImportSources', (event, args: { environmentId: string }) => { @@ -243,8 +238,7 @@ export function registerBrowserSessionProfileHandlers(): void { return detectedBrowserPickerEntries() }) - // Why: the picker must list the machine the import will actually read from, and for a - // client-hosted environment that is this desktop — never the (usually headless) remote. + // List browsers on the machine whose cookie store import will read. ipcMain.handle( 'browser:session:detectBrowsersForClientHost', (event, args: { environmentId: string }): DetectedBrowserPickerEntry[] | null => { @@ -272,9 +266,7 @@ export function registerBrowserSessionProfileHandlers(): void { return { ok: false, reason: 'Session profile not found.' } } - // Why: browserProfile comes from the renderer and is used to construct - // a filesystem path. Reject traversal characters to prevent a compromised - // renderer from reading arbitrary files via the cookie import pipeline. + // Renderer profile names become paths; reject traversal before cookie import. if ( args.browserProfile && (/[/\\]/.test(args.browserProfile) || args.browserProfile.includes('..')) @@ -288,8 +280,6 @@ export function registerBrowserSessionProfileHandlers(): void { return { ok: false, reason: 'Browser not found on this system.' } } - // Why: if the user selected a non-default profile from the picker, - // resolve the cookies path for that specific profile. if (args.browserProfile && args.browserProfile !== browser.selectedProfile) { const reselected = selectBrowserProfile(browser, args.browserProfile) if (!reselected) { @@ -325,9 +315,7 @@ type DetectedBrowserPickerEntry = { selectedProfile: string } -// Why: the renderer only needs family/label/profiles for the UI picker. Strip cookiesPath, -// keychainService, and keychainAccount to avoid exposing filesystem paths and credential store -// identifiers to the renderer. +// Return picker metadata only; keep cookie paths and keychain identifiers in main. function detectedBrowserPickerEntries(): DetectedBrowserPickerEntry[] { return detectInstalledBrowsers().map((browser) => ({ family: browser.family, diff --git a/src/main/ipc/browser-tab-registration-wait.test.ts b/src/main/ipc/browser-tab-registration-wait.test.ts index bd498109eed..8d8598e866a 100644 --- a/src/main/ipc/browser-tab-registration-wait.test.ts +++ b/src/main/ipc/browser-tab-registration-wait.test.ts @@ -30,7 +30,6 @@ import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' const WAIT_MS = 1_000 -/** Registers a document guest the way the attach door does, which is what notifies the waiters. */ function attachDocumentGuest(browserPageId: string): void { const grant = mintDocPreviewGrant({ owner: { kind: 'ssh', connectionId: 'ssh-1' }, @@ -50,7 +49,6 @@ function attachDocumentGuest(browserPageId: string): void { installDocPreviewGuestPolicy(guest as never, { id: 91, send: vi.fn() }) } -/** Whether a wait has settled, without letting its rejection escape as an unhandled one. */ function track(pending: Promise): () => 'pending' | 'resolved' | 'rejected' { let state: 'pending' | 'resolved' | 'rejected' = 'pending' pending.then( @@ -86,9 +84,7 @@ describe('a workspace document registering', () => { expect(settled()).toBe('resolved') }) - // Why these two stay pending: they are how the CLI and agents ask for a browser tab to drive, and - // a preview is neither drivable by them nor visible to them. Satisfying either would hand the - // caller a surface that answers nothing, instead of letting it keep waiting for a real tab. + // CLI/agent any-tab waits need drivable browser tabs; documents satisfy only their own page wait. it('leaves the waits that ask for any browser tab still waiting', async () => { const worktreeWait = track(waitForWorktreeTabRegistration('wt-1', WAIT_MS)) const anyWait = track(waitForAnyTabRegistration(WAIT_MS)) @@ -99,8 +95,7 @@ describe('a workspace document registering', () => { expect(worktreeWait()).toBe('pending') expect(anyWait()).toBe('pending') - // The presence half: those waits do settle, so a mutant that never resolves them would pass the - // assertions above by being uniformly stuck. + // Verify unsatisfied waits actually time out. await vi.advanceTimersByTimeAsync(WAIT_MS) expect(worktreeWait()).toBe('rejected') expect(anyWait()).toBe('rejected') diff --git a/src/main/ipc/browser-tab-registration-wait.ts b/src/main/ipc/browser-tab-registration-wait.ts index 9055a66f58c..0881781781c 100644 --- a/src/main/ipc/browser-tab-registration-wait.ts +++ b/src/main/ipc/browser-tab-registration-wait.ts @@ -2,10 +2,7 @@ import { webContents } from 'electron' import { browserManager } from '../browser/browser-manager' import { onWorkspaceDocGuestRegistered } from '../browser/doc-preview-guest-policy' -// Why: CLI-driven tab creation must wait until the renderer mounts the webview -// and calls registerGuest, so the tab has a webContentsId and is operable by -// subsequent commands. Multiple commands can wait for the same page during -// startup, so keep all one-shot resolvers keyed by browserPageId. +// CLI commands wait for mounted guest registration; multiple startup callers may wait on one page. const pendingTabRegistrations = new Map void>>() const pendingWorktreeTabRegistrations = new Map void>>() const pendingAnyTabRegistrations = new Set<() => void>() @@ -110,12 +107,7 @@ export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise { return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {}) } -/** - * Why a document page resolves only its own waiters: the worktree-wide and any-tab waits are how - * the CLI and agents ask for a browser tab to drive, and a preview is neither drivable by them nor - * visible to them. Only a request already naming this page — a tool the reader opened on the - * document — is waiting for this. - */ +// Documents satisfy only page-specific tools, never CLI/agent waits for a drivable browser tab. onWorkspaceDocGuestRegistered((browserPageId) => { const pendingResolves = pendingTabRegistrations.get(browserPageId) pendingTabRegistrations.delete(browserPageId) diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index e8548289fe7..d506e9961e4 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -829,16 +829,12 @@ describe('registerBrowserHandlers', () => { markers: [], token: 'annotationviewporttoken' }, - // Why a resolver and not the guest: the op is serialized per page, so it has to read the - // registry when it runs — a navigation while it waited may have swapped the contents. - // Which guest it then resolves is pinned in browser-manager-annotation-bridge.test.ts. + // Resolve at execution time because queued navigation may replace the guest. expect.any(Function) ) }) - // Why this is new: the channel used to hand a page id straight to the manager, so any trusted - // renderer could drive any page's guest. It now resolves through the same authority the grab - // channels use, which pins the request to the renderer that registered the page. + // Annotation tools must enforce the same renderer ownership as grab tools. it('refuses an annotation viewport bridge request from a renderer that does not own the page', async () => { registerBrowserHandlers() getAuthorizedGuestMock.mockReturnValue(null) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index a28e56a4bc4..c1b7c62437b 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -80,10 +80,7 @@ export function registerBrowserHandlers(): void { } browserManager.attachGuestPolicies(guest) } - // Why: when Chromium swaps a guest's renderer process (navigation, - // crash recovery), the renderer re-registers the same browserPageId - // with a new webContentsId. The bridge must destroy the old session's - // proxy (its webContents is gone) and let the next command recreate it. + // Guest process swaps require retiring the old proxy; the next command recreates it. const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId) const registered = browserManager.registerGuest({ ...args, @@ -103,8 +100,7 @@ export function registerBrowserHandlers(): void { registerGuest(event, args, false) ) - // Why: an SSH workspace's page may only mount on a proxy-verified partition, - // so the renderer asks main to prepare it and blocks the webview until then. + // Block SSH webview mounting until its proxy-verified partition is ready. ipcMain.handle( 'browser:prepareSshWorkspacePartition', async ( @@ -116,8 +112,7 @@ export function registerBrowserHandlers(): void { expectedSshTargetGeneration?: unknown } ) => { - // Why (review P1-2): preparing mints bindings whose LRU eviction destroys - // cookie jars; only the trusted renderer naming a REGISTERED target may. + // Preparation can evict cookie jars; require a trusted renderer and registered target. if (!isTrustedBrowserRenderer(event.sender)) { throw new Error('browser_local_route_renderer_untrusted') } @@ -170,15 +165,11 @@ export function registerBrowserHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return false } - // Why the whole door and not just the manager call: a document page shares this renderer, and - // the grab disposal below drops the intent an in-flight preview grab compares by identity — - // that grab would then answer ok without ever arming. A document page withdraws by revoking - // its grant, so its id arriving here is misaddressed however it got here. + // Documents withdraw by grant revocation; dropping their intent here could silently settle an unarmed grab. if (typeof args?.browserPageId !== 'string' || isWorkspaceDocPageId(args.browserPageId)) { return false } - // Why: notify bridge before unregistering so it can destroy the session - // process and proxy. Must happen before unregisterGuest clears the mapping. + // Destroy bridge sessions before unregistering clears the guest mapping. const wcId = browserManager.getGuestWebContentsId(args.browserPageId) if (wcId !== null && agentBrowserBridgeRef) { agentBrowserBridgeRef.onTabClosed(wcId) @@ -226,9 +217,7 @@ export function registerBrowserHandlers(): void { } ) - // Why: keeps the bridge's active tab in sync with the renderer's UI state. - // Without this, a user switching tabs in the UI would leave the agent operating - // on the previous tab, which is confusing. + // Keep agent commands aligned with the tab the user selected. ipcMain.handle('browser:activeTabChanged', (event, args: { browserPageId: string }) => { if (!isTrustedBrowserRenderer(event.sender)) { return false @@ -238,9 +227,7 @@ export function registerBrowserHandlers(): void { } const wcId = browserManager.getGuestWebContentsId(args.browserPageId) if (wcId !== null) { - // Why: renderer tab changes are scoped to a worktree. If we only update - // the global active guest, later worktree-scoped commands can still - // resolve to the previously active page inside that worktree. + // Update worktree-scoped active state as well as the global guest. agentBrowserBridgeRef.onTabChanged( wcId, browserManager.getWorktreeIdForTab(args.browserPageId) @@ -251,11 +238,7 @@ export function registerBrowserHandlers(): void { registerBrowserGuestViewHandlers() - // --- Browser Context Grab IPC --- - registerBrowserGrabHandlers() - // --- Browser Session Profile IPC --- - registerBrowserSessionProfileHandlers() } diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts index b2a54cb5dad..e8b0e15f929 100644 --- a/src/main/ipc/cli.ts +++ b/src/main/ipc/cli.ts @@ -61,10 +61,7 @@ async function persistWslCliRegistration( try { await operation() } catch (error) { - // Why: the WSL file operation already succeeded; advisory metadata must - // not turn that success into a false Settings failure. The atomic write - // left the prior registry intact, and repair is disk-authoritative, so a - // stale entry self-corrects on the next startup probe. + // Advisory metadata failure must not invalidate the completed WSL write; startup repair reads authoritative disk state. console.warn( `[wsl-cli] Failed to persist ${action} registration metadata:`, error instanceof Error ? error.message : String(error) @@ -76,8 +73,7 @@ async function hydrateLocalShellPathForCli(force = false): Promise { if (process.platform === 'win32') { return } - // Why: CLI registration must match `which orca` in the user's terminal, not - // the sparse PATH a GUI-launched Electron process inherited from launchd. + // Match terminal PATH rather than the sparse environment inherited by GUI Electron. const hydration = await hydrateShellPath(force ? { force: true } : undefined) if (hydration.ok) { mergePathSegments(hydration.segments) @@ -94,7 +90,7 @@ export function registerCliHandlers(): void { await hydrateLocalShellPathForCli() const installer = new CliInstaller() const status = await installer.getStatus() - // Why: an AppImage update replaces the outer file while the managed symlink still targets the prior extracted payload. + // AppImage updates replace the outer file while managed links can retain the old extracted payload. const repairKey = resolveStaleAppImageRepairKey(status) if (!repairKey || installer.isAppImageRegistrationOwnedBySibling(status)) { return status @@ -141,15 +137,12 @@ export function registerCliHandlers(): void { ipcMain.handle( 'cli:getWslInstallStatus', async (_event, args?: { distro?: string | null }): Promise => { - // Why: status is a read-only probe; queuing it behind a long-running - // repair/install would hang the Settings spinner for its duration, and - // Settings re-polls, so a rare transient read self-corrects. + // Keep status reads independent of long repairs; re-polling corrects transient state. const distro = resolveWslCliDistro(args) try { return await new WslCliInstaller({ distro }).getStatus() } catch (error) { - // Why: a cold or busy distro is a retryable state for the Settings row, - // not a raw IPC rejection (#29497). + // Cold/busy distros are retryable Settings states, not raw IPC failures (#29497). if (distro && error instanceof WslCliUnreachableError) { return unreachableWslCliStatus(distro) } diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 10014baf4b3..cb7d8ef57d9 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -41,8 +41,7 @@ export function registerCodexAccountHandlers( ) ipcMain.handle('codexAccounts:cancelPendingLogin', () => codexAccounts.cancelPendingLogin()) ipcMain.handle('codexAccounts:pendingLoginUrl', () => codexAccounts.getPendingLoginUrl()) - // Why: Settings can open after the login already printed its link, so the - // renderer reads the current value on mount and this only carries changes. + // Settings reads the current link on mount; broadcasts carry later changes. codexAccounts.onPendingLoginUrlChanged(broadcastCodexPendingLoginUrl) codexAccounts.setWslLoginUrlOpener((url) => openAccountSignInLink('codex', url)) ipcMain.handle( @@ -59,9 +58,7 @@ export function registerCodexAccountHandlers( 'codexAccounts:select', (_event, args: { accountId: string | null } & CodexAccountSelectionTarget) => { if (!args.runtime) { - // Why: older renderer surfaces selected by account id only. Let the - // service infer the account's runtime instead of treating missing - // runtime as Windows/host and rejecting valid WSL accounts. + // Legacy renderers omit runtime; infer it from the account so valid WSL selection still works. return codexAccounts.selectAccount(args.accountId) } return codexAccounts.selectAccountForTarget(args.accountId, args) diff --git a/src/main/ipc/codex-config-sync.test.ts b/src/main/ipc/codex-config-sync.test.ts index f9504a149c8..69c4d5bff4d 100644 --- a/src/main/ipc/codex-config-sync.test.ts +++ b/src/main/ipc/codex-config-sync.test.ts @@ -53,8 +53,7 @@ afterEach(() => { describe('codexConfigSync:status handler', () => { it('reports the stall for the home the current selection actually mirrors into', () => { - // Why: a managed account mirrors into its own per-account home, not the - // shared one — reporting on the shared home would miss the stall entirely. + // Managed accounts mirror per-account homes, not the shared runtime home. const perAccountHome = join(root, 'codex-accounts', 'acct-1', 'home') mkdirSync(perAccountHome, { recursive: true }) writeFileSync(join(perAccountHome, 'config.toml'), 'model = "runtime-model"\n', 'utf-8') @@ -67,8 +66,7 @@ describe('codexConfigSync:status handler', () => { }) it('reports synced when the selection runs directly on the real home', () => { - // Why: the system default has no mirror, so a stale shared runtime home - // must not produce a warning about a config that lane never reads. + // System default uses no mirror; stale shared homes must not produce warnings. const staleSharedHome = join(root, 'codex-runtime-home', 'home') mkdirSync(staleSharedHome, { recursive: true }) writeFileSync(join(staleSharedHome, 'config.toml'), 'model = "stale"\n', 'utf-8') @@ -102,11 +100,8 @@ describe('codexConfigSync:status handler', () => { }) }) -// STA-4422: an unreadable managed home must not be reported as healthy. Before -// the fix the resolver collapsed to `null`, which this channel reads as "no -// mirror exists" and reports as synced. +// Unreadable managed homes must not collapse into the healthy no-mirror state (STA-4422). it('reports a managed-home-unavailable stall instead of synced when the home is unreadable', () => { - // Anchor: a genuine no-mirror lane still reports synced. expect(invokeHandlerWithStatus({ kind: 'ready', homePath: null })).toEqual({ state: 'synced', reason: null, diff --git a/src/main/ipc/codex-config-sync.ts b/src/main/ipc/codex-config-sync.ts index 53a718da3c5..ee40ef02ee8 100644 --- a/src/main/ipc/codex-config-sync.ts +++ b/src/main/ipc/codex-config-sync.ts @@ -10,15 +10,13 @@ type CodexMirroredHomeResolver = { getMirroredHostHomePathForStatus: () => CodexMirroredHomeStatus } -/** Registers the read-only IPC channel the settings pane reads once per mount for Codex config sync health. */ export function registerCodexConfigSyncHandlers(runtimeHome: CodexMirroredHomeResolver): void { ipcMain.removeHandler('codexConfigSync:status') ipcMain.handle('codexConfigSync:status', (): CodexConfigSyncStatus => { const systemHomePath = getSystemCodexHomePath() const mirrored = runtimeHome.getMirroredHostHomePathForStatus() if (mirrored.kind === 'unavailable') { - // Why: do not throw — the settings pane catches thrown status errors and - // would show nothing at all. Report the stall so the user sees why. + // Return a stall: Settings catches thrown status errors and otherwise displays nothing. return { state: 'stalled', reason: 'managed-home-unavailable', @@ -27,9 +25,7 @@ export function registerCodexConfigSyncHandlers(runtimeHome: CodexMirroredHomeRe } const runtimeHomePath = mirrored.homePath if (!runtimeHomePath) { - // Why: the system default runs Codex directly against ~/.codex, so there - // is no mirror that can fall behind. Reporting on the shared home here - // would warn about a config that lane never reads. + // System default reads ~/.codex directly; stale shared mirrors are irrelevant. return { state: 'synced', reason: null, diff --git a/src/main/ipc/command-path-resolver.test.ts b/src/main/ipc/command-path-resolver.test.ts index 9e1c9cd7d5c..a096dc1f733 100644 --- a/src/main/ipc/command-path-resolver.test.ts +++ b/src/main/ipc/command-path-resolver.test.ts @@ -74,11 +74,7 @@ describe('isCommandOnLocalPath', () => { let dir = '' beforeAll(async () => { dir = await mkdtemp(path.join(tmpdir(), 'cmd-resolver-win32-')) - // Why: the fixture extension matches a PATHEXT entry's case exactly so these - // tests are filesystem-portable. Real Windows resolves `.CMD` vs `.cmd` - // case-insensitively via the FS itself (same as where.exe); we deliberately - // do NOT emulate that in the resolver, so we must not depend on a - // case-insensitive FS here — this suite also runs on case-sensitive Linux CI. + // Match PATHEXT case in fixtures; real Windows handles filename case while Linux test filesystems do not. await writeFile(path.join(dir, 'tool.CMD'), '@echo off\n') }) afterAll(async () => { @@ -113,9 +109,7 @@ describe('isCommandOnLocalPath', () => { }) }) - // Why the full list exists (#22975): a dead version-manager shim passes the - // same executable check as the binary it shadows, so a winner-only lookup can - // only ever hand the caller the shim. The copies behind it are the answer. + // Executable-but-broken shims can shadow working binaries; retain later PATH candidates (#22975). describe.skipIf(process.platform === 'win32')('listLocalCommandPaths', () => { let front = '' let middle = '' @@ -132,8 +126,6 @@ describe('isCommandOnLocalPath', () => { back = await mkdtemp(path.join(tmpdir(), 'cmd-list-back-')) await executable(front, 'gh', '#!/usr/bin/env bash\nexec /nope/asdf exec "gh" "$@"\n') await executable(back, 'gh', "#!/bin/sh\nprintf 'gh version 2.98.0\\n'\n") - // Both a non-executable file and a directory in the middle: neither is a - // match, and neither may end the scan. await writeFile(path.join(middle, 'gh'), 'not executable\n') await chmod(path.join(middle, 'gh'), 0o644) await mkdir(path.join(middle, 'glab')) @@ -186,12 +178,7 @@ describe('isCommandOnLocalPath', () => { }) it('drops a relative match the filesystem would otherwise find', async () => { - // Why a second relative case: `path.posix.join('.', 'gh')` collapses to - // `gh`, so the case above is unaffected by deleting the gate. `fs.access` - // resolves a relative candidate against the process cwd, so only a PATH - // entry that really reaches a fixture proves the filter — and the `access` - // proves that reach first, making this fail loudly instead of going - // vacuous if the suite is ever run from a directory that hides it. + // Ensure this relative PATH entry actually reaches a fixture before asserting the absolute-only filter. const relative = path.relative(process.cwd(), back) await expect(access(path.join(relative, 'gh'))).resolves.toBeUndefined() await expect( @@ -200,8 +187,7 @@ describe('isCommandOnLocalPath', () => { }) it('lists a PATH entry that repeats only once', async () => { - // Why it matters: callers spawn each entry, and `/usr/local/bin` appearing - // twice in a real PATH is normal — a doomed shim must not be probed twice. + // Duplicate PATH directories must not probe doomed shims repeatedly. await expect( listLocalCommandPaths('gh', { platform: 'linux', diff --git a/src/main/ipc/command-path-resolver.ts b/src/main/ipc/command-path-resolver.ts index 27808b6aded..486dff2f1fb 100644 --- a/src/main/ipc/command-path-resolver.ts +++ b/src/main/ipc/command-path-resolver.ts @@ -14,9 +14,7 @@ export type ResolveCommandOptions = { maxResults?: number } -// Why: Windows env keys are case-insensitive (PATH is usually stored as `Path`, -// PATHEXT as `PathExt`), but the merged env object we search is a plain, -// case-sensitive Record — so look the key up case-insensitively. +// Windows environment keys are case-insensitive even though this Record is not. function readEnvCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | undefined { const direct = env[key] if (direct !== undefined) { @@ -34,8 +32,7 @@ function readEnvCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | u function getWindowsExtensions(env: NodeJS.ProcessEnv, command: string): string[] { const pathext = readEnvCaseInsensitive(env, 'PATHEXT') ?? '.EXE;.CMD;.BAT;.COM' const extensions = pathext.split(';').filter((ext) => ext.length > 0) - // Why: when the command already carries an extension (e.g. `node.exe`), an - // exact-name match must be allowed alongside the PATHEXT permutations. + // Explicit extensions also permit exact-name matches alongside PATHEXT variants. if (command.includes('.')) { extensions.unshift('') } @@ -147,10 +144,10 @@ export function resolveSelectedLocalCommand( async function isExecutableFile(candidate: string, isWin: boolean): Promise { try { - // Why: stat (not lstat) so symlinked CLIs resolve to their real target. + // Follow symlinked CLIs to their real targets. const stats = await stat(candidate) if (stats.isDirectory()) { - // Why: PATH dirs carry the search/exec bit; reject them like `[ ! -d ]`. + // PATH directories are not executable files despite their search bit. return false } if (isWin) { @@ -164,16 +161,7 @@ async function isExecutableFile(candidate: string, isWin: boolean): Promise() for (const dir of searchDirs) { for (const ext of extensions) { - // Why: forward-slash joins so candidates are statable on every platform - // (Windows fs accepts `/`), keeping the win32 lookup testable off-Windows. + // Windows accepts forward slashes; POSIX joins keep synthetic Windows lookups testable everywhere. const candidate = path.posix.join(dir, command) + ext - // Why: preserve the prior `.some(line => path.isAbsolute(line))` filter - // over where/which stdout — only absolute resolutions count. + // Preserve the prior where/which absolute-resolution filter. const candidateKey = isWin ? candidate.toLowerCase() : candidate if (!isAbsolute(candidate) || seen.has(candidateKey)) { continue diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index 5568030c17a..39d92a05ed9 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -250,8 +250,7 @@ describe('renderer breadcrumb IPC routing', () => { }) }) - // Why: park-churn notices are per-tab but the ring is process-wide, so many - // tabs churning at once would otherwise evict the pre-crash trail. + // Per-tab churn must not evict the process-wide pre-crash trail. it('coalesces park-verdict churn notices by trigger across tabs', () => { emitRendererBreadcrumb({ name: 'terminal_park_verdict_churn', @@ -269,9 +268,7 @@ describe('renderer breadcrumb IPC routing', () => { } }) - // Why: `burst` means damping engaged a commit short of React #185 while - // `window` is slow benign churn. A shared key would drop the near-crash - // signal into the slow-churn slot. + // Keep near-crash burst evidence separate from slow window churn. it('keeps burst and window park-verdict churn triggers in separate slots', () => { emitRendererBreadcrumb({ name: 'terminal_park_verdict_churn', @@ -287,9 +284,7 @@ describe('renderer breadcrumb IPC routing', () => { ).toEqual(['terminal_park_verdict_churn:burst', 'terminal_park_verdict_churn:window']) }) - // Why: every hidden pane is 0x0, so one post-reload reattach wave exhausts - // the fit budget once per mounted pane inside ~60ms. Windows crash - // F0BKR84AHEH lost 26-90% of its 30-entry ring to two such bursts. + // Hidden panes exhaust fit retries together; coalesce their burst without losing pane counts. it('coalesces fit-retry exhaustion by name, not by pane', () => { emitRendererBreadcrumb({ name: 'terminal_safe_fit_retry_exhausted', @@ -307,8 +302,7 @@ describe('renderer breadcrumb IPC routing', () => { } }) - // Why kind-scoped: a routine post-wake atlas reset must never suppress the - // context-loss crumb that says the driver gave up on this renderer. + // Routine atlas resets must not suppress GPU context-loss evidence. it('coalesces WebGL diagnostics per kind so one kind cannot mask another', () => { emitRendererBreadcrumb({ name: 'terminal_webgl_diagnostic', @@ -350,8 +344,7 @@ describe('renderer breadcrumb IPC routing', () => { ]) }) - // Why: the renderer guard is once per tab-id/verdict, so one stale worktree - // map can emit enough crumbs to evict the pre-crash trail. + // A stale worktree map can emit once per tab; bound the resulting storm. it('coalesces duplicate-tab-owner notices across tabs', () => { emitRendererBreadcrumb({ name: 'terminal_tab_id_owned_by_multiple_worktrees', @@ -371,8 +364,7 @@ describe('renderer breadcrumb IPC routing', () => { } }) - // Why flag-scoped: coalescing keeps only the newest payload, and the verdict - // flips under a persisting duplicate, so one would erase the other. + // Last-write coalescing must preserve both convergence verdicts. it('keeps a non-converging duplicate-tab-owner notice out of the converging one', () => { emitRendererBreadcrumb({ name: 'terminal_tab_id_owned_by_multiple_worktrees', @@ -393,8 +385,7 @@ describe('renderer breadcrumb IPC routing', () => { ]) }) - // Why: a #185 crash loop emits a cascade notice per cascade, back to back, - // and uncoalesced they would flush the 30-entry ring holding the trail. + // Back-to-back React cascades must not flush the 30-entry ring. it('coalesces react commit cascade notices from one driver into one slot', () => { emitRendererBreadcrumb({ name: 'react_commit_cascade', @@ -416,8 +407,7 @@ describe('renderer breadcrumb IPC routing', () => { ]) }) - // Why keyed and not name-only: coalescing keeps only the newest payload, so - // one surface or driver would erase the other's evidence. + // Last-write coalescing must preserve distinct surfaces and drivers. it('keeps different cascade surfaces and drivers in separate slots', () => { emitRendererBreadcrumb({ name: 'react_commit_cascade', @@ -441,8 +431,7 @@ describe('renderer breadcrumb IPC routing', () => { ).toBe(3) }) - // Why asserted here: the key ends in `stack`, which is what buys the driver - // frames the 4000-char budget instead of the 240-char one details get. + // The stack key permits a 4000-character budget instead of ordinary detail truncation. it('keeps the full driver stack on a react commit cascade notice', () => { const driverStack = `tabs.ts:12:3 bump\n${'tabs.ts:99:1 other\n'.repeat(60)}` emitRendererBreadcrumb({ @@ -456,8 +445,7 @@ describe('renderer breadcrumb IPC routing', () => { expect(recorded?.data.driverStack.length).toBeGreaterThan(240) }) - // Why uncoalesced: it fires at most once per renderer, and it is the signal - // that the diagnostic itself never wired up. + // A once-per-renderer diagnostic installation failure does not need coalescing. it('records the uninstalled self-check notice directly', () => { emitRendererBreadcrumb({ name: 'react_commit_cascade_uninstalled' }) diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts index 8d126f557b5..67c95a5f48a 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts @@ -37,15 +37,11 @@ function recordRendererBreadcrumbTrace( ...(data ? { 'breadcrumb.data': data } : {}) } }) - // Why: main-process native crashes cannot persist memory-only breadcrumbs. - // A tiny trace span gives the next crash report durable pre-crash context. + // Persist pre-crash context because native crashes cannot retain memory-only breadcrumbs. span.end() } -// Why: a repeating renderer error (e.g. a ResizeObserver or SSH-rejection -// storm, #8260) can flush the whole fixed-size breadcrumb ring in seconds, -// erasing the pre-crash trail. Coalesce repeats into one entry that carries a -// suppressed count instead. +// Coalesce error storms so they cannot erase the bounded pre-crash trail (#8260). const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn' const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade' @@ -61,20 +57,9 @@ const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 -// Why: these carry no message identity — they are per-tab telemetry whose rate, -// not whose text, is the signal. Coalescing by name alone bounds a many-tab -// storm to one ring entry plus a suppressed count. -// -// terminal_safe_fit_retry_exhausted: every hidden (display:none) pane is 0x0 and -// burns its whole retry budget, so one post-reload reattach wave fires once per -// mounted pane within ~60ms. Windows crash F0BKR84AHEH lost 26-90% of its -// 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count -// — the only signal these carry — in one slot. +// Hidden panes exhaust retries together; retain multiplicity in one slot via suppressedSinceLast. const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted']) -// Why: the 30-slot ring is the scarce sink; the durable span stream is not. For -// bounded-rate pane telemetry whose multiplicity is the whole signal, spans are the -// only place a burst survives the restart that clears the ring, so coalesce the ring -// but keep every event's span. +// Coalesce the scarce ring while preserving every bounded-rate event in durable spans. const PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES = new Set([REPLAY_GUARD_WEDGED_BREADCRUMB]) function rendererBreadcrumbCoalesceKey( @@ -84,24 +69,15 @@ function rendererBreadcrumbCoalesceKey( if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) { return name } - // Why presence and not value: `ptyId`/`tabIdHash` are absent on the restore call - // site (layout-serialization restoreScrollbackBuffers) and present on reattach, so - // their presence is the call-site identity a mixed burst would otherwise lose. Four - // slots per storm at most, regardless of pane count. + // Identity-field presence distinguishes restore and reattach call sites; bound to four slots per storm. if (name === REPLAY_GUARD_WEDGED_BREADCRUMB) { return `${name}:${data?.ptyId ? 'pty' : ''}:${data?.tabIdHash ? 'tab' : ''}` } - // Why trigger and not name alone: `burst` means damping engaged a commit - // short of React #185, `window` means slow benign churn. Collapsing them - // would drop the near-crash signal into a slow-churn slot. Still bounded — - // two slots per storm regardless of tab count. + // Preserve near-crash burst evidence separately from benign window churn. if (name === PARK_VERDICT_CHURN_BREADCRUMB) { return `${name}:${String(data?.trigger ?? '')}` } - // Why keyed on surface and driver frame: the popout and the main window can - // cascade independently, and two different driving writes are two different - // bugs that last-write coalescing would collapse into one. A driverless crumb - // (storeWrites 0, a useState loop) keys separately for the same reason. + // Preserve independent surfaces, drivers and driverless cascades instead of last-write collapse. if (name === REACT_COMMIT_CASCADE_BREADCRUMB) { return `${name}:${String(data?.rendererSurface ?? '')}:${String(data?.driverFrame ?? '')}` } @@ -111,8 +87,7 @@ function rendererBreadcrumbCoalesceKey( const reason = kind === 'webgl-atlas-reset' ? data?.reason : undefined return reason ? `${name}:${kind}:${String(reason)}` : `${name}:${kind}` } - // Why: a stale map can emit once per tab-id/verdict; key by verdict so - // last-write coalescing cannot erase the other signal while remaining bounded. + // Bound per-tab storms without collapsing opposing convergence verdicts. if (name === DUPLICATE_TAB_OWNER_BREADCRUMB) { return `${name}:${String(data?.resolvedToActiveWorktree ?? '')}` } @@ -124,15 +99,12 @@ function rendererBreadcrumbCoalesceKey( : typeof fallbackMessage === 'string' && fallbackMessage.length > 0 ? fallbackMessage : undefined - // Why: message-less failures have no stable identity, so grouping them could - // erase unrelated crash evidence. Sanitization already caps messages at 240 chars. + // Message-less failures lack stable identity; do not collapse unrelated crash evidence. if (!message) { return undefined } - // Why: common messages such as "Script error" or "Cannot read properties" - // can come from unrelated sites. Include sanitized source evidence so one - // failure cannot suppress the breadcrumb for another. + // Include sanitized source identity so common error text cannot collapse unrelated failures. const sourceIdentity = name === 'renderer_error' ? [ @@ -145,7 +117,7 @@ function rendererBreadcrumbCoalesceKey( data?.errorMessage ] : [data?.reasonStack, data?.reasonType, data?.reasonName] - // Why: error storms re-serialize the same message + up-to-4KB stack per event just to build a map key — reuse the last key on field-equality. + // Reuse equal-field keys instead of serializing the same message/stack throughout a storm. if ( lastCoalesceKey !== null && lastCoalesceName === name && @@ -206,12 +178,10 @@ export function recordRendererBreadcrumbFromRenderer( ...(origin ? { origin } : {}) }) if (PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES.has(args.name)) { - // Why the raw data: every event already gets its own span, so folding the ring's - // running count in here would double-count in any span-stream total. + // Per-event spans already count multiplicity; do not add the ring's running total. recordRendererBreadcrumbTrace(args.name, data) } else if (coalesceResult) { - // Why gated: tracing every suppressed duplicate would preserve the same - // serialization and disk churn that breadcrumb coalescing removes. + // Avoid per-duplicate serialization and disk churn after coalescing. recordRendererBreadcrumbTrace( args.name, coalesceResult.suppressedSinceLast > 0 diff --git a/src/main/ipc/crash-reporting-renderer-error-report.ts b/src/main/ipc/crash-reporting-renderer-error-report.ts index 451ca7850c9..86e94e86c02 100644 --- a/src/main/ipc/crash-reporting-renderer-error-report.ts +++ b/src/main/ipc/crash-reporting-renderer-error-report.ts @@ -152,9 +152,7 @@ export async function recordRendererErrorReport( return { ok: true, report: null, deduped: true } } recentRendererErrorReportKeys.set(key, now) - // Why: renderer error reports are IPC input. A broken renderer can vary the - // component stack/message inside the age window, so bound the main-side - // dedupe map by count as well as time. + // Bound IPC-controlled dedupe keys by count as well as age. pruneRendererErrorReportKeys(now) const report = await store.record({ @@ -191,8 +189,7 @@ export async function recordRendererErrorReport( ? { has_active_worktree: normalized.hasActiveWorktree } : {}) }, - // Why: React render failures are recoverable only because a boundary - // caught them; persist the same recent app breadcrumbs as native crashes. + // Boundary-caught React failures need the same pre-crash breadcrumbs as native crashes. breadcrumbs: getCrashBreadcrumbSnapshot( webContentsId === undefined ? undefined : rendererCrashBreadcrumbOrigin(webContentsId) ) diff --git a/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts b/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts index e3823f0b313..357d3dac957 100644 --- a/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts +++ b/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts @@ -55,12 +55,7 @@ afterEach(() => { clearCrashBreadcrumbsForTest() }) -// One mount/reveal/wake transition expires every in-flight replay write at once, so -// the burst reaches the 30-slot ring as N distinct entries. Field span streams measure -// bursts of 26 in 0.96s and 62 over 85s. No captured report in the 09-02 corpus shows -// a ring that actually drained — all nine bursts predate their report's ring window — -// so this bounds a demonstrated hazard, not an observed loss, and must not cost the -// durable span evidence that did carry those bursts. +// Field spans show simultaneous replay wedges; bound the ring hazard while preserving durable per-event evidence. describe('replay-guard wedge burst against the fixed-size breadcrumb ring', () => { it('costs one ring slot per call site and preserves the pre-crash trail', () => { for (let index = 0; index < 10; index += 1) { @@ -83,20 +78,16 @@ describe('replay-guard wedge burst against the fixed-size breadcrumb ring', () = emitReattachWedge(pane) } - // 26 emissions: one owns the slot, 25 fold into it. expect(wedgeCrumbs()[0]?.data?.suppressedSinceLast).toBe(25) }) - // The 121-event field corpus lives entirely in the renderer.breadcrumb span stream, - // and the ring is cleared by the restart that usually precedes the crash report, so - // ring coalescing must not suppress the per-event spans. + // Restarts clear the ring; retain one durable span for every wedge event. it('still emits one durable span per wedge event', () => { for (let pane = 0; pane < 26; pane += 1) { emitReattachWedge(pane) } expect(wedgeSpanCount()).toBe(26) - // Why no count on the span: one span per event already carries the multiplicity. expect( startSpanMock.mock.calls.some((call) => JSON.stringify(call[1]).includes('suppressedSinceLast') @@ -104,8 +95,7 @@ describe('replay-guard wedge burst against the fixed-size breadcrumb ring', () = ).toBe(false) }) - // Bundle 8907a508 mixes restore-path (identity-less) and reattach-path crumbs in one - // window; name-only keying would report only the last one's shape. + // Mixed restore/reattach bursts must retain both call-site shapes (bundle 8907a508). it('keeps restore-path and reattach-path call sites in separate slots', () => { emitRestoreWedge(1) emitRestoreWedge(2) diff --git a/src/main/ipc/crash-reporting-sendable-reports.ts b/src/main/ipc/crash-reporting-sendable-reports.ts index e3d6b43e4c1..1e1886fc961 100644 --- a/src/main/ipc/crash-reporting-sendable-reports.ts +++ b/src/main/ipc/crash-reporting-sendable-reports.ts @@ -6,8 +6,7 @@ export const submittedReportIds = new Set() const MAX_SUBMITTED_REPORT_IDS = 256 export function rememberSubmittedReportId(reportId: string): void { - // Why: report ids are IPC input. Keep duplicate-send suppression useful for - // recent reports without retaining every id a broken renderer can vary. + // Bound IPC-controlled report IDs while retaining recent duplicate-send suppression. submittedReportIds.delete(reportId) submittedReportIds.add(reportId) while (submittedReportIds.size > MAX_SUBMITTED_REPORT_IDS) { @@ -49,7 +48,6 @@ export async function getRequestedCrashReport( if (args?.reportId) { return store.getById(args.reportId) } - // Why: Help > Report Crash can intentionally submit without a report ID. - // Do not replace that uncaptured report with a pending crash that appears later. + // An explicit uncaptured report must not be replaced by a later pending crash. return args ? null : getLatestPendingReport(store) } diff --git a/src/main/ipc/crash-reporting-submission.ts b/src/main/ipc/crash-reporting-submission.ts index b95fe62141f..f4cd5e0bbaf 100644 --- a/src/main/ipc/crash-reporting-submission.ts +++ b/src/main/ipc/crash-reporting-submission.ts @@ -69,8 +69,7 @@ export async function submitCrashReport( return result.ok ? { ok: true, report: null, diagnosticBundle: submittedDiagnosticBundle } : { - // Why: the transport-only attachment failure may contain raw - // endpoint detail; only its sanitized bundle reason crosses IPC. + // Only sanitized bundle reasons may cross IPC; raw failures can include endpoint details. ok: false, status: result.status, error: result.error, @@ -122,8 +121,7 @@ export async function submitCrashReport( const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result) if (!result.ok) { return { - // Why: keep the renderer contract allow-listed instead of leaking - // the transport's internal diagnosticBundleFailure object. + // Preserve the allow-listed renderer contract without internal transport diagnostics. ok: false, status: result.status, error: result.error, @@ -134,8 +132,7 @@ export async function submitCrashReport( rememberSubmittedReportId(report.id) if (report.status === 'dismissed') { try { - // Why: startup prompts are dismissed before the user can send from - // the still-open dialog, so successful uploads must update storage. + // Startup prompts dismiss before sending; successful uploads must update dismissed storage. const sent = await store.markDismissedSent(report.id) return { ok: true, @@ -159,9 +156,7 @@ export async function submitCrashReport( diagnosticBundle: submittedDiagnosticBundle } } catch (error) { - // Why: the upstream submission already succeeded. A local persistence - // failure must not present as upload failure or invite duplicate sends - // during this app session. + // Upload already succeeded; local persistence failure must not invite duplicate sends. console.error('[crash-reporting] Failed to mark crash report sent:', error) return { ok: true, diff --git a/src/main/ipc/created-worktree-reconciliation.ts b/src/main/ipc/created-worktree-reconciliation.ts index 8b1953bbc13..4f1ee5f2fe0 100644 --- a/src/main/ipc/created-worktree-reconciliation.ts +++ b/src/main/ipc/created-worktree-reconciliation.ts @@ -1,5 +1,5 @@ import { describeCreatedWorktree, listWorktreesSharedStrict } from '../git/worktree' -// Not via the worktree barrel: suites mock that module wholesale and would blank the constant. +// Import directly because suites mock the worktree barrel wholesale. import { WORKTREE_LIST_TIMEOUT_MS } from '../git/worktree-operation-options' import type { GitWorktreeExecOptions } from '../git/worktree' import type { GitWorktreeInfo } from '../../shared/worktree/types' @@ -29,7 +29,7 @@ export type CreatedWorktreeResolution = { listingComplete: boolean } -/** `created but not found in listing` is load-bearing for `classifyWorkspaceCreateError`. */ +/** Preserve this wording for classifyWorkspaceCreateError. */ export function createdWorktreeNotFoundError(worktreePath: string, branchName: string): Error { return new Error( `Worktree created but not found in listing: ${worktreePath} (branch ${branchName})` diff --git a/src/main/ipc/created-worktree-root-prune.test.ts b/src/main/ipc/created-worktree-root-prune.test.ts index 30808d7d79e..7f2a971121f 100644 --- a/src/main/ipc/created-worktree-root-prune.test.ts +++ b/src/main/ipc/created-worktree-root-prune.test.ts @@ -57,8 +57,7 @@ describe('recovered worktree root pruning', () => { invalidateAuthorizedRootsCache() __resetCreatedWorktreeRootsForTests() vi.mocked(listRepoWorktreeGraph).mockReset() - // The #16520 outage itself: `listWorktrees` softens every Git failure to `[]`, so the rebuild - // reports success with the recovered row missing and the probe is the only remaining evidence. + // Git failures soften to empty listings; the probe must preserve independently recovered roots (#16520). vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) statMock.mockReset() statMock.mockResolvedValue({}) @@ -79,8 +78,7 @@ describe('recovered worktree root pruning', () => { }) it('keeps a recovered root when the probe fails for any reason but ENOENT', async () => { - // A transient EACCES/EIO is not evidence of removal, and revoking on it would deny a worktree - // the user is working in. + // Transient permission/I/O errors do not prove removal. for (const code of ['EACCES', 'EIO', 'EPERM']) { __resetCreatedWorktreeRootsForTests() const store = makeStore() @@ -94,7 +92,7 @@ describe('recovered worktree root pruning', () => { }) it('finishes the rebuild and keeps the root when the probe never settles', async () => { - // A dead mount pins the syscall; the rebuild gates filesystem auth, so it must not wait on it. + // Authorization rebuilds must not wait on a dead mount's stat. vi.useFakeTimers() const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, RECOVERED) diff --git a/src/main/ipc/credential-change-rate-limit-refresh.ts b/src/main/ipc/credential-change-rate-limit-refresh.ts index 7e5bf3eaf3e..6c6f3ef6de9 100644 --- a/src/main/ipc/credential-change-rate-limit-refresh.ts +++ b/src/main/ipc/credential-change-rate-limit-refresh.ts @@ -1,9 +1,6 @@ import type { RateLimitService } from '../rate-limits/service' -/** - * Drops a provider's stale usage, then refreshes in the background. - * Why fire-and-forget: callers return the persisted credential status immediately; a failed refresh only logs. - */ +/** Refresh usage in the background so callers can return persisted credential status even if refresh fails. */ export function refreshAfterCredentialChange>( rateLimits: T | null, invalidate: (rateLimits: T) => void, diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 58828d2af79..63dd1076a4b 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -2,8 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { DashboardSnapshot } from '../../shared/dashboard-snapshot' import type * as RepoIconModule from '../../shared/repo-icon' -// Counts real sanitizer entries so the icon cache is proven by decode count -// rather than by wall clock, which is unfalsifiable on a loaded CI box. +// Count real decodes to verify caching without timing-sensitive assertions. const sanitizeRepoIconCalls = vi.hoisted(() => vi.fn()) vi.mock('../../shared/repo-icon', async (importOriginal) => { const actual = await importOriginal() @@ -347,8 +346,7 @@ describe('dashboard payload validation', () => { } }) - // Why: the pop-out replays the last accepted snapshot, so rejecting the whole - // board over one card froze every other agent's status until it was renamed. + // One malformed card must not freeze every other agent on the last accepted board. describe('admitDashboardSnapshot', () => { it('drops only the offending card and keeps the rest of the board', () => { const good = SNAPSHOT.cards[0] @@ -412,8 +410,7 @@ describe('dashboard payload validation', () => { expect(admitDashboardSnapshot({ ...SNAPSHOT, generatedAt: Number.NaN })).toBeNull() expect(admitDashboardSnapshot({ ...SNAPSHOT, cards: 'nope' })).toBeNull() expect(admitDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: [] })).toBeNull() - // Why: showIdle and filterOptions describe the frame, not one card, so a - // bad value there has no card to drop and must fail the whole snapshot. + // Invalid frame fields cannot be salvaged by dropping one card. expect(admitDashboardSnapshot({ ...SNAPSHOT, showIdle: 'yes' })).toBeNull() expect( admitDashboardSnapshot({ @@ -424,8 +421,7 @@ describe('dashboard payload validation', () => { }) }) - // Why: sanitizing an image icon decodes the whole payload to read a 24-byte - // header, and the renderer republishes the same icons every 250 ms. + // Renderer publishes every 250ms; repeated icons must not re-decode their full payload. it('validates a repeated image icon without re-decoding it every publish', () => { const src = imageIconSrc(256 * 1024) const snapshot = { @@ -443,15 +439,13 @@ describe('dashboard payload validation', () => { expect(isDashboardSnapshot(snapshot)).toBe(true) } - // 10 repos x 20 publishes = 200 icon checks against ONE decode. expect(sanitizeRepoIconCalls).toHaveBeenCalledTimes(1) }) it('re-decodes when the icon payload or its source actually changes', () => { const first = { type: 'image', src: imageIconSrc(1_024), source: 'upload' } const second = { type: 'image', src: imageIconSrc(2_048), source: 'upload' } - // Same bytes, different source: `source` picks which src pattern is legal, - // so it must not collide with the first entry's cached verdict. + // Source changes legal patterns, so equal bytes cannot share verdicts across sources. const rebranded = { ...first, source: 'file' } sanitizeRepoIconCalls.mockClear() @@ -480,8 +474,7 @@ describe('dashboard payload validation', () => { }) it('does not let a split of one icon key inherit another icon verdict', () => { - // A valid base64 src may contain whitespace, so `source` and `src` must not - // be joined ambiguously: this pair concatenates identically. + // Whitespace in valid src values makes plain separator keys ambiguous. const accepted = { type: 'image', src: imageIconSrc(1_024, true), source: 'upload' } const joined = `upload ${accepted.src}` const splitAt = joined.indexOf(' ', 'upload '.length) @@ -495,8 +488,6 @@ describe('dashboard payload validation', () => { expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: { 'repo-1': accepted } })).toBe( true ) - // `forged.source` is not a legal RepoIconImageSource, so it must be rejected - // no matter what the accepted icon left in the cache. expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: { 'repo-1': forged } })).toBe( false ) @@ -513,7 +504,6 @@ describe('dashboard payload validation', () => { ) } - // Cheap branches bypass the cache entirely rather than sharing its keyspace. expect(sanitizeRepoIconCalls).toHaveBeenCalledTimes(3) }) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 2116b0b5a0f..4199d7d99bd 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -23,8 +23,7 @@ export { isDashboardSpawnAgentArgs } from './dashboard-agent-launch-validation' const MAX_DASHBOARD_CARDS = 1_000 const MAX_DASHBOARD_REPO_ICONS = 500 -// Why: sanitizing an image icon base64-decodes the whole data URI to read a -// 24-byte header, and the renderer republishes the same icons every 250 ms. +// Repeated 250ms publishes otherwise decode entire icons just to read their headers. const MAX_CACHED_ICON_SRC_BYTES = 8 * 1024 * 1024 const imageIconValidity = new BoundedMap({ maxEntries: MAX_DASHBOARD_REPO_ICONS, @@ -119,13 +118,7 @@ export type DashboardSnapshotAdmission = { droppedCardCount: number } -/** - * Why: one malformed card used to reject the whole snapshot, and the pop-out - * then replayed its last good board forever with nothing logged. A single - * over-long label must cost that card, not every other agent's live status. - * Snapshot-level fields still reject outright — there is no partial board to - * salvage when the frame itself is unusable. - */ +/** Drop malformed cards individually; reject unusable snapshot-level fields because no partial frame can be salvaged. */ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmission | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null @@ -155,8 +148,7 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi } } -/** Repo icons reach the pop-out's ``, so each one must survive the - * same sanitizer the settings picker writes through. */ +/** Apply the Settings sanitizer before repo icons can reach pop-out img sources. */ function isDashboardRepoIcons(value: unknown): boolean { if (value === undefined) { return true @@ -231,10 +223,7 @@ function imageIconCacheKey(icon: unknown): string | null { return null } const candidate = icon as Record - // Why: `source` and `src` are the only fields an image icon can be rejected - // on — `label` is normalized rather than rejected — so they alone key it. - // Length-prefixed because a valid `src` may contain whitespace, so a plain - // separator would let a rejected icon collide with an accepted one's verdict. + // Key source/src only: labels normalize. Prefix lengths so whitespace cannot create verdict collisions. return candidate.type === 'image' && typeof candidate.src === 'string' && typeof candidate.source === 'string' diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index c9076288bf9..d197794f3bf 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -157,8 +157,7 @@ describe('registerDashboardPopoutHandlers', () => { expect(popoutSender.send).toHaveBeenCalledWith('dashboard:snapshot', SNAPSHOT) }) - // Why: an over-long label used to drop the whole snapshot silently, leaving - // the board frozen on its last good paint with nothing logged. + // Invalid cards must not silently freeze the rest of the board. it('keeps publishing the board when one card is invalid, and says so', () => { const popout = makeWindow(popoutSender) getPopoutMock.mockReturnValue(popout) @@ -211,8 +210,7 @@ describe('registerDashboardPopoutHandlers', () => { const publish = handlers.get('dashboard:publishSnapshot')! publish({ sender: mainSender } as never, { ...SNAPSHOT, repoIconsByRepoId }) - // A throttled republish drops the unchanged map — the popout on the wire - // still holds it, but one mounting now would have nothing to hold. + // Cached replays need the previous icon map even when the live republish omits it. publish({ sender: mainSender } as never, { generatedAt: 2, cards: [] }) expect(popoutSender.send).toHaveBeenLastCalledWith('dashboard:snapshot', { generatedAt: 2, @@ -249,7 +247,6 @@ describe('registerDashboardPopoutHandlers', () => { paneKey: 'tab1:leaf1', intent: 'explicit' }) - // Anything but a click's explicit intent reads as a view. expect(sendToTrustedMock.mock.calls).toEqual([ ['ui:ackDashboardAgent', { paneKey: 'tab1:leaf1', intent: 'view' }], ['ui:ackDashboardAgent', { paneKey: 'tab1:leaf1', intent: 'explicit' }] diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts index 5b05c43e715..f8ae77df799 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -20,9 +20,7 @@ import { isDashboardSpawnAgentArgs } from './dashboard-payload-validation' -// The most recent snapshot the main renderer published, replayed to the popout -// the instant it mounts so the board paints without waiting for the next tick. -// Cleared on close so a reopened popout never flashes a previous session. +// Replay immediately on mount; clear on close to avoid showing a previous session. let lastSnapshot: DashboardSnapshot | null = null function isDashboardEnabled(store: Store): boolean { @@ -66,14 +64,12 @@ export function registerDashboardPopoutHandlers( }) }) - // Relay: the main renderer publishes derived snapshots; forward to the popout. ipcMain.handle('dashboard:publishSnapshot', (event, snapshot: unknown): void => { if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) { return } const admitted = admitDashboardSnapshot(snapshot) - // Why: silently dropping left the pop-out replaying a stale board with no - // trace of why it stopped updating. + // Explain stale replays instead of silently discarding malformed snapshots. if (!admitted) { console.warn('[dashboard] rejected malformed snapshot; pop-out keeps its previous board') return @@ -81,10 +77,7 @@ export function registerDashboardPopoutHandlers( if (admitted.droppedCardCount > 0) { console.warn(`[dashboard] dropped ${admitted.droppedCardCount} invalid card(s) from snapshot`) } - // The renderer omits repoIconsByRepoId once it is unchanged, so carry the - // last map into the cache — a popout mounting mid-session is replayed this - // and has no icons of its own to retain. The live popout does, so what is - // forwarded stays as slim as the renderer sent it. + // Retain omitted unchanged icons for newly mounting popouts; live forwards stay sparse. lastSnapshot = admitted.snapshot.repoIconsByRepoId === undefined && lastSnapshot?.repoIconsByRepoId ? { ...admitted.snapshot, repoIconsByRepoId: lastSnapshot.repoIconsByRepoId } @@ -92,8 +85,7 @@ export function registerDashboardPopoutHandlers( getDashboardPopoutWindow()?.webContents.send('dashboard:snapshot', admitted.snapshot) }) - // The popout asks for a snapshot on mount: replay the cache immediately, then - // nudge the main renderer to publish a fresh one. + // Replay immediately, then request fresh renderer state. ipcMain.handle('dashboard:requestSnapshot', (event): void => { if (!isDashboardPopoutRenderer(event.sender) || !isDashboardEnabled(store)) { return @@ -110,8 +102,7 @@ export function registerDashboardPopoutHandlers( : false ) - // Seen-sync: opening a card's terminal dialog acknowledges the agent in the - // main renderer's store — the same ack that mutes its sidebar row. + // Acknowledge in the main store so popout views also mute sidebar rows. ipcMain.handle('dashboardPopout:ackAgent', (event, args: unknown): void => { const ack = args && typeof args === 'object' ? args : {} const paneKey = 'paneKey' in ack ? ack.paneKey : undefined @@ -128,7 +119,6 @@ export function registerDashboardPopoutHandlers( }) }) - // Click-to-focus: raise the main window and route it to the agent's pane. ipcMain.handle('dashboardPopout:revealAgent', (event, args: unknown): void => { if ( !isDashboardPopoutRenderer(event.sender) || diff --git a/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts b/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts index a4d510650d6..fe76c33eec9 100644 --- a/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts +++ b/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts @@ -2,8 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' import { requireEmojiShortcodeDataset } from './deferred-emoji-shortcode-dataset' -// Lives under src/main (not next to the shared catalog) so the shared tsconfig projects stay -// free of a src/main import — the boundary emoji-shortcode-catalog.lazy.test.ts asserts on. +// Keep main imports outside shared tsconfig projects; the lazy catalog test enforces that boundary. describe('deferred emoji shortcode dataset', () => { it('loads the main-side dataset synchronously into an identical catalog', async () => { vi.resetModules() @@ -16,7 +15,7 @@ describe('deferred emoji shortcode dataset', () => { const deferred = await import('../../shared/emoji-shortcode-catalog.js') deferred.setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) - // No await between registration and first use: the require path keeps the sync contract. + // Immediate use must work without awaiting an async import. expect(deferred.getStandardEmojiShortcodeEntries()).toEqual(eagerEntries) expect(deferred.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}')).toBe( eagerTransform diff --git a/src/main/ipc/deferred-emoji-shortcode-dataset.ts b/src/main/ipc/deferred-emoji-shortcode-dataset.ts index 0d499d25750..bcb0a29c407 100644 --- a/src/main/ipc/deferred-emoji-shortcode-dataset.ts +++ b/src/main/ipc/deferred-emoji-shortcode-dataset.ts @@ -1,11 +1,7 @@ import { createRequire } from 'node:module' import type { EmojiShortcodeDataset } from '../../shared/emoji-shortcode-catalog' -// Why createRequire (same reason as linear-sdk.ts): a static import inlines the 166 KB -// shortcode dataset into out/main/index.js and JSON.parses it on every launch, while only -// worktree-name sanitization ever reads it. app.asar ships no node_modules, so this bare require -// resolves out of Resources/node_modules — electron-builder.config.cjs copies exactly this file -// there (the package root is 49 MB of locale data). +// Defer the 166KB catalog; packaged require resolves the builder-copied file in Resources/node_modules outside app.asar. const requireFromMain = createRequire(__filename) export function requireEmojiShortcodeDataset(): EmojiShortcodeDataset { diff --git a/src/main/ipc/desktop-browser-bridge-wiring.ts b/src/main/ipc/desktop-browser-bridge-wiring.ts index f9e1433a847..a555979d531 100644 --- a/src/main/ipc/desktop-browser-bridge-wiring.ts +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -1,7 +1,4 @@ -/** - * Production wiring for the desktop browser bridge: which managed SSH hosts may attach, how to - * reach their orcad over the paired encrypted connection, and how a forwarded command runs here. - */ +/** Route eligible SSH hosts through their paired orcad connection to this desktop's browser bridge. */ import { randomUUID } from 'node:crypto' import { webContents, type BrowserWindow } from 'electron' import { @@ -63,12 +60,12 @@ export function listDesktopBrowserBridgeCandidates( if (connections?.getState(target.id)?.status !== 'connected') { continue } - // Why first: with the switch on by default, every plain host reaches here; only managed ones qualify. + // Only managed hosts qualify even when the global switch defaults on. const environmentId = getManagedOrcadFenceEnvironmentId(target) if (!environmentId || isRuntimeEnvironmentManuallyDisconnected(environmentId)) { continue } - // Why: an unrouted host's pages egress from this desktop, so it must not stay attached at all. + // Detached routing must not retain desktop egress authority. if (!isSshTargetBrowsingRouted(target.id)) { continue } @@ -100,7 +97,7 @@ export function installDesktopBrowserBridge(options: { const getDispatcher = (): RpcDispatcher => { dispatcher ??= new RpcDispatcher({ runtime, - // Why a registry of only these methods: even a bug upstream cannot reach anything else. + // Restrict the registry even if an upstream caller misroutes a method. methods: ALL_RPC_METHODS.filter((method) => isDesktopBrowserBridgeMethod(method.name)), callerScope: { kind: 'runtime-paired', grants: [] } }) @@ -176,9 +173,8 @@ export function installDesktopBrowserBridge(options: { ), log }) - // Why: turning routing off for a host must detach it (and cancel its waiting commands) at once. + // Routing changes must immediately detach hosts and cancel pending commands. stopWatchingRoutingSettings?.() - // Why optional: test doubles of the store omit the listener API. stopWatchingRoutingSettings = persistedStore?.onSettingsChanged?.((updates) => { if ( @@ -195,7 +191,6 @@ export function installDesktopBrowserBridge(options: { manager.reconcile() } -// Mirrors the renderer's route eligibility: routing on globally and not opted out for this host. function isSshTargetBrowsingRouted(targetId: string): boolean { const settings = persistedStore?.getSettings() return ( diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index aae7f5a47d2..16485f10417 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -10,31 +10,15 @@ import { } from '../../shared/agent-launch-runtime-capability' import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from '../../shared/terminal-quick-command-capabilities' -/** - * What the desktop renderer advertises when it calls its own main process over `runtime:call`. - * - * Main and the renderer ship as one build, so nothing here is about version skew — the renderer - * arrives as `clientKind: 'runtime'`, not in the `clientKind === undefined` population, so any - * capability the host uses as an authorization gate has to be named here or the method is refused. - * That is why this stays a curated set rather than the remote list: several remote-only entries - * would change local behaviour if adopted (`SESSION_TAB_CLOSE_INTENT` alone would start refusing - * an unattributed desktop tab close), the lists remain curated for those reasons. - * - * One constant, not one list per dispatch path: the unary and streaming handlers held separate - * copies, and a capability added to one and missed on the other is invisible until a user hits it. - */ +/** Curate one capability set for unary/streaming desktop calls; remote-only gates can alter local semantics even on the same build. */ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ ...STRUCTURED_AGENT_SESSION_RENDERER_CLIENT_CAPABILITIES, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, - // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer - // targeting a remote host is admitted — the asymmetry this constant exists to close. + // Agent-launch authorization requires this even for the same-build desktop renderer. AGENT_LAUNCH_RUNTIME_CAPABILITY, - // A replay after a restart mid-delivery answers the running agent with an `unconfirmed` prompt, - // which the desktop reads, rather than refusing it as unknown. + // Read unconfirmed prompt replays after interrupted delivery. AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, - // A launch whose tab the user closed is answered as exactly that, which the desktop stays silent - // on, rather than as unknown, which it would report as a failure. + // Treat closed launch tabs as silent cancellation instead of unknown failure. AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY, - // Same build as its host, so it reads every quick-command prompt the host stores. TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY ] as const diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index 8116af8f6b7..9b5d9e0da71 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -88,8 +88,7 @@ function triggerAppleEventsPrompt(): Promise { resolve() } - // Why: this request only nudges macOS to show the Automation prompt; a - // stuck osascript process should not keep the permission IPC pending. + // A stuck osascript permission nudge must not hold IPC indefinitely. const timeout = setTimeout(() => { child?.kill() finish() @@ -161,8 +160,7 @@ async function getPermissionState(id: DeveloperPermissionId): Promise { - // Consent gate: main is the consent enforcement boundary; the - // renderer-side button-hide is UX, not security. A compromised or - // malicious renderer must not be able to assemble a bundle when the - // user has disabled diagnostic-bundle collection in Settings → Privacy. + // Enforce consent in main; hiding renderer controls is insufficient. const status = getDiagnosticsStatus() if (!status.bundleEnabled) { throw new Error('creating review files is disabled') } - // Renderer-controlled input → narrow at the boundary. The default - // (DEFAULT_LOOKBACK_MINUTES in bundle.ts) is fine for the common - // "last 30 minutes" case the Privacy pane button triggers. + // Validate untyped IPC input before applying the default lookback. const lookbackMinutes = typeof lookbackMinutesIn === 'number' && Number.isFinite(lookbackMinutesIn) ? Math.max(1, Math.min(30 * 24 * 60, Math.floor(lookbackMinutesIn))) @@ -243,12 +218,9 @@ export function registerDiagnosticsHandlers(): void { ipcMain.handle( 'diagnostics:uploadBundle', async (_event, bundleSubmissionId: unknown): Promise => { - // Why: the renderer is in the threat model. Upload only a payload main - // collected and retained for preview, never renderer-supplied bytes. + // Accept only main-retained payload IDs, never renderer-supplied bytes. const pendingForConfirmation = getPendingBundleForUpload(bundleSubmissionId) - // Consent gate: main is the consent enforcement boundary; the - // renderer-side button-hide is UX, not security. Re-check here in case - // the user toggled the setting off between collect and upload. + // Recheck consent in case it changed after collection. if (!getDiagnosticsStatus().bundleEnabled) { throw new Error('sending diagnostics is disabled') } @@ -256,8 +228,7 @@ export function registerDiagnosticsHandlers(): void { if (!confirmed) { return { canceled: true } } - // Why: the preview can be discarded or diagnostics can be disabled - // while the native confirmation dialog is open. + // Revalidate after the dialog: payloads can be discarded or consent revoked while it is open. const { bundle, payload } = getPendingBundleForUpload(bundleSubmissionId) if (!getDiagnosticsStatus().bundleEnabled) { throw new Error('sending diagnostics is disabled') diff --git a/src/main/ipc/doc-preview-grant-ipc.test.ts b/src/main/ipc/doc-preview-grant-ipc.test.ts index 453f3ac854c..2055b56bc8f 100644 --- a/src/main/ipc/doc-preview-grant-ipc.test.ts +++ b/src/main/ipc/doc-preview-grant-ipc.test.ts @@ -121,9 +121,7 @@ describe('document preview grant handlers', () => { expect(() => mint({ ...REQUEST, browserPageId: ' ' })).toThrow(/Invalid/) }) - // Why this is refused here and not left to registration: this is where a page becomes a document - // page, and the two halves of the page registry have to stay disjoint. A page already hosting a - // browsing guest would otherwise resolve in both, and the tool door prefers the document one. + // Keep browser/document registries disjoint when minting the document identity. it('refuses to make a page that already hosts a browsing guest into a document page', () => { mocks.getGuestWebContentsId.mockReturnValue(42) @@ -131,8 +129,7 @@ describe('document preview grant handlers', () => { expect(mocks.getGuestWebContentsId).toHaveBeenCalledWith('doc-page-1') }) - // Why: this channel hands out filesystem-read authority, so an untrusted sender must leave with - // nothing rather than with a grant id the scheme handler would honor. + // Untrusted senders must receive no filesystem-read grant. it('mints nothing for a sender that is not the trusted renderer', () => { mocks.isTrustedBrowserRenderer.mockReturnValue(false) @@ -172,9 +169,7 @@ describe('document preview grant handlers', () => { expect(authorize('0'.repeat(32), 'assets/app.js')).toBe(false) }) - // Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a - // workspace document, which is the untrusted side by construction. The guest policy holds the - // gate, so all this listener owes is the sender and a string. + // Preview guests are untrusted; the guest policy validates sender binding and link URL. it('hands a reported link click to the guest policy with the sender that reported it', () => { reportLinkClick('https://example.com/docs') diff --git a/src/main/ipc/doc-preview-grant-ipc.ts b/src/main/ipc/doc-preview-grant-ipc.ts index 9b3c6636322..3ad39d703fc 100644 --- a/src/main/ipc/doc-preview-grant-ipc.ts +++ b/src/main/ipc/doc-preview-grant-ipc.ts @@ -47,27 +47,19 @@ function isValidGrantRequest(request: DocPreviewGrantRequest): boolean { ) } -/** - * Minting never widens what the renderer can already read: an SSH grant reads - * through the same provider as `fs:readFile`, and a runtime grant through the - * same worktree-scoped `files.read` RPC the renderer can call directly. - */ +/** Grants retain existing read scopes: SSH uses the filesystem provider and runtime uses worktree-scoped files.read. */ export function registerDocPreviewGrantHandlers(): void { ipcMain.handle( DOC_PREVIEW_MINT_GRANT_CHANNEL, (event, request: DocPreviewGrantRequest): DocPreviewGrantResult => { - // Why gate a channel guests cannot reach today: this one hands out filesystem-read - // authority, so it holds the same sender check its sibling browser channels do rather than - // relying on guests never gaining an ipcRenderer. + // Granting filesystem-read authority requires a trusted renderer even if guests cannot currently invoke IPC. if (!isTrustedBrowserRenderer(event.sender)) { throw new Error('Untrusted document preview grant request') } if (!isValidGrantRequest(request)) { throw new Error('Invalid document preview grant request') } - // Why the other half of the registry is consulted here: this is where a page first becomes a - // document page, and the two halves must stay disjoint. Naming a page that already hosts a - // browsing guest would make one id resolve in both. + // A page ID cannot belong to both browser and document registries. if (browserManager.getGuestWebContentsId(request.browserPageId) !== null) { throw new Error('Document preview grant names a browsing page') } @@ -98,9 +90,7 @@ export function registerDocPreviewGrantHandlers(): void { authorizeDocPreviewDirectory(grantId, relativePath) ) - // Why no trusted-renderer check here: the sender is a preview guest rendering a workspace - // document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a - // live bound grant and a web URL — and it drops everything else silently. + // Guest senders are untrusted; the policy checks a live bound grant and web URL. ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => { if (typeof url === 'string') { reportDocPreviewLinkClick(event.sender, url) diff --git a/src/main/ipc/dropped-path-resolution.ts b/src/main/ipc/dropped-path-resolution.ts index 926b9b5b315..e1890b99031 100644 --- a/src/main/ipc/dropped-path-resolution.ts +++ b/src/main/ipc/dropped-path-resolution.ts @@ -1,8 +1,7 @@ import { parseWslPath, toLinuxPath } from '../wsl' export function resolveLocalDroppedPathsForAgent(paths: string[], worktreePath: string): string[] { - // Why: a local WSL PTY runs inside Linux, so Windows drop paths must be - // rewritten to paths the shell and agent can read. + // Local WSL shells need dropped Windows paths rewritten into their Linux namespace. const targetWsl = parseWslPath(worktreePath) return targetWsl ? paths.map((droppedPath) => resolveDroppedPathForTargetWsl(droppedPath, targetWsl.distro)) @@ -12,8 +11,7 @@ export function resolveLocalDroppedPathsForAgent(paths: string[], worktreePath: function resolveDroppedPathForTargetWsl(droppedPath: string, targetDistro: string): string { const droppedWsl = parseWslPath(droppedPath) if (droppedWsl) { - // Why: WSL UNC paths are only Linux-native inside their own distro. - // Rewriting another distro would paste a plausible but wrong path. + // Rewrite UNC paths only for their owning distro; another distro would receive a plausible wrong path. return isSameWslDistro(droppedWsl.distro, targetDistro) ? droppedWsl.linuxPath : droppedPath } return toLinuxPath(droppedPath) diff --git a/src/main/ipc/emulator-frame-stream.ts b/src/main/ipc/emulator-frame-stream.ts index 9a806704154..0fc63b2a11e 100644 --- a/src/main/ipc/emulator-frame-stream.ts +++ b/src/main/ipc/emulator-frame-stream.ts @@ -37,8 +37,7 @@ export function registerEmulatorFrameStreamHandlers(): void { } const streamId = randomUUID() - // Why: Chromium's NetworkService can restart under long-lived MJPEG loads; - // the main process owns the socket so the renderer only receives JPEG bytes. + // Main owns MJPEG sockets so Chromium NetworkService restarts cannot interrupt long-lived streams. const stream = new MjpegFrameStream( args.streamUrl, { diff --git a/src/main/ipc/emulator-stream-listener-cleanup.test.ts b/src/main/ipc/emulator-stream-listener-cleanup.test.ts index ea016d9c739..d635e41a573 100644 --- a/src/main/ipc/emulator-stream-listener-cleanup.test.ts +++ b/src/main/ipc/emulator-stream-listener-cleanup.test.ts @@ -1,8 +1,6 @@ import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' -// Capture the ipcMain handlers the modules register so tests can invoke them -// directly with a fake WebContents owner. const handlers = new Map unknown>() vi.mock('electron', () => ({ @@ -11,7 +9,6 @@ vi.mock('electron', () => ({ handlers.set(channel, listener) } }, - // Any non-null return marks the sender as a real BrowserWindow renderer. BrowserWindow: { fromWebContents: () => ({}) } })) @@ -31,8 +28,7 @@ vi.mock('../emulator/emulator-probe', () => ({ emulatorProbe: () => {} })) import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream' -/** A fake main-window WebContents: a real EventEmitter plus the members the - * stream handlers touch, so we can assert on its real `destroyed` listeners. */ +/** Keep real EventEmitter destruction listeners behind the fake WebContents. */ function makeOwner(): EventEmitter & { isDestroyed: () => boolean; send: () => void } { const owner = new EventEmitter() as EventEmitter & { isDestroyed: () => boolean @@ -55,8 +51,7 @@ describe('emulator frame stream listener cleanup', () => { const owner = makeOwner() const event = { sender: owner } - // 15 show/hide cycles: without the fix this would leave 15 destroyed - // listeners and trip Node's MaxListenersExceededWarning (default 10). + // Fifteen cycles expose leaks beyond Node's default ten-listener warning threshold. for (let i = 0; i < 15; i++) { const { streamId } = start(event, { streamUrl: 'http://127.0.0.1:0/stream' }) as { streamId: string diff --git a/src/main/ipc/emulator-video-stream.ts b/src/main/ipc/emulator-video-stream.ts index f6aeb8f2f75..e1242655824 100644 --- a/src/main/ipc/emulator-video-stream.ts +++ b/src/main/ipc/emulator-video-stream.ts @@ -4,10 +4,7 @@ import { scrcpyVideoRegistry } from '../emulator/scrcpy-video-registry' import { emulatorProbe } from '../emulator/emulator-probe' import { abortWhenRendererGone } from './renderer-lifetime-abort' -// Bridges the main-process scrcpy video registry to renderer subscribers. The -// renderer calls emulator:videoStreamStart with a deviceId; meta + H.264 access -// units arrive on emulator:videoStreamMeta / emulator:videoStreamFrame. Mirrors -// the MJPEG emulator-frame-stream handler but for the Android H.264 path. +// Relay scrcpy metadata/H.264 units to renderer subscribers, parallel to the MJPEG stream handler. export function registerEmulatorVideoStreamHandlers(): void { type Subscription = { owner: WebContents diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index 6fa591da9c1..034d34663bc 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -110,8 +110,7 @@ export function getRuntimeRecipeContext( if (!repo.ok) { throw new Error(repo.message) } - // Pre-snapshot runtimes can only be attributed to repo-owned recipes. Never - // substitute a later same-id plugin recipe for an older runtime lifecycle. + // Legacy runtimes lack snapshots; never substitute a newer same-ID plugin recipe for their repo-owned recipe. const recipe = runtime.recipe ?? (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index 3e06936a71e..898e997259a 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -148,8 +148,6 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { } ) - // Sleep suspends a workspace's VM from main, after its chats stop. - ipcMain.handle( 'ephemeralVm:resumeWorkspace', async (_event, args: { workspaceId: string }): Promise => { @@ -177,7 +175,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { throw new Error(result.error) } const connection = getEphemeralVmRecipeResultConnection(result.runtime.recipeResult) - // Why: an SSH VM's server is reached through its target, so it has no pairing to refresh. + // SSH servers use their target and have no pairing to refresh. if (!result.skipped && connection.type !== 'ssh' && runtime.runtimeEnvironmentId) { const pairingCode = getEphemeralVmRecipeResultPairingCode(result.runtime.recipeResult) if (!pairingCode) { diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 38fef94d3c8..5b6441ec810 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -10,8 +10,7 @@ import { type EphemeralVmRecipeResultWarning } from '../../shared/ephemeral-vm-recipe-diagnostics' import { getProvisionedRootRecipeRepoUrl } from '../../shared/ephemeral-vm-recipe-repo-url' -// Why: import directly from the doctor module (not the barrel) — it uses Node -// fs/path and must stay out of the browser bundle that imports the barrel. +// Import the Node-only doctor directly so browser consumers of the barrel do not bundle fs/path. import { doctorEphemeralVmRecipe } from '../../shared/ephemeral-vm-recipe-doctor' import { updateEphemeralVmRuntimeStatus } from '../../shared/ephemeral-vm-runtime-store' import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes' @@ -146,8 +145,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin chunk: redactEphemeralVmRecipeDiagnosticText(chunk) }) } - // Why: deregister only when the whole handler settles, so cancelProvision still finds the - // controller for every phase that reads its signal. + // Retain cancellation registration until every signal-consuming provisioning phase settles. try { let recipeRepoUrl = repo.repo.gitRemoteIdentity?.remoteUrl let sourceRef = args.ref diff --git a/src/main/ipc/export.ts b/src/main/ipc/export.ts index 667d3529287..7b8c944791f 100644 --- a/src/main/ipc/export.ts +++ b/src/main/ipc/export.ts @@ -24,9 +24,7 @@ export function registerExportHandlers(): void { try { const pdfBuffer = await htmlToPdf(html) - // Why: sanitize to keep the suggested filename legal on every platform. - // Windows forbids /\:*?"<>| in filenames; truncate to keep the OS save - // dialog stable when titles are pathologically long. + // Keep long titles and Windows-forbidden characters out of suggested save filenames. const sanitizedTitle = title.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100) || 'export' const defaultFilename = `${sanitizedTitle}.pdf` diff --git a/src/main/ipc/feedback-image-attachments.test.ts b/src/main/ipc/feedback-image-attachments.test.ts index 230cd1a50c0..ebf9f5e9913 100644 --- a/src/main/ipc/feedback-image-attachments.test.ts +++ b/src/main/ipc/feedback-image-attachments.test.ts @@ -24,8 +24,7 @@ describe('isSupportedFeedbackImageContentType', () => { expect(isSupportedFeedbackImageContentType(contentType)).toBe(false) }) - // Why: the allow-list is an object literal, so a prototype member must not - // pass as a content type and get spliced into the upload filename. + // Prototype members must not become accepted MIME types and filename extensions. it.each(['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty'])( 'rejects the inherited member %s', (contentType) => { diff --git a/src/main/ipc/feedback-image-attachments.ts b/src/main/ipc/feedback-image-attachments.ts index d8d00ff1877..6e48c4e96c7 100644 --- a/src/main/ipc/feedback-image-attachments.ts +++ b/src/main/ipc/feedback-image-attachments.ts @@ -6,8 +6,7 @@ import { } from '../../shared/feedback-image-limits' import type { FeedbackImageAttachment } from '../../shared/feedback-submit-contract' -// Why: mirrors the server allow-list. Slack picks a renderer from the filename -// extension, so every accepted type needs one. +// Match the server allow-list; Slack selects rendering by filename extension. const FEEDBACK_IMAGE_EXTENSIONS: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', @@ -19,8 +18,7 @@ export const MAX_FEEDBACK_IMAGE_RESPONSE_BYTES = 64 * 1024 export const FEEDBACK_IMAGE_FORM_FIELD = 'feedbackImage' export function isSupportedFeedbackImageContentType(contentType: string): boolean { - // Why: `in` walks the prototype chain, so "constructor" and "__proto__" would - // clear the allow-list and name the upload after an inherited member. + // Own-property checks exclude prototype members such as constructor and __proto__. return Object.hasOwn(FEEDBACK_IMAGE_EXTENSIONS, contentType) } @@ -28,7 +26,7 @@ export function feedbackImageFilename(index: number, contentType: string): strin return `feedback-image-${index + 1}.${FEEDBACK_IMAGE_EXTENSIONS[contentType]}` } -/** Defence in depth: the renderer validates first, but this channel is reachable directly. */ +/** Enforce validation at IPC even if renderer validation was bypassed. */ export function validateFeedbackImages(images: unknown): string | null { if (!Array.isArray(images)) { return 'Image attachments must be a list.' diff --git a/src/main/ipc/feedback-request.ts b/src/main/ipc/feedback-request.ts index 4bbb8c08cbf..ec44fef3f9a 100644 --- a/src/main/ipc/feedback-request.ts +++ b/src/main/ipc/feedback-request.ts @@ -5,11 +5,7 @@ import type { FeedbackRequestFailure } from '../../shared/feedback-submit-contract' -// Why: the production Mac build loads the renderer from a file:// origin, so a -// cross-origin POST from fetch() triggers a CORS preflight that the feedback -// endpoint rejects. Electron's net module runs in the main process and is not -// subject to CORS, so we proxy the submission through IPC. This mirrors the -// same pattern used by updater-changelog.ts and updater-nudge.ts. +// Main-process HTTP avoids CORS preflights rejected by feedback when packaged renderers use file origins. export const FEEDBACK_API_URL = 'https://www.onorca.dev/v1/feedback' const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000 const DIAGNOSTIC_BUNDLE_CONTENT_TYPE = 'application/x-ndjson' @@ -43,7 +39,7 @@ export async function postFeedback( readResponse?: (response: Response) => Promise ): Promise { const controller = new AbortController() - // Why: a silent endpoint must not leave feedback IPC pending forever. + // Bound IPC even when the endpoint stays silent. const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const init: RequestInit = { @@ -55,14 +51,13 @@ export async function postFeedback( if (readResponse) { await readResponse(response) } - // Why: a response parser may tolerate malformed legacy bodies, but it must - // not turn the deadline's aborted body into a confirmed delivery. + // Tolerant legacy parsing must not turn an aborted body into confirmed delivery. if (controller.signal.aborted) { throw new Error(`request timed out after ${timeoutMs / 1000} seconds`) } return response } catch (error) { - // Why: Electron and Node report AbortError differently; keep deadline logs stable. + // Normalize differing Electron/Node AbortError shapes for stable deadline logs. if (controller.signal.aborted) { throw new Error(`request timed out after ${timeoutMs / 1000} seconds`) } @@ -110,8 +105,7 @@ function feedbackRequestBodyInit(body: FeedbackSubmitBody): Pick { expect(body).toBeInstanceOf(FormData) expect(body.getAll('feedbackImage')).toHaveLength(2) expect(body.get('feedback')).toBe('images attached') - // Why: multipart must not lose the enrichment fields the JSON lane sends. expect(body.get('submissionType')).toBe('feedback') expect(body.get('appVersion')).toBe('1.2.3-test') }) @@ -551,8 +550,7 @@ describe('submitFeedback', () => { expect(fetchMock).not.toHaveBeenCalled() }) - // Why: the renderer screens types first, so this lane only matters for a - // renderer invoking the channel directly — the case the handler guards. + // Enforce MIME ownership at IPC even if a renderer bypasses its own checks. it('rejects a prototype member posing as a content type over IPC', async () => { registerFeedbackHandlers() const result = (await handlers.get('feedback:submit')?.(null, { @@ -596,8 +594,7 @@ describe('submitFeedback', () => { }) it('does not fail a crash report over images it was never going to send', async () => { - // Why: the crash lane discards images, so validating them there would - // abort a crash report the user needs delivered. + // Crash submissions discard images; invalid unused images must not block delivery. await submitFeedback({ ...diagnosticSubmitArgs(), images: Array.from({ length: 9 }, () => ({ diff --git a/src/main/ipc/feedback.ts b/src/main/ipc/feedback.ts index b747751091a..523e9071785 100644 --- a/src/main/ipc/feedback.ts +++ b/src/main/ipc/feedback.ts @@ -28,9 +28,7 @@ export type { } const FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS = 60_000 -// Why: corporate filters can reject multipart with 403 and the host caps bodies -// near 4.5 MB (413) while allowing the small JSON report, so content-shaped -// failures should shed the attachment. +// Content-shaped rejections may still accept small JSON after dropping attachments (corporate filters/body caps). const ATTACHMENT_JSON_RETRY_STATUSES = new Set([400, 403, 408, 413, 415, 422]) type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & { @@ -39,18 +37,13 @@ type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & { feedbackWithoutDiagnosticBundle?: string } -// Why: the Slack notification and any follow-up investigation need to know -// which Orca build and which OS the feedback came from. The main process is -// the only place with trusted access to these values (app.getVersion and the -// node os module), so we enrich the payload here rather than trusting the -// renderer. +// Enrich trusted build/OS identity in main rather than accepting renderer claims. function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody { const identity = args.submitAnonymously ? { githubLogin: null, githubEmail: null } : { githubLogin: args.githubLogin, githubEmail: args.githubEmail } - // Why: anonymity is an IPC-only privacy decision. Allow-list fields here so - // stale renderer state or future identity-shaped fields cannot leak upstream. + // Allow-list anonymous payloads so stale or future identity fields cannot leak upstream. return { feedback: args.feedback, submissionType: args.submissionType ?? 'feedback', @@ -62,8 +55,7 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody { ...(args.submissionType === 'crash' && args.diagnosticBundle ? { diagnosticBundle: args.diagnosticBundle } : {}), - // Why: images are a feedback-only affordance; crash reports already carry - // diagnostic bundles and the server rejects images on that lane. + // Server accepts images only for feedback; crash reports carry diagnostic bundles. ...(args.submissionType !== 'crash' && args.images?.length ? { images: args.images } : {}) } } @@ -81,8 +73,7 @@ async function retryFeedbackOnPrimary( if (primaryError === undefined) { return { ok: false, status: retry.status, error: retryMessage } } - // Why: keep the first failure visible so support can see 5xx → retry outcome, - // not only the last error in a same-host retry chain. + // Preserve the first failure as well as the retry outcome for support. return { ok: false, status: retry.status, @@ -155,10 +146,7 @@ async function submitFeedbackWithImages( } : { ok: true, imagesDelivered: false, imagesFailure } } - // Why: the text lane retries 5xx, this one does not. Replaying the - // attachments on a flaky link costs more than it saves, and the dialog - // stays open with its thumbnails while the draft text sits in the app - // store, so a manual resend loses nothing. + // Do not retry image uploads automatically; the open dialog preserves thumbnails and draft text for manual retry. return { ok: false, ...imagesFailure } } catch (error) { return { ok: false, ...errorFailure(error) } @@ -170,8 +158,7 @@ async function submitFeedbackWithDiagnosticBundle( bodyWithoutDiagnosticBundle: FeedbackSubmitBody | null ): Promise { try { - // Why: diagnostic bundles can approach 4 MiB and need more upload time than - // the small JSON report-only path, especially on constrained connections. + // Large diagnostic bundles need longer upload deadlines than small JSON reports. const response = await postFeedback( FEEDBACK_API_URL, body, @@ -196,8 +183,7 @@ async function submitFeedbackWithDiagnosticBundle( export async function submitFeedback( args: InternalFeedbackSubmitArgs ): Promise { - // Why: buildSubmitBody drops images on the crash lane, so validating them - // there would abort a crash report over attachments it never meant to send. + // Ignore discarded crash-lane images instead of rejecting over unused attachments. if (args.submissionType !== 'crash' && args.images !== undefined) { const imageError = validateFeedbackImages(args.images) if (imageError) { @@ -224,8 +210,7 @@ export async function submitFeedback( if (res.ok) { return { ok: true } } - // Why: api.onorca.dev serves a different product, so transient failures - // retry the endpoint that owns feedback and crash delivery. + // Retry the owning endpoint; api.onorca.dev serves a different product. if (res.status >= 500) { return retryFeedbackOnPrimary(body, new Error(`status ${res.status}`)) } @@ -238,16 +223,14 @@ export async function submitFeedback( export function registerFeedbackHandlers(): void { ipcMain.removeHandler('feedback:submit') ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) => { - // Why: validate the raw clone before normalization so a tiny hostile value - // cannot become a large main-process typed-array allocation. + // Validate before typed-array normalization can expand hostile renderer input. if (args.images !== undefined) { const imageError = validateFeedbackImages(args.images) if (imageError) { return { ok: false, status: null, error: imageError } } } - // Why: crash submissions are main-only. A compromised renderer can invoke - // this channel directly, so force the public feedback lane at the boundary. + // Force the public feedback lane at IPC; crash submission is main-only. return submitFeedback({ ...args, submissionType: 'feedback' diff --git a/src/main/ipc/filesystem-allowed-roots.test.ts b/src/main/ipc/filesystem-allowed-roots.test.ts index 7d965f218d7..0613ca97de5 100644 --- a/src/main/ipc/filesystem-allowed-roots.test.ts +++ b/src/main/ipc/filesystem-allowed-roots.test.ts @@ -85,10 +85,7 @@ function makeCountingStore(fixture: StoreFixture): { store: Store; counts: Store return { store, counts } } -/** - * The pre-change `getAllowedRoots` algorithm, kept verbatim so the equivalence test compares the - * new root list against the old one rather than against a hand-written expectation. - */ +/** Preserve the old algorithm as an independent allowed-root equivalence oracle. */ function referenceAllowedRoots(store: Store): string[] { const scopeStore = store as unknown as { getRepos: () => Repo[] @@ -219,7 +216,6 @@ function makeMixedFixture(): StoreFixture { ] const folderWorkspaces = [ makeWorkspace({ id: 'ws-git', folderPath: '/folders/root/feature' }), - // Not a git worktree: a plain folder workspace under a folder-kind repo. makeWorkspace({ id: 'ws-plain', folderPath: '/folders/plain/scratch', @@ -294,9 +290,7 @@ describe('getAllowedRoots', () => { expect.soft(counts.getRepos).toBe(1) expect.soft(counts.getProjectGroups).toBe(1) expect.soft(counts.getFolderWorkspaces).toBe(1) - // Batched runtime resolution scans the project list once, not once per local repo. expect.soft(counts.getProjects).toBe(1) - // The per-scope subtree walk no longer rebuilds the parent->children index. expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(1) expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled() }) @@ -334,7 +328,7 @@ describe('resolveAuthorizedPath allowed-root reuse', () => { } const buildCount = callCount * 2 - // One build per authorization, not one per raw-path check plus one per realpath check. + // Reuse one root snapshot across raw and realpath authorization. expect.soft(counts.getFolderWorkspaces).toBe(buildCount) expect.soft(counts.getRepos).toBe(buildCount) expect.soft(counts.getProjects).toBe(buildCount) @@ -342,9 +336,7 @@ describe('resolveAuthorizedPath allowed-root reuse', () => { expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled() }) - // Why (both symlink cases): creating a symlink on Windows needs elevation or - // Developer Mode, so these would fail EPERM in setup rather than exercise the - // escape check. Every non-symlink case still runs there. + // Windows symlinks require elevation/Developer Mode; retain ordinary path coverage there. it.skipIf(process.platform === 'win32')( 'still refuses a symlink that escapes every allowed root', async () => { diff --git a/src/main/ipc/filesystem-allowed-roots.ts b/src/main/ipc/filesystem-allowed-roots.ts index 5b060bbec15..65cc69e503d 100644 --- a/src/main/ipc/filesystem-allowed-roots.ts +++ b/src/main/ipc/filesystem-allowed-roots.ts @@ -54,12 +54,7 @@ function isRemoteOnlyFolderScope( return hasRemoteCandidate } -/** - * Deliberately stricter than `resolveFolderWorkspaceHost`, which lets the workspace's own - * `executionHostId` pin win: a workspace pinned `local` under a group carrying only a legacy - * `connectionId` dispatches locally but is denied here. Letting the pin win would hand out a root - * the store refuses today, so authorization keeps the fail-closed read of either field. - */ +/** Authorization rejects either remote ownership field, even where dispatch lets a workspace-local pin override legacy group ownership. */ function hasRemoteFolderWorkspaceOwner( workspace: FolderWorkspace, projectGroups: readonly ProjectGroup[] @@ -73,7 +68,7 @@ function hasRemoteFolderWorkspaceOwner( function getLocalFolderScopeRoots(store: RuntimeStore, repos: readonly Repo[]): string[] { const scopeStore = store as FolderScopeStore - // Why: many filesystem tests use narrow RuntimeStore doubles; folder scopes are additive. + // Narrow RuntimeStore doubles may omit additive folder-scope methods. const projectGroups = scopeStore.getProjectGroups?.() ?? [] const childGroupIndex = buildProjectGroupChildIndex(projectGroups) const roots: string[] = [] @@ -113,15 +108,7 @@ type HostPathResolver = { resolve: (value: string) => string } -/** - * The allowed root a `workspaceDir` with no local repo to anchor it may contribute, or `null`. - * - * The predicate has to be the host's own, not `isRuntimePathAbsolute`: that helper accepts either - * flavour, so on POSIX it calls `C:\ws` absolute while this `resolve` reads the same string as a - * relative name and anchors the root under the main-process cwd — authorizing an unrelated local - * tree. Pairing both here keeps them from diverging again; `hostPath` is injectable so the - * flavour matrix can run POSIX and Windows without the test choosing on `process.platform`. - */ +/** Pair host-native absolute checks and resolution: cross-flavor acceptance could anchor C:\ws under POSIX cwd and grant an unrelated tree. */ export function resolveUnanchoredWorkspaceRoot( workspaceDir: string, hostPath: HostPathResolver = { isAbsolute, resolve } @@ -130,7 +117,7 @@ export function resolveUnanchoredWorkspaceRoot( } export function getAllowedRoots(store: RuntimeStore): string[] { - // Why one read: `getRepos` rehydrates every repo, and this runs twice per filesystem IPC. + // Rehydrate repos only once per authorization snapshot. const repos = store.getRepos() const localRepos = filterLocalRepos(repos) const settings = store.getSettings() @@ -151,9 +138,7 @@ export function getAllowedRoots(store: RuntimeStore): string[] { resolve( computeWorkspaceRoot( repo.path, - // Why enriched here too: placement has to agree with the create - // flow, or renderer file access is denied for a worktree Orca - // just put on the WSL side. + // Match creation placement, including WSL, so newly created worktrees remain authorized. getWorktreePathSettings( repo, settings, diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 137051c9e33..e6728a05828 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -120,8 +120,7 @@ describe('filesystem auth worktree roots', () => { }) it("keeps a repo's roots when its listing fails mid-rebuild", async () => { - // Why: the rebuild runs while Git is still broken, and dropping the roots would revoke - // a worktree a create just recovered without a listing (#16520). + // Broken Git listings must not revoke independently recovered roots (#16520). const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered') vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.')) @@ -134,8 +133,7 @@ describe('filesystem auth worktree roots', () => { }) it('keeps a recovered root when a healthy rebuild still cannot list it', async () => { - // Why: the rebuild recomputes from the very listing that omitted the row, so replacing the cache - // would re-deny the worktree the create just recovered (#16520). + // A rebuild from the incomplete listing must not re-deny the recovered worktree (#16520). const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-recovered-')) const recovered = join(scratch, 'feature') await mkdir(recovered) @@ -180,7 +178,6 @@ describe('filesystem auth worktree roots', () => { ]) await rebuildAuthorizedRootsCache(store) - // Still authorized, but now from the listing itself; a later listing that drops it is authoritative. await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe( resolve('/linked/feature') ) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index ec04ce67a5a..cd58d6ce20d 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -11,7 +11,7 @@ import { } from './registered-worktree-roots-cache' export const PATH_ACCESS_DENIED_MESSAGE = `${PATH_OUTSIDE_ALLOWED_DIRECTORIES}. If this blocks a legitimate workflow, please file a GitHub issue.` -/** One allowed-root list shared by every check in a single authorization, built on first use. */ +/** Lazily share allowed roots across every check in one authorization. */ type AllowedRootsSnapshot = { get: () => readonly string[] } function createAllowedRootsSnapshot( @@ -34,9 +34,7 @@ export function isPathAllowed( } export type ResolveAuthorizedPathOptions = { - /** - * Canonicalize the parent but preserve the leaf so delete/rename target the symlink itself, not its destination (which may live outside allowed roots). - */ + /** Canonicalize parents but preserve the leaf so delete/rename act on a symlink, not its destination. */ preserveSymlink?: boolean /** Roots one caller owns outside the store (desktop: floating workspace; runtime: chat scratch). */ extraRoots?: readonly string[] @@ -48,8 +46,7 @@ export async function resolveAuthorizedPath( options: ResolveAuthorizedPathOptions = {} ): Promise { const resolvedTarget = resolve(targetPath) - // Why: the roots depend only on store state, not on the candidate path, so one snapshot serves - // every authorization below; each candidate is still checked against it in full. + // Reuse store-derived roots while fully checking each candidate. const allowedRoots = createAllowedRootsSnapshot(store, options.extraRoots) if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -79,7 +76,7 @@ export async function resolveAuthorizedPath( } try { - // Why: Windows/WSL realpath can return UNC-shaped paths; re-resolve to compare against this module's allow-list roots. + // Normalize Windows/WSL realpath output before comparing allow-list roots. const realTarget = resolve(await realpath(resolvedTarget)) if ( !(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, { @@ -124,14 +121,14 @@ async function resolveAuthorizedMissingPath( throw error } if (await isSymlink(existingAncestor)) { - // Why: realpath can't say where a dangling link leads, but a write follows it and creates its target. + // Writes create dangling-link targets that realpath cannot authorize. throw new Error(PATH_ACCESS_DENIED_MESSAGE) } const parent = dirname(existingAncestor) if (parent === existingAncestor) { throw error } - // Why: create/copy make missing parents after auth; canonicalize nearest existing ancestor to catch symlink escapes without rejecting nested paths. + // Canonicalize the nearest existing ancestor to catch symlink escapes through missing parents. missingSegments.unshift(basename(existingAncestor)) existingAncestor = parent } @@ -181,7 +178,7 @@ async function isPathAllowedIncludingRegisteredWorktrees( await ensureAuthorizedRootsCache(store) - // Why: linked worktrees are already git-trusted; reuse the cached root index so reads don't spawn `git worktree list` each time. + // Reuse git-trusted registered roots without spawning git worktree list on each read. return ( isRegisteredWorktreePath(targetPath, store) || (await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath, store)) @@ -202,7 +199,7 @@ async function isPathAllowedByCanonicalAllowedRoot( if (!isDescendantOrEqual(sourcePath, resolvedRoot)) { continue } - // Why: macOS resolves /var→/private/var; canonicalize only the matched root, not the whole repo set. + // Resolve macOS /var symlinks only for the matching root, not every repo. const canonicalRoot = await normalizeExistingPath(resolvedRoot) if (isDescendantOrEqual(targetPath, canonicalRoot)) { return true diff --git a/src/main/ipc/filesystem-branch-compare-diff.test.ts b/src/main/ipc/filesystem-branch-compare-diff.test.ts index 0ec7cc974ad..e19a0e78b90 100644 --- a/src/main/ipc/filesystem-branch-compare-diff.test.ts +++ b/src/main/ipc/filesystem-branch-compare-diff.test.ts @@ -54,8 +54,7 @@ import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cach describe('registerFilesystemHandlers', () => { beforeEach(() => { resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) @@ -87,10 +86,7 @@ describe('registerFilesystemHandlers', () => { }) it('allows git operations on worktrees outside repo/workspace roots', async () => { - // Linked worktrees can live anywhere on disk (e.g. ~/.codex/worktrees/). - // As long as the path matches a worktree reported by `git worktree list` - // for a registered repo, it should be allowed — the security boundary is - // worktree registration, not directory containment. + // Git-registered worktrees may live outside the repo; registration defines the boundary. const externalWorktreePath = path.resolve('/external/worktrees/feature') listWorktreesMock.mockResolvedValue([ { @@ -133,21 +129,15 @@ describe('registerFilesystemHandlers', () => { }) it('rejects branchCompare for a worktree added after cache was built, then succeeds after invalidation', async () => { - // Reproduces the bug where CLI-created worktrees fail with - // "Access denied: unknown repository or worktree path" because the - // filesystem-auth cache was not invalidated after creation. const cliWorktreePath = path.resolve('/external/cli-created-worktree') - // Step 1: register handlers and trigger initial cache build with only - // the original worktree in the listing. registerFilesystemHandlers(store as never) - // Warm the cache by calling a git operation on the existing worktree. + // Warm authorization before simulating CLI creation. getStatusMock.mockResolvedValue({ entries: [] }) await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) - // Step 2: simulate the CLI creating a new worktree — git now lists it, - // but the auth cache is stale. + // Git now lists the CLI-created worktree, but authorization still has its old cache. listWorktreesMock.mockResolvedValue([ { path: WORKTREE_FEATURE_PATH, @@ -165,8 +155,6 @@ describe('registerFilesystemHandlers', () => { } ]) - // Step 3: branchCompare on the new worktree should fail — this is the - // exact error the user reported. await expect( handlers.get('git:branchCompare')!(null, { worktreePath: cliWorktreePath, @@ -174,10 +162,8 @@ describe('registerFilesystemHandlers', () => { }) ).rejects.toThrow('Access denied: unknown repository or worktree path') - // Step 4: invalidate the cache (what our fix does after CLI create). invalidateAuthorizedRootsCache() - // Step 5: the same branchCompare should now succeed. getBranchCompareMock.mockResolvedValue({ summary: { baseRef: 'origin/main', @@ -222,8 +208,7 @@ describe('registerFilesystemHandlers', () => { oldPath: 'src/old-file.ts' }) - // Why: validateGitRelativeFilePath uses path.relative() which produces - // platform-specific separators (backslashes on Windows). + // Expected Git paths use the host's path.relative separators. expect(getBranchDiffMock).toHaveBeenCalledWith( WORKTREE_FEATURE_PATH, { diff --git a/src/main/ipc/filesystem-canonical-owner.test.ts b/src/main/ipc/filesystem-canonical-owner.test.ts index ec4888a3f52..cb7a02a9cdb 100644 --- a/src/main/ipc/filesystem-canonical-owner.test.ts +++ b/src/main/ipc/filesystem-canonical-owner.test.ts @@ -39,11 +39,7 @@ vi.mock('../project-runtime-git-options', () => ({ resolveLocalProjectRuntimesForRepos: mocks.projectRuntimes })) -/** - * Widened past `Repo['executionHostId']` on purpose: the union names the stamps Orca writes, while a - * store carries whatever an older build, a hand-edited catalog or a partial migration left behind. - * Those are exactly the stamps authorization has to place, so the matrix must be able to build them. - */ +/** Widen owner stamps to exercise older catalogs, malformed input and partial migrations beyond the typed union. */ type Owner = { connectionId?: string | null; executionHostId?: string | null } const root = resolve('/owner-fixture') const linked = resolve('/linked-fixture') @@ -121,8 +117,7 @@ const deniedOwners: { name: string; owner: Owner }[] = [ name: 'runtime with legacy SSH', owner: { executionHostId: 'runtime:env', connectionId: 'host-a' } }, - // Host ids the shared parser rejects. Each must fail closed: an owner we cannot place is not - // evidence of a local one, and a bare `getSshTargetIdForExecutionHost` answers null for all five. + // Rejected host IDs must fail closed; null SSH extraction alone does not prove local ownership. { name: 'empty SSH target', owner: { executionHostId: 'ssh:' } }, { name: 'undecodable SSH target', owner: { executionHostId: 'ssh:%zz' } }, { name: 'alias-delimiter SSH target', owner: { executionHostId: 'ssh:host-a|alias' } }, @@ -270,15 +265,13 @@ it('denies a workspace pinned local under a group carrying only a legacy connect const store = storeFor([], [legacyGroup], [pinnedFolder]) expect(getAllowedRoots(store)).toEqual([]) expect(isPathAllowed(join(root, 'file'), store)).toBe(false) - // Dispatch reads the same row as local; authorization is deliberately the stricter of the two, - // because agreeing would grant a root this store refuses today. + // Dispatch accepts the local pin, but authorization preserves the stricter legacy ownership check. expect( resolveFolderWorkspaceHost( { folderWorkspaces: [pinnedFolder], projectGroups: [legacyGroup], repos: [] }, 'folder' ) ).toEqual({ kind: 'local' }) - // The mirrored row needs no such note: the workspace's own legacy connection is remote on both sides. const mirrored = storeFor( [], [group({ executionHostId: 'local' }, { parentPath: null })], @@ -306,7 +299,6 @@ describe('workspace-directory fallback', () => { storeFor([repo({ executionHostId: 'ssh:host-a' })], [], [], { workspaceDir }) ) expect(local).toEqual([root, workspaceDir]) - // The SSH repo's own path is gone; nothing beyond the fallback the same config already granted. expect(sshOnly).toEqual([workspaceDir]) expect( isPathAllowed( @@ -338,15 +330,12 @@ describe('workspace-directory fallback', () => { ).toEqual([]) }) - // Why guarded rather than injected: this is the end-to-end claim that the foreign-flavour string - // never reaches `resolve`, so it has to run against the real host path module — and on Windows the - // same string is a legitimate root. The flavour matrix below covers both hosts unguarded. + // Use the real host module here; the injected flavor matrix below covers both platforms. it.skipIf(process.platform === 'win32')( 'does not anchor a Windows-style workspace directory under the POSIX main-process cwd', () => { const store = storeFor([], [], [], { workspaceDir: 'C:\\workspaces' }) expect(getAllowedRoots(store)).toEqual([]) - // The cross-platform predicate used to grant exactly this: `/C:\workspaces`. expect(isPathAllowed(join(resolve('C:\\workspaces'), 'file'), store)).toBe(false) } ) diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index ed76d0bf65a..0bdd7bcdffc 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -57,8 +57,7 @@ import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-er describe('registerFilesystemHandlers', () => { beforeEach(() => { resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) @@ -260,9 +259,7 @@ describe('registerFilesystemHandlers', () => { ) }) - // Why: folder-repo instances keep `::workspace:` on the meta key while the - // request path is the stripped cwd. A strip-before-lookup "cleanup" would still - // pass plain-id tests and silently lose enrichment on second workspaces. + // Folder workspace metadata retains ::workspace:; stripping before lookup would lose enrichment. it('enriches local commit context when the worktree id carries a folder-repo workspace suffix', async () => { const context = { branch: 'feature/ai', @@ -293,8 +290,7 @@ describe('registerFilesystemHandlers', () => { ) }) - // Why: the renderer derives worktreePath from worktreeId, so a mismatched pair - // models an independent caller (relay/CLI/future), not a stale renderer context. + // Model an independent caller's mismatched ID/path; renderer derives both from the same worktree. it('ignores an independently supplied id that does not own the requested worktree path', async () => { const context = { branch: 'feature/ai', @@ -315,7 +311,7 @@ describe('registerFilesystemHandlers', () => { }) expect(getWorktreeMeta).not.toHaveBeenCalled() - // Why: without this the assertion below passes vacuously on an early return. + // A call count prevents vacuous success on an unrelated early return. expect(generateCommitMessageFromContextMock.mock.calls).toHaveLength(1) expect(generateCommitMessageFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( 'linkedIssue' diff --git a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts index c324f0e9536..765ca9a3baf 100644 --- a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts +++ b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts @@ -52,8 +52,7 @@ import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cach describe('registerFilesystemHandlers', () => { beforeEach(() => { resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) diff --git a/src/main/ipc/filesystem-conflict-operation-routing.test.ts b/src/main/ipc/filesystem-conflict-operation-routing.test.ts index def0f98c8b4..ec5056542c1 100644 --- a/src/main/ipc/filesystem-conflict-operation-routing.test.ts +++ b/src/main/ipc/filesystem-conflict-operation-routing.test.ts @@ -41,8 +41,7 @@ import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' -// Why: `git:conflictOperation` reads the worktree's `.git` pointer directly, so it has to run in -// the same host namespace as that worktree's git — a WSL project answers in the guest namespace. +// Read .git in the worktree's own host namespace, including WSL. describe('git:conflictOperation local routing', () => { beforeEach(() => { resetFilesystemIpcMocks() diff --git a/src/main/ipc/filesystem-create-path-guards.ts b/src/main/ipc/filesystem-create-path-guards.ts index 625e75c1095..3ae4bc131f8 100644 --- a/src/main/ipc/filesystem-create-path-guards.ts +++ b/src/main/ipc/filesystem-create-path-guards.ts @@ -2,11 +2,7 @@ import { lstat } from 'node:fs/promises' import { basename } from 'node:path' import { isENOENT } from './filesystem-path-containment' -/** - * Re-throw filesystem errors with user-friendly messages. - * The `wx` flag on writeFile throws a raw EEXIST with no helpful message, - * so we catch it here and provide context the renderer can display directly. - */ +/** Translate raw filesystem errors, including exclusive-create EEXIST, into renderer-facing messages. */ export function rethrowWithUserMessage(error: unknown, targetPath: string): never { const name = basename(targetPath) if (error instanceof Error && 'code' in error) { @@ -21,14 +17,7 @@ export function rethrowWithUserMessage(error: unknown, targetPath: string): neve throw error } -/** - * Ensure `targetPath` does not already exist. Throws if it does. - * - * Note: this is a non-atomic check — a concurrent operation could create the - * path between `lstat` and the caller's next action. Acceptable for a desktop - * app with low concurrency; `createFile` uses the `wx` flag for an atomic - * alternative where possible. - */ +/** Non-atomic existence check; createFile uses wx when an atomic alternative is available. */ export async function assertNotExists(targetPath: string): Promise { try { await lstat(targetPath) diff --git a/src/main/ipc/filesystem-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts index bca599738b0..e764539de57 100644 --- a/src/main/ipc/filesystem-document-folder-writes.test.ts +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -123,7 +123,6 @@ describe('renaming a document the user opened outside every project', () => { expect(await readdir(docFolder)).toEqual(['note.md']) }) - // Why each destination is undone: the Undo declares the moved file, so it must come back from anywhere. it('moves it into another outside folder, and Undo brings it back', async () => { await mkdir(join(base, 'other')) const moved = join(base, 'other', 'note.md') diff --git a/src/main/ipc/filesystem-download-transfers.test.ts b/src/main/ipc/filesystem-download-transfers.test.ts index 2344fce4fee..2f511df96ef 100644 --- a/src/main/ipc/filesystem-download-transfers.test.ts +++ b/src/main/ipc/filesystem-download-transfers.test.ts @@ -64,8 +64,7 @@ describe('registerFilesystemHandlers', () => { folderDownloadSender.removeAllListeners() folderDownloadSender.isDestroyed.mockReset().mockReturnValue(false) resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 175b3be97a4..b323ae088ad 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -57,8 +57,7 @@ import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-er describe('registerFilesystemHandlers', () => { beforeEach(() => { resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) @@ -147,8 +146,7 @@ describe('registerFilesystemHandlers', () => { describe('registerFilesystemHandlers pull request field generation', () => { beforeEach(() => { resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) @@ -235,8 +233,7 @@ describe('registerFilesystemHandlers pull request field generation', () => { }) expect(getWorktreeMeta).not.toHaveBeenCalled() - // Why: without the length guard the property assertion passes vacuously on `undefined`, - // so an unrelated early return would read as "enrichment correctly suppressed". + // A call count prevents vacuous success on undefined after an early return. expect(generatePullRequestFieldsFromContextMock.mock.calls).toHaveLength(1) expect(generatePullRequestFieldsFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( 'linkedIssue' diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index d82eb21d010..d496220f4d7 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -67,8 +67,7 @@ import { describe('registerFilesystemHandlers', () => { beforeEach(() => { resetFilesystemIpcMocks() - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) @@ -82,8 +81,7 @@ describe('registerFilesystemHandlers', () => { filePath: './src/../src/file.ts' }) - // Why: validateGitRelativeFilePath uses path.relative() which produces - // platform-specific separators (backslashes on Windows). + // Expected Git paths use the host's path.relative separators. expect(stageFileMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, path.join('src', 'file.ts'), { admissionTier: 'interactive' }) diff --git a/src/main/ipc/filesystem-import-local-tree-copy.ts b/src/main/ipc/filesystem-import-local-tree-copy.ts index 06d68368ce9..3a290655e08 100644 --- a/src/main/ipc/filesystem-import-local-tree-copy.ts +++ b/src/main/ipc/filesystem-import-local-tree-copy.ts @@ -3,10 +3,6 @@ import { lstat, mkdir, open, readdir, rm, unlink } from 'node:fs/promises' import { basename, join } from 'node:path' import { pipeline } from 'node:stream/promises' -/** - * Pre-scan a directory tree for symlinks. Returns true if any symlink - * is found anywhere in the subtree. - */ export async function preScanForSymlinks(dirPath: string): Promise { const entries = await readdir(dirPath, { withFileTypes: true }) for (const entry of entries) { @@ -87,9 +83,7 @@ export async function copyLocalFileNoFollow( ) { throw new Error(`File changed during import: '${basename(srcPath)}'`) } - // Why: copyFile(path, path) would follow a source symlink if the source is - // swapped after validation. Streaming from an O_NOFOLLOW handle keeps the - // authorized file identity pinned for the copy. + // Copy from the authorized O_NOFOLLOW handle so a swapped source cannot redirect through a symlink. destinationHandle = await open(dstPath, 'wx') destinationCreated = true await pipeline(sourceHandle.createReadStream(), destinationHandle.createWriteStream()) diff --git a/src/main/ipc/filesystem-import-local.ts b/src/main/ipc/filesystem-import-local.ts index a6482b25f56..08605eb1ee0 100644 --- a/src/main/ipc/filesystem-import-local.ts +++ b/src/main/ipc/filesystem-import-local.ts @@ -10,10 +10,6 @@ import { recursiveCopyDir } from './filesystem-import-local-tree-copy' -/** - * Import a single top-level source into destDir, handling validation, pre-scan, - * deconfliction, and copy. - */ export async function importOneSource( sourcePath: string, destDir: string, @@ -27,9 +23,7 @@ export async function importOneSource( } const isDir = classified.stat.isDirectory() - // Why: for directories, pre-scan the entire tree for symlinks before - // creating any destination files. This prevents partially imported - // trees when a symlink is discovered halfway through recursive copy. + // Reject symlinks before creating destinations to avoid partially copied trees. if (isDir) { const hasSymlink = await preScanForSymlinks(resolvedSource) if (hasSymlink) { @@ -37,7 +31,6 @@ export async function importOneSource( } } - // Top-level deconfliction: generate a unique name if collision exists const originalName = basename(resolvedSource) const finalName = await deconflictCopyName(originalName, (n) => nameExists(destDir, n).then((exists) => exists || reservedNames.has(n)) diff --git a/src/main/ipc/filesystem-import-source-stat.ts b/src/main/ipc/filesystem-import-source-stat.ts index ddf4c2edf4c..4a0707bbaea 100644 --- a/src/main/ipc/filesystem-import-source-stat.ts +++ b/src/main/ipc/filesystem-import-source-stat.ts @@ -13,7 +13,7 @@ export async function classifyImportSource( sourcePath: string, resolvedSource: string ): Promise<{ stat: Stats } | { rejection: ImportSourceRejection }> { - // Why: lstat the unresolved path before canonicalization so top-level symlinks are rejected, not dereferenced. + // lstat before canonicalization so top-level symlinks are rejected, not followed. let stat: Stats try { stat = await lstat(resolvedSource) @@ -26,7 +26,7 @@ export async function classifyImportSource( } return { rejection: { sourcePath, status: 'failed', reason: errorMessage(error) } } } - // Why: symlink copy semantics differ across platforms, and following one can escape the dropped subtree. + // Symlinks can escape the dropped subtree and differ across platforms. if (stat.isSymbolicLink()) { return { rejection: { sourcePath, status: 'skipped', reason: 'symlink' } } } diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index c2df10fd70b..b54a9466369 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -514,7 +514,7 @@ describe('fs:importExternalPaths', () => { } ]) expect(copyFileMock).not.toHaveBeenCalled() - // Why: bodies stream at upload time, so staging must never read the file. + // Bodies stream at upload time; staging must not read them. expect(readFileHandleMock).not.toHaveBeenCalled() expect(closeMock).toHaveBeenCalled() }) @@ -652,7 +652,6 @@ describe('fs:importExternalPaths', () => { path.join(resolvedPath, name) ) const mib = 1024 * 1024 - // Four files exactly fill the 8 GB total ceiling; the fifth pushes past it. const regularSize = 2 * 1024 * mib const overflowSize = Number(mib) const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) diff --git a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts index a70456f9e52..29cec31e0a8 100644 --- a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts @@ -95,7 +95,7 @@ describe('fs:listFiles local name filter', () => { expect(listQuickOpenFilesMock).not.toHaveBeenCalled() }) - // Why #7721: a workspace switch must stop the previous workspace's full-tree scan. + // Workspace switches must cancel prior full-tree scans (#7721). it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])( 'aborts an in-flight file listing on %s (#7721)', async (eventName) => { @@ -111,7 +111,7 @@ describe('fs:listFiles local name filter', () => { ) registerHandlers() - // Why: cancellation keys are scoped to the issuing webContents. + // Cancellation tokens belong to the issuing WebContents. const senderEvent = { sender: Object.assign(new EventEmitter(), { id: 7 }) } const pending = Promise.resolve( handlers.get('fs:listFiles')!(senderEvent, { rootPath: '/repo', requestToken: 'token-1' }) @@ -128,7 +128,6 @@ describe('fs:listFiles local name filter', () => { await expect(pending).rejects.toThrow('listing cancelled') expect(senderEvent.sender.eventNames()).toEqual([]) - // Unknown or already-settled tokens are a no-op, not an error. expect(() => handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' }) ).not.toThrow() diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index cb9ec9926a7..90c0eeae43c 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -15,8 +15,7 @@ const { vi.mock('child_process', () => ({ spawn: spawnMock, - // runner.ts imports these from child_process; stubs prevent - // "missing export" errors when the mock is resolved transitively. + // Transitive runner imports require both child_process exports in the mock. execFile: vi.fn(), execFileSync: vi.fn() })) @@ -160,9 +159,7 @@ describe('filesystem-list-files', () => { await expect(listQuickOpenFiles('/repo', {} as Store)).rejects.toThrow('EMFILE') }) - // Why close(97) and not a spawn error: this is the WSL wrapper's "cd failed" code. It is above - // rg's own 0/1/2, so a handler that checks it after the unavailable branch reports a broken - // install instead -- a regression this file would otherwise not catch. + // WSL cd failure is 97; classify it before generic unavailable checks for codes above rg's 0/1/2. it('names the unreachable root when the WSL wrapper cannot enter it', async () => { const child = createMockProcess() spawnMock.mockReturnValue(child) @@ -239,19 +236,18 @@ describe('filesystem-list-files', () => { expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0]?.[1]).toContain('--no-ignore-vcs') - // Simulate stdout output for normal files setTimeout(() => { p1.stdout?.emit('data', 'file1.ts\0') p1.stdout?.emit('data', 'node_modules/bad.js\0') p1.stdout?.emit('data', '.git/config\0') p1.stdout?.emit('data', '.github/workflows/ci.yml\0') - p1.stdout?.emit('data', 'dir1/') // incomplete line + p1.stdout?.emit('data', 'dir1/') p1.stdout?.emit('data', 'file2.js\0') - // The broad pass includes ignored files too. + // Broad pass includes ignored files. p1.stdout?.emit('data', '.env.local\0') p1.stdout?.emit('data', 'dist/generated.js\0') - p1.stdout?.emit('data', 'file1.ts\0') // Duplicate + p1.stdout?.emit('data', 'file1.ts\0') p1.stdout?.emit('data', 'node_modules/ignored.js\0') p1.emit('close', 0, null) }, 10) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index fec4fad9f51..26bbc8d1a7d 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -51,10 +51,7 @@ export async function listQuickOpenFiles( { followSymlinks: options.followSymlinks } ) - // Why: when the main worktree sits at the repo root, linked worktrees are - // nested subdirectories. Without excluding them, rg/git lists files from - // every worktree instead of just the active one. The shared helper - // normalizes, validates, and root-relativizes every input. + // Exclude nested linked worktrees so listing the main repo root cannot traverse every checkout. const excludePathPrefixes = [ ...new Set([ ...buildExcludePathPrefixes(rootPath, excludePaths), @@ -66,23 +63,19 @@ export async function listQuickOpenFiles( const inventoryBudget = maxResults === undefined && maxSerializedBytes === undefined ? new FileInventoryBudget() : null const files = new Set() - let serializedBytes = 2 // [] + let serializedBytes = 2 const children: { child: ChildProcess isDone: () => boolean finish: (error?: Error) => void }[] = [] - // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry - // their distro in the path; Windows-path repos carry it in project runtime. + // WSL rg may emit absolute Linux paths; distro identity comes from UNC paths or project runtime. const { primary, ignoredPass } = buildRgArgsForQuickOpen({ - // Why: rg evaluates root-relative exclude globs against cwd only when the - // search target is cwd-relative. With an absolute target, `!packages/app` - // filters output after traversal but does not prune the nested worktree. + // Use a relative search root so root-relative exclusion globs prune traversal instead of only filtering output. searchRoot: '.', followSymlinks: options.followSymlinks, excludePathPrefixes, - // On Windows, rg outputs '\\'-separated paths; force '/'. Also force on - // macOS/Linux for idempotence — it's a no-op there. + // Normalize Windows output to forward slashes. forceSlashSeparator: sep === '\\' }) @@ -177,22 +170,17 @@ export async function listQuickOpenFiles( } const handleError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true - // Why: treat spawn errors like an abnormal exit — discard residual - // buffer so a truncated final byte sequence cannot leak as a path. + // Discard truncated final bytes on spawn errors just as on abnormal exits. buf = '' - // Why: fd/process pressure is not a broken install; say so instead of blaming the bundled binary. + // Resource pressure is transient, not a broken bundled installation. if (isTransientRipgrepSpawnError(error)) { finish(new Error(`rg could not start (${error.code}); try again`)) return } if (isRipgrepUnavailableExit(child, null, null)) { - // Why the cwd check: spawn reports a missing cwd as ENOENT too, and blaming the binary - // for it tells the user to reinstall Orca over a workspace that simply moved. - // Why detach close first: a failed spawn emits error THEN close(code < 0), and close - // settles synchronously, so this probe would otherwise race it on a sub-millisecond - // margin -- two measurements disagreed on which wins. Detaching makes it deterministic. + // ENOENT may mean missing cwd; detach close first so its synchronous failure cannot race the async probe. child.off('close', handleClose) - // Why catch: a failed probe must not strand the search; fall back to the prior verdict. + // A failed cwd probe must not strand the listing; preserve the prior classification. void isRipgrepSpawnCwdUsable(authorizedRootPath) .catch(() => true) .then((usable) => { @@ -205,8 +193,7 @@ export async function listQuickOpenFiles( finish(new Error('rg failed to start')) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { - // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise - // read an unreachable workspace as a broken install and tell the user to reinstall Orca. + // WSL missing-cwd codes exceed rg's 0/1/2; classify before the generic unavailable branch. if (isRipgrepMissingCwdExit(code)) { buf = '' finish(ripgrepMissingCwdError(authorizedRootPath)) @@ -223,9 +210,7 @@ export async function listQuickOpenFiles( return } if (signal) { - // Why: a signal exit means timeout/OOM/external kill. Returning the - // already-streamed prefix would recreate the false-empty bug this - // path is meant to avoid. + // Signal exits leave incomplete prefixes; never report them as successful listings. buf = '' finish(new Error(`rg killed by ${signal}`)) return @@ -252,8 +237,7 @@ export async function listQuickOpenFiles( } done = true clearTimeout(timer) - // Why: child.kill() is advisory. If rg ignores it, detach our - // closures so repeated Quick Open attempts do not retain old scans. + // kill is advisory; detach closures so ignored signals cannot retain old scans. child.stdout?.off('data', handleStdoutData) child.stderr?.off('data', handleStderrData) child.off('error', handleError) @@ -282,8 +266,7 @@ export async function listQuickOpenFiles( child.once('error', handleError) child.once('close', handleClose) timer = setTimeout(() => { - // Why: on timeout, the buffer is likely truncated mid-path. Discard - // it so Quick Open never displays a malformed entry. + // Discard timeout buffers that may end mid-path. buf = '' killSpawnedRipgrepProcess(child) finish(new Error('rg list timed out')) @@ -295,7 +278,6 @@ export async function listQuickOpenFiles( }) const killSurvivors = (error?: Error): void => { - // Failed listings must release any process still walking the tree. for (const entry of children) { if (entry.isDone()) { continue @@ -317,14 +299,13 @@ export async function listQuickOpenFiles( // Candidate membership has no primary-first ordering, so one broader pass is enough. await runRg(ignoredPass) } else { - // Why: ignored-file output can be much larger and faster than the primary pass; let source - // files claim every bounded autocomplete budget first, including the transport byte cap. + // Give source files first claim on bounded result/transport budgets before large ignored-file output. await runRg(primary) if ( (maxResults === undefined || files.size < maxResults) && (maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes) ) { - // Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches. + // Keep primary matches if the full-tree ignored pass times out. await runRg(ignoredPass).catch((err: unknown) => { if ( !pathFilter || diff --git a/src/main/ipc/filesystem-markdown-document-listing.test.ts b/src/main/ipc/filesystem-markdown-document-listing.test.ts index f8c61655e7a..52e74e80518 100644 --- a/src/main/ipc/filesystem-markdown-document-listing.test.ts +++ b/src/main/ipc/filesystem-markdown-document-listing.test.ts @@ -68,8 +68,7 @@ describe('registerFilesystemHandlers', () => { resetFilesystemIpcMocks() listMarkdownDocumentsMock.mockReset().mockResolvedValue([]) localOptionsMock.mockReset().mockReturnValue({}) - // Reset module-level auth cache so each test starts with a fresh dirty - // flag — prevents stale worktree data from a prior test's cache rebuild. + // Drop cached roots between fixtures. invalidateAuthorizedRootsCache() }) diff --git a/src/main/ipc/filesystem-mutations-runtime-upload.test.ts b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts index fa7edc1d5b7..357bf8a5900 100644 --- a/src/main/ipc/filesystem-mutations-runtime-upload.test.ts +++ b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts @@ -168,8 +168,7 @@ describe('fs:uploadExternalFileToRuntime', () => { }) ) - // Why: the sweep contract is "never rejects"; if it ever did, this documents - // that the handler would surface the sweep error instead of the upload's. + // A broken never-rejects sweep would mask the original upload failure. await expect(invoke(sender)).rejects.toThrow('sweep exploded') expect(listenerCount(sender)).toBe(0) }) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 4aaf23bc9ab..f9b934648ab 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -34,8 +34,7 @@ import { setSshConnectionGeneration } from '../ssh/connection/ssh-connection-generation' -// Why: paths are resolved via path.resolve() in production code, so test -// data must use resolved paths to avoid Unix-vs-Windows mismatches. +// Resolve fixture paths like production to preserve cross-platform comparisons. const REPO_PATH = path.resolve('/workspace/repo') const WORKSPACE_DIR = path.resolve('/workspace') @@ -79,7 +78,6 @@ describe('registerFilesystemMutationHandlers', () => { handlers.set(channel, handler) }) - // By default, paths resolve to themselves and targets don't exist yet realpathMock.mockImplementation(async (p: string) => p) lstatMock.mockRejectedValue(enoent()) mkdirMock.mockResolvedValue(undefined) @@ -90,8 +88,6 @@ describe('registerFilesystemMutationHandlers', () => { registerFilesystemMutationHandlers(store as never) }) - // ── fs:createFile ────────────────────────────────────────────── - it('creates an empty file and its parent directories', async () => { const filePath = path.resolve('/workspace/repo/src/new.ts') await handlers.get('fs:createFile')!(null, { filePath }) @@ -104,8 +100,7 @@ describe('registerFilesystemMutationHandlers', () => { }) it('rejects file creation when path already exists (wx flag)', async () => { - // The wx flag causes writeFile to throw EEXIST atomically, without a - // separate lstat check — no TOCTOU race. + // wx rejects collisions atomically without an existence-check race. writeFileMock.mockRejectedValue(Object.assign(new Error('EEXIST'), { code: 'EEXIST' })) await expect( @@ -127,8 +122,6 @@ describe('registerFilesystemMutationHandlers', () => { expect(writeFileMock).not.toHaveBeenCalled() }) - // ── fs:createDir ─────────────────────────────────────────────── - it('creates a directory recursively', async () => { const dirPath = path.resolve('/workspace/repo/src/components') await handlers.get('fs:createDir')!(null, { dirPath }) @@ -158,8 +151,6 @@ describe('registerFilesystemMutationHandlers', () => { expect(mkdirMock).not.toHaveBeenCalled() }) - // ── fs:rename ────────────────────────────────────────────────── - it('renames a file within the same directory', async () => { const oldPath = path.resolve('/workspace/repo/old.ts') const newPath = path.resolve('/workspace/repo/new.ts') @@ -308,9 +299,7 @@ describe('registerFilesystemMutationHandlers', () => { }) it('rejects rename when parent directory escapes allowed roots', async () => { - // Why: the parent is still canonicalized (preserveSymlink only preserves - // the leaf). A symlinked ancestor that points outside allowed roots must - // still be rejected so callers cannot redirect rename through it. + // Preserve only the leaf; symlinked ancestors must still be contained. mockRealpath({ [path.resolve('/workspace/repo/escape-dir')]: path.resolve('/private/escape-dir') }) @@ -326,11 +315,7 @@ describe('registerFilesystemMutationHandlers', () => { }) it('renames a symlink without following its target', async () => { - // Why: rename must operate on the symlink entry, not its target — - // following the link would rename the target file (possibly elsewhere in - // the worktree, or outside allowed roots entirely). Even though the - // symlink points at /private/secret.ts, renaming the link entry inside - // the allowed root is a safe directory-entry mutation. + // Rename the allowed link entry, even when its target is outside every root. mockRealpath({ [path.resolve('/workspace/repo/symlink.ts')]: path.resolve('/private/secret.ts') }) @@ -430,8 +415,6 @@ describe('registerFilesystemMutationHandlers', () => { } ) - // ── fs:copy ──────────────────────────────────────────────────── - it('copies a file without overwriting an existing destination', async () => { const sourcePath = path.resolve('/workspace/repo/source.ts') const destinationPath = path.resolve('/workspace/repo/source copy.ts') @@ -442,8 +425,6 @@ describe('registerFilesystemMutationHandlers', () => { expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number)) }) - // ── Edge cases ───────────────────────────────────────────────── - it('propagates non-ENOENT lstat errors in assertNotExists', async () => { lstatMock.mockRejectedValue(new Error('EPERM: operation not permitted')) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 9c1d0951a38..4486898f4ad 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -31,10 +31,6 @@ import { sweepAbandonedRuntimeUploadTempPath } from './runtime-upload-temp-sweep import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' import { resolveEnvironment } from '../../shared/runtime-environment-store' -/** - * IPC handlers for file/folder creation and renaming. - * Deletion is handled separately via `fs:deletePath` (shell.trashItem). - */ export function registerFilesystemMutationHandlers(store: Store): void { ipcMain.handle( 'fs:createFile', @@ -54,7 +50,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { const filePath = await resolveDesktopAuthorizedPath(args.filePath, store) await mkdir(dirname(filePath), { recursive: true }) try { - // Use the 'wx' flag for atomic create-if-not-exists, avoiding TOCTOU races + // Exclusive creation closes the existence-check race. await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) } catch (error) { rethrowWithUserMessage(error, filePath) @@ -83,9 +79,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) - // Note: fs.rename throws EXDEV if old and new paths are on different - // filesystems/volumes. This is unlikely since both paths are under the same - // workspace root, but a cross-drive rename would surface as an IPC error. + // Cross-volume rename propagates EXDEV through IPC. ipcMain.handle( 'fs:rename', async ( @@ -106,13 +100,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { if (args.connectionId) { throw new SshHostNotServedError(args.connectionId) } - // Why: rename() operates on directory entries, not file contents. If - // oldPath is a symlink, we must rename the link itself rather than - // resolving it to its target — following the link would rename the - // target file (potentially elsewhere in the worktree) and leave the - // symlink dangling. newPath must also preserve its leaf so we don't - // accidentally write into a symlinked destination name. - // Outside every project, a document the user opened may still be renamed, to any path. + // Preserve both leaves: rename acts on directory entries, including links; opened documents can move outside project roots. const { from, to } = await resolveLocalRenamePaths( args.oldPath, args.newPath, @@ -149,8 +137,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { preserveSymlink: true }) await mkdir(dirname(destinationPath), { recursive: true }) - // Why: duplicate/copy callers deconflict before copying. COPYFILE_EXCL - // keeps a late race from silently overwriting an existing file. + // Keep deconflicted copy destinations exclusive against late collisions. await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) } ) @@ -177,9 +164,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { throw new SshHostNotServedError(args.connectionId) } - // Why: destDir must be authorized before any copy work begins. If the - // destination is outside allowed roots, the entire import fails. - // An image inserted into a document the user opened lands in that document's own folder. + // Authorize before copying; opened-document images may target that document's own folder. const resolvedDest = await resolveLocalRequestPath( args.destDir, args.access, @@ -209,8 +194,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args: { sourcePaths: string[]; nameExtensionlessImages?: boolean } ): Promise<{ sources: StagedExternalImportSource[] }> => { const sources: StagedExternalImportSource[] = [] - // Why: one budget for the whole drop — per-source counters would let five - // 2 GB files through a ceiling meant to cap the drop. + // Apply the size ceiling across the whole drop, not separately to each source. let totalBytes = 0 for (const sourcePath of args.sourcePaths) { const source = await stageOneSourceForRuntimeUpload(sourcePath, totalBytes, { @@ -223,22 +207,17 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) - // Why: the file handle and the runtime socket both live in main, so the byte - // pump runs here. The renderer keeps deconflict/commit/rollback orchestration - // and never sees file contents. + // Main owns handles and runtime sockets; renderer retains commit/rollback orchestration without file bytes. ipcMain.handle( 'fs:uploadExternalFileToRuntime', async (event, args: RuntimeUploadFileStreamRequest): Promise<{ byteLength: number }> => { const userDataPath = app.getPath('userData') - // Why: the streamer's manual-disconnect check keys on the environment id, - // and the renderer may pass any selector the store resolves. + // Resolve selectors to the environment ID used by manual-disconnect checks. const request = { ...args, environmentId: resolveEnvironment(userDataPath, args.environmentId).id } - // Why: the renderer's own loop died with its window. Now that the bytes - // move in main, a reload or close has to stop the transfer explicitly, - // or a multi-GB upload outlives the window that asked for it. + // Main-owned uploads must abort when the requesting renderer reloads or closes. const lifetime = abortWhenRendererGone(event.sender) try { return await streamExternalFileToRuntime({ @@ -248,8 +227,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { }) } catch (error) { if (lifetime.signal.aborted) { - // Why: the renderer owns temp cleanup, and it is gone — so the - // abandoned temp path is only collectable from here. + // The renderer cannot clean up its temp path after it disappears. await sweepAbandonedRuntimeUploadTempPath(userDataPath, request) } throw error @@ -259,10 +237,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) - // Why: terminal drag-and-drop resolver. Local worktrees pass paths through - // unchanged (reference-in-place; preserves zero-latency drop). Kept as a - // separate IPC from fs:importExternalPaths because terminal semantics differ - // from the explorer's "copy into user-picked destDir". + // Terminal drops reference local paths in place; explorer imports copy into a picked destination. ipcMain.handle( 'fs:resolveDroppedPathsForAgent', async ( @@ -279,8 +254,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedSshConnectionGeneration, args.expectedExecutionHostId ) - // Why: `== null` (not `!args.connectionId`) so an empty string is - // treated as a renderer error, not silently routed to the local branch. + // Empty connection IDs are errors; only null/undefined selects local routing. if (args.connectionId == null) { return { resolvedPaths: resolveLocalDroppedPathsForAgent(args.paths, args.worktreePath), diff --git a/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts b/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts index aef3b693cda..4fdc040bb96 100644 --- a/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts +++ b/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts @@ -2,20 +2,7 @@ import { describe, expect, it } from 'vitest' import { isENOENT, isPathAbsentError } from './filesystem-path-containment' -/** - * Creating a worktree over SSH failed with a raw - * `ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234'`. - * - * The path was the one about to be created, so its absence was correct and expected — the caller - * (`remotePathExists`) asks exactly that question and returns false on ENOENT. It could not: the - * error was raised on the SSH host, crossed the relay as JSON-RPC, and - * `ssh-channel-multiplexer.handleResponse` rebuilds it with the TRANSPORT's numeric code. Node's - * 'ENOENT' string code is gone by construction, so the check never matched and the error was - * rethrown at the user. - * - * The stack in the trace log is what settles it — the throw originates in `handleResponse`, not in - * any local fs call, which is why hunting for a local `lstat` found nothing. - */ +/** Relay JSON-RPC preserves Node ENOENT messages but replaces string codes; remote creation must still recognize expected absence. */ describe('isENOENT across the SSH relay boundary', () => { it('recognises a local Node error by its code', () => { const local = new Error( @@ -27,7 +14,7 @@ describe('isENOENT across the SSH relay boundary', () => { }) it('recognises an error rebuilt from the relay with a numeric transport code', () => { - // Exactly what handleResponse produces: message preserved, code replaced by the JSON-RPC code. + // Relay response reconstruction preserves the message and replaces the code. const fromRelay = new Error( "ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234'" ) @@ -54,9 +41,7 @@ describe('isENOENT across the SSH relay boundary', () => { }) it('does not match a message that merely mentions the word', () => { - // The full canonical phrase is required, so a branch name, a log line, or a user's commit - // message cannot make an existing path look missing — which would silently skip collision - // checks rather than report them. + // Match the canonical phrase so unrelated ENOENT mentions cannot bypass collision checks. expect(isENOENT(new Error('fix: handle ENOENT better'))).toBe(false) expect(isENOENT(new Error('branch enoent-handling created'))).toBe(false) }) @@ -70,7 +55,7 @@ describe('isENOENT across the SSH relay boundary', () => { describe('isPathAbsentError', () => { it('treats a path under a file as absent, but not a denied one', () => { - // Why: `a.txt/b.txt` is the hard-wrap guess a terminal link click probes (#32128). + // Terminal hard-wrap guesses may probe a child under a file (#32128). const underFile = Object.assign(new Error("ENOTDIR: not a directory, stat '/r/a.txt/b.txt'"), { code: 'ENOTDIR' }) diff --git a/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts b/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts index 54922246cd9..09159ab925f 100644 --- a/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts +++ b/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts @@ -8,19 +8,7 @@ import type { Repo } from '../../shared/repo-types' import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from './filesystem-auth' import { isDescendantOrEqual } from './filesystem-path-containment' -/** - * Opening any file under a Korean-named workspace failed on macOS with - * "Access denied: path resolves outside allowed directories" (#21172). - * - * The file was inside the workspace the whole time. The two sides of the containment check reached - * it through different doors: the root was registered from the file picker or from git, which spell - * the name in NFC, while the path being read came back from the filesystem in NFD. Same name, same - * file on APFS, different bytes — so the guard reported an escape. - * - * "Same file" is the load-bearing half, so it is the half that gets proven: both spellings reaching - * one directory authorize the file, two distinct directories — which ext4 allows and APFS does not - * — leave the unregistered one denied. A symlink stands in for the APFS fold on a byte-exact host. - */ +/** NFC/NFD spellings must authorize only when they reach one filesystem object; model APFS folding with a symlink on byte-exact hosts (#21172). */ const FOLDER = '테스트프로젝트' const NFC_FOLDER = FOLDER.normalize('NFC') @@ -29,7 +17,7 @@ const NFD_FOLDER = FOLDER.normalize('NFD') const scratchDirs: string[] = [] async function makeScratchDir(): Promise { - // realpath first: macOS fronts the temp dir with a /var symlink of its own. + // Canonicalize macOS /var temp aliases before building fixtures. const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-unicode-path-')) scratchDirs.push(scratch) return scratch @@ -115,7 +103,6 @@ describe('path containment across Unicode forms', () => { const scratch = await makeScratchDir() const siblings = await makeDistinctSiblings(scratch) if (!siblings) { - // The filesystem folds the two names, so there is no second directory to reach. return } @@ -126,7 +113,7 @@ describe('path containment across Unicode forms', () => { }) it('denies a fold it cannot check against the filesystem', () => { - // Neither spelling exists on disk, so identity is unproven — and unproven is denied. + // Canonically equal names without proven on-disk identity must remain denied. expect( isDescendantOrEqual(resolve(`/repos/${NFD_FOLDER}/test.txt`), resolve(`/repos/${NFC_FOLDER}`)) ).toBe(false) @@ -163,7 +150,7 @@ describe('fs:readFile authorization for a Korean-named workspace', () => { await writeFile(file, 'hello') const store = makeStore(registered) - // Resolved before the assertion so a rejection lands on expect(), not on an unawaited promise. + // Await realpath first so assertion rejections stay handled. const expected = await realpath(file) await expect( diff --git a/src/main/ipc/filesystem-path-containment.ts b/src/main/ipc/filesystem-path-containment.ts index 9745e3fb81f..ad3a722ce14 100644 --- a/src/main/ipc/filesystem-path-containment.ts +++ b/src/main/ipc/filesystem-path-containment.ts @@ -3,13 +3,7 @@ import { statSync } from 'node:fs' import { realpath } from 'node:fs/promises' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -/** - * Check whether resolvedTarget is equal to or a descendant of resolvedBase. - * Uses relative() so it works with both `/` (Unix) and `\` (Windows) separators. - * - * Compared byte-exactly first, then across Unicode forms — but only where both spellings prove to - * be one filesystem object. - */ +/** Check native-path containment; accept Unicode-equivalent spellings only after proving filesystem identity. */ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string): boolean { if (isDescendantOrEqualExact(resolvedTarget, resolvedBase)) { return true @@ -23,15 +17,11 @@ function isDescendantOrEqualExact(resolvedTarget: string, resolvedBase: string): return true } const rel = relative(resolvedBase, resolvedTarget) - // Security: reject "..", "../…" or an absolute rel — on Windows relative() returns absolute across drives, which would bypass drive-traversal checks. - // Use isAbsolute, not rejoin+compare: Windows path.relative() ignores drive/root casing, so rejoining would deny valid c:\repo under C:\Repo. + // Reject parent/cross-drive escapes with isAbsolute; rejoin comparison would wrongly reject Windows drive/root case differences. return rel !== '' && !(rel === '..' || rel.startsWith(`..${sep}`)) && !isAbsolute(rel) } -/** - * Why a loop and not a regex: `[^\u0000-\u007f]` trips no-control-regex, and this runs once per - * registered root on every filesystem IPC, where charCodeAt beats an ICU-backed scan anyway. - */ +/** Avoid ICU scans and control-character regex linting on this per-root hot path. */ function hasNonAscii(value: string): boolean { for (let index = 0; index < value.length; index += 1) { if (value.charCodeAt(index) > 0x7f) { @@ -41,27 +31,8 @@ function hasNonAscii(value: string): boolean { return false } -/** - * The same name, spelled two ways. - * - * macOS returns a path in whichever Unicode form its source held: APFS gives back the form it - * stores — NFD for names typed into Finder — while the file picker and git (`core.precomposeunicode`) - * give back NFC. A workspace whose path contains Korean, accented or otherwise composed characters - * is registered in one form and read in the other, so byte comparison puts the file outside its own - * workspace and fs:readFile denies a path the user is looking at (#21172). ASCII paths are immune, - * which is why the guard held for so long. - * - * Canonical equivalence is not containment on its own: APFS folds both spellings onto one - * directory, ext4 keeps them as distinct siblings, and the unregistered sibling is not the root — - * equivalence includes singletons such as U+212A KELVIN SIGN folding to K. So the fold only locates - * the candidate ancestor, in the caller's spelling; isDescendantOrEqual settles identity. - * - * Walks up by component count, never by offset: NFD is longer than NFC. - */ +/** Locate a Unicode-equivalent ancestor by component count; NFD is longer than NFC. Filesystem identity must still prove containment (#21172). */ function foldedContainmentAncestor(resolvedTarget: string, resolvedBase: string): string | null { - // Both sides must carry non-ASCII before normalize() earns its allocation: ASCII is identical in - // every form, so a mismatch confined to it is a real one. Target first — it is the side the - // allow-list scan holds fixed while it walks every registered root. if (!hasNonAscii(resolvedTarget) || !hasNonAscii(resolvedBase)) { return null } @@ -78,14 +49,7 @@ function foldedContainmentAncestor(resolvedTarget: string, resolvedBase: string) return ancestor } -/** - * The same directory entry, not merely the same name — the question the fold is really asking, and - * only the filesystem can answer it. - * - * Fails closed: an ancestor that cannot be stat'ed has not shown it is the registered root, and ino - * is 0 on volumes that expose none. Reached only on a containment the exact comparison refused, so - * ASCII paths and non-folding roots still touch no disk. - */ +/** Fail closed unless both spellings have a nonzero inode and match filesystem identity; folded names alone can be distinct on ext4. */ function isSameFilesystemObject(pathA: string, pathB: string): boolean { try { const statA = statSync(pathA) @@ -96,10 +60,7 @@ function isSameFilesystemObject(pathA: string, pathB: string): boolean { } } -/** - * Node's canonical ENOENT message. Matched in full so a message that merely mentions the word — a - * log line, a user's branch name — cannot be mistaken for a missing path. - */ +// Match the full Node phrase so unrelated ENOENT mentions cannot imply absence. const ENOENT_MESSAGE = /\bENOENT: no such file or directory\b/ export function isENOENT(error: unknown): boolean { @@ -109,18 +70,7 @@ export function isENOENT(error: unknown): boolean { if ('code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { return true } - // Why the message is also consulted: an error raised on an SSH host crosses the relay as JSON-RPC, - // and ssh-channel-multiplexer rebuilds it with the TRANSPORT's numeric code (msg.error.code), so - // Node's 'ENOENT' string code no longer exists on the object by the time any caller sees it. Every - // caller here asks the same question — "is this path simply absent?" — and for a remote path the - // answer was unreachable: remotePathExists rethrew instead of returning false, which surfaced as a - // raw "ENOENT: no such file or directory, lstat ''" when creating a worktree over SSH. - // - // The cost, accepted rather than overlooked: a remote host can make an unrelated failure read as - // "absent" by putting that sentence in a message. Narrowing back to the code alone is not an - // option — the transport overwrites it, which IS the bug — and the blast radius is a host already - // trusted to run our relay. normalizeExistingPath is unaffected either way: its realpath runs - // locally and always carries a real .code, so symlink containment cannot be spoofed this way. + // Relay RPC replaces Node codes; accept its canonical message from trusted hosts. Local realpath containment still uses actual filesystem identity. return ENOENT_MESSAGE.test(error.message) } diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts index 8cf33a9168f..6f3adf2692d 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.test.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -6,8 +6,7 @@ import type * as RuntimeImportLimits from './runtime-import-limits' type RuntimeImportLimitsModule = typeof RuntimeImportLimits -// Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a -// literal over-limit fixture would allocate that much on Windows CI. +// Scale down gigabyte ceilings: NTFS truncate may allocate the entire oversized fixture. vi.mock('./runtime-import-limits', async (importOriginal) => ({ ...(await importOriginal()), REMOTE_IMPORT_MAX_FILE_BYTES: 4 * 1024, @@ -70,8 +69,7 @@ describe('stageOneSourceForRuntimeUpload', () => { const staged = await stageOneSourceForRuntimeUpload(filePath) expect(staged).toMatchObject({ status: 'failed' }) - // Why: a dropped file's relative path is '', so this is the regression that - // would otherwise report "'' is 6 KB, over the 4 KB ... limit". + // Dropped files have an empty relative path; errors must use their filename. expect(staged.status === 'failed' && staged.reason).toBe( "'clip.mp4' is 6 KB, over the 4 KB per-file remote import limit" ) @@ -91,7 +89,6 @@ describe('stageOneSourceForRuntimeUpload', () => { const filePath = join(workDir, 'second.bin') await writeFile(filePath, Buffer.alloc(3 * 1024)) - // Alone it fits; after 14 KB of earlier sources the 16 KB drop ceiling is gone. await expect(stageOneSourceForRuntimeUpload(filePath, 0)).resolves.toMatchObject({ status: 'staged' }) diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 9e2a561fcfb..8e1a1f1081d 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -26,12 +26,8 @@ export function stagedRuntimeUploadByteLength(source: StagedExternalImportSource ) } -/** - * @param totalBytesBefore Bytes already staged by earlier sources in the same drop, - * so the total ceiling covers the whole drop rather than each source alone. - * @param options.nameExtensionlessImages Append the sniffed image extension to an - * extensionless top-level file; only for Orca-owned destinations like `.orca/drops`. - */ +/** @param totalBytesBefore Earlier staged bytes in this drop; the ceiling spans all sources. + * @param options.nameExtensionlessImages Append a sniffed image suffix only in Orca-owned destinations. */ export async function stageOneSourceForRuntimeUpload( sourcePath: string, totalBytesBefore = 0, @@ -62,8 +58,7 @@ export async function stageOneSourceForRuntimeUpload( return { sourcePath, status: 'staged', - // Why: some drag sources hand over images as an extensionless `noname`; the - // agent reading the dropped path needs `.png` to know it is an image. + // Extensionless drag images need a suffix for agents to recognize their format. name: staged.imageExtension ? `${sourceName}.${staged.imageExtension}` : sourceName, kind: 'file', entries: [staged.entry] @@ -140,8 +135,7 @@ async function stageFileEntry( ): Promise<{ entry: StagedExternalImportEntry; byteLength: number; imageExtension?: string }> { const statResult = await lstat(filePath) const displayPath = normalizeRelativeUploadPath(relativePath) - // Why: a dropped file's relative path is '', so errors would name nothing. - // The entry keeps '' — only the message falls back to the file's own name. + // Keep the empty relative path in entries, but name the dropped file in errors. const displayName = displayPath || basename(filePath) if (statResult.isSymbolicLink()) { throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayName}'`) @@ -171,9 +165,7 @@ async function stageFileEntry( openedStat.size, options.totalBytesBefore + openedStat.size ) - // Why: bytes are read slice-by-slice at upload time, so staging records the - // identity the streamer re-checks rather than the body itself. Size alone - // would let a same-size replacement slip through between the two calls. + // Record identity for upload-time checks; size alone cannot detect same-size replacements. return { entry: { relativePath: displayPath, @@ -206,7 +198,7 @@ async function assertRealPathInsideRoot( ): Promise { const candidateRealPath = await realpath(candidatePath) const relativeToRoot = relative(rootRealPath, candidateRealPath) - // Why: `..name` is a valid child path; only `..` and `../...` escape. + // ..name is valid; only .. and ../ paths escape. if ( relativeToRoot !== '' && (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) diff --git a/src/main/ipc/filesystem-search-file-paths.test.ts b/src/main/ipc/filesystem-search-file-paths.test.ts index 7adadbd7bf7..e7b665cfae1 100644 --- a/src/main/ipc/filesystem-search-file-paths.test.ts +++ b/src/main/ipc/filesystem-search-file-paths.test.ts @@ -57,8 +57,6 @@ function createMockProcess(spawned = true): ChildProcess { const BUNDLED_ERROR = "Orca's bundled search tool (ripgrep) could not start" -// Why Object.create and not `{} as Store`: this path never reads the store, and the changed-code -// quality gate rejects type assertions. const UNUSED_STORE: Store = Object.create(null) function createSpawnError(code: string): NodeJS.ErrnoException { @@ -289,13 +287,11 @@ describe('searchQuickOpenFilePaths', () => { expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2) }) - // Why both events: a failed spawn emits 'error' and THEN 'close' with a negative code. Emitting - // only 'error' lets the async cwd check win a race it loses in production. + // Model error then negative close so the async cwd check faces the real spawn ordering. it('reports the bundled-ripgrep error when rg genuinely cannot start', async () => { const child = createMockProcess(false) wslAwareSpawnMock.mockReturnValue(child) - // Why a root that exists: an ENOENT spawn error is also what a vanished workspace looks like, - // so this stays about the binary only while the search root is actually reachable. + // ENOENT must concern the binary here, not a missing workspace. resolveAuthorizedPathMock.mockImplementation(async () => process.cwd()) const promise = searchQuickOpenFilePaths('/repo', {} as Store, { query: 'target', @@ -310,8 +306,7 @@ describe('searchQuickOpenFilePaths', () => { expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) }) - // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler - // that checks it after the unavailable branch reports a broken install instead. + // Classify WSL cd failure 97 before generic unavailable checks for codes above rg's 0/1/2. it('names the unreachable root when the WSL wrapper cannot enter it', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts index 8e34a93705f..59a29c81066 100644 --- a/src/main/ipc/filesystem-search-file-paths.ts +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -98,11 +98,11 @@ export async function searchQuickOpenFilePaths( if (error instanceof RipgrepUnavailableError) { throw bundledRipgrepUnavailableError() } - // Why: a supersede that lands after the scan rejected still owes the caller a cancellation. + // Superseding aborts still owe cancellation after a scan rejection. if (args.signal?.aborted) { throw fileListingCancellationError(args.signal) } - // Why: one-off fork/exec pressure should not blank Quick Open until the query changes. + // Retry transient fork/exec pressure so Quick Open need not wait for another query. if (!(error instanceof RipgrepLaunchFailureError) || attempt > 0) { throw error } @@ -142,8 +142,7 @@ function scanRipgrepPaths(args: { stdio: ['ignore', 'pipe', 'pipe'] }) } catch (error) { - // Why route through the classifier: a sync throw skips the 'error' handler, and ENOTDIR -- - // a search root that is a file -- should read the same here as it does there. + // Classify synchronous failures too, including file-shaped roots returning ENOTDIR. void classifySynchronousRipgrepSpawnFailure(error, args.authorizedRootPath).then( reject, reject @@ -223,13 +222,9 @@ function scanRipgrepPaths(args: { finish(new Error(`rg failed to start${error.code ? ` (${error.code})` : ''}`)) return } - // Why the cwd check: spawn reports a missing cwd as ENOENT too, and blaming the binary - // for it tells the user to reinstall Orca over a workspace that simply moved. - // Why detach close first: a failed spawn emits error THEN close(code < 0), and close settles - // synchronously, so this probe would otherwise race it on a sub-millisecond margin -- two - // measurements disagreed on which wins. Detaching makes the verdict deterministic. + // ENOENT may mean missing cwd; detach close first so its synchronous failure cannot race the async probe. child.off('close', handleClose) - // Why catch: a failed probe must not strand the search; fall back to the prior verdict. + // A failed cwd probe must not strand the search; preserve the prior classification. void isRipgrepSpawnCwdUsable(args.authorizedRootPath) .catch(() => true) .then((usable) => { @@ -239,8 +234,7 @@ function scanRipgrepPaths(args: { }) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { - // Why before the unavailable check: classifyNativeLauncherExit treats any code above 2 as a - // broken install, and this code is 97 -- so checking second makes this branch dead. + // WSL missing-cwd code 97 would otherwise be classified as a broken install. if (isRipgrepMissingCwdExit(code)) { pathAccumulator.clear() finish(ripgrepMissingCwdError(args.authorizedRootPath)) diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 320b1f9f61a..734f03825b7 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -174,8 +174,7 @@ describe('filesystem rg search timeout', () => { wslAwareSpawnMock.mockReturnValue(child) registerFilesystemHandlers({} as never) - // Why a root that exists: an ENOENT spawn failure is also what a vanished workspace looks - // like, so this stays about the binary only while the search root is reachable. + // ENOENT must concern the binary here, not a missing workspace. resolveAuthorizedPathMock.mockImplementation(async () => process.cwd()) const promise = handlers.get('fs:search')!( { sender: { id: 7 } }, @@ -214,8 +213,7 @@ describe('filesystem rg search timeout', () => { await expect(promise).rejects.toThrow('post-spawn failure') }) - // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler - // that checks it after the unavailable branch reports a broken install instead. + // Classify WSL cd failure 97 before generic unavailable checks for codes above rg's 0/1/2. it('names the unreachable root when the WSL wrapper cannot enter it', async () => { const child = createMockProcess() Object.defineProperty(child, 'pid', { value: 1 }) diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 039059ae35d..a0d7b8265c2 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -4,8 +4,7 @@ import { type Mock, vi } from 'vitest' /** Loose signature: these mocks stand in for many unrelated module exports. */ type IpcMock = Mock<(...args: any[]) => unknown> -// Why: paths are resolved via path.resolve() in production code, so test -// data must use resolved paths to avoid Unix-vs-Windows mismatches. +// Resolve fixture paths like production for cross-platform comparisons. export const REPO_PATH = path.resolve('/workspace/repo') export const WORKSPACE_DIR = path.resolve('/workspace') export const WORKTREE_FEATURE_PATH = path.resolve('/workspace/repo-feature') @@ -208,7 +207,6 @@ const ALL_MOCKS = [ pullRequestLinkedIssueMock ].flatMap(collectMocks) -/** A FileHandle double over `content`: positional reads copy from it, and stat reports its size. */ export function localFileHandleMock( content: Buffer, { isFile = true, size = content.byteLength }: { isFile?: boolean; size?: number } = {} @@ -238,7 +236,6 @@ export function localFileHandleMock( } } -/** Resets every filesystem IPC mock and reinstalls the defaults every suite starts from. */ export function resetFilesystemIpcMocks(): void { handlers.clear() for (const mock of ALL_MOCKS) { diff --git a/src/main/ipc/filesystem-watcher-batch-control.ts b/src/main/ipc/filesystem-watcher-batch-control.ts index 55df665d602..d7c49501164 100644 --- a/src/main/ipc/filesystem-watcher-batch-control.ts +++ b/src/main/ipc/filesystem-watcher-batch-control.ts @@ -23,7 +23,6 @@ export function createDebouncedBatch(): DebouncedBatch { } } -/** Cancel pending and queued flush work when a root is torn down. */ export function cancelLocalBatchFlush(root: WatchedRoot): void { root.batch.cancelled = true root.batch.flushQueued = false diff --git a/src/main/ipc/filesystem-watcher-event-batch.ts b/src/main/ipc/filesystem-watcher-event-batch.ts index 761cf352486..ac7874c5abf 100644 --- a/src/main/ipc/filesystem-watcher-event-batch.ts +++ b/src/main/ipc/filesystem-watcher-event-batch.ts @@ -11,8 +11,7 @@ export function appendWatcherEvents( batchEvents: WatcherEvent[], incomingEvents: WatcherEvent[] ): void { - // Why: worktree deletion can deliver enough events that `push(...events)` - // exceeds V8's argument limit and crashes the main process. + // Deletion storms can exceed V8's argument limit if spread into push. for (const event of incomingEvents) { batchEvents.push(event) } @@ -27,8 +26,7 @@ export function queueWatcherEvents( } if (batch.events.length + incomingEvents.length > MAX_BATCHED_WATCHER_EVENTS) { - // Why: once precision is too expensive, keeping every path only burns - // memory before flush sends the same conservative overflow refresh. + // Once overflow requires a conservative refresh, retaining individual paths only wastes memory. batch.events = [] batch.overflowed = true return diff --git a/src/main/ipc/filesystem-watcher-handlers.ts b/src/main/ipc/filesystem-watcher-handlers.ts index f7e3e04ee32..9b989c25da9 100644 --- a/src/main/ipc/filesystem-watcher-handlers.ts +++ b/src/main/ipc/filesystem-watcher-handlers.ts @@ -9,12 +9,11 @@ export function registerFilesystemWatcherHandlers(): void { ipcMain.handle( 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { - // Why: an SSH path names a remote file; the desktop has no SSH filesystem transport, and - // managed-server workspaces watch through the runtime stream instead of this channel. + // Remote workspaces watch through runtime streams; local IPC has no SSH filesystem transport. if (args.connectionId) { return } - // Why: reopen the local subsystem for tests and post-shutdown reattachment; stale callers keep the prior generation. + // Reopen for post-shutdown attachment; older callers retain the retired generation. watcherLifecycleState.localWatchersClosed = false await subscribeLocalWatcher(args.worktreePath, event.sender) } diff --git a/src/main/ipc/filesystem-watcher-ignore-real.test.ts b/src/main/ipc/filesystem-watcher-ignore-real.test.ts index aaaeec7a9a6..233f112c7a6 100644 --- a/src/main/ipc/filesystem-watcher-ignore-real.test.ts +++ b/src/main/ipc/filesystem-watcher-ignore-real.test.ts @@ -44,7 +44,7 @@ describe('filesystem watcher native ignores', () => { buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) ) const seen = (file: string): boolean => events.some((event) => event.path === file) - // Why: under CI load a single write can miss delivery, so rewrite until each file is seen. + // Retry writes until observed to tolerate native delivery gaps under CI load. const writeUntilSeen = async (files: string[]): Promise => { await vi.waitFor( async () => { @@ -56,7 +56,7 @@ describe('filesystem watcher native ignores', () => { ) } - // Proves both watched levels are live before the storm, so the ignore check can't pass vacuously. + // Prove both watch levels are live before checking ignored events. await writeUntilSeen([join(root, 'ready.ts'), join(nestedSource, 'ready.ts')]) const generatedNames = Array.from({ length: 20 }, (_, index) => `generated-${index}.js`) @@ -73,8 +73,7 @@ describe('filesystem watcher native ignores', () => { sourceFiles.push(join(root, 'source\nnewline.ts'), join(nestedSource, 'source\nnewline.ts')) } await writeUntilSeen(sourceFiles) - // Why: events arrive in order, so once a file written after the storm is seen at both levels, any - // unignored generated event is already recorded. Replaces a fixed debounce sleep. + // A post-storm sentinel at each level proves prior events arrived without a fixed sleep. await writeUntilSeen([join(root, 'sentinel.ts'), join(nestedSource, 'sentinel.ts')]) expect(errors).toEqual([]) diff --git a/src/main/ipc/filesystem-watcher-ignore.test.ts b/src/main/ipc/filesystem-watcher-ignore.test.ts index 755ef9489ab..982d1f5e904 100644 --- a/src/main/ipc/filesystem-watcher-ignore.test.ts +++ b/src/main/ipc/filesystem-watcher-ignore.test.ts @@ -20,9 +20,7 @@ describe('buildParcelWatcherIgnoreOptions', () => { setPlatform('darwin') const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) const option = options.ignore ?? [] - // @parcel/watcher passes non-glob entries to FSEventStreamSetExclusionPaths, - // which rejects the WHOLE set past 8 paths — silently disabling daemon-side - // exclusion of node_modules/.git churn. + // FSEvents rejects all plain exclusions above eight paths, silently restoring node_modules/.git churn. const plainPaths = option.filter((entry) => !entry.includes('*')) expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) expect(option).toEqual(WATCHER_IGNORE_DIRS.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) diff --git a/src/main/ipc/filesystem-watcher-ignore.ts b/src/main/ipc/filesystem-watcher-ignore.ts index 2f45b7ced50..1ea8daa7fad 100644 --- a/src/main/ipc/filesystem-watcher-ignore.ts +++ b/src/main/ipc/filesystem-watcher-ignore.ts @@ -1,7 +1,4 @@ -// Canonical ignore list for recursive worktree watchers (mirrors VS Code's -// predefined recursive-watch excludes). Shared by the explorer watcher and the -// runtime file-watcher host so every @parcel/watcher subscription gets the -// same high-churn exclusions. +// Share high-churn exclusions across desktop and runtime watchers, following VS Code's recursive-watch defaults. export const WATCHER_IGNORE_DIRS: string[] = [ '.git', 'node_modules', @@ -14,17 +11,12 @@ export const WATCHER_IGNORE_DIRS: string[] = [ '__pycache__' ] -// Why: macOS FSEventStreamSetExclusionPaths accepts at most 8 paths and fails -// closed — one entry over the cap and @parcel/watcher silently loses ALL -// daemon-side exclusions, so fseventsd delivers every node_modules/.git event -// to this process (measured ~29x client CPU plus daemon-side delivery load). -// The first 8 names get root-level daemon exclusions; the regex covers all names at every depth. +// FSEvents rejects all exclusions above eight paths; regex filtering covers the remaining names at every depth. export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8 export type ParcelWatcherIgnoreOptions = { ignore?: string[] - // @parcel/watcher's wrapper preserves this native option even though its - // public TypeScript declaration omits it. See parcel-bundler/watcher#244. + // Parcel forwards this native option despite omitting it from TS declarations (parcel-bundler/watcher#244). ignoreGlobs?: string[] } @@ -37,7 +29,7 @@ function buildNestedDirectoryRegex(ignoreDirs: readonly string[]): string { if (process.platform === 'win32') { return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/][\\s\\S]*)?$` } - // Why: backslash is a legal POSIX filename character, not a path separator. + // Backslash is a valid POSIX filename character. return `^(?:[^/]+/)*(?:${alternatives})(?:/[\\s\\S]*)?$` } @@ -48,15 +40,13 @@ export function buildParcelWatcherIgnoreOptions( return {} } if (process.platform !== 'darwin') { - // Why: Parcel 2.5.6 turns leading-** globs into nested-lookahead regexes - // that are pathological in native std::regex (10-17x slower upstream). - // One component-aware regex preserves nested pruning without that CPU cost. + // Parcel 2.5.6 leading-** globs cause pathological std::regex lookaheads; one component regex avoids that cost. return { ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] } } const daemonExcludedDirs = ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) return { ignore: [...daemonExcludedDirs], - // Why: plain exclusions resolve under the root, leaving nested generated directories unfiltered. + // Plain exclusions cover only the root; regex also prunes nested generated directories. ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] } } diff --git a/src/main/jcode/hook-command-quoting.test.ts b/src/main/jcode/hook-command-quoting.test.ts index 9f1afc8d2f6..369c7d7776c 100644 --- a/src/main/jcode/hook-command-quoting.test.ts +++ b/src/main/jcode/hook-command-quoting.test.ts @@ -1,12 +1,7 @@ import { describe, expect, it } from 'vitest' import { getJcodeManagedCommand, isJcodeManagedCommand } from './hook-settings' -/** - * jcode tokenizes a configured hook command shell-style before executing it directly - * (`parse_hook_command`, crates/jcode-terminal-launch/src/lib.rs). These cases mirror - * that tokenizer: unquoted whitespace splits, unquoted `\` is an escape, single quotes - * are verbatim, and inside double quotes only `\` and `"` are escapes. - */ +/** Mirror parse_hook_command in crates/jcode-terminal-launch/src/lib.rs: shell-style tokenization before direct execution. */ function tokenizeLikeJcode(raw: string): string[] { const parts: string[] = [] let current = '' @@ -65,8 +60,7 @@ describe('jcode managed hook command quoting', () => { }) it('is what a bare path fails to do, which is why the quoting exists', () => { - // Regression anchor: the unquoted Windows path loses every separator, so jcode - // execs `C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook ever fires. + // Unquoted Windows separators are consumed as escapes before direct execution. expect(tokenizeLikeJcode('C:\\Users\\me\\.orca\\agent-hooks\\jcode-hook.cmd')).toEqual([ 'C:Usersme.orcaagent-hooksjcode-hook.cmd' ]) diff --git a/src/main/jcode/hook-config.test.ts b/src/main/jcode/hook-config.test.ts index 17dbcb379c2..d3325055860 100644 --- a/src/main/jcode/hook-config.test.ts +++ b/src/main/jcode/hook-config.test.ts @@ -30,7 +30,6 @@ emoji = false turn_end = "~/bin/my-turn-notify" ` const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') - // Why: the user-owned turn_end is kept verbatim and reported partial. expect(result.userOwnedEvents).toEqual(['turn_end']) expect(result.content).toContain('turn_end = "~/bin/my-turn-notify"') for (const event of ['session_start', 'session_end', 'post_tool']) { @@ -57,8 +56,7 @@ post_tool = ${tomlQuoteString(MANAGED_COMMAND)} }) it('tolerates jcode-owned scalar non-string hook settings', () => { - // Why: jcode seeds `pre_tool_timeout_ms = 5000` into its own [hooks] table; - // it is config, not a command, so parsing must skip it instead of erroring. + // jcode seeds numeric pre_tool_timeout_ms; it is config, not a hook command. const table = parseJcodeHooksTable('[hooks]\npre_tool_timeout_ms = 5000\n') expect(table).toEqual({}) const result = applyJcodeManagedHooks( @@ -85,8 +83,7 @@ session_start = "~/bin/mine" }) it('keeps every table declared after [hooks] when removing managed entries', () => { - // Why: `remote` rebuilds the file from the lines it keeps, so an early exit at - // the next table header silently truncated the rest of a user's config. + // Remote editing rebuilds the file; stopping at the next header would truncate user tables. const source = `[hooks] turn_end = ${tomlQuoteString(MANAGED_COMMAND)} pre_tool_timeout_ms = 5000 @@ -120,7 +117,7 @@ turn_end = ${tomlQuoteString(MANAGED_COMMAND)} }) it('never deletes a user hook whose comment merely mentions the managed script', () => { - // Why: matching the raw line treated the comment as the value — real config loss. + // A managed-script mention in a trailing comment does not confer ownership. const source = `[hooks] turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh ` @@ -144,7 +141,7 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh const source = `['hooks']\npre_tool_timeout_ms = 5000 # ms\n` expect(parseJcodeHooksTable(source)).toEqual({}) const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') - // Why: a second [hooks] table makes jcode reject the whole config. + // A duplicate hooks table makes jcode reject the config. expect(result.content.match(/^\[?'?hooks/gm)?.length).toBe(1) expect(result.content).toContain('pre_tool_timeout_ms = 5000 # ms') }) @@ -154,7 +151,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') expect(result.content).toContain('\r\n') expect(result.content).not.toContain('\n[hooks]') - // Why: preserved CRLF lines must not gain a second `\r` on every edit. expect(result.content).not.toContain('\r\r') }) }) diff --git a/src/main/jcode/hook-config.ts b/src/main/jcode/hook-config.ts index 57d666cbd04..e0b77fa99a8 100644 --- a/src/main/jcode/hook-config.ts +++ b/src/main/jcode/hook-config.ts @@ -1,8 +1,4 @@ -// Why: byte-preserving edits of jcode's `~/.jcode/config.toml` [hooks] table, -// following the codebase convention of hand-rolled TOML scanning (no TOML -// dependency, see src/main/codex/config-toml-line-scan.ts). jcode hook values -// are scalar commands; user-owned values are left untouched so Orca never -// silently disables a user's own hook. +// Reuse byte-preserving TOML scanning; scalar hook commands stay user-owned unless they target Orca. import { createTomlLineScanState, getTomlTableHeader, @@ -42,11 +38,7 @@ export function parseJcodeHooksTable(content: string): JcodeHooksTable | null { line.trim().length > 0 && !isTomlScalarValue(line, parsed.end + 1) ) { - // Why: a multiline string hook value is not a scalar command; treat - // the whole table as unmanaged so install reports `error` instead of - // clobbering it. Scalar non-string values (jcode's own - // `pre_tool_timeout_ms = 5000`) are legitimate config, just not - // commands, so they are skipped. + // Fail closed on multiline hook strings; non-string scalars such as pre_tool_timeout_ms are valid config. parseError = true } } @@ -59,9 +51,7 @@ export function parseJcodeHooksTable(content: string): JcodeHooksTable | null { return hooks } -// Why the comment tail: jcode ships `pre_tool_timeout_ms = 5000` and a user may -// annotate it. Rejecting that set parseError, which made install() and getStatus() -// report `error` and blocked the whole integration over one ordinary comment. +// jcode seeds non-command scalars; inline comments must not make them parse errors. const TOML_SCALAR_VALUE_RE = /^\s*(?:true|false|[-+]?\d[\d_]*(?:\.[\d_]+)?(?:[eE][-+]?\d+)?)\s*(?:#.*)?\r?$/ @@ -69,9 +59,7 @@ function isTomlScalarValue(line: string, offset: number): boolean { return TOML_SCALAR_VALUE_RE.test(line.slice(offset)) } -// Why the shared parser: it already handles quoted (`['hooks']`) and dotted keys. -// Rolling our own missed those spellings, so apply() appended a SECOND [hooks] -// table and jcode then failed to parse the whole config. +// Reuse quoted/dotted header parsing to avoid appending a duplicate hooks table. function isHooksTableHeader(header: string): boolean { return parseTomlTableHeaderPath(header)?.segments.join('.') === 'hooks' } @@ -93,8 +81,7 @@ export function applyJcodeManagedHooks( scriptFileName: string ): JcodeHooksEdit { const isManaged = createManagedCommandMatcher(scriptFileName) - // Why: a CRLF config leaves a trailing `\r` on each split line; re-joining - // with the CRLF eol would double it into `\r\r\n` and grow the file per edit. + // Strip split CRLF tails before rejoining so repeated edits cannot grow carriage returns. const lines = content.split('\n').map((line) => line.replace(/\r$/, '')) const usesCrlf = content.includes('\r\n') const eol = usesCrlf ? '\r\n' : '\n' @@ -127,19 +114,14 @@ export function applyJcodeManagedHooks( if (parsed && line[parsed.end] === '=' && parsed.segments.length === 1) { const key = parsed.segments[0] if (events.includes(key)) { - // Why the parsed value and not the raw line: a user-owned command whose - // trailing comment merely mentions the managed script would otherwise read - // as managed, and on Windows tomlQuoteString escapes `\` so the raw line - // never matched Orca's own value at all. + // Parse the string so trailing comments cannot confer ownership and escaped Windows paths still match. const value = parseTomlSingleLineStringValue(line, parsed.end + 1)?.value if (!isManaged(value)) { userOwnedEvents.push(key) } else if (value === managedCommand) { existingKeyIndexes.set(key, index) } else { - // A managed entry pointing at a script that no longer exists — a copied - // ~/.jcode, or a platform switch between .sh and .cmd. Rewrite it, or the - // hooks stay broken with no Orca action able to repair them. + // Repair stale managed paths after copied configs or platform switches. staleKeyIndexes.set(key, index) } } @@ -165,8 +147,6 @@ export function applyJcodeManagedHooks( let result = staleKeyIndexes.size > 0 ? repointed.join(eol) : content if (insertions.length > 0) { if (hooksHeaderIndex === -1) { - // Why: append a new [hooks] table at the end; jcode re-reads config on - // reload, so placement at EOF is safe. result = `${result.endsWith('\n') || result.length === 0 ? result : `${result}\n`}[hooks]${eol}${insertions.join(eol)}${eol}` } else { const insertionLines = [...repointed] @@ -182,7 +162,7 @@ export function removeJcodeManagedHooks( scriptFileName: string ): { content: string; changed: boolean } { const isManaged = createManagedCommandMatcher(scriptFileName) - // Why: same CRLF strip as applyJcodeManagedHooks so re-joining never doubles `\r`. + // Strip CRLF tails before rejoining. const lines = content.split('\n').map((line) => line.replace(/\r$/, '')) const usesCrlf = content.includes('\r\n') const eol = usesCrlf ? '\r\n' : '\n' @@ -199,9 +179,7 @@ export function removeJcodeManagedHooks( } const header = getTomlTableHeader(line) if (header) { - // Why: leaving the table stops the removal, but the rest of the file must - // still be copied out — `kept` is the whole result, so breaking here once - // truncated every table declared after [hooks]. + // Keep copying after the hooks table so removal cannot truncate later user tables. inHooksTable = isHooksTableHeader(header) kept.push(line) state = updateTomlLineScanState(state, line) diff --git a/src/main/jcode/hook-gate-script.test.ts b/src/main/jcode/hook-gate-script.test.ts index 98b3fa81b1f..25ddc0228e1 100644 --- a/src/main/jcode/hook-gate-script.test.ts +++ b/src/main/jcode/hook-gate-script.test.ts @@ -17,7 +17,6 @@ vi.mock('os', async () => { import { JcodeHookService } from './hook-service' import { getJcodeConfigPath, getJcodeManagedScriptPath, JCODE_HOOK_EVENTS } from './hook-settings' -/** Installs the managed hook into a throwaway home and returns the script path. */ function installManagedScript(): { scriptPath: string; cleanup: () => void } { const homeDir = mkdtempSync(join(tmpdir(), 'orca-jcode-gate-')) homedirMock.mockReturnValue(homeDir) @@ -37,15 +36,12 @@ describe.runIf(process.platform !== 'win32')('jcode managed hook as jcode runs i it('drains the gate stdin before exiting on a missing Orca environment', () => { const { scriptPath, cleanup } = installManagedScript() try { - // The claim is that this returns at all: a gate that exits without reading - // leaves jcode blocked mid-write on an input larger than the pipe buffer, and - // execFileSync would then raise ETIMEDOUT rather than complete. + // Returning proves the gate drained input larger than the pipe buffer before exiting. execFileSync('/bin/sh', [scriptPath], { input: JSON.stringify({ content: 'y'.repeat(512 * 1024) }), - // No ORCA_PANE_KEY: the script exits early, but only after taking stdin. + // Outside Orca, POSIX must still drain stdin before exiting. env: { ...process.env, JCODE_HOOK_EVENT: 'pre_tool', ORCA_PANE_KEY: '' }, - // Generous on purpose: the claim is "does not hang", not "is fast", and a - // tight bound here is the same flake the dropped latency test had. + // This checks completion, not latency; allow slow CI hosts. timeout: 60_000, stdio: ['pipe', 'pipe', 'pipe'] }) @@ -71,8 +67,7 @@ describe.runIf(process.platform !== 'win32')('jcode managed hook as jcode runs i }) describe('the Windows managed hook', () => { - // Why these run everywhere: Windows is the platform this PR could not exercise on - // real hardware, so the generated script's shape is pinned from any host. + // Validate generated Windows command shapes on every host. afterEach(() => vi.restoreAllMocks()) function windowsScript(): string { @@ -91,18 +86,13 @@ describe('the Windows managed hook', () => { const drainIndex = script.indexOf('if "%JCODE_HOOK_EVENT%"=="pre_tool"') expect(guardIndex).toBeGreaterThan(-1) expect(drainIndex).toBeGreaterThan(guardIndex) - // Why this order is inverted from the POSIX script: outside an Orca pane the - // caller abandons stdin instead of closing it, so a hook that reads it hangs - // forever and strands a console window (#11549). Exiting early instead costs - // only jcode's own 5s pre_tool timeout, which fails open. + // Outside Orca on Windows, abandoned stdin can hang forever; early exit leaves only jcode's fail-open timeout (#11549). expect(script).toContain('more.com') }) it('posts jcode\u2019s payload, which never reaches stdin on Windows', () => { const script = windowsScript() - // Why: the shared builder reads `payload@-`, but the gate has already drained - // stdin and observer hooks get a null one — so the payload has to come from the - // env var via a temp file, or the server sees no event name and drops everything. + // Windows stdin is drained or null, so payload@- must read a temp file populated from the environment. expect(script).toContain('setlocal EnableDelayedExpansion') expect(script).toContain('echo(!JCODE_HOOK_PAYLOAD!') expect(script).toMatch(/<"%ORCA_JCODE_PAYLOAD_FILE%" .*curl\.exe/) @@ -116,8 +106,7 @@ describe('the Windows managed hook', () => { expect(script.startsWith('@echo off\r\n')).toBe(true) expect(script.includes('\r\n')).toBe(true) expect(script).toContain('exit /b 0') - // Why: jcode parses the hook command shell-style but executes it directly, so - // the file has to be runnable on its own. + // Direct execution requires a Windows-runnable script. expect(script).not.toContain('#!/bin/sh') }) @@ -214,8 +203,7 @@ describe.runIf(process.platform !== 'win32')('managed script shape', () => { mkdirSync(dirname(scriptPath), { recursive: true }) chmodSync(scriptPath, 0o755) const script = readFileSync(scriptPath, 'utf8') - // Why: jcode parses the command shell-style but executes it directly, so the - // file itself must carry the interpreter. + // Direct execution requires the script to carry its interpreter. expect(script.startsWith('#!/bin/sh\n')).toBe(true) } finally { cleanup() diff --git a/src/main/jcode/hook-service.test.ts b/src/main/jcode/hook-service.test.ts index f86fe52444c..f9927865a5c 100644 --- a/src/main/jcode/hook-service.test.ts +++ b/src/main/jcode/hook-service.test.ts @@ -29,8 +29,7 @@ describe('JcodeHookService', () => { beforeEach(() => { homeDir = mkdtempSync(join(tmpdir(), 'orca-jcode-home-')) homedirMock.mockReturnValue(homeDir) - // Why: getJcodeConfigPath prefers JCODE_HOME; an ambient value would write - // outside the temp home and leak the written config past afterEach. + // Isolate JCODE_HOME as well as homedir so installs cannot escape the temporary home. vi.stubEnv('JCODE_HOME', '') }) @@ -58,9 +57,7 @@ describe('JcodeHookService', () => { const config = readFileSync(getJcodeConfigPath(), 'utf8') for (const event of JCODE_HOOK_EVENTS) { - // Why both wrappers: the value is shell-quoted for jcode's hook tokenizer and - // then TOML-quoted for the file, so neither the raw path nor the shell-quoted - // string appears on its own. + // Commands are shell-quoted for jcode, then TOML-quoted for config storage. expect(config).toContain( `${event} = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}` ) @@ -68,7 +65,6 @@ describe('JcodeHookService', () => { const script = readFileSync(getJcodeManagedScriptPath(), 'utf8') expect(script).toContain('/hook/jcode') expect(script).toContain('payload@-') - // Why: the payload is jcode's own JCODE_HOOK_PAYLOAD, forwarded verbatim. expect(script).toContain('$JCODE_HOOK_PAYLOAD') expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"') }) diff --git a/src/main/jcode/hook-service.ts b/src/main/jcode/hook-service.ts index eff119f91d5..9f4232a920e 100644 --- a/src/main/jcode/hook-service.ts +++ b/src/main/jcode/hook-service.ts @@ -38,28 +38,17 @@ import { function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { - // Why a temp file rather than stdin: the shared builder posts `payload@-`, and on - // Windows jcode's payload never reaches stdin — the pre_tool gate has already - // drained it, and observer hooks are given a null stdin. Without this the server - // sees no event name and normalizeJcodeEvent drops every event, so a Windows pane - // would show no jcode status at all. + // Windows stdin is drained or null; post JCODE_HOOK_PAYLOAD through a temp file instead. const payloadFile = '%ORCA_JCODE_PAYLOAD_FILE%' return [ '@echo off', - // EnableDelayedExpansion so `!JCODE_HOOK_PAYLOAD!` is written verbatim: plain - // `%VAR%` expansion re-parses the JSON's quotes and `&` as batch syntax. + // Delayed expansion keeps JSON quotes and & out of cmd parsing. 'setlocal EnableDelayedExpansion', - // Why: endpoint file holds the live port/token; a PTY that outlives an Orca restart carries stale env, so `call` it to refresh (else PTY env). + // Refresh endpoint credentials for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - // Why the guard comes first here, unlike the POSIX script: on Windows a hook - // that owns stdin outside an Orca pane can hang forever, because the caller - // abandons the pipe rather than closing it (#11549). A jcode gate that exits - // without draining costs at most jcode's own 5s pre_tool timeout, which fails - // open; a hung hook process costs a stranded window per event. + // Guard before reading: abandoned Windows stdin can hang forever outside Orca (#11549). ...buildWindowsHookEnvironmentGuardLines(), - // Why: inside an Orca pane, pre_tool is jcode's gate — it writes the tool input - // to our stdin and waits for us, so drain it before the POST or a tool input - // larger than the pipe buffer stalls the agent mid-write. + // Drain pre_tool input before POST so jcode cannot block writing a payload larger than the pipe. `if "%JCODE_HOOK_EVENT%"=="pre_tool" ${WINDOWS_HOOK_STDIN_DRAIN_COMMAND}`, `set "ORCA_JCODE_PAYLOAD_FILE=%TEMP%\\orca-jcode-hook-%RANDOM%%RANDOM%.json"`, `>"${payloadFile}" echo(!JCODE_HOOK_PAYLOAD!`, @@ -78,31 +67,23 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', - // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). - // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race or CRLF-mangled line can't leak shell parse errors into agent transcripts (fail-open). + // Refresh surviving PTYs; silence sourcing failures from endpoint races or CRLF damage. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', - // Why before the env guard: pre_tool is jcode's gate — it writes the tool input to - // our stdin and waits for us, so stdin must be drained before ANY exit path or a - // tool input larger than the pipe buffer stalls the agent mid-write. + // Drain gated input before every exit path so jcode cannot block mid-write. 'if [ "$JCODE_HOOK_EVENT" = pre_tool ]; then', ` ${POSIX_HOOK_STDIN_DRAIN_COMMAND}`, 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why the env var rather than a stdin capture: jcode hands the hook its payload as - // a ready JSON object (capped at 16 KB), so Orca forwards it verbatim instead of - // hand-building JSON in shell, which is unsafe for arbitrary text. + // Forward jcode's JSON payload verbatim; rebuilding arbitrary text in shell is unsafe. 'payload="$JCODE_HOOK_PAYLOAD"', 'orca_post_jcode_event() {', ...buildPosixAgentHookPostCommand('jcode').map((line) => ` ${line}`), '}', - // Why detached on the gate: jcode reads this script's stderr to EOF before it - // releases the tool call, so an inherited pipe would hold the tool open for as long - // as the POST ran. Orca observes the tool live and adds no latency; the gate always - // allows, because Orca never blocks a jcode tool. + // Detach the observer POST: jcode waits for gate stderr EOF before releasing the tool. 'if [ "$JCODE_HOOK_EVENT" = pre_tool ]; then', ' orca_post_jcode_event >/dev/null 2>&1 &', 'else', @@ -201,14 +182,13 @@ export class JcodeHookService { return this.getStatus() } - // Why: jcode invokes the script path recorded in its own config.toml, so an Orca - // upgrade that changes the script body must rewrite the file the user already has. + // jcode invokes the configured file, so upgrades must refresh existing scripts. async refreshManagedScripts(): Promise { await refreshManagedScriptIfPresent(getJcodeManagedScriptPath(), getManagedScript()) } async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - // Why: remote-Windows is out of scope for v1 (same as Devin); assume POSIX. + // Remote hook execution assumes POSIX; Windows remotes are unsupported. const remoteConfigPath = getJcodeRemoteConfigPath(remoteHome) const remoteScriptFileName = getJcodePosixManagedScriptFileName() const remoteScriptPath = `${remoteHome.replace(/\/+$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` @@ -225,7 +205,7 @@ export class JcodeHookService { detail: 'Could not parse remote jcode config.toml [hooks] table' } } - // Why: write script before settings so a mid-install failure never leaves config referencing a missing script. + // Publish the script before config so interrupted installs cannot reference a missing file. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) const edited = applyJcodeManagedHooks( content, @@ -288,8 +268,6 @@ function readConfigContent(configPath: string): string { } function writeConfigContent(configPath: string, content: string): void { - // Why: skip the write when the on-disk content is already identical (same - // no-op guard as writeHooksJson) so repeated install() calls stay inert. if (readConfigContent(configPath) === content) { return } diff --git a/src/main/jcode/hook-settings.ts b/src/main/jcode/hook-settings.ts index 063699f544f..a1751311f8e 100644 --- a/src/main/jcode/hook-settings.ts +++ b/src/main/jcode/hook-settings.ts @@ -1,17 +1,10 @@ -// Why: jcode config paths and the [hooks] table contract. jcode loads lifecycle -// hooks from ~/.jcode/config.toml (or $JCODE_HOME/config.toml); Orca writes its -// managed observer hooks there so sessions launched outside Orca still report. +// Honor JCODE_HOME so hooks land in the config jcode actually reads. import { homedir } from 'node:os' import { join } from 'node:path' const JCODE_SCRIPT_BASE = 'jcode-hook' -// The lifecycle points Orca subscribes to, in the order jcode fires them. -// `pre_tool` is jcode's synchronous gate, but the managed script backgrounds its -// POST and exits 0 immediately, so Orca observes the tool without ever holding -// up a tool call. Without it a long `bash` would show no tool at all until it -// finished, and `request_permission` (the only jcode tool a human answers) -// would only be seen after the answer. +// pre_tool observes running tools and unanswered questions; its POST is detached so the gate never blocks on HTTP. export const JCODE_HOOK_EVENTS = [ 'session_start', 'turn_start', @@ -47,13 +40,7 @@ export function getSharedJcodeScriptPath(scriptFileName: string): string { return join(homedir(), '.orca', 'agent-hooks', scriptFileName) } -// Why quoted: jcode executes hook commands directly (no shell), but it tokenizes the -// configured string shell-style first — parse_hook_command in jcode-terminal-launch -// splits on unquoted whitespace AND consumes every unquoted backslash as an escape, so -// a bare `C:\Users\me\.orca\agent-hooks\jcode-hook.cmd` reaches exec as -// `C:Usersme.orcaagent-hooksjcode-hook.cmd` and no Windows hook ever fires. Single -// quotes pass the path through verbatim (backslashes are literal inside them); a path -// that itself contains one falls back to double quotes, where \ and " are the escapes. +// jcode tokenizes commands before direct execution; quote paths to preserve Windows separators and spaces. export function getJcodeManagedCommand(scriptPath: string): string { return scriptPath.includes("'") ? `"${scriptPath.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` @@ -64,8 +51,7 @@ export function getJcodeRemoteManagedCommand(scriptPath: string): string { return getJcodeManagedCommand(scriptPath) } -// Why the separator normalize: the stored command is a native path, so on Windows it -// carries backslashes and a `/`-only needle never matches its own managed entry. +// Normalize Windows separators before matching the managed script. export function isJcodeManagedCommand(command: string | null | undefined): boolean { return ( typeof command === 'string' && command.replaceAll('\\', '/').includes('agent-hooks/jcode-hook') diff --git a/src/main/jira/__mocks__/jira-client-module.ts b/src/main/jira/__mocks__/jira-client-module.ts index 8b883d83cbd..ee2e25f41bc 100644 --- a/src/main/jira/__mocks__/jira-client-module.ts +++ b/src/main/jira/__mocks__/jira-client-module.ts @@ -30,9 +30,7 @@ export async function loadJiraClientModule( } } })) - // Why here and not in beforeEach: vi.resetModules() above gives the http-client module - // a fresh singleton, so the port must be installed on that instance. The electron net - // mock alone is inert now that Jira fetches through the port. + // Install the HTTP port after resetModules so the fixture uses the fresh singleton. const { setMainHttpClient } = await import('../../network/http-client') setMainHttpClient({ fetch: (url, init) => netFetchMock(url, init), @@ -51,8 +49,7 @@ export async function loadJiraClientModule( return { ...actual, homedir: () => tempHome } }) - // One import call per reset so the split modules share a single graph (and - // thus one copy of the request queue / credential caches) per test. + // Split modules must share one graph, request queue and credential cache per reset. const [client, queue, api] = await Promise.all([ import('../client'), import('../request-queue'), diff --git a/src/main/jira/adf-markdown.ts b/src/main/jira/adf-markdown.ts index 0f081960e64..dff8f4c2676 100644 --- a/src/main/jira/adf-markdown.ts +++ b/src/main/jira/adf-markdown.ts @@ -74,12 +74,11 @@ function mediaAttrsFromRecord(record: JiraAdfRecord): JiraAdfMediaAttrs { export function unresolvedMediaPlaceholder(attrs: JiraAdfMediaAttrs): string { const label = escapeMarkdownAlt(attrs.alt?.trim() || 'Image') - // Why: keep a visible marker when media cannot be downloaded so screenshots - // are not silently dropped from the issue body. + // Preserve a visible marker when media cannot be downloaded. return `*[${label}]*` } -/** Collect media attrs in document order (read-only; separate from adfToMarkdownText). */ +/** Collect media attributes in document order without rendering. */ export function collectAdfMediaAttrs(value: unknown): JiraAdfMediaAttrs[] { const collected: JiraAdfMediaAttrs[] = [] @@ -149,8 +148,6 @@ function renderInline(node: unknown, options?: AdfToMarkdownOptions): string { if (record.type === 'hardBreak') { return '\n' } - // Why: Jira pastes screenshots as media/mediaInline ADF nodes; without this - // branch they collapse to empty strings and disappear from the UI. if (record.type === 'media' || record.type === 'mediaInline') { return renderMediaMarkdown(record, options) } @@ -260,8 +257,6 @@ function renderBlock(node: unknown, options?: AdfToMarkdownOptions): MarkdownBlo } } if (type === 'bulletList') { - // Why: Orca renders Jira bodies as Markdown, so ADF list containers need - // concrete list markers instead of newline-only flattened text. return { kind: 'list', text: renderList(record, false, options) } } if (type === 'orderedList') { diff --git a/src/main/jira/adf-media-destination.ts b/src/main/jira/adf-media-destination.ts index 67deb7a685d..1e6c2d3af28 100644 --- a/src/main/jira/adf-media-destination.ts +++ b/src/main/jira/adf-media-destination.ts @@ -1,5 +1,4 @@ -// Why: markdown image destinations close at the first unescaped `)`. encodeURI -// leaves `()` alone, so Jira-controlled external URLs must be destination-safe. +// encodeURI leaves parentheses that can terminate Markdown image destinations. const MARKDOWN_DESTINATION_HOSTILE = new Set(['(', ')', '[', ']', '<', '>', '"', "'", '`', '\\']) diff --git a/src/main/jira/adf-text-marks.ts b/src/main/jira/adf-text-marks.ts index 4bc5a7f47a9..71aa5d8efac 100644 --- a/src/main/jira/adf-text-marks.ts +++ b/src/main/jira/adf-text-marks.ts @@ -76,7 +76,7 @@ export function applyAdfTextMarks(text: string, marksValue: unknown): string { const href = linkHrefFromMarks(marks) const linkedText = href ? text.replace(/\r\n?|\n/g, ' ') : text let formatted = linkedText - // Why: code spans cannot nest other Markdown; apply code before bold/em/strike. + // Code spans cannot nest other Markdown; apply code before other marks. if (marks.some((mark) => markType(mark) === 'code')) { let longestBacktickRun = 0 for (const match of linkedText.matchAll(/`+/g)) { @@ -104,7 +104,6 @@ export function applyAdfTextMarks(text: string, marksValue: unknown): string { } const safeUrl = escapeMarkdownLinkDestination(href) - // Unsafe destinations retain the label without becoming clickable. if (!safeUrl) { return formatted } diff --git a/src/main/jira/attachments/discovery.ts b/src/main/jira/attachments/discovery.ts index 96b34efa87b..8c041e9059a 100644 --- a/src/main/jira/attachments/discovery.ts +++ b/src/main/jira/attachments/discovery.ts @@ -1,17 +1,13 @@ import type { JiraAdfMediaAttrs } from '../adf-markdown' import { MAX_IMAGES, parseImageAttachmentMetas } from './meta' -/** - * Pull attachment content IDs from Jira rendered HTML in document order. - * Why: thumbnail paths use the same numeric attachment id as content URLs. - */ +/** Thumbnail and content paths share numeric attachment IDs; retain document order. */ export function extractAttachmentContentIdsFromHtml(html: string | undefined | null): string[] { if (!html) { return [] } const ids: string[] = [] const seen = new Set() - // content, secure/attachment, thumbnail, and rest thumbnail forms const pattern = /\/(?:rest\/api\/\d+\/attachment\/(?:content|thumbnail)|secure\/(?:attachment|thumbnail))\/(\d+)(?:\/|\b|"|'|\?)/gi let match: RegExpExecArray | null @@ -37,7 +33,7 @@ export function selectPreferredAttachmentIds(args: { const needing = args.mediaAttrs.filter((attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url))) const needCount = needing.length if (needCount === 0) { - // Why: no attachment-needing ADF media — skip downloads (do not sweep HTML-only). + // HTML-only images must not trigger downloads. return { preferredIds: [], fallbackRan: false, needCount: 0 } } @@ -47,7 +43,7 @@ export function selectPreferredAttachmentIds(args: { if (needCount > preferredIds.length) { const metas = parseImageAttachmentMetas(args.attachmentField, args.maxBytes) - // Why: multiple Jira screenshots often share image.png — assign next unused meta per node. + // Screenshots often share image.png; assign each the next unused attachment. for (const node of needing) { if (preferredIds.length >= (args.maxImages ?? MAX_IMAGES)) { break diff --git a/src/main/jira/attachments/image-cache.ts b/src/main/jira/attachments/image-cache.ts index 1552671b8a0..6e705ca34da 100644 --- a/src/main/jira/attachments/image-cache.ts +++ b/src/main/jira/attachments/image-cache.ts @@ -22,9 +22,7 @@ const inFlight = new Map< >() let expiryTimer: ReturnType | null = null let scheduledExpiryAt = Infinity -// Why: mid-flight downloads must not repopulate cache after disconnect/clearToken. -// Why ONE ticker across both scopes: summing separate counters lets distinct clear -// states collide, passing the guard and re-inserting credentialed bytes. +// One ticker prevents global/site clears from colliding and letting stale credentialed bytes repopulate the cache. let epochTicker = 0 let globalEpoch = 0 const siteEpoch = new Map() @@ -116,7 +114,7 @@ function getCachedImage(siteId: string, attachmentId: string): ImageValue | null cache.delete(key) return null } - // Why: delete-then-set refreshes insertion order for LRU-style eviction. + // Refresh insertion order for LRU-style eviction. cache.delete(key) cache.set(key, entry) return entry.image @@ -141,10 +139,7 @@ function setCachedImage(args: { scheduleAttachmentExpiry() } -/** - * Singleflight loader: concurrent cold misses share one download within their - * cancellation domain. Failed loads are not cached so a later retry can succeed. - */ +/** Share cold downloads within a cancellation domain; failed loads remain retryable. */ async function loadImageWithCache(args: { siteId: string attachmentId: string @@ -171,7 +166,6 @@ async function loadImageWithCache(args: { if (!loaded) { return null } - // Why: return bytes to the waiter but skip cache if site was cleared mid-flight. if (!args.signal?.aborted && attachmentImagesEpoch(args.siteId) === epochAtStart) { setCachedImage({ siteId: args.siteId, diff --git a/src/main/jira/attachments/images.test.ts b/src/main/jira/attachments/images.test.ts index c139d873feb..a85c709c5f8 100644 --- a/src/main/jira/attachments/images.test.ts +++ b/src/main/jira/attachments/images.test.ts @@ -175,7 +175,6 @@ describe('attachment image helpers', () => { it('returns placeholder for external URLs that remain hostile after encode', async () => { const { createMediaMarkdownResolver } = await import('./images') const resolve = createMediaMarkdownResolver([], []) - // non-http rejected expect(resolve({ url: 'javascript:alert(1)', alt: 'x' })).toBe('*[x]*') }) diff --git a/src/main/jira/attachments/images.ts b/src/main/jira/attachments/images.ts index e14e11c3758..b7af8c45b27 100644 --- a/src/main/jira/attachments/images.ts +++ b/src/main/jira/attachments/images.ts @@ -65,7 +65,7 @@ async function downloadImageAttachment( byteSize: binary.data.byteLength } } catch (error) { - // Why: one bad attachment should not blank the whole issue description. + // A missing attachment must not blank the whole issue. if (error instanceof JiraApiError && error.status === 404) { return null } @@ -133,8 +133,7 @@ export async function loadIssueImageAttachments( })) } - // Why: pre-select by declared size so concurrent downloads do not fetch bodies - // that will be dropped by the total budget after completion. + // Enforce declared-size budgets before concurrent downloads fetch bytes that would be discarded. const toDownload: AttachmentMeta[] = [] let plannedBytes = 0 for (const meta of ordered.slice(0, MAX_IMAGES)) { @@ -208,7 +207,7 @@ export function createMediaMarkdownResolver( return null } const index = queue.findIndex((entry) => entry.id === image.id) - // Why: already-consumed images must not re-emit; fall through to positional pairing. + // Consumed filename matches must fall through to positional pairing. if (index === -1) { return null } @@ -228,8 +227,7 @@ export function createMediaMarkdownResolver( } const alt = attrs.alt?.trim() || 'Image' if (attrs.url) { - // Why: return placeholder (not null) so non-http / hostile externals do not - // fall through to positional attachment pairing. + // Return a placeholder so unsafe external URLs cannot fall through to attachment pairing. if (!/^https?:\/\//i.test(attrs.url)) { return unresolvedMediaPlaceholder({ ...attrs, alt }) } @@ -242,20 +240,18 @@ export function createMediaMarkdownResolver( } let resolved: string | null = null - // Why: ADF media IDs are Media Service UUIDs, not attachment IDs — skip byId - // lookup on attrs.id against attachment map (they never match). + // ADF media UUIDs are not attachment IDs; pair by filename or document order. if (attrs.alt?.trim()) { const matches = byFilename.get(attrs.alt.trim().toLowerCase()) if (matches && matches.length > 0) { const stillQueued = matches.find((image) => queue.some((entry) => entry.id === image.id)) - // Why: only take still-queued matches; never re-emit matches[0] after consume. if (stillQueued) { resolved = take(stillQueued) } } } - // Why: take() removes from queue; do not shift first or membership check fails. + // take() checks membership and removes; shifting first would make it fail. if (!resolved && queue.length > 0) { resolved = take(queue[0]) } diff --git a/src/main/jira/attachments/meta.ts b/src/main/jira/attachments/meta.ts index 8c6f1f68bf2..73404aa836a 100644 --- a/src/main/jira/attachments/meta.ts +++ b/src/main/jira/attachments/meta.ts @@ -1,4 +1,4 @@ -// Why: image inlined as data URLs over IPC — keep per-image and selection caps modest. +// Images cross IPC as data URLs; bound per-image and aggregate size. export const MAX_IMAGE_BYTES = 2 * 1024 * 1024 export const MAX_TOTAL_IMAGE_BYTES = 5 * 1024 * 1024 export const MAX_IMAGES = 12 @@ -22,7 +22,7 @@ function asString(value: unknown): string { export function isImageMimeType(mimeType: string): boolean { const normalized = mimeType.toLowerCase() return ( - normalized.startsWith('image/') && !normalized.includes('svg') // Why: SVG can carry script; stick to raster screenshots. + normalized.startsWith('image/') && !normalized.includes('svg') // Exclude SVG because it can contain script. ) } diff --git a/src/main/jira/authenticated-request.ts b/src/main/jira/authenticated-request.ts index 76a87153d6c..62798e1314c 100644 --- a/src/main/jira/authenticated-request.ts +++ b/src/main/jira/authenticated-request.ts @@ -11,11 +11,7 @@ import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' -// Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser -// User-Agent, failing them with "XSRF check failed" even under API-token auth. -// Electron's net.fetch sends a Chrome UA, so issue search/create/update/comment -// all 403'd while GET calls (connect, /myself) passed. A non-browser UA is the -// reliable fix; X-Atlassian-Token: no-check is not honored for this case. +// Electron's Chrome UA triggers Atlassian XSRF rejection on token-authenticated writes; no-check does not bypass it. const JIRA_API_USER_AGENT = 'Orca' export type JiraClientForSite = { @@ -23,9 +19,7 @@ export type JiraClientForSite = { authorization: string } -// Self-hosted Jira Server/Data Center only exposes REST v2; Cloud endpoints -// in this codebase are written against v3. Callers build paths with this -// prefix so one code path serves both deployments. +// Server/DC needs REST v2; Cloud uses v3. export function apiBasePath(site: JiraSite): string { return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3' } @@ -41,10 +35,7 @@ export function apiBaseUrl(site: JiraSite): string { } export function authHeader(email: string, apiToken: string, _authType?: JiraAuthType): string { - // Self-hosted with no username = a personal access token (Bearer); Basic auth - // with a PAT in the password slot is what produces the 401s users report. - // Self-hosted WITH a username is classic username+password Basic auth, which - // older Server/DC instances require. Email-free Cloud tokens use the gateway. + // Server/DC uses Bearer for username-free PATs, Basic for username/password pairs. if (!email) { return `Bearer ${apiToken}` } @@ -79,9 +70,7 @@ export async function jiraFetch(url: string, init: RequestInit): Promise { const requestUrl = resolveBinaryRequestUrl(client.site, pathOrUrl) const headers = new Headers() - // Why: attachment content is binary; forcing JSON Accept/Content-Type can - // break downloads and confuses some Atlassian edge responses. + // Binary attachments must not inherit JSON content negotiation. headers.set('Accept', '*/*') headers.set('User-Agent', JIRA_API_USER_AGENT) headers.set('Authorization', client.authorization) diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index fe1785fde6e..fc9beac7ea4 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -158,9 +158,7 @@ describe('Jira client credential storage', () => { }) it('sends a non-browser User-Agent on Jira POST requests', async () => { - // Why: Electron's net.fetch defaults to a Chrome User-Agent, which trips - // Atlassian's XSRF filter on POST/PUT REST calls (issue search, create, - // update, comment) even under API-token auth, surfacing as a 403. + // Electron's Chrome UA trips Atlassian XSRF checks on token-authenticated writes. const siteId = 'site-alpha' writeJiraFiles(siteId, 'token-alpha') netFetchMock.mockResolvedValueOnce( @@ -487,8 +485,7 @@ describe('Jira client credential storage', () => { await expect( jira.connect({ siteUrl: 'jira.example.com', - // A username present means classic Basic auth (older Server/DC that - // predate PATs); the token slot carries the account password. + // Username selects classic Server/DC Basic auth; the token slot holds the password. email: 'jdoe', apiToken: 'account-password', authType: 'server' @@ -605,8 +602,7 @@ describe('Jira client credential storage', () => { authType: 'server' }) - // Two PATs (both with empty email) to the same host must not collide onto - // one id and silently overwrite each other — the viewer identity keys them. + // Same-host PAT accounts need verified viewer identity to avoid overwriting each other. const stored = JSON.parse( readFileSync(join(tempHome, '.orca', 'jira-sites.json'), 'utf-8') ) as { @@ -782,8 +778,7 @@ describe('Jira site file cross-process visibility', () => { }) it('picks up a site connected by another Orca process', async () => { - // Every Orca process on the machine shares ~/.orca. Loading before any site - // exists must not pin "disconnected" for the life of the process. + // Processes share ~/.orca; an initially empty read must observe later connections. const jira = await loadClientModule() expect(jira.getStatus()).toMatchObject({ connected: false, sites: [] }) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index 8cccf9ee369..2d7c8fa4e97 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -42,11 +42,7 @@ export function getClients(selection?: JiraSiteSelection | null): JiraClientForS try { token = readToken(site.id) } catch (error) { - // Why: under an 'all' selection one un-decryptable site must not collapse - // reads for the healthy ones. readToken already recorded the per-site - // credentialError for getStatus to surface, so skip this site like a - // missing token. A specific-site selection still rethrows so the renderer - // can surface the decrypt banner promptly. + // For all-sites reads, skip unreadable credentials; explicit site reads still throw and status retains the error. if (isAllSelection && error instanceof CredentialDecryptionError) { return [] } @@ -63,8 +59,7 @@ export function getStatus(): JiraConnectionStatus { const credentialError = sites .map((site) => getCredentialError(site.id)) .find((message) => message !== undefined) - // Why any-not-active: sealing is a host-wide property, so a second site stored while - // the keyring was missing is exposed even when the active one is sealed. + // Protection is host-wide: inactive sites with plaintext credentials still matter. const credentialProtection = sites.some((site) => getSiteTokenProtection(site.id) === 'plaintext') ? 'plaintext' : null @@ -109,10 +104,7 @@ export async function connect( await acquire() try { const { viewer, apiBaseUrl } = await verifyJiraCredentials(siteUrl, email, apiToken, authType) - // PAT sites have no email, so keying on it alone would collide every PAT - // connection to the same host into one id (silently overwriting a prior - // account + token). Fall back to the verified viewer identity so distinct - // accounts stay distinct. Cloud/Basic keep keying on their non-empty email. + // PAT sites lack email; verified viewer identity prevents same-host account collisions. const id = getSiteId(siteUrl, email || viewer.accountId) const site: JiraSite = { id, @@ -146,8 +138,6 @@ export function disconnect(siteId?: string): void { for (const id of ids) { deleteToken(id) } - // Why: drop cached attachment data URLs for disconnected sites so main does - // not retain multi-MB strings after logout. clearAttachmentImagesForSite(siteId) writeSiteFile({ version: 1, @@ -198,14 +188,12 @@ export async function testConnection( export function clearToken(siteId: string): void { deleteToken(siteId) - // Why: auth failure removes the site; drop cached attachment data URLs too. clearAttachmentImagesForSite(siteId) const file = getSiteFile() writeSiteFile({ ...file, sites: file.sites.filter((site) => site.id !== siteId) }) } export function isAuthError(error: unknown): boolean { - // Why: Jira returns 403 for project/API permission gaps even when /myself - // succeeds, so only 401 means the saved credential itself is invalid. + // Jira 403 may mean missing project permissions; only an unscoped 401 invalidates the credential. return error instanceof JiraApiError && error.status === 401 && !error.scopeMismatch } diff --git a/src/main/jira/jira-issue-comments.ts b/src/main/jira/jira-issue-comments.ts index 29ab9a6cd89..851e9c00c1f 100644 --- a/src/main/jira/jira-issue-comments.ts +++ b/src/main/jira/jira-issue-comments.ts @@ -36,11 +36,7 @@ function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraCom } } -/** - * Pooled comment media collect: attachment metadata JSON stays under the semaphore. - * Residual: Server/DC comment bodies are wiki markup, not ADF — this only fixes - * the lookup path; wiki `!filename!` is not rendered as media. - */ +/** Keep metadata JSON under the semaphore; Server/DC wiki !filename! media remains unsupported. */ async function collectCommentMediaRequest( client: JiraClientForSite, key: string, @@ -63,14 +59,12 @@ async function collectCommentMediaRequest( const needingCount = mediaAttrs.filter( (attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)) ).length - // Why: selectPreferredAttachmentIds yields nothing without attachment-needing media, so - // HTML ids alone can never produce a download — skip the extra metadata request entirely. + // HTML IDs alone cannot produce downloads; skip metadata without attachment-needing media. if (needingCount === 0) { return undefined } - // Why: comment media usually references issue-level attachments; pull them once - // for the whole thread. Use apiBasePath so Server/DC does not 404 on /rest/api/3. + // Comment media shares issue attachments; use the site's API version for Server/DC. let attachmentField: unknown try { const issue = await jiraRequest( diff --git a/src/main/jira/jira-issue-create-metadata.ts b/src/main/jira/jira-issue-create-metadata.ts index 2e555edc723..f4befe199a9 100644 --- a/src/main/jira/jira-issue-create-metadata.ts +++ b/src/main/jira/jira-issue-create-metadata.ts @@ -62,7 +62,6 @@ export async function listIssueTypes( } } -/** Lists the required create fields for a project and issue type. */ export async function listCreateFields( projectIdOrKey: string, issueTypeId: string, @@ -118,7 +117,6 @@ export async function listCreateFields( } } -/** Lists the site's issue priorities. */ export async function listPriorities( siteId?: string | null, strict = false @@ -148,11 +146,7 @@ export async function listPriorities( } } -/** - * Searches all users on the site. Reporter and user-picker create fields are not - * limited to assignable users and no issue key exists before create, so neither - * `/user/assignable/search` variant fits. Returns `[]` when browse-users is denied. - */ +/** Create user-pickers need all users, not just assignable users; return [] if browse-users is denied. */ export async function searchUsers(query?: string, siteId?: string | null): Promise { const entry = getClients(siteId)[0] if (!entry) { @@ -160,8 +154,7 @@ export async function searchUsers(query?: string, siteId?: string | null): Promi } const isServer = entry.site.authType === 'server' const params = new URLSearchParams({ maxResults: '50' }) - // Server/DC filters by `username`; `query` is Cloud-only. Server rejects an - // empty username, so fall back to the wildcard it accepts for "list everyone". + // Server/DC needs username and rejects an empty filter; Cloud uses query. params.set(isServer ? 'username' : 'query', query?.trim() || (isServer ? '.' : '')) await acquire() try { @@ -233,7 +226,7 @@ export async function listAssignableUsers( return searchAssignableUsers({ issueKey: key }, query, siteId, 'listAssignableUsers', strict) } -// Creation has no issue key yet, so assignability is scoped by project. +// Before creation, assignability is project-scoped because no issue key exists. export async function listAssignableUsersForProject( projectIdOrKey: string, query?: string, diff --git a/src/main/jira/jira-issue-mapping.ts b/src/main/jira/jira-issue-mapping.ts index a83c9a21413..e4d39fd7c9f 100644 --- a/src/main/jira/jira-issue-mapping.ts +++ b/src/main/jira/jira-issue-mapping.ts @@ -36,14 +36,12 @@ export const ISSUE_FIELDS = [ 'timeestimate' ] -// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost. +// Lists need metadata without expensive description parsing. export const ISSUE_LIST_FIELDS = ISSUE_FIELDS.filter((field) => field !== 'description') -// Why: detail reads need attachment metadata so inline ADF media can be resolved -// to downloadable image content; list/search omit this for payload size. +// Only detail reads need attachment metadata for inline media. export const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment'] -// `created`/`updated` are required: mapJiraIssue falls back to "now" when they're absent, -// which would silently report the lookup time as the issue's timestamps. +// Omitted timestamps would make mapJiraIssue report lookup time as creation/update time. export const ISSUE_SUMMARY_FIELDS = [ 'summary', 'project', @@ -222,7 +220,7 @@ function sprintState(value: unknown): string | undefined { return asString(asRecord(value).state) || undefined } -// Why: Jira orders an issue's sprints by id, not state, so the last one may be closed. +// Sprints are ordered by ID; the last entry may be closed. export function mapSprint(value: unknown): string | undefined { const sprints = Array.isArray(value) ? value : value ? [value] : [] const active = sprints.find((sprint) => sprintState(sprint)?.toLowerCase() === 'active') diff --git a/src/main/jira/jira-issue-mutations.ts b/src/main/jira/jira-issue-mutations.ts index 62750254e1d..acc2d059a11 100644 --- a/src/main/jira/jira-issue-mutations.ts +++ b/src/main/jira/jira-issue-mutations.ts @@ -11,19 +11,12 @@ import { clearToken, getClients, isAuthError } from './client' import { issueUrl, toBodyText } from './jira-issue-mapping' import type { JiraRecord } from './jira-record-pages' -/** - * Wraps a user id in the reference object the site expects: `{accountId}` on - * Cloud, `{name}` on Server/DC, which identifies users by username and whose - * ids `mapUser` stores in the accountId slot. - */ +/** Cloud uses accountId; Server/DC uses username, stored in mapUser's accountId slot. */ export function userFieldRef(site: JiraSite, id: string | null): JiraRecord { return site.authType === 'server' ? { name: id } : { accountId: id } } -/** - * Shapes a user-typed create value (scalar or array) into Jira's user reference - * objects. Jira rejects a bare string here and reports the field as missing. - */ +/** User-picker fields require reference objects, including within arrays; Jira treats bare strings as missing. */ function toUserFieldValue(site: JiraSite, value: unknown): unknown { if (typeof value === 'string') { return userFieldRef(site, value) @@ -34,7 +27,7 @@ function toUserFieldValue(site: JiraSite, value: unknown): unknown { return value } -/** Creates an issue, shaping the customFields keys named by `userFieldKeys`. */ +/** Shape customFields named by userFieldKeys into user references. */ export async function createIssue(args: JiraCreateIssueArgs): Promise { const entry = getClients(args.siteId)[0] if (!entry) { @@ -82,7 +75,6 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise { // Discovery owns a slot, so finish it before acquiring the search slot. const agileFields = await getAgileFieldIds(entry, requestSignal) - // Why: queueing on an abandoned search would keep occupying the shared Jira pool. + // Pass cancellation through queue admission so abandoned searches do not occupy the shared pool. await acquire(requestSignal) try { return await readIssuesForClient(entry, query, safeLimit, agileFields, requestSignal) @@ -188,8 +188,7 @@ async function runIssueSearch( }) ) ) - // 'all' fan-out: only surface an error when every connected site failed, so a - // partial success (or a genuinely empty result) is not reported as an error. + // Preserve partial success, including empty results; report failure only when every connected site fails. const recordedFailures = failures.filter( (failure): failure is JiraIssueSearchFailure => failure !== undefined ) diff --git a/src/main/jira/jira-read-failure.ts b/src/main/jira/jira-read-failure.ts index 29341a49e83..e2bee2285a7 100644 --- a/src/main/jira/jira-read-failure.ts +++ b/src/main/jira/jira-read-failure.ts @@ -75,8 +75,7 @@ export function shouldSurfaceSiteFailure( selection: JiraSiteSelection | null | undefined, entryCount: number ): boolean { - // getClients can resolve an omitted selection to the persisted 'all' choice; - // multi-entry reads need the same resilient fan-out policy as explicit 'all'. + // Omitted selections can resolve to persisted all-sites; apply the same resilient fan-out policy. return selection !== 'all' && entryCount <= 1 } diff --git a/src/main/jira/jira-transition-queries.ts b/src/main/jira/jira-transition-queries.ts index e8b34d2b077..6eedf86d738 100644 --- a/src/main/jira/jira-transition-queries.ts +++ b/src/main/jira/jira-transition-queries.ts @@ -65,8 +65,7 @@ export async function getProjectStatusOrder( projectKey: string, siteId?: string | null ): Promise { - // Why: an omitted site can resolve to the persisted "all" selection; board - // metadata is only truthful when exactly one Jira connection owns the project. + // Omitted siteId can resolve to all-sites; board metadata needs one owning connection. const entries = getClients(siteId) const entry = entries.length === 1 ? entries[0] : undefined if (!entry) { @@ -74,8 +73,7 @@ export async function getProjectStatusOrder( } await acquire() try { - // Why: without an explicit board picker there is no truthful way to choose - // among multiple project boards, so ambiguous projects keep alphabetical order. + // Without a board picker, ambiguous projects keep alphabetical status order. const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) const boardsResponse = await jiraRequest>( entry, @@ -101,8 +99,7 @@ export async function getProjectStatusOrder( return { statusIdsByColumn: [] } } - // Why: issues already carry status names and IDs, so returning board IDs - // avoids a second metadata request and keeps duplicate names unambiguous. + // Use status IDs already on issues to avoid metadata fetches and ambiguous duplicate names. const seenStatusIds = new Set() const statusIdsByColumn: string[][] = [] const boardColumns: NonNullable = [] diff --git a/src/main/jira/site-credential-store.ts b/src/main/jira/site-credential-store.ts index 697e95c56e1..d72162ce1c7 100644 --- a/src/main/jira/site-credential-store.ts +++ b/src/main/jira/site-credential-store.ts @@ -189,7 +189,5 @@ export function deleteToken(siteId: string): void { tokenFileCache.forget(siteId) try { unlinkSync(getTokenPath(siteId)) - } catch { - // Token may not exist — safe to ignore. - } + } catch {} } diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts index bf44c9034c1..cb2c7b4d9ad 100644 --- a/src/main/jsonl-rpc/agent-connection.ts +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -80,7 +80,6 @@ export class JsonlRpcAgentConnection { managed.child.stderr.on('error', this.onError) managed.onExit((exit) => { clearTimeout(this.streamExitTimer) - // The agent's last stderr lines are what a person can act on when it dies. const error = withProviderDiagnostic( new Error( exit.processless @@ -169,7 +168,7 @@ export class JsonlRpcAgentConnection { this.peer.resumeReading() } - /** Stop sends the dialect's abort first, then closes this connection through the supervisor. */ + /** The dialect sends abort before the supervisor closes the process connection. */ close(error?: Error): Promise { this.closing = true clearTimeout(this.streamExitTimer) diff --git a/src/main/jsonl-rpc/peer.ts b/src/main/jsonl-rpc/peer.ts index 7e934b9a278..0aa6c6b0e66 100644 --- a/src/main/jsonl-rpc/peer.ts +++ b/src/main/jsonl-rpc/peer.ts @@ -44,8 +44,7 @@ export class JsonlRpcResponseError extends Error { } } export type JsonlRpcPeerHandlers = { - /** Includes deferred prompt acknowledgements without an id. */ - /** A dialect failure is fatal: continuing would discard session or tool lifecycle evidence. */ + /** Includes id-less deferred prompt acknowledgements; dialect failures are fatal to preserve lifecycle evidence. */ onRecord?: (record: JsonlRpcRecord) => void onClose?: (error: Error) => void onDiagnostic?: (message: string) => void diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts index a722e2c5ed7..e91facf53ec 100644 --- a/src/main/keybindings/keybinding-file.test.ts +++ b/src/main/keybindings/keybinding-file.test.ts @@ -265,7 +265,6 @@ describe('keybinding-file', () => { 'tab.nextAllTypes': ['Mod+Alt+BracketRight'], 'tab.previousAllTypes': ['Mod+Alt+BracketLeft'] }) - // Effective bindings reproduce the pre-swap behavior with no conflicts dropped. expect(snapshot.overrides).toMatchObject({ 'tab.nextSameType': ['Mod+Shift+BracketRight'], 'tab.nextAllTypes': ['Mod+Alt+BracketRight'] @@ -274,8 +273,7 @@ describe('keybinding-file', () => { }) it('preserves a customized action while still pinning the un-customized ones', () => { - // A partially-customized existing user: they rebound one action. The other - // three must still land on their pre-swap defaults, not the new ones. + // Pin unchanged actions even when the user customized only one. writeKeybindingOverride(filePath, 'darwin', 'tab.nextSameType', ['Mod+Alt+K']) const result = seedLegacyTabSwitchBindings(filePath, 'darwin', LEGACY_TAB_SWITCH_BINDINGS) @@ -290,10 +288,7 @@ describe('keybinding-file', () => { }) it('preserves pre-swap customizations that conflict only with the new defaults', () => { - // This was valid before the swap: moving nextSameType to Mod+K freed its - // old Shift+] chord for previousSameType. The new registry temporarily - // assigns Shift+] to nextAllTypes, but the seed must not mistake the - // resulting conflict for an absent user override and replace it. + // Inspect raw overrides: new defaults can make a valid pre-swap custom chord appear conflicting. writeFileSync( filePath, JSON.stringify({ @@ -322,8 +317,7 @@ describe('keybinding-file', () => { }) it('is a no-op once every swapped action already resolves on this platform', () => { - // Seeding writes the whole document at once, so it clears the per-write - // conflict guard that a naive one-action-at-a-time write would trip. + // Seed atomically so per-action conflict guards cannot reject valid pre-swap chords. const first = seedLegacyTabSwitchBindings(filePath, 'darwin', LEGACY_TAB_SWITCH_BINDINGS) expect(first.seeded).toBe(true) expect(first.snapshot.diagnostics).toEqual([]) @@ -333,8 +327,7 @@ describe('keybinding-file', () => { }) it('does not let a foreign-platform override block the active-platform pin', () => { - // Only linux is customized; a darwin launch must still pin darwin so the - // active platform keeps the pre-swap behavior. + // Only active-platform customizations affect whether its actions need pins. writeKeybindingOverride(filePath, 'linux', 'tab.nextAllTypes', ['Mod+Alt+K']) const result = seedLegacyTabSwitchBindings(filePath, 'darwin', LEGACY_TAB_SWITCH_BINDINGS) @@ -355,7 +348,6 @@ describe('keybinding-file', () => { const second = seedLegacyTabSwitchBindings(filePath, 'darwin', LEGACY_TAB_SWITCH_BINDINGS) expect(first.seeded).toBe(true) - // A second pass is a no-op: the pins now read as existing customization. expect(second.seeded).toBe(false) const snapshot = readKeybindingFile(filePath, 'darwin') expect(snapshot.overrides['terminal.search']).toEqual(['Mod+Shift+F']) @@ -395,9 +387,7 @@ describe('keybinding-file', () => { }) it('throws instead of freezing the seed when a legacy binding fails normalization', () => { - // A dropped pin must not be silent: the service catches the throw and keeps - // the cohort pending so a corrected build can retry the seed. Valid pins - // from the same batch are still written so users keep the good shortcuts. + // Write good pins, but retain the migration cohort so dropped pins can retry after a fix. expect(() => seedLegacyTabSwitchBindings(filePath, 'darwin', { 'tab.nextSameType': ['J'], diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index 4ee6bfeb2ce..672733ea597 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -101,20 +101,7 @@ export function migrateLegacyKeybindings( writeJsonDocument(path, document) } -/** - * Pin the pre-swap tab-switch chords for a pre-existing install so upgrading - * users keep the shortcuts they learned. Writes into the active-platform - * section (mirroring `writeKeybindingOverride`) so the seeded values stay - * resettable from Settings. - * - * Pins per action, not all-or-nothing: an action is seeded only when this - * platform has no effective override for it yet. That way a user who rebound - * just one of the swapped actions keeps that choice AND keeps the pre-swap - * default on the other three — an existing user's behavior is never altered, - * whether they customized none, some, or all of them. Because every pin equals - * the action's old default, the seeded set reproduces exactly today's effective - * config and introduces no new conflicts. - */ +/** Pin each uncustomized action to its pre-swap chord in the active-platform section, preserving resettable user choices. */ export function seedLegacyTabSwitchBindings( path: string, platform: NodeJS.Platform, @@ -124,9 +111,7 @@ export function seedLegacyTabSwitchBindings( const actionIds = Object.keys(legacyBindings) as KeybindingActionId[] const current = readKeybindingFile(path, platform) const activePlatformOverrides = current.platformOverrides[keybindingPlatform] ?? {} - // Why: the new defaults can temporarily make a valid pre-swap customization - // look conflicting and remove it from `current.overrides`. Inspect the parsed - // common + active-platform sections directly so the seed never replaces it. + // New defaults can hide valid pre-swap overrides as conflicts; inspect raw common/platform sections. const toSeed = actionIds.filter( (actionId) => !Object.hasOwn(current.commonOverrides, actionId) && @@ -136,9 +121,7 @@ export function seedLegacyTabSwitchBindings( return { seeded: false, snapshot: current } } - // Why: seed every pin that normalizes, but never freeze the one-shot if any - // pin was dropped — throw after writing good pins so the cohort stays pending - // and a fixed build retries the failed action without wiping the others. + // Write valid pins, then throw on dropped pins so a fixed build can retry the pending cohort. const pins: (readonly [KeybindingActionId, string[]])[] = [] const failedActionIds: KeybindingActionId[] = [] for (const actionId of toSeed) { @@ -163,8 +146,7 @@ export function seedLegacyTabSwitchBindings( return { seeded: pins.length > 0, snapshot } } -// Why: the one-shot seed migration and Settings writes must produce the same -// on-disk document shape; a single assembly path keeps them from drifting. +// Migration and Settings writes must use the same document assembly. function writeActivePlatformSection( path: string, platform: NodeJS.Platform, @@ -174,8 +156,7 @@ function writeActivePlatformSection( const keybindingPlatform = getKeybindingPlatform(platform) const readResult = readJsonDocument(path) if (!readResult.document) { - // Why: writes must never replace a user-owned file that could not be - // parsed; callers surface the error (or retry the migration) after repair. + // Never overwrite an unreadable user-owned file; callers surface or retry the error. throw new Error(readResult.error ?? 'Could not read keybindings file.') } const document = { ...readResult.document } @@ -240,9 +221,7 @@ export function writeKeybindingOverride( currentSnapshot.commonOverrides, (activePlatform) => { if (normalizedBindings === null) { - // Why: Settings edits are scoped to the current platform. A hand-authored - // common binding may be intentional for other OSes, so reset only removes - // the platform-specific mask instead of deleting the shared value. + // Reset only the platform mask; common bindings may be intentional on other OSes. delete activePlatform[actionId] } else { activePlatform[actionId] = normalizedBindings diff --git a/src/main/keybindings/keybinding-service.test.ts b/src/main/keybindings/keybinding-service.test.ts index 2d18190fba8..42e5892470a 100644 --- a/src/main/keybindings/keybinding-service.test.ts +++ b/src/main/keybindings/keybinding-service.test.ts @@ -12,11 +12,7 @@ import { import { getUserKeybindingsPath, writeKeybindingOverride } from './keybinding-file' import { KeybindingService } from './keybinding-service' -// End-to-end coverage of the tab-switch convention swap: constructs a real -// KeybindingService (which runs the seed) and asserts the effective bindings AND -// real keystroke matching for both cohorts. The load-bearing claim under test is -// that a pre-existing install's behavior is byte-identical to what it was before -// the swap, on every platform and every customization state. +// Verify effective bindings and keystroke matching for fresh/existing cohorts across all platforms. const PLATFORMS: NodeJS.Platform[] = ['darwin', 'linux', 'win32'] const SWAPPED_ACTIONS = Object.keys(LEGACY_TAB_SWITCH_BINDINGS) as KeybindingActionId[] @@ -39,8 +35,7 @@ function makeCohort(pending: boolean): { } } -// A physical bracket press. Mod is Cmd on macOS and Ctrl elsewhere; the matcher -// resolves brackets by `code`, so this stays layout-independent. +// Physical bracket codes are layout-independent; Mod maps to the platform modifier. function bracketPress(opts: { code: 'BracketLeft' | 'BracketRight' alt: boolean @@ -80,7 +75,6 @@ describe('KeybindingService tab-switch cohort seeding', () => { }) expect(cohort.seeded()).toBe(false) - // Nothing written: a fresh install rides on the registry defaults. expect(existsSync(getUserKeybindingsPath(home))).toBe(false) const overrides = service.getOverrides() @@ -91,7 +85,6 @@ describe('KeybindingService tab-switch cohort seeding', () => { 'Mod+Alt+BracketRight' ]) - // Shift+bracket now drives the broad all-types cycle. expect( keybindingMatchesAction( 'tab.nextAllTypes', @@ -124,15 +117,13 @@ describe('KeybindingService tab-switch cohort seeding', () => { expect(cohort.seeded()).toBe(true) const overrides = service.getOverrides() - // Effective bindings for all four actions equal the pre-swap defaults. for (const actionId of SWAPPED_ACTIONS) { expect(getEffectiveKeybindingsForAction(actionId, platform, overrides)).toEqual( LEGACY_TAB_SWITCH_BINDINGS[actionId] ) } - // Behavior is unchanged: Mod+Shift+bracket still cycles same-type, and - // Mod+Alt+bracket still cycles all-types — the inverse of a fresh install. + // Existing installs keep the inverse of fresh-install tab-switch defaults. const shiftRight = bracketPress({ code: 'BracketRight', alt: false, shift: true, platform }) expect(keybindingMatchesAction('tab.nextSameType', shiftRight, platform, overrides)).toBe( true @@ -153,7 +144,6 @@ describe('KeybindingService tab-switch cohort seeding', () => { it('existing install keeps a rebound action AND pins the pre-swap default on the rest', () => { const platform: NodeJS.Platform = 'darwin' - // The user had rebound just one action before upgrading. writeKeybindingOverride(getUserKeybindingsPath(home), platform, 'tab.nextSameType', ['Mod+K']) const cohort = makeCohort(true) @@ -164,12 +154,9 @@ describe('KeybindingService tab-switch cohort seeding', () => { }) const overrides = service.getOverrides() - // Their custom binding is untouched... expect(getEffectiveKeybindingsForAction('tab.nextSameType', platform, overrides)).toEqual([ 'Mod+K' ]) - // ...and the other three still resolve to their pre-swap defaults, not the - // new ones — no silent behavior change for a partially-customized user. expect(getEffectiveKeybindingsForAction('tab.previousSameType', platform, overrides)).toEqual([ 'Mod+Shift+BracketLeft' ]) @@ -191,8 +178,7 @@ describe('KeybindingService tab-switch cohort seeding', () => { }) const afterFirst = first.getOverrides() - // Same cohort controller: markSeeded flipped it to not-pending, mirroring the - // persisted flag flipping to 'done'. + // Reuse the cohort after markSeeded to model the persisted done flag. const second = new KeybindingService({ homePath: home, platform, @@ -207,8 +193,7 @@ describe('KeybindingService tab-switch cohort seeding', () => { }) it('leaves the cohort pending when the seed write fails (retries next launch)', () => { - // homePath is a regular file, so creating `/.orca/` throws. The service - // must swallow the error and NOT mark the one-shot done. + // A file at homePath forces mkdir failure; the cohort must remain pending. const fileHome = join(home, 'not-a-dir') writeFileSync(fileHome, 'x', 'utf8') const cohort = makeCohort(true) diff --git a/src/main/keybindings/keybinding-service.ts b/src/main/keybindings/keybinding-service.ts index d32e105b1d1..65dc8e684b6 100644 --- a/src/main/keybindings/keybinding-service.ts +++ b/src/main/keybindings/keybinding-service.ts @@ -17,9 +17,7 @@ export type KeybindingServiceOptions = { homePath: string platform?: NodeJS.Platform getLegacyOverrides?: () => KeybindingOverrides | undefined - /** Cohort seed for the tab-switch convention swap. `isPending` is true only - * for pre-existing installs on the first launch after the swap; `markSeeded` - * freezes the one-shot so it never runs again. */ + /** Seed once for pre-existing installs; markSeeded completes the persisted cohort. */ legacyTabSwitchSeed?: { isPending: () => boolean markSeeded: () => void @@ -34,16 +32,12 @@ export class KeybindingService { constructor(options: KeybindingServiceOptions) { this.configPath = getUserKeybindingsPath(options.homePath) this.platform = options.platform ?? process.platform - // Why: older builds persisted custom shortcuts inside global settings. - // Once a keybindings file exists, it is the sole source of truth. + // A keybindings file supersedes shortcuts stored in legacy global settings. migrateLegacyKeybindings(this.configPath, this.platform, options.getLegacyOverrides?.()) - // Why: pre-existing installs keep the old tab-switch chords. Only mark the - // one-shot done on success so a transient IO failure retries next launch - // instead of silently dropping the pin. + // Complete the cohort only after successful pinning so transient I/O failures retry next launch. if (options.legacyTabSwitchSeed?.isPending()) { try { - // Why: the seed already read the file to build its snapshot — prime the - // lazy cache with it instead of re-reading on the first getSnapshot(). + // Seed already read the file; reuse its snapshot for the lazy cache. this.snapshot = seedLegacyTabSwitchBindings( this.configPath, this.platform, diff --git a/src/main/kimi/hook-service-config-delete-race.test.ts b/src/main/kimi/hook-service-config-delete-race.test.ts index 6a801cef4c0..95acf64774f 100644 --- a/src/main/kimi/hook-service-config-delete-race.test.ts +++ b/src/main/kimi/hook-service-config-delete-race.test.ts @@ -8,9 +8,7 @@ const deleteRace = vi.hoisted((): { path: string | null; code: string } => ({ code: 'ENOENT' })) -// Why: simulates the config file (or an ancestor directory) vanishing between -// writeConfigToml's existsSync check and its statSync mode read — e.g. a -// concurrent uninstall, user delete, or a directory replaced by a file. +// Simulate the config or its ancestor disappearing between existence and mode reads. vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() const statSync = ( diff --git a/src/main/kimi/hook-service.test.ts b/src/main/kimi/hook-service.test.ts index ae5a6d6586a..e953014be5b 100644 --- a/src/main/kimi/hook-service.test.ts +++ b/src/main/kimi/hook-service.test.ts @@ -15,10 +15,7 @@ import { wrapPosixHookCommand, wrapWindowsHookCommand } from '../agent-hooks/ins import { getManagedCommand, KimiHookService } from './hook-service' import { buildManagedKimiHooksBlock, KIMI_HOOK_EVENTS } from './kimi-hook-config-toml' -// Why: getSharedManagedScriptPath() writes the managed script under -// homedir()/.orca, and getKimiHome() honors KIMI_CODE_HOME. Point both at a -// temp dir so the local install/remove cycle never touches the real ~/.orca or -// ~/.kimi-code. os.homedir() resolves HOME on POSIX and USERPROFILE on Windows. +// Isolate homedir (HOME/USERPROFILE) and KIMI_CODE_HOME so installs cannot touch real configs. let home: string let originalHome: string | undefined let originalKimiHome: string | undefined @@ -63,8 +60,7 @@ describe('KimiHookService', () => { expect(new KimiHookService().getStatus().state).toBe('not_installed') }) - // Why: Kimi runs hooks through cmd.exe on Windows, where a POSIX `if [ -f ... ]` command - // fails before it ever reaches the script, so no status event was ever posted. + // Kimi uses cmd.exe on Windows; POSIX conditionals prevent all hook delivery there. describe('on Windows', () => { beforeEach(() => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') @@ -129,15 +125,12 @@ describe('KimiHookService', () => { for (const event of KIMI_HOOK_EVENTS) { expect(config).toContain(`event = "${event}"`) } - // The managed script must exist and POST to the Kimi hook endpoint. const script = readFileSync(scriptPath(), 'utf-8') expect(script).toContain('/hook/kimi') - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive). + // Keep large payloads off curl's command line to avoid EDR false positives. expect(script).toContain('printf \'%s\' "$payload" | curl') expect(script).toContain('--data-urlencode "payload@-"') expect(script).not.toContain('--data-urlencode "payload=${payload}"') - // The command Kimi runs points at the managed script via sh. expect(config).toContain('agent-hooks/kimi-hook.sh') } ) @@ -145,7 +138,6 @@ describe('KimiHookService', () => { it('keeps user config when installing, then restores it on remove', () => { const dir = join(home, '.kimi-code') mkdirSync(dir, { recursive: true }) - // Pre-existing user config with their own provider. const userConfig = 'default_model = "kimi-k2.6"\n\n[providers."mine"]\ntype = "openai"\napi_key = "sk-secret"\n' writeFileSync(configPath(), userConfig) @@ -157,7 +149,6 @@ describe('KimiHookService', () => { expect(installed).toContain('api_key = "sk-secret"') expect(installed).toContain('default_model = "kimi-k2.6"') - // Reinstall must not duplicate the managed block. service.install() const reinstalled = readFileSync(configPath(), 'utf-8') expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 8a2af74fa10..35197a593a5 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -42,9 +42,7 @@ import { removeManagedKimiHooks } from './kimi-hook-config-toml' -// Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (also used by -// kimi-fetcher.ts and the AI Vault session scanner) so hooks land in the same -// home Kimi reads at launch. +// Honor the same KIMI_CODE_HOME as the CLI and session scanner. function getKimiHome(): string { return process.env.KIMI_CODE_HOME?.trim() || join(homedir(), '.kimi-code') } @@ -53,14 +51,11 @@ function getConfigPath(): string { return join(getKimiHome(), 'config.toml') } -// Kimi runs hooks with asyncio.create_subprocess_shell: cmd.exe on Windows, sh -// elsewhere (it needs Git Bash only for its Shell tool). SSH remotes keep the .sh. +// Kimi uses cmd.exe on Windows and sh elsewhere; Git Bash is only for its Shell tool. const POSIX_SCRIPT_FILE_NAME = 'kimi-hook.sh' const WINDOWS_SCRIPT_FILE_NAME = 'kimi-hook.cmd' -// Ownership test for every managed-block path: status, install, remove and the -// bounded orphan recovery all agree on what counts as an Orca-written hook. It -// matches every extension of the stem, so a pre-fix Windows `.sh` entry is replaced. +// Share ownership across status/install/remove/orphan recovery, including legacy .sh entries on Windows. const isManagedKimiCommand = createManagedCommandMatcher(POSIX_SCRIPT_FILE_NAME) function getManagedScriptPath(): string { @@ -70,8 +65,7 @@ function getManagedScriptPath(): string { } export function getManagedCommand(scriptPath: string): string { - // Why the direct form's forward slashes are load-bearing: ownership matches the still-escaped - // TOML literal, where a backslash path reads as `agent-hooks\\kimi-hook.cmd` and never matches. + // Forward slashes preserve ownership matching against still-escaped TOML literals. return process.platform === 'win32' ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) @@ -82,7 +76,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: the endpoint file holds this install's live port/token; sourcing it lets a surviving PTY reach the current server (see claude/hook-service.ts). + // Refresh endpoint credentials for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookCurlPostCommand('kimi'), @@ -95,9 +89,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '#!/bin/sh', ...buildPosixHookPayloadCapture(), ...buildPosixHookSpoolLines('kimi'), - // Why: refresh PORT/TOKEN/ENV/VERSION from the current Orca install so a PTY - // that survived an Orca restart still reaches the live listener. See - // claude/hook-service.ts for the full rationale. + // Refresh endpoint credentials for PTYs surviving an Orca restart. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', @@ -105,12 +97,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' spool_hook_event', ' exit 0', 'fi', - // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX - // shell is not safe once a path contains quotes or newlines. Post the raw - // hook payload plus metadata as form fields and let the receiver parse it. - // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline - // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl - // command line (EDR command-line false positives). Wire body is identical. + // Form fields preserve arbitrary path text; stdin keeps large payloads off curl's command line for EDR. 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/kimi" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -127,8 +114,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\n') } -// Returns the file text, '' when the config does not exist yet (Kimi creates it -// lazily), or null on an unreadable file so callers can report a structured error. +/** Empty means missing; null means unreadable and must surface an error. */ function readConfigToml(configPath: string): string | null { if (!existsSync(configPath)) { return '' @@ -140,25 +126,22 @@ function readConfigToml(configPath: string): string | null { } } -// Why: temp+rename keeps a hand-editable config.toml intact if a write is -// interrupted, and a single rolling .bak makes a bad write recoverable. +// Atomic replacement protects interrupted writes; a rolling backup permits recovery. function writeConfigToml(configPath: string, text: string): void { const dir = dirname(configPath) mkdirSync(dir, { recursive: true }) - // Why: renameSync replaces the inode, so the temp mode becomes the config mode. + // Atomic rename gives the config the temp file's mode. let mode = 0o600 if (existsSync(configPath)) { try { if (readFileSync(configPath, 'utf-8') === text) { return } - } catch { - // Fall through to the atomic write path. - } + } catch {} try { mode = statSync(configPath).mode & 0o777 } catch (error) { - // Why: file was deleted between the existsSync check and here — nothing to preserve. + // Concurrent deletion leaves no mode to preserve; other errors must propagate. if (!isDefinitiveAbsence(error)) { throw error } @@ -240,8 +223,7 @@ export class KimiHookService { return this.getStatus() } - // Why: install Orca's managed Kimi hooks on a remote box over SFTP, mirroring - // the local install. POSIX-only by design: remote hosts run hooks through sh. + // Remote hooks assume POSIX sh. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remoteConfigPath = pathPosix.join(remoteHome, '.kimi-code', 'config.toml') const remoteScriptPath = pathPosix.join( @@ -251,7 +233,7 @@ export class KimiHookService { POSIX_SCRIPT_FILE_NAME ) try { - // null (file absent) → start from an empty config; Kimi creates it lazily. + // Kimi creates config lazily; a missing remote file starts empty. const text = (await readTextFileRemote(sftp, remoteConfigPath)) ?? '' const command = wrapPosixHookCommand(remoteScriptPath) // Write the script first so config.toml never points at a missing script. diff --git a/src/main/kimi/kimi-hook-config-toml.test.ts b/src/main/kimi/kimi-hook-config-toml.test.ts index 525cca03efc..dbf7f3b44ba 100644 --- a/src/main/kimi/kimi-hook-config-toml.test.ts +++ b/src/main/kimi/kimi-hook-config-toml.test.ts @@ -81,8 +81,7 @@ describe('kimi managed hooks TOML block', () => { it('is stable across repeated calls (no stateful global-regex lastIndex drift)', () => { const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) - // Repeated detection/removal on the same and on a clean input must be - // consistent — a `g`-flagged .test() would drift lastIndex and flip results. + // Repeated detection must not inherit stateful regex lastIndex. expect(removeManagedKimiHooks(installed, isManaged).changed).toBe(true) expect(removeManagedKimiHooks(installed, isManaged).changed).toBe(true) expect(removeManagedKimiHooks('default_model = "x"\n', isManaged).changed).toBe(false) @@ -95,14 +94,11 @@ describe('kimi managed hooks TOML block', () => { const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) const orphaned = deleteEndMarker(installed) expect(orphaned).not.toContain('<<<') - // The orphaned (still-active) hook tables are still recognized... expect(readManagedKimiHookEvents(orphaned, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) - // ...remove strips them... expect(removeManagedKimiHooks(orphaned, isManaged)).toEqual({ text: 'default_model = "x"\n', changed: true }) - // ...and reinstall converges to a single block instead of duplicating. const reinstalled = applyManagedKimiHooks(orphaned, COMMAND, isManaged) expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) }) @@ -138,7 +134,6 @@ describe('orphaned managed block ownership (#18861)', () => { expect(text).toContain('api_key = "sk-secret"') expect(text).toContain('command = "node my-own-hook.mjs"') expect(text).toContain('default_model = "x"') - // The reclaimed managed tables and the stray marker are gone. expect(text).not.toContain(START_MARKER) expect(text).not.toContain('agent-hooks/kimi-hook.sh') }) @@ -147,11 +142,9 @@ describe('orphaned managed block ownership (#18861)', () => { const reinstalled = applyManagedKimiHooks(orphanedWithUserTail(), COMMAND, isManaged) expect(reinstalled).toContain('api_key = "sk-secret"') expect(reinstalled).toContain('command = "node my-own-hook.mjs"') - // Exactly one well-formed block, appended after the surviving user bytes. expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) expect(reinstalled.indexOf('sk-secret')).toBeLessThan(reinstalled.indexOf(START_MARKER)) expect(readManagedKimiHookEvents(reinstalled, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) - // And a second install is a no-op, so the recovery converges. expect(applyManagedKimiHooks(reinstalled, COMMAND, isManaged)).toBe(reinstalled) }) @@ -194,9 +187,7 @@ describe('orphaned managed block ownership (#18861)', () => { expect(text).not.toContain(START_MARKER) }) - // A user adding keys has customised Orca's hook, not authored their own: the - // command path is what makes it fire. Leaving it would keep sending Orca their - // events after uninstall, and reinstall would double-fire the event. + // Command ownership survives extra user keys; leaving the table would still emit events after uninstall. it('owns a managed table the user added an extra key to', () => { const orphan = [ START_MARKER, @@ -222,7 +213,6 @@ describe('orphaned managed block ownership (#18861)', () => { '' ].join('\n') expect(removeManagedKimiHooks(customised, isManaged).text).toBe('default_model = "x"\n') - // Status agrees, so install cannot append a second table for the same event. expect(readManagedKimiHookEvents(customised, isManaged)).toEqual(new Set(['Stop'])) const reinstalled = applyManagedKimiHooks(customised, COMMAND, isManaged) expect((reinstalled.match(/event = "Stop"/g) ?? []).length).toBe(1) @@ -246,8 +236,7 @@ describe('orphaned managed block ownership (#18861)', () => { expect(text).not.toContain(START_MARKER) }) - // CodeRabbit on #20148: the old regex reader matched key *suffixes* and - // commented-out keys. Keys are parsed exactly now; these must not register. + // Match exact parsed keys, excluding suffixes and comments. it('does not read a managed event from key suffixes or commented keys', () => { const nearMiss = [ START_MARKER, @@ -265,8 +254,7 @@ describe('orphaned managed block ownership (#18861)', () => { expect(readManagedKimiHookEvents(nearMiss, isManaged)).toEqual(new Set()) }) - // CodeRabbit on #20148: a blank or comment between keys does not end a TOML - // table. Splicing the bounded run would strand `timeout` without its header. + // TOML gaps do not end tables; removing a partial run would orphan later keys. it('fails closed when more keys follow a gap inside the table', () => { for (const gap of ['', '# note']) { const orphan = [ @@ -302,13 +290,11 @@ describe('orphaned managed block ownership (#18861)', () => { const { text } = removeManagedKimiHooks(orphan, isManaged) expect(text).not.toContain(START_MARKER) expect(text).not.toContain('agent-hooks/kimi-hook.sh') - // The user's comment and table are theirs; only the managed table goes. expect(text).toContain('# a user comment') expect(text).toContain('[user.table]') }) - // pullfrog on #20148: ownership keys on `command`, so an `event` Orca cannot - // parse must never let status claim nothing is installed. + // Command-owned tables with unreadable events must still appear installed or partial. it('never reports not_installed for a table remove() would strip', () => { for (const eventLine of [`event = 'Stop'`, 'event = "Stop" # note', 'event = 12']) { const config = [ @@ -320,11 +306,9 @@ describe('orphaned managed block ownership (#18861)', () => { END_MARKER_LINE, '' ].join('\n') - // remove() strips it, so status must see it too. expect(removeManagedKimiHooks(config, isManaged).changed).toBe(true) expect(readManagedKimiHookEvents(config, isManaged).size).toBeGreaterThan(0) } - // The single-quoted form resolves to the real event name. const singleQuoted = [ START_MARKER, '[[hooks]]', @@ -352,8 +336,7 @@ describe('orphaned managed block ownership (#18861)', () => { expect(text).toContain('matcher = "Bash"') }) - // A stranded managed table still executes, so remove() must reclaim it wherever - // a hand-edit left it; only the user's own bytes are off limits. + // Reclaim recognized managed tables even when edits strand them outside the marker block. it('reclaims managed tables stranded below user text', () => { const managedTable = [ '[[hooks]]', diff --git a/src/main/kimi/kimi-hook-config-toml.ts b/src/main/kimi/kimi-hook-config-toml.ts index 3c735c2a772..b7f213ffdfc 100644 --- a/src/main/kimi/kimi-hook-config-toml.ts +++ b/src/main/kimi/kimi-hook-config-toml.ts @@ -1,10 +1,4 @@ -// Kimi Code keeps all preferences in TOML (`~/.kimi-code/config.toml`) and reads -// lifecycle hooks from an array of `[[hooks]]` tables. There is no JSON settings -// file to reuse the shared JSON installer with, and no TOML library is vendored, -// so Orca manages only its own marker-delimited block: install rewrites the -// block, remove strips it, and user config is left untouched apart from hook -// tables Orca itself emitted. Appending table headers is always valid TOML, so -// the block can live at the end of any existing file. +// Preserve user TOML while managing marker blocks and positively recognized Orca hook tables. import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { @@ -16,9 +10,6 @@ import { type RecognizedManagedTable } from '../agent-hooks/managed-toml-ownership' -// Why: mirror the Claude-compatible events Orca normalizes for status. Kimi uses -// these exact event names (see normalizeKimiEvent), so each maps to a -// working/waiting/done transition. export const KIMI_HOOK_EVENTS = [ 'UserPromptSubmit', 'PreToolUse', @@ -37,15 +28,7 @@ const HOOK_TABLE_HEADER = '[[hooks]]' export type ManagedCommandMatcher = (command: string | undefined) => boolean -// A `[[hooks]]` table that invokes Orca's managed script is Orca's hook: that -// command path is the only reason it fires, and it is there because Orca put it -// there. Extra keys are a user customising our hook, not authoring their own, so -// uninstall still owns it — leaving it would keep feeding Orca their events -// after they asked it to stop, and reinstall would double-fire the event. -// -// The key run is still parsed strictly: an unrecognized line shape (a multi-line -// array or string, say) means the table's extent is unknown, and guessing it -// would splice the wrong bytes. That case fails closed. +// Command paths confer ownership even with extra keys; unknown multiline extents fail closed to preserve user bytes. function matchManagedHookTable( lines: readonly string[], index: number, @@ -70,10 +53,7 @@ function matchManagedHookTable( pairs.set(pair[1], pair[2].trim()) cursor++ } - // TOML lets blank lines and comments sit between keys of one table, so a gap - // is not proof the table ended. If more keys follow it, the run above covered - // only part of the table and splicing it would strand the rest without its - // header — the extent is unknown, so fail closed. + // TOML gaps do not end tables; later keys make this partial run unsafe to splice. if (keysFollowGap(lines, cursor)) { return null } @@ -85,8 +65,6 @@ function matchManagedHookTable( return { lineCount: cursor - index, value: readEventName(pairs.get('event')) } } -// True when a key line follows the gap before the next table header, meaning -// the table extends past the bounded key run above. function keysFollowGap(lines: readonly string[], from: number): boolean { for (let cursor = from; cursor < lines.length; cursor++) { const line = lines[cursor].trim() @@ -103,11 +81,7 @@ function readTomlString(value: string | undefined): string | undefined { return value?.match(/^"((?:[^"\\]|\\.)*)"/)?.[1] ?? value?.match(/^'([^']*)'/)?.[1] } -// Ownership keys on the command, so an event Orca cannot parse must still -// register: status reporting `not_installed` for a table remove() will strip is -// the exact split this recognizer exists to close. An unreadable literal falls -// back to its raw text, which matches no known event and lands status on -// `partial` rather than claiming nothing is installed. +// Unreadable command-owned events must report partial, not absent; raw text cannot match a known event. function readEventName(value: string | undefined): string | null { if (value === undefined) { return null @@ -124,8 +98,7 @@ function recognizeManagedTables( ) } -// Orca owns two things here: whatever sits inside a matched marker pair, and -// every table it can positively recognize wherever that table ended up. +// Own matched marker pairs and positively recognized tables wherever edits moved them. function findOwnedRegions( configText: string, isManagedCommand: ManagedCommandMatcher @@ -136,8 +109,7 @@ function findOwnedRegions( ] } -// TOML basic (double-quoted) string. The Windows encoded-PowerShell fallback -// carries a backslashed powershell.exe path, so backslashes and quotes are escaped. +// Escape Windows fallback paths for TOML basic strings. function tomlBasicString(value: string): string { const escaped = value .replace(/\\/g, '\\\\') @@ -151,10 +123,7 @@ function tomlBasicString(value: string): string { export function buildManagedKimiHooksBlock(command: string, eol = '\n'): string { const commandLiteral = tomlBasicString(command) - // Omit `matcher`: Kimi treats it as a regex (so Claude's literal "*" is - // invalid) and an absent matcher already matches every tool. - // `timeout` is the host-level backstop; the shell wrapper's curl budget is - // the normal dead-endpoint bound. + // Omit matcher: Kimi treats it as regex, so * is invalid. Host timeout backs up the normal curl deadline. const entries = KIMI_HOOK_EVENTS.map((event) => [ HOOK_TABLE_HEADER, @@ -202,10 +171,7 @@ export function removeManagedKimiHooks( return { text: trimmed.length > 0 ? `${trimmed}${eol}` : '', changed: true } } -// Events a managed table is live for, counted wherever the table sits (by script -// filename, so a moved userData path is still seen). Status must include tables -// stranded outside the markers — those still fire, so reporting them absent -// would tell the user a hook is uninstalled while Orca keeps receiving events. +// Count managed tables outside markers too: they still emit events and must not appear uninstalled. export function readManagedKimiHookEvents( configText: string, isManagedCommand: ManagedCommandMatcher diff --git a/src/main/kimi/kimi-runtime-home.ts b/src/main/kimi/kimi-runtime-home.ts index 475b1851647..83998aa6d16 100644 --- a/src/main/kimi/kimi-runtime-home.ts +++ b/src/main/kimi/kimi-runtime-home.ts @@ -14,17 +14,12 @@ export type KimiHomeResolution = LocalAccountRuntimeTarget & { path: string | null } -// Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution so we read the -// same files the running Kimi CLI writes. +// Match CLI KIMI_CODE_HOME resolution. export function getHostKimiHome(): string { return process.env.KIMI_CODE_HOME?.trim() || join(homedir(), '.kimi-code') } -/** - * Kimi has no per-account runtime switcher, so it follows the local-account - * runtime policy. WSL is Windows-only — pin everywhere else so no `wsl.exe` - * probe is attempted on a stale `wsl` setting synced from a Windows machine. - */ +/** Kimi follows local-account runtime policy; ignore synced WSL settings outside Windows. */ export function getKimiRuntimeTarget( settings: GlobalSettings, platform: NodeJS.Platform = process.platform @@ -35,11 +30,7 @@ export function getKimiRuntimeTarget( return resolveLocalAccountRuntimeTarget(settings, platform) } -/** - * Resolve a runtime target to the Kimi home Orca should read (UNC path for WSL). - * Async because it runs on every quota poll: the sync `wsl.exe` probes park - * Electron's main process for up to 5s each cycle while a distro is stopped. - */ +/** Use async WSL probes on quota polls to avoid blocking Electron; WSL paths are UNC. */ export async function resolveKimiHome( target: LocalAccountRuntimeTarget, platform: NodeJS.Platform = process.platform @@ -51,7 +42,7 @@ export async function resolveKimiHome( if (!distro) { return { runtime: 'wsl', wslDistro: null, path: null } } - // Why: a quota poll is background work and must not boot a stopped distro. + // Background quota polling must not start stopped distros. if (!(await isWslDistroRunning(distro))) { return { runtime: 'wsl', wslDistro: distro, path: null } } @@ -60,7 +51,6 @@ export async function resolveKimiHome( return { runtime: 'wsl', wslDistro: distro, path: home ? joinKimiHome(home) : null } } -/** Async twin of `getDefaultWslDistro`, which shells out to wsl.exe synchronously. */ async function defaultWslDistro(): Promise { return (await listWslDistrosAsync())[0] ?? null } diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index 5af123a0e09..01b90960ccf 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -8,8 +8,7 @@ import { wrapWindowsBatchHookCommand } from '../agent-hooks/windows-direct-cmd-h import { KiroHookService } from './hook-service' import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings' -// Why both names: os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows; setting -// only one would let a run edit the developer's real ~/.kiro or ~/.orca. +// Isolate HOME and USERPROFILE so tests cannot write real Kiro or Orca configs on any OS. let home: string let originalHome: string | undefined let originalUserProfile: string | undefined @@ -81,7 +80,6 @@ describe('KiroHookService', () => { const service = new KiroHookService() expect(service.install().state).toBe('installed') - // Idempotent: a second install must not duplicate the managed entries. expect(service.install().state).toBe('installed') const caverna = readAgent('caverna') @@ -193,7 +191,6 @@ describe('KiroHookService', () => { expect(result.state).toBe('installed') const remote = JSON.parse(readFileSync(join(kiroHome, 'agents', 'remote.json'), 'utf-8')) expect(Object.keys(remote.hooks).sort()).toEqual([...KIRO_HOOK_EVENTS].sort()) - // The inactive default home is left alone. expect(readAgent('stale').hooks).toBeUndefined() }) @@ -235,7 +232,7 @@ describe('KiroHookService', () => { expect(readAgent('kept').hooks).toBeDefined() }) - // Why: runs on every OS; windows-hook-payload-delivery.test.ts runs each form under Kiro's shells. + // Check command shapes everywhere; windows-hook-payload-delivery.test.ts executes them under Kiro's shells. it('registers the Windows batch hook command for every event', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') try { diff --git a/src/main/kiro/hook-service.ts b/src/main/kiro/hook-service.ts index a0b70e6a8cc..6d0e7fade41 100644 --- a/src/main/kiro/hook-service.ts +++ b/src/main/kiro/hook-service.ts @@ -40,8 +40,7 @@ import { serializeKiroAgentConfig } from './hook-settings' -// Why: hook stdout from agentSpawn/userPromptSubmit is injected into Kiro's model context, -// so every path below must stay silent. +// Kiro injects hook stdout into model context; every path must stay silent. function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -60,8 +59,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { '#!/bin/sh', ...buildPosixHookPayloadCapture(), ...buildPosixHookSpoolLines('kiro'), - // Why: the endpoint file holds the live port/token; a PTY that outlived an Orca - // restart carries stale env, so source it to reach the new server. + // Refresh endpoint credentials for PTYs surviving an Orca restart. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', @@ -163,7 +161,6 @@ function buildStatus(agentsDir: string, files: AgentFileRead[]): AgentHookInstal return status(agentsDir, 'partial', problems.join('; '), managedHooksPresent) } -/** Installs Orca's status hooks into every global Kiro agent config. */ export class KiroHookService { async refreshManagedScripts(): Promise { await refreshManagedScriptIfPresent(getKiroManagedScriptPath(), getManagedScript()) @@ -208,7 +205,7 @@ export class KiroHookService { return this.getStatus() } - /** Install on an SSH execution host, where Kiro runs hooks through sh. */ + /** SSH hosts execute hooks through sh. */ async installRemote( sftp: SFTPWrapper, remoteHome: string, diff --git a/src/main/kiro/hook-settings.ts b/src/main/kiro/hook-settings.ts index da2e8906bd9..1c91b17f667 100644 --- a/src/main/kiro/hook-settings.ts +++ b/src/main/kiro/hook-settings.ts @@ -38,10 +38,7 @@ export function isKiroHooksConfigSupported(config: Record): boo ) } -/** - * Kiro has no global hook config: hooks live in each agent file, and the built-in - * `kiro_default` agent has none to edit. So Orca patches every global agent the user owns. - */ +/** Kiro has no global hook config and no editable built-in agent; patch each user-owned global agent file. */ export function getKiroAgentsDir(): string { return join(resolveKiroHomeDir(), 'agents') } @@ -66,7 +63,7 @@ export function getKiroManagedScriptPath(): string { } export function getKiroManagedCommand(scriptPath: string): string { - // Why: Kiro runs hook commands through PowerShell on Windows and sh elsewhere. + // Kiro uses PowerShell on Windows, sh elsewhere. return process.platform === 'win32' ? wrapWindowsBatchHookCommand(scriptPath) : wrapPosixHookCommand(scriptPath) @@ -105,7 +102,7 @@ function hasManagedDefinition( ) } -/** Returns the agent config with exactly one Orca hook per event, user hooks untouched. */ +/** Preserve user hooks while converging to one Orca hook per event. */ export function applyManagedKiroHooks( config: Record, command: string, diff --git a/src/renderer/src/components/task-page/jira/IssueUserField.test.tsx b/src/renderer/src/components/task-page/jira/IssueUserField.test.tsx index 43acea2f2af..c0752ded60b 100644 --- a/src/renderer/src/components/task-page/jira/IssueUserField.test.tsx +++ b/src/renderer/src/components/task-page/jira/IssueUserField.test.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TaskPageJiraIssueDialog } from './IssueDialog' import { useTaskPageJiraIssueCreation } from '../../use-task-page-jira-issue-creation' import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-actions' -import type { JiraCreateField } from '../../../../../shared/jira-types' +import type { JiraCreateField } from '../../../../../shared/jira/jira-types' import type { TaskSourceContext } from '../../../../../shared/task-source-context' const { search, create, getIssue } = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/task-page/jira/IssueUserField.tsx b/src/renderer/src/components/task-page/jira/IssueUserField.tsx index 0fe284a990e..2b5c7616098 100644 --- a/src/renderer/src/components/task-page/jira/IssueUserField.tsx +++ b/src/renderer/src/components/task-page/jira/IssueUserField.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { JiraUserPicker } from '@/components/jira-user-picker' import type { RuntimeJiraSettings } from '@/runtime/runtime-jira-client' -import type { JiraUser } from '../../../../../shared/jira-types' +import type { JiraUser } from '../../../../../shared/jira/jira-types' export function TaskPageJiraIssueUserField({ providerSettings,