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' }); }); });