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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/scripts/run-ssh-docker-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
142 changes: 142 additions & 0 deletions src/main/git/repo-detection-async.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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'
import * as runner from './runner'
import { runProcess } from '@orca/process-host'
import {
getGitRepoRoot,
getGitRepoRootAsync,
inspectGitRepoForRegistration,
inspectGitRepoForRegistrationAsync,
isGitRepo,
isGitRepoAsync
} from './repo-detection'

let fixture: string
async function git(cwd: string, args: string[]): Promise<void> {
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<void> {
expect(await isGitRepoAsync(path)).toBe(isGitRepo(path))
expect(await getGitRepoRootAsync(path)).toBe(getGitRepoRoot(path))
expect(await inspectGitRepoForRegistrationAsync(path)).toEqual(
inspectGitRepoForRegistration(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)
}
})

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)
})
})
90 changes: 90 additions & 0 deletions src/main/git/repo-detection-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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<void>((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')
})

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']
])
})
})
39 changes: 39 additions & 0 deletions src/main/git/repo-detection-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { gitExecFileAsync, gitExecFileSync } from './runner'

type GitRepoCommand = { args: string[]; cwd: string }
export type GitRepoCommands<T> = Generator<GitRepoCommand, T, string>

export function* gitRepoOutput(args: string[], options: { cwd: string }): GitRepoCommands<string> {
return yield { args, cwd: options.cwd }
}

// One decision tree keeps Git failures and marker fallbacks identical across both callers.
export async function runGitRepoCommands<T>(commands: GitRepoCommands<T>): Promise<T> {
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<T>(commands: GitRepoCommands<T>): 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
}
55 changes: 55 additions & 0 deletions src/main/git/repo-detection-failure-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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))
}
)
4 changes: 2 additions & 2 deletions src/main/git/repo-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe('isGitRepo', () => {
git(realRepo, ['init', '--quiet'])

withGitUnavailable(() => {
expect(getGitRepoRoot(nestedDir)).toBe(realRepo)
expect(getGitRepoRoot(nestedDir)).toBe(realRepo.replaceAll('\\', '/'))
})
})

Expand Down Expand Up @@ -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('\\', '/'))
})
})

Expand Down
Loading
Loading