mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(windows): show first window before shell PATH hydration (#13799)
(cherry picked from commit e790266546)
This commit is contained in:
@@ -179,8 +179,8 @@ describe('commandExecFileAsync Windows command shims', () => {
|
||||
describe('runner execFile timeout handling', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
execFileSyncMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
execFileSyncMock.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, spawnMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: vi.fn(),
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
import {
|
||||
awaitWindowsHostGitEnvironmentReady,
|
||||
configureWindowsHostGitEnvironmentReadiness,
|
||||
gitExecFileAsync,
|
||||
gitExecFileAsyncBuffer,
|
||||
gitSpawnAfterWindowsEnvironmentReady,
|
||||
gitStreamStdout
|
||||
} from './runner'
|
||||
|
||||
type MockChildProcess = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
pid: number
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createMockChildProcess(pid: number): MockChildProcess {
|
||||
const child = new EventEmitter() as MockChildProcess
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.pid = pid
|
||||
child.kill = vi.fn()
|
||||
return child
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: original })
|
||||
}
|
||||
}
|
||||
|
||||
describe('Windows host Git environment readiness', () => {
|
||||
const originalPath = process.env.Path
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
configureWindowsHostGitEnvironmentReadiness(null)
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.Path
|
||||
} else {
|
||||
process.env.Path = originalPath
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for each native operation and refreshes a snapshotted PATH', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const waitUntilReady = vi.fn(() => ready.promise)
|
||||
const child = createMockChildProcess(1234)
|
||||
execFileMock.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, 'ok', '')
|
||||
return child
|
||||
})
|
||||
configureWindowsHostGitEnvironmentReadiness(waitUntilReady)
|
||||
process.env.Path = 'hydrating-path'
|
||||
|
||||
const firstExec = gitExecFileAsync(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
env: { Path: 'stale-path' }
|
||||
})
|
||||
|
||||
expect(waitUntilReady).toHaveBeenCalledOnce()
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
process.env.Path = 'hydrated-path'
|
||||
ready.resolve()
|
||||
await expect(firstExec).resolves.toEqual({ stdout: 'ok', stderr: '' })
|
||||
expect(execFileMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-path')
|
||||
|
||||
await expect(gitExecFileAsync(['status'], { cwd: String.raw`C:\repo` })).resolves.toEqual({
|
||||
stdout: 'ok',
|
||||
stderr: ''
|
||||
})
|
||||
expect(waitUntilReady).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('waits before native spawned Git and refreshes a snapshotted PATH', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const waitUntilReady = vi.fn(() => ready.promise)
|
||||
const child = createMockChildProcess(1234)
|
||||
spawnMock.mockReturnValue(child)
|
||||
configureWindowsHostGitEnvironmentReadiness(waitUntilReady)
|
||||
process.env.Path = 'hydrating-path'
|
||||
|
||||
const spawned = gitSpawnAfterWindowsEnvironmentReady(['clone', 'url', 'repo'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
env: { Path: 'stale-path' },
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
|
||||
expect(waitUntilReady).toHaveBeenCalledOnce()
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
process.env.Path = 'hydrated-path'
|
||||
ready.resolve()
|
||||
|
||||
await expect(spawned).resolves.toBe(child)
|
||||
expect(spawnMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-path')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate spawned Git routed through WSL on the Windows host profile', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const waitUntilReady = vi.fn(() => new Promise<void>(() => {}))
|
||||
const child = createMockChildProcess(1234)
|
||||
spawnMock.mockReturnValue(child)
|
||||
configureWindowsHostGitEnvironmentReadiness(waitUntilReady)
|
||||
|
||||
await expect(
|
||||
gitSpawnAfterWindowsEnvironmentReady(['clone', 'url', 'repo'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
wslDistro: 'Ubuntu',
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
).resolves.toBe(child)
|
||||
|
||||
expect(waitUntilReady).not.toHaveBeenCalled()
|
||||
expect(spawnMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not spawn Git when canceled during Windows environment readiness', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const controller = new AbortController()
|
||||
configureWindowsHostGitEnvironmentReadiness(() => ready.promise)
|
||||
|
||||
const spawned = gitSpawnAfterWindowsEnvironmentReady(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
signal: controller.signal,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
controller.abort()
|
||||
|
||||
await expect(spawned).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
ready.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels async Git immediately while Windows environment readiness is pending', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const controller = new AbortController()
|
||||
configureWindowsHostGitEnvironmentReadiness(() => ready.promise)
|
||||
|
||||
const operation = gitExecFileAsync(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
signal: controller.signal
|
||||
})
|
||||
controller.abort()
|
||||
|
||||
await expect(operation).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
ready.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate WSL paths at a synchronous Git consumer boundary', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const waitUntilReady = vi.fn(() => new Promise<void>(() => {}))
|
||||
configureWindowsHostGitEnvironmentReadiness(waitUntilReady)
|
||||
|
||||
await expect(
|
||||
awaitWindowsHostGitEnvironmentReady({
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\me\repo`
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(waitUntilReady).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not gate Git routed through WSL on the Windows host profile', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const waitUntilReady = vi.fn(() => new Promise<void>(() => {}))
|
||||
const child = createMockChildProcess(1234)
|
||||
execFileMock.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, 'ok', '')
|
||||
return child
|
||||
})
|
||||
configureWindowsHostGitEnvironmentReadiness(waitUntilReady)
|
||||
|
||||
await expect(
|
||||
gitExecFileAsync(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
).resolves.toEqual({ stdout: 'ok', stderr: '' })
|
||||
|
||||
expect(waitUntilReady).not.toHaveBeenCalled()
|
||||
expect(execFileMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('gates buffered native Git reads on the current generation', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const child = createMockChildProcess(1234)
|
||||
execFileMock.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, Buffer.from('blob'), Buffer.alloc(0))
|
||||
return child
|
||||
})
|
||||
configureWindowsHostGitEnvironmentReadiness(() => ready.promise)
|
||||
|
||||
const read = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: String.raw`C:\repo` })
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
ready.resolve()
|
||||
|
||||
await expect(read).resolves.toEqual({ stdout: Buffer.from('blob') })
|
||||
})
|
||||
})
|
||||
|
||||
it('gates native Git streams and refreshes their snapshotted PATH', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const ready = deferred()
|
||||
const child = createMockChildProcess(1234)
|
||||
spawnMock.mockReturnValue(child)
|
||||
configureWindowsHostGitEnvironmentReadiness(() => ready.promise)
|
||||
process.env.Path = 'hydrated-stream-path'
|
||||
|
||||
const stream = gitStreamStdout(['status'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
env: { Path: 'stale-stream-path' },
|
||||
onStdout: () => {}
|
||||
})
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
ready.resolve()
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
expect(spawnMock.mock.calls[0]?.[2]?.env?.Path).toBe('hydrated-stream-path')
|
||||
child.emit('close', 0)
|
||||
|
||||
await expect(stream).resolves.toEqual({ stoppedEarly: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
+139
-10
@@ -182,12 +182,100 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
|
||||
}
|
||||
|
||||
let defaultWslDistroOverride: string | null = null
|
||||
let waitForWindowsHostGitEnvironment: (() => Promise<void>) | 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
|
||||
}
|
||||
|
||||
export function configureWindowsHostGitEnvironmentReadiness(
|
||||
waitUntilReady: (() => Promise<void>) | null
|
||||
): void {
|
||||
waitForWindowsHostGitEnvironment = waitUntilReady
|
||||
}
|
||||
|
||||
export async function awaitWindowsHostGitEnvironmentReady(options: {
|
||||
cwd: string
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<void> {
|
||||
const resolved = resolveGitCommand(['--version'], options)
|
||||
await prepareWindowsHostGitEnvironment(resolved, undefined, options.signal)
|
||||
}
|
||||
|
||||
function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined {
|
||||
if (!env) {
|
||||
return undefined
|
||||
}
|
||||
const currentPath = process.env.Path ?? process.env.PATH
|
||||
if (currentPath === undefined) {
|
||||
return env
|
||||
}
|
||||
const next = { ...env }
|
||||
const pathKeys = Object.keys(next).filter((key) => key.toLowerCase() === 'path')
|
||||
if (pathKeys.length === 0) {
|
||||
next[process.env.Path === undefined ? 'PATH' : 'Path'] = currentPath
|
||||
} else {
|
||||
for (const key of pathKeys) {
|
||||
next[key] = currentPath
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function prepareWindowsHostGitEnvironment(
|
||||
resolved: ResolvedCommand,
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<NodeJS.ProcessEnv | undefined> | null {
|
||||
if (
|
||||
process.platform !== 'win32' ||
|
||||
resolved.wsl !== null ||
|
||||
waitForWindowsHostGitEnvironment === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const ready = waitForWindowsHostGitEnvironment().then(() => refreshWindowsHostPath(env))
|
||||
if (!signal) {
|
||||
return ready
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(createAbortError())
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const cleanup = (): void => signal.removeEventListener('abort', onAbort)
|
||||
const onAbort = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(createAbortError())
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
ready.then(
|
||||
(value) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null {
|
||||
const distro = defaultWslDistroOverride ?? getDefaultWslDistro()
|
||||
return distro ? resolveCommand(command, args, undefined, distro) : null
|
||||
@@ -972,10 +1060,18 @@ export async function gitExecFileAsync(
|
||||
signal: options.signal
|
||||
})
|
||||
}
|
||||
const resolved = resolveGitCommand(args, options)
|
||||
const policy = options.useConfiguredSshCommandForNetwork
|
||||
? await buildNetworkSshPolicyEnv(options)
|
||||
: { env: nonInteractiveGitEnv(options.env), mode: 'default' as const }
|
||||
let resolved = resolveGitCommand(args, options)
|
||||
const environmentReady = prepareWindowsHostGitEnvironment(
|
||||
resolved,
|
||||
options.env,
|
||||
options.signal
|
||||
)
|
||||
const env = environmentReady ? await environmentReady : options.env
|
||||
const effectiveOptions = env === options.env ? options : { ...options, env }
|
||||
resolved = resolveGitCommand(args, effectiveOptions)
|
||||
const policy = effectiveOptions.useConfiguredSshCommandForNetwork
|
||||
? await buildNetworkSshPolicyEnv(effectiveOptions)
|
||||
: { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const }
|
||||
const capture = (
|
||||
command: ResolvedCommand
|
||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> =>
|
||||
@@ -994,7 +1090,7 @@ export async function gitExecFileAsync(
|
||||
} catch (error) {
|
||||
if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) {
|
||||
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
|
||||
result = await capture(resolveGitCommand(args, options, true))
|
||||
result = await capture(resolveGitCommand(args, effectiveOptions, true))
|
||||
// Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient.
|
||||
disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved)
|
||||
const { stdout, stderr } = result
|
||||
@@ -1066,7 +1162,12 @@ export async function gitExecFileAsyncBuffer(
|
||||
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
|
||||
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro)
|
||||
}
|
||||
const resolved = resolveGitCommand(args, options, true)
|
||||
let resolved = resolveGitCommand(args, options, true)
|
||||
const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined)
|
||||
if (environmentReady) {
|
||||
await environmentReady
|
||||
}
|
||||
resolved = resolveGitCommand(args, options, true)
|
||||
const { stdout } = (await execFileCapture(resolved.binary, resolved.args, {
|
||||
cwd: resolved.cwd,
|
||||
encoding: 'buffer',
|
||||
@@ -1120,6 +1221,15 @@ export async function gitStreamStdout(
|
||||
...(options.signal ? { signal: options.signal } : {})
|
||||
}
|
||||
let resolved = resolveGitCommand(args, gitOptions)
|
||||
const environmentReady = prepareWindowsHostGitEnvironment(
|
||||
resolved,
|
||||
gitOptions.env,
|
||||
options.signal
|
||||
)
|
||||
if (environmentReady) {
|
||||
gitOptions.env = await environmentReady
|
||||
}
|
||||
resolved = resolveGitCommand(args, gitOptions)
|
||||
const stream = (command: ResolvedCommand): Promise<GitStreamResult> =>
|
||||
new Promise<GitStreamResult>((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
@@ -1129,7 +1239,7 @@ export async function gitStreamStdout(
|
||||
const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe']
|
||||
const spawnOptions = {
|
||||
cwd: options.cwd,
|
||||
env: nonInteractiveGitEnv(options.env),
|
||||
env: nonInteractiveGitEnv(gitOptions.env),
|
||||
stdio,
|
||||
wslDistro: options.wslDistro,
|
||||
windowsHide: true
|
||||
@@ -1303,10 +1413,29 @@ export function gitExecFileSync(
|
||||
* Spawn a git child process. Drop-in replacement for
|
||||
* `spawn('git', args, { cwd, stdio, ... })`.
|
||||
*/
|
||||
export function gitSpawn(
|
||||
type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string }
|
||||
|
||||
export async function gitSpawnAfterWindowsEnvironmentReady(
|
||||
args: string[],
|
||||
options: SpawnOptions & { cwd: string; wslDistro?: string }
|
||||
): ChildProcess {
|
||||
options: GitSpawnOptions
|
||||
): Promise<ChildProcess> {
|
||||
if (options.signal?.aborted) {
|
||||
throw createAbortError()
|
||||
}
|
||||
const resolved = resolveGitCommand(args, {
|
||||
cwd: options.cwd,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
...(options.env ? { env: options.env } : {})
|
||||
})
|
||||
const env = await (prepareWindowsHostGitEnvironment(resolved, options.env, options.signal) ??
|
||||
options.env)
|
||||
if (options.signal?.aborted) {
|
||||
throw createAbortError()
|
||||
}
|
||||
return gitSpawn(args, env === options.env ? options : { ...options, env })
|
||||
}
|
||||
|
||||
export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess {
|
||||
const { wslDistro, ...spawnOptions } = options
|
||||
const resolved = resolveGitCommand(args, {
|
||||
cwd: options.cwd,
|
||||
|
||||
+56
-20
@@ -163,6 +163,10 @@ import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reco
|
||||
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
|
||||
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
|
||||
import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration'
|
||||
import {
|
||||
startWindowsDesktopBeforeShellPathReady,
|
||||
type WindowsDesktopStartupServices
|
||||
} from './startup/windows-desktop-shell-path-startup'
|
||||
import {
|
||||
acquireSingleInstanceLock,
|
||||
logSingleInstanceLockBypass,
|
||||
@@ -257,7 +261,10 @@ import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branc
|
||||
import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output'
|
||||
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
|
||||
import { moveWorktree } from './git/worktree'
|
||||
import { setDefaultWslDistroOverride } from './git/runner'
|
||||
import {
|
||||
configureWindowsHostGitEnvironmentReadiness,
|
||||
setDefaultWslDistroOverride
|
||||
} from './git/runner'
|
||||
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../shared/workspace-scope'
|
||||
import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state'
|
||||
@@ -923,7 +930,7 @@ async function reapRestoredSubagentsWithoutLiveAgent(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function startTerminalRuntimeStartupServices(): Promise<void> {
|
||||
function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices {
|
||||
logStartupMilestone('first-window-startup-services-start')
|
||||
const startupServices = startFirstWindowStartupServices({
|
||||
// Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer.
|
||||
@@ -980,19 +987,24 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
|
||||
console.error('[agent-hooks] Failed to start local hook server:', error)
|
||||
}
|
||||
})
|
||||
firstWindowStartupServicesReady = startupServices.firstWindowReady
|
||||
localPtyStartupReady = startupServices.localPtyReady
|
||||
localPtyProviderStartupReady = startupServices.localPtyProviderReady
|
||||
void firstWindowStartupServicesReady.then(() => {
|
||||
void startupServices.firstWindowReady.then(() => {
|
||||
logStartupMilestone('first-window-startup-services-ready')
|
||||
})
|
||||
void localPtyStartupReady.then(() => {
|
||||
void startupServices.localPtyReady.then(() => {
|
||||
logStartupMilestone('local-pty-startup-ready')
|
||||
void reapRestoredSubagentsWithoutLiveAgent().catch((error) => {
|
||||
console.warn('[agent-hooks] restored-subagent liveness probe failed:', error)
|
||||
})
|
||||
})
|
||||
return firstWindowStartupServicesReady
|
||||
return startupServices
|
||||
}
|
||||
|
||||
function bindTerminalRuntimeStartupServices(
|
||||
services: Promise<WindowsDesktopStartupServices>
|
||||
): void {
|
||||
firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)
|
||||
localPtyStartupReady = services.then((value) => value.localPtyReady)
|
||||
localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady)
|
||||
}
|
||||
|
||||
function prepareCodexRuntimeHomeForLaunch(
|
||||
@@ -1248,7 +1260,7 @@ function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null {
|
||||
return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null
|
||||
}
|
||||
|
||||
function openMainWindow(): BrowserWindow {
|
||||
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
|
||||
logStartupMilestone('open-main-window-start')
|
||||
if (!store) {
|
||||
throw new Error('Store must be initialized before opening the main window')
|
||||
@@ -1337,6 +1349,7 @@ function openMainWindow(): BrowserWindow {
|
||||
void presentRendererRecoveryPrompt(recentRecoveryCount)
|
||||
},
|
||||
deferLoad: true,
|
||||
...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}),
|
||||
title: devInstanceIdentity.name,
|
||||
getKeybindings: () => keybindings?.getOverrides(),
|
||||
onBeforeReload: ({ ignoreCache, webContentsId }) => {
|
||||
@@ -1376,6 +1389,9 @@ function openMainWindow(): BrowserWindow {
|
||||
logStartupMilestone('ready-to-show')
|
||||
setImmediate(createSystemTrayDeferred)
|
||||
})
|
||||
window.once('show', () => {
|
||||
logStartupMilestone('window-shown')
|
||||
})
|
||||
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
|
||||
trayCreateFallback.unref?.()
|
||||
|
||||
@@ -2153,6 +2169,9 @@ void app.whenReady().then(async () => {
|
||||
const activeOrcaProfile = ensureActiveOrcaProfile()
|
||||
store = new Store({ dataFile: activeOrcaProfile.dataFile })
|
||||
const windowsShellPathHydration = createWindowsShellPathHydration()
|
||||
configureWindowsHostGitEnvironmentReadiness(
|
||||
process.platform === 'win32' ? windowsShellPathHydration.whenReady : null
|
||||
)
|
||||
if (process.platform === 'win32') {
|
||||
const settings = store.getSettings()
|
||||
if (app.isPackaged) {
|
||||
@@ -2980,9 +2999,20 @@ void app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: Git hooks inherit process.env, so Source Control must not open before profile PATH settles.
|
||||
await windowsShellPathHydration.whenReady()
|
||||
startTerminalRuntimeStartupServices()
|
||||
const shellPathReady = windowsShellPathHydration.whenReady()
|
||||
let desktopWindow: BrowserWindow | null = null
|
||||
if (process.platform === 'win32' && app.isPackaged && !serveOptions) {
|
||||
const desktopStartup = startWindowsDesktopBeforeShellPathReady({
|
||||
openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }),
|
||||
shellPathReady,
|
||||
startServices: startTerminalRuntimeStartupServices
|
||||
})
|
||||
desktopWindow = desktopStartup.window
|
||||
bindTerminalRuntimeStartupServices(desktopStartup.services)
|
||||
} else {
|
||||
await shellPathReady
|
||||
bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))
|
||||
}
|
||||
app.on('activate', handleMacAppActivation)
|
||||
|
||||
if (serveOptions) {
|
||||
@@ -3066,15 +3096,21 @@ void app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
// Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap.
|
||||
const desktopRuntimeRpc = runtimeRpc
|
||||
if (!desktopRuntimeRpc) {
|
||||
throw new Error('runtime_rpc_unavailable')
|
||||
}
|
||||
const [win, runtimeRpcStartResult] = await Promise.all([
|
||||
Promise.resolve(openMainWindow()),
|
||||
runtimeRpc.start().then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: unknown) => {
|
||||
recordRuntimeRpcStartFailure(error)
|
||||
return { ok: false as const, error }
|
||||
}
|
||||
)
|
||||
Promise.resolve(desktopWindow ?? openMainWindow()),
|
||||
shellPathReady
|
||||
.then(() => desktopRuntimeRpc.start())
|
||||
.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: unknown) => {
|
||||
recordRuntimeRpcStartFailure(error)
|
||||
return { ok: false as const, error }
|
||||
}
|
||||
)
|
||||
])
|
||||
if (!runtimeRpcStartResult.ok) {
|
||||
void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error)
|
||||
|
||||
@@ -10,7 +10,7 @@ const { gitSpawnMock } = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitSpawn: gitSpawnMock
|
||||
gitSpawnAfterWindowsEnvironmentReady: gitSpawnMock
|
||||
}))
|
||||
|
||||
import { listFilesWithGit } from './filesystem-list-files-git-fallback'
|
||||
@@ -56,11 +56,13 @@ describe('main Quick Open git directory expansion', () => {
|
||||
const primary = createMockProcess()
|
||||
const ignored = createMockProcess()
|
||||
gitSpawnMock
|
||||
.mockReturnValueOnce(revParse)
|
||||
.mockReturnValueOnce(primary)
|
||||
.mockReturnValueOnce(ignored)
|
||||
.mockResolvedValueOnce(revParse)
|
||||
.mockResolvedValueOnce(primary)
|
||||
.mockResolvedValueOnce(ignored)
|
||||
|
||||
const promise = listFilesWithGit(root, [], {})
|
||||
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0))
|
||||
revParse.emit('close', 0, null)
|
||||
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3))
|
||||
;(primary.stdout as unknown as EventEmitter).emit(
|
||||
@@ -86,12 +88,14 @@ describe('main Quick Open git directory expansion', () => {
|
||||
const primary = createMockProcess()
|
||||
const ignored = createMockProcess()
|
||||
gitSpawnMock
|
||||
.mockReturnValueOnce(revParse)
|
||||
.mockReturnValueOnce(primary)
|
||||
.mockReturnValueOnce(ignored)
|
||||
.mockResolvedValueOnce(revParse)
|
||||
.mockResolvedValueOnce(primary)
|
||||
.mockResolvedValueOnce(ignored)
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = listFilesWithGit(root, [], {}, controller.signal)
|
||||
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1))
|
||||
await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0))
|
||||
revParse.emit('close', 0, null)
|
||||
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3))
|
||||
controller.abort()
|
||||
@@ -100,4 +104,92 @@ describe('main Quick Open git directory expansion', () => {
|
||||
expect(primary.kill).toHaveBeenCalled()
|
||||
expect(ignored.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills a repository probe returned after cancellation', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-main-git-probe-race-'))
|
||||
tempDirs.push(root)
|
||||
const revParse = createMockProcess()
|
||||
let resolveRevParse!: (child: ChildProcess) => void
|
||||
gitSpawnMock.mockReturnValueOnce(
|
||||
new Promise<ChildProcess>((resolve) => {
|
||||
resolveRevParse = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = listFilesWithGit(root, [], {}, controller.signal)
|
||||
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledOnce())
|
||||
controller.abort()
|
||||
resolveRevParse(revParse)
|
||||
|
||||
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(revParse.kill).toHaveBeenCalled()
|
||||
expect(revParse.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('kills file scans returned after cancellation', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-main-git-scan-race-'))
|
||||
tempDirs.push(root)
|
||||
const revParse = createMockProcess()
|
||||
const primary = createMockProcess()
|
||||
const ignored = createMockProcess()
|
||||
let resolvePrimary!: (child: ChildProcess) => void
|
||||
let resolveIgnored!: (child: ChildProcess) => void
|
||||
gitSpawnMock
|
||||
.mockResolvedValueOnce(revParse)
|
||||
.mockReturnValueOnce(
|
||||
new Promise<ChildProcess>((resolve) => {
|
||||
resolvePrimary = resolve
|
||||
})
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
new Promise<ChildProcess>((resolve) => {
|
||||
resolveIgnored = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = listFilesWithGit(root, [], {}, controller.signal)
|
||||
await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0))
|
||||
revParse.emit('close', 0, null)
|
||||
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3))
|
||||
controller.abort()
|
||||
resolvePrimary(primary)
|
||||
resolveIgnored(ignored)
|
||||
|
||||
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
|
||||
expect(primary.kill).toHaveBeenCalled()
|
||||
expect(ignored.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a pending sibling spawn when the primary scan fails', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-main-git-sibling-failure-'))
|
||||
tempDirs.push(root)
|
||||
const revParse = createMockProcess()
|
||||
const primary = createMockProcess()
|
||||
let pendingSignal: AbortSignal | undefined
|
||||
gitSpawnMock
|
||||
.mockResolvedValueOnce(revParse)
|
||||
.mockResolvedValueOnce(primary)
|
||||
.mockImplementationOnce(
|
||||
(_args: string[], options: { signal?: AbortSignal }) =>
|
||||
new Promise<ChildProcess>((_resolve, reject) => {
|
||||
pendingSignal = options.signal
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const promise = listFilesWithGit(root, [], {})
|
||||
await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0))
|
||||
revParse.emit('close', 0, null)
|
||||
await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3))
|
||||
primary.emit('error', new Error('primary failed'))
|
||||
|
||||
await expect(promise).rejects.toThrow('primary failed')
|
||||
expect(pendingSignal?.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { gitSpawn } from '../git/runner'
|
||||
import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner'
|
||||
import {
|
||||
isWslLinkedWorktreeGitRoutingCandidate,
|
||||
prepareWslLinkedWorktreeGitRouting
|
||||
@@ -45,12 +45,13 @@ async function isInsideGitWorkTree(
|
||||
if (signal?.aborted) {
|
||||
throw fileListingCancellationError(signal)
|
||||
}
|
||||
const child = await gitSpawnAfterWindowsEnvironmentReady(['rev-parse', '--is-inside-work-tree'], {
|
||||
cwd: rootPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = gitSpawn(['rev-parse', '--is-inside-work-tree'], {
|
||||
cwd: rootPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
})
|
||||
let done = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const cleanup = (): void => {
|
||||
@@ -88,6 +89,9 @@ async function isInsideGitWorkTree(
|
||||
child.kill()
|
||||
finish(false)
|
||||
}, 10_000)
|
||||
if (signal?.aborted) {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,8 +125,17 @@ export async function listFilesWithGit(
|
||||
reject: (error: Error) => void
|
||||
resolve: () => void
|
||||
}[] = []
|
||||
const scanController = new AbortController()
|
||||
|
||||
const runGitLsFiles = (args: string[]): Promise<void> => {
|
||||
const runGitLsFiles = async (args: string[]): Promise<void> => {
|
||||
// Why: git ls-files outputs paths relative to cwd, so we set cwd to
|
||||
// rootPath and use the output directly — no prefix stripping needed.
|
||||
const child = await gitSpawnAfterWindowsEnvironmentReady(['ls-files', ...args], {
|
||||
cwd: rootPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
||||
signal: scanController.signal,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = ''
|
||||
let done = false
|
||||
@@ -155,13 +168,6 @@ export async function listFilesWithGit(
|
||||
return maxResults !== undefined && directFileCandidates.size >= maxResults
|
||||
}
|
||||
|
||||
// Why: git ls-files outputs paths relative to cwd, so we set cwd to
|
||||
// rootPath and use the output directly — no prefix stripping needed.
|
||||
const child = gitSpawn(['ls-files', ...args], {
|
||||
cwd: rootPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
@@ -246,6 +252,10 @@ export async function listFilesWithGit(
|
||||
child.kill()
|
||||
rejectPass(new Error('git ls-files timed out'))
|
||||
}, 10000)
|
||||
if (scanController.signal.aborted) {
|
||||
child.kill()
|
||||
rejectPass(fileListingCancellationError(scanController.signal))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -275,14 +285,17 @@ export async function listFilesWithGit(
|
||||
}
|
||||
}
|
||||
|
||||
const onAbort = (): void => killSurvivors('git ls-files cancelled')
|
||||
const onAbort = (): void => {
|
||||
scanController.abort(signal?.reason)
|
||||
killSurvivors('git ls-files cancelled')
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
const runIgnoredPass = () =>
|
||||
// Why: ignored files are supplementary — a failed or timed-out ignored
|
||||
// pass must not discard the primary listing the user actually needs.
|
||||
runGitLsFiles(ignoredPass).catch((err: Error) => {
|
||||
if (!signal?.aborted) {
|
||||
if (!scanController.signal.aborted) {
|
||||
console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err)
|
||||
}
|
||||
})
|
||||
@@ -297,6 +310,7 @@ export async function listFilesWithGit(
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
scanController.abort(err)
|
||||
killSurvivors()
|
||||
if (signal?.aborted) {
|
||||
throw fileListingCancellationError(signal)
|
||||
|
||||
@@ -184,6 +184,9 @@ describe('filesystem-search-git', () => {
|
||||
|
||||
const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect((proc.stdout as unknown as EventEmitter).listenerCount('data')).toBeGreaterThan(0)
|
||||
)
|
||||
;(proc.stdout as unknown as EventEmitter).emit('data', 'valid.ts\x001\x00ok\npartial')
|
||||
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ingestGitGrepLine,
|
||||
SEARCH_TIMEOUT_MS
|
||||
} from '../../shared/text-search'
|
||||
import { gitSpawn } from '../git/runner'
|
||||
import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner'
|
||||
import {
|
||||
isWslLinkedWorktreeGitRoutingCandidate,
|
||||
prepareWslLinkedWorktreeGitRouting
|
||||
@@ -29,18 +29,18 @@ export async function searchWithGitGrep(
|
||||
if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) {
|
||||
await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro)
|
||||
}
|
||||
const gitArgs = buildGitGrepArgs(args.query, args)
|
||||
const child = await gitSpawnAfterWindowsEnvironmentReady(gitArgs, {
|
||||
cwd: rootPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
return new Promise((resolve) => {
|
||||
const gitArgs = buildGitGrepArgs(args.query, args)
|
||||
const matchRegex = buildSubmatchRegex(args.query, args)
|
||||
const acc = createAccumulator()
|
||||
let stdoutBuffer = ''
|
||||
let done = false
|
||||
|
||||
const child = gitSpawn(gitArgs, {
|
||||
cwd: rootPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let killTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
function resolveOnce(): void {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { FileHandle } from 'node:fs/promises'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
|
||||
import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, wslAwareSpawn } from '../git/runner'
|
||||
import { parseWslPath, toWindowsWslPath } from '../wsl'
|
||||
import { tryDeleteWslUncPath } from '../wsl-unc-delete'
|
||||
import type { Store } from '../persistence'
|
||||
@@ -2322,6 +2322,7 @@ export function registerFilesystemHandlers(
|
||||
return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line)
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath })
|
||||
return getRemoteFileUrl(worktreePath, args.relativePath, args.line)
|
||||
}
|
||||
)
|
||||
@@ -2342,6 +2343,7 @@ export function registerFilesystemHandlers(
|
||||
return provider.getRemoteCommitUrl(args.worktreePath, sha)
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath })
|
||||
return getRemoteCommitUrl(worktreePath, sha)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
mockFilesystemProvider,
|
||||
mockMultiplexer,
|
||||
gitExecFileAsyncMock,
|
||||
gitSpawnAfterWindowsEnvironmentReadyMock,
|
||||
gitSpawnMock,
|
||||
listWorktreeGraphMock,
|
||||
invalidateAuthorizedRootsCacheMock,
|
||||
@@ -69,6 +70,7 @@ const {
|
||||
notify: vi.fn()
|
||||
},
|
||||
gitSpawnMock: vi.fn(),
|
||||
gitSpawnAfterWindowsEnvironmentReadyMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
listWorktreeGraphMock: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheMock: vi.fn(),
|
||||
@@ -104,7 +106,8 @@ vi.mock('../git/runner', async () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitExecFileAsyncBuffer: vi.fn(),
|
||||
gitStreamStdout: vi.fn(),
|
||||
gitSpawn: gitSpawnMock
|
||||
gitSpawn: gitSpawnMock,
|
||||
gitSpawnAfterWindowsEnvironmentReady: gitSpawnAfterWindowsEnvironmentReadyMock
|
||||
}))
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
@@ -1149,7 +1152,7 @@ describe('repos:addRemote', () => {
|
||||
gitSpawnMock.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => proc.emit('close', 0, null))
|
||||
setImmediate(() => proc.emit('close', 0, null))
|
||||
return proc
|
||||
})
|
||||
mockWindow.webContents.send.mockReset()
|
||||
@@ -1849,11 +1852,14 @@ describe('repos:add + repos:clone', () => {
|
||||
mockStore.updateProjectHostSetup.mockReset()
|
||||
mockWindow.webContents.send.mockReset()
|
||||
gitSpawnMock.mockReset()
|
||||
gitSpawnAfterWindowsEnvironmentReadyMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (...args) => gitSpawnMock(...args))
|
||||
invalidateAuthorizedRootsCacheMock.mockReset()
|
||||
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
|
||||
gitSpawnMock.mockImplementation(() => {
|
||||
const proc = createMockCloneProcess()
|
||||
queueMicrotask(() => proc.emit('close', 0, null))
|
||||
setImmediate(() => proc.emit('close', 0, null))
|
||||
return proc
|
||||
})
|
||||
|
||||
@@ -2243,7 +2249,7 @@ describe('repos:add + repos:clone', () => {
|
||||
)
|
||||
gitSpawnMock.mockImplementationOnce(() => {
|
||||
const proc = createMockCloneProcess()
|
||||
queueMicrotask(() => {
|
||||
setImmediate(() => {
|
||||
cloned = true
|
||||
proc.emit('close', 0, null)
|
||||
})
|
||||
@@ -2390,6 +2396,84 @@ describe('repos:add + repos:clone', () => {
|
||||
await expect(handlers.get('repos:cloneAbort')!(null, undefined)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('cancels a local clone before Git starts when environment readiness is pending', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
gitSpawnAfterWindowsEnvironmentReadyMock.mockImplementation(
|
||||
(_args: string[], options: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }))
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
abort()
|
||||
} else {
|
||||
options.signal?.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const clonePromise = Promise.resolve(
|
||||
handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/orca.git',
|
||||
destination
|
||||
})
|
||||
)
|
||||
const rejection = expect(clonePromise).rejects.toThrow('Clone failed')
|
||||
await waitForAssertion(() =>
|
||||
expect(gitSpawnAfterWindowsEnvironmentReadyMock).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(gitSpawnMock).not.toHaveBeenCalled()
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
await rejection
|
||||
expect(gitSpawnMock).not.toHaveBeenCalled()
|
||||
expect(existsSync(clonePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels every concurrent local clone waiting for environment readiness', async () => {
|
||||
const firstDestination = await createTempRoot()
|
||||
const secondDestination = await createTempRoot()
|
||||
gitSpawnAfterWindowsEnvironmentReadyMock.mockImplementation(
|
||||
(_args: string[], options: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }))
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
abort()
|
||||
} else {
|
||||
options.signal?.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const firstClone = Promise.resolve(
|
||||
handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/first.git',
|
||||
destination: firstDestination
|
||||
})
|
||||
)
|
||||
const secondClone = Promise.resolve(
|
||||
handlers.get('repos:clone')!(null, {
|
||||
url: 'https://example.com/second.git',
|
||||
destination: secondDestination
|
||||
})
|
||||
)
|
||||
const firstRejection = expect(firstClone).rejects.toThrow('Clone failed')
|
||||
const secondRejection = expect(secondClone).rejects.toThrow('Clone failed')
|
||||
await waitForAssertion(() =>
|
||||
expect(gitSpawnAfterWindowsEnvironmentReadyMock).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
|
||||
await handlers.get('repos:cloneAbort')!(null, undefined)
|
||||
|
||||
await Promise.all([firstRejection, secondRejection])
|
||||
expect(gitSpawnMock).not.toHaveBeenCalled()
|
||||
expect(existsSync(join(firstDestination, 'first'))).toBe(false)
|
||||
expect(existsSync(join(secondDestination, 'second'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not remove an existing target directory when aborting a pending clone', async () => {
|
||||
const destination = await createTempRoot()
|
||||
const clonePath = join(destination, 'orca')
|
||||
|
||||
+45
-16
@@ -44,7 +44,12 @@ import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-
|
||||
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { access, mkdir, readdir, rm } from 'node:fs/promises'
|
||||
import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner'
|
||||
import {
|
||||
awaitWindowsHostGitEnvironmentReady,
|
||||
gitExecFileAsync,
|
||||
gitSpawnAfterWindowsEnvironmentReady,
|
||||
nonInteractiveGitEnv
|
||||
} from '../git/runner'
|
||||
import { isAbsolute, join, posix } from 'node:path'
|
||||
import {
|
||||
cleanupClaimedCloneTarget,
|
||||
@@ -283,6 +288,9 @@ async function addLocalRepoFromPath(
|
||||
kind: 'git' | 'folder' = 'git'
|
||||
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
|
||||
const repoKind = kind === 'folder' ? 'folder' : 'git'
|
||||
if (repoKind === 'git') {
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: path })
|
||||
}
|
||||
if (repoKind === 'git' && !isGitRepo(path)) {
|
||||
return { error: `Not a valid git repository: ${path}` }
|
||||
}
|
||||
@@ -762,6 +770,7 @@ export function setRepoRemoteClientNotifier(notifier: RepoRemoteClientNotifier):
|
||||
|
||||
// Why: module-scoped so the abort handle survives macOS window re-creation, when registerRepoHandlers re-runs.
|
||||
let activeClone: ActiveCloneMetadata | null = null
|
||||
const pendingLocalCloneControllers = new Set<AbortController>()
|
||||
let activeRemoteClone: ActiveRemoteCloneMetadata | null = null
|
||||
let nextCloneGeneration = 1
|
||||
const latestCloneGenerationByPath = new Map<string, number>()
|
||||
@@ -1169,6 +1178,10 @@ async function scanNestedReposForIpc(args: {
|
||||
}): Promise<NestedRepoScanResult> {
|
||||
validateNestedRepoScanRoot(args.path, args.connectionId)
|
||||
if (!args.connectionId) {
|
||||
await awaitWindowsHostGitEnvironmentReady({
|
||||
cwd: args.path,
|
||||
...(args.signal ? { signal: args.signal } : {})
|
||||
})
|
||||
return scanNestedRepos({
|
||||
path: args.path,
|
||||
options: args.options,
|
||||
@@ -1702,10 +1715,16 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
continue
|
||||
}
|
||||
importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider)
|
||||
} else if (!isGitRepo(repoPath)) {
|
||||
results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' })
|
||||
continue
|
||||
} else {
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath })
|
||||
if (!isGitRepo(repoPath)) {
|
||||
results.push({
|
||||
path: repoPath,
|
||||
status: 'failed',
|
||||
error: 'Not a valid git repository'
|
||||
})
|
||||
continue
|
||||
}
|
||||
importRepoPath = await importTargetResolver.resolveLocal(repoPath)
|
||||
}
|
||||
const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath)
|
||||
@@ -2325,6 +2344,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
})
|
||||
|
||||
ipcMain.handle('repos:cloneAbort', async () => {
|
||||
for (const controller of pendingLocalCloneControllers) {
|
||||
controller.abort()
|
||||
}
|
||||
pendingLocalCloneControllers.clear()
|
||||
if (activeClone) {
|
||||
const clone = activeClone
|
||||
clone.abortRequested = true
|
||||
@@ -2361,24 +2384,30 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
// Why: spawn (not execFile) avoids the maxBuffer limit — clone progress on stderr can exceed Node's 1 MB default.
|
||||
// Why: --progress forces git to emit progress even when stderr isn't a TTY.
|
||||
const cloneMetadataRef: { current: ActiveCloneMetadata | null } = { current: null }
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let proc: Awaited<ReturnType<typeof gitSpawnAfterWindowsEnvironmentReady>>
|
||||
const pendingController = new AbortController()
|
||||
pendingLocalCloneControllers.add(pendingController)
|
||||
try {
|
||||
// Why: use the parent destination as cwd so the runner detects a WSL path and routes through wsl.exe.
|
||||
// Why: '--' isolates the URL so a malicious URL can't be read as git flags (command injection).
|
||||
let proc: ReturnType<typeof gitSpawn>
|
||||
try {
|
||||
proc = gitSpawn(['clone', '--progress', '--', args.url, clonePath], {
|
||||
proc = await gitSpawnAfterWindowsEnvironmentReady(
|
||||
['clone', '--progress', '--', args.url, clonePath],
|
||||
{
|
||||
cwd: args.destination,
|
||||
// Why: without this, an auth-needing clone pops Git Credential Manager's OAuth window on Windows, unclosable in a restricted env (issue #7652).
|
||||
env: nonInteractiveGitEnv(),
|
||||
signal: pendingController.signal,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
} catch (err) {
|
||||
void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
reject(new Error(`Clone failed: ${message}`))
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
await cleanupClaimedCloneTarget(clonePath, claimedTarget)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Clone failed: ${message}`)
|
||||
} finally {
|
||||
pendingLocalCloneControllers.delete(pendingController)
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const generation = nextCloneGeneration++
|
||||
latestCloneGenerationByPath.set(clonePathKey, generation)
|
||||
const metadata: ActiveCloneMetadata = {
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
isAllowedPluginGitUrl,
|
||||
PLUGIN_COMMIT_PATTERN
|
||||
} from '../../shared/plugins/plugin-install-lockfile'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const PLUGIN_GIT_TIMEOUT_MS = 120_000
|
||||
|
||||
/** Runs system Git with argv-only invocation so credential helpers and SSH
|
||||
* remotes work without exposing an executable remote-helper surface. */
|
||||
export async function runPluginGit(args: string[], cwd: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
const { stdout } = await gitExecFileAsync(args, {
|
||||
cwd,
|
||||
timeout: PLUGIN_GIT_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
// Existing non-interactive helpers and SSH agents still work, but a
|
||||
// background marketplace refresh can never hang on a terminal prompt.
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
timeout: PLUGIN_GIT_TIMEOUT_MS
|
||||
})
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ import type {
|
||||
import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment'
|
||||
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
|
||||
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync } from '../git/runner'
|
||||
import type { GitRuntimeOptions } from '../git/git-runtime-options'
|
||||
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
|
||||
import {
|
||||
@@ -986,6 +986,7 @@ export class RuntimeGitCommands {
|
||||
}
|
||||
return provider.getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
|
||||
}
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path })
|
||||
return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
|
||||
}
|
||||
|
||||
@@ -1001,6 +1002,7 @@ export class RuntimeGitCommands {
|
||||
}
|
||||
return provider.getRemoteCommitUrl(target.worktree.path, sha)
|
||||
}
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path })
|
||||
return getRemoteCommitUrl(target.worktree.path, sha)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7761,7 +7761,7 @@ describe('OrcaRuntimeService', () => {
|
||||
it('keeps project clone setup on the cloned host-qualified repo', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
const runtimeStore = {
|
||||
@@ -7786,7 +7786,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => {
|
||||
setImmediate(() => {
|
||||
mkdirSync(clonePath, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
|
||||
proc.emit('close', 0, null)
|
||||
@@ -7828,14 +7828,16 @@ describe('OrcaRuntimeService', () => {
|
||||
const existingFolder = join(destination, 'orca')
|
||||
mkdirSync(existingFolder, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd: existingFolder, stdio: 'ignore' })
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn').mockImplementation(() => {
|
||||
// Why: unreachable while the guard holds; stubbed so a regression records the call
|
||||
// instead of shelling out to a real network clone.
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => proc.emit('close', 1, null))
|
||||
return proc as never
|
||||
})
|
||||
const spawnSpy = vi
|
||||
.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
.mockImplementation(() => {
|
||||
// Why: unreachable while the guard holds; stubbed so a regression records the call
|
||||
// instead of shelling out to a real network clone.
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
setImmediate(() => proc.emit('close', 1, null))
|
||||
return proc as never
|
||||
})
|
||||
const repos: Record<string, unknown>[] = []
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -7884,7 +7886,7 @@ describe('OrcaRuntimeService', () => {
|
||||
it('adopts public clone repos into host-qualified project setup', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const repos: Record<string, unknown>[] = []
|
||||
getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
const runtimeStore = {
|
||||
@@ -7909,7 +7911,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => {
|
||||
setImmediate(() => {
|
||||
mkdirSync(clonePath, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
|
||||
proc.emit('close', 0, null)
|
||||
@@ -7954,7 +7956,7 @@ describe('OrcaRuntimeService', () => {
|
||||
it('keeps project clone repos split by runtime host on the same clone path', async () => {
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-'))
|
||||
const clonePath = join(destination, 'orca')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const repos: Record<string, unknown>[] = [
|
||||
{
|
||||
id: 'repo-host-a',
|
||||
@@ -7989,7 +7991,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => {
|
||||
setImmediate(() => {
|
||||
mkdirSync(clonePath, { recursive: true })
|
||||
execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' })
|
||||
proc.emit('close', 0, null)
|
||||
@@ -8126,7 +8128,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const added: Record<string, unknown>[] = []
|
||||
const colorStore = {
|
||||
...store,
|
||||
@@ -8139,7 +8141,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => proc.emit('close', 0, null))
|
||||
setImmediate(() => proc.emit('close', 0, null))
|
||||
return proc as never
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(colorStore as never)
|
||||
@@ -8162,7 +8164,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('drops a same-path negative submodule cache before runtime cloneRepo', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-reclone-'))
|
||||
const clonePath = join(destination, 'reclone')
|
||||
const added: Record<string, unknown>[] = []
|
||||
@@ -8175,7 +8177,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => {
|
||||
setImmediate(() => {
|
||||
void mkdir(clonePath, { recursive: true })
|
||||
.then(() =>
|
||||
writeFile(join(clonePath, '.gitmodules'), '[submodule "lib"]\n\tpath = vendor/lib\n')
|
||||
@@ -8199,11 +8201,11 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
spawnSpy.mockImplementation(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
queueMicrotask(() => proc.emit('close', 0, null))
|
||||
setImmediate(() => proc.emit('close', 0, null))
|
||||
return proc as never
|
||||
})
|
||||
const existing = {
|
||||
@@ -8297,7 +8299,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const runtime = createRuntime()
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const destination = join(tempRoot, 'destination')
|
||||
@@ -8314,7 +8316,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const runtime = createRuntime()
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const destination = join(tempRoot, 'destination')
|
||||
@@ -8331,10 +8333,10 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('removes an owned runtime clone target when git exits unsuccessfully', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
spawnSpy.mockResolvedValue(proc as never)
|
||||
const runtime = createRuntime()
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const clonePath = join(destination, 'repo-badge-color')
|
||||
@@ -8358,10 +8360,10 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('preserves an existing runtime clone target when git exits unsuccessfully', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
spawnSpy.mockResolvedValue(proc as never)
|
||||
const runtime = createRuntime()
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const clonePath = join(destination, 'repo-badge-color')
|
||||
@@ -8385,10 +8387,10 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('skips runtime clone failure cleanup when the owned target is replaced', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
proc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValue(proc as never)
|
||||
spawnSpy.mockResolvedValue(proc as never)
|
||||
const runtime = createRuntime()
|
||||
const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-'))
|
||||
const clonePath = join(destination, 'repo-badge-color')
|
||||
@@ -8414,10 +8416,10 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('serializes concurrent runtime clones for the same target', async () => {
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn')
|
||||
const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady')
|
||||
const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter }
|
||||
firstProc.stderr = new EventEmitter()
|
||||
spawnSpy.mockReturnValueOnce(firstProc as never)
|
||||
spawnSpy.mockResolvedValueOnce(firstProc as never)
|
||||
const added: Record<string, unknown>[] = []
|
||||
const colorStore = {
|
||||
...store,
|
||||
|
||||
@@ -94,7 +94,12 @@ import {
|
||||
AGENT_PROMPT_SUBMIT_DELAY_MS,
|
||||
buildAgentPromptPasteBytes
|
||||
} from '../../shared/agent-prompt-injection'
|
||||
import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner'
|
||||
import {
|
||||
awaitWindowsHostGitEnvironmentReady,
|
||||
gitExecFileAsync,
|
||||
gitSpawnAfterWindowsEnvironmentReady,
|
||||
nonInteractiveGitEnv
|
||||
} from '../git/runner'
|
||||
import { runWithGitReadCacheInvalidation } from '../git/status'
|
||||
import {
|
||||
cleanupClaimedCloneTarget,
|
||||
@@ -18479,6 +18484,7 @@ export class OrcaRuntimeService {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new Error('Project path must be an absolute path')
|
||||
}
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: path })
|
||||
return scanNestedRepos({ path, options: { timeoutMs: 15_000 } })
|
||||
}
|
||||
|
||||
@@ -18527,6 +18533,7 @@ export class OrcaRuntimeService {
|
||||
projectPaths: string[]
|
||||
mode: ProjectGroupImportMode
|
||||
}): Promise<ProjectGroupImportResult> {
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: args.parentPath })
|
||||
if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
@@ -18554,6 +18561,7 @@ export class OrcaRuntimeService {
|
||||
const importTargetResolver = createNestedRepoImportTargetResolver()
|
||||
for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) {
|
||||
try {
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath })
|
||||
if (!isGitRepo(repoPath)) {
|
||||
results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' })
|
||||
continue
|
||||
@@ -18681,6 +18689,9 @@ export class OrcaRuntimeService {
|
||||
// server-side repo paths to be explicit so `orca serve` cwd is irrelevant.
|
||||
throw new Error('Project path must be an absolute path')
|
||||
}
|
||||
if (kind === 'git') {
|
||||
await awaitWindowsHostGitEnvironmentReady({ cwd: path })
|
||||
}
|
||||
if (kind === 'git' && !isGitRepo(path)) {
|
||||
throw new Error(`Not a valid git repository: ${path}`)
|
||||
}
|
||||
@@ -18927,10 +18938,11 @@ export class OrcaRuntimeService {
|
||||
|
||||
await mkdir(trimmedDestination, { recursive: true })
|
||||
const claimedTarget = await claimCloneTarget(clonePath)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let proc: ReturnType<typeof gitSpawn>
|
||||
try {
|
||||
proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], {
|
||||
let proc: Awaited<ReturnType<typeof gitSpawnAfterWindowsEnvironmentReady>>
|
||||
try {
|
||||
proc = await gitSpawnAfterWindowsEnvironmentReady(
|
||||
['clone', '--progress', '--', trimmedUrl, clonePath],
|
||||
{
|
||||
cwd: trimmedDestination,
|
||||
// Why: without the non-interactive guard, a clone that needs GitHub
|
||||
// auth makes Git Credential Manager pop its "Connect to GitHub" OAuth
|
||||
@@ -18939,14 +18951,14 @@ export class OrcaRuntimeService {
|
||||
// (issue #7652). Fail fast with a clear error instead.
|
||||
env: nonInteractiveGitEnv(),
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
} catch (err) {
|
||||
void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
reject(new Error(`Clone failed: ${message}`))
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
await cleanupClaimedCloneTarget(clonePath, claimedTarget)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Clone failed: ${message}`)
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let stderrTail = ''
|
||||
let settled = false
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
|
||||
@@ -26,15 +26,20 @@ describe('startup ordering', () => {
|
||||
expect(attachBlock).toContain(
|
||||
'awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady'
|
||||
)
|
||||
expect(source).toContain('firstWindowStartupServicesReady = startupServices.firstWindowReady')
|
||||
expect(source).toContain('localPtyStartupReady = startupServices.localPtyReady')
|
||||
expect(source).toContain(
|
||||
'firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)'
|
||||
)
|
||||
expect(source).toContain('localPtyStartupReady = services.then((value) => value.localPtyReady)')
|
||||
|
||||
const windowIndex = desktopStartup.indexOf('Promise.resolve(openMainWindow())')
|
||||
const windowIndex = desktopStartup.indexOf('Promise.resolve(desktopWindow ?? openMainWindow())')
|
||||
const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()')
|
||||
const legacyRpcStartIndex = desktopStartup.indexOf('runtimeRpc.start()')
|
||||
|
||||
expect(windowIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0)
|
||||
expect(desktopStartup).toMatch(
|
||||
/shellPathReady\s*\.then\(\(\) => (?:desktopRuntimeRpc|runtimeRpc)\.start\(\)\)/
|
||||
)
|
||||
expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(')
|
||||
// Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal.
|
||||
expect(desktopStartup).toMatch(/void showRuntimeRpcStartupFailureDialog\(\s*win,/)
|
||||
@@ -60,7 +65,7 @@ describe('startup ordering', () => {
|
||||
it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'")
|
||||
const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart)
|
||||
const barrierEnd = source.indexOf("'app:startupDiagnostic'", barrierStart)
|
||||
const barrier = source.slice(barrierStart, barrierEnd)
|
||||
const reconciliationStart = source.indexOf(
|
||||
'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations('
|
||||
@@ -68,22 +73,40 @@ describe('startup ordering', () => {
|
||||
const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart)
|
||||
const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart)
|
||||
const serveEnd = source.indexOf('return', serveReady)
|
||||
const desktopWindowStart = source.indexOf('Promise.resolve(openMainWindow())')
|
||||
const desktopWindowStart = source.indexOf(
|
||||
'const desktopStartup = startWindowsDesktopBeforeShellPathReady('
|
||||
)
|
||||
const desktopWindowJoin = source.indexOf(
|
||||
'Promise.resolve(desktopWindow ?? openMainWindow())',
|
||||
serveEnd
|
||||
)
|
||||
const serveStartup = source.slice(serveStart, serveEnd)
|
||||
const desktopStartup = source.slice(serveEnd, desktopWindowStart)
|
||||
const desktopStartup = source.slice(reconciliationStart, serveStart)
|
||||
|
||||
expect(barrierStart).toBeGreaterThanOrEqual(0)
|
||||
expect(barrierEnd).toBeGreaterThan(barrierStart)
|
||||
expect(reconciliationStart).toBeGreaterThanOrEqual(0)
|
||||
expect(serveStart).toBeGreaterThan(reconciliationStart)
|
||||
expect(serveEnd).toBeGreaterThan(serveStart)
|
||||
// Why: bound against serveEnd, not reconciliationStart — an earlier openMainWindow() call
|
||||
// would steal this anchor, collapse desktopStartup to '', and pass the negative check below.
|
||||
expect(desktopWindowStart).toBeGreaterThan(serveEnd)
|
||||
expect(desktopWindowStart).toBeGreaterThan(reconciliationStart)
|
||||
expect(desktopWindowStart).toBeLessThan(serveStart)
|
||||
expect(desktopWindowJoin).toBeGreaterThan(serveEnd)
|
||||
expect(serveStartup).toContain('await managedWslCliStartupBarrierReady')
|
||||
expect(serveStartup).not.toContain('await managedWslCliReconciliationReady')
|
||||
expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan(
|
||||
serveStartup.indexOf('await runtimeRpc.start()')
|
||||
)
|
||||
expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady')
|
||||
expect(desktopStartup).toContain(
|
||||
"process.platform === 'win32' && app.isPackaged && !serveOptions"
|
||||
)
|
||||
expect(desktopStartup).toContain(
|
||||
'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })'
|
||||
)
|
||||
expect(desktopStartup).toContain('shellPathReady,')
|
||||
expect(desktopStartup).toContain('startServices: startTerminalRuntimeStartupServices')
|
||||
expect(barrier).toContain('managedWslCliStartupBarrierReady')
|
||||
expect(barrier).not.toContain('managedWslCliReconciliationReady')
|
||||
expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'")
|
||||
@@ -110,7 +133,10 @@ describe('startup ordering', () => {
|
||||
inventoryIndex
|
||||
)
|
||||
const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex)
|
||||
const desktopIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex)
|
||||
const desktopIndex = source.indexOf(
|
||||
'Promise.resolve(desktopWindow ?? openMainWindow())',
|
||||
serveIndex
|
||||
)
|
||||
|
||||
expect(daemonInitIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex)
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('serve desktop activation wiring', () => {
|
||||
it('settles the persistent provider before headless PTY registration', () => {
|
||||
const appReadyIndex = source.indexOf('app.whenReady().then(async () => {')
|
||||
const startupIndex = source.indexOf(
|
||||
'\n startTerminalRuntimeStartupServices()\n',
|
||||
'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))',
|
||||
appReadyIndex
|
||||
)
|
||||
const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup'
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('Windows desktop shell PATH startup', () => {
|
||||
it('opens the first window while shell PATH hydration remains unresolved', async () => {
|
||||
const shellPath = deferred()
|
||||
const window = { visible: true }
|
||||
const openWindow = vi.fn(() => window)
|
||||
const startServices = vi.fn(() => ({
|
||||
firstWindowReady: Promise.resolve(),
|
||||
localPtyReady: Promise.resolve(),
|
||||
localPtyProviderReady: Promise.resolve()
|
||||
}))
|
||||
|
||||
const startup = startWindowsDesktopBeforeShellPathReady({
|
||||
openWindow,
|
||||
shellPathReady: shellPath.promise,
|
||||
startServices
|
||||
})
|
||||
|
||||
expect(startup.window).toBe(window)
|
||||
expect(openWindow).toHaveBeenCalledOnce()
|
||||
expect(startServices).not.toHaveBeenCalled()
|
||||
|
||||
shellPath.resolve()
|
||||
await startup.services
|
||||
expect(startServices).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export type WindowsDesktopStartupServices = {
|
||||
firstWindowReady: Promise<void>
|
||||
localPtyReady: Promise<void>
|
||||
localPtyProviderReady: Promise<void>
|
||||
}
|
||||
|
||||
type WindowsDesktopShellPathStartupOptions<TWindow> = {
|
||||
openWindow: () => TWindow
|
||||
shellPathReady: Promise<void>
|
||||
startServices: () => WindowsDesktopStartupServices
|
||||
}
|
||||
|
||||
export function startWindowsDesktopBeforeShellPathReady<TWindow>(
|
||||
options: WindowsDesktopShellPathStartupOptions<TWindow>
|
||||
): { window: TWindow; services: Promise<WindowsDesktopStartupServices> } {
|
||||
const services = options.shellPathReady.then(options.startServices)
|
||||
return { window: options.openWindow(), services }
|
||||
}
|
||||
@@ -157,6 +157,9 @@ describe('createMainWindow', () => {
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
once: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
@@ -699,6 +702,9 @@ describe('createMainWindow', () => {
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
once: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
@@ -3431,6 +3437,9 @@ describe('createMainWindow', () => {
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
once: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
@@ -3490,6 +3499,21 @@ describe('createMainWindow', () => {
|
||||
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('can reveal the startup window after renderer load before ready-to-show', () => {
|
||||
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
|
||||
|
||||
createMainWindow(null, { revealOnDidFinishLoad: true })
|
||||
const revealAfterLoad = browserWindowInstance.webContents.on.mock.calls.find(
|
||||
([event]) => event === 'did-finish-load'
|
||||
)?.[1]
|
||||
expect(revealAfterLoad).toBeTypeOf('function')
|
||||
revealAfterLoad?.()
|
||||
|
||||
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
|
||||
windowHandlers['ready-to-show']()
|
||||
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reveals the startup window on Windows when ready-to-show never fires', () => {
|
||||
vi.useFakeTimers()
|
||||
const { browserWindowInstance } = createStartupRevealWindowFixture()
|
||||
|
||||
@@ -206,6 +206,8 @@ type CreateMainWindowOptions = {
|
||||
}) => void
|
||||
/** Defer renderer load until IPC handlers are registered, or eager renderer calls race into missing channels. */
|
||||
deferLoad?: boolean
|
||||
/** Reveal after load instead of first paint when startup must show the shell before slower renderer work. */
|
||||
revealOnDidFinishLoad?: boolean
|
||||
title?: string
|
||||
getKeybindings?: () => KeybindingOverrides | undefined
|
||||
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
|
||||
@@ -377,6 +379,9 @@ export function createMainWindow(
|
||||
mainWindow.show()
|
||||
}
|
||||
mainWindow.on('ready-to-show', revealInitialWindow)
|
||||
if (opts?.revealOnDidFinishLoad === true) {
|
||||
mainWindow.webContents.on('did-finish-load', revealInitialWindow)
|
||||
}
|
||||
|
||||
// Why: persist window bounds to restore last position/size; debounce to avoid hammering persistence during resize drags.
|
||||
let boundsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -963,6 +963,10 @@ function App(): React.JSX.Element {
|
||||
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
|
||||
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
|
||||
)
|
||||
// Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first.
|
||||
await timeRendererStartupStep('first-window-services-await', () =>
|
||||
window.api.app.awaitFirstWindowStartupServices()
|
||||
)
|
||||
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
|
||||
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
|
||||
actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) })
|
||||
@@ -1095,10 +1099,7 @@ function App(): React.JSX.Element {
|
||||
logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 })
|
||||
}
|
||||
|
||||
// Why: main overlaps daemon/hook startup with hydration, but restored terminals need those services ready before they spawn/reconnect PTYs.
|
||||
await timeRendererStartupStep('first-window-services-await', () =>
|
||||
window.api.app.awaitFirstWindowStartupServices()
|
||||
)
|
||||
// first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state.
|
||||
await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () =>
|
||||
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
|
||||
)
|
||||
|
||||
@@ -36,6 +36,10 @@ describe('renderer startup runtime routing', () => {
|
||||
const hydrationWorktreesIndex = source.indexOf(
|
||||
"timeRendererStartupStep('fetch-hydration-worktrees'"
|
||||
)
|
||||
const servicesIndex = source.indexOf(
|
||||
"timeRendererStartupStep('first-window-services-await'",
|
||||
sessionIndex
|
||||
)
|
||||
const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()')
|
||||
const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()')
|
||||
|
||||
@@ -53,7 +57,8 @@ describe('renderer startup runtime routing', () => {
|
||||
expect(localReposIndex).toBeLessThan(localGroupsIndex)
|
||||
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
|
||||
expect(localReposIndex).toBeLessThan(sessionIndex)
|
||||
expect(sessionIndex).toBeLessThan(hydrationWorktreesIndex)
|
||||
expect(sessionIndex).toBeLessThan(servicesIndex)
|
||||
expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex)
|
||||
const hydrationWorktreeBlock = source.slice(
|
||||
hydrationWorktreesIndex,
|
||||
source.indexOf('await keybindingsPromise')
|
||||
|
||||
Reference in New Issue
Block a user