feat(groups): session de groupe au parent commun + redirection vers le terminal

Le cwd d'une session de groupe devient le PARENT COMMUN des répertoires
couverts (racine neutre, chemins relatifs cross-repo naturels) au lieu du
premier repo arbitraire ; chaque repo reste relié en --add-dir. Garde-fou :
si le parent commun est la racine du FS, on retombe sur le premier répertoire.
Nouveau module pur resolveGroupCwd (core/group-session.ts). Côté web, un
lancement réussi redirige vers le terminal plein écran de la session, avec
un toast (succès ou couverture partielle).
This commit is contained in:
2026-06-22 14:41:15 +02:00
parent 4e3b1f5604
commit 1b57c3801c
7 changed files with 107 additions and 5 deletions

View File

@@ -127,9 +127,11 @@ try {
const gsession = gsBody.session;
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession);
check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2);
check('session de groupe : addedDirs (1 dir supplémentaire)', (gsession?.addedDirs?.length ?? 0) === 1);
// cwd = parent commun des repos (P6), chaque repo relié en --add-dir → 2 addedDirs.
check('session de groupe : cwd = parent commun des repos', gsession?.cwd === tmp);
check('session de groupe : addedDirs = les 2 repos', (gsession?.addedDirs?.length ?? 0) === 2);
check('session de groupe : groupId posé', gsession?.groupId === group.id);
check('session de groupe : cwd primaire + addedDir = 2 repos', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 2);
check('session de groupe : cwd parent + 2 repos addedDirs = 3 chemins distincts', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 3);
// La session apparaît dans la liste globale avec son groupId.
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();

View File

@@ -0,0 +1,37 @@
import { parse, resolve, sep } from 'node:path';
/**
* Plus long ancêtre commun d'un ensemble de chemins absolus (par segments, jamais par préfixe
* de chaîne — `/a/bc` n'est PAS un ancêtre de `/a/bcd`). Pour un seul chemin, renvoie ce chemin.
*/
export function commonAncestorDir(paths: string[]): string {
const first = paths[0];
if (first === undefined) throw new Error('commonAncestorDir: empty input');
const firstSegs = resolve(first).split(sep);
let common = firstSegs.length;
for (let i = 1; i < paths.length; i++) {
const segs = resolve(paths[i]!).split(sep);
let k = 0;
while (k < common && k < segs.length && segs[k] === firstSegs[k]) k++;
common = k;
}
const joined = firstSegs.slice(0, common).join(sep);
// `['', 'a', 'b'].slice(0,1).join('/')` === '' → racine POSIX ; rétablir le séparateur racine.
return joined === '' ? parse(resolve(first)).root : joined;
}
/**
* Politique de répertoire de travail d'une session de groupe (P6) : le `cwd` est le PARENT COMMUN
* des répertoires couverts (racine neutre, chemins relatifs cross-repo naturels), chaque répertoire
* étant ensuite relié via `--add-dir` (le dédoublonnage + filtrage du cwd est fait par `PtyManager`).
* Garde-fou : si le parent commun est la racine du FS (repos éparpillés sur des racines différentes),
* on retombe sur le premier répertoire pour ne pas accorder à Claude la permission sur tout le disque.
*/
export function resolveGroupCwd(dirs: string[]): { cwd: string; addDirs: string[] } {
const first = dirs[0];
if (first === undefined) throw new Error('resolveGroupCwd: empty input');
const ancestor = commonAncestorDir(dirs);
const root = parse(resolve(first)).root;
const cwd = ancestor === root ? first : ancestor;
return { cwd, addDirs: dirs };
}

View File

