Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts). Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
201 lines
8.8 KiB
TypeScript
201 lines
8.8 KiB
TypeScript
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<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,
|
|
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<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);
|
|
}
|
|
});
|
|
|
|
// 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<CommitWorktreeRequest> | 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<PushWorktreeRequest> | 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<PromoteWorktreeRequest> | 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);
|
|
}
|
|
});
|
|
}
|