feat(worktrees): création intelligente (auto) + commit/push/promotion + grille agrandissable
- addWorktree résout la branche par dépôt (branchExists) : checkout si présente, suivi de origin/<b> si seulement remote, sinon création (-b) depuis la branche par défaut. Corrige le 'fatal: invalid reference' sur les groupes hétérogènes ; newBranch -> mode (déprécié, mappé en route).
- Nouvelles opérations git : commit (add -A), push (upstream auto), promotion « en principal » (la branche du worktree devient le checkout principal, sans merge ni conflit, ancienne branche conservée). Routes /worktrees/{commit,push,promote} auditées + GET /repos/:id/branches.
- Web : actions Commit/Push/Promouvoir sur WorktreeCard, sélecteur de branche de base (datalist) dans RepoSection et GroupSessionModal, agrandissement d'un terminal au choix dans TerminalGrid.
- Court-circuite la session de groupe quand aucun worktree n'a pu être créé (fini le masquage par « no resolvable worktree »).
- Tests : git.ts (branchExists/listBranches/commitAll/auto), worktree-manager (commit/promote/branches) ; acceptance P5 étend le scénario worktree de groupe sur branche neuve + commit + promotion.
This commit is contained in:
@@ -139,7 +139,8 @@ export function registerGroupRoutes(
|
||||
}
|
||||
|
||||
if (dirs.length === 0) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
|
||||
const detail = branch ? `no repo has a worktree on branch "${branch}" (create it first)` : 'no repo has a resolvable main checkout';
|
||||
return reply.status(400).send({ error: { code: 'NO_RESOLVABLE_WORKTREE', message: `Cannot start group session: ${detail}` } });
|
||||
}
|
||||
// cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd.
|
||||
const { cwd, addDirs } = resolveGroupCwd(dirs);
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
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';
|
||||
|
||||
export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager): void {
|
||||
/** 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> => {
|
||||
@@ -31,7 +46,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
||||
try {
|
||||
const out = await wt.createWorktree(id, {
|
||||
branch: body.branch,
|
||||
newBranch: body.newBranch !== false, // défaut : créer la branche
|
||||
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 } : {}),
|
||||
@@ -45,6 +60,17 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
||||
}
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
@@ -101,6 +127,57 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
||||
}
|
||||
});
|
||||
|
||||
// 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' } });
|
||||
}
|
||||
if (typeof body.message !== 'string' || body.message.trim() === '') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'message is required' } });
|
||||
}
|
||||
try {
|
||||
const worktree = await wt.commitWorktree(id, body.path, body.message.trim());
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.commit', resourceId: id, details: { path: body.path } });
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user