diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index b70a9fd21b1..dd2d26b4b0e 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -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, - cwdParentWorktree: string | undefined -): string { + cwdParentWorktree: string | undefined, + client: Parameters[0]['client'] +): Promise { + 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 = { }, '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 = { 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 = { } } const result = await client.call('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'), diff --git a/src/cli/help.ts b/src/cli/help.ts index 46731378483..6a79b39a57a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -187,7 +187,7 @@ Common Commands: orca environment show --environment [--json] orca environment rm --environment [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --name [--repo ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] + orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index adfb191501d..2597fc507a7 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -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, diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 2a79f75ff53..9315221387c 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -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 [--repo ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', + 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--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: --name related-task --json', + 'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json', 'orca worktree create --repo id: --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id: --name related-task --parent-worktree active --json', 'orca worktree create --repo id: --name independent-task --no-parent --json' diff --git a/src/cli/worktree-project-target.ts b/src/cli/worktree-project-target.ts new file mode 100644 index 00000000000..243dc0276c7 --- /dev/null +++ b/src/cli/worktree-project-target.ts @@ -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, + 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): boolean { + return flags.has('project') || flags.has('host') || flags.has('project-host-setup') +} + +export function assertWorkspaceTargetFlagsCompatible(flags: Map): 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, + client: RuntimeClient +): Promise { + 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}` +}