Files
arboretum/packages/server/test/worktree-manager.test.ts
Johan LEROY bad1230a21 P3-A: worktrees multi-repo & cycle de vie (backend)
Enregistrement de repos et gestion de leurs worktrees git, source de vérité
= git (worktrees dérivés + cache court par repo), corrélation worktree ↔
sessions par cwd, mutations sérialisées par repo.

- shared: RepoSummary, PostCreateHook, WorktreeSummary, WorktreeGitStatus ;
  messages WS repo_update/worktree_update/*_removed ; topic sub 'worktrees'
  (+ parseClientMessage) ; DTOs REST repos/worktrees.
- db: migration id:3 (table repos).
- core/git.ts: couche git sûre (execFile, jamais de shell, -- avant chemins,
  GIT_OPTIONAL_LOCKS=0), parseWorktreePorcelain, list/add/remove/prune/status/
  ahead-behind, validation branche + chemin.
- core/claude-trust.ts: pré-trust atomique de ~/.claude.json (spike S3).
- core/worktree-manager.ts: repos CRUD, create (worktree add + pré-trust +
  hooks post-create + startSession optionnel), adopt, delete (garde-fous 409
  dirty / 400 main / 409 session live), prune ; events.
- routes/repos.ts + routes/worktrees.ts, câblage app.ts, gateway topic worktrees.
- tests: git (repos tmp réels), claude-trust, worktree-manager (146 verts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:31:06 +02:00

148 lines
6.2 KiB
TypeScript

import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, basename, dirname, resolve } from 'node:path';
import type { WorktreeSummary } from '@arboretum/shared';
import { WorktreeManager } from '../src/core/worktree-manager.js';
import { PtyManager } from '../src/core/pty-manager.js';
import { DiscoveryService } from '../src/core/discovery-service.js';
import { openDb, type Db } from '../src/db/index.js';
// node-pty inerte (la corrélation de session n'a besoin que d'un pid et d'un cwd).
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
let pid = 50_000;
class FakePty {
pid = pid++;
write = vi.fn();
resize = vi.fn();
pause = vi.fn();
resume = vi.fn();
kill = vi.fn();
onData(): { dispose: () => void } {
return { dispose: () => {} };
}
onExit(): { dispose: () => void } {
return { dispose: () => {} };
}
}
return { default: { spawn: (): FakePty => new FakePty() } };
});
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
function makeTmpRepo(): string {
const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-'));
dirs.push(dir);
const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
run('init', '-b', 'main');
run('config', 'user.email', 'test@arboretum.dev');
run('config', 'user.name', 'Test');
writeFileSync(join(dir, 'README.md'), '# test\n');
run('add', '-A');
run('commit', '-m', 'init');
return dir;
}
describe('WorktreeManager', () => {
let db: Db;
let pty: PtyManager;
let discovery: DiscoveryService;
let wt: WorktreeManager;
let claudeHome: string;
beforeEach(() => {
db = openDb(':memory:');
claudeHome = mkdtempSync(join(tmpdir(), 'arb-ch-'));
dirs.push(claudeHome);
pty = new PtyManager(db, join(claudeHome, 'sessions'));
discovery = new DiscoveryService({ ptyManager: pty, projectsDir: join(claudeHome, 'projects'), sessionsDir: join(claudeHome, 'sessions') });
wt = new WorktreeManager(db, pty, discovery);
});
it('addRepo : repo valide enregistré ; non-repo rejeté (400)', async () => {
const repo = makeTmpRepo();
const summary = await wt.addRepo({ path: repo });
expect(summary).toMatchObject({ path: repo, label: basename(repo), valid: true });
expect((await wt.listRepos())).toHaveLength(1);
const notRepo = mkdtempSync(join(tmpdir(), 'arb-nogit-'));
dirs.push(notRepo);
await expect(wt.addRepo({ path: notRepo })).rejects.toMatchObject({ statusCode: 400 });
// doublon
await expect(wt.addRepo({ path: repo })).rejects.toMatchObject({ statusCode: 409 });
});
it('createWorktree : worktree + hook exécuté + event worktree_update', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({
path: repo,
postCreateHooks: [{ id: 'h1', label: 'touch', run: 'touch hook-ran.txt', enabled: true }],
});
const events: Array<{ repoId: string; worktree: WorktreeSummary }> = [];
wt.on('worktree_update', (e) => events.push(e));
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
dirs.push(wtPath);
const out = await wt.createWorktree(r.id, { branch: 'feat', newBranch: true, runHooks: true });
expect(resolve(out.worktree.path)).toBe(resolve(wtPath));
expect(out.worktree.branch).toBe('feat');
expect(out.hookResults).toHaveLength(1);
expect(out.hookResults[0]).toMatchObject({ exitCode: 0 });
expect(existsSync(join(wtPath, 'hook-ran.txt'))).toBe(true);
expect(events.some((e) => resolve(e.worktree.path) === resolve(wtPath))).toBe(true);
const list = await wt.listRepoWorktrees(r.id, true);
expect(list.some((w) => resolve(w.path) === resolve(wtPath))).toBe(true);
});
it('createWorktree : branche invalide → 400', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
await expect(wt.createWorktree(r.id, { 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 });
await expect(wt.deleteWorktree(r.id, repo, false)).rejects.toMatchObject({ statusCode: 400, code: 'IS_MAIN_WORKTREE' });
const wtPath = join(dirname(repo), `${basename(repo)}-wt-x`);
dirs.push(wtPath);
await wt.createWorktree(r.id, { branch: 'x', newBranch: true, runHooks: false });
writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n'); // worktree sale
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'WORKTREE_DIRTY' });
await wt.deleteWorktree(r.id, wtPath, true);
expect((await wt.listRepoWorktrees(r.id, true)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
});
it('corrélation : une session dont le cwd = worktree apparaît dans worktree.sessions', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`);
dirs.push(wtPath);
await wt.createWorktree(r.id, { branch: 'sess', newBranch: true, runHooks: false });
pty.spawn({ cwd: wtPath, command: 'bash' });
const list = await wt.listRepoWorktrees(r.id, true);
const target = list.find((w) => resolve(w.path) === resolve(wtPath));
expect(target?.sessions).toHaveLength(1);
expect(target?.sessions[0]).toMatchObject({ source: 'managed', live: true });
});
it('deleteWorktree : session live dans le worktree → 409 sans force', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`);
dirs.push(wtPath);
await wt.createWorktree(r.id, { branch: 'busy', newBranch: true, runHooks: false });
pty.spawn({ cwd: wtPath, command: 'bash' });
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' });
});
});