mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Allow CLI worktree creation by project host setup
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user