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:
2026-06-22 11:30:40 +02:00
parent ea7b2fd278
commit 915fdf185d
12 changed files with 324 additions and 21 deletions

View File

@@ -116,6 +116,46 @@ describe('WorktreeManager', () => {
await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
});
it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
const out = await wt.startMainSession(r.id, { command: 'bash' });
expect(resolve(out.session.cwd)).toBe(resolve(repo));
expect(out.session).toMatchObject({ command: 'bash', live: true });
// pas de bascule de branche → toujours sur main.
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main');
const main = (await wt.listRepoWorktrees(r.id, true)).find((w) => w.isMain);
expect(main?.sessions.some((s) => s.id === out.session.id)).toBe(true);
});
it('startMainSession : newBranch crée et bascule la branche dans le checkout principal + event', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
const events: Array<{ worktree: WorktreeSummary }> = [];
wt.on('worktree_update', (e) => events.push(e));
const out = await wt.startMainSession(r.id, { command: 'bash', branch: 'feature/x', newBranch: true });
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('feature/x');
expect(out.worktree?.branch).toBe('feature/x');
expect(events.some((e) => e.worktree.isMain && e.worktree.branch === 'feature/x')).toBe(true);
});
it('startMainSession : checkout principal sale → 409 DIRTY_TREE (pas de bascule)', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
writeFileSync(join(repo, 'scratch.txt'), 'wip\n'); // arbre sale
await expect(wt.startMainSession(r.id, { command: 'bash', branch: 'feature/y', newBranch: true })).rejects.toMatchObject({
statusCode: 409,
code: 'DIRTY_TREE',
});
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main');
});
it('startMainSession : branche invalide → 400', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
await expect(wt.startMainSession(r.id, { command: 'bash', branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
});
it('deleteWorktree : main refusé (400), dirty refusé (409) puis force OK', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });