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.
This commit is contained in:
@@ -190,6 +190,16 @@ export async function addWorktree(
|
||||
await git(repoPath, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` — utilisé pour démarrer une
|
||||
* session sur la branche principale sans worktree dédié. `create` → `git switch -c <branch>` (échoue
|
||||
* si la branche existe) ; sinon `git switch <branch>` (branche existante). Pas de `--` : l'argument
|
||||
* est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag).
|
||||
*/
|
||||
export async function switchBranch(repoPath: string, opts: { branch: string; create: boolean }): Promise<void> {
|
||||
await git(repoPath, opts.create ? ['switch', '-c', opts.branch] : ['switch', opts.branch]);
|
||||
}
|
||||
|
||||
export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> {
|
||||
await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
listWorktrees,
|
||||
pruneWorktrees,
|
||||
removeWorktree,
|
||||
switchBranch,
|
||||
worktreeStatus,
|
||||
type ParsedWorktree,
|
||||
} from './git.js';
|
||||
@@ -355,6 +356,41 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance UNE session dans le checkout principal du repo (`repo.path`) — pour « bosser sur la branche
|
||||
* principale » sans créer de worktree. Si `branch` est fourni, crée/bascule d'abord cette branche
|
||||
* dans ce checkout (`git switch[-c]`), refusé si l'arbre est sale (on n'écrase pas un HEAD modifié).
|
||||
* Volontairement SANS hooks ni pré-trust (contraste avec createWorktree) : le checkout principal
|
||||
* est le dépôt réel de l'utilisateur, déjà configuré/approuvé. Sérialisé par repo (withLock).
|
||||
*/
|
||||
async startMainSession(
|
||||
repoId: string,
|
||||
req: { command?: 'claude' | 'bash'; branch?: string; newBranch?: boolean },
|
||||
): Promise<{ session: SessionSummary; worktree: WorktreeSummary | null }> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
const branch = req.branch?.trim() || undefined;
|
||||
if (branch !== undefined && !isValidBranchName(branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${branch}`);
|
||||
|
||||
return this.withLock(repoId, async () => {
|
||||
if (branch !== undefined) {
|
||||
// garde-fou : ne pas basculer le HEAD du checkout principal s'il a des changements non sauvegardés.
|
||||
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
|
||||
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes — commit or stash before switching branch');
|
||||
}
|
||||
try {
|
||||
await switchBranch(row.path, { branch, create: req.newBranch ?? true });
|
||||
} catch (err) {
|
||||
throw httpError(400, 'SWITCH_FAILED', (err as Error).message);
|
||||
}
|
||||
this.factsCache.delete(repoId);
|
||||
}
|
||||
const session = this.ptyManager.spawn({ cwd: row.path, command: req.command ?? 'claude' });
|
||||
const worktree = await this.emitWorktree(row, row.path);
|
||||
return { session, worktree };
|
||||
});
|
||||
}
|
||||
|
||||
async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
AdoptWorktreeRequest,
|
||||
CreateWorktreeRequest,
|
||||
CreateWorktreeResponse,
|
||||
SessionResponse,
|
||||
StartRepoSessionRequest,
|
||||
WorktreeResponse,
|
||||
WorktreesListResponse,
|
||||
} from '@arboretum/shared';
|
||||
@@ -43,6 +45,33 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user