feat: découverte automatique des dépôts git (scan + montrer/cacher)

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).
This commit is contained in:
2026-06-18 14:45:12 +02:00
parent b070b74929
commit fe2a3e66c7
28 changed files with 824 additions and 34 deletions

View File

@@ -7,6 +7,7 @@ import { openDb, type Db } from '../src/db/index.js';
import { munge } from '../src/core/jsonl-discovery.js';
import { readProcStart } from '../src/core/session-registry.js';
import type { Config } from '../src/config.js';
import type { DiscoverReposResponse, RepoResponse, ReposListResponse } from '@arboretum/shared';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
@@ -354,3 +355,59 @@ describe('app e2e — découverte, resume & fork (P2)', () => {
expect(fork.statusCode).toBe(201);
});
});
describe('app e2e — découverte auto des repos & masquage', () => {
let t: TestApp;
let scanRoot: string;
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });
beforeAll(() => {
t = makeApp('repos-discover');
// un « repo » côté scanner = un dossier avec .git (le scanner ne lance pas git).
scanRoot = join(dir, 'scan-root');
mkdirSync(join(scanRoot, 'alpha', '.git'), { recursive: true });
mkdirSync(join(scanRoot, 'beta', '.git'), { recursive: true });
});
it('POST /repos/discover enregistre les repos sous les racines configurées', async () => {
// configure la racine de scan via l'allow-list settings, désactive le périodique
const patch = await t.bundle.app.inject({
method: 'PATCH',
url: '/api/v1/settings',
headers: bearer(),
payload: { scanRoots: [scanRoot], scanIntervalMin: 0 },
});
expect(patch.statusCode).toBe(200);
const disc = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() });
expect(disc.statusCode).toBe(200);
const body = disc.json() as DiscoverReposResponse;
expect(body.added).toBe(2);
expect(body.scanned).toBe(2);
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
const repos = (list.json() as ReposListResponse).repos;
expect(repos.map((r) => r.label).sort()).toEqual(['alpha', 'beta']);
expect(repos.every((r) => r.hidden === false)).toBe(true);
// re-scan : idempotent (aucun nouveau)
const disc2 = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() });
expect((disc2.json() as DiscoverReposResponse).added).toBe(0);
});
it('PATCH /repos/:id { hidden } masque le repo', async () => {
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
const repo = (list.json() as ReposListResponse).repos[0];
const patch = await t.bundle.app.inject({
method: 'PATCH',
url: `/api/v1/repos/${repo.id}`,
headers: bearer(),
payload: { hidden: true },
});
expect(patch.statusCode).toBe(200);
expect((patch.json() as RepoResponse).repo.hidden).toBe(true);
// toujours listé (les masqués restent récupérables) mais avec hidden=true
const after = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
expect((after.json() as ReposListResponse).repos.find((r) => r.id === repo.id)?.hidden).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { scanForRepos } from '../src/core/repo-scanner.js';
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
function tmpRoot(): string {
const d = mkdtempSync(join(tmpdir(), 'arb-scan-'));
dirs.push(d);
return d;
}
// un « repo » pour le scanner = un dossier contenant `.git` (le scanner ne lance jamais git).
function makeRepo(...segs: string[]): void {
mkdirSync(join(...segs, '.git'), { recursive: true });
}
const limits = { maxDepth: 6, maxRepos: 2000 };
describe('scanForRepos', () => {
it('trouve un repo simple', async () => {
const root = tmpRoot();
makeRepo(root, 'proj');
const { paths, truncated } = await scanForRepos([root], limits);
expect(paths).toEqual([join(root, 'proj')]);
expect(truncated).toBe(false);
});
it('ne descend pas dans un repo trouvé (sous-repo ignoré)', async () => {
const root = tmpRoot();
makeRepo(root, 'a');
makeRepo(root, 'a', 'sub'); // imbriqué : doit être ignoré
const { paths } = await scanForRepos([root], limits);
expect(paths).toEqual([join(root, 'a')]);
});
it('ignore node_modules et les dotdirs', async () => {
const root = tmpRoot();
makeRepo(root, 'node_modules', 'pkg');
makeRepo(root, '.hidden', 'x');
makeRepo(root, 'real');
const { paths } = await scanForRepos([root], limits);
expect(paths).toEqual([join(root, 'real')]);
});
it('respecte maxDepth', async () => {
const root = tmpRoot();
makeRepo(root, 'a', 'b', 'c', 'deep'); // repo à profondeur 4
const shallow = await scanForRepos([root], { maxDepth: 2, maxRepos: 2000 });
expect(shallow.paths).toEqual([]);
const deep = await scanForRepos([root], { maxDepth: 4, maxRepos: 2000 });
expect(deep.paths).toEqual([join(root, 'a', 'b', 'c', 'deep')]);
});
it('ne suit pas les symlinks (cycle terminé, pas de doublon)', async () => {
const root = tmpRoot();
makeRepo(root, 'proj');
try {
symlinkSync(root, join(root, 'loop')); // cycle vers la racine
} catch {
/* symlink non autorisé sous certains CI : le test reste valide sans le lien */
}
const { paths } = await scanForRepos([root], limits);
expect(paths).toEqual([join(root, 'proj')]);
});
it('tronque à maxRepos', async () => {
const root = tmpRoot();
makeRepo(root, 'r1');
makeRepo(root, 'r2');
makeRepo(root, 'r3');
const { paths, truncated } = await scanForRepos([root], { maxDepth: 6, maxRepos: 2 });
expect(truncated).toBe(true);
expect(paths.length).toBeLessThanOrEqual(2);
});
it('ignore une racine inexistante sans lever', async () => {
const { paths } = await scanForRepos(['/nope/does/not/exist'], limits);
expect(paths).toEqual([]);
});
it('scanne plusieurs racines', async () => {
const r1 = tmpRoot();
const r2 = tmpRoot();
makeRepo(r1, 'one');
makeRepo(r2, 'two');
const { paths } = await scanForRepos([r1, r2], limits);
expect([...paths].sort()).toEqual([join(r1, 'one'), join(r2, 'two')].sort());
});
});

View File

@@ -75,6 +75,10 @@ describe('GET /api/v1/settings', () => {
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
expect(body.settings.giteaUrl).toBeNull();
// défauts de découverte : home (1 racine) + intervalle 5 min
expect(Array.isArray(body.settings.scanRoots)).toBe(true);
expect(body.settings.scanRoots).toHaveLength(1);
expect(body.settings.scanIntervalMin).toBe(5);
});
it('nexpose AUCUN secret (server_secret, clé privée VAPID)', async () => {
@@ -124,3 +128,36 @@ describe('PATCH /api/v1/settings', () => {
expect(res.statusCode).toBe(401);
});
});
describe('PATCH /api/v1/settings — découverte des dépôts', () => {
it('enregistre des scanRoots et un intervalle valides et les renvoie', async () => {
const res = await bundle.app.inject({
method: 'PATCH',
url: '/api/v1/settings',
headers: auth(),
payload: { scanRoots: ['/home/u/work', '/srv/code'], scanIntervalMin: 10 },
});
expect(res.statusCode).toBe(200);
const body = res.json() as SettingsResponse;
expect(body.settings.scanRoots).toEqual(['/home/u/work', '/srv/code']);
expect(body.settings.scanIntervalMin).toBe(10);
// persisté
const get = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
expect((get.json() as SettingsResponse).settings.scanRoots).toEqual(['/home/u/work', '/srv/code']);
});
it('rejette des racines non absolues, "/", avec ".." ou en surnombre (400)', async () => {
const bads: unknown[] = [['relative/path'], ['/'], ['/a/../b'], Array.from({ length: 17 }, (_, i) => `/r${i}`)];
for (const scanRoots of bads) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanRoots } });
expect(res.statusCode).toBe(400);
}
});
it('rejette un intervalle hors borne (400)', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanIntervalMin: 5000 } });
expect(res.statusCode).toBe(400);
const neg = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanIntervalMin: -1 } });
expect(neg.statusCode).toBe(400);
});
});

View File

@@ -1,9 +1,9 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { mkdtempSync, mkdirSync, 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 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';
@@ -34,9 +34,7 @@ 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);
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');
@@ -44,9 +42,21 @@ function makeTmpRepo(): string {
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;
@@ -144,4 +154,79 @@ describe('WorktreeManager', () => {
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([]);
});
});