Allow CLI worktree creation by project host setup

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:58 -07:00
co-authored by Orca
parent 0a4a4ddbd1
commit d05ec67e2b
5 changed files with 241 additions and 7 deletions
+17 -5
View File
@@ -21,6 +21,11 @@ import {
resolveCurrentWorktreeSelector
} from '../selectors'
import { isTuiAgent } from '../../shared/tui-agent-config'
import {
assertWorkspaceTargetFlagsCompatible,
hasWorkspaceProjectTarget,
resolveProjectCreateRepoSelector
} from '../worktree-project-target'
type HookWarningResult = {
warning?: string
@@ -148,10 +153,15 @@ function getRepoSelectorFromWorktreeSelector(selector: string | undefined): stri
return `id:${worktreeId.slice(0, separatorIndex)}`
}
function getCreateRepoSelector(
async function getCreateRepoSelector(
flags: Map<string, string | boolean>,
cwdParentWorktree: string | undefined
): string {
cwdParentWorktree: string | undefined,
client: Parameters<CommandHandler>[0]['client']
): Promise<string> {
const projectRepoSelector = await resolveProjectCreateRepoSelector(flags, client)
if (projectRepoSelector) {
return projectRepoSelector
}
const explicitRepo = getPresentStringFlag(flags, 'repo')
if (explicitRepo) {
return explicitRepo
@@ -194,6 +204,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
},
'worktree create': async ({ flags, client, cwd, json }) => {
assertParentFlagsCompatible(flags)
assertWorkspaceTargetFlagsCompatible(flags)
const callerTerminalHandle =
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
process.env.ORCA_TERMINAL_HANDLE.length > 0
@@ -209,7 +220,8 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
const setupDecision = getOptionalSetupDecision(flags)
const noParent = flags.get('no-parent') === true
let cwdParentWorktree: string | undefined
if ((!explicitParentWorktree && !noParent) || !flags.has('repo')) {
const needsCwdRepoInference = !flags.has('repo') && !hasWorkspaceProjectTarget(flags)
if ((!explicitParentWorktree && !noParent) || needsCwdRepoInference) {
try {
// Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running
// inside an Orca worktree. Cwd keeps CLI-created children nestable and
@@ -220,7 +232,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
}
}
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
repo: getCreateRepoSelector(flags, cwdParentWorktree),
repo: await getCreateRepoSelector(flags, cwdParentWorktree, client),
name: getRequiredStringFlag(flags, 'name'),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
+1 -1
View File
@@ -187,7 +187,7 @@ Common Commands:
orca environment show --environment <selector> [--json]
orca environment rm --environment <selector> [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
+147
View File
@@ -638,6 +638,153 @@ describe('orca cli worktree awareness', () => {
})
})
it('resolves project and host flags to the matching repo for worktree.create', async () => {
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(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
repo: 'id:repo-gpu',
name: 'feature',
baseBranch: undefined,
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: false,
parentWorktree: undefined,
noParent: true,
callerTerminalHandle: undefined
})
})
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 an explicit parent through worktree.create without cwd inference', async () => {
queueFixtures(
callMock,
+6 -1
View File
@@ -102,10 +102,13 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
path: ['worktree', 'create'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'repo',
'project',
'host',
'project-host-setup',
'name',
'agent',
'prompt',
@@ -121,6 +124,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
notes: [
'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.',
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
'Use --project with --host to create on a ready project host setup without spelling the backing repo id.',
'For related work, use the inferred parent or pass --parent-worktree active to make the current workspace relationship explicit.',
'Use --no-parent when the new worktree should be independent of the current workspace.',
'By default this creates the worktree and its first terminal without switching the active Orca workspace.',
@@ -132,6 +136,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
examples: [
'orca worktree create --name agent-task --agent codex --prompt "hi" --json',
'orca worktree create --repo id:<repoId> --name related-task --json',
'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json',
'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json',
'orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json',
'orca worktree create --repo id:<repoId> --name independent-task --no-parent --json'
+70
View File
@@ -0,0 +1,70 @@
import type { ProjectHostSetup } from '../shared/types'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
function getPresentStringFlag(
flags: Map<string, string | boolean>,
name: string
): string | undefined {
if (!flags.has(name)) {
return undefined
}
const value = flags.get(name)
if (typeof value === 'string' && value.length > 0) {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
}
export function hasWorkspaceProjectTarget(flags: Map<string, string | boolean>): boolean {
return flags.has('project') || flags.has('host') || flags.has('project-host-setup')
}
export function assertWorkspaceTargetFlagsCompatible(flags: Map<string, string | boolean>): void {
const hasProjectTarget = hasWorkspaceProjectTarget(flags)
if (flags.has('repo') && hasProjectTarget) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --repo or project target flags, not both.'
)
}
if (flags.has('host') && !flags.has('project') && !flags.has('project-host-setup')) {
throw new RuntimeClientError(
'invalid_argument',
'--host requires --project unless --project-host-setup is provided.'
)
}
}
export async function resolveProjectCreateRepoSelector(
flags: Map<string, string | boolean>,
client: RuntimeClient
): Promise<string | undefined> {
const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup')
const projectId = getPresentStringFlag(flags, 'project')
const hostId = getPresentStringFlag(flags, 'host')
if (!projectHostSetupId && !projectId && !hostId) {
return undefined
}
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
const setup = result.result.setups.find((candidate) => {
if (candidate.setupState !== 'ready') {
return false
}
if (projectHostSetupId) {
return candidate.id === projectHostSetupId
}
return (
candidate.projectId === projectId && (hostId === undefined || candidate.hostId === hostId)
)
})
if (!setup) {
throw new RuntimeClientError(
'invalid_argument',
projectHostSetupId
? `Project host setup is not ready or was not found: ${projectHostSetupId}`
: `Project is not set up on the selected host: ${projectId}${hostId ? ` on ${hostId}` : ''}`
)
}
return `id:${setup.repoId}`
}