Files
orca/src/main/providers/ssh-git-provider.test.ts
T

761 lines
24 KiB
TypeScript

/* eslint-disable max-lines -- Why: this suite covers the SSH git provider's one-RPC-per-method contract; splitting it would duplicate the shared mux fixture. */
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshGitProvider } from './ssh-git-provider'
type MockMultiplexer = {
request: ReturnType<typeof vi.fn>
notify: ReturnType<typeof vi.fn>
onNotification: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
isDisposed: ReturnType<typeof vi.fn>
}
function createMockMux(): MockMultiplexer {
return {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
}
async function waitForRequestCount(mock: ReturnType<typeof vi.fn>, count: number): Promise<void> {
for (let i = 0; i < 20; i++) {
if (mock.mock.calls.length >= count) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
}
describe('SshGitProvider', () => {
let mux: MockMultiplexer
let provider: SshGitProvider
beforeEach(() => {
mux = createMockMux()
provider = new SshGitProvider('conn-1', mux as never)
})
it('returns the connectionId', () => {
expect(provider.getConnectionId()).toBe('conn-1')
})
it('getStatus sends git.status request', async () => {
const statusResult = { entries: [], conflictOperation: 'unknown' }
mux.request.mockResolvedValue(statusResult)
const result = await provider.getStatus('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('git.status', { worktreePath: '/home/user/repo' })
expect(result).toEqual(statusResult)
})
it('getStatus forwards includeIgnored only when requested', async () => {
const statusResult = { entries: [], conflictOperation: 'unknown', ignoredPaths: ['dist/'] }
mux.request.mockResolvedValue(statusResult)
await provider.getStatus('/home/user/repo', { includeIgnored: true })
await provider.getStatus('/home/user/repo', { includeIgnored: false })
expect(mux.request).toHaveBeenNthCalledWith(1, 'git.status', {
worktreePath: '/home/user/repo',
includeIgnored: true
})
expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', {
worktreePath: '/home/user/repo'
})
})
it('checkIgnoredPaths sends git.checkIgnored request', async () => {
mux.request.mockResolvedValue(['dist/bundle.js'])
const result = await provider.checkIgnoredPaths('/home/user/repo', ['dist/bundle.js'])
expect(mux.request).toHaveBeenCalledWith('git.checkIgnored', {
worktreePath: '/home/user/repo',
paths: ['dist/bundle.js']
})
expect(result).toEqual(['dist/bundle.js'])
})
it('getHistory sends git.history request', async () => {
const historyResult = {
items: [],
hasIncomingChanges: false,
hasOutgoingChanges: false,
hasMore: false,
limit: 50
}
mux.request.mockResolvedValue(historyResult)
const result = await provider.getHistory('/home/user/repo', {
limit: 25,
baseRef: 'origin/main'
})
expect(mux.request).toHaveBeenCalledWith('git.history', {
worktreePath: '/home/user/repo',
limit: 25,
baseRef: 'origin/main'
})
expect(result).toEqual(historyResult)
})
it('commit sends git.commit request', async () => {
const commitResult = { success: true }
mux.request.mockResolvedValue(commitResult)
const result = await provider.commit('/home/user/repo', 'feat: add source control commit')
expect(mux.request).toHaveBeenCalledWith('git.commit', {
worktreePath: '/home/user/repo',
message: 'feat: add source control commit'
})
expect(result).toEqual(commitResult)
})
it('execNonInteractive delegates fixed binary commands to the relay', async () => {
const execResult = {
stdout: '10.0.0\n',
stderr: '',
exitCode: 0,
timedOut: false
}
mux.request.mockResolvedValue(execResult)
const result = await provider.execNonInteractive('pnpm', ['--version'], '/home/user/repo', 8000)
expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', {
binary: 'pnpm',
args: ['--version'],
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 8000
})
expect(result).toEqual(execResult)
})
it('execNonInteractive forwards environment variables to the relay', async () => {
const execResult = {
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false
}
mux.request.mockResolvedValue(execResult)
await provider.execNonInteractive(
'/bin/bash',
['-lc', 'echo "$ORCA_WORKTREE_PATH"'],
'/home/user/repo',
120_000,
undefined,
{
ORCA_ROOT_PATH: '/home/user/repo',
ORCA_WORKTREE_PATH: '/home/user/repo-feature'
}
)
expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', {
binary: '/bin/bash',
args: ['-lc', 'echo "$ORCA_WORKTREE_PATH"'],
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 120_000,
env: {
ORCA_ROOT_PATH: '/home/user/repo',
ORCA_WORKTREE_PATH: '/home/user/repo-feature'
}
})
})
it('cancelNonInteractiveExec sends best-effort relay cancellation', async () => {
await provider.cancelNonInteractiveExec('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' })
})
it('getStagedCommitContext reads branch, staged summary, and staged patch remotely', async () => {
mux.request.mockImplementation(async (method, payload) => {
expect(method).toBe('git.exec')
if (payload.args[1] === '--show-current') {
return { stdout: 'feature/ai-commit\n' }
}
if (payload.args[2] === '--name-status') {
return { stdout: 'M\tREADME.md\n' }
}
if (payload.args[2] === '--patch') {
return { stdout: 'diff --git a/README.md b/README.md\n+hello' }
}
throw new Error(`unexpected args: ${payload.args.join(' ')}`)
})
const result = await provider.getStagedCommitContext('/home/user/repo')
expect(result).toEqual({
branch: 'feature/ai-commit',
stagedSummary: 'M\tREADME.md',
stagedPatch: 'diff --git a/README.md b/README.md\n+hello'
})
expect(mux.request).toHaveBeenCalledWith('git.exec', {
args: ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'],
cwd: '/home/user/repo'
})
})
it('getStagedCommitContext returns null when nothing is staged', async () => {
mux.request.mockImplementation(async (_method, payload) => {
if (payload.args[1] === '--show-current') {
return { stdout: 'main\n' }
}
return { stdout: '' }
})
await expect(provider.getStagedCommitContext('/home/user/repo')).resolves.toBeNull()
expect(mux.request).toHaveBeenCalledTimes(2)
})
it('executeCommitMessagePlan delegates the prepared plan to the relay', async () => {
const execResult = {
stdout: 'Update docs',
stderr: '',
exitCode: 0,
timedOut: false
}
mux.request.mockResolvedValue(execResult)
const result = await provider.executeCommitMessagePlan(
{
binary: 'codex',
args: ['exec', 'PROMPT'],
stdinPayload: null,
label: 'Codex'
},
'/home/user/repo',
60_000
)
expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', {
binary: 'codex',
args: ['exec', 'PROMPT'],
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 60_000,
operation: 'commit-message'
})
expect(result).toEqual(execResult)
})
it('keeps SSH commit-message and pull-request execution lanes separate', async () => {
const completeRequests: (() => void)[] = []
mux.request.mockImplementation((method) => {
if (method === 'agent.cancelExec') {
return Promise.resolve({ canceled: true })
}
return new Promise((resolve) => {
completeRequests.push(() =>
resolve({
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false
})
)
})
})
const plan = {
binary: 'codex',
args: ['exec', 'PROMPT'],
stdinPayload: null,
label: 'Codex'
}
const commit = provider.executeCommitMessagePlan(plan, '/home/user/repo', 60_000)
const pullRequest = provider.executeCommitMessagePlan(
plan,
'/home/user/repo',
60_000,
'pull-request-fields'
)
await waitForRequestCount(mux.request, 2)
expect(mux.request).toHaveBeenNthCalledWith(1, 'agent.execNonInteractive', {
binary: 'codex',
args: ['exec', 'PROMPT'],
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 60_000,
operation: 'commit-message'
})
expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', {
binary: 'codex',
args: ['exec', 'PROMPT'],
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 60_000,
operation: 'pull-request-fields'
})
await provider.cancelGenerateCommitMessage('/home/user/repo')
await provider.cancelGenerateCommitMessage('/home/user/repo', 'pull-request-fields')
expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.cancelExec', {
cwd: '/home/user/repo',
operation: 'commit-message'
})
expect(mux.request).toHaveBeenNthCalledWith(4, 'agent.cancelExec', {
cwd: '/home/user/repo',
operation: 'pull-request-fields'
})
completeRequests.shift()?.()
completeRequests.shift()?.()
await Promise.all([commit, pullRequest])
})
it('serializes non-interactive relay execs for the same cwd and operation', async () => {
const completeRequests: (() => void)[] = []
mux.request.mockImplementation(
() =>
new Promise((resolve) => {
completeRequests.push(() =>
resolve({
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false
})
)
})
)
const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000)
const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000)
await waitForRequestCount(mux.request, 1)
expect(mux.request).toHaveBeenCalledTimes(1)
completeRequests.shift()?.()
await first
await waitForRequestCount(mux.request, 2)
expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.execNonInteractive', {
binary: 'pnpm',
args: ['install'],
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 8000
})
completeRequests.shift()?.()
await second
})
it('cancels a queued non-interactive exec without canceling the active relay child', async () => {
const completeRequests: (() => void)[] = []
mux.request.mockImplementation(
() =>
new Promise((resolve) => {
completeRequests.push(() =>
resolve({
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false
})
)
})
)
const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000)
const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000)
await waitForRequestCount(mux.request, 1)
await provider.cancelNonInteractiveExec('/home/user/repo')
expect(mux.request).toHaveBeenCalledTimes(1)
expect(mux.request).not.toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' })
completeRequests.shift()?.()
await first
const secondResult = await second
expect(mux.request).toHaveBeenCalledTimes(1)
expect(secondResult).toMatchObject({ canceled: true })
})
it('uses an exec abort signal to cancel the matching active relay child with queued work present', async () => {
const completeRequests: (() => void)[] = []
mux.request.mockImplementation((method) => {
if (method === 'agent.cancelExec') {
return Promise.resolve({ canceled: true })
}
return new Promise((resolve) => {
completeRequests.push(() =>
resolve({
stdout: '',
stderr: '',
exitCode: 0,
timedOut: false
})
)
})
})
const controller = new AbortController()
const first = provider.execNonInteractive(
'pnpm',
['store', 'prune'],
'/home/user/repo',
8000,
controller.signal
)
const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000)
await waitForRequestCount(mux.request, 1)
controller.abort()
await waitForRequestCount(mux.request, 2)
expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.cancelExec', { cwd: '/home/user/repo' })
completeRequests.shift()?.()
await first
await waitForRequestCount(mux.request, 3)
expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.execNonInteractive', {
binary: 'pnpm',
args: ['install'],
cwd: '/home/user/repo',
stdin: null,
timeoutMs: 8000
})
completeRequests.shift()?.()
await second
})
it('cancelGenerateCommitMessage sends best-effort relay cancellation', async () => {
await provider.cancelGenerateCommitMessage('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', {
cwd: '/home/user/repo',
operation: 'commit-message'
})
})
it('getDiff sends git.diff request', async () => {
const diffResult = { kind: 'text', originalContent: '', modifiedContent: 'hello' }
mux.request.mockResolvedValue(diffResult)
const result = await provider.getDiff('/home/user/repo', 'src/index.ts', true)
expect(mux.request).toHaveBeenCalledWith('git.diff', {
worktreePath: '/home/user/repo',
filePath: 'src/index.ts',
staged: true
})
expect(result).toEqual(diffResult)
})
it('stageFile sends git.stage request', async () => {
await provider.stageFile('/home/user/repo', 'src/file.ts')
expect(mux.request).toHaveBeenCalledWith('git.stage', {
worktreePath: '/home/user/repo',
filePath: 'src/file.ts'
})
})
it('unstageFile sends git.unstage request', async () => {
await provider.unstageFile('/home/user/repo', 'src/file.ts')
expect(mux.request).toHaveBeenCalledWith('git.unstage', {
worktreePath: '/home/user/repo',
filePath: 'src/file.ts'
})
})
it('bulkStageFiles sends git.bulkStage request', async () => {
await provider.bulkStageFiles('/home/user/repo', ['a.ts', 'b.ts'])
expect(mux.request).toHaveBeenCalledWith('git.bulkStage', {
worktreePath: '/home/user/repo',
filePaths: ['a.ts', 'b.ts']
})
})
it('bulkUnstageFiles sends git.bulkUnstage request', async () => {
await provider.bulkUnstageFiles('/home/user/repo', ['a.ts', 'b.ts'])
expect(mux.request).toHaveBeenCalledWith('git.bulkUnstage', {
worktreePath: '/home/user/repo',
filePaths: ['a.ts', 'b.ts']
})
})
it('discardChanges sends git.discard request', async () => {
await provider.discardChanges('/home/user/repo', 'src/file.ts')
expect(mux.request).toHaveBeenCalledWith('git.discard', {
worktreePath: '/home/user/repo',
filePath: 'src/file.ts'
})
})
it('bulkDiscardChanges sends git.bulkDiscard request', async () => {
await provider.bulkDiscardChanges('/home/user/repo', ['a.ts', 'b.ts'])
expect(mux.request).toHaveBeenCalledWith('git.bulkDiscard', {
worktreePath: '/home/user/repo',
filePaths: ['a.ts', 'b.ts']
})
})
it('detectConflictOperation sends git.conflictOperation request', async () => {
mux.request.mockResolvedValue('rebase')
const result = await provider.detectConflictOperation('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('git.conflictOperation', {
worktreePath: '/home/user/repo'
})
expect(result).toBe('rebase')
})
it('getBranchCompare sends git.branchCompare request', async () => {
const compareResult = { summary: { ahead: 2, behind: 0 }, entries: [] }
mux.request.mockResolvedValue(compareResult)
const result = await provider.getBranchCompare('/home/user/repo', 'main')
expect(mux.request).toHaveBeenCalledWith('git.branchCompare', {
worktreePath: '/home/user/repo',
baseRef: 'main'
})
expect(result).toEqual(compareResult)
})
it('getUpstreamStatus sends git.upstreamStatus request', async () => {
const upstreamResult = { hasUpstream: true, upstreamName: 'origin/main', ahead: 1, behind: 0 }
mux.request.mockResolvedValue(upstreamResult)
const result = await provider.getUpstreamStatus('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('git.upstreamStatus', {
worktreePath: '/home/user/repo'
})
expect(result).toEqual(upstreamResult)
})
it('getUpstreamStatus forwards an explicit push target', async () => {
const upstreamResult = { hasUpstream: true, upstreamName: 'fork/feature', ahead: 0, behind: 1 }
mux.request.mockResolvedValue(upstreamResult)
const pushTarget = { remoteName: 'fork', branchName: 'feature' }
const result = await provider.getUpstreamStatus('/home/user/repo', pushTarget)
expect(mux.request).toHaveBeenCalledWith('git.upstreamStatus', {
worktreePath: '/home/user/repo',
pushTarget
})
expect(result).toEqual(upstreamResult)
})
it('pushBranch sends git.push request and forwards publish mode and target', async () => {
await provider.pushBranch('/home/user/repo', true, {
remoteName: 'pr-fork-orca',
branchName: 'contributor/fix'
})
expect(mux.request).toHaveBeenCalledWith('git.push', {
worktreePath: '/home/user/repo',
publish: true,
pushTarget: {
remoteName: 'pr-fork-orca',
branchName: 'contributor/fix'
}
})
})
it('pushBranch forwards force-with-lease mode', async () => {
await provider.pushBranch('/home/user/repo', false, undefined, { forceWithLease: true })
expect(mux.request).toHaveBeenCalledWith('git.push', {
worktreePath: '/home/user/repo',
publish: false,
pushTarget: undefined,
forceWithLease: true
})
})
it('pullBranch sends git.pull request', async () => {
await provider.pullBranch('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('git.pull', {
worktreePath: '/home/user/repo'
})
})
it('pullBranch forwards an explicit push target', async () => {
const pushTarget = { remoteName: 'fork', branchName: 'feature' }
await provider.pullBranch('/home/user/repo', pushTarget)
expect(mux.request).toHaveBeenCalledWith('git.pull', {
worktreePath: '/home/user/repo',
pushTarget
})
})
it('fastForwardBranch sends git.fastForward request', async () => {
await provider.fastForwardBranch('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('git.fastForward', {
worktreePath: '/home/user/repo'
})
})
it('fastForwardBranch forwards an explicit push target', async () => {
const pushTarget = { remoteName: 'fork', branchName: 'feature' }
await provider.fastForwardBranch('/home/user/repo', pushTarget)
expect(mux.request).toHaveBeenCalledWith('git.fastForward', {
worktreePath: '/home/user/repo',
pushTarget
})
})
it('rebaseFromBase sends git.rebaseFromBase request', async () => {
await provider.rebaseFromBase('/home/user/repo', 'upstream/main')
expect(mux.request).toHaveBeenCalledWith('git.rebaseFromBase', {
worktreePath: '/home/user/repo',
baseRef: 'upstream/main'
})
})
it('fetchRemote sends git.fetch request', async () => {
await provider.fetchRemote('/home/user/repo')
expect(mux.request).toHaveBeenCalledWith('git.fetch', {
worktreePath: '/home/user/repo'
})
})
it('fetchRemote forwards an explicit push target', async () => {
const pushTarget = { remoteName: 'fork', branchName: 'feature' }
await provider.fetchRemote('/home/user/repo', pushTarget)
expect(mux.request).toHaveBeenCalledWith('git.fetch', {
worktreePath: '/home/user/repo',
pushTarget
})
})
it('fetchRemoteTrackingRef sends git.fetchRemoteTrackingRef request', async () => {
await provider.fetchRemoteTrackingRef(
'/home/user/repo',
'origin',
'main',
'refs/remotes/origin/main'
)
expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', {
worktreePath: '/home/user/repo',
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main'
})
})
it('getBranchDiff sends git.branchDiff request', async () => {
const diffs = [{ kind: 'text', originalContent: '', modifiedContent: 'new' }]
mux.request.mockResolvedValue(diffs)
const result = await provider.getBranchDiff('/home/user/repo', 'main')
expect(mux.request).toHaveBeenCalledWith('git.branchDiff', {
worktreePath: '/home/user/repo',
baseRef: 'main'
})
expect(result).toEqual(diffs)
})
it('listWorktrees sends git.listWorktrees request', async () => {
const worktrees = [
{
path: '/home/user/repo',
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: true
}
]
mux.request.mockResolvedValue(worktrees)
const controller = new AbortController()
const result = await provider.listWorktrees('/home/user/repo', { signal: controller.signal })
expect(mux.request).toHaveBeenCalledWith(
'git.listWorktrees',
{ repoPath: '/home/user/repo' },
{ signal: controller.signal }
)
expect(result).toEqual(worktrees)
})
it('addWorktree sends git.addWorktree request', async () => {
await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', {
base: 'main',
noCheckout: true
})
expect(mux.request).toHaveBeenCalledWith('git.addWorktree', {
repoPath: '/home/user/repo',
branchName: 'feature',
targetDir: '/home/user/feat',
base: 'main',
noCheckout: true
})
})
it('removeWorktree sends git.removeWorktree request', async () => {
await provider.removeWorktree('/home/user/feat', true)
expect(mux.request).toHaveBeenCalledWith('git.removeWorktree', {
worktreePath: '/home/user/feat',
force: true
})
})
it('worktreeIsClean sends git.worktreeIsClean request', async () => {
const cleanResult = { clean: false, stdout: '?? scratch.txt\n' }
mux.request.mockResolvedValue(cleanResult)
const result = await provider.worktreeIsClean('/home/user/feat')
expect(mux.request).toHaveBeenCalledWith('git.worktreeIsClean', {
worktreePath: '/home/user/feat'
})
expect(result).toEqual(cleanResult)
})
it('worktreeIsClean falls back to git.status for old relays', async () => {
const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), {
code: -32601
})
mux.request.mockRejectedValueOnce(methodNotFound).mockResolvedValueOnce({
entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }],
conflictOperation: 'unknown'
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const result = await provider.worktreeIsClean('/home/user/feat')
expect(mux.request).toHaveBeenNthCalledWith(1, 'git.worktreeIsClean', {
worktreePath: '/home/user/feat'
})
expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', {
worktreePath: '/home/user/feat'
})
expect(result).toEqual({ clean: false, stdout: 'untracked untracked: scratch.txt' })
} finally {
warnSpy.mockRestore()
}
})
it('renameCurrentBranch sends the narrow branch-rename request', async () => {
await provider.renameCurrentBranch('/home/user/feat', 'you/fix-auth')
expect(mux.request).toHaveBeenCalledWith('git.renameCurrentBranch', {
worktreePath: '/home/user/feat',
newBranch: 'you/fix-auth'
})
})
it('isGitRepo always returns true for remote paths', () => {
expect(provider.isGitRepo('/any/path')).toBe(true)
})
})