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).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-18 14:45:12 +02:00
parent 940ccffeab
commit fa9952bc5c
28 changed files with 824 additions and 34 deletions

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