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:
42
packages/server/src/core/repo-discovery.ts
Normal file
42
packages/server/src/core/repo-discovery.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Planificateur de la découverte auto des repos : scan au démarrage + re-scan périodique.
|
||||
// Calqué sur DiscoveryService (sessions) — start()/stop() avec timer .unref(). Démarré depuis
|
||||
// runDaemon() UNIQUEMENT (jamais buildApp), ce qui isole naturellement les tests vitest du scan.
|
||||
// Lui-même sans état : il lit les racines/l'intervalle dans `settings` et délègue à WorktreeManager.
|
||||
import type { Db } from '../db/index.js';
|
||||
import type { WorktreeManager } from './worktree-manager.js';
|
||||
import { readScanIntervalMin, readScanRoots } from './scan-settings.js';
|
||||
|
||||
export class RepoDiscoveryService {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
private readonly worktrees: WorktreeManager,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
void this.refresh(); // scan initial asynchrone : ne bloque pas le boot
|
||||
const intervalMin = readScanIntervalMin(this.db);
|
||||
if (intervalMin > 0) {
|
||||
this.timer = setInterval(() => void this.refresh(), intervalMin * 60_000);
|
||||
this.timer.unref(); // ne maintient pas le process en vie
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Relit les racines (changement effectif sans redémarrage) et lance un scan. Ne lève jamais. */
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
await this.worktrees.discoverRepos({ roots: readScanRoots(this.db) });
|
||||
} catch {
|
||||
/* scan tolérant : une erreur ne doit pas tuer le timer */
|
||||
}
|
||||
}
|
||||
}
|
||||
92
packages/server/src/core/repo-scanner.ts
Normal file
92
packages/server/src/core/repo-scanner.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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 };
|
||||
}
|
||||
65
packages/server/src/core/scan-settings.ts
Normal file
65
packages/server/src/core/scan-settings.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur).
|
||||
// Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH
|
||||
// /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici.
|
||||
import { homedir } from 'node:os';
|
||||
import { getSetting } from '../db/index.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { isSafeAbsolutePath } from './git.js';
|
||||
|
||||
export const SCAN_ROOTS_KEY = 'scan_roots';
|
||||
export const SCAN_INTERVAL_KEY = 'scan_interval_min';
|
||||
|
||||
/** Intervalle par défaut du re-scan périodique (minutes). 0 = désactivé. */
|
||||
export const DEFAULT_SCAN_INTERVAL_MIN = 5;
|
||||
/** Borne haute de l'intervalle (24 h) et nombre maximal de racines. */
|
||||
export const MAX_SCAN_INTERVAL_MIN = 1440;
|
||||
export const MAX_SCAN_ROOTS = 16;
|
||||
|
||||
/** Racines à scanner. Défaut : le home de l'utilisateur. Lecture tolérante (JSON malformé → défaut). */
|
||||
export function readScanRoots(db: Db): string[] {
|
||||
const raw = getSetting(db, SCAN_ROOTS_KEY);
|
||||
if (!raw) return [homedir()];
|
||||
const parsed = normalizeScanRoots(safeParse(raw));
|
||||
return parsed && parsed.length > 0 ? parsed : [homedir()];
|
||||
}
|
||||
|
||||
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */
|
||||
export function readScanIntervalMin(db: Db): number {
|
||||
const raw = getSetting(db, SCAN_INTERVAL_KEY);
|
||||
if (raw === null) return DEFAULT_SCAN_INTERVAL_MIN;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 0 && n <= MAX_SCAN_INTERVAL_MIN ? n : DEFAULT_SCAN_INTERVAL_MIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide/normalise une liste de racines : tableau de chemins absolus normalisés (isSafeAbsolutePath),
|
||||
* jamais `/` (scan catastrophique), dédupliqués, ≤ MAX_SCAN_ROOTS. Retourne null si invalide (⇒ 400).
|
||||
* Une liste vide est valide (revient au défaut côté lecture).
|
||||
*/
|
||||
export function normalizeScanRoots(raw: unknown): string[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
if (raw.length > MAX_SCAN_ROOTS) return null;
|
||||
const out: string[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'string') return null;
|
||||
const p = item.trim();
|
||||
if (!isSafeAbsolutePath(p) || p === '/') return null;
|
||||
if (!out.includes(p)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Valide un intervalle (entier 0–MAX_SCAN_INTERVAL_MIN). Retourne null si invalide. */
|
||||
export function normalizeScanIntervalMin(raw: unknown): number | null {
|
||||
if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;
|
||||
if (raw < 0 || raw > MAX_SCAN_INTERVAL_MIN) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function safeParse(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import type {
|
||||
DiscoverReposResponse,
|
||||
HookRunResult,
|
||||
PostCreateHook,
|
||||
RepoSummary,
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
import type { Db } from '../db/index.js';
|
||||
import type { PtyManager } from './pty-manager.js';
|
||||
import { DiscoveryService, mergeSessions } from './discovery-service.js';
|
||||
import { scanForRepos } from './repo-scanner.js';
|
||||
import { preTrustProject } from './claude-trust.js';
|
||||
import {
|
||||
addWorktree,
|
||||
@@ -35,6 +37,10 @@ import {
|
||||
const FACTS_TTL_MS = 2500;
|
||||
const HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||
const HOOK_OUTPUT_MAX = 64 * 1024;
|
||||
// Bornes du scan de découverte (anti-explosion sur un home volumineux).
|
||||
const SCAN_MAX_DEPTH = 6;
|
||||
const SCAN_MAX_REPOS = 2000;
|
||||
const SCAN_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface RepoRow {
|
||||
id: string;
|
||||
@@ -44,6 +50,7 @@ interface RepoRow {
|
||||
post_create_hooks: string;
|
||||
pre_trust: number;
|
||||
created_at: string;
|
||||
hidden: number;
|
||||
}
|
||||
|
||||
export interface WorktreeManagerEvents {
|
||||
@@ -96,6 +103,8 @@ function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
|
||||
export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
private readonly factsCache = new Map<string, { facts: Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>; at: number }>();
|
||||
private readonly locks = new Map<string, Promise<unknown>>();
|
||||
/** Scan de découverte en cours : coalesce boot + bouton + périodique sur un seul scan. */
|
||||
private scanInFlight: Promise<DiscoverReposResponse> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
@@ -121,6 +130,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
preTrust: row.pre_trust === 1,
|
||||
createdAt: row.created_at,
|
||||
valid: await isRepo(row.path),
|
||||
hidden: row.hidden === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,24 +153,35 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
post_create_hooks: JSON.stringify(opts.postCreateHooks ?? []),
|
||||
pre_trust: opts.preTrust ? 1 : 0,
|
||||
created_at: new Date().toISOString(),
|
||||
hidden: 0,
|
||||
};
|
||||
this.db
|
||||
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at);
|
||||
try {
|
||||
this.db
|
||||
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden);
|
||||
} catch (err) {
|
||||
// Course possible avec un scan concurrent qui aurait inséré le même path entre le SELECT
|
||||
// d'unicité et cet INSERT (contrainte UNIQUE sur path) → on rend le même 409 explicite.
|
||||
if (String((err as { code?: string }).code).includes('CONSTRAINT')) {
|
||||
throw httpError(409, 'ALREADY_REGISTERED', 'This repository is already registered');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const summary = await this.rowToSummary(row);
|
||||
this.emit('repo_update', summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
|
||||
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean }): Promise<RepoSummary> {
|
||||
const row = this.getRepoRow(id);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
|
||||
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
|
||||
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
|
||||
if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
|
||||
this.db
|
||||
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ? WHERE id = ?')
|
||||
.run(row.label, row.post_create_hooks, row.pre_trust, id);
|
||||
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ? WHERE id = ?')
|
||||
.run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, id);
|
||||
const summary = await this.rowToSummary(row);
|
||||
this.emit('repo_update', summary);
|
||||
return summary;
|
||||
@@ -174,6 +195,53 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Découvre les repos git sous `roots` et auto-enregistre les NOUVEAUX (path absent de la DB).
|
||||
* Idempotent et anti-résurrection : un path déjà présent — visible OU masqué — n'est jamais
|
||||
* réécrit (INSERT ... ON CONFLICT DO NOTHING). Les scans concurrents sont coalescés. Tolérant :
|
||||
* ne lève pas (le scanner avale les erreurs FS). N'appelle aucun git pendant le scan
|
||||
* (default_branch=NULL, résolu paresseusement par rowToSummary à l'affichage).
|
||||
*/
|
||||
discoverRepos(opts: { roots: string[]; maxDepth?: number; maxRepos?: number }): Promise<DiscoverReposResponse> {
|
||||
if (this.scanInFlight) return this.scanInFlight;
|
||||
this.scanInFlight = this.runDiscovery(opts).finally(() => {
|
||||
this.scanInFlight = null;
|
||||
});
|
||||
return this.scanInFlight;
|
||||
}
|
||||
|
||||
private async runDiscovery(opts: { roots: string[]; maxDepth?: number; maxRepos?: number }): Promise<DiscoverReposResponse> {
|
||||
const t0 = Date.now();
|
||||
const { paths, truncated } = await scanForRepos(
|
||||
opts.roots,
|
||||
{ maxDepth: opts.maxDepth ?? SCAN_MAX_DEPTH, maxRepos: opts.maxRepos ?? SCAN_MAX_REPOS },
|
||||
AbortSignal.timeout(SCAN_TIMEOUT_MS),
|
||||
);
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden)
|
||||
VALUES (?, ?, ?, NULL, '[]', 0, ?, 0) ON CONFLICT(path) DO NOTHING`,
|
||||
);
|
||||
let added = 0;
|
||||
for (const path of paths) {
|
||||
const row: RepoRow = {
|
||||
id: randomUUID(),
|
||||
path,
|
||||
label: basename(path),
|
||||
default_branch: null,
|
||||
post_create_hooks: '[]',
|
||||
pre_trust: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
hidden: 0,
|
||||
};
|
||||
const res = insert.run(row.id, row.path, row.label, row.created_at);
|
||||
if (res.changes === 1) {
|
||||
added++;
|
||||
this.emit('repo_update', await this.rowToSummary(row)); // nouveaux uniquement
|
||||
}
|
||||
}
|
||||
return { scanned: paths.length, added, durationMs: Date.now() - t0, truncated };
|
||||
}
|
||||
|
||||
// ---- worktrees ----
|
||||
|
||||
/** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */
|
||||
@@ -214,8 +282,13 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
}
|
||||
|
||||
async listAllWorktrees(): Promise<WorktreeSummary[]> {
|
||||
const rows = this.db.prepare('SELECT id FROM repos ORDER BY created_at ASC').all() as Array<{ id: string }>;
|
||||
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id)));
|
||||
// Les repos masqués sont exclus du dashboard : inutile de calculer leurs worktrees (sous-process
|
||||
// git par repo). Le front charge paresseusement ceux d'un repo masqué via listRepoWorktrees
|
||||
// quand l'utilisateur active « afficher les masqués ».
|
||||
const rows = this.db.prepare('SELECT id FROM repos WHERE hidden = 0 ORDER BY created_at ASC').all() as Array<{ id: string }>;
|
||||
// Tolérance par repo : avec la découverte auto, un repo douteux (git en échec, chemin disparu,
|
||||
// permission) ne doit JAMAIS faire planter tout l'endpoint — il ne contribue alors aucun worktree.
|
||||
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id).catch(() => [])));
|
||||
return lists.flat();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user