diff --git a/packages/server/scripts/acceptance-p5.mjs b/packages/server/scripts/acceptance-p5.mjs index 318b3fa..009c0de 100644 --- a/packages/server/scripts/acceptance-p5.mjs +++ b/packages/server/scripts/acceptance-p5.mjs @@ -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(); diff --git a/packages/server/src/core/group-session.ts b/packages/server/src/core/group-session.ts new file mode 100644 index 0000000..2e61942 --- /dev/null +++ b/packages/server/src/core/group-session.ts @@ -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 }; +} diff --git a/packages/server/src/routes/groups.ts b/packages/server/src/routes/groups.ts index 28126f3..bcfe889 100644 --- a/packages/server/src/routes/groups.ts +++ b/packages/server/src/routes/groups.ts @@ -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', diff --git a/packages/server/test/group-cwd.test.ts b/packages/server/test/group-cwd.test.ts new file mode 100644 index 0000000..d375f72 --- /dev/null +++ b/packages/server/test/group-cwd.test.ts @@ -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 d’un 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 n’est 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'); + }); +}); diff --git a/packages/web/src/components/GroupSessionModal.vue b/packages/web/src/components/GroupSessionModal.vue index 678c743..9cda131 100644 --- a/packages/web/src/components/GroupSessionModal.vue +++ b/packages/web/src/components/GroupSessionModal.vue @@ -79,15 +79,19 @@