Files
arboretum/packages/server/src/routes/worktrees.ts
Johan LEROY 915fdf185d feat(worktrees): démarrer une session sur la branche principale ou tout worktree
Ajoute le choix « créer un worktree OU bosser sur la branche principale » au
moment de démarrer le travail, sur deux surfaces :
- bouton « Démarrer » (claude/bash) sur chaque WorktreeCard, y compris le
  checkout principal (badge « principal ») — comblait un manque : impossible
  jusqu'ici de lancer une session sur un worktree existant depuis le dashboard ;
- toggle de mode (Worktree / Branche principale) dans le formulaire d'entête
  de RepoSection.

Option git « créer & basculer » : nouvel endpoint POST /api/v1/repos/:id/session
qui lance UNE session dans le checkout principal (repo.path), avec création ou
bascule de branche optionnelle (git switch[-c]) — refusée en 409 si l'arbre est
sale. Pas de hooks ni de pré-trust (c'est le dépôt réel de l'utilisateur).

- shared : type additif StartRepoSessionRequest.
- server : git.switchBranch + WorktreeManager.startMainSession + route.
- web : store worktrees.startMainSession, UI WorktreeCard/RepoSection, i18n fr/en.
- tests : +5 unitaires (switchBranch, startMainSession) ; acceptance-p3 étendue.
2026-06-22 11:30:40 +02:00

118 lines
5.1 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import type {
AdoptWorktreeRequest,
CreateWorktreeRequest,
CreateWorktreeResponse,
SessionResponse,
StartRepoSessionRequest,
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);
}
});
// 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<StartRepoSessionRequest> | 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<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);
}
});
}