import type { FastifyInstance } from 'fastify'; import type { AdoptWorktreeRequest, CommitWorktreeRequest, CreateWorktreeRequest, CreateWorktreeResponse, PromoteWorktreeRequest, PushWorktreeRequest, RepoBranchesResponse, SessionResponse, StartRepoSessionRequest, WorktreeBranchMode, WorktreeResponse, WorktreesListResponse, } from '@arboretum/shared'; import type { WorktreeManager } from '../core/worktree-manager.js'; import type { Db } from '../db/index.js'; import { recordAudit } from '../core/audit-log.js'; import { sendManagerError } from './repos.js'; /** Mappe l'API (mode prioritaire ; `newBranch` déprécié) vers la stratégie de résolution de branche. */ function resolveMode(body: { mode?: unknown; newBranch?: unknown }): WorktreeBranchMode { if (body.mode === 'auto' || body.mode === 'create' || body.mode === 'checkout') return body.mode; if (body.newBranch === true) return 'create'; if (body.newBranch === false) return 'checkout'; return 'auto'; } export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void { app.get('/api/v1/worktrees', async (): Promise => ({ worktrees: await wt.listAllWorktrees() })); app.get('/api/v1/repos/:id/worktrees', async (req): Promise => { const { id } = req.params as { id: string }; return { worktrees: await wt.listRepoWorktrees(id) }; }); app.post('/api/v1/repos/:id/worktrees', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Partial | null; if (!body || typeof body.branch !== 'string' || body.branch.trim() === '') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'branch is required' } }); } if (body.startSession != null && body.startSession !== 'claude' && body.startSession !== 'bash') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'startSession must be claude, bash or null' } }); } try { const out = await wt.createWorktree(id, { branch: body.branch, mode: resolveMode(body), // défaut : auto (détecte créer / checkout / suivi remote) ...(body.baseRef !== undefined ? { baseRef: body.baseRef } : {}), ...(body.path !== undefined ? { path: body.path } : {}), ...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}), ...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}), ...(body.startSession !== undefined ? { startSession: body.startSession } : {}), }); const res: CreateWorktreeResponse = out; return reply.status(201).send(res); } catch (err) { return sendManagerError(reply, err); } }); // Branches du repo (locales + suivies de origin + défaut) : alimente le sélecteur de base côté UI. app.get('/api/v1/repos/:id/branches', async (req, reply) => { const { id } = req.params as { id: string }; try { const out = await wt.listRepoBranches(id); return reply.send(out satisfies RepoBranchesResponse); } catch (err) { return sendManagerError(reply, err); } }); // Session sur le checkout principal (« bosser sur la branche principale » sans worktree), // avec création/bascule de branche optionnelle côté serveur. app.post('/api/v1/repos/:id/session', async (req, reply) => { const { id } = req.params as { id: string }; const body = (req.body as Partial | null) ?? {}; if (body.command != null && body.command !== 'claude' && body.command !== 'bash') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } }); } if (body.branch != null && typeof body.branch !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'branch must be a string' } }); } if (body.newBranch != null && typeof body.newBranch !== 'boolean') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'newBranch must be a boolean' } }); } try { const out = await wt.startMainSession(id, { ...(body.command !== undefined ? { command: body.command } : {}), ...(body.branch !== undefined ? { branch: body.branch } : {}), ...(body.newBranch !== undefined ? { newBranch: body.newBranch } : {}), }); const res: SessionResponse = { session: out.session }; return reply.status(201).send(res); } catch (err) { return sendManagerError(reply, err); } }); app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Partial | null; if (!body || typeof body.path !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); } try { const out = await wt.adoptWorktree(id, { path: body.path, ...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}), ...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}), }); const res: WorktreeResponse & { hookResults: typeof out.hookResults } = { worktree: out.worktree, hookResults: out.hookResults }; return reply.send(res); } catch (err) { return sendManagerError(reply, err); } }); app.post('/api/v1/repos/:id/worktrees/prune', async (req, reply) => { const { id } = req.params as { id: string }; try { await wt.prune(id); return reply.send({ ok: true }); } catch (err) { return sendManagerError(reply, err); } }); // Commit (git add -A + commit) dans un worktree (ou le checkout principal). app.post('/api/v1/repos/:id/worktrees/commit', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Partial | null; if (!body || typeof body.path !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); } const amend = body.amend === true; if (!amend && (typeof body.message !== 'string' || body.message.trim() === '')) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'message is required' } }); } const mode = body.mode === 'staged' ? 'staged' : 'all'; try { const worktree = await wt.commitWorktree(id, body.path, { message: typeof body.message === 'string' ? body.message.trim() : '', mode, amend, }); recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.commit', resourceId: id, details: { path: body.path, mode, amend } }); return reply.send({ worktree } satisfies WorktreeResponse); } catch (err) { return sendManagerError(reply, err); } }); // Push de la branche d'un worktree (upstream auto si absent). app.post('/api/v1/repos/:id/worktrees/push', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Partial | null; if (!body || typeof body.path !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); } try { const worktree = await wt.pushWorktree(id, body.path); recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.push', resourceId: id, details: { path: body.path } }); return reply.send({ worktree } satisfies WorktreeResponse); } catch (err) { return sendManagerError(reply, err); } }); // Promotion « en principal » : la branche du worktree devient le checkout principal (worktree supprimé). app.post('/api/v1/repos/:id/worktrees/promote', async (req, reply) => { const { id } = req.params as { id: string }; const body = req.body as Partial | null; if (!body || typeof body.path !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } }); } try { const worktree = await wt.promoteWorktree(id, body.path, body.force === true); recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.promote', resourceId: id, details: { path: body.path } }); return reply.send({ worktree }); } catch (err) { return sendManagerError(reply, err); } }); app.delete('/api/v1/repos/:id/worktrees', async (req, reply) => { const { id } = req.params as { id: string }; const query = req.query as { path?: string; force?: string }; if (typeof query.path !== 'string') { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path query param is required' } }); } try { await wt.deleteWorktree(id, query.path, query.force === 'true'); return reply.send({ ok: true }); } catch (err) { return sendManagerError(reply, err); } }); }