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>
93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
// 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<string>;
|
|
}
|
|
|
|
/** Dossiers jamais explorés : grosses arborescences sans repos racine, ou bruit de build. */
|
|
export const DEFAULT_EXCLUDE_DIRS = new Set<string>([
|
|
'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<string>();
|
|
const seen = new Set<string>(); // 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 };
|
|
}
|