Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts). Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
98 lines
4.0 KiB
TypeScript
98 lines
4.0 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 ; en profondeur on NE descend PAS dedans (sous-modules/worktrees imbriqués
|
|
* ignorés). EXCEPTION : une racine fournie (depth 0) qui est elle-même un repo est aussi un
|
|
* conteneur : on l'enregistre ET on continue de descendre pour trouver les repos internes ;
|
|
* - 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 dossier avec `.git` est un repo. En PROFONDEUR (depth > 0) c'est une feuille : on
|
|
// l'enregistre sans descendre (on n'ouvre pas les sous-modules/worktrees imbriqués). Mais une
|
|
// RACINE fournie explicitement (depth 0) est un CONTENEUR de scan : si elle est elle-même un
|
|
// repo on l'enregistre, puis on CONTINUE de descendre pour découvrir les dépôts qu'elle contient.
|
|
if (existsSync(join(dir, '.git'))) {
|
|
found.add(dir);
|
|
if (depth > 0) 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 };
|
|
}
|