@@ -13,6 +13,7 @@ import type { WorktreeManager } from '../core/worktree-manager.js';
import type { PtyManager } from '../core/pty-manager.js';
import type { Db } from '../db/index.js';
import { recordAudit } from '../core/audit-log.js';
import { resolveGroupCwd } from '../core/group-session.js';
import { sendManagerError } from './repos.js';
export function registerGroupRoutes(
@@ -137,13 +138,14 @@ export function registerGroupRoutes(
if (!dirs.includes(match.path)) dirs.push(match.path);
}
const [primary, ...rest] = dirs;
if (primary === undefined) {
if (dirs.length === 0) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
}
// cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd.
const { cwd, addDirs } = resolveGroupCwd(dirs);
try {
const session = manager.spawn({ cwd: primary, addDirs: rest, command, groupId: id });
const session = manager.spawn({ cwd, addDirs, command, groupId: id });
recordAudit(db, {
actor: req.authContext?.tokenId ?? 'unknown',
action: 'group.session.create',

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { commonAncestorDir, resolveGroupCwd } from '../src/core/group-session.js';
describe('commonAncestorDir', () => {
it('renvoie le chemin lui-même pour un seul répertoire', () => {
expect(commonAncestorDir(['/home/johan/WebstormProjects/arboretum'])).toBe('/home/johan/WebstormProjects/arboretum');
});
it('renvoie le parent commun de deux siblings', () => {
expect(commonAncestorDir(['/p/a', '/p/b'])).toBe('/p');
});
it('gère un répertoire ancêtre dun autre', () => {
expect(commonAncestorDir(['/p/a', '/p/a/b'])).toBe('/p/a');
});
it('compare par segments, pas par préfixe de chaîne', () => {
// /a/bc n'est PAS un ancêtre de /a/bcd : ancêtre commun = /a
expect(commonAncestorDir(['/a/bc', '/a/bcd'])).toBe('/a');
});
it('renvoie la racine quand aucun segment nest commun', () => {
expect(commonAncestorDir(['/x/a', '/y/b'])).toBe('/');
});
});
describe('resolveGroupCwd', () => {
it('mono-repo : cwd = le repo, addDirs sera filtré à vide par PtyManager', () => {
const { cwd, addDirs } = resolveGroupCwd(['/p/a']);
expect(cwd).toBe('/p/a');
expect(addDirs).toEqual(['/p/a']);
});
it('multi-repos sous un parent commun : cwd = parent, addDirs = tous les repos', () => {
const dirs = ['/home/johan/WebstormProjects/beehelp_lambdas', '/home/johan/WebstormProjects/beehelp_api'];
const { cwd, addDirs } = resolveGroupCwd(dirs);
expect(cwd).toBe('/home/johan/WebstormProjects');
expect(addDirs).toEqual(dirs);
});
it('garde-fou : repos sur des racines différentes → retombe sur le premier répertoire', () => {
const { cwd } = resolveGroupCwd(['/x/a', '/y/b']);
expect(cwd).toBe('/x/a');
});
});

View File

@@ -79,15 +79,19 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import type { RepoSummary } from '@arboretum/shared';
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
import { useToastsStore } from '../stores/toasts';
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const router = useRouter();
const groups = useGroupsStore();
const toasts = useToastsStore();
const mode = ref<'feature' | 'main'>('feature');
const branch = ref('');
@@ -139,6 +143,14 @@ async function onSubmit(): Promise<void> {
wtResults.value = { ...wtResults.value, [result.repoId]: result };
},
);
// Session lancée → on redirige vers son terminal plein écran (comme une session individuelle).
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
if (outcome.value.session) {
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
else toasts.success(t('toast.groupSessionLaunched'));
await router.push({ name: 'session', params: { id: outcome.value.session.id } });
}
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {

View File

@@ -306,6 +306,8 @@ export default {
groupCreated: 'Group created',
groupUpdated: 'Group updated',
groupDeleted: 'Group deleted',
groupSessionLaunched: 'Group session started',
groupSessionPartial: 'Group session started ({n} repo(s) skipped)',
genericError: 'Something went wrong',
dismiss: 'Dismiss',
},

View File

@@ -309,6 +309,8 @@ const fr: typeof en = {
groupCreated: 'Groupe créé',
groupUpdated: 'Groupe mis à jour',
groupDeleted: 'Groupe supprimé',
groupSessionLaunched: 'Session de groupe démarrée',
groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))',
genericError: 'Une erreur est survenue',
dismiss: 'Fermer',
},