Scan borné du système de fichiers (racines configurables, défaut home ; profondeur/nombre/timeout bornés ; symlinks non suivis ; exclusions node_modules/dotdirs) qui auto-enregistre les nouveaux dépôts. Insertion atomique ON CONFLICT DO NOTHING (idempotence + anti-résurrection d'un dépôt masqué + anti-course). Scan au démarrage + bouton manuel + re-scan périodique (RepoDiscoveryService, démarré dans runDaemon). Colonne repos.hidden : masquer = conservé en DB mais exclu du dashboard et jamais ré-ajouté ; supprimer = re-découvrable. UI : bouton œil par dépôt + bascule afficher-les-masqués sur le dashboard, section Découverte dans les Réglages (racines + intervalle, allow-list stricte). Robustesse : listAllWorktrees tolère l'échec git par dépôt ; flag --no-discover (escape hatch + hermétisme des acceptations). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
9.9 KiB
TypeScript
233 lines
9.9 KiB
TypeScript
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({ 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' });
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|