Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/oxlint-anti-slop.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"anti-slop/no-module-mocking": "off",
"anti-slop/no-object-parameters": "off",
"anti-slop/no-reduce-accumulator-copy": "error",
"anti-slop/no-reflect-apply": "off",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "off",
"anti-slop/no-runtime-typeof": "off",
"anti-slop/no-shape-in-symbol-names": "off",
Expand Down
2 changes: 1 addition & 1 deletion config/scripts/main-blocking-probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function installMainBlockingProbe() {
const epoch = Date.now()
let result
try {
result = Reflect.apply(original, this, args)
result = original.call(this, ...args)
return result
} finally {
const durationMs = performance.now() - start
Expand Down
2 changes: 1 addition & 1 deletion config/scripts/persistence-call-probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function installPersistenceCallProbe() {
const epoch = Date.now()
let result
try {
result = Reflect.apply(original, this, args)
result = original.call(this, ...args)
return result
} finally {
const durationMs = performance.now() - start
Expand Down
2 changes: 1 addition & 1 deletion config/scripts/terminal-stream-byte-length-benchmark.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ function runWithNativeCallCount(fn) {
let calls = 0
Buffer.byteLength = (...args) => {
calls += 1
return Reflect.apply(nativeByteLength, Buffer, args)
return nativeByteLength.call(Buffer, ...args)
}
try {
return { output: fn(), calls }
Expand Down
9 changes: 2 additions & 7 deletions src/main/git/command-runner/command-exec-file.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import { isWindowsBatchScript, resolveWindowsCommand } from '../../win32-utils'
import { isMissingCommandBinaryError } from '../exec-error'
import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution'
import { execFileCapture } from './exec-file-capture'
import { spawnCommandCapture, type CommandExecOptions } from './spawn-command-capture'

function isMissingCommandError(error: unknown): boolean {
return Boolean(
error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'
)
}

function hasPathSeparator(command: string): boolean {
return command.includes('/') || command.includes('\\')
}
Expand All @@ -17,7 +12,7 @@ function shouldRetryWindowsCommandShim(error: unknown, resolved: ResolvedCommand
return (
process.platform === 'win32' &&
resolved.wsl === null &&
isMissingCommandError(error) &&
isMissingCommandBinaryError(error) &&
!hasPathSeparator(resolved.binary) &&
!/\.[A-Za-z0-9]+$/.test(resolved.binary)
)
Expand Down
13 changes: 13 additions & 0 deletions src/main/git/exec-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ export function extractExecError(err: unknown): { stderr: string; stdout: string
return { stderr: String(err), stdout: '' }
}

/** Recognizes spawn ENOENT; callers must separately rule out a missing cwd. */
export function isMissingCommandBinaryError(err: unknown): boolean {
return Boolean(
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT' &&
'syscall' in err &&
typeof err.syscall === 'string' &&
err.syscall.startsWith('spawn ')
)
}

/**
* Detect a Retry-After hint in gh stderr and return the suggested delay in ms,
* or null when the response includes no Retry-After.
Expand Down
31 changes: 31 additions & 0 deletions src/main/git/git-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { access } from 'node:fs/promises'
import { isMissingCommandBinaryError } from './exec-error'

type GitVersionExec = (
args: string[],
options: { cwd: string; timeout: number }
) => Promise<unknown>

/**
* Resolves `false` only when the spawn proved Git absent; every other failure rejects so callers
* keep an unknown answer instead of reporting a host with no Git.
*/
export async function probeGitAvailability(
exec: GitVersionExec,
options: { cwd: string; timeout: number }
): Promise<boolean> {
try {
await exec(['--version'], options)
return true
} catch (err) {
if (isMissingCommandBinaryError(err)) {
try {
await access(options.cwd)
return false
} catch {
// Node reports the same spawn ENOENT for a missing binary and a missing cwd.
}
}
throw err
}
}
2 changes: 1 addition & 1 deletion src/main/gitlab/client-mr-auth-rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe('gitlab client — MR operations', () => {
if (this[0] === 'gitlab.com' && this.every((value) => typeof value === 'string')) {
knownHostCacheScans += 1
}
return Reflect.apply(originalMap, this, [callback, thisArg])
return originalMap.call(this, callback, thisArg)
})

try {
Expand Down
82 changes: 82 additions & 0 deletions src/main/ipc/repos/repo-creation-git-availability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* `repos:isGitAvailable` gates the create dialog's Git option. Only a spawn that never started may
* answer `false`; everything else rejects so the renderer's existing `unknown` branch is reachable.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))

vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } }))
vi.mock('../../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
vi.mock('../../repo-icon-autodetect', () => ({
detectRepoIconAndUpstream: vi.fn(async () => ({}))
}))
vi.mock('../../worktree-root-preparation', () => ({
prepareLocalWorktreeRootForRepo: vi.fn(async () => {})
}))
vi.mock('../registered-worktree-roots-cache', () => ({
invalidateAuthorizedRootsCache: vi.fn()
}))
vi.mock('./repo-added-telemetry', () => ({ emitRepoAdded: vi.fn() }))
vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() }))
vi.mock('./local-repo-registration', () => ({ addLocalRepoFromPath: vi.fn() }))
vi.mock('./remote-repo-registration', () => ({ addRemoteRepoFromPath: vi.fn() }))
vi.mock('./remote-repo-creation', () => ({ createRemoteRepo: vi.fn() }))

import { probeLocalGitAvailability } from './repo-creation-handlers'

describe('repos:isGitAvailable', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})

afterEach(() => {
vi.restoreAllMocks()
})

it('answers true when git reports its version', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git version 2.25.1\n', stderr: '' })
await expect(probeLocalGitAvailability()).resolves.toBe(true)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['--version'], {
cwd: process.cwd(),
timeout: 1500
})
})

