P3-A: worktrees multi-repo & cycle de vie (backend)

Enregistrement de repos et gestion de leurs worktrees git, source de vérité
= git (worktrees dérivés + cache court par repo), corrélation worktree ↔
sessions par cwd, mutations sérialisées par repo.

- shared: RepoSummary, PostCreateHook, WorktreeSummary, WorktreeGitStatus ;
  messages WS repo_update/worktree_update/*_removed ; topic sub 'worktrees'
  (+ parseClientMessage) ; DTOs REST repos/worktrees.
- db: migration id:3 (table repos).
- core/git.ts: couche git sûre (execFile, jamais de shell, -- avant chemins,
  GIT_OPTIONAL_LOCKS=0), parseWorktreePorcelain, list/add/remove/prune/status/
  ahead-behind, validation branche + chemin.
- core/claude-trust.ts: pré-trust atomique de ~/.claude.json (spike S3).
- core/worktree-manager.ts: repos CRUD, create (worktree add + pré-trust +
  hooks post-create + startSession optionnel), adopt, delete (garde-fous 409
  dirty / 400 main / 409 session live), prune ; events.
- routes/repos.ts + routes/worktrees.ts, câblage app.ts, gateway topic worktrees.
- tests: git (repos tmp réels), claude-trust, worktree-manager (146 verts).
This commit is contained in:
2026-06-12 18:31:06 +02:00
parent 92c48f0978
commit 08320e166e
16 changed files with 1208 additions and 10 deletions

View File

@@ -0,0 +1,56 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { CreateRepoRequest, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared';
import type { WorktreeManager } from '../core/worktree-manager.js';
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
const e = err as { statusCode?: number; code?: string; message?: string };
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
}
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): void {
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
app.post('/api/v1/repos', async (req, reply) => {
const body = req.body as Partial<CreateRepoRequest> | null;
if (!body || typeof body.path !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path (absolute) is required' } });
}
try {
const repo = await wt.addRepo({
path: body.path,
...(body.label !== undefined ? { label: body.label } : {}),
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
});
const res: RepoResponse = { repo };
return reply.status(201).send(res);
} catch (err) {
return sendManagerError(reply, err);
}
});
app.patch('/api/v1/repos/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const body = (req.body as Partial<UpdateRepoRequest> | null) ?? {};
try {
const repo = await wt.updateRepo(id, {
...(body.label !== undefined ? { label: body.label } : {}),
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
});
const res: RepoResponse = { repo };
return reply.send(res);
} catch (err) {
return sendManagerError(reply, err);
}
});
app.delete('/api/v1/repos/:id', async (req, reply) => {
const { id } = req.params as { id: string };
if (!wt.removeRepo(id)) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No repo with this id' } });
}
return reply.send({ ok: true });
});
}

View File

@@ -0,0 +1,88 @@
import type { FastifyInstance } from 'fastify';
import type {
AdoptWorktreeRequest,
CreateWorktreeRequest,
CreateWorktreeResponse,
WorktreeResponse,
WorktreesListResponse,
} from '@arboretum/shared';
import type { WorktreeManager } from '../core/worktree-manager.js';
import { sendManagerError } from './repos.js';
export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager): void {
app.get('/api/v1/worktrees', async (): Promise<WorktreesListResponse> => ({ worktrees: await wt.listAllWorktrees() }));
app.get('/api/v1/repos/:id/worktrees', async (req): Promise<WorktreesListResponse> => {
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<CreateWorktreeRequest> | 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,
newBranch: body.newBranch !== false, // défaut : créer la branche
...(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);
}
});
app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Partial<AdoptWorktreeRequest> | 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);
}
});
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);
}
});
}