// Découverte auto des dépôts git : marche bornée du système de fichiers à la recherche de `.git`. // Fonction PURE et tolérante (ne lève jamais) — testable isolément comme parseWorktreePorcelain. // N'appelle JAMAIS git (détection par présence de `.git`) : la validation réelle (isRepo) et la // résolution de default_branch se font paresseusement à l'enregistrement, pas par dépôt scanné. import { readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; export interface ScanLimits { /** profondeur maximale de descente sous chaque racine (la racine = 0). */ maxDepth: number; /** nombre maximal de repos retournés (garde-fou anti-explosion d'un FS pathologique). */ maxRepos: number; /** noms de dossiers à ne jamais ouvrir (en plus des dotdirs, toujours exclus). */ excludeDirs?: Set; } /** Dossiers jamais explorés : grosses arborescences sans repos racine, ou bruit de build. */ export const DEFAULT_EXCLUDE_DIRS = new Set([ 'node_modules', 'vendor', 'target', 'dist', 'build', '.cache', 'venv', '.venv', '__pycache__', ]); interface Frame { dir: string; depth: number; } /** * Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`. * Règles : * - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo : * on l'enregistre et on NE descend PAS dedans (sous-modules/worktrees imbriqués ignorés) ; * - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS * suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ; * - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ; * - tolérance : un `readdir` qui échoue (EACCES/ENOENT) est ignoré, le scan continue ; * - racine inexistante/illisible : ignorée silencieusement. * Retourne les chemins absolus dédupliqués des racines de repos, et `truncated` si une borne a coupé. */ export async function scanForRepos( roots: string[], limits: ScanLimits, signal?: AbortSignal, ): Promise<{ paths: string[]; truncated: boolean }> { const excludes = limits.excludeDirs ?? DEFAULT_EXCLUDE_DIRS; const found = new Set(); const seen = new Set(); // ceinture-bretelles anti-cycle (chemins déjà visités) let truncated = false; // pile partagée entre toutes les racines : un seul plafond global maxRepos. const stack: Frame[] = []; for (const root of roots) stack.push({ dir: root, depth: 0 }); while (stack.length > 0) { if (signal?.aborted || found.size >= limits.maxRepos) { truncated = true; break; } const { dir, depth } = stack.pop() as Frame; if (seen.has(dir)) continue; seen.add(dir); // un repo : on l'enregistre et on ne descend pas. if (existsSync(join(dir, '.git'))) { found.add(dir); continue; } if (depth >= limits.maxDepth) continue; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; // EACCES/ENOENT/… : dossier ignoré, on poursuit } for (const e of entries) { if (!e.isDirectory()) continue; // symlinks non suivis (isDirectory() est false pour un lien) if (e.name.startsWith('.') || excludes.has(e.name)) continue; stack.push({ dir: join(dir, e.name), depth: depth + 1 }); } } return { paths: [...found], truncated }; }