it('answers false only when the spawn itself found no binary', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' })
)
await expect(probeLocalGitAvailability()).resolves.toBe(false)
})

it('rejects an ENOENT when the working directory disappeared', async () => {
const missingCwd = `${process.cwd()}-missing`
vi.spyOn(process, 'cwd').mockReturnValue(missingCwd)
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' })
)

await expect(probeLocalGitAvailability()).rejects.toThrow('spawn git ENOENT')
})

it('rejects a non-spawn ENOENT rather than reporting no Git', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('open config ENOENT'), { code: 'ENOENT', syscall: 'open' })
)

await expect(probeLocalGitAvailability()).rejects.toThrow('open config ENOENT')
})

it('rejects on the timeout rather than reporting no git', async () => {
gitExecFileAsyncMock.mockRejectedValue(new Error('git --version timed out after 1500ms'))
await expect(probeLocalGitAvailability()).rejects.toThrow('timed out')
})

it('rejects when git runs and fails', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('fatal: detected dubious ownership'), { code: 128 })
)
await expect(probeLocalGitAvailability()).rejects.toThrow('dubious ownership')
})
})
19 changes: 8 additions & 11 deletions src/main/ipc/repos/repo-creation-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceDir } from '../../../share
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { getEffectiveHostSetting } from '../../../shared/host-setting-overrides'
import { probeGitAvailability } from '../../git/git-availability'
import { gitExecFileAsync } from '../../git/runner'
import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect'
import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation'
Expand All @@ -22,16 +23,12 @@ import { createRemoteRepo } from './remote-repo-creation'

const GIT_AVAILABILITY_TIMEOUT_MS = 1500

async function isGitAvailable(): Promise<boolean> {
try {
await gitExecFileAsync(['--version'], {
cwd: process.cwd(),
timeout: GIT_AVAILABILITY_TIMEOUT_MS
})
return true
} catch {
return false
}
// Only ENOENT proves Git absent; rejecting other failures preserves the renderer's unknown state.
export async function probeLocalGitAvailability(): Promise<boolean> {
return probeGitAvailability(gitExecFileAsync, {
cwd: process.cwd(),
timeout: GIT_AVAILABILITY_TIMEOUT_MS
})
}

/**
Expand Down Expand Up @@ -63,7 +60,7 @@ function getDefaultCreateProjectParent(store: Store): string {
}

export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: Store): void {
ipcMain.handle('repos:isGitAvailable', () => isGitAvailable())
ipcMain.handle('repos:isGitAvailable', () => probeLocalGitAvailability())
ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent(store))

ipcMain.handle(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,17 @@ it.each([1, 100, 200])('serializes each of %i unchanged forward page items once'
await appendItems(count, 'x'.repeat(8_000))
const snapshot = journal.snapshot()
const stringify = JSON.stringify
// Method-shaped type: the JSON.stringify overloads split on replacer shape and reject a forwarded one.
const forwardStringify: {
stringify(value: unknown, replacer?: unknown, space?: unknown): string
}['stringify'] = stringify
let itemSerializations = 0
JSON.stringify = ((value: unknown, ...args: unknown[]) => {
JSON.stringify = (value: unknown, replacer?: unknown, space?: unknown): string => {
if (value && typeof value === 'object' && 'itemId' in value && 'body' in value) {
itemSerializations++
}
return Reflect.apply(stringify, JSON, [value, ...args])
}) as typeof JSON.stringify
return forwardStringify(value, replacer, space)
}
try {
const result = readAgentSessionHistory(
journal,
Expand Down
Loading