From 3b5312cfb89c95f8c3f2c9453310b3f622b24749 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 8 Oct 2026 22:41:01 -0700 Subject: [PATCH 01/11] Keep repository detection off the main IPC thread Share repository discovery decisions across synchronous compatibility callers and the existing asynchronous Git runner. Await discovery in project registration, nested imports, and folder upgrades, and recheck folder ownership after discovery completes. --- src/main/git/repo-detection-async.test.ts | 94 +++++++++++++++++++ src/main/git/repo-detection-command.test.ts | 50 ++++++++++ src/main/git/repo-detection-command.ts | 39 ++++++++ src/main/git/repo-detection.ts | 81 ++++++++++++---- src/main/git/repo.ts | 4 + .../folder-repo-git-upgrade-lifetime.test.ts | 28 +++++- src/main/ipc/folder-repo-git-upgrade.test.ts | 2 +- src/main/ipc/folder-repo-git-upgrade.ts | 23 +++-- .../ipc/repos-add-linked-worktree.test.ts | 4 +- src/main/ipc/repos-remote-test-harness.ts | 4 +- src/main/ipc/repos/local-repo-registration.ts | 12 ++- .../ipc/repos/nested-repo-import-handler.ts | 6 +- .../project-groups/nested-repo-discovery.ts | 5 +- .../runtime/runtime-nested-repo-import.ts | 4 +- ...time-repository-registration-controller.ts | 4 +- 15 files changed, 319 insertions(+), 41 deletions(-) create mode 100644 src/main/git/repo-detection-async.test.ts create mode 100644 src/main/git/repo-detection-command.test.ts create mode 100644 src/main/git/repo-detection-command.ts diff --git a/src/main/git/repo-detection-async.test.ts b/src/main/git/repo-detection-async.test.ts new file mode 100644 index 000000000000..32f98ddbd7ab --- /dev/null +++ b/src/main/git/repo-detection-async.test.ts @@ -0,0 +1,94 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as runner from './runner' +import { runProcess } from '../../shared/child-process/run-process' +import { + getGitRepoRoot, + getGitRepoRootAsync, + getLinkedWorktreeMainRepoRoot, + getLinkedWorktreeMainRepoRootAsync, + inspectGitRepoForRegistration, + inspectGitRepoForRegistrationAsync, + isGitRepo, + isGitRepoAsync +} from './repo-detection' + +let fixture: string +async function git(cwd: string, args: string[]): Promise { + const result = await runProcess({ program: 'git', args, cwd, timeoutMs: 15_000 }) + if (result.code !== 0) { + throw new Error(result.stderr) + } +} + +beforeEach(() => { + fixture = mkdtempSync(join(tmpdir(), 'orca-repo-async-')) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(fixture, { recursive: true, force: true }) +}) + +async function expectSameDetection(path: string): Promise { + expect(await isGitRepoAsync(path)).toBe(isGitRepo(path)) + expect(await getGitRepoRootAsync(path)).toBe(getGitRepoRoot(path)) + expect(await inspectGitRepoForRegistrationAsync(path)).toEqual( + inspectGitRepoForRegistration(path) + ) + expect(await getLinkedWorktreeMainRepoRootAsync(path)).toBe(getLinkedWorktreeMainRepoRoot(path)) +} + +describe('asynchronous repository detection with real Git', () => { + it('preserves normal, nested, linked-worktree, bare, and folder results', async () => { + const repo = join(fixture, 'repo with spaces') + const nested = join(repo, 'packages', 'web') + mkdirSync(nested, { recursive: true }) + await git(repo, ['init', '--quiet']) + await git(repo, [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca@example.invalid', + 'commit', + '--allow-empty', + '--quiet', + '-m', + 'fixture' + ]) + const linked = join(fixture, 'linked worktree') + await git(repo, ['worktree', 'add', '--quiet', '-b', 'fixture-linked', linked]) + const bare = join(fixture, 'bare.git') + await git(fixture, ['init', '--bare', '--quiet', bare]) + const folder = join(fixture, 'folder') + mkdirSync(folder) + for (const path of [repo, nested, linked, bare, folder]) { + await expectSameDetection(path) + } + expect(await isGitRepoAsync(folder)).toBe(false) + expect(await isGitRepoAsync(bare)).toBe(true) + expect((await inspectGitRepoForRegistrationAsync(linked)).mainRepoPath).not.toBeNull() + }) + + it('preserves genuine metadata fallback when asynchronous Git cannot answer', async () => { + const repo = join(fixture, 'fallback repo') + const nested = join(repo, 'packages') + mkdirSync(nested, { recursive: true }) + await git(repo, ['init', '--quiet']) + vi.spyOn(runner, 'gitExecFileAsync').mockRejectedValue(new Error('Git unavailable')) + expect(await isGitRepoAsync(nested)).toBe(true) + expect(await getGitRepoRootAsync(nested)).toBe(repo.replaceAll('\\', '/')) + expect(await isGitRepoAsync(join(repo, '.git'))).toBe(false) + }) + + it('rejects missing directories and invalid Git markers without changing root fallback', async () => { + const invalid = join(fixture, 'invalid') + mkdirSync(invalid) + writeFileSync(join(invalid, '.git'), 'not a gitdir file') + for (const path of [invalid, join(fixture, 'missing')]) { + await expectSameDetection(path) + expect(await isGitRepoAsync(path)).toBe(false) + } + }) +}) diff --git a/src/main/git/repo-detection-command.test.ts b/src/main/git/repo-detection-command.test.ts new file mode 100644 index 000000000000..fd1502e0d69f --- /dev/null +++ b/src/main/git/repo-detection-command.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { gitExecFileAsync, gitExecFileSync } from './runner' +import { gitRepoOutput, runGitRepoCommands, runGitRepoCommandsSync } from './repo-detection-command' + +vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn(), gitExecFileSync: vi.fn() })) +afterEach(() => vi.resetAllMocks()) + +describe('repository command execution', () => { + it('lets another event-loop turn run while Git is pending, with no synchronous spawn', async () => { + let finish: (value: { stdout: string; stderr: string }) => void = () => {} + vi.mocked(gitExecFileAsync).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + let settled = false + const detection = runGitRepoCommands( + gitRepoOutput(['rev-parse', '--show-toplevel'], { cwd: '/repo' }) + ).then((result) => { + settled = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) + expect(gitExecFileSync).not.toHaveBeenCalled() + expect(gitExecFileAsync).toHaveBeenCalledExactlyOnceWith(['rev-parse', '--show-toplevel'], { + cwd: '/repo', + timeout: 15_000 + }) + finish({ stdout: '/repo\n', stderr: '' }) + expect(await detection).toBe('/repo\n') + }) + + it('delivers command failures to the shared decision tree in both execution modes', async () => { + function* withFallback() { + try { + return yield* gitRepoOutput(['rev-parse'], { cwd: '/repo' }) + } catch { + return 'marker fallback' + } + } + vi.mocked(gitExecFileAsync).mockRejectedValue(new Error('Git unavailable')) + vi.mocked(gitExecFileSync).mockImplementation(() => { + throw new Error('Git unavailable') + }) + expect(await runGitRepoCommands(withFallback())).toBe('marker fallback') + expect(runGitRepoCommandsSync(withFallback())).toBe('marker fallback') + }) +}) diff --git a/src/main/git/repo-detection-command.ts b/src/main/git/repo-detection-command.ts new file mode 100644 index 000000000000..778688e15477 --- /dev/null +++ b/src/main/git/repo-detection-command.ts @@ -0,0 +1,39 @@ +import { gitExecFileAsync, gitExecFileSync } from './runner' + +type GitRepoCommand = { args: string[]; cwd: string } +export type GitRepoCommands = Generator + +export function* gitRepoOutput(args: string[], options: { cwd: string }): GitRepoCommands { + return yield { args, cwd: options.cwd } +} + +// One decision tree keeps Git failures and marker fallbacks identical across both callers. +export async function runGitRepoCommands(commands: GitRepoCommands): Promise { + let step = commands.next() + while (!step.done) { + const command = step.value + try { + const { stdout } = await gitExecFileAsync(command.args, { + cwd: command.cwd, + timeout: 15_000 + }) + step = commands.next(stdout) + } catch (error) { + step = commands.throw(error) + } + } + return step.value +} + +export function runGitRepoCommandsSync(commands: GitRepoCommands): T { + let step = commands.next() + while (!step.done) { + const command = step.value + try { + step = commands.next(gitExecFileSync(command.args, { cwd: command.cwd })) + } catch (error) { + step = commands.throw(error) + } + } + return step.value +} diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts index 3fe75a1148d5..f5cf483f9768 100644 --- a/src/main/git/repo-detection.ts +++ b/src/main/git/repo-detection.ts @@ -4,7 +4,12 @@ import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { scanGitMarkerSync, resolveRealPathSync } from './repo-git-marker-scan' -import { gitExecFileSync } from './runner' +import { + gitRepoOutput, + runGitRepoCommands, + runGitRepoCommandsSync, + type GitRepoCommands +} from './repo-detection-command' type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' type GitRepoProbe = { @@ -23,7 +28,7 @@ export type GitRepoRegistrationInfo = { let warnedMarkerFallbackThisSession = false /** Check if a path is a valid git repository (regular or bare). */ -export function isGitRepo(path: string): boolean { +function* isGitRepoCommands(path: string): GitRepoCommands { try { if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return false @@ -32,7 +37,7 @@ export function isGitRepo(path: string): boolean { return false } - return isGitRepoFromProbe(path, probeGitRepo(path).result) + return isGitRepoFromProbe(path, (yield* probeGitRepo(path)).result) } function isGitRepoFromProbe(path: string, result: GitRepoProbeResult): boolean { @@ -54,10 +59,10 @@ function isGitRepoFromProbe(path: string, result: GitRepoProbeResult): boolean { } /** Only a clean pair of negative Git answers is a definitive non-repo. */ -function probeGitRepo(path: string, includeLocation = false): GitRepoProbe { +function* probeGitRepo(path: string, includeLocation = false): GitRepoCommands { try { const records = readGitPathOutput( - gitExecFileSync( + yield* gitRepoOutput( [ 'rev-parse', '--is-inside-work-tree', @@ -76,7 +81,7 @@ function probeGitRepo(path: string, includeLocation = false): GitRepoProbe { : 'indeterminate' const location = includeLocation && insideWorkTree === 'true' && records.length !== 4 - ? readGitRepoDirectories(path) + ? yield* readGitRepoDirectories(path) : { gitDir, commonDir } return { result, insideWorkTree: insideWorkTree === 'true', ...location } } catch { @@ -85,7 +90,9 @@ function probeGitRepo(path: string, includeLocation = false): GitRepoProbe { } /** Reuse one repository discovery across registration's validity, root and worktree checks. */ -export function inspectGitRepoForRegistration(path: string): GitRepoRegistrationInfo { +function* inspectGitRepoForRegistrationCommands( + path: string +): GitRepoCommands { try { if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return { isRepo: false, rootPath: path, mainRepoPath: null } @@ -93,14 +100,14 @@ export function inspectGitRepoForRegistration(path: string): GitRepoRegistration } catch { return { isRepo: false, rootPath: path, mainRepoPath: null } } - const probe = probeGitRepo(path) + const probe = yield* probeGitRepo(path) const isRepo = isGitRepoFromProbe(path, probe.result) let rootPath = path let mainRepoPath: string | null = null if (isRepo && probe.insideWorkTree) { try { const records = readGitPathOutput( - gitExecFileSync( + yield* gitRepoOutput( [ 'rev-parse', '--is-inside-work-tree', @@ -119,9 +126,9 @@ export function inspectGitRepoForRegistration(path: string): GitRepoRegistration ? { toplevel, gitDir, commonDir } : { toplevel: readGitPathOutput( - gitExecFileSync(['rev-parse', '--show-toplevel'], { cwd: path }) + yield* gitRepoOutput(['rev-parse', '--show-toplevel'], { cwd: path }) ), - ...readGitRepoDirectories(path) + ...(yield* readGitRepoDirectories(path)) } if (location.toplevel) { rootPath = normalizeGitRepoRootForInputPath(path, location.toplevel) @@ -145,13 +152,13 @@ export function inspectGitRepoForRegistration(path: string): GitRepoRegistration return { isRepo, rootPath, mainRepoPath } } -export function getGitRepoRoot(path: string): string { +function* getGitRepoRootCommands(path: string): GitRepoCommands { try { if (!existsSync(path) || !statSync(path).isDirectory()) { return path } // A bare repo has no toplevel; keep its marker fallback separate from the boolean probes. - const output = gitExecFileSync(['rev-parse', '--is-inside-work-tree', '--show-toplevel'], { + const output = yield* gitRepoOutput(['rev-parse', '--is-inside-work-tree', '--show-toplevel'], { cwd: path }) const firstNewline = output.indexOf('\n') @@ -171,10 +178,14 @@ function readGitPathOutput(output: string): string { return output.endsWith('\n') ? output.slice(0, -1) : output } -function readGitRepoDirectories(path: string): Pick { +function* readGitRepoDirectories( + path: string +): GitRepoCommands> { return { - gitDir: readGitPathOutput(gitExecFileSync(['rev-parse', '--git-dir'], { cwd: path })), - commonDir: readGitPathOutput(gitExecFileSync(['rev-parse', '--git-common-dir'], { cwd: path })) + gitDir: readGitPathOutput(yield* gitRepoOutput(['rev-parse', '--git-dir'], { cwd: path })), + commonDir: readGitPathOutput( + yield* gitRepoOutput(['rev-parse', '--git-common-dir'], { cwd: path }) + ) } } @@ -191,13 +202,13 @@ function canonicalizeGitDirPath(path: string): string { } /** Return the main-checkout path only when `path` is a linked worktree. */ -export function getLinkedWorktreeMainRepoRoot(path: string): string | null { +function* getLinkedWorktreeMainRepoRootCommands(path: string): GitRepoCommands { try { if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return null } - const mainRepoPath = mainRepoPathFromProbe(path, probeGitRepo(path, true)) - return mainRepoPath ? getGitRepoRoot(mainRepoPath) : null + const mainRepoPath = mainRepoPathFromProbe(path, yield* probeGitRepo(path, true)) + return mainRepoPath ? yield* getGitRepoRootCommands(mainRepoPath) : null } catch { return null } @@ -225,3 +236,35 @@ export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: st } return normalizeRuntimePathSeparators(rootPath) } + +export function isGitRepo(path: string): boolean { + return runGitRepoCommandsSync(isGitRepoCommands(path)) +} + +export function isGitRepoAsync(path: string): Promise { + return runGitRepoCommands(isGitRepoCommands(path)) +} + +export function inspectGitRepoForRegistration(path: string): GitRepoRegistrationInfo { + return runGitRepoCommandsSync(inspectGitRepoForRegistrationCommands(path)) +} + +export function inspectGitRepoForRegistrationAsync(path: string): Promise { + return runGitRepoCommands(inspectGitRepoForRegistrationCommands(path)) +} + +export function getGitRepoRoot(path: string): string { + return runGitRepoCommandsSync(getGitRepoRootCommands(path)) +} + +export function getGitRepoRootAsync(path: string): Promise { + return runGitRepoCommands(getGitRepoRootCommands(path)) +} + +export function getLinkedWorktreeMainRepoRoot(path: string): string | null { + return runGitRepoCommandsSync(getLinkedWorktreeMainRepoRootCommands(path)) +} + +export function getLinkedWorktreeMainRepoRootAsync(path: string): Promise { + return runGitRepoCommands(getLinkedWorktreeMainRepoRootCommands(path)) +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index da64c013efba..7843fdba6412 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -11,9 +11,13 @@ import { gitExecFileAsync } from './runner' export { isGitRepo, + isGitRepoAsync, inspectGitRepoForRegistration, + inspectGitRepoForRegistrationAsync, getGitRepoRoot, + getGitRepoRootAsync, getLinkedWorktreeMainRepoRoot, + getLinkedWorktreeMainRepoRootAsync, normalizeGitRepoRootForInputPath } from './repo-detection' export { diff --git a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts index 6d86ec820faf..5e0f063e6f7c 100644 --- a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts @@ -16,7 +16,7 @@ const fake = vi.hoisted(() => ({ })) vi.mock('node:fs/promises', () => ({ stat: fake.stat })) vi.mock('node:fs', () => ({ realpathSync: (path: string) => path })) -vi.mock('../git/repo', () => ({ isGitRepo: fake.git, getGitRepoRoot: fake.root })) +vi.mock('../git/repo', () => ({ isGitRepoAsync: fake.git, getGitRepoRootAsync: fake.root })) vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: fake.prepare })) vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: fake.invalidate @@ -108,6 +108,32 @@ describe('folder repo upgrade poll lifetime', () => { expect(vi.getTimerCount()).toBe(0) }) + it.each(['stopped', 'removed', 'path changed', 'already upgraded'])( + 'refuses a stale folder upgrade when its owner is %s during Git discovery', + async (change) => { + const pending = deferred() + fake.git.mockReturnValue(pending.promise) + const repos = [folder()] + const { store } = begin(repos) + await vi.advanceTimersByTimeAsync(25) + expect(fake.git).toHaveBeenCalledTimes(1) + if (change === 'stopped') { + stopFolderRepoGitUpgradeWatch() + } else if (change === 'removed') { + repos.splice(0) + } else if (change === 'path changed') { + repos[0].path = join('mock', 'new-root') + } else { + repos[0].kind = 'git' + } + pending.resolve(true) + await flush() + expect(store.updateRepo).not.toHaveBeenCalled() + expect(fake.prepare).not.toHaveBeenCalled() + expect(fake.reposChanged).not.toHaveBeenCalled() + } + ) + it('stops the remaining folder scan when a pending marker fails after stop', async () => { const pending = deferred() fake.stat.mockReturnValue(pending.promise) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 5fe485ee3a21..9863e57ede3a 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -22,7 +22,7 @@ vi.mock('../git/repo', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - isGitRepo: (path: string) => { + isGitRepoAsync: (path: string) => { gitProbes.push(path) return actual.isGitRepo(path) } diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index 8a0a6130c0e0..724c80d0df88 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -9,7 +9,7 @@ import { isFolderRepo } from '../../shared/repo-kind' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' -import { getGitRepoRoot, isGitRepo } from '../git/repo' +import { getGitRepoRootAsync, isGitRepoAsync } from '../git/repo' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' import { notifyReposChanged } from './repos/repos-changed-notification' @@ -101,13 +101,13 @@ function resolveRealPath(pathValue: string): string { * the path the user picked; when a symlinked parent makes those differ, the root reads * as an *external* worktree, and hiding those would hide the project's only workspace. */ -function resolveUpgrade( +async function resolveUpgrade( repoPath: string -): { folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null { - if (!isGitRepo(repoPath)) { +): Promise<{ folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null> { + if (!(await isGitRepoAsync(repoPath))) { return null } - const gitRoot = getGitRepoRoot(repoPath) + const gitRoot = await getGitRepoRootAsync(repoPath) if (resolveRealPath(gitRoot) !== resolveRealPath(repoPath)) { return null } @@ -127,7 +127,18 @@ async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise ({ vi.mock('../git/repo', () => ({ isGitRepo: isGitRepoMock, - inspectGitRepoForRegistration: vi.fn((path: string) => ({ + isGitRepoAsync: isGitRepoMock, + inspectGitRepoForRegistrationAsync: vi.fn((path: string) => ({ isRepo: isGitRepoMock(path), rootPath: getGitRepoRootMock(path), mainRepoPath: getLinkedWorktreeMainRepoRootMock(path) })), getGitRepoRoot: getGitRepoRootMock, + getGitRepoRootAsync: getGitRepoRootMock, getLinkedWorktreeMainRepoRoot: getLinkedWorktreeMainRepoRootMock, getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 183e3a4ab7b9..eea5e7677252 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -121,8 +121,10 @@ export function gitRepoModuleMock(actual: typeof RepoModule): Record ({ + getGitRepoRootAsync: getGitRepoRoot, + inspectGitRepoForRegistrationAsync: vi.fn((path: string) => ({ isRepo: isGitRepo(path), rootPath: getGitRepoRoot(path), mainRepoPath: null diff --git a/src/main/ipc/repos/local-repo-registration.ts b/src/main/ipc/repos/local-repo-registration.ts index 8a3c2864bb54..6d0e03445b1c 100644 --- a/src/main/ipc/repos/local-repo-registration.ts +++ b/src/main/ipc/repos/local-repo-registration.ts @@ -5,7 +5,11 @@ import { isFolderRepo } from '../../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' -import { inspectGitRepoForRegistration, getGitRepoRoot, getRepoName } from '../../git/repo' +import { + inspectGitRepoForRegistrationAsync, + getGitRepoRootAsync, + getRepoName +} from '../../git/repo' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' @@ -20,7 +24,7 @@ export async function addLocalRepoFromPath( if (repoKind === 'git') { await awaitWindowsHostGitEnvironmentReady({ cwd: path }) } - const gitInfo = repoKind === 'git' ? inspectGitRepoForRegistration(path) : null + const gitInfo = repoKind === 'git' ? await inspectGitRepoForRegistrationAsync(path) : null if (gitInfo && !gitInfo.isRepo) { return { error: `Not a valid git repository: ${path}` } } @@ -51,7 +55,9 @@ export async function addLocalRepoFromPath( // it belongs to an already-tracked repo. Adding it anyway yields a second "ready" host setup on the // same project and host — a duplicate run-target row that resolves to a transient worktree path. if (repoKind === 'git') { - const mainRepoRoot = gitInfo?.mainRepoPath ? getGitRepoRoot(gitInfo.mainRepoPath) : null + const mainRepoRoot = gitInfo?.mainRepoPath + ? await getGitRepoRootAsync(gitInfo.mainRepoPath) + : null if (mainRepoRoot) { const mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) // Why !isFolderRepo: only a git-kind main checkout projects onto the same project as its diff --git a/src/main/ipc/repos/nested-repo-import-handler.ts b/src/main/ipc/repos/nested-repo-import-handler.ts index 90294ec464ad..1a6501edc631 100644 --- a/src/main/ipc/repos/nested-repo-import-handler.ts +++ b/src/main/ipc/repos/nested-repo-import-handler.ts @@ -7,7 +7,7 @@ import type { ProjectGroupImportResult } from '../../../shared/project-group-typ import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' -import { isGitRepo, getRepoName } from '../../git/repo' +import { isGitRepoAsync, getRepoName } from '../../git/repo' import { createNestedProjectGroupResolver, resolveNestedRepoSelection @@ -83,7 +83,7 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider) } else { await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) - if (!isGitRepo(repoPath)) { + if (!(await isGitRepoAsync(repoPath))) { results.push({ path: repoPath, status: 'failed', @@ -154,7 +154,7 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store } importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) - // Why: reaches here only after the isGitRepo guard above confirmed a git repo, so always true. + // Why: reaches here only after the isGitRepoAsync guard above confirmed a git repo, so always true. emitRepoAdded('folder_picker', false, true) } catch (error) { results.push({ diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index 1f25a85a26f1..84abbf74ae60 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -1,7 +1,7 @@ import { readFile, readdir, stat } from 'node:fs/promises' import { basename, join } from 'node:path' import type { NestedRepoCandidate, NestedRepoScanResult } from '../../shared/project-group-types' -import { isGitRepo } from '../git/repo' +import { isGitRepoAsync } from '../git/repo' import { isIgnoredNestedRepoDirectory, normalizeNestedRepoScanOptions, @@ -58,7 +58,8 @@ export async function scanNestedRepos(args: { joinPath: join, basename, hasGitMarker, - isSelectedPathGitRepo: async (path: string) => isGitRepo(path) || (await hasGitMarker(path)) + isSelectedPathGitRepo: async (path: string) => + (await isGitRepoAsync(path)) || (await hasGitMarker(path)) } const buildResult = (selectedPathKind: NestedRepoScanResult['selectedPathKind']) => ({ selectedPath: args.path, diff --git a/src/main/runtime/runtime-nested-repo-import.ts b/src/main/runtime/runtime-nested-repo-import.ts index ba2df920b519..81ab6d99f001 100644 --- a/src/main/runtime/runtime-nested-repo-import.ts +++ b/src/main/runtime/runtime-nested-repo-import.ts @@ -9,7 +9,7 @@ import type { } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import { awaitWindowsHostGitEnvironmentReady } from '../git/runner' -import { getRepoName, isGitRepo } from '../git/repo' +import { getRepoName, isGitRepoAsync } from '../git/repo' import { scanNestedRepos } from '../project-groups/nested-repo-discovery' import { createNestedProjectGroupResolver, @@ -77,7 +77,7 @@ export class RuntimeNestedRepoImport { for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) - if (!isGitRepo(repoPath)) { + if (!(await isGitRepoAsync(repoPath))) { results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) continue } diff --git a/src/main/runtime/runtime-repository-registration-controller.ts b/src/main/runtime/runtime-repository-registration-controller.ts index cc64e20a9acb..01099f34b63d 100644 --- a/src/main/runtime/runtime-repository-registration-controller.ts +++ b/src/main/runtime/runtime-repository-registration-controller.ts @@ -9,7 +9,7 @@ import { } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import { gitExecFileAsync, awaitWindowsHostGitEnvironmentReady } from '../git/runner' -import { getRepoName, isGitRepo } from '../git/repo' +import { getRepoName, isGitRepoAsync } from '../git/repo' import { invalidateAuthorizedRootsCache, isENOENT } from '../ipc/filesystem-auth' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' @@ -40,7 +40,7 @@ export class RuntimeRepositoryRegistrationController { if (kind === 'git') { await awaitWindowsHostGitEnvironmentReady({ cwd: path }) } - if (kind === 'git' && !isGitRepo(path)) { + if (kind === 'git' && !(await isGitRepoAsync(path))) { throw new Error(`Not a valid git repository: ${path}`) } const existing = store.getRepos().find((repo) => { From 2d4f582d5554be44cf7f168a28226e41414d0672 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 8 Oct 2026 22:45:12 -0700 Subject: [PATCH 02/11] Make repository detection fixtures work on native Windows --- src/main/git/repo-detection.test.ts | 4 ++-- src/main/ipc/folder-repo-git-upgrade.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index 83bf39391893..37e6a2ed0833 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -80,7 +80,7 @@ describe('isGitRepo', () => { git(realRepo, ['init', '--quiet']) withGitUnavailable(() => { - expect(getGitRepoRoot(nestedDir)).toBe(realRepo) + expect(getGitRepoRoot(nestedDir)).toBe(realRepo.replaceAll('\\', '/')) }) }) @@ -330,7 +330,7 @@ describe('isGitRepo', () => { const bareRepo = path.join(tmpDir, 'bare.git') git(tmpDir, ['init', '--bare', '--quiet', bareRepo]) - expect(getGitRepoRoot(bareRepo)).toBe(bareRepo) + expect(getGitRepoRoot(bareRepo)).toBe(bareRepo.replaceAll('\\', '/')) }) }) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 9863e57ede3a..f0fcb3b2b4e1 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -143,7 +143,7 @@ describe('folder repo git upgrade watch', () => { vi.clearAllMocks() root = realpathSync(await mkdtemp(join(tmpdir(), 'folder-repo-upgrade-'))) symlinkedRoot = `${root}-link` - await symlink(root, symlinkedRoot, 'dir') + await symlink(root, symlinkedRoot, process.platform === 'win32' ? 'junction' : 'dir') statCalls.length = 0 gitProbes.length = 0 }) From 14b4df9ea76a7f1242f33c6bb95907a52bb7c56e Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 8 Oct 2026 22:48:49 -0700 Subject: [PATCH 03/11] Await asynchronous Git checks in folder upgrade fixtures --- src/main/ipc/folder-repo-git-upgrade.test.ts | 23 +++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index f0fcb3b2b4e1..ebc58b92b721 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -10,22 +10,36 @@ import type { Repo } from '../../shared/repo-types' // Why: the watch must stay one stat per folder project per tick — counting the real // calls is what keeps a directory-listing fan-out from creeping back in. -const { statCalls, readdirSpy, gitProbes } = vi.hoisted(() => ({ +const { statCalls, readdirSpy, gitProbes, pendingGitChecks } = vi.hoisted(() => ({ statCalls: [] as string[], readdirSpy: vi.fn(), + pendingGitChecks: new Set>(), // Why: the rejected-marker cache exists to stop git respawning every tick; counting the // real probes is the only way that guarantee stays true. gitProbes: [] as string[] })) +function trackGitCheck(pending: Promise): Promise { + const completed = pending.finally(() => pendingGitChecks.delete(completed)) + pendingGitChecks.add(completed) + return completed +} + +async function drainGitChecks(): Promise { + while (pendingGitChecks.size > 0) { + await Promise.allSettled(pendingGitChecks) + } +} + vi.mock('../git/repo', async (importOriginal) => { const actual = await importOriginal() return { ...actual, isGitRepoAsync: (path: string) => { gitProbes.push(path) - return actual.isGitRepo(path) - } + return trackGitCheck(actual.isGitRepoAsync(path)) + }, + getGitRepoRootAsync: (path: string) => trackGitCheck(actual.getGitRepoRootAsync(path)) } }) @@ -150,6 +164,7 @@ describe('folder repo git upgrade watch', () => { afterEach(async () => { stopFolderRepoGitUpgradeWatch() + await drainGitChecks() await rm(symlinkedRoot, { force: true }) await rm(root, { recursive: true, force: true }) }) @@ -157,6 +172,7 @@ describe('folder repo git upgrade watch', () => { // Real timers: the tick awaits real filesystem stats, which fake timers cannot flush. async function tick(times = 1): Promise { await new Promise((resolve) => setTimeout(resolve, POLL_MS * times + POLL_MS)) + await drainGitChecks() } /** Why: a tick that spawns git can outrun a fixed wait on a loaded machine. */ @@ -165,6 +181,7 @@ describe('folder repo git upgrade watch', () => { while (statCalls.length < count && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, POLL_MS)) } + await drainGitChecks() } it('upgrades a local folder repo once an external git init creates .git', async () => { From 72208aa57e9b38d0d165787931e16481fd351fc6 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 8 Oct 2026 23:36:09 -0700 Subject: [PATCH 04/11] Prevent duplicate project registration after async discovery --- .../ipc/repos-add-linked-worktree.test.ts | 41 +++- src/main/ipc/repos/local-repo-registration.ts | 25 +- src/shared/git-binary-compatibility.test.ts | 3 + ...tration-binary-compatibility.test-cases.ts | 60 +++++ tests/e2e/repo-registration-async.spec.ts | 231 ++++++++++++++++++ 5 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 src/shared/git-repository-registration-binary-compatibility.test-cases.ts create mode 100644 tests/e2e/repo-registration-async.spec.ts diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index ec18019c2869..1433a1e816cc 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -58,7 +58,7 @@ vi.mock('../git/repo', () => ({ searchBaseRefs: vi.fn().mockResolvedValue([]) })) -vi.mock('../repo-detection', () => ({ +vi.mock('../repo-icon-autodetect', () => ({ detectRepoIconAndUpstream: detectRepoIconAndUpstreamMock })) @@ -163,6 +163,45 @@ describe('repos:add with git worktrees', () => { expect(mockStore.addRepo).toHaveBeenCalledTimes(1) }) + it.each(['git', 'folder'] as const)( + 'deduplicates concurrent %s registrations after pending icon discovery', + async (kind) => { + const repos: Repo[] = [] + mockStore.getRepos.mockImplementation(() => repos) + mockStore.addRepo.mockImplementation((repo: Repo) => repos.push(repo)) + const releases: (() => void)[] = [] + detectRepoIconAndUpstreamMock.mockImplementation( + () => new Promise((resolve) => releases.push(() => resolve({}))) + ) + const first = callAdd({ path: MAIN_CHECKOUT, kind }) + const second = callAdd({ path: MAIN_CHECKOUT, kind }) + await vi.waitFor(() => expect(releases).toHaveLength(2)) + releases[0]() + const winner = await first + releases[1]() + expect(await second).toEqual(winner) + expect(repos).toHaveLength(1) + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + } + ) + + it('returns a main checkout added while linked-worktree icon discovery is pending', async () => { + let release: () => void = () => {} + detectRepoIconAndUpstreamMock.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve({}) + }) + ) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + const pending = callAdd({ path: LINKED_WORKTREE }) + await vi.waitFor(() => expect(detectRepoIconAndUpstreamMock).toHaveBeenCalled()) + mockStore.getRepos.mockReturnValue([trackedMainRepo()]) + release() + expect(await pending).toEqual({ repo: expect.objectContaining({ id: 'main-repo-id' }) }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('matches the tracked main checkout across path separator differences', async () => { mockStore.getRepos.mockReturnValue([ { ...trackedMainRepo(), path: 'C:\\Users\\dev\\projects\\orca' } as Repo diff --git a/src/main/ipc/repos/local-repo-registration.ts b/src/main/ipc/repos/local-repo-registration.ts index 6d0e03445b1c..9006f96a9c72 100644 --- a/src/main/ipc/repos/local-repo-registration.ts +++ b/src/main/ipc/repos/local-repo-registration.ts @@ -54,12 +54,12 @@ export async function addLocalRepoFromPath( // Why: a linked worktree reports itself as its own toplevel, so the path checks above can't see that // it belongs to an already-tracked repo. Adding it anyway yields a second "ready" host setup on the // same project and host — a duplicate run-target row that resolves to a transient worktree path. + const mainRepoRoot = gitInfo?.mainRepoPath + ? await getGitRepoRootAsync(gitInfo.mainRepoPath) + : null + const mainRepoKey = mainRepoRoot ? normalizeRuntimePathForComparison(mainRepoRoot) : null if (repoKind === 'git') { - const mainRepoRoot = gitInfo?.mainRepoPath - ? await getGitRepoRootAsync(gitInfo.mainRepoPath) - : null - if (mainRepoRoot) { - const mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) + if (mainRepoKey) { // Why !isFolderRepo: only a git-kind main checkout projects onto the same project as its // worktree, so matching a folder record would suppress the add without deduping anything. const trackedMainRepo = store @@ -81,6 +81,21 @@ export async function addLocalRepoFromPath( kind: repoKind, executionHostId: LOCAL_EXECUTION_HOST_ID }) + // Another registration can finish while icon discovery is pending. + const raceWinner = store.getRepos().find((repo) => { + if (repo.connectionId) { + return false + } + const repoPathKey = normalizeRuntimePathForComparison(repo.path) + return ( + repoPathKey === pathKey || + repoPathKey === resolvedPathKey || + (mainRepoKey !== null && !isFolderRepo(repo) && repoPathKey === mainRepoKey) + ) + }) + if (raceWinner) { + return { repo: raceWinner, alreadyExisted: true } + } const repo: Repo = { id: randomUUID(), path: resolvedPath, diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 139990cd5821..69fa1ec3e3a2 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -30,6 +30,7 @@ import { fastForwardLocalBaseBranch } from './worktree/local-base-branch-fast-fo import { gitChangeListArgs, parseGitChangeList } from './git-change-list' import { encodeGitPathspecs } from './git-pathspec-stdin' import { endSubprocessStdin } from './subprocess-stdin-write' +import { registerGitRepositoryRegistrationBinaryCompatibilityCase } from './git-repository-registration-binary-compatibility.test-cases' import { registerGitResolutionBinaryCompatibilityCases } from './git-resolution-binary-compatibility.test-cases' const execFileAsync = promisify(execFile) @@ -138,6 +139,8 @@ describeBinaryCompatibility('real Git binary compatibility', () => { } }) + registerGitRepositoryRegistrationBinaryCompatibilityCase(runGit, () => repoPath, Boolean(image)) + it('stages, unstages and restores NUL-delimited literal pathspecs from stdin', async () => { const fixturePath = join(repoPath, 'stdin-pathspec') await mkdir(fixturePath) diff --git a/src/shared/git-repository-registration-binary-compatibility.test-cases.ts b/src/shared/git-repository-registration-binary-compatibility.test-cases.ts new file mode 100644 index 000000000000..ff7811e6c46b --- /dev/null +++ b/src/shared/git-repository-registration-binary-compatibility.test-cases.ts @@ -0,0 +1,60 @@ +import { mkdir, rm } from 'node:fs/promises' +import { join, posix, resolve } from 'node:path' +import { expect, it } from 'vitest' + +export function registerGitRepositoryRegistrationBinaryCompatibilityCase( + runGit: (args: string[]) => Promise<{ stdout: string; stderr: string }>, + getRepoPath: () => string, + image: boolean +): void { + it('supports repository registration probes for nested, linked and bare repositories', async () => { + const repoPath = getRepoPath() + const executionRoot = image ? '/repo' : repoPath + const nested = join(repoPath, 'registration nested') + const linked = image ? '/repo/registration linked' : join(repoPath, 'registration linked') + const bare = image ? '/repo/registration bare' : join(repoPath, 'registration bare') + await mkdir(nested) + await runGit(['worktree', 'add', '-b', 'registration-linked', linked]) + await runGit(['init', '--bare', bare]) + try { + const root = (await runGit(['rev-parse', '--show-toplevel'])).stdout.trim() + const linkedRoot = ( + await runGit(['-C', linked, 'rev-parse', '--show-toplevel']) + ).stdout.trim() + for (const cwd of [executionRoot, image ? '/repo/registration nested' : nested, linked]) { + expect( + (await runGit(['-C', cwd, 'rev-parse', '--is-inside-work-tree', '--is-bare-repository'])) + .stdout + ).toBe('true\nfalse\n') + const records = ( + await runGit([ + '-C', + cwd, + 'rev-parse', + '--is-inside-work-tree', + '--show-toplevel', + '--git-dir', + '--git-common-dir' + ]) + ).stdout + .trim() + .split('\n') + expect(records).toHaveLength(4) + expect(records[0]).toBe('true') + expect(records[1]).toBe(cwd === linked ? linkedRoot : root) + const resolveDirectory = image ? posix.resolve : resolve + expect(resolveDirectory(cwd, records[2]) === resolveDirectory(cwd, records[3])).toBe( + cwd !== linked + ) + } + expect( + (await runGit(['-C', bare, 'rev-parse', '--is-inside-work-tree', '--is-bare-repository'])) + .stdout + ).toBe('false\ntrue\n') + } finally { + await runGit(['worktree', 'remove', '--force', linked]) + await rm(nested, { recursive: true, force: true }) + await rm(join(repoPath, 'registration bare'), { recursive: true, force: true }) + } + }) +} diff --git a/tests/e2e/repo-registration-async.spec.ts b/tests/e2e/repo-registration-async.spec.ts new file mode 100644 index 000000000000..e07dd3e7ef58 --- /dev/null +++ b/tests/e2e/repo-registration-async.spec.ts @@ -0,0 +1,231 @@ +import { mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog' +import { runProcess } from '../../src/shared/child-process/run-process' + +test.use({ seedTestRepo: false }) + +async function runFixtureGit(spec: Parameters[0]): Promise { + const result = await runProcess(spec) + expect(result.code, result.stderr).toBe(0) +} + +test('concurrent registration keeps one identity, including nested and linked paths', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}) => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-registration-'))) + const repoPath = join(root, 'repo') + const nestedPath = join(repoPath, 'nested') + const linkedPath = join(root, 'linked') + mkdirSync(nestedPath, { recursive: true }) + registerPostElectronShutdownCleanup(async () => { + const { rm } = await import('node:fs/promises') + await rm(root, { recursive: true, force: true }) + }) + await runFixtureGit({ program: 'git', args: ['init', repoPath] }) + await runFixtureGit({ + program: 'git', + args: [ + '-c', + 'user.name=E2E', + '-c', + 'user.email=e2e@test.local', + 'commit', + '--allow-empty', + '-m', + 'fixture' + ], + cwd: repoPath + }) + await runFixtureGit({ + program: 'git', + args: ['worktree', 'add', '-b', 'linked', linkedPath], + cwd: repoPath + }) + await waitForSessionReady(orcaPage) + const result = await orcaPage.evaluate( + async ({ repoPath, nestedPath }) => { + const results = await Promise.all( + Array.from({ length: 6 }, (_, index) => + window.api.repos.add({ path: index % 2 ? nestedPath : repoPath }) + ) + ) + if (results.some((result) => 'error' in result)) { + throw new Error(JSON.stringify(results)) + } + await window.__store!.getState().fetchRepos() + return { + ids: results.map((result) => ('repo' in result ? result.repo.id : null)), + count: window.__store!.getState().repos.length + } + }, + { repoPath, nestedPath } + ) + expect(new Set(result.ids).size).toBe(1) + expect(result.count).toBe(1) + const linked = await orcaPage.evaluate(async (path) => window.api.repos.add({ path }), linkedPath) + expect('repo' in linked && linked.repo.id).toBe(result.ids[0]) + const missing = await orcaPage.evaluate( + async (path) => window.api.repos.add({ path }), + join(root, 'missing') + ) + expect('error' in missing).toBe(true) +}) + +test('Add Project opens a folder and preserves its workspace when Git appears', async ({ + electronApp, + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + const folderPath = realpathSync(mkdtempSync(join(tmpdir(), 'orca-folder-upgrade-'))) + registerPostElectronShutdownCleanup(async () => { + const { rm } = await import('node:fs/promises') + await rm(folderPath, { recursive: true, force: true }) + }) + await waitForSessionReady(orcaPage) + await electronApp.evaluate(({ dialog }, path) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [path], bookmarks: [] }) + }, folderPath) + await openSidebarProjectDialog(orcaPage) + await orcaPage.getByRole('button', { name: /Browse folder/i }).click() + const dialog = orcaPage.getByRole('dialog', { name: 'Open as Folder' }) + await expect(dialog).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('open-folder.png') }) + await dialog.getByRole('button', { name: 'Open as Folder', exact: true }).click() + await expect + .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos[0]?.kind)) + .toBe('folder') + await expect + .poll(() => + orcaPage.evaluate(() => { + const state = window.__store!.getState() + return state.worktreesByRepo[state.repos[0].id]?.[0]?.id + }) + ) + .toBeTruthy() + const before = await orcaPage.evaluate(() => { + const state = window.__store!.getState() + const repo = state.repos[0] + return { repoId: repo.id, worktreeId: state.worktreesByRepo[repo.id]?.[0]?.id } + }) + await orcaPage.screenshot({ path: testInfo.outputPath('folder-workspace.png') }) + await runFixtureGit({ program: 'git', args: ['init', folderPath] }) + await expect + .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos[0]?.kind), { + timeout: 30_000 + }) + .toBe('git') + await expect + .poll( + () => + orcaPage.evaluate(() => { + const state = window.__store!.getState() + const repo = state.repos[0] + return { repoId: repo.id, worktreeId: state.worktreesByRepo[repo.id]?.[0]?.id } + }), + { timeout: 30_000 } + ) + .toEqual(before) + await orcaPage.screenshot({ path: testInfo.outputPath('git-workspace.png') }) +}) + +test('SSH registration reads the repository on its execution host', async ({ + orcaPage +}, testInfo) => { + test.skip(process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Requires the existing Docker SSH fixture') + test.setTimeout(180_000) + const { + startDockerSshRelayTarget, + cleanupDockerSshRelayTarget, + execDockerSshRelayTargetCommand, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH + } = await import('./helpers/docker-ssh-relay-target') + const { connectDockerSshRelayTarget, disconnectDockerSshRelayTarget } = + await import('./helpers/docker-ssh-relay-connection') + const target = startDockerSshRelayTarget(testInfo) + let targetId: string | null = null + try { + await waitForSessionReady(orcaPage) + const connected = await connectDockerSshRelayTarget(orcaPage, target, { seedInitialTab: false }) + targetId = connected.targetId + execDockerSshRelayTargetCommand(target, `mkdir -p ${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/nested`) + const nested = await orcaPage.evaluate( + async ({ connectionId, remotePath }) => + window.api.repos.addRemote({ connectionId, remotePath }), + { connectionId: targetId, remotePath: `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/nested` } + ) + expect('repo' in nested && nested.repo.id).toBe(connected.repoId) + expect('repo' in nested && nested.repo.connectionId).toBe(targetId) + expect('repo' in nested && nested.repo.executionHostId).toBe(`ssh:${targetId}`) + const missing = await orcaPage.evaluate( + async (connectionId) => + window.api.repos.addRemote({ connectionId, remotePath: '/tmp/or60risk-missing' }), + targetId + ) + expect('error' in missing).toBe(true) + await orcaPage.screenshot({ path: testInfo.outputPath('ssh-project.png') }) + } finally { + if (targetId) { + await disconnectDockerSshRelayTarget(orcaPage, targetId) + } + cleanupDockerSshRelayTarget(target) + } +}) + +test('real WSL registration preserves literal paths and fences login output', async ({ + orcaPage +}, testInfo) => { + const distro = process.env.ORCA_REAL_WSL_DISTRO + test.skip(process.platform !== 'win32' || !distro, 'Requires a real Windows WSL distro') + const { buildWslExecArgs, buildWslCapturedLoginShellCommand, quotePosixShell } = + await import('../../src/shared/wsl-login-shell-command') + const root = `/tmp/or60risk/run-${Date.now()}` + const repoPath = `${root}/repo-$literal space` + const uncPath = `\\\\wsl.localhost\\${distro}${repoPath.replaceAll('/', '\\')}` + const setup = await runProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, [ + '/bin/sh', + '-c', + `mkdir -p ${quotePosixShell(`${repoPath}/nested`)} && git init ${quotePosixShell(repoPath)}` + ]), + timeoutMs: 30_000 + }) + expect(setup.code, setup.stderr).toBe(0) + try { + const capture = buildWslCapturedLoginShellCommand( + `git -C ${quotePosixShell(repoPath)} rev-parse --is-inside-work-tree` + ) + const raw = await runProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, [ + '/bin/sh', + '-c', + `printf 'OR60_LOGIN_NOISE\\n'; ${capture.command}` + ]), + timeoutMs: 30_000 + }) + expect(raw.code, raw.stderr).toBe(0) + expect(raw.stdout).toContain('OR60_LOGIN_NOISE') + expect(capture.readStdout(raw.stdout)).toBe('true\n') + await waitForSessionReady(orcaPage) + const added = await orcaPage.evaluate( + async (path) => window.api.repos.add({ path }), + `${uncPath}\\nested` + ) + expect('repo' in added).toBe(true) + expect('repo' in added && added.repo.path).toBe(uncPath) + await orcaPage.screenshot({ path: testInfo.outputPath('wsl-project.png') }) + } finally { + const cleanup = await runProcess({ + program: 'wsl.exe', + args: buildWslExecArgs(distro, ['/bin/sh', '-c', `rm -rf ${quotePosixShell(root)}`]), + timeoutMs: 30_000 + }) + expect(cleanup.code, cleanup.stderr).toBe(0) + } +}) From 68126f22a6e29fd456ad0c98c4e5f04ea695932b Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 00:10:18 -0700 Subject: [PATCH 05/11] Check authoritative registration identity before renderer convergence --- tests/e2e/repo-registration-async.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/e2e/repo-registration-async.spec.ts b/tests/e2e/repo-registration-async.spec.ts index e07dd3e7ef58..0f6f3e3386e6 100644 --- a/tests/e2e/repo-registration-async.spec.ts +++ b/tests/e2e/repo-registration-async.spec.ts @@ -60,13 +60,17 @@ test('concurrent registration keeps one identity, including nested and linked pa await window.__store!.getState().fetchRepos() return { ids: results.map((result) => ('repo' in result ? result.repo.id : null)), - count: window.__store!.getState().repos.length + persistedIds: (await window.api.repos.list()).map((repo) => repo.id) } }, { repoPath, nestedPath } ) expect(new Set(result.ids).size).toBe(1) - expect(result.count).toBe(1) + expect(result.persistedIds).toEqual([result.ids[0]]) + // A repos:changed refresh can supersede the explicitly awaited catalog fetch. + await expect + .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos.map((repo) => repo.id))) + .toEqual(result.persistedIds) const linked = await orcaPage.evaluate(async (path) => window.api.repos.add({ path }), linkedPath) expect('repo' in linked && linked.repo.id).toBe(result.ids[0]) const missing = await orcaPage.evaluate( @@ -74,6 +78,9 @@ test('concurrent registration keeps one identity, including nested and linked pa join(root, 'missing') ) expect('error' in missing).toBe(true) + expect( + await orcaPage.evaluate(async () => (await window.api.repos.list()).map((repo) => repo.id)) + ).toEqual(result.persistedIds) }) test('Add Project opens a folder and preserves its workspace when Git appears', async ({ From 1310f074c822ab5aac46162b406fd018da5531c5 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 00:13:14 -0700 Subject: [PATCH 06/11] Narrow repository responsiveness to detection and registration --- src/main/git/repo-detection-async.test.ts | 3 - src/main/git/repo-detection.ts | 4 -- src/main/git/repo.ts | 1 - .../folder-repo-git-upgrade-lifetime.test.ts | 28 +------- src/main/ipc/folder-repo-git-upgrade.test.ts | 27 ++----- src/main/ipc/folder-repo-git-upgrade.ts | 23 ++---- .../ipc/repos-add-linked-worktree.test.ts | 39 ----------- src/main/ipc/repos/local-repo-registration.ts | 25 ++----- .../ipc/repos/nested-repo-import-handler.ts | 6 +- .../project-groups/nested-repo-discovery.ts | 5 +- .../runtime/runtime-nested-repo-import.ts | 4 +- tests/e2e/repo-registration-async.spec.ts | 70 ++----------------- 12 files changed, 30 insertions(+), 205 deletions(-) diff --git a/src/main/git/repo-detection-async.test.ts b/src/main/git/repo-detection-async.test.ts index 32f98ddbd7ab..af11f275cb74 100644 --- a/src/main/git/repo-detection-async.test.ts +++ b/src/main/git/repo-detection-async.test.ts @@ -7,8 +7,6 @@ import { runProcess } from '../../shared/child-process/run-process' import { getGitRepoRoot, getGitRepoRootAsync, - getLinkedWorktreeMainRepoRoot, - getLinkedWorktreeMainRepoRootAsync, inspectGitRepoForRegistration, inspectGitRepoForRegistrationAsync, isGitRepo, @@ -37,7 +35,6 @@ async function expectSameDetection(path: string): Promise { expect(await inspectGitRepoForRegistrationAsync(path)).toEqual( inspectGitRepoForRegistration(path) ) - expect(await getLinkedWorktreeMainRepoRootAsync(path)).toBe(getLinkedWorktreeMainRepoRoot(path)) } describe('asynchronous repository detection with real Git', () => { diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts index f5cf483f9768..9be70187a793 100644 --- a/src/main/git/repo-detection.ts +++ b/src/main/git/repo-detection.ts @@ -264,7 +264,3 @@ export function getGitRepoRootAsync(path: string): Promise { export function getLinkedWorktreeMainRepoRoot(path: string): string | null { return runGitRepoCommandsSync(getLinkedWorktreeMainRepoRootCommands(path)) } - -export function getLinkedWorktreeMainRepoRootAsync(path: string): Promise { - return runGitRepoCommands(getLinkedWorktreeMainRepoRootCommands(path)) -} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 7843fdba6412..f1bb563be62e 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -17,7 +17,6 @@ export { getGitRepoRoot, getGitRepoRootAsync, getLinkedWorktreeMainRepoRoot, - getLinkedWorktreeMainRepoRootAsync, normalizeGitRepoRootForInputPath } from './repo-detection' export { diff --git a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts index 5e0f063e6f7c..6d86ec820faf 100644 --- a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts @@ -16,7 +16,7 @@ const fake = vi.hoisted(() => ({ })) vi.mock('node:fs/promises', () => ({ stat: fake.stat })) vi.mock('node:fs', () => ({ realpathSync: (path: string) => path })) -vi.mock('../git/repo', () => ({ isGitRepoAsync: fake.git, getGitRepoRootAsync: fake.root })) +vi.mock('../git/repo', () => ({ isGitRepo: fake.git, getGitRepoRoot: fake.root })) vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: fake.prepare })) vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: fake.invalidate @@ -108,32 +108,6 @@ describe('folder repo upgrade poll lifetime', () => { expect(vi.getTimerCount()).toBe(0) }) - it.each(['stopped', 'removed', 'path changed', 'already upgraded'])( - 'refuses a stale folder upgrade when its owner is %s during Git discovery', - async (change) => { - const pending = deferred() - fake.git.mockReturnValue(pending.promise) - const repos = [folder()] - const { store } = begin(repos) - await vi.advanceTimersByTimeAsync(25) - expect(fake.git).toHaveBeenCalledTimes(1) - if (change === 'stopped') { - stopFolderRepoGitUpgradeWatch() - } else if (change === 'removed') { - repos.splice(0) - } else if (change === 'path changed') { - repos[0].path = join('mock', 'new-root') - } else { - repos[0].kind = 'git' - } - pending.resolve(true) - await flush() - expect(store.updateRepo).not.toHaveBeenCalled() - expect(fake.prepare).not.toHaveBeenCalled() - expect(fake.reposChanged).not.toHaveBeenCalled() - } - ) - it('stops the remaining folder scan when a pending marker fails after stop', async () => { const pending = deferred() fake.stat.mockReturnValue(pending.promise) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index ebc58b92b721..5fe485ee3a21 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -10,36 +10,22 @@ import type { Repo } from '../../shared/repo-types' // Why: the watch must stay one stat per folder project per tick — counting the real // calls is what keeps a directory-listing fan-out from creeping back in. -const { statCalls, readdirSpy, gitProbes, pendingGitChecks } = vi.hoisted(() => ({ +const { statCalls, readdirSpy, gitProbes } = vi.hoisted(() => ({ statCalls: [] as string[], readdirSpy: vi.fn(), - pendingGitChecks: new Set>(), // Why: the rejected-marker cache exists to stop git respawning every tick; counting the // real probes is the only way that guarantee stays true. gitProbes: [] as string[] })) -function trackGitCheck(pending: Promise): Promise { - const completed = pending.finally(() => pendingGitChecks.delete(completed)) - pendingGitChecks.add(completed) - return completed -} - -async function drainGitChecks(): Promise { - while (pendingGitChecks.size > 0) { - await Promise.allSettled(pendingGitChecks) - } -} - vi.mock('../git/repo', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - isGitRepoAsync: (path: string) => { + isGitRepo: (path: string) => { gitProbes.push(path) - return trackGitCheck(actual.isGitRepoAsync(path)) - }, - getGitRepoRootAsync: (path: string) => trackGitCheck(actual.getGitRepoRootAsync(path)) + return actual.isGitRepo(path) + } } }) @@ -157,14 +143,13 @@ describe('folder repo git upgrade watch', () => { vi.clearAllMocks() root = realpathSync(await mkdtemp(join(tmpdir(), 'folder-repo-upgrade-'))) symlinkedRoot = `${root}-link` - await symlink(root, symlinkedRoot, process.platform === 'win32' ? 'junction' : 'dir') + await symlink(root, symlinkedRoot, 'dir') statCalls.length = 0 gitProbes.length = 0 }) afterEach(async () => { stopFolderRepoGitUpgradeWatch() - await drainGitChecks() await rm(symlinkedRoot, { force: true }) await rm(root, { recursive: true, force: true }) }) @@ -172,7 +157,6 @@ describe('folder repo git upgrade watch', () => { // Real timers: the tick awaits real filesystem stats, which fake timers cannot flush. async function tick(times = 1): Promise { await new Promise((resolve) => setTimeout(resolve, POLL_MS * times + POLL_MS)) - await drainGitChecks() } /** Why: a tick that spawns git can outrun a fixed wait on a loaded machine. */ @@ -181,7 +165,6 @@ describe('folder repo git upgrade watch', () => { while (statCalls.length < count && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, POLL_MS)) } - await drainGitChecks() } it('upgrades a local folder repo once an external git init creates .git', async () => { diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index 724c80d0df88..8a0a6130c0e0 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -9,7 +9,7 @@ import { isFolderRepo } from '../../shared/repo-kind' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' -import { getGitRepoRootAsync, isGitRepoAsync } from '../git/repo' +import { getGitRepoRoot, isGitRepo } from '../git/repo' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' import { notifyReposChanged } from './repos/repos-changed-notification' @@ -101,13 +101,13 @@ function resolveRealPath(pathValue: string): string { * the path the user picked; when a symlinked parent makes those differ, the root reads * as an *external* worktree, and hiding those would hide the project's only workspace. */ -async function resolveUpgrade( +function resolveUpgrade( repoPath: string -): Promise<{ folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null> { - if (!(await isGitRepoAsync(repoPath))) { +): { folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null { + if (!isGitRepo(repoPath)) { return null } - const gitRoot = await getGitRepoRootAsync(repoPath) + const gitRoot = getGitRepoRoot(repoPath) if (resolveRealPath(gitRoot) !== resolveRealPath(repoPath)) { return null } @@ -127,18 +127,7 @@ async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise { expect(mockStore.addRepo).toHaveBeenCalledTimes(1) }) - it.each(['git', 'folder'] as const)( - 'deduplicates concurrent %s registrations after pending icon discovery', - async (kind) => { - const repos: Repo[] = [] - mockStore.getRepos.mockImplementation(() => repos) - mockStore.addRepo.mockImplementation((repo: Repo) => repos.push(repo)) - const releases: (() => void)[] = [] - detectRepoIconAndUpstreamMock.mockImplementation( - () => new Promise((resolve) => releases.push(() => resolve({}))) - ) - const first = callAdd({ path: MAIN_CHECKOUT, kind }) - const second = callAdd({ path: MAIN_CHECKOUT, kind }) - await vi.waitFor(() => expect(releases).toHaveLength(2)) - releases[0]() - const winner = await first - releases[1]() - expect(await second).toEqual(winner) - expect(repos).toHaveLength(1) - expect(mockStore.addRepo).toHaveBeenCalledTimes(1) - } - ) - - it('returns a main checkout added while linked-worktree icon discovery is pending', async () => { - let release: () => void = () => {} - detectRepoIconAndUpstreamMock.mockImplementation( - () => - new Promise((resolve) => { - release = () => resolve({}) - }) - ) - getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) - const pending = callAdd({ path: LINKED_WORKTREE }) - await vi.waitFor(() => expect(detectRepoIconAndUpstreamMock).toHaveBeenCalled()) - mockStore.getRepos.mockReturnValue([trackedMainRepo()]) - release() - expect(await pending).toEqual({ repo: expect.objectContaining({ id: 'main-repo-id' }) }) - expect(mockStore.addRepo).not.toHaveBeenCalled() - }) - it('matches the tracked main checkout across path separator differences', async () => { mockStore.getRepos.mockReturnValue([ { ...trackedMainRepo(), path: 'C:\\Users\\dev\\projects\\orca' } as Repo diff --git a/src/main/ipc/repos/local-repo-registration.ts b/src/main/ipc/repos/local-repo-registration.ts index 9006f96a9c72..6d0e03445b1c 100644 --- a/src/main/ipc/repos/local-repo-registration.ts +++ b/src/main/ipc/repos/local-repo-registration.ts @@ -54,12 +54,12 @@ export async function addLocalRepoFromPath( // Why: a linked worktree reports itself as its own toplevel, so the path checks above can't see that // it belongs to an already-tracked repo. Adding it anyway yields a second "ready" host setup on the // same project and host — a duplicate run-target row that resolves to a transient worktree path. - const mainRepoRoot = gitInfo?.mainRepoPath - ? await getGitRepoRootAsync(gitInfo.mainRepoPath) - : null - const mainRepoKey = mainRepoRoot ? normalizeRuntimePathForComparison(mainRepoRoot) : null if (repoKind === 'git') { - if (mainRepoKey) { + const mainRepoRoot = gitInfo?.mainRepoPath + ? await getGitRepoRootAsync(gitInfo.mainRepoPath) + : null + if (mainRepoRoot) { + const mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) // Why !isFolderRepo: only a git-kind main checkout projects onto the same project as its // worktree, so matching a folder record would suppress the add without deduping anything. const trackedMainRepo = store @@ -81,21 +81,6 @@ export async function addLocalRepoFromPath( kind: repoKind, executionHostId: LOCAL_EXECUTION_HOST_ID }) - // Another registration can finish while icon discovery is pending. - const raceWinner = store.getRepos().find((repo) => { - if (repo.connectionId) { - return false - } - const repoPathKey = normalizeRuntimePathForComparison(repo.path) - return ( - repoPathKey === pathKey || - repoPathKey === resolvedPathKey || - (mainRepoKey !== null && !isFolderRepo(repo) && repoPathKey === mainRepoKey) - ) - }) - if (raceWinner) { - return { repo: raceWinner, alreadyExisted: true } - } const repo: Repo = { id: randomUUID(), path: resolvedPath, diff --git a/src/main/ipc/repos/nested-repo-import-handler.ts b/src/main/ipc/repos/nested-repo-import-handler.ts index 1a6501edc631..90294ec464ad 100644 --- a/src/main/ipc/repos/nested-repo-import-handler.ts +++ b/src/main/ipc/repos/nested-repo-import-handler.ts @@ -7,7 +7,7 @@ import type { ProjectGroupImportResult } from '../../../shared/project-group-typ import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' -import { isGitRepoAsync, getRepoName } from '../../git/repo' +import { isGitRepo, getRepoName } from '../../git/repo' import { createNestedProjectGroupResolver, resolveNestedRepoSelection @@ -83,7 +83,7 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider) } else { await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) - if (!(await isGitRepoAsync(repoPath))) { + if (!isGitRepo(repoPath)) { results.push({ path: repoPath, status: 'failed', @@ -154,7 +154,7 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store } importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) - // Why: reaches here only after the isGitRepoAsync guard above confirmed a git repo, so always true. + // Why: reaches here only after the isGitRepo guard above confirmed a git repo, so always true. emitRepoAdded('folder_picker', false, true) } catch (error) { results.push({ diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index 84abbf74ae60..1f25a85a26f1 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -1,7 +1,7 @@ import { readFile, readdir, stat } from 'node:fs/promises' import { basename, join } from 'node:path' import type { NestedRepoCandidate, NestedRepoScanResult } from '../../shared/project-group-types' -import { isGitRepoAsync } from '../git/repo' +import { isGitRepo } from '../git/repo' import { isIgnoredNestedRepoDirectory, normalizeNestedRepoScanOptions, @@ -58,8 +58,7 @@ export async function scanNestedRepos(args: { joinPath: join, basename, hasGitMarker, - isSelectedPathGitRepo: async (path: string) => - (await isGitRepoAsync(path)) || (await hasGitMarker(path)) + isSelectedPathGitRepo: async (path: string) => isGitRepo(path) || (await hasGitMarker(path)) } const buildResult = (selectedPathKind: NestedRepoScanResult['selectedPathKind']) => ({ selectedPath: args.path, diff --git a/src/main/runtime/runtime-nested-repo-import.ts b/src/main/runtime/runtime-nested-repo-import.ts index 81ab6d99f001..ba2df920b519 100644 --- a/src/main/runtime/runtime-nested-repo-import.ts +++ b/src/main/runtime/runtime-nested-repo-import.ts @@ -9,7 +9,7 @@ import type { } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import { awaitWindowsHostGitEnvironmentReady } from '../git/runner' -import { getRepoName, isGitRepoAsync } from '../git/repo' +import { getRepoName, isGitRepo } from '../git/repo' import { scanNestedRepos } from '../project-groups/nested-repo-discovery' import { createNestedProjectGroupResolver, @@ -77,7 +77,7 @@ export class RuntimeNestedRepoImport { for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) - if (!(await isGitRepoAsync(repoPath))) { + if (!isGitRepo(repoPath)) { results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) continue } diff --git a/tests/e2e/repo-registration-async.spec.ts b/tests/e2e/repo-registration-async.spec.ts index 0f6f3e3386e6..4d31fb43529b 100644 --- a/tests/e2e/repo-registration-async.spec.ts +++ b/tests/e2e/repo-registration-async.spec.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' -import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog' import { runProcess } from '../../src/shared/child-process/run-process' test.use({ seedTestRepo: false }) @@ -13,7 +12,7 @@ async function runFixtureGit(spec: Parameters[0]): Promise { @@ -49,15 +48,15 @@ test('concurrent registration keeps one identity, including nested and linked pa await waitForSessionReady(orcaPage) const result = await orcaPage.evaluate( async ({ repoPath, nestedPath }) => { - const results = await Promise.all( - Array.from({ length: 6 }, (_, index) => - window.api.repos.add({ path: index % 2 ? nestedPath : repoPath }) - ) - ) + const results = [] + for (const path of [repoPath, nestedPath]) { + results.push(await window.api.repos.add({ path })) + } if (results.some((result) => 'error' in result)) { throw new Error(JSON.stringify(results)) } await window.__store!.getState().fetchRepos() + await window.__store!.getState().awaitLocalRepoCatalogSettlement() return { ids: results.map((result) => ('repo' in result ? result.repo.id : null)), persistedIds: (await window.api.repos.list()).map((repo) => repo.id) @@ -83,63 +82,6 @@ test('concurrent registration keeps one identity, including nested and linked pa ).toEqual(result.persistedIds) }) -test('Add Project opens a folder and preserves its workspace when Git appears', async ({ - electronApp, - orcaPage, - registerPostElectronShutdownCleanup -}, testInfo) => { - const folderPath = realpathSync(mkdtempSync(join(tmpdir(), 'orca-folder-upgrade-'))) - registerPostElectronShutdownCleanup(async () => { - const { rm } = await import('node:fs/promises') - await rm(folderPath, { recursive: true, force: true }) - }) - await waitForSessionReady(orcaPage) - await electronApp.evaluate(({ dialog }, path) => { - dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [path], bookmarks: [] }) - }, folderPath) - await openSidebarProjectDialog(orcaPage) - await orcaPage.getByRole('button', { name: /Browse folder/i }).click() - const dialog = orcaPage.getByRole('dialog', { name: 'Open as Folder' }) - await expect(dialog).toBeVisible() - await orcaPage.screenshot({ path: testInfo.outputPath('open-folder.png') }) - await dialog.getByRole('button', { name: 'Open as Folder', exact: true }).click() - await expect - .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos[0]?.kind)) - .toBe('folder') - await expect - .poll(() => - orcaPage.evaluate(() => { - const state = window.__store!.getState() - return state.worktreesByRepo[state.repos[0].id]?.[0]?.id - }) - ) - .toBeTruthy() - const before = await orcaPage.evaluate(() => { - const state = window.__store!.getState() - const repo = state.repos[0] - return { repoId: repo.id, worktreeId: state.worktreesByRepo[repo.id]?.[0]?.id } - }) - await orcaPage.screenshot({ path: testInfo.outputPath('folder-workspace.png') }) - await runFixtureGit({ program: 'git', args: ['init', folderPath] }) - await expect - .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos[0]?.kind), { - timeout: 30_000 - }) - .toBe('git') - await expect - .poll( - () => - orcaPage.evaluate(() => { - const state = window.__store!.getState() - const repo = state.repos[0] - return { repoId: repo.id, worktreeId: state.worktreesByRepo[repo.id]?.[0]?.id } - }), - { timeout: 30_000 } - ) - .toEqual(before) - await orcaPage.screenshot({ path: testInfo.outputPath('git-workspace.png') }) -}) - test('SSH registration reads the repository on its execution host', async ({ orcaPage }, testInfo) => { From b4ce9df98f1f784e9fd79e5f0574b1eeed3c7c89 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 00:16:03 -0700 Subject: [PATCH 07/11] Keep one local project identity after concurrent registration --- .../ipc/repos-add-linked-worktree.test.ts | 41 ++++++++- src/main/ipc/repos/local-repo-registration.ts | 18 +++- .../e2e/repo-registration-concurrency.spec.ts | 84 +++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/repo-registration-concurrency.spec.ts diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index 6b191c5c0a89..b6ad18063b1c 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -56,7 +56,7 @@ vi.mock('../git/repo', () => ({ searchBaseRefs: vi.fn().mockResolvedValue([]) })) -vi.mock('../repo-detection', () => ({ +vi.mock('../repo-icon-autodetect', () => ({ detectRepoIconAndUpstream: detectRepoIconAndUpstreamMock })) @@ -161,6 +161,45 @@ describe('repos:add with git worktrees', () => { expect(mockStore.addRepo).toHaveBeenCalledTimes(1) }) + it.each(['git', 'folder'] as const)( + 'deduplicates concurrent %s registrations after pending icon discovery', + async (kind) => { + const repos: Repo[] = [] + mockStore.getRepos.mockImplementation(() => repos) + mockStore.addRepo.mockImplementation((repo: Repo) => repos.push(repo)) + const releases: (() => void)[] = [] + detectRepoIconAndUpstreamMock.mockImplementation( + () => new Promise((resolve) => releases.push(() => resolve({}))) + ) + const first = callAdd({ path: MAIN_CHECKOUT, kind }) + const second = callAdd({ path: MAIN_CHECKOUT, kind }) + await vi.waitFor(() => expect(releases).toHaveLength(2)) + releases[0]() + const winner = await first + releases[1]() + expect(await second).toEqual(winner) + expect(repos).toHaveLength(1) + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + } + ) + + it('returns a main checkout added while linked-worktree icon discovery is pending', async () => { + let release: () => void = () => {} + detectRepoIconAndUpstreamMock.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve({}) + }) + ) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + const pending = callAdd({ path: LINKED_WORKTREE }) + await vi.waitFor(() => expect(detectRepoIconAndUpstreamMock).toHaveBeenCalled()) + mockStore.getRepos.mockReturnValue([trackedMainRepo()]) + release() + expect(await pending).toEqual({ repo: expect.objectContaining({ id: 'main-repo-id' }) }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('matches the tracked main checkout across path separator differences', async () => { mockStore.getRepos.mockReturnValue([ { ...trackedMainRepo(), path: 'C:\\Users\\dev\\projects\\orca' } as Repo diff --git a/src/main/ipc/repos/local-repo-registration.ts b/src/main/ipc/repos/local-repo-registration.ts index 8a3c2864bb54..43fec4637cd1 100644 --- a/src/main/ipc/repos/local-repo-registration.ts +++ b/src/main/ipc/repos/local-repo-registration.ts @@ -50,10 +50,11 @@ export async function addLocalRepoFromPath( // Why: a linked worktree reports itself as its own toplevel, so the path checks above can't see that // it belongs to an already-tracked repo. Adding it anyway yields a second "ready" host setup on the // same project and host — a duplicate run-target row that resolves to a transient worktree path. + let mainRepoKey: string | null = null if (repoKind === 'git') { const mainRepoRoot = gitInfo?.mainRepoPath ? getGitRepoRoot(gitInfo.mainRepoPath) : null if (mainRepoRoot) { - const mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) + mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) // Why !isFolderRepo: only a git-kind main checkout projects onto the same project as its // worktree, so matching a folder record would suppress the add without deduping anything. const trackedMainRepo = store @@ -75,6 +76,21 @@ export async function addLocalRepoFromPath( kind: repoKind, executionHostId: LOCAL_EXECUTION_HOST_ID }) + // Another registration can finish while icon discovery is pending. + const raceWinner = store.getRepos().find((repo) => { + if (repo.connectionId) { + return false + } + const repoPathKey = normalizeRuntimePathForComparison(repo.path) + return ( + repoPathKey === pathKey || + repoPathKey === resolvedPathKey || + (mainRepoKey !== null && !isFolderRepo(repo) && repoPathKey === mainRepoKey) + ) + }) + if (raceWinner) { + return { repo: raceWinner, alreadyExisted: true } + } const repo: Repo = { id: randomUUID(), path: resolvedPath, diff --git a/tests/e2e/repo-registration-concurrency.spec.ts b/tests/e2e/repo-registration-concurrency.spec.ts new file mode 100644 index 000000000000..e3a814f3f449 --- /dev/null +++ b/tests/e2e/repo-registration-concurrency.spec.ts @@ -0,0 +1,84 @@ +import { mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { runProcess } from '../../src/shared/child-process/run-process' + +test.use({ seedTestRepo: false }) + +async function runFixtureGit(spec: Parameters[0]): Promise { + const result = await runProcess(spec) + expect(result.code, result.stderr).toBe(0) +} + +test('concurrent registration keeps one identity, including nested and linked paths', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}) => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-registration-'))) + const repoPath = join(root, 'repo') + const nestedPath = join(repoPath, 'nested') + const linkedPath = join(root, 'linked') + mkdirSync(nestedPath, { recursive: true }) + registerPostElectronShutdownCleanup(async () => { + const { rm } = await import('node:fs/promises') + await rm(root, { recursive: true, force: true }) + }) + await runFixtureGit({ program: 'git', args: ['init', repoPath] }) + await runFixtureGit({ + program: 'git', + args: [ + '-c', + 'user.name=E2E', + '-c', + 'user.email=e2e@test.local', + 'commit', + '--allow-empty', + '-m', + 'fixture' + ], + cwd: repoPath + }) + await runFixtureGit({ + program: 'git', + args: ['worktree', 'add', '-b', 'linked', linkedPath], + cwd: repoPath + }) + await waitForSessionReady(orcaPage) + const result = await orcaPage.evaluate( + async ({ repoPath, nestedPath }) => { + const results = await Promise.all( + Array.from({ length: 6 }, (_, index) => + window.api.repos.add({ path: index % 2 ? nestedPath : repoPath }) + ) + ) + if (results.some((result) => 'error' in result)) { + throw new Error(JSON.stringify(results)) + } + await window.__store!.getState().fetchRepos() + await window.__store!.getState().awaitLocalRepoCatalogSettlement() + return { + ids: results.map((result) => ('repo' in result ? result.repo.id : null)), + persistedIds: (await window.api.repos.list()).map((repo) => repo.id) + } + }, + { repoPath, nestedPath } + ) + expect(new Set(result.ids).size).toBe(1) + expect(result.persistedIds).toEqual([result.ids[0]]) + // A repos:changed refresh can supersede the explicitly awaited catalog fetch. + await expect + .poll(() => orcaPage.evaluate(() => window.__store!.getState().repos.map((repo) => repo.id))) + .toEqual(result.persistedIds) + const linked = await orcaPage.evaluate(async (path) => window.api.repos.add({ path }), linkedPath) + expect('repo' in linked && linked.repo.id).toBe(result.ids[0]) + const missing = await orcaPage.evaluate( + async (path) => window.api.repos.add({ path }), + join(root, 'missing') + ) + expect('error' in missing).toBe(true) + expect( + await orcaPage.evaluate(async () => (await window.api.repos.list()).map((repo) => repo.id)) + ).toEqual(result.persistedIds) +}) From 73ec1010a64e015011973583229e5db73f2640ca Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 00:17:25 -0700 Subject: [PATCH 08/11] Run registration SSH coverage in the existing Docker lane --- config/scripts/run-ssh-docker-e2e.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index 96727e837abb..1a67f56cf10c 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -54,6 +54,7 @@ const result = spawnSync( 'playwright', 'test', 'tests/e2e/local-ssh-browser-routing.spec.ts', + 'tests/e2e/repo-registration-async.spec.ts', 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts', 'tests/e2e/pty-input-write-queue-ssh.spec.ts', 'tests/e2e/ssh-ai-vault-session-history.spec.ts', From e7f6a9f7c9bb10bb4484b64e3443234eebe59c24 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 01:18:36 -0700 Subject: [PATCH 09/11] test(git): cover classifier failure stages and literal path identity --- src/main/git/repo-detection-async.test.ts | 53 +++++++++++++++++- src/main/git/repo-detection-command.test.ts | 40 ++++++++++++++ .../git/repo-detection-failure-parity.test.ts | 55 +++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/main/git/repo-detection-failure-parity.test.ts diff --git a/src/main/git/repo-detection-async.test.ts b/src/main/git/repo-detection-async.test.ts index af11f275cb74..f84eb980a958 100644 --- a/src/main/git/repo-detection-async.test.ts +++ b/src/main/git/repo-detection-async.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -88,4 +88,55 @@ describe('asynchronous repository detection with real Git', () => { expect(await isGitRepoAsync(path)).toBe(false) } }) + + it.skipIf(process.platform === 'win32')( + 'preserves newlines, dollar signs and trailing spaces in roots and linked identity', + async () => { + const repo = join(fixture, 'repo\nwith dollar$ and trailing space ') + const nested = join(repo, 'nested\n') + mkdirSync(nested, { recursive: true }) + await git(repo, ['init', '--quiet']) + await git(repo, [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca@example.invalid', + 'commit', + '--allow-empty', + '--quiet', + '-m', + 'fixture' + ]) + const linked = join(fixture, 'linked\n ') + await git(repo, ['worktree', 'add', '--quiet', '-b', 'edge-linked', linked]) + for (const path of [repo, nested, linked]) { + await expectSameDetection(path) + } + expect((await inspectGitRepoForRegistrationAsync(nested)).rootPath).toBe(realpathSync(repo)) + expect((await inspectGitRepoForRegistrationAsync(linked)).mainRepoPath).toBe( + realpathSync(repo) + ) + } + ) + + it('preserves symlink target identity and rejects its invalid nested marker without Git', async () => { + const repo = join(fixture, 'target') + const nested = join(repo, 'nested') + mkdirSync(nested, { recursive: true }) + await git(repo, ['init', '--quiet']) + const alias = join(fixture, 'alias') + symlinkSync(nested, alias, process.platform === 'win32' ? 'junction' : 'dir') + await expectSameDetection(alias) + vi.spyOn(runner, 'gitExecFileSync').mockImplementation(() => { + throw new Error('Git unavailable') + }) + vi.spyOn(runner, 'gitExecFileAsync').mockRejectedValue(new Error('Git unavailable')) + await expectSameDetection(alias) + expect((await inspectGitRepoForRegistrationAsync(alias)).rootPath).toBe( + realpathSync(repo).replaceAll('\\', '/') + ) + writeFileSync(join(nested, '.git'), 'gitdir: missing-metadata') + await expectSameDetection(alias) + expect(await isGitRepoAsync(alias)).toBe(false) + }) }) diff --git a/src/main/git/repo-detection-command.test.ts b/src/main/git/repo-detection-command.test.ts index fd1502e0d69f..e3e1120feed0 100644 --- a/src/main/git/repo-detection-command.test.ts +++ b/src/main/git/repo-detection-command.test.ts @@ -47,4 +47,44 @@ describe('repository command execution', () => { expect(await runGitRepoCommands(withFallback())).toBe('marker fallback') expect(runGitRepoCommandsSync(withFallback())).toBe('marker fallback') }) + + it('preserves an exception from decision logic after a successful command', async () => { + const error = new Error('decision failed') + function* decisionFailure() { + yield* gitRepoOutput(['first'], { cwd: '/repo' }) + throw error + } + vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: 'result', stderr: '' }) + vi.mocked(gitExecFileSync).mockReturnValue('result') + await expect(runGitRepoCommands(decisionFailure())).rejects.toBe(error) + expect(() => runGitRepoCommandsSync(decisionFailure())).toThrow(error) + }) + + it('executes a fallback command yielded while handling a rejected command', async () => { + function* withFallbackCommand() { + try { + return yield* gitRepoOutput(['first'], { cwd: '/repo' }) + } catch { + return yield* gitRepoOutput(['second'], { cwd: '/repo' }) + } + } + vi.mocked(gitExecFileAsync) + .mockRejectedValueOnce(new Error('first failed')) + .mockResolvedValueOnce({ stdout: 'fallback', stderr: '' }) + vi.mocked(gitExecFileSync) + .mockImplementationOnce(() => { + throw new Error('first failed') + }) + .mockReturnValueOnce('fallback') + expect(await runGitRepoCommands(withFallbackCommand())).toBe('fallback') + expect(runGitRepoCommandsSync(withFallbackCommand())).toBe('fallback') + expect(vi.mocked(gitExecFileAsync).mock.calls.map(([args]) => args)).toEqual([ + ['first'], + ['second'] + ]) + expect(vi.mocked(gitExecFileSync).mock.calls.map(([args]) => args)).toEqual([ + ['first'], + ['second'] + ]) + }) }) diff --git a/src/main/git/repo-detection-failure-parity.test.ts b/src/main/git/repo-detection-failure-parity.test.ts new file mode 100644 index 000000000000..38c69253a9d0 --- /dev/null +++ b/src/main/git/repo-detection-failure-parity.test.ts @@ -0,0 +1,55 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import * as runner from './runner' +import { inspectGitRepoForRegistration, inspectGitRepoForRegistrationAsync } from './repo-detection' + +let fixture: string +beforeEach(() => { + fixture = mkdtempSync(join(tmpdir(), 'orca-repo-failure-parity-')) + mkdirSync(join(fixture, '.git', 'objects'), { recursive: true }) + mkdirSync(join(fixture, '.git', 'refs'), { recursive: true }) + writeFileSync(join(fixture, '.git', 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(join(fixture, '.git', 'config'), '[core]\n bare = false\n') +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(fixture, { recursive: true, force: true }) +}) + +it.each([0, 1, 2, 3, 4])( + 'preserves registration fallback and command order when command %i fails', + async (failAt) => { + // Ambiguous combined output requires separate root, Git directory and common directory reads. + const outputs = [ + 'true\nfalse\n', + `true\n${fixture}\nextra\n.git\n.git\n`, + `${fixture}\n`, + '.git\n', + '.git\n' + ] + const sync = vi.spyOn(runner, 'gitExecFileSync') + const async = vi.spyOn(runner, 'gitExecFileAsync') + let syncIndex = 0 + let asyncIndex = 0 + sync.mockImplementation(() => { + const index = syncIndex++ + if (index === failAt) { + throw new Error('rejected stage') + } + return outputs[index] ?? '' + }) + async.mockImplementation(async () => { + const index = asyncIndex++ + if (index === failAt) { + throw new Error('rejected stage') + } + return { stdout: outputs[index] ?? '', stderr: '' } + }) + const expected = inspectGitRepoForRegistration(fixture) + expect(expected).toEqual({ isRepo: true, rootPath: fixture, mainRepoPath: null }) + expect(await inspectGitRepoForRegistrationAsync(fixture)).toEqual(expected) + expect(async.mock.calls.map(([args]) => args)).toEqual(sync.mock.calls.map(([args]) => args)) + } +) From 97c4a99d2b9e21cb847b07ffd41e29d68468d14b Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 01:20:26 -0700 Subject: [PATCH 10/11] test(git): use the current process host package --- src/main/git/repo-detection-async.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/git/repo-detection-async.test.ts b/src/main/git/repo-detection-async.test.ts index f84eb980a958..19c89216d10c 100644 --- a/src/main/git/repo-detection-async.test.ts +++ b/src/main/git/repo-detection-async.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as runner from './runner' -import { runProcess } from '../../shared/child-process/run-process' +import { runProcess } from '@orca/process-host' import { getGitRepoRoot, getGitRepoRootAsync, From 1fdc56bf558cd5bb9156124ae02e9e536f31f814 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 01:23:29 -0700 Subject: [PATCH 11/11] test(repos): use current process host in registration fixture --- tests/e2e/repo-registration-async.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/repo-registration-async.spec.ts b/tests/e2e/repo-registration-async.spec.ts index 4d31fb43529b..01fa0bf276bf 100644 --- a/tests/e2e/repo-registration-async.spec.ts +++ b/tests/e2e/repo-registration-async.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' -import { runProcess } from '../../src/shared/child-process/run-process' +import { runProcess } from '@orca/process-host' test.use({ seedTestRepo: false })