Files
orca/src/cli/index-worktree-create-target.test.ts
T
Brennan Benson 7a59aa39d0 fix(worktrees): decide setup before git worktree add on the host's create (#26006)
* fix(worktrees): decide setup before git worktree add on the host's create, so an undecided ask repo leaves nothing behind

The host's create (agent.launch, worktree.create, the phone and the CLI) only checked the setup policy
after adding the worktree. An ask repo with no setup decision then threw with the worktree already on
disk and no worktrees-changed event. It now refuses before the add, as the desktop create does, and a
setup hook the new branch adds (one nobody decided on) is skipped with a warning instead of failing a
create that already exists.

* fix(worktrees): read the host create's setup decision in one place, after recording its host

The pre-add refusal now runs after the create records its execution host, so a refused create's
failure telemetry still says where it ran; it still runs before any git work. Both setup checks
read the decision from one helper so they cannot disagree, and the post-add skip logs once.
The refusal test now also proves no base resolution, fetch or branch naming ran and that the
hooks came from the main checkout.

* test(worktrees): cover a setup hook only the new branch adds through the managed create

An ask repo with no decision, whose setup hook exists only in the new worktree's orca.yaml, now
creates, refreshes the worktree list, reports setup as skipped and returns the warning.

* fix(cli): name the --setup flag when a worktree create needs a setup decision

The host refuses an undecided create in an ask repo with "Setup decision required for this
repository", which desktop and phone answer in their own UI. The CLI now adds the next step:
pass --setup run or --setup skip.
2026-10-06 19:11:48 -07:00

333 lines
9.6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
import { RuntimeRpcFailureError } from './runtime-client'
describe('orca cli worktree awareness', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('passes explicit activation through worktree.create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'feature', '--activate', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'feature',
displayName: 'feature',
displayNameKind: 'user',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: true,
navigation: 'host',
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('resolves project and host flags to the matching repo for worktree.create', async () => {
pairRuntimeEnvironment(listEnvironmentsMock, 'gpu')
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-local',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-local',
path: '/tmp/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-gpu',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
}),
okFixture('req_create', {
worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--project',
'github:stablyai/orca',
'--host',
'runtime:gpu',
'--name',
'feature',
'--no-parent',
'--json'
],
'/tmp/repo'
)
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu')
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-gpu',
name: 'feature',
displayName: 'feature',
displayNameKind: 'user',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
noParent: true,
callerTerminalHandle: undefined,
cliProvenanceRequest: {}
})
})
it('resolves project-host-setup directly for worktree.create', async () => {
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-gpu',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-gpu',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
}),
okFixture('req_create', {
worktree: buildWorktree('/srv/orca/feature', 'feature', 'abc', 'repo-gpu'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'worktree',
'create',
'--project-host-setup',
'setup-gpu',
'--name',
'feature',
'--no-parent',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenNthCalledWith(
2,
'worktree.create',
expect.objectContaining({ repo: 'id:repo-gpu' })
)
})
it('rejects mixing repo and project target flags on worktree.create', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'worktree',
'create',
'--repo',
'id:repo-local',
'--project',
'github:stablyai/orca',
'--name',
'feature',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Choose either --repo or project target flags, not both.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('passes caller terminal handle through worktree.create with cwd fallback', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_parent'
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
okFixture('req_create', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledTimes(2)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-1',
name: 'child',
displayName: 'child',
displayNameKind: 'user',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
noParent: false,
callerTerminalHandle: 'term_parent',
cliProvenanceRequest: { callerTerminalHandle: 'term_parent' }
})
})
it('marks every worktree.create as CLI-created even from an external shell', async () => {
// Why: the sidebar badge/filter must catch hand-typed creates too, so the
// provenance request is sent with no terminal handle rather than omitted.
delete process.env.ORCA_TERMINAL_HANDLE
queueFixtures(
callMock,
okFixture('req_create_external', {
worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'),
lineage: null,
warnings: []
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent', '--json'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({ cliProvenanceRequest: {} })
)
})
it('tells the user which --setup flag answers an undecided ask repo', async () => {
callMock.mockRejectedValueOnce(
new RuntimeRpcFailureError({
id: 'req_create',
ok: false,
error: { code: 'runtime_error', message: 'Setup decision required for this repository' },
_meta: { runtimeId: 'runtime-1' }
})
)
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent'],
'/tmp/repo'
)
expect(errSpy.mock.calls.flat().join('\n')).toBe(
'Setup decision required for this repository\n' +
'Next step: Pass --setup run to run the setup script, or --setup skip to create without it.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})