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:
94
packages/server/test/repo-scanner.test.ts
Normal file
94
packages/server/test/repo-scanner.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user