Add current worktree shortcuts to Orca CLI (#301)

This commit is contained in:
Jinwoo Hong
2026-04-04 18:33:58 -07:00
committed by GitHub
parent e32d8c3e0c
commit abe54ea84d
6 changed files with 443 additions and 23 deletions
+10 -8
View File
@@ -34,6 +34,7 @@ The following commands are implemented against the running Orca app:
- `orca repo search-refs`
- `orca worktree list`
- `orca worktree show`
- `orca worktree current`
- `orca worktree create`
- `orca worktree set`
- `orca worktree rm`
@@ -50,14 +51,15 @@ The following commands are implemented against the running Orca app:
Focused v1 supports the complete agent loop for worktree orchestration:
1. Inspect current Orca runtime availability.
2. Discover repos indirectly through existing worktrees and summary views.
3. Create a new worktree in a chosen repo.
4. Attach or update worktree metadata like display name, linked issue, and comment.
5. Inspect many worktrees at once with `worktree ps`.
6. Discover live terminal handles in a worktree.
7. Read terminal output with bounded token-efficient reads.
8. Send input back to the terminal.
9. Stop live terminals for a worktree when needed.
2. Discover the enclosing Orca-managed worktree from the current shell directory.
3. Discover repos indirectly through existing worktrees and summary views.
4. Create a new worktree in a chosen repo.
5. Attach or update worktree metadata like display name, linked issue, and comment.
6. Inspect many worktrees at once with `worktree ps`.
7. Discover live terminal handles in a worktree.
8. Read terminal output with bounded token-efficient reads.
9. Send input back to the terminal.
10. Stop live terminals for a worktree when needed.
It also covers the adjacent setup tasks needed to make worktree creation usable:
+15
View File
@@ -375,6 +375,20 @@ Status:
orca worktree show --worktree branch:feature/foo --json
```
Focused v1 also accepts `active` / `current` as CLI-only shortcuts for worktree
selectors. The CLI resolves them from the caller's current directory and sends a
`path:` selector to the runtime.
## `orca worktree current`
Status:
- `v1-now`
```bash
orca worktree current --json
```
## `orca worktree create`
Status:
@@ -404,6 +418,7 @@ Status:
```bash
orca worktree set --worktree branch:feature/foo --display-name "Parser" --issue 123 --comment "parser work" --json
orca worktree set --worktree active --comment "parser work" --json
```
## `orca worktree rm`
+4
View File
@@ -97,9 +97,11 @@ orca repo search-refs --repo id:<repoId> --query main --limit 10 --json
```bash
orca worktree list --repo id:<repoId> --json
orca worktree ps --json
orca worktree current --json
orca worktree show --worktree id:<worktreeId> --json
orca worktree create --repo id:<repoId> --name my-task --issue 123 --comment "seed" --json
orca worktree set --worktree id:<worktreeId> --display-name "My Task" --json
orca worktree set --worktree active --comment "waiting on review" --json
orca worktree rm --worktree id:<worktreeId> --force --json
```
@@ -109,6 +111,7 @@ Worktree selectors supported in focused v1:
- `path:<absolute-path>`
- `branch:<branch-name>`
- `issue:<number>`
- `active` / `current` to resolve the enclosing Orca-managed worktree from the shell `cwd`
### Terminal
@@ -131,6 +134,7 @@ Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca
- Treat Orca as the source of truth for Orca worktree and terminal tasks. Do not mix Orca-managed state with ad hoc git worktree commands unless Orca explicitly cannot perform the requested action.
- Prefer `--json` for all machine-driven use.
- Use `worktree ps` as the first summary view when many worktrees may exist.
- Use `worktree current` or `--worktree active` when the agent is already running inside the target worktree.
- Use `terminal list` to reacquire handles after Orca reloads.
- Use `terminal read` before `terminal send` unless the next input is obvious.
- Use `terminal wait --for exit` only when the task actually depends on process completion.
+301
View File
@@ -0,0 +1,301 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('./runtime-client', () => {
class RuntimeClient {
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
}
class RuntimeClientError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError
}
})
import { buildCurrentWorktreeSelector, main, normalizeWorktreeSelector } from './index'
describe('orca cli worktree awareness', () => {
beforeEach(() => {
callMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('builds the current worktree selector from cwd', () => {
expect(buildCurrentWorktreeSelector('/tmp/repo/feature')).toBe('path:/tmp/repo/feature')
})
it('normalizes active/current worktree selectors to cwd', () => {
expect(normalizeWorktreeSelector('active', '/tmp/repo/feature')).toBe('path:/tmp/repo/feature')
expect(normalizeWorktreeSelector('current', '/tmp/repo/feature')).toBe('path:/tmp/repo/feature')
expect(normalizeWorktreeSelector('branch:feature/foo', '/tmp/repo/feature')).toBe(
'branch:feature/foo'
)
})
it('shows the enclosing worktree for `worktree current`', async () => {
callMock
.mockResolvedValueOnce({
id: 'req_list',
ok: true,
result: {
worktrees: [
{
id: 'repo::/tmp/repo/feature',
repoId: 'repo',
path: '/tmp/repo/feature',
branch: 'feature/foo',
linkedIssue: null,
git: {
path: '/tmp/repo/feature',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
},
displayName: '',
comment: ''
}
],
totalCount: 1,
truncated: false
},
_meta: {
runtimeId: 'runtime-1'
}
})
.mockResolvedValueOnce({
id: 'req_1',
ok: true,
result: {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
},
_meta: {
runtimeId: 'runtime-1'
}
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'path:/tmp/repo/feature'
})
expect(logSpy).toHaveBeenCalledTimes(1)
})
it('uses cwd when active is passed to worktree.set', async () => {
callMock
.mockResolvedValueOnce({
id: 'req_list',
ok: true,
result: {
worktrees: [
{
id: 'repo::/tmp/repo',
repoId: 'repo',
path: '/tmp/repo',
branch: 'main',
linkedIssue: null,
git: {
path: '/tmp/repo',
head: 'aaa',
branch: 'main',
isBare: false,
isMainWorktree: false
},
displayName: '',
comment: ''
},
{
id: 'repo::/tmp/repo/feature',
repoId: 'repo',
path: '/tmp/repo/feature',
branch: 'feature/foo',
linkedIssue: null,
git: {
path: '/tmp/repo/feature',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
},
displayName: '',
comment: ''
}
],
totalCount: 2,
truncated: false
},
_meta: {
runtimeId: 'runtime-1'
}
})
.mockResolvedValueOnce({
id: 'req_1',
ok: true,
result: {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature',
comment: 'hello'
}
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['worktree', 'set', '--worktree', 'active', '--comment', 'hello', '--json'],
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', {
worktree: 'path:/tmp/repo/feature',
displayName: undefined,
linkedIssue: undefined,
comment: 'hello'
})
})
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
callMock
.mockResolvedValueOnce({
id: 'req_list',
ok: true,
result: {
worktrees: [
{
id: 'repo::/tmp/repo/feature',
repoId: 'repo',
path: '/tmp/repo/feature',
branch: 'feature/foo',
linkedIssue: null,
git: {
path: '/tmp/repo/feature',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
},
displayName: '',
comment: ''
}
],
totalCount: 1,
truncated: false
},
_meta: {
runtimeId: 'runtime-1'
}
})
.mockResolvedValueOnce({
id: 'req_show',
ok: true,
result: {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'path:/tmp/repo/feature'
})
})
it('uses the resolved enclosing worktree for terminal consumers', async () => {
callMock
.mockResolvedValueOnce({
id: 'req_list',
ok: true,
result: {
worktrees: [
{
id: 'repo::/tmp/repo/feature',
repoId: 'repo',
path: '/tmp/repo/feature',
branch: 'feature/foo',
linkedIssue: null,
git: {
path: '/tmp/repo/feature',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
},
displayName: '',
comment: ''
}
],
totalCount: 1,
truncated: false
},
_meta: {
runtimeId: 'runtime-1'
}
})
.mockResolvedValueOnce({
id: 'req_term',
ok: true,
result: {
terminals: [],
totalCount: 0,
truncated: false
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', {
worktree: 'path:/tmp/repo/feature',
limit: undefined
})
})
})
+107 -15
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/* eslint-disable max-lines -- Why: the public CLI entrypoint keeps command dispatch in one place so the bundled shell command and development fallback stay behaviorally identical. */
import { isAbsolute, relative, resolve as resolvePath } from 'path'
import type {
CliStatusResult,
RuntimeRepoList,
@@ -95,6 +96,16 @@ const COMMAND_SPECS: CommandSpec[] = [
usage: 'orca worktree show --worktree <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
},
{
path: ['worktree', 'current'],
summary: 'Show the Orca-managed worktree for the current directory',
usage: 'orca worktree current [--json]',
allowedFlags: [...GLOBAL_FLAGS],
notes: [
'Resolves the current shell directory to a path: selector so agents can target the enclosing Orca worktree without spelling out $PWD.'
],
examples: ['orca worktree current', 'orca worktree current --json']
},
{
path: ['worktree', 'create'],
summary: 'Create a new Orca-managed worktree',
@@ -161,8 +172,8 @@ const COMMAND_SPECS: CommandSpec[] = [
}
]
async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2))
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
const parsed = parseArgs(argv)
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
printHelp(helpPath)
@@ -237,7 +248,7 @@ async function main(): Promise<void> {
if (matches(commandPath, ['terminal', 'list'])) {
const result = await client.call<RuntimeTerminalListResult>('terminal.list', {
worktree: getOptionalStringFlag(parsed.flags, 'worktree'),
worktree: await getOptionalWorktreeSelector(parsed.flags, 'worktree', cwd, client),
limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit')
})
return printResult(result, json, formatTerminalList)
@@ -288,7 +299,7 @@ async function main(): Promise<void> {
if (matches(commandPath, ['terminal', 'stop'])) {
const result = await client.call<{ stopped: number }>('terminal.stop', {
worktree: getRequiredStringFlag(parsed.flags, 'worktree')
worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client)
})
return printResult(result, json, (value) => `Stopped ${value.stopped} terminals.`)
}
@@ -310,7 +321,14 @@ async function main(): Promise<void> {
if (matches(commandPath, ['worktree', 'show'])) {
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree: getRequiredStringFlag(parsed.flags, 'worktree')
worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client)
})
return printResult(result, json, formatWorktreeShow)
}
if (matches(commandPath, ['worktree', 'current'])) {
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree: await resolveCurrentWorktreeSelector(cwd, client)
})
return printResult(result, json, formatWorktreeShow)
}
@@ -328,7 +346,7 @@ async function main(): Promise<void> {
if (matches(commandPath, ['worktree', 'set'])) {
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
worktree: getRequiredStringFlag(parsed.flags, 'worktree'),
worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client),
displayName: getOptionalStringFlag(parsed.flags, 'display-name'),
linkedIssue: getOptionalNullableNumberFlag(parsed.flags, 'issue'),
comment: getOptionalStringFlag(parsed.flags, 'comment')
@@ -338,7 +356,7 @@ async function main(): Promise<void> {
if (matches(commandPath, ['worktree', 'rm'])) {
const result = await client.call<{ removed: boolean }>('worktree.rm', {
worktree: getRequiredStringFlag(parsed.flags, 'worktree'),
worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client),
force: parsed.flags.get('force') === true
})
return printResult(result, json, (value) => `removed: ${value.removed}`)
@@ -370,7 +388,7 @@ async function main(): Promise<void> {
}
}
function parseArgs(argv: string[]): ParsedArgs {
export function parseArgs(argv: string[]): ParsedArgs {
const commandPath: string[] = []
const flags = new Map<string, string | boolean>()
@@ -394,7 +412,7 @@ function parseArgs(argv: string[]): ParsedArgs {
return { commandPath, flags }
}
function resolveHelpPath(parsed: ParsedArgs): string[] | null {
export function resolveHelpPath(parsed: ParsedArgs): string[] | null {
if (parsed.commandPath[0] === 'help') {
return parsed.commandPath.slice(1)
}
@@ -404,7 +422,7 @@ function resolveHelpPath(parsed: ParsedArgs): string[] | null {
return null
}
function validateCommandAndFlags(parsed: ParsedArgs): void {
export function validateCommandAndFlags(parsed: ParsedArgs): void {
const spec = findCommandSpec(parsed.commandPath)
if (!spec) {
throw new RuntimeClientError(
@@ -423,7 +441,7 @@ function validateCommandAndFlags(parsed: ParsedArgs): void {
}
}
function findCommandSpec(commandPath: string[]): CommandSpec | undefined {
export function findCommandSpec(commandPath: string[]): CommandSpec | undefined {
return COMMAND_SPECS.find((spec) => matches(spec.path, commandPath))
}
@@ -447,6 +465,74 @@ function getOptionalStringFlag(
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function buildCurrentWorktreeSelector(cwd: string): string {
return `path:${resolvePath(cwd)}`
}
export function normalizeWorktreeSelector(selector: string, cwd: string): string {
if (selector === 'active' || selector === 'current') {
return buildCurrentWorktreeSelector(cwd)
}
return selector
}
function isWithinPath(parentPath: string, childPath: string): boolean {
const relativePath = relative(parentPath, childPath)
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
}
async function resolveCurrentWorktreeSelector(cwd: string, client: RuntimeClient): Promise<string> {
const currentPath = resolvePath(cwd)
const worktrees = await client.call<RuntimeWorktreeListResult>('worktree.list', {
limit: 10_000
})
const enclosingWorktree = worktrees.result.worktrees
.filter((worktree) => isWithinPath(resolvePath(worktree.path), currentPath))
.sort((left, right) => right.path.length - left.path.length)[0]
if (!enclosingWorktree) {
throw new RuntimeClientError(
'selector_not_found',
`No Orca-managed worktree contains the current directory: ${currentPath}`
)
}
// Why: users expect "active/current" to mean the enclosing managed worktree
// even from nested subdirectories. The CLI resolves that shell-local concept
// to the deepest matching worktree root, then hands the runtime a normal
// path selector so selector semantics stay centralized in one layer.
return buildCurrentWorktreeSelector(enclosingWorktree.path)
}
async function getOptionalWorktreeSelector(
flags: Map<string, string | boolean>,
name: string,
cwd: string,
client: RuntimeClient
): Promise<string | undefined> {
const value = getOptionalStringFlag(flags, name)
if (!value) {
return undefined
}
if (value === 'active' || value === 'current') {
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
}
async function getRequiredWorktreeSelector(
flags: Map<string, string | boolean>,
name: string,
cwd: string,
client: RuntimeClient
): Promise<string> {
const value = getRequiredStringFlag(flags, name)
if (value === 'active' || value === 'current') {
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
}
function getOptionalNumberFlag(
flags: Map<string, string | boolean>,
name: string
@@ -487,7 +573,7 @@ function getOptionalNullableNumberFlag(
return getOptionalNumberFlag(flags, name)
}
function matches(actual: string[], expected: string[]): boolean {
export function matches(actual: string[], expected: string[]): boolean {
return (
actual.length === expected.length && actual.every((value, index) => value === expected[index])
)
@@ -685,6 +771,7 @@ Repos:
Worktrees:
worktree list List Orca-managed worktrees
worktree show Show one worktree
worktree current Show the Orca-managed worktree for the current directory
worktree create Create a new Orca-managed worktree
worktree set Update Orca metadata for a worktree
worktree rm Remove a worktree from Orca and git
@@ -704,6 +791,7 @@ Common Commands:
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
orca worktree rm --worktree <selector> [--force] [--json]
orca worktree ps [--limit <n>] [--json]
@@ -721,7 +809,7 @@ Common Commands:
Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<path>
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
Terminal Send Options:
@@ -747,6 +835,8 @@ Examples:
$ orca repo list
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
$ orca worktree show --worktree branch:Jinwoo-H/cli
$ orca worktree current
$ orca worktree set --worktree active --comment "waiting on review"
$ orca worktree ps --limit 10
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
$ orca terminal send --terminal term_123 --text "hi" --enter
@@ -812,10 +902,12 @@ function formatFlagHelp(flag: string): string {
text: '--text <text> Text to send to the terminal',
'timeout-ms': '--timeout-ms <ms> Maximum wait time before timing out',
worktree:
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, or path:<path>'
'--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current'
}
return helpByFlag[flag] ?? `--${flag}`
}
void main()
if (require.main === module) {
void main()
}
+6
View File
@@ -261,6 +261,12 @@ describe('OrcaRuntimeService', () => {
})
})
it('does not interpret active as a runtime-global worktree selector', async () => {
const runtime = new OrcaRuntimeService(store)
await expect(runtime.showManagedWorktree('active')).rejects.toThrow('selector_not_found')
})
it('reads bounded terminal output and writes through the PTY controller', async () => {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)