import type { Stats } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { isAbsolute, relative } from "node:path"; import { type Static, type TSchema, Type } from "typebox"; import { hasParsedArgumentSyntax, MAX_BRANCH_NAME_LENGTH, MAX_LABEL_LENGTH, MAX_PATH_LENGTH, MAX_WORKSPACE_ID_LENGTH, MIN_WORKSPACE_ID_LENGTH, type ParsedAbsolutePath, type ParsedArgumentValue, type ParsedBase, type ParsedBranchName, type ParsedLabel, type ParsedRevision, type ParsedWorkspaceId, parseAbsolutePath, parseBase, parseBranchName, parseLabel, parseWorkspaceId, } from "./argument.js"; import { type GitRepository, type HerdrCapability, HerdrCapabilityResolver, HerdrUnavailableError, } from "./capability.js"; import { type HerdrPromptApi, registerHerdrGuidance } from "./guidance.js"; import { CommandCancelledError, CommandInvocationError, type CommandResult, type CommandRunner, runCommand, } from "./process.js"; import { parseEnvelope, parseWorktreeList, parseWorktreeOpened, parseWorktreeRemoved, type WorktreeInfo, type WorktreeListResult, type WorktreeOpenedResult, type WorktreeRemovedResult, } from "./response.js"; import { findGitWorktree, type GitWorktreeEntry, readGitWorktrees } from "./worktree.js"; const BranchName = Type.String({ minLength: 1, maxLength: MAX_BRANCH_NAME_LENGTH, description: "Exact branch name. Leading options are rejected.", }); const CheckoutPath = Type.String({ minLength: 1, maxLength: MAX_PATH_LENGTH, description: "Absolute path of the worktree checkout. Omit to let Herdr choose its default path.", }); const WorkspaceLabel = Type.String({ minLength: 1, maxLength: MAX_LABEL_LENGTH, description: "Single-line label shown for the workspace in the Herdr sidebar.", }); const Focus = Type.Boolean({ description: "Move the user's view to the new workspace. Defaults to false.", }); const HerdrWorktreeParameters = Type.Union([ Type.Object({ operation: Type.Literal("list") }, { additionalProperties: false }), Type.Object( { operation: Type.Literal("create"), branch: BranchName, base: Type.Optional( Type.String({ minLength: 1, maxLength: MAX_BRANCH_NAME_LENGTH, description: "Existing commit, branch, or tag the new branch starts from.", }), ), path: Type.Optional(CheckoutPath), label: Type.Optional(WorkspaceLabel), focus: Type.Optional(Focus), }, { additionalProperties: false }, ), Type.Object( { operation: Type.Literal("open"), path: Type.Optional(CheckoutPath), branch: Type.Optional(BranchName), label: Type.Optional(WorkspaceLabel), focus: Type.Optional(Focus), }, { additionalProperties: false }, ), Type.Object( { operation: Type.Literal("remove"), workspace_id: Type.String({ minLength: MIN_WORKSPACE_ID_LENGTH, maxLength: MAX_WORKSPACE_ID_LENGTH, description: "Herdr workspace id of the worktree workspace to remove, such as `wG`.", }), force: Type.Optional( Type.Boolean({ description: "Remove the checkout even when it holds uncommitted changes.", }), ), }, { additionalProperties: false }, ), ]); type HerdrWorktreeParameters = Static; type HerdrWorktreeOperation = HerdrWorktreeParameters["operation"]; /** The fields one operation accepts, typed against the schema so the two cannot drift. */ type FieldsOf = keyof Extract< HerdrWorktreeParameters, { operation: TOperation } > & string; interface RequestSchema { readonly required: readonly FieldsOf[]; readonly optional: readonly FieldsOf[]; } export const OPERATION_FIELDS: { readonly [K in HerdrWorktreeOperation]: RequestSchema } = { list: { required: ["operation"], optional: [] }, create: { required: ["operation", "branch"], optional: ["base", "path", "label", "focus"] }, open: { required: ["operation"], optional: ["path", "branch", "label", "focus"] }, remove: { required: ["operation", "workspace_id"], optional: ["force"] }, }; type ParsedOperation = | { readonly operation: "list" } | { readonly operation: "create"; readonly branch: ParsedBranchName; readonly base: ParsedBase | undefined; readonly path: ParsedAbsolutePath | undefined; readonly label: ParsedLabel | undefined; readonly focus: boolean; } | { readonly operation: "open"; readonly path: ParsedAbsolutePath | undefined; readonly branch: ParsedBranchName | undefined; readonly label: ParsedLabel | undefined; readonly focus: boolean; } | { readonly operation: "remove"; readonly workspaceId: ParsedWorkspaceId; readonly force: boolean; }; /** The only `herdr` subcommand this extension is allowed to spawn. */ const HERDR_SUBCOMMAND = "worktree"; type HerdrAction = "list" | "create" | "open" | "remove"; type HerdrSwitch = "--focus" | "--no-focus" | "--force"; /** An option and its value, so a value can only travel under the option it was parsed for. */ type HerdrOption = | { readonly name: "--cwd"; readonly value: ParsedAbsolutePath } | { readonly name: "--branch"; readonly value: ParsedBranchName } | { readonly name: "--base"; readonly value: ParsedRevision } | { readonly name: "--path"; readonly value: ParsedAbsolutePath } | { readonly name: "--label"; readonly value: ParsedLabel } | { readonly name: "--workspace"; readonly value: ParsedWorkspaceId }; interface HerdrCommand { readonly action: HerdrAction; readonly options: readonly HerdrOption[]; readonly switches: readonly HerdrSwitch[]; } const ALLOWED_OPTIONS: Record = { list: ["--cwd"], create: ["--cwd", "--branch", "--base", "--path", "--label"], open: ["--cwd", "--branch", "--path", "--label"], remove: ["--workspace"], }; const ALLOWED_SWITCHES: Record = { list: [], create: ["--focus", "--no-focus"], open: ["--focus", "--no-focus"], remove: ["--force"], }; interface TextContent { readonly type: "text"; readonly text: string; } interface ToolResult { readonly content: readonly TextContent[]; readonly details: TDetails; } interface ToolContext { readonly cwd: string; readonly ui?: { confirm(title: string, message: string): Promise; }; } type ToolTier = "read" | "write" | "exec"; type ToolApprovalDecision = | ToolTier | { readonly tier: ToolTier; readonly reason?: string; readonly policy?: "allow" | "deny" | "prompt"; }; interface ToolDefinition { readonly name: string; readonly label: string; readonly description: string; readonly parameters: TParameters; readonly approval: ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision); readonly loadMode: "essential" | "discoverable"; readonly concurrency?: | "shared" | "exclusive" | ((args: Partial>) => "shared" | "exclusive"); readonly executionMode?: "sequential" | "parallel"; readonly formatApprovalDetails?: (args: unknown) => string | readonly string[] | undefined; execute( toolCallId: string, parameters: Static, signal: AbortSignal | undefined, onUpdate: unknown, context: ToolContext, ): Promise>; } export interface HerdrExtensionApi extends HerdrPromptApi { registerTool( definition: ToolDefinition, ): void; } export interface HerdrExtensionDependencies { readonly runner?: CommandRunner; readonly capabilities?: HerdrCapabilityResolver; } interface CommandDetails { readonly executable: string; readonly args: readonly string[]; readonly exitCode: number; } interface CapabilityDetails { readonly repositoryRoot: string; readonly herdrVersion: string; readonly cache: HerdrCapability["cache"]; } interface ListDetails { readonly operation: "list"; readonly capability: CapabilityDetails; readonly command: CommandDetails; readonly result: WorktreeListResult; } interface OpenDetails { readonly operation: "create" | "open"; readonly capability: CapabilityDetails; readonly command: CommandDetails; readonly result: WorktreeOpenedResult; } interface RemoveDetails { readonly operation: "remove"; readonly capability: CapabilityDetails; readonly command: CommandDetails; readonly result: WorktreeRemovedResult; } type HerdrWorktreeToolDetails = ListDetails | OpenDetails | RemoveDetails; const repositoryQueues = new Map>(); async function withRepositoryLock( repositoryRoot: ParsedAbsolutePath, signal: AbortSignal | undefined, operation: () => Promise, ): Promise { const predecessor = repositoryQueues.get(repositoryRoot) ?? Promise.resolve(); const { promise: releasePromise, resolve: release } = Promise.withResolvers(); const queueTail = predecessor.then(() => releasePromise); repositoryQueues.set(repositoryRoot, queueTail); await predecessor; try { if (signal?.aborted) { throw new CommandCancelledError("Herdr worktree operation"); } return await operation(); } finally { release(); if (repositoryQueues.get(repositoryRoot) === queueTail) { repositoryQueues.delete(repositoryRoot); } } } function capabilityDetails(capability: HerdrCapability): CapabilityDetails { return { repositoryRoot: capability.repository.root, herdrVersion: capability.herdrVersion, cache: capability.cache, }; } function commandDetails(result: CommandResult): CommandDetails { return { executable: result.command, args: result.args, exitCode: result.exitCode }; } function parseOperationFrom(args: unknown): HerdrWorktreeOperation | undefined { if (!args || typeof args !== "object") { return undefined; } switch (Reflect.get(args, "operation")) { case "list": return "list"; case "create": return "create"; case "open": return "open"; case "remove": return "remove"; default: return undefined; } } /** The fields an operation declares, with values that are not parsed yet. */ type RequestFields = Readonly< Record, unknown> >; function parseRequestFields( parameters: unknown, operation: TOperation, fields: RequestSchema, ): RequestFields { if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { throw new Error(`Operation ${operation} requires an object request.`); } const entries = Object.entries(parameters); const present = entries.map(([field]) => field); const allowed: readonly string[] = [...fields.required, ...fields.optional]; const unexpected = present.filter((field) => !allowed.includes(field)); if (unexpected.length > 0) { throw new Error( `Operation ${operation} does not accept ${unexpected.join(", ")}. Allowed fields: ${allowed.join(", ")}.`, ); } const missing = fields.required.filter((field) => !present.includes(field)); if (missing.length > 0) { throw new Error(`Operation ${operation} requires ${missing.join(", ")}.`); } // A null prototype, so an absent field cannot resolve to an inherited value. return Object.assign( Object.create(null) as Record, Object.fromEntries(entries), ) as RequestFields; } function parseFlag(raw: unknown, label: string): boolean { if (raw === undefined || typeof raw === "boolean") { return raw === true; } throw new Error(`${label} must be a boolean.`); } function optional(raw: unknown, parse: (raw: unknown) => T): T | undefined { return raw === undefined ? undefined : parse(raw); } async function optionalAsync( raw: unknown, parse: (raw: unknown) => Promise, ): Promise { return raw === undefined ? undefined : await parse(raw); } async function parseOperation( parameters: unknown, runner: CommandRunner, cwd: string, signal: AbortSignal | undefined, ): Promise { const operation = parseOperationFrom(parameters); if (!operation) { throw new Error("Unknown Herdr worktree operation."); } switch (operation) { case "list": parseRequestFields(parameters, operation, OPERATION_FIELDS[operation]); return { operation }; case "create": { const fields = parseRequestFields(parameters, operation, OPERATION_FIELDS[operation]); return { operation, branch: await parseBranchName(runner, fields.branch, "branch", cwd, signal), base: await optionalAsync(fields.base, (raw) => parseBase(runner, raw, "base", cwd, signal), ), path: optional(fields.path, (raw) => parseAbsolutePath(raw, "path")), label: optional(fields.label, (raw) => parseLabel(raw, "label")), focus: parseFlag(fields.focus, "focus"), }; } case "open": { const fields = parseRequestFields(parameters, operation, OPERATION_FIELDS[operation]); const path = optional(fields.path, (raw) => parseAbsolutePath(raw, "path")); const branch = await optionalAsync(fields.branch, (raw) => parseBranchName(runner, raw, "branch", cwd, signal), ); if ((path === undefined) === (branch === undefined)) { throw new Error("Operation open requires exactly one of path or branch."); } return { operation, path, branch, label: optional(fields.label, (raw) => parseLabel(raw, "label")), focus: parseFlag(fields.focus, "focus"), }; } case "remove": { const fields = parseRequestFields(parameters, operation, OPERATION_FIELDS[operation]); return { operation, workspaceId: parseWorkspaceId(fields.workspace_id, "workspace_id"), force: parseFlag(fields.force, "force"), }; } } } function approvalFor(args: unknown): ToolApprovalDecision { const operation = parseOperationFrom(args); if (operation === "list") { return "read"; } if (operation === "remove") { return { tier: "exec", policy: "prompt", reason: "Removing a worktree deletes its checkout and closes its Herdr workspace.", }; } // A caller-chosen path writes wherever it names, so the user decides. Otherwise Herdr picks. if (operation === "create" && args && typeof args === "object") { if (Reflect.get(args, "path") !== undefined) { return { tier: "exec", policy: "prompt", reason: "Creating a worktree at an explicit path writes to a directory the caller chose.", }; } } if (operation) { return "exec"; } return { tier: "exec", policy: "deny", reason: "Unknown Herdr worktree operation." }; } function concurrencyFor(args: Partial): "shared" | "exclusive" { return parseOperationFrom(args) === "list" ? "shared" : "exclusive"; } const APPROVAL_VALUE_MAX_LENGTH = 200; /** * The approval prompt is built before the request is parsed, so it shows raw text. A value must * stay on one line, or it could forge the lines around it. */ function approvalValue(raw: unknown): string { const text = typeof raw === "string" ? raw : (JSON.stringify(raw) ?? String(raw)); const singleLine = text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, " ").trim(); return singleLine.length > APPROVAL_VALUE_MAX_LENGTH ? `${singleLine.slice(0, APPROVAL_VALUE_MAX_LENGTH)}...` : singleLine; } function approvalDetails(args: unknown): readonly string[] | undefined { const operation = parseOperationFrom(args); if (!operation || !args || typeof args !== "object") { return undefined; } const lines = [`Operation: ${operation}`]; for (const field of ["branch", "base", "path", "label", "workspace_id"]) { const raw = Reflect.get(args, field); if (raw !== undefined) { lines.push(`${field}: ${approvalValue(raw)}`); } } if (Reflect.get(args, "force") === true) { lines.push("force: discards uncommitted changes in the checkout"); } return lines; } /** * The last check before a spawn, although the types already make most of it impossible. Herdr * does not accept `--flag=value`, so every value travels as its own argument. */ function buildHerdrArguments(command: HerdrCommand): readonly string[] { if (!Object.hasOwn(ALLOWED_OPTIONS, command.action)) { throw new Error( `Refusing to run an unknown Herdr worktree action: ${JSON.stringify(command.action)}.`, ); } const allowedOptions = ALLOWED_OPTIONS[command.action]; const allowedSwitches = ALLOWED_SWITCHES[command.action]; const args: string[] = [HERDR_SUBCOMMAND, command.action]; const used = new Set(); for (const option of command.options) { if (!allowedOptions.includes(option.name)) { throw new Error(`Refusing to pass ${option.name} to ${command.action}.`); } if (used.has(option.name)) { throw new Error(`Refusing to pass ${option.name} twice.`); } const value: ParsedArgumentValue = option.value; if (!hasParsedArgumentSyntax(value)) { throw new Error(`Refusing to pass ${option.name} without a safe value.`); } used.add(option.name); args.push(option.name, value); } for (const flag of command.switches) { if (!allowedSwitches.includes(flag)) { throw new Error(`Refusing to pass ${flag} to ${command.action}.`); } if (used.has(flag)) { throw new Error(`Refusing to pass ${flag} twice.`); } used.add(flag); args.push(flag); } if (used.has("--focus") && used.has("--no-focus")) { throw new Error("Refusing to pass both --focus and --no-focus."); } return args; } async function runHerdr( runner: CommandRunner, capabilities: HerdrCapabilityResolver, capability: HerdrCapability, command: HerdrCommand, signal: AbortSignal | undefined, ): Promise<{ result: CommandResult; payload: Record }> { const args = buildHerdrArguments(command); let result: CommandResult; try { result = await runner("herdr", args, { cwd: capability.repository.root, signal }); } catch (error) { if (error instanceof CommandInvocationError) { capabilities.forget(capability.repository.root); throw new HerdrUnavailableError( "The Herdr session was detected, but the `herdr` executable could not be started.", { cause: error }, ); } throw error; } // Exit 2 is a CLI syntax error: the argument vocabulary above and the installed CLI // disagree, which is a bug here rather than a rejected request. if (result.exitCode === 2) { capabilities.forget(capability.repository.root); throw new HerdrUnavailableError( `The installed Herdr CLI rejected the argument list: ${[result.stderr, result.stdout] .map((text) => text.trim()) .filter(Boolean) .join("\n")}`, ); } return { result, payload: parseEnvelope(result) }; } function describeWorktree(worktree: WorktreeInfo): string { const branch = worktree.branch ?? "detached HEAD"; const workspace = worktree.openWorkspaceId ? ` in workspace ${worktree.openWorkspaceId}` : ""; const flags = [ worktree.isLinkedWorktree ? "linked" : "main checkout", worktree.isPrunable ? "prunable" : undefined, worktree.isBare ? "bare" : undefined, ].filter(Boolean); return `${worktree.path} [${branch}]${workspace} (${flags.join(", ")})`; } function textResult(text: string, details: TDetails): ToolResult { return { content: [{ type: "text", text }], details }; } async function assertGitWorktreeRegistered( runner: CommandRunner, cwd: string, signal: AbortSignal | undefined, path: string, branch: string | null, label: string, ): Promise { const entries = await readGitWorktrees(runner, cwd, signal); const entry = findGitWorktree(entries, path); if (!entry) { throw new Error(`Git does not list ${JSON.stringify(path)} as a worktree of this repository.`); } if (branch !== null && entry.branch !== branch) { throw new Error( `${label} reports branch ${JSON.stringify(branch)}, but Git has ${JSON.stringify(entry.branch)} checked out there.`, ); } return entry; } function isInside(directory: string, path: string): boolean { const offset = relative(directory, path); return offset === "" || (!offset.startsWith("..") && !isAbsolute(offset)); } /** * Git refuses to overwrite files too, but only after Herdr made the workspace for it. The tool may * run from a linked worktree, where the main checkout is not the root Git reported, so every * registered checkout is checked and not only that root. */ async function assertCheckoutPathAvailable( path: ParsedAbsolutePath, repository: GitRepository, existing: readonly GitWorktreeEntry[], ): Promise { const directories: readonly (readonly [string, string])[] = [ [repository.root, "repository"], [repository.gitDir, "Git directory"], ...existing.map((entry) => [entry.path, "worktree"] as const), ]; for (const [directory, label] of directories) { if (isInside(directory, path)) { throw new Error( `path ${JSON.stringify(path)} is inside the ${label} at ${JSON.stringify(directory)}. Create the worktree outside it.`, ); } } let target: Stats; try { target = await stat(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return; } throw new Error(`Unable to inspect path ${JSON.stringify(path)}.`, { cause: error }); } if (!target.isDirectory()) { throw new Error(`path ${JSON.stringify(path)} already exists and is not a directory.`); } if ((await readdir(path)).length > 0) { throw new Error(`path ${JSON.stringify(path)} already exists and is not empty.`); } } function focusSwitch(focus: boolean): HerdrSwitch { return focus ? "--focus" : "--no-focus"; } function listCommand(root: ParsedAbsolutePath): HerdrCommand { return { action: "list", options: [{ name: "--cwd", value: root }], switches: [] }; } export function registerHerdrWorktreeTools( pi: HerdrExtensionApi, dependencies: HerdrExtensionDependencies = {}, ): void { const runner = dependencies.runner ?? runCommand; const capabilities = dependencies.capabilities ?? new HerdrCapabilityResolver(runner); registerHerdrGuidance(pi, capabilities); pi.registerTool({ name: "herdr_worktree", label: "Herdr worktree", description: "List, create, open, and remove Herdr Git worktree workspaces for the current repository, with checked arguments and verified results.", parameters: HerdrWorktreeParameters, approval: approvalFor, formatApprovalDetails: approvalDetails, loadMode: "discoverable", concurrency: concurrencyFor, executionMode: "sequential", async execute(_toolCallId, parameters, signal, _onUpdate, context) { const capability = await capabilities.ensure(context.cwd, signal); const root = capability.repository.root; const operation = await parseOperation(parameters, runner, root, signal); switch (operation.operation) { case "list": { const { result, payload } = await runHerdr( runner, capabilities, capability, listCommand(root), signal, ); const list = parseWorktreeList(payload); const lines = list.worktrees.map((worktree) => `- ${describeWorktree(worktree)}`); const body = lines.length > 0 ? lines.join("\n") : "No worktrees are registered."; return textResult(`${list.source.repoName} (${list.source.repoRoot})\n${body}`, { operation: "list", capability: capabilityDetails(capability), command: commandDetails(result), result: list, }); } case "create": return withRepositoryLock(root, signal, async () => { const existing = await readGitWorktrees(runner, root, signal); if (existing.some((entry) => entry.branch === operation.branch)) { throw new Error( `Branch ${JSON.stringify(operation.branch)} is already checked out in a worktree of this repository. Open it instead of creating it.`, ); } if (operation.path && findGitWorktree(existing, operation.path)) { throw new Error( `${JSON.stringify(operation.path)} is already a worktree of this repository.`, ); } if (operation.path) { await assertCheckoutPathAvailable(operation.path, capability.repository, existing); } const options: HerdrOption[] = [ { name: "--cwd", value: root }, { name: "--branch", value: operation.branch }, ]; if (operation.base) { options.push({ name: "--base", value: operation.base.revision }); } if (operation.path) { options.push({ name: "--path", value: operation.path }); } if (operation.label) { options.push({ name: "--label", value: operation.label }); } const { result, payload } = await runHerdr( runner, capabilities, capability, { action: "create", options, switches: [focusSwitch(operation.focus)] }, signal, ); const created = parseWorktreeOpened(payload); if (created.type !== "worktree_created") { throw new Error( `Herdr answered a create request with ${JSON.stringify(created.type)}.`, ); } if (created.worktree.branch !== operation.branch) { throw new Error( `Herdr created the worktree on branch ${JSON.stringify(created.worktree.branch)} instead of ${JSON.stringify(operation.branch)}.`, ); } if (!created.worktree.isLinkedWorktree) { throw new Error("Herdr reported the main checkout instead of a new linked worktree."); } if (operation.path && created.worktree.path !== operation.path) { throw new Error( `Herdr created the worktree at ${JSON.stringify(created.worktree.path)} instead of ${JSON.stringify(operation.path)}.`, ); } const entry = await assertGitWorktreeRegistered( runner, root, signal, created.worktree.path, created.worktree.branch, "Herdr", ); // A ref can move between the parse and the create, so the commit is checked again. if (operation.base && entry.head !== operation.base.commit) { throw new Error( `base ${JSON.stringify(operation.base.revision)} was at ${operation.base.commit}, but the new worktree is at ${JSON.stringify(entry.head)}.`, ); } return textResult( `Created ${describeWorktree(created.worktree)}\nWorkspace ${created.workspaceId}, tab ${created.tabId}, pane ${created.rootPaneId}`, { operation: "create", capability: capabilityDetails(capability), command: commandDetails(result), result: created, }, ); }); case "open": return withRepositoryLock(root, signal, async () => { const options: HerdrOption[] = [{ name: "--cwd", value: root }]; if (operation.path) { options.push({ name: "--path", value: operation.path }); } if (operation.branch) { options.push({ name: "--branch", value: operation.branch }); } if (operation.label) { options.push({ name: "--label", value: operation.label }); } const { result, payload } = await runHerdr( runner, capabilities, capability, { action: "open", options, switches: [focusSwitch(operation.focus)] }, signal, ); const opened = parseWorktreeOpened(payload); if (opened.type !== "worktree_opened") { throw new Error( `Herdr answered an open request with ${JSON.stringify(opened.type)}.`, ); } if (operation.branch && opened.worktree.branch !== operation.branch) { throw new Error( `Herdr opened branch ${JSON.stringify(opened.worktree.branch)} instead of ${JSON.stringify(operation.branch)}.`, ); } if (operation.path && opened.worktree.path !== operation.path) { throw new Error( `Herdr opened ${JSON.stringify(opened.worktree.path)} instead of ${JSON.stringify(operation.path)}.`, ); } await assertGitWorktreeRegistered( runner, root, signal, opened.worktree.path, opened.worktree.branch, "Herdr", ); const prefix = opened.alreadyOpen ? "Already open:" : "Opened"; return textResult( `${prefix} ${describeWorktree(opened.worktree)}\nWorkspace ${opened.workspaceId}, tab ${opened.tabId}, pane ${opened.rootPaneId}`, { operation: "open", capability: capabilityDetails(capability), command: commandDetails(result), result: opened, }, ); }); case "remove": return withRepositoryLock(root, signal, async () => { const { payload: listPayload } = await runHerdr( runner, capabilities, capability, listCommand(root), signal, ); const list = parseWorktreeList(listPayload); const target = list.worktrees.find( (worktree) => worktree.openWorkspaceId === operation.workspaceId, ); if (!target) { throw new Error( `Workspace ${JSON.stringify(operation.workspaceId)} does not hold a worktree of ${JSON.stringify(list.source.repoRoot)}.`, ); } if (!target.isLinkedWorktree) { throw new Error( `Workspace ${JSON.stringify(operation.workspaceId)} holds the main checkout at ${JSON.stringify(target.path)}, which cannot be removed.`, ); } if (target.path === capability.repository.root) { throw new Error("remove cannot remove the repository the tool is running in."); } const confirmed = await context.ui?.confirm( `Remove Herdr worktree ${target.path}?`, operation.force ? "This deletes the checkout even if it holds uncommitted changes, and closes its Herdr workspace." : "This deletes the checkout and closes its Herdr workspace.", ); if (!confirmed) { throw new Error("Removing a worktree requires explicit user confirmation."); } const { result, payload } = await runHerdr( runner, capabilities, capability, { action: "remove", options: [{ name: "--workspace", value: operation.workspaceId }], switches: operation.force ? ["--force"] : [], }, signal, ); const removed = parseWorktreeRemoved(payload); if (removed.workspaceId !== operation.workspaceId) { throw new Error( `Herdr removed workspace ${JSON.stringify(removed.workspaceId)} instead of ${JSON.stringify(operation.workspaceId)}.`, ); } if (removed.path !== target.path) { throw new Error( `Herdr removed ${JSON.stringify(removed.path)} instead of ${JSON.stringify(target.path)}.`, ); } const remaining = await readGitWorktrees(runner, root, signal); if (findGitWorktree(remaining, removed.path)) { throw new Error( `Herdr reported ${JSON.stringify(removed.path)} removed, but Git still lists it as a worktree.`, ); } return textResult( `Removed ${removed.path} and closed workspace ${removed.workspaceId}${removed.forced ? " (forced)" : ""}.`, { operation: "remove", capability: capabilityDetails(capability), command: commandDetails(result), result: removed, }, ); }); } }, }); }