import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, basename, dirname, resolve } from 'node:path'; import type { RepoSummary, 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 gitInit(dir: string): void { 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'); } function makeTmpRepo(): string { const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-')); dirs.push(dir); gitInit(dir); return dir; } /** Crée un vrai repo git à un chemin donné (sous une racine de scan contrôlée). */ function makeRepoAt(path: string): void { mkdirSync(path, { recursive: true }); gitInit(path); } 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({ db, 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('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 }); 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' }); }); it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => { const repo = makeTmpRepo(); const r = await wt.addRepo({ path: repo }); expect(r.hidden).toBe(false); const updates: RepoSummary[] = []; wt.on('repo_update', (s) => updates.push(s)); const u = await wt.updateRepo(r.id, { hidden: true }); expect(u.hidden).toBe(true); expect(updates.some((s) => s.id === r.id && s.hidden)).toBe(true); }); it('listAllWorktrees exclut les repos masqués', async () => { const repo = makeTmpRepo(); const r = await wt.addRepo({ path: repo }); expect((await wt.listAllWorktrees()).length).toBeGreaterThan(0); // main worktree présent await wt.updateRepo(r.id, { hidden: true }); expect(await wt.listAllWorktrees()).toHaveLength(0); }); it('discoverRepos : auto-ajoute les nouveaux, idempotent, masqué non ressuscité, supprimé re-découvrable', async () => { const root = mkdtempSync(join(tmpdir(), 'arb-scan-')); dirs.push(root); makeRepoAt(join(root, 'a')); makeRepoAt(join(root, 'b')); const updates: RepoSummary[] = []; wt.on('repo_update', (s) => updates.push(s)); const res = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); expect(res.added).toBe(2); expect(res.scanned).toBe(2); expect(await wt.listRepos()).toHaveLength(2); expect(updates).toHaveLength(2); // un repo_update par nouveau // re-scan : idempotent (aucun ajout, aucune émission) updates.length = 0; const res2 = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); expect(res2.added).toBe(0); expect(updates).toHaveLength(0); expect(await wt.listRepos()).toHaveLength(2); // masquer 'a' puis re-scan : reste masqué, jamais ré-ajouté (invariant central) const repoA = (await wt.listRepos()).find((r) => resolve(r.path) === resolve(join(root, 'a'))); await wt.updateRepo(repoA!.id, { hidden: true }); updates.length = 0; const res3 = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); expect(res3.added).toBe(0); expect((await wt.listRepos()).find((r) => r.id === repoA!.id)?.hidden).toBe(true); // supprimer 'b' puis re-scan : re-découvert (volontaire) const repoB = (await wt.listRepos()).find((r) => resolve(r.path) === resolve(join(root, 'b'))); wt.removeRepo(repoB!.id); expect(await wt.listRepos()).toHaveLength(1); const res4 = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); expect(res4.added).toBe(1); expect(await wt.listRepos()).toHaveLength(2); }); it('discoverRepos : repo disparu du disque conservé en DB (valid=false), non re-trouvé', async () => { const root = mkdtempSync(join(tmpdir(), 'arb-scan-')); dirs.push(root); makeRepoAt(join(root, 'gone')); await wt.discoverRepos({ roots: [root], maxDepth: 2 }); expect(await wt.listRepos()).toHaveLength(1); rmSync(join(root, 'gone'), { recursive: true, force: true }); // disparaît du disque const res = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); expect(res.added).toBe(0); // plus trouvé par le scan const repos = await wt.listRepos(); expect(repos).toHaveLength(1); // mais la ligne est conservée (pas de suppression auto) expect(repos[0].valid).toBe(false); // robustesse : un repo dont le chemin a disparu ne fait pas planter /worktrees (git échoue → []) await expect(wt.listAllWorktrees()).resolves.toEqual([]); }); });