Files
orca/src/cli/handlers/worktree.ts
T

290 lines
11 KiB
TypeScript

import type {
RuntimeWorktreeListResult,
RuntimeWorktreePsResult,
RuntimeWorktreeRecord,
RuntimeWorktreeCreateResult,
RuntimeWorktreeRemoveResult
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
import {
getOptionalNullableNumberFlag,
getOptionalNumberFlag,
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import {
getOptionalWorktreeSelector,
getRequiredWorktreeSelector,
resolveCurrentWorktreeSelector
} from '../selectors'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { isWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { printLineageSummary } from './worktree-lineage-summary'
import {
assertWorkspaceTargetFlagsCompatible,
hasWorkspaceProjectTarget,
resolveProjectCreateRepoSelector
} from '../worktree-project-target'
import {
assertCreateParentFlagsCompatible,
resolveCreateParentSelector
} from './worktree-create-parent-selector'
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
type HookWarningResult = {
warning?: string
}
type PreservedBranchResult = {
preservedBranch?: {
branchName: string
}
}
function printHookWarning(result: HookWarningResult, json: boolean): void {
if (!json && result.warning) {
console.error(`warning: ${result.warning}`)
}
}
function printPreservedBranchWarning(result: PreservedBranchResult, json: boolean): void {
if (!json && result.preservedBranch) {
console.error(
`warning: local branch "${result.preservedBranch.branchName}" was kept because Git could not safely delete it`
)
}
}
function assertParentWorktreeFlagsCompatible(flags: Map<string, string | boolean>): void {
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --parent-worktree or --no-parent, not both.'
)
}
const parentWorktree = flags.get('parent-worktree')
if (
flags.has('parent-worktree') &&
(typeof parentWorktree !== 'string' || parentWorktree === '')
) {
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-worktree')
}
}
function getEnvParentWorkspace(): string | undefined {
const workspaceId = process.env.ORCA_WORKSPACE_ID
if (typeof workspaceId === 'string' && isWorkspaceKey(workspaceId)) {
return workspaceId
}
const worktreeId = process.env.ORCA_WORKTREE_ID
if (typeof worktreeId === 'string' && worktreeId.length > 0) {
return isWorkspaceKey(worktreeId) ? worktreeId : worktreeWorkspaceKey(worktreeId)
}
return undefined
}
function getPresentStringFlag(
flags: Map<string, string | boolean>,
name: string,
options: { allowEmpty?: boolean } = {}
): string | undefined {
if (!flags.has(name)) {
return undefined
}
const value = flags.get(name)
if (typeof value === 'string' && (options.allowEmpty || value.length > 0)) {
return value
}
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
}
function getOptionalStartupAgent(flags: Map<string, string | boolean>): string | undefined {
const agent = getPresentStringFlag(flags, 'agent')
if (agent === undefined) {
if (flags.has('prompt')) {
throw new RuntimeClientError('invalid_argument', '--prompt requires --agent')
}
return undefined
}
if (!isTuiAgent(agent)) {
throw new RuntimeClientError('invalid_argument', `Unknown TUI agent "${agent}"`)
}
return agent
}
function getOptionalSetupDecision(
flags: Map<string, string | boolean>
): 'run' | 'skip' | 'inherit' | undefined {
const setup = getPresentStringFlag(flags, 'setup')
if (setup !== undefined && setup !== 'run' && setup !== 'skip' && setup !== 'inherit') {
throw new RuntimeClientError('invalid_argument', '--setup must be one of: run, skip, inherit')
}
if (flags.get('run-hooks') === true) {
if (setup !== undefined && setup !== 'run') {
throw new RuntimeClientError(
'invalid_argument',
'Choose either --run-hooks or --setup run, not contradictory setup flags.'
)
}
return setup
}
return setup
}
function getRepoSelectorFromWorktreeSelector(selector: string | undefined): string | undefined {
if (!selector?.startsWith('id:')) {
return undefined
}
const worktreeId = selector.slice('id:'.length)
const separatorIndex = worktreeId.indexOf('::')
if (separatorIndex <= 0) {
return undefined
}
return `id:${worktreeId.slice(0, separatorIndex)}`
}
async function getCreateRepoSelector(
flags: Map<string, string | boolean>,
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
}
const inferredRepo = getRepoSelectorFromWorktreeSelector(cwdParentWorktree)
if (inferredRepo) {
return inferredRepo
}
throw new RuntimeClientError(
'invalid_argument',
'Missing repo selector. Pass --repo or run from inside an Orca-managed worktree.'
)
}
export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
'worktree ps': async ({ flags, client, json }) => {
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
})
printResult(result, json, formatWorktreePs)
},
'worktree list': async ({ flags, client, json }) => {
const result = await client.call<RuntimeWorktreeListResult>('worktree.list', {
repo: getOptionalStringFlag(flags, 'repo'),
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
})
printResult(result, json, formatWorktreeList)
},
'worktree show': async ({ flags, client, cwd, json }) => {
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
})
printResult(result, json, formatWorktreeShow)
},
'worktree current': async ({ client, cwd, json }) => {
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree: await resolveCurrentWorktreeSelector(cwd, client)
})
printResult(result, json, formatWorktreeShow)
},
'worktree create': async ({ flags, client, cwd, json }) => {
assertCreateParentFlagsCompatible(flags)
assertWorkspaceTargetFlagsCompatible(flags)
const callerTerminalHandle =
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
process.env.ORCA_TERMINAL_HANDLE.length > 0
? process.env.ORCA_TERMINAL_HANDLE
: undefined
const explicitParent = await resolveCreateParentSelector(flags, cwd, client)
const explicitParentWorktree = explicitParent.parentWorktree
const explicitParentWorkspace = explicitParent.parentWorkspace
const startupAgent = getOptionalStartupAgent(flags)
const setupDecision = getOptionalSetupDecision(flags)
const noParent = flags.get('no-parent') === true
const envParentWorkspace =
!noParent && !explicitParentWorkspace && !explicitParentWorktree
? getEnvParentWorkspace()
: undefined
let cwdParentWorktree: string | undefined
const needsCwdRepoInference = !flags.has('repo') && !hasWorkspaceProjectTarget(flags)
if (
(!explicitParentWorktree && !explicitParentWorkspace && !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
// lets create infer the repo for the common current-workspace case.
cwdParentWorktree = await resolveCurrentWorktreeSelector(cwd, client)
} catch {
cwdParentWorktree = undefined
}
}
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
repo: await getCreateRepoSelector(flags, cwdParentWorktree, client),
name: getRequiredStringFlag(flags, 'name'),
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
...linearIssueLink,
comment: getOptionalStringFlag(flags, 'comment'),
runHooks: flags.get('run-hooks') === true,
activate: flags.get('activate') === true || flags.get('run-hooks') === true,
...(setupDecision ? { setupDecision } : {}),
parentWorktree: explicitParentWorktree,
...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}),
...(envParentWorkspace ? { envParentWorkspace } : {}),
...(cwdParentWorktree ? { cwdParentWorktree } : {}),
noParent,
callerTerminalHandle,
// Why: marks the workspace as CLI-created so the sidebar can badge and
// filter it. Sent on every `worktree create` — hand-typed or agent-run.
cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {},
...(startupAgent
? {
startupAgent,
startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? ''
}
: {})
})
printHookWarning(result.result, json)
printLineageSummary(result.result, json)
printResult(result, json, formatWorktreeShow)
},
'worktree set': async ({ flags, client, cwd, json }) => {
assertParentWorktreeFlagsCompatible(flags)
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue', {
allowNull: true
})
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
displayName: getOptionalStringFlag(flags, 'display-name'),
linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'),
...linearIssueLink,
comment: getOptionalStringFlag(flags, 'comment'),
workspaceStatus: getOptionalStringFlag(flags, 'workspace-status'),
parentWorktree: await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client),
noParent: flags.get('no-parent') === true
})
printResult(result, json, formatWorktreeShow)
},
'worktree rm': async ({ flags, client, cwd, json }) => {
const result = await client.call<RuntimeWorktreeRemoveResult>('worktree.rm', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
force: flags.get('force') === true,
runHooks: flags.get('run-hooks') === true
})
printHookWarning(result.result, json)
printPreservedBranchWarning(result.result, json)
printResult(result, json, (value) => `removed: ${value.removed}`)
}
}