diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 19835d4..5c7ebbb 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -1,22 +1,71 @@ import { Command } from "commander"; +import type { SessionState } from "@echohello/protocol"; + +import { connectCli, DEFAULT_HTTP_ENDPOINT } from "../connect.js"; export const agentCommand = new Command("agent") - .description("Inspect and operate on agents (TODO)") + .description("Inspect and operate on agent sessions") .addCommand( new Command("list") - .description("List agents (queries daemon via WS — not yet wired)") - .action(() => { - process.stderr.write("Not implemented yet.\n"); - process.exit(1); + .description("List agent sessions known to the daemon") + .option("--workspace ", "filter by workspace id") + .option("--endpoint ", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT) + .action(async (opts) => { + const client = await connectCli(String(opts.endpoint), "cli-agent-list"); + try { + const args = opts.workspace ? { workspaceId: String(opts.workspace) } : undefined; + const { sessions } = await client.rpc( + "session.list", + args, + ); + for (const session of sessions) { + process.stdout.write( + `${session.sessionId}\t${session.providerId}\t${session.status}\t${session.workspaceId}\n`, + ); + } + } finally { + client.close(); + } }), ) .addCommand( new Command("send") - .description("Send a prompt to an agent (TODO)") + .description("Send a prompt to an agent session and stream the reply") .argument("", "session id") .argument("", "prompt text") - .action(() => { - process.stderr.write("Not implemented yet.\n"); - process.exit(1); + .option("--endpoint ", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT) + .option("--timeout ", "stream timeout", "120000") + .action(async (sessionId: string, promptParts: string[], opts) => { + const client = await connectCli(String(opts.endpoint), "cli-agent-send"); + const prompt = promptParts.join(" "); + const done = new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error("timed out waiting for the agent")), + Number(opts.timeout), + ); + client.onAgentEvent((event) => { + if (event.sessionId !== sessionId) return; + if (event.type === "message.delta") { + process.stdout.write(event.text); + } else if (event.type === "message.final") { + process.stdout.write(`\n${event.text}\n`); + } else if (event.type === "status" && event.status === "idle") { + clearTimeout(timer); + resolvePromise(); + } else if (event.type === "error") { + clearTimeout(timer); + rejectPromise(new Error(event.message)); + } + }); + }); + try { + client.sendCommand({ type: "session.send", sessionId, prompt, attachments: [] }); + await done; + } catch (err) { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + } finally { + client.close(); + } }), ); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index e0601d4..3d50d79 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -1,5 +1,7 @@ import { Command } from "commander"; +import { DEFAULT_HTTP_ENDPOINT } from "../connect.js"; + export const daemonCommand = new Command("daemon") .description("Control the Supaplane daemon (start, stop, status, log, pair)") .addCommand( @@ -23,7 +25,7 @@ export const daemonCommand = new Command("daemon") .addCommand( new Command("status") .description("Show daemon status (queries the health endpoint)") - .option("--endpoint ", "daemon HTTP endpoint", "http://127.0.0.1:6767") + .option("--endpoint ", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT) .action(async (opts) => { const res = await fetch(`${opts.endpoint}/api/health`); if (!res.ok) { @@ -37,7 +39,7 @@ export const daemonCommand = new Command("daemon") .addCommand( new Command("version") .description("Print the daemon version + protocol version") - .option("--endpoint ", "daemon HTTP endpoint", "http://127.0.0.1:6767") + .option("--endpoint ", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT) .action(async (opts) => { const res = await fetch(`${opts.endpoint}/api/version`); process.stdout.write(`${await res.text()}\n`); diff --git a/packages/cli/src/commands/provider.ts b/packages/cli/src/commands/provider.ts index 3bdcfe4..8ccb8b3 100644 --- a/packages/cli/src/commands/provider.ts +++ b/packages/cli/src/commands/provider.ts @@ -1,10 +1,32 @@ import { Command } from "commander"; +import { connectCli, DEFAULT_HTTP_ENDPOINT } from "../connect.js"; + export const providerCommand = new Command("provider") - .description("Inspect provider availability (TODO)") + .description("Inspect provider availability") .addCommand( - new Command("list").description("List providers and their diagnostic status").action(() => { - process.stderr.write("Not implemented yet.\n"); - process.exit(1); - }), + new Command("list") + .description("List providers and their diagnostic status") + .option("--endpoint ", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT) + .action(async (opts) => { + const client = await connectCli(String(opts.endpoint), "cli-provider-list"); + try { + const { providers } = await client.rpc("provider.list"); + for (const providerId of providers) { + let diagnostic = "no diagnostic"; + try { + const result = await client.rpc<{ providerId: string }, { diagnostic: string }>( + "provider.diagnostic", + { providerId }, + ); + diagnostic = result.diagnostic; + } catch (err) { + diagnostic = `unavailable: ${err instanceof Error ? err.message : String(err)}`; + } + process.stdout.write(`${providerId}\t${diagnostic}\n`); + } + } finally { + client.close(); + } + }), ); diff --git a/packages/cli/src/commands/worktree.ts b/packages/cli/src/commands/worktree.ts index 1a2a61c..8272fcf 100644 --- a/packages/cli/src/commands/worktree.ts +++ b/packages/cli/src/commands/worktree.ts @@ -1,19 +1,91 @@ import { Command } from "commander"; +import type { WorkspaceState } from "@echohello/protocol"; + +import { connectCli, DEFAULT_HTTP_ENDPOINT } from "../connect.js"; + +interface WorktreeInfo { + path: string; + branch: string | null; + bare: boolean; +} export const worktreeCommand = new Command("worktree") - .description("Manage git worktrees (TODO)") + .description("Manage git worktrees") .addCommand( new Command("create") - .description("Create a worktree for the given branch/PR") - .argument("", "branch name or PR number") - .action(() => { - process.stderr.write("Not implemented yet.\n"); - process.exit(1); + .description("Create a worktree for the given branch") + .argument("", "branch name for the worktree") + .option("--cwd ", "repo to branch from (defaults to process cwd)", process.cwd()) + .option("--endpoint ", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT) + .action(async (name: string, opts) => { + const client = await connectCli(String(opts.endpoint), "cli-worktree-create"); + try { + const created = new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error("timed out waiting for the daemon")), + 30_000, + ); + client.onWorkspaceState((workspace) => { + if (!workspace.cwd.endsWith(`-${sanitize(name)}`)) return; + clearTimeout(timer); + resolvePromise(workspace); + }); + }); + client.sendCommand({ type: "workspace.open", cwd: String(opts.cwd) }); + const workspace = await waitForWorkspace(client); + client.sendCommand({ + type: "git.checkout", + workspaceId: workspace.workspaceId, + target: { kind: "worktree", name }, + }); + const worktree = await created; + process.stdout.write(`${worktree.workspaceId}\t${worktree.cwd}\n`); + } catch (err) { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + } finally { + client.close(); + } }), ) .addCommand( - new Command("list").description("List worktrees known to the daemon").action(() => { - process.stderr.write("Not implemented yet.\n"); - process.exit(1); - }), + new Command("list") + .description("List worktrees for a workspace") + .option("--cwd ", "repo path (defaults to process cwd)", process.cwd()) + .option("--endpoint ", "daemon HTTP endpoint", DEFAULT_HTTP_ENDPOINT) + .action(async (opts) => { + const client = await connectCli(String(opts.endpoint), "cli-worktree-list"); + try { + client.sendCommand({ type: "workspace.open", cwd: String(opts.cwd) }); + const workspace = await waitForWorkspace(client); + const { worktrees } = await client.rpc< + { workspaceId: string }, + { worktrees: WorktreeInfo[] } + >("worktree.list", { workspaceId: workspace.workspaceId }); + for (const worktree of worktrees) { + process.stdout.write(`${worktree.path}\t${worktree.branch ?? "(detached)"}\n`); + } + } finally { + client.close(); + } + }), ); + +function sanitize(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]/g, "-"); +} + +function waitForWorkspace(client: { + onWorkspaceState: (listener: (workspace: WorkspaceState) => void) => unknown; +}): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error("timed out waiting for workspace_state")), + 10_000, + ); + client.onWorkspaceState((workspace) => { + clearTimeout(timer); + resolvePromise(workspace); + }); + }); +} diff --git a/packages/cli/src/connect.ts b/packages/cli/src/connect.ts new file mode 100644 index 0000000..a613cba --- /dev/null +++ b/packages/cli/src/connect.ts @@ -0,0 +1,30 @@ +import { SupaplaneClient } from "@echohello/client"; + +export const DEFAULT_HTTP_ENDPOINT = "http://127.0.0.1:17687"; + +/** Convert an HTTP daemon endpoint (or explicit ws:// URL) to a WS endpoint. */ +export function toWsEndpoint(endpoint: string): string { + if (endpoint.startsWith("ws://") || endpoint.startsWith("wss://")) return endpoint; + return endpoint.replace(/^http/, "ws"); +} + +/** Connect a CLI client to the daemon, failing fast with a useful message. */ +export async function connectCli(endpoint: string, clientId: string): Promise { + const client = new SupaplaneClient({ + endpoint: toWsEndpoint(endpoint), + clientId, + clientType: "cli", + reconnect: false, + }); + try { + await client.connect(); + } catch (err) { + client.close(); + const message = err instanceof Error ? err.message : String(err); + process.stderr.write( + `Failed to connect to daemon at ${endpoint}: ${message}\nIs the daemon running? (supaplane daemon start)\n`, + ); + process.exit(1); + } + return client; +} diff --git a/packages/server/src/daemon.ts b/packages/server/src/daemon.ts index fc38621..1752e58 100644 --- a/packages/server/src/daemon.ts +++ b/packages/server/src/daemon.ts @@ -13,6 +13,7 @@ import { buildProviders } from "./server/agent/provider-factory.js"; import { CommandDispatcher } from "./server/command-dispatcher.js"; import { RpcRouter } from "./server/rpc-router.js"; import { WorkspaceRegistry } from "./server/workspace-registry.js"; +import { WorktreeService } from "./server/worktree-service.js"; import { SupaplaneWebsocketServer } from "./websocket-server.js"; export interface DaemonHandle { @@ -24,6 +25,7 @@ export interface DaemonHandle { wsServer: SupaplaneWebsocketServer; agentManager: AgentManager; workspaces: WorkspaceRegistry; + worktreeService: WorktreeService; } /** @@ -71,6 +73,7 @@ export async function startDaemon(args?: { agentManager.registerProvider(provider); } const workspaces = new WorkspaceRegistry(); + const worktreeService = new WorktreeService({ workspaces, logger }); const wsServer = new SupaplaneWebsocketServer({ httpServer, @@ -87,6 +90,7 @@ export async function startDaemon(args?: { const dispatcher = new CommandDispatcher({ workspaces, agents: agentManager, + worktrees: worktreeService, broadcast: (event) => wsServer.broadcast(event), logger, }); @@ -102,7 +106,12 @@ export async function startDaemon(args?: { }), ); - const rpcRouter = new RpcRouter({ workspaces, agents: agentManager, logger }); + const rpcRouter = new RpcRouter({ + workspaces, + agents: agentManager, + worktrees: worktreeService, + logger, + }); wsServer.setRpcHandler((req) => rpcRouter.handle(req)); await new Promise((resolve, reject) => { @@ -129,6 +138,7 @@ export async function startDaemon(args?: { wsServer, agentManager, workspaces, + worktreeService, async stop(): Promise { logger.info("stopping daemon"); await agentManager.disposeAll(); diff --git a/packages/server/src/server/command-dispatcher.ts b/packages/server/src/server/command-dispatcher.ts index db33e6d..dc91664 100644 --- a/packages/server/src/server/command-dispatcher.ts +++ b/packages/server/src/server/command-dispatcher.ts @@ -8,6 +8,7 @@ import { import type { AgentManager } from "./agent/agent-manager.js"; import type { WorkspaceRegistry } from "./workspace-registry.js"; +import type { WorktreeService } from "./worktree-service.js"; export interface CommandContext { clientId: string; @@ -18,6 +19,7 @@ export interface CommandContext { export interface CommandDispatcherOptions { workspaces: WorkspaceRegistry; agents: AgentManager; + worktrees: WorktreeService; broadcast: (event: ServerEvent) => void; logger: Logger; } @@ -32,12 +34,14 @@ export interface CommandDispatcherOptions { export class CommandDispatcher { readonly #workspaces: WorkspaceRegistry; readonly #agents: AgentManager; + readonly #worktrees: WorktreeService; readonly #broadcast: (event: ServerEvent) => void; readonly #logger: Logger; constructor(options: CommandDispatcherOptions) { this.#workspaces = options.workspaces; this.#agents = options.agents; + this.#worktrees = options.worktrees; this.#broadcast = options.broadcast; this.#logger = options.logger.child({ module: "command-dispatcher" }); } @@ -106,10 +110,22 @@ export class CommandDispatcher { }); return; } + case "git.checkout": { + void this.#guard(ctx, async () => { + const target = + cmd.target.kind === "pr" + ? { kind: "pr" as const, number: cmd.target.number } + : { kind: cmd.target.kind, name: cmd.target.name }; + const affected = await this.#worktrees.checkout(cmd.workspaceId, target); + for (const workspace of affected) { + this.#broadcast({ kind: "workspace_state", workspace }); + } + }); + return; + } case "session.fork": case "diff.open": case "file.open": - case "git.checkout": case "permission.resolve": { this.#fail( ctx, diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 28d823c..3d89af1 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -24,6 +24,7 @@ export { ClaudeAgentClient } from "./agent/providers/claude/claude-provider.js"; export { ClaudeToolCallMapper } from "./agent/providers/claude/tool-call-mapper.js"; export { CommandDispatcher, type CommandContext } from "./command-dispatcher.js"; export { WorkspaceRegistry } from "./workspace-registry.js"; +export { WorktreeService, parseWorktreeList, type WorktreeInfo } from "./worktree-service.js"; export type { AgentClient, AgentEventSink, diff --git a/packages/server/src/server/rpc-router.ts b/packages/server/src/server/rpc-router.ts index 4964504..f6381a6 100644 --- a/packages/server/src/server/rpc-router.ts +++ b/packages/server/src/server/rpc-router.ts @@ -4,14 +4,17 @@ import { SupaplaneError, type RpcRequest, type RpcResponse } from "@echohello/pr import type { AgentManager } from "./agent/agent-manager.js"; import type { WorkspaceRegistry } from "./workspace-registry.js"; +import type { WorktreeService } from "./worktree-service.js"; export interface RpcRouterOptions { workspaces: WorkspaceRegistry; agents: AgentManager; + worktrees: WorktreeService; logger: Logger; } const ProviderArgsSchema = z.object({ providerId: z.string().min(1) }); +const WorkspaceArgsSchema = z.object({ workspaceId: z.string().min(1) }); const SessionListArgsSchema = z.object({ workspaceId: z.string().optional() }); /** @@ -22,11 +25,13 @@ const SessionListArgsSchema = z.object({ workspaceId: z.string().optional() }); export class RpcRouter { readonly #workspaces: WorkspaceRegistry; readonly #agents: AgentManager; + readonly #worktrees: WorktreeService; readonly #logger: Logger; constructor(options: RpcRouterOptions) { this.#workspaces = options.workspaces; this.#agents = options.agents; + this.#worktrees = options.worktrees; this.#logger = options.logger.child({ module: "rpc-router" }); } @@ -60,6 +65,10 @@ export class RpcRouter { return this.#agents.getDiagnostic(this.#providerId(req)); case "workspace.list": return { workspaces: this.#workspaces.list() }; + case "worktree.list": { + const args = WorkspaceArgsSchema.parse(req.args); + return { worktrees: await this.#worktrees.listWorktrees(args.workspaceId) }; + } case "session.list": { const args = SessionListArgsSchema.parse(req.args ?? {}); return { sessions: this.#agents.listSessions(args.workspaceId) }; diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 76d217f..252ae3b 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -45,6 +45,17 @@ export class WorkspaceRegistry { return this.#byId.get(workspaceId); } + update( + workspaceId: string, + patch: Partial>, + ): WorkspaceState | undefined { + const workspace = this.#byId.get(workspaceId); + if (workspace === undefined) return undefined; + const updated: WorkspaceState = { ...workspace, ...patch, updatedAt: Date.now() }; + this.#byId.set(workspaceId, updated); + return updated; + } + list(): WorkspaceState[] { return [...this.#byId.values()]; } diff --git a/packages/server/src/server/worktree-service.test.ts b/packages/server/src/server/worktree-service.test.ts new file mode 100644 index 0000000..e041acb --- /dev/null +++ b/packages/server/src/server/worktree-service.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Logger } from "pino"; + +import { WorkspaceRegistry } from "./workspace-registry.js"; +import { parseWorktreeList, WorktreeService } from "./worktree-service.js"; + +const silentLogger = { + child: () => silentLogger, + warn: () => undefined, + info: () => undefined, +} as unknown as Logger; + +function initRepo(path: string): void { + execFileSync("git", ["init", "-b", "main", path]); + execFileSync("git", ["-C", path, "config", "user.email", "test@example.com"]); + execFileSync("git", ["-C", path, "config", "user.name", "Test"]); + execFileSync("git", ["-C", path, "commit", "--allow-empty", "-m", "init"]); + execFileSync("git", ["-C", path, "branch", "feature-x"]); +} + +describe("parseWorktreeList", () => { + it("parses porcelain output", () => { + const parsed = parseWorktreeList( + [ + "worktree /repo", + "HEAD abc123", + "branch refs/heads/main", + "", + "worktree /repo-feature", + "HEAD def456", + "branch refs/heads/feature-x", + "", + ].join("\n"), + ); + expect(parsed).toEqual([ + { path: "/repo", branch: "main", bare: false }, + { path: "/repo-feature", branch: "feature-x", bare: false }, + ]); + }); +}); + +describe("WorktreeService", () => { + let repoDir: string; + let workspaces: WorkspaceRegistry; + let service: WorktreeService; + let workspaceId: string; + + beforeEach(async () => { + repoDir = await mkdtemp(join(tmpdir(), "supaplane-git-")); + initRepo(repoDir); + workspaces = new WorkspaceRegistry(); + service = new WorktreeService({ workspaces, logger: silentLogger }); + workspaceId = workspaces.open(repoDir).workspaceId; + }); + + afterEach(async () => { + await rm(repoDir, { recursive: true, force: true }); + const sibling = `${repoDir}-feature-x`; + await rm(sibling, { recursive: true, force: true }); + }); + + it("checks out a branch and reports it in workspace state", async () => { + const [updated] = await service.checkout(workspaceId, { kind: "branch", name: "feature-x" }); + expect(updated?.branch).toBe("feature-x"); + }); + + it("creates a worktree and registers it as a workspace", async () => { + const affected = await service.checkout(workspaceId, { kind: "worktree", name: "feature-x" }); + expect(affected).toHaveLength(2); + const created = affected[1]; + expect(created?.cwd).toBe(`${repoDir}-feature-x`); + + const realWorktreePath = realpathSync(`${repoDir}-feature-x`); + const worktrees = await service.listWorktrees(workspaceId); + expect(worktrees.map((w) => w.path)).toContain(realWorktreePath); + expect(worktrees.find((w) => w.path === realWorktreePath)?.branch).toBe("feature-x"); + }); + + it("rejects unknown workspaces", async () => { + await expect(service.checkout("ws_nope", { kind: "branch", name: "x" })).rejects.toThrow( + "Unknown workspace", + ); + }); + + it("rejects a missing branch with a git error", async () => { + await expect( + service.checkout(workspaceId, { kind: "branch", name: "does-not-exist" }), + ).rejects.toThrow("git checkout does-not-exist failed"); + }); +}); diff --git a/packages/server/src/server/worktree-service.ts b/packages/server/src/server/worktree-service.ts new file mode 100644 index 0000000..faa58e5 --- /dev/null +++ b/packages/server/src/server/worktree-service.ts @@ -0,0 +1,163 @@ +import { execFile } from "node:child_process"; +import { dirname, resolve } from "node:path"; + +import type { Logger } from "pino"; +import { DaemonError, type WorkspaceState } from "@echohello/protocol"; + +import type { WorkspaceRegistry } from "./workspace-registry.js"; + +export interface CheckoutTarget { + kind: "branch" | "worktree" | "pr"; + name?: string; + number?: number; +} + +export interface WorktreeInfo { + path: string; + branch: string | null; + bare: boolean; +} + +export interface WorktreeServiceOptions { + workspaces: WorkspaceRegistry; + logger: Logger; +} + +/** + * Git checkout/worktree operations backing the `git.checkout` command. + * Shells out to the user's `git` binary; never manages credentials. + */ +export class WorktreeService { + readonly #workspaces: WorkspaceRegistry; + readonly #logger: Logger; + + constructor(options: WorktreeServiceOptions) { + this.#workspaces = options.workspaces; + this.#logger = options.logger.child({ module: "worktree-service" }); + } + + /** + * Apply a checkout target to a workspace. Returns the affected workspace + * states (the refreshed workspace for branch/pr, or the refreshed workspace + * plus the new worktree workspace for worktree targets). + */ + async checkout(workspaceId: string, target: CheckoutTarget): Promise { + const workspace = this.#workspaces.get(workspaceId); + if (workspace === undefined) { + throw new DaemonError({ code: "not_found", message: `Unknown workspace: ${workspaceId}` }); + } + switch (target.kind) { + case "branch": { + const name = requireName(target); + await git(workspace.cwd, ["checkout", name]); + return [await this.#refreshWithBranch(workspaceId)]; + } + case "pr": { + const number = target.number; + if (number === undefined) { + throw new DaemonError({ code: "bad_request", message: "pr target requires a number" }); + } + const branch = `pr-${number}`; + await git(workspace.cwd, ["fetch", "origin", `pull/${number}/head:${branch}`]); + await git(workspace.cwd, ["checkout", branch]); + return [await this.#refreshWithBranch(workspaceId)]; + } + case "worktree": { + const name = requireName(target); + const path = defaultWorktreePath(workspace.cwd, name); + await git(workspace.cwd, ["worktree", "add", path, name]); + const created = this.#workspaces.open(path); + return [await this.#refreshWithBranch(workspaceId), created]; + } + } + } + + async listWorktrees(workspaceId: string): Promise { + const workspace = this.#workspaces.get(workspaceId); + if (workspace === undefined) { + throw new DaemonError({ code: "not_found", message: `Unknown workspace: ${workspaceId}` }); + } + const { stdout } = await git(workspace.cwd, ["worktree", "list", "--porcelain"]); + return parseWorktreeList(stdout); + } + + async #refreshWithBranch(workspaceId: string): Promise { + const workspace = this.#workspaces.get(workspaceId); + if (workspace === undefined) { + throw new DaemonError({ code: "not_found", message: `Unknown workspace: ${workspaceId}` }); + } + let branch: string | undefined; + try { + const { stdout } = await git(workspace.cwd, ["rev-parse", "--abbrev-ref", "HEAD"]); + branch = stdout.trim(); + } catch (err) { + this.#logger.warn({ err, cwd: workspace.cwd }, "failed to read branch"); + } + const updated = this.#workspaces.update(workspaceId, { + ...(branch !== undefined && branch.length > 0 ? { branch } : {}), + }); + return updated ?? workspace; + } +} + +function requireName(target: CheckoutTarget): string { + if (target.name === undefined || target.name.length === 0) { + throw new DaemonError({ + code: "bad_request", + message: `${target.kind} target requires a name`, + }); + } + return target.name; +} + +function defaultWorktreePath(cwd: string, name: string): string { + const repo = resolve(cwd); + const safe = name.replace(/[^a-zA-Z0-9._-]/g, "-"); + return resolve(dirname(repo), `${repo.split("/").pop() ?? "repo"}-${safe}`); +} + +export function parseWorktreeList(porcelain: string): WorktreeInfo[] { + const worktrees: WorktreeInfo[] = []; + let current: { path?: string; branch?: string | null; bare?: boolean } = {}; + const flush = () => { + if (current.path === undefined) return; + worktrees.push({ + path: current.path, + branch: current.branch ?? null, + bare: current.bare === true, + }); + current = {}; + }; + for (const line of porcelain.split("\n")) { + if (line.startsWith("worktree ")) { + flush(); + current = { path: line.slice("worktree ".length) }; + } else if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length).replace(/^refs\/heads\//, ""); + } else if (line === "detached") { + current.branch = null; + } else if (line === "bare") { + current.bare = true; + } + } + flush(); + return worktrees; +} + +function git(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolvePromise, rejectPromise) => { + execFile("git", ["-C", cwd, ...args], { timeout: 60_000 }, (err, stdout, stderr) => { + if (err) { + rejectPromise( + new DaemonError({ + code: "internal", + message: `git ${args.join(" ")} failed: ${stderr.trim() || err.message}`, + cause: err, + }), + ); + return; + } + resolvePromise({ stdout, stderr }); + }); + }); +}