Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938/** * Focused unit tests for the worktree CLI command module (saga j8dtju.8). * * Coverage: * - Pure argument parsing: `shard new` (worktree/workspace, launch flags, ambiguous/ * duplicate/unknown/missing rejection), `worktree list`/`status`/`retire`. * - Pure command builders: field mapping (label/base_ref/integration_target/version/cwd). * - Pure rendering: create/attach, list, status, retire, summary/detail lines. * - Pure helpers: disposition mapping, confirmation prompt/choice parsing, retry hint. * - Runner flows with injectable deps + a fake daemon client: * * create/attach success → exit 0 + Agent/Session ids rendered. * * daemon unavailable (no socket / connect throw) → exit 2. * * daemon refused (`success:false`) → exit 1. * * invalid (schema-failing) response → exit 1. * * list default sends cwd; --all omits cwd; --include-retired forwarded. * * retire keep/delete/force-delete mapping; confirmation_required interactive prompt * (cancel/keep/confirmed-delete retry) vs noninteractive actionable retry hint that * NEVER forces automatically. * * `shard worktree` dispatch (list/status/retire/unknown/help). * * All daemon interaction is stubbed through {@link WorktreeCmdDeps}; no real socket, fs, or * Git is touched. No Git imports/calls (the module forbids them). */import { describe, expect, it } from 'bun:test';import { buildAttachCommand, buildConfirmationPrompt, buildCreateCommand, buildListCommand, buildRetryHint, defaultWorktreeCmdDeps, EXIT_DAEMON_REFUSED, EXIT_DAEMON_UNAVAILABLE, EXIT_OK, parseConfirmationChoice, parseListArgs, parseNewArgs, parseRetireArgs, parseSocketFlag, parseStatusArgs, renderCreateAttach, renderList, renderRetire, renderStatus, renderWorkspaceDetail, renderWorkspaceSummaryLine, runListSub, runNewSub, runRetireSub, runStatusSub, runWorktreeSub, toWireDisposition, WorktreeHelpRequested, type WorktreeCmdDeps,} from '@/cli/commands/worktree-cmd';import type { SubagentClient, SubagentClientResult } from '@/subagent/daemon-client';import { PROTOCOL_VERSION, type Hello } from '@/protocol/lifecycle';import type { ResponseError } from '@/protocol/response';
// ─── Fixtures ───────────────────────────────────────────────────────────────────
/** A valid WorkspaceSummary fixture (snake_case wire shape). */function summary(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> { return { workspace_id: 'ws-aaaaaaaa-0000-4000-8000-000000000001', label: 'change-x', cwd: '/worktrees/change-x', ownership: 'managed', lifecycle: 'active', repository_fingerprint: 'repo-fp-0123456789', worktree_fingerprint: 'tree-fp-0123456789', created_at: 1_700_000_000_000, updated_at: 1_700_000_000_000, binding_count: 1, live_agent_count: 1, live_session_count: 1, ...overrides, };}
/** A valid WorkspaceDetail fixture. */function detail(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> { return { summary: summary(), bindings: [{ agent_id: 'agent-1', session_id: 'session-1', bound_at: 1_700_000_000_000 }], ...overrides, };}
/** A valid workspace.create / workspace.attach response. */function createAttachResponse(wsId = 'ws-aaaaaaaa-0000-4000-8000-000000000001'): Record<string, unknown> { return { workspace: detail({ summary: summary({ workspace_id: wsId }) }), agent_id: 'agent-fresh', session_id: '11111111-2222-3333-4444-555555555555' };}
function listResponse(workspaces: Record<string, unknown>[] = [summary()]): Record<string, unknown> { return { workspaces };}
function retireResponse(branchDeleted = false): Record<string, unknown> { return { workspace: detail({ summary: summary({ lifecycle: 'retired' }) }), branch_deleted: branchDeleted };}
/** Build a typed `success:false` refusal (CONFLICT with confirmation_required data). */function confirmationRefusal(wsId: string): ResponseError { return { code: 'CONFLICT', message: `delete-merged refused (branch refs/heads/shard/managed/${wsId} is not merged into refs/heads/main): ${wsId}`, data: { kind: 'confirmation_required', reason: `branch refs/heads/shard/managed/${wsId} is not merged into refs/heads/main`, workspace_id: wsId, requested_disposition: 'delete_if_merged', confirmation_disposition: 'delete_confirmed', }, };}
// ─── Fake daemon client + deps ──────────────────────────────────────────────────
interface FakeClientOptions { /** Replies keyed by command `type`; each reply is a `SubagentClientResult`. */ replies: Record<string, SubagentClientResult[]>; /** If set, `connect` rejects with this Error (unavailable daemon). */ connectError?: Error;}
interface Captured { /** Every command `send` received, in order. */ commands: Record<string, unknown>[]; /** Number of `close()` calls. */ closes: number; /** Whether `connect` was called and with which socket. */ connects: string[];}
function makeFakeClient(opts: FakeClientOptions): { client: () => Promise<SubagentClient>; captured: Captured } { const captured: Captured = { commands: [], closes: 0, connects: [] }; const queues: Record<string, SubagentClientResult[]> = { ...opts.replies }; const client: () => Promise<SubagentClient> = async () => { if (opts.connectError !== undefined) throw opts.connectError; captured.connects.push('connected'); const hello: Hello = { type: 'hello', protocol_version: PROTOCOL_VERSION, server_version: 'test', capabilities: [] }; return { hello, async send(command): Promise<SubagentClientResult> { captured.commands.push(command as Record<string, unknown>); const type = (command as { type: string }).type; const q = queues[type]; if (q === undefined || q.length === 0) { throw new Error(`no scripted reply for ${type}`); } return q.shift()!; }, close(): void { captured.closes++; }, }; }; return { client, captured };}
interface DepsOverrides { cwd?: string; discover?: string | undefined; isTTY?: boolean; replies?: Record<string, SubagentClientResult[]>; connectError?: Error; answers?: string[];}
function makeDeps(o: DepsOverrides = {}): { deps: WorktreeCmdDeps; captured: Captured; out: string[]; errOut: string[]; answers: string[] } { const out: string[] = []; const errOut: string[] = []; const answers = o.answers ?? []; let answerIdx = 0; const { client, captured } = makeFakeClient({ replies: o.replies ?? {}, ...(o.connectError !== undefined ? { connectError: o.connectError } : {}), }); const deps: WorktreeCmdDeps = { cwd: () => o.cwd ?? '/repo', discover: () => o.discover ?? '/tmp/shard/daemon.sock', connect: client, isTTY: () => o.isTTY ?? false, prompt: { async question(_query: string): Promise<string> { return answers[answerIdx++] ?? ''; }, }, stdout: { write: (s: string) => { out.push(s); } }, stderr: { write: (s: string) => { errOut.push(s); } }, }; return { deps, captured, out, errOut, answers };}
/** A success reply carrying `data`. */function okReply(data: unknown): SubagentClientResult { return { ok: true, command: 'workspace.x', data };}/** A refused reply carrying a `ResponseError`. */function errReply(error: ResponseError, command = 'workspace.x'): SubagentClientResult { return { ok: false, command, error };}
const WS = 'ws-bbbbbbbb-0000-4000-8000-000000000002';
// ─── parseNewArgs ───────────────────────────────────────────────────────────────
describe('parseNewArgs', () => { it('parses --worktree with create + launch flags', () => { expect(parseNewArgs(['--worktree', '--name', 'feat', '--branch', 'refs/heads/feat', '--base', 'main', '--integration-target', 'refs/heads/main', '--title', 'T', '--model', 'm', '--provider', 'p', '--mode', 'build'])).toEqual({ worktree: true, name: 'feat', branch: 'refs/heads/feat', base: 'main', integrationTarget: 'refs/heads/main', title: 'T', model: 'm', provider: 'p', mode: 'build', }); });
it('parses --workspace <id> with launch flags', () => { const p = parseNewArgs(['--workspace', WS, '--title', 'follow-up', '--model', 'm2']); expect(p.worktree).toBe(false); expect(p.workspaceId).toBe(WS); expect(p.title).toBe('follow-up'); expect(p.model).toBe('m2'); // No create-only fields on attach. expect('name' in p).toBe(false); expect('branch' in p).toBe(false); });
it('accepts the --flag=value form', () => { const p = parseNewArgs(['--worktree', '--name=feat', '--branch=refs/heads/feat', '--base=main', '--integration-target=refs/heads/main', '--socket=/tmp/s.sock']); expect(p).toMatchObject({ worktree: true, name: 'feat', branch: 'refs/heads/feat', base: 'main', integrationTarget: 'refs/heads/main', socketPath: '/tmp/s.sock' }); });
it('throws when both --worktree and --workspace are given (ambiguous)', () => { expect(() => parseNewArgs(['--worktree', '--workspace', WS])).toThrow('mutually exclusive'); expect(() => parseNewArgs(['--workspace', WS, '--worktree'])).toThrow('mutually exclusive'); });
it('throws when neither --worktree nor --workspace is given (missing)', () => { expect(() => parseNewArgs(['--title', 'T'])).toThrow('missing required flag'); expect(() => parseNewArgs([])).toThrow('missing required flag'); });
it('rejects duplicate value flags', () => { expect(() => parseNewArgs(['--worktree', '--name', 'a', '--name', 'b'])).toThrow('duplicate --name'); expect(() => parseNewArgs(['--worktree', '--branch', 'x', '--branch', 'y'])).toThrow('duplicate --branch'); expect(() => parseNewArgs(['--worktree', '--title', 'a', '--title', 'b'])).toThrow('duplicate --title'); expect(() => parseNewArgs(['--worktree', '--socket', '/a', '--socket', '/b'])).toThrow('duplicate --socket'); expect(() => parseNewArgs(['--worktree', '--worktree'])).toThrow('duplicate --worktree'); expect(() => parseNewArgs(['--workspace', WS, '--workspace', WS])).toThrow('duplicate --workspace'); });
it('rejects flag-like values (a value that starts with -)', () => { expect(() => parseNewArgs(['--worktree', '--name', '--branch'])).toThrow('invalid --name value'); expect(() => parseNewArgs(['--worktree', '--branch', '--base'])).toThrow('invalid --branch value'); expect(() => parseNewArgs(['--worktree', '--socket', '--model'])).toThrow('invalid --socket value'); });
it('rejects missing values at end of argv', () => { expect(() => parseNewArgs(['--worktree', '--name'])).toThrow('invalid --name value'); expect(() => parseNewArgs(['--workspace'])).toThrow('invalid --workspace value'); });
it('rejects unknown flags', () => { expect(() => parseNewArgs(['--worktree', '--bogus'])).toThrow('unknown argument: --bogus'); });
it('throws WorktreeHelpRequested on --help/-h', () => { expect(() => parseNewArgs(['--help'])).toThrow(WorktreeHelpRequested); expect(() => parseNewArgs(['-h'])).toThrow(WorktreeHelpRequested); });
it('parses --socket override', () => { expect(parseNewArgs(['--worktree', '--socket', '/explicit.sock']).socketPath).toBe('/explicit.sock'); });});
// ─── parseListArgs / parseStatusArgs / parseRetireArgs ──────────────────────────
describe('parseListArgs', () => { it('defaults all/include-retired to false', () => { expect(parseListArgs([])).toEqual({ all: false, includeRetired: false }); }); it('parses --all and --include-retired', () => { const parsed = parseListArgs(['--all', '--include-retired']); expect(parsed.all).toBe(true); expect(parsed.includeRetired).toBe(true); expect(parsed.socketPath).toBeUndefined(); }); it('parses --socket', () => { expect(parseListArgs(['--socket', '/s']).socketPath).toBe('/s'); }); it('rejects duplicates', () => { expect(() => parseListArgs(['--all', '--all'])).toThrow('duplicate --all'); expect(() => parseListArgs(['--include-retired', '--include-retired'])).toThrow('duplicate --include-retired'); expect(() => parseListArgs(['--socket', '/a', '--socket', '/b'])).toThrow('duplicate --socket'); }); it('rejects unknown', () => { expect(() => parseListArgs(['--nope'])).toThrow('unknown argument'); }); it('throws on --help', () => { expect(() => parseListArgs(['--help'])).toThrow(WorktreeHelpRequested); });});
describe('parseStatusArgs', () => { it('parses the workspace id', () => { expect(parseStatusArgs([WS])).toEqual({ workspaceId: WS }); }); it('parses --socket', () => { expect(parseStatusArgs([WS, '--socket', '/s']).socketPath).toBe('/s'); }); it('rejects a second positional', () => { expect(() => parseStatusArgs([WS, 'extra'])).toThrow('unexpected argument'); }); it('rejects unknown flags', () => { expect(() => parseStatusArgs([WS, '--bogus'])).toThrow('unknown argument'); }); it('throws on missing id', () => { expect(() => parseStatusArgs([])).toThrow('missing required workspace id'); expect(() => parseStatusArgs(['--socket', '/s'])).toThrow('missing required workspace id'); }); it('throws on --help', () => { expect(() => parseStatusArgs(['--help'])).toThrow(WorktreeHelpRequested); });});
describe('parseRetireArgs', () => { it('defaults disposition to keep', () => { expect(parseRetireArgs([WS]).disposition).toBe('keep'); }); it('parses --keep/--delete/--force-delete', () => { expect(parseRetireArgs([WS, '--keep']).disposition).toBe('keep'); expect(parseRetireArgs([WS, '--delete']).disposition).toBe('delete'); expect(parseRetireArgs([WS, '--force-delete']).disposition).toBe('force-delete'); }); it('parses --socket', () => { expect(parseRetireArgs([WS, '--socket', '/s']).socketPath).toBe('/s'); }); it('rejects ambiguous dispositions', () => { expect(() => parseRetireArgs([WS, '--keep', '--delete'])).toThrow('ambiguous'); expect(() => parseRetireArgs([WS, '--delete', '--force-delete'])).toThrow('ambiguous'); expect(() => parseRetireArgs([WS, '--keep', '--force-delete'])).toThrow('ambiguous'); }); it('rejects a second positional', () => { expect(() => parseRetireArgs([WS, 'extra'])).toThrow('unexpected argument'); }); it('rejects unknown flags', () => { expect(() => parseRetireArgs([WS, '--bogus'])).toThrow('unknown argument'); }); it('throws on missing id', () => { expect(() => parseRetireArgs(['--keep'])).toThrow('missing required workspace id'); }); it('throws on --help', () => { expect(() => parseRetireArgs(['--help'])).toThrow(WorktreeHelpRequested); });});
// ─── parseSocketFlag ────────────────────────────────────────────────────────────
describe('parseSocketFlag', () => { it('extracts --socket <path> and --socket=<path>', () => { expect(parseSocketFlag(['--socket', '/s'])).toEqual({ socketPath: '/s', args: [] }); expect(parseSocketFlag(['--socket=/s'])).toEqual({ socketPath: '/s', args: [] }); }); it('leaves other args untouched', () => { expect(parseSocketFlag(['--all', '--socket', '/s', 'x'])).toEqual({ socketPath: '/s', args: ['--all', 'x'] }); }); it('returns no socketPath when absent', () => { expect(parseSocketFlag(['--all'])).toEqual({ args: ['--all'] }); expect('socketPath' in parseSocketFlag(['--all'])).toBe(false); }); it('rejects duplicate --socket', () => { expect(() => parseSocketFlag(['--socket', '/a', '--socket', '/b'])).toThrow('duplicate --socket flag'); }); it('rejects missing/flag-like values', () => { expect(() => parseSocketFlag(['--socket'])).toThrow('invalid --socket value'); expect(() => parseSocketFlag(['--socket', '--all'])).toThrow('invalid --socket value'); expect(() => parseSocketFlag(['--socket='])).toThrow('invalid --socket value'); });});
// ─── Command builders ───────────────────────────────────────────────────────────
describe('buildCreateCommand', () => { it('maps --name→label, --base→base_ref, --integration-target→integration_target, version + cwd', () => { const args = parseNewArgs(['--worktree', '--name', 'feat', '--branch', 'refs/heads/feat', '--base', 'main', '--integration-target', 'refs/heads/main', '--title', 'T', '--model', 'm', '--provider', 'p', '--mode', 'build']); const cmd = buildCreateCommand(args, '/repo'); expect(cmd).toEqual({ type: 'workspace.create', id: '', version: 1, cwd: '/repo', label: 'feat', branch: 'refs/heads/feat', base_ref: 'main', integration_target: 'refs/heads/main', title: 'T', model: 'm', provider: 'p', mode: 'build', }); }); it('omits optional fields when absent', () => { const cmd = buildCreateCommand(parseNewArgs(['--worktree']), '/repo'); expect(cmd).toEqual({ type: 'workspace.create', id: '', version: 1, cwd: '/repo' }); expect('label' in cmd).toBe(false); expect('branch' in cmd).toBe(false); });});
describe('buildAttachCommand', () => { it('maps --workspace→workspace_id + launch fields, no cwd/label', () => { const args = parseNewArgs(['--workspace', WS, '--title', 'T', '--model', 'm', '--provider', 'p', '--mode', 'build']); const cmd = buildAttachCommand(args); expect(cmd).toEqual({ type: 'workspace.attach', id: '', version: 1, workspace_id: WS, title: 'T', model: 'm', provider: 'p', mode: 'build', }); expect('cwd' in cmd).toBe(false); expect('label' in cmd).toBe(false); });});
describe('buildListCommand', () => { it('sends cwd and active-only filtering by default (repository scope)', () => { expect(buildListCommand(parseListArgs([]), '/repo')).toEqual({ type: 'workspace.list', id: '', version: 1, cwd: '/repo', include_retired: false }); }); it('--all omits cwd', () => { const cmd = buildListCommand(parseListArgs(['--all']), '/repo'); expect(cmd).toEqual({ type: 'workspace.list', id: '', version: 1, include_retired: false }); expect('cwd' in cmd).toBe(false); }); it('--include-retired forwards include_retired: true', () => { expect(buildListCommand(parseListArgs(['--include-retired']), '/repo')).toEqual({ type: 'workspace.list', id: '', version: 1, cwd: '/repo', include_retired: true }); });});
// ─── toWireDisposition ──────────────────────────────────────────────────────────
describe('toWireDisposition', () => { it('maps keep/delete/force-delete', () => { expect(toWireDisposition('keep')).toBe('keep'); expect(toWireDisposition('delete')).toBe('delete_if_merged'); expect(toWireDisposition('force-delete')).toBe('delete_confirmed'); });});
// ─── Rendering ─────────────────────────────────────────────────────────────────
describe('rendering', () => { it('renderWorkspaceSummaryLine includes id, label, ownership/lifecycle, counts, branch, cwd', () => { const line = renderWorkspaceSummaryLine(summary({ branch: 'refs/heads/feat', integration_target: 'refs/heads/main' }) as never); expect(line).toContain('ws-aaaaaaaa-0000-4000-8000-000000000001'); expect(line).toContain('change-x'); expect(line).toContain('[managed/active]'); expect(line).toContain('bindings=1'); expect(line).toContain('live=1/1'); expect(line).toContain('branch=refs/heads/feat'); expect(line).toContain('→ refs/heads/main'); expect(line).toContain('cwd=/worktrees/change-x'); });
it('renderWorkspaceSummaryLine shows repo= (original project root) when present, omits it when absent', () => { const withRepo = renderWorkspaceSummaryLine(summary({ repository_cwd: '/home/hbn/code/shard' }) as never); expect(withRepo).toContain('repo=/home/hbn/code/shard'); expect(withRepo).toContain('cwd=/worktrees/change-x'); // Legacy daemon payload without the additive field renders unchanged. const withoutRepo = renderWorkspaceSummaryLine(summary() as never); expect(withoutRepo).not.toContain('repo='); });
it('renderWorkspaceDetail lists bindings + optional fields', () => { const d = detail({ last_head_commit: '0'.repeat(40), bindings: [{ agent_id: 'a1', session_id: 's1', bound_at: 1 }] }) as never; const out = renderWorkspaceDetail(d); expect(out).toContain('agent=a1 session=s1'); expect(out).toContain('head=' + '0'.repeat(40)); });
it('renderCreateAttach renders workspace + created Agent/Session ids', () => { const out = renderCreateAttach(createAttachResponse() as never); expect(out).toContain('agent=agent-fresh'); expect(out).toContain('session=11111111-2222-3333-4444-555555555555'); expect(out).toContain('ws-aaaaaaaa-0000-4000-8000-000000000001'); });
it('renderList prints each summary line', () => { expect(renderList(listResponse([summary({ workspace_id: 'ws-1' }), summary({ workspace_id: 'ws-2' })]) as never)).toContain('ws-1'); expect(renderList(listResponse([summary({ workspace_id: 'ws-1' }), summary({ workspace_id: 'ws-2' })]) as never)).toContain('ws-2'); });
it('renderList prints "No workspaces." for an empty list', () => { expect(renderList(listResponse([]) as never)).toBe('No workspaces.'); });
it('renderStatus delegates to renderWorkspaceDetail', () => { const out = renderStatus(detail() as never); expect(out).toContain('ws-aaaaaaaa-0000-4000-8000-000000000001'); });
it('renderRetire shows branch deleted vs kept', () => { expect(renderRetire(retireResponse(true) as never)).toContain('branch deleted'); expect(renderRetire(retireResponse(false) as never)).toContain('branch kept'); });});
// ─── Confirmation helpers ───────────────────────────────────────────────────────
describe('confirmation helpers', () => { const refusal = confirmationRefusal(WS);
it('buildConfirmationPrompt offers cancel/keep/confirmed-delete', () => { const p = buildConfirmationPrompt(refusal.data as never); expect(p).toContain('Cancel'); expect(p).toContain('Keep the branch'); expect(p).toContain('Delete the branch anyway'); expect(p).toContain('1-3'); });
it('parseConfirmationChoice maps numeric + word answers', () => { expect(parseConfirmationChoice('1')).toBe('cancel'); expect(parseConfirmationChoice('cancel')).toBe('cancel'); expect(parseConfirmationChoice('2')).toBe('keep'); expect(parseConfirmationChoice('keep')).toBe('keep'); expect(parseConfirmationChoice('3')).toBe('confirmed-delete'); expect(parseConfirmationChoice('delete')).toBe('confirmed-delete'); expect(parseConfirmationChoice('confirmed')).toBe('confirmed-delete'); expect(parseConfirmationChoice('')).toBe(undefined); expect(parseConfirmationChoice('9')).toBe(undefined); expect(parseConfirmationChoice('banana')).toBe(undefined); });
it('buildRetryHint lists explicit retry flags and never forces', () => { const hint = buildRetryHint(refusal.data as never); expect(hint).toContain('--keep'); expect(hint).toContain('--force-delete'); expect(hint).toContain(WS); // Never suggests auto-forcing: the hint requires an explicit flag. expect(hint).not.toContain('automatically'); });});
// ─── Runner: runNewSub ──────────────────────────────────────────────────────────
describe('runNewSub', () => { it('create: success → exit 0, renders workspace + Agent/Session ids, closes the client', async () => { const { deps, captured, out } = makeDeps({ replies: { 'workspace.create': [okReply(createAttachResponse())] } }); const code = await runNewSub(['--worktree', '--name', 'feat'], deps); expect(code).toBe(EXIT_OK); // Sent the create command from cwd with label. expect(captured.commands[0]).toMatchObject({ type: 'workspace.create', cwd: '/repo', label: 'feat', version: 1 }); // Output includes the created ids. expect(out.join('')).toContain('agent=agent-fresh'); expect(out.join('')).toContain('session=11111111-2222-3333-4444-555555555555'); // Client was closed exactly once. expect(captured.closes).toBe(1); });
it('attach: success → sends workspace.attach with workspace_id, renders ids', async () => { const { deps, captured, out } = makeDeps({ replies: { 'workspace.attach': [okReply(createAttachResponse('ws-attach'))] } }); const code = await runNewSub(['--workspace', 'ws-attach', '--title', 'follow-up'], deps); expect(code).toBe(EXIT_OK); expect(captured.commands[0]).toMatchObject({ type: 'workspace.attach', workspace_id: 'ws-attach', title: 'follow-up' }); expect(out.join('')).toContain('agent=agent-fresh'); });
it('unavailable daemon (no socket) → exit 2', async () => { const { deps, errOut } = makeDeps({ discover: undefined }); const code = await runNewSub(['--worktree'], deps); expect(code).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('daemon is not running'); });
it('connect failure → exit 2 (unavailable, not a refusal)', async () => { const { deps, errOut } = makeDeps({ connectError: new Error('ENOENT') }); const code = await runNewSub(['--worktree'], deps); expect(code).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('daemon is not running'); });
it('daemon refused (success:false) → exit 1', async () => { const { deps, errOut } = makeDeps({ replies: { 'workspace.create': [errReply({ code: 'CONFLICT', message: 'managed path already exists' })] } }); const code = await runNewSub(['--worktree'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); expect(errOut.join('')).toContain('managed path already exists'); });
it('runtime startup failure reports retained opaque identities and attach retry', async () => { const { deps, errOut } = makeDeps({ replies: { 'workspace.create': [errReply({ code: 'RUNTIME_START_FAILED', message: 'runtime startup failed', data: { workspace_id: WS, agent_id: 'agent-retained', session_id: 'session-retained', retryable: true }, })] } }); const code = await runNewSub(['--worktree'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); expect(errOut.join('')).toContain(`Workspace retained: ${WS}`); expect(errOut.join('')).toContain('Agent retained: agent-retained'); expect(errOut.join('')).toContain('Session retained: session-retained'); expect(errOut.join('')).toContain(`Retry with: shard new --workspace ${WS}`); });
it('malformed runtime startup failure data degrades without rendering partial identities', async () => { const { deps, errOut } = makeDeps({ replies: { 'workspace.create': [errReply({ code: 'RUNTIME_START_FAILED', message: 'runtime startup failed', data: { workspace_id: WS, agent_id: 42, retryable: true }, })] } }); const code = await runNewSub(['--worktree'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); expect(errOut.join('')).toContain('runtime startup failed'); expect(errOut.join('')).not.toContain('Workspace retained:'); expect(errOut.join('')).not.toContain('Retry with:'); });
it('schema-invalid response → exit 2 (client/protocol failure)', async () => { // Missing required agent_id/session_id → fails WorkspaceCreateResponseSchema. const { deps, errOut } = makeDeps({ replies: { 'workspace.create': [okReply({ workspace: detail(), agent_id: 'a' })] } }); const code = await runNewSub(['--worktree'], deps); expect(code).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('invalid workspace.create response'); });
it('parse error → exit 2', async () => { const { deps, errOut } = makeDeps(); const code = await runNewSub(['--bogus'], deps); expect(code).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('unknown argument'); });
it('missing required flag → exit 2', async () => { const { deps, errOut } = makeDeps(); const code = await runNewSub(['--title', 'T'], deps); expect(code).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('missing required flag'); });
it('ambiguous --worktree + --workspace → exit 2', async () => { const { deps, errOut } = makeDeps(); const code = await runNewSub(['--worktree', '--workspace', WS], deps); expect(code).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('mutually exclusive'); });
it('--help prints usage → exit 0', async () => { const { deps, errOut } = makeDeps(); const code = await runNewSub(['--help'], deps); expect(code).toBe(EXIT_OK); expect(errOut.join('')).toContain('Usage: shard new'); });
it('--socket override bypasses discover', async () => { const { deps, captured } = makeDeps({ discover: undefined, replies: { 'workspace.create': [okReply(createAttachResponse())] } }); const code = await runNewSub(['--worktree', '--socket', '/explicit.sock'], deps); expect(code).toBe(EXIT_OK); expect(captured.connects).toHaveLength(1); });});
// ─── Runner: runListSub ─────────────────────────────────────────────────────────
describe('runListSub', () => { it('default sends cwd; success → exit 0', async () => { const { deps, captured, out } = makeDeps({ replies: { 'workspace.list': [okReply(listResponse())] } }); const code = await runListSub([], deps); expect(code).toBe(EXIT_OK); expect(captured.commands[0]).toMatchObject({ type: 'workspace.list', cwd: '/repo' }); expect(out.join('')).toContain('ws-aaaaaaaa-0000-4000-8000-000000000001'); });
it('--all omits cwd', async () => { const { deps, captured } = makeDeps({ replies: { 'workspace.list': [okReply(listResponse())] } }); await runListSub(['--all'], deps); expect(captured.commands[0]).toMatchObject({ type: 'workspace.list' }); expect('cwd' in (captured.commands[0] as Record<string, unknown>)).toBe(false); });
it('--include-retired forwards include_retired', async () => { const { deps, captured } = makeDeps({ replies: { 'workspace.list': [okReply(listResponse())] } }); await runListSub(['--include-retired'], deps); expect(captured.commands[0]).toMatchObject({ include_retired: true }); });
it('empty list prints "No workspaces."', async () => { const { deps, out } = makeDeps({ replies: { 'workspace.list': [okReply(listResponse([]))] } }); await runListSub([], deps); expect(out.join('')).toContain('No workspaces.'); });
it('unavailable daemon → exit 2', async () => { const { deps, errOut } = makeDeps({ discover: undefined }); expect(await runListSub([], deps)).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('daemon is not running'); });
it('refused → exit 1', async () => { const { deps, errOut } = makeDeps({ replies: { 'workspace.list': [errReply({ code: 'NOT_FOUND', message: 'workspace lifecycle unavailable' }, 'workspace.list')] } }); expect(await runListSub([], deps)).toBe(EXIT_DAEMON_REFUSED); expect(errOut.join('')).toContain('workspace lifecycle unavailable'); });});
// ─── Runner: runStatusSub ───────────────────────────────────────────────────────
describe('runStatusSub', () => { it('success → exit 0, renders detail', async () => { const { deps, captured, out } = makeDeps({ replies: { 'workspace.status': [okReply(detail({ summary: summary({ workspace_id: WS }) }))] } }); const code = await runStatusSub([WS], deps); expect(code).toBe(EXIT_OK); expect(captured.commands[0]).toMatchObject({ type: 'workspace.status', workspace_id: WS, version: 1 }); expect(out.join('')).toContain(WS); });
it('NOT_FOUND refused → exit 1', async () => { const { deps, errOut } = makeDeps({ replies: { 'workspace.status': [errReply({ code: 'NOT_FOUND', message: 'workspace not found: ws-x' }, 'workspace.status')] } }); expect(await runStatusSub(['ws-x'], deps)).toBe(EXIT_DAEMON_REFUSED); expect(errOut.join('')).toContain('workspace not found'); });
it('unavailable → exit 2', async () => { const { deps } = makeDeps({ discover: undefined }); expect(await runStatusSub([WS], deps)).toBe(EXIT_DAEMON_UNAVAILABLE); });
it('missing id → exit 2', async () => { const { deps, errOut } = makeDeps(); expect(await runStatusSub([], deps)).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('missing required workspace id'); });});
// ─── Runner: runRetireSub ───────────────────────────────────────────────────────
describe('runRetireSub — disposition mapping', () => { it('keep → branch_disposition keep, branch kept rendered', async () => { const { deps, captured, out } = makeDeps({ replies: { 'workspace.retire': [okReply(retireResponse(false))] } }); const code = await runRetireSub([WS, '--keep'], deps); expect(code).toBe(EXIT_OK); expect(captured.commands[0]).toMatchObject({ type: 'workspace.retire', workspace_id: WS, branch_disposition: 'keep' }); expect(out.join('')).toContain('branch kept'); });
it('delete → branch_disposition delete_if_merged', async () => { const { deps, captured } = makeDeps({ replies: { 'workspace.retire': [okReply(retireResponse(true))] } }); await runRetireSub([WS, '--delete'], deps); expect(captured.commands[0]).toMatchObject({ branch_disposition: 'delete_if_merged' }); });
it('force-delete → branch_disposition delete_confirmed', async () => { const { deps, captured } = makeDeps({ replies: { 'workspace.retire': [okReply(retireResponse(true))] } }); await runRetireSub([WS, '--force-delete'], deps); expect(captured.commands[0]).toMatchObject({ branch_disposition: 'delete_confirmed' }); });
it('default (no disposition) → keep', async () => { const { deps, captured } = makeDeps({ replies: { 'workspace.retire': [okReply(retireResponse(false))] } }); await runRetireSub([WS], deps); expect(captured.commands[0]).toMatchObject({ branch_disposition: 'keep' }); });
it('unavailable → exit 2', async () => { const { deps } = makeDeps({ discover: undefined }); expect(await runRetireSub([WS, '--keep'], deps)).toBe(EXIT_DAEMON_UNAVAILABLE); });});
describe('runRetireSub — confirmation_required (noninteractive)', () => { it('--delete refusal + noninteractive → actionable retry hint, exit 1, NEVER forces', async () => { const { deps, errOut, captured } = makeDeps({ isTTY: false, replies: { 'workspace.retire': [errReply(confirmationRefusal(WS), 'workspace.retire')] }, }); const code = await runRetireSub([WS, '--delete'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); // Only one command sent (no automatic retry/force). expect(captured.commands).toHaveLength(1); // Hint lists explicit retry flags. const text = errOut.join(''); expect(text).toContain('--keep'); expect(text).toContain('--force-delete'); expect(text).toContain(WS); });
it('--force-delete refusal (non-confirmation path) → plain error, exit 1', async () => { // A confirmation_required refusal from --force-delete is surfaced as a plain error: // only --delete triggers the confirmation flow; we never force automatically. const { deps, errOut, captured } = makeDeps({ isTTY: false, replies: { 'workspace.retire': [errReply(confirmationRefusal(WS), 'workspace.retire')] }, }); const code = await runRetireSub([WS, '--force-delete'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); expect(captured.commands).toHaveLength(1); expect(errOut.join('')).toContain('not merged'); });
it('non-confirmation refusal (e.g. live bindings) → plain error, exit 1', async () => { const { deps, errOut } = makeDeps({ isTTY: false, replies: { 'workspace.retire': [errReply({ code: 'CONFLICT', message: 'cannot retire workspace with live binding' }, 'workspace.retire')] }, }); const code = await runRetireSub([WS, '--delete'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); expect(errOut.join('')).toContain('live binding'); });});
describe('runRetireSub — confirmation_required (interactive)', () => { it('cancel → exit 1, no retry sent', async () => { const { deps, captured, errOut } = makeDeps({ isTTY: true, answers: ['1'], replies: { 'workspace.retire': [errReply(confirmationRefusal(WS), 'workspace.retire')] }, }); const code = await runRetireSub([WS, '--delete'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); expect(captured.commands).toHaveLength(1); // only the initial --delete expect(errOut.join('')).toContain('Cancelled'); });
it('keep → retries with branch_disposition keep, exit 0', async () => { const { deps, captured, out } = makeDeps({ isTTY: true, answers: ['2'], replies: { 'workspace.retire': [errReply(confirmationRefusal(WS), 'workspace.retire'), okReply(retireResponse(false))] }, }); const code = await runRetireSub([WS, '--delete'], deps); expect(code).toBe(EXIT_OK); expect(captured.commands).toHaveLength(2); expect(captured.commands[0]).toMatchObject({ branch_disposition: 'delete_if_merged' }); expect(captured.commands[1]).toMatchObject({ branch_disposition: 'keep' }); expect(out.join('')).toContain('branch kept'); });
it('confirmed-delete → retries with branch_disposition delete_confirmed, exit 0', async () => { const { deps, captured, out } = makeDeps({ isTTY: true, answers: ['3'], replies: { 'workspace.retire': [errReply(confirmationRefusal(WS), 'workspace.retire'), okReply(retireResponse(true))] }, }); const code = await runRetireSub([WS, '--delete'], deps); expect(code).toBe(EXIT_OK); expect(captured.commands).toHaveLength(2); expect(captured.commands[1]).toMatchObject({ branch_disposition: 'delete_confirmed' }); expect(out.join('')).toContain('branch deleted'); });
it('invalid answer re-prompts, then keep succeeds', async () => { const { deps, captured } = makeDeps({ isTTY: true, answers: ['banana', '2'], replies: { 'workspace.retire': [errReply(confirmationRefusal(WS), 'workspace.retire'), okReply(retireResponse(false))] }, }); const code = await runRetireSub([WS, '--delete'], deps); expect(code).toBe(EXIT_OK); expect(captured.commands).toHaveLength(2); });
it('three invalid answers → cancel, exit 1', async () => { const { deps, errOut, captured } = makeDeps({ isTTY: true, answers: ['x', 'y', 'z'], replies: { 'workspace.retire': [errReply(confirmationRefusal(WS), 'workspace.retire')] }, }); const code = await runRetireSub([WS, '--delete'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); expect(captured.commands).toHaveLength(1); expect(errOut.join('')).toContain('no valid choice'); });
it('retry that fails again is surfaced as a plain error (no infinite retry)', async () => { const { deps, errOut, captured } = makeDeps({ isTTY: true, answers: ['3'], replies: { 'workspace.retire': [errReply(confirmationRefusal(WS), 'workspace.retire'), errReply({ code: 'CONFLICT', message: 'still live' }, 'workspace.retire')] }, }); const code = await runRetireSub([WS, '--delete'], deps); expect(code).toBe(EXIT_DAEMON_REFUSED); expect(captured.commands).toHaveLength(2); expect(errOut.join('')).toContain('still live'); });});
// ─── Runner: runWorktreeSub dispatch ────────────────────────────────────────────
describe('runWorktreeSub dispatch', () => { it('routes list/status/retire', async () => { const listDeps = makeDeps({ replies: { 'workspace.list': [okReply(listResponse([]))] } }); expect(await runWorktreeSub(['list'], listDeps.deps)).toBe(EXIT_OK); const statusDeps = makeDeps({ replies: { 'workspace.status': [okReply(detail())] } }); expect(await runWorktreeSub(['status', WS], statusDeps.deps)).toBe(EXIT_OK); const retireDeps = makeDeps({ replies: { 'workspace.retire': [okReply(retireResponse(false))] } }); expect(await runWorktreeSub(['retire', WS, '--keep'], retireDeps.deps)).toBe(EXIT_OK); });
it('unknown subcommand → exit 2', async () => { const { deps, errOut } = makeDeps(); expect(await runWorktreeSub(['bogus'], deps)).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('unknown worktree subcommand'); });
it('no subcommand → exit 2 with usage', async () => { const { deps, errOut } = makeDeps(); expect(await runWorktreeSub([], deps)).toBe(EXIT_DAEMON_UNAVAILABLE); expect(errOut.join('')).toContain('Usage: shard worktree'); });
it('--help → exit 0 with usage', async () => { const { deps, errOut } = makeDeps(); expect(await runWorktreeSub(['--help'], deps)).toBe(EXIT_OK); expect(errOut.join('')).toContain('Usage: shard worktree'); });});
// ─── defaultWorktreeCmdDeps sanity ──────────────────────────────────────────────
describe('defaultWorktreeCmdDeps', () => { it('builds a deps object with the expected surface', () => { const deps = defaultWorktreeCmdDeps(); expect(typeof deps.cwd()).toBe('string'); expect(typeof deps.isTTY()).toBe('boolean'); expect(typeof deps.discover).toBe('function'); expect(typeof deps.connect).toBe('function'); expect(typeof deps.prompt.question).toBe('function'); expect(typeof deps.stdout.write).toBe('function'); expect(typeof deps.stderr.write).toBe('function'); });});