From fa9952bc5cb00deb20597ddbe2f5fa967961ef0c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 18 Jun 2026 14:45:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20d=C3=A9couverte=20automatique=20des=20d?= =?UTF-8?q?=C3=A9p=C3=B4ts=20git=20(scan=20+=20montrer/cacher)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/server/scripts/acceptance-p1.mjs | 2 +- packages/server/scripts/acceptance-p2.mjs | 2 +- packages/server/scripts/acceptance-p3.mjs | 2 +- packages/server/scripts/acceptance-p4.mjs | 2 +- packages/server/scripts/acceptance-p5.mjs | 2 +- packages/server/src/app.ts | 8 +- packages/server/src/config.ts | 5 + packages/server/src/core/repo-discovery.ts | 42 ++++++++ packages/server/src/core/repo-scanner.ts | 92 ++++++++++++++++++ packages/server/src/core/scan-settings.ts | 65 +++++++++++++ packages/server/src/core/worktree-manager.ts | 89 +++++++++++++++-- packages/server/src/db/index.ts | 6 ++ packages/server/src/index.ts | 4 +- packages/server/src/routes/repos.ts | 17 +++- packages/server/src/routes/settings.ts | 33 ++++++- packages/server/test/app.e2e.test.ts | 57 +++++++++++ packages/server/test/repo-scanner.test.ts | 94 ++++++++++++++++++ packages/server/test/settings-routes.test.ts | 37 ++++++++ packages/server/test/worktree-manager.test.ts | 95 ++++++++++++++++++- packages/shared/src/api.ts | 25 ++++- packages/shared/src/protocol.ts | 2 + packages/web/src/components/RepoSection.vue | 17 +++- packages/web/src/i18n/en.ts | 16 +++- packages/web/src/i18n/fr.ts | 16 +++- packages/web/src/stores/settings.ts | 6 +- packages/web/src/stores/worktrees.ts | 17 ++++ packages/web/src/views/DashboardView.vue | 38 +++++++- packages/web/src/views/SettingsView.vue | 67 ++++++++++++- 28 files changed, 824 insertions(+), 34 deletions(-) create mode 100644 packages/server/src/core/repo-discovery.ts create mode 100644 packages/server/src/core/repo-scanner.ts create mode 100644 packages/server/src/core/scan-settings.ts create mode 100644 packages/server/test/repo-scanner.test.ts diff --git a/packages/server/scripts/acceptance-p1.mjs b/packages/server/scripts/acceptance-p1.mjs index cb28521..84f8f48 100644 --- a/packages/server/scripts/acceptance-p1.mjs +++ b/packages/server/scripts/acceptance-p1.mjs @@ -23,7 +23,7 @@ const check = (name, ok, detail = '') => { }; const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-')); -const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db')], { +const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/packages/server/scripts/acceptance-p2.mjs b/packages/server/scripts/acceptance-p2.mjs index 2bc2737..a1762ed 100644 --- a/packages/server/scripts/acceptance-p2.mjs +++ b/packages/server/scripts/acceptance-p2.mjs @@ -62,7 +62,7 @@ writeFileSync( const srv = spawn( 'node', - [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', claudeHome], + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', claudeHome, '--no-discover'], { env: { ...process.env, ARBORETUM_LOG: 'warn', PATH: `${fakeBin}:${process.env.PATH}` }, stdio: ['ignore', 'pipe', 'pipe'] }, ); let srvOut = ''; diff --git a/packages/server/scripts/acceptance-p3.mjs b/packages/server/scripts/acceptance-p3.mjs index c684966..0b3a6e0 100644 --- a/packages/server/scripts/acceptance-p3.mjs +++ b/packages/server/scripts/acceptance-p3.mjs @@ -36,7 +36,7 @@ git('commit', '-m', 'init'); const srv = spawn( 'node', - [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')], + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, ); let srvOut = ''; diff --git a/packages/server/scripts/acceptance-p4.mjs b/packages/server/scripts/acceptance-p4.mjs index d5e5e0e..0967d4d 100644 --- a/packages/server/scripts/acceptance-p4.mjs +++ b/packages/server/scripts/acceptance-p4.mjs @@ -27,7 +27,7 @@ const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p4-')); const srv = spawn( 'node', - [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')], + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, ); let srvOut = ''; diff --git a/packages/server/scripts/acceptance-p5.mjs b/packages/server/scripts/acceptance-p5.mjs index 5d47baa..cd01d81 100644 --- a/packages/server/scripts/acceptance-p5.mjs +++ b/packages/server/scripts/acceptance-p5.mjs @@ -36,7 +36,7 @@ git('commit', '-m', 'init'); const srv = spawn( 'node', - [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')], + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, ); let srvOut = ''; diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 0f19eeb..26414d7 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -11,6 +11,7 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service. import { PtyManager } from './core/pty-manager.js'; import { DiscoveryService } from './core/discovery-service.js'; import { WorktreeManager } from './core/worktree-manager.js'; +import { RepoDiscoveryService } from './core/repo-discovery.js'; import { GroupManager } from './core/group-manager.js'; import { PushService } from './core/push-service.js'; import { registerAuthRoutes } from './routes/auth.js'; @@ -37,6 +38,7 @@ export interface AppBundle { auth: AuthService; manager: PtyManager; discovery: DiscoveryService; + repoDiscovery: RepoDiscoveryService; worktrees: WorktreeManager; groups: GroupManager; push: PushService; @@ -54,6 +56,8 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund sessionsDir: config.claudeSessionsDir, }); const worktrees = new WorktreeManager(db, manager, discovery); + // Démarré dans runDaemon() (jamais ici) → le scan ne tourne pas pendant les tests qui appellent buildApp. + const repoDiscovery = new RepoDiscoveryService(db, worktrees); const groups = new GroupManager(db); void app.register(fastifyCookie); @@ -95,7 +99,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund registerAuthRoutes(app, auth, limiter, serverVersion); registerSessionRoutes(app, manager, discovery); - registerRepoRoutes(app, worktrees); + registerRepoRoutes(app, worktrees, db); registerGroupRoutes(app, groups); registerWorktreeRoutes(app, worktrees); registerPushRoutes(app, push); @@ -119,5 +123,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund }); } - return { app, auth, manager, discovery, worktrees, groups, push }; + return { app, auth, manager, discovery, repoDiscovery, worktrees, groups, push }; } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 176e205..f95c02f 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -17,6 +17,8 @@ export interface Config { claudeSessionsDir: string; /** sujet VAPID des notifications Web Push (mailto: ou URL). */ vapidContact: string; + /** découverte auto des repos au démarrage + périodique (désactivable via --no-discover). */ + autoDiscover: boolean; } export function loadConfig(argv = process.argv.slice(2)): Config { @@ -33,6 +35,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config { 'claude-home': { type: 'string' }, // sujet VAPID des notifications push (contact requis par la spec Web Push). 'vapid-contact': { type: 'string' }, + // désactive la découverte auto des repos (boot + périodique) — utilisé par les tests d'acceptation. + 'no-discover': { type: 'boolean', default: false }, }, strict: true, }); @@ -60,5 +64,6 @@ export function loadConfig(argv = process.argv.slice(2)): Config { claudeProjectsDir: join(claudeHome, 'projects'), claudeSessionsDir: join(claudeHome, 'sessions'), vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost', + autoDiscover: !(values['no-discover'] ?? false), }; } diff --git a/packages/server/src/core/repo-discovery.ts b/packages/server/src/core/repo-discovery.ts new file mode 100644 index 0000000..ab05888 --- /dev/null +++ b/packages/server/src/core/repo-discovery.ts @@ -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 { + try { + await this.worktrees.discoverRepos({ roots: readScanRoots(this.db) }); + } catch { + /* scan tolérant : une erreur ne doit pas tuer le timer */ + } + } +} diff --git a/packages/server/src/core/repo-scanner.ts b/packages/server/src/core/repo-scanner.ts new file mode 100644 index 0000000..604a595 --- /dev/null +++ b/packages/server/src/core/repo-scanner.ts @@ -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; +} + +/** 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 }; +} diff --git a/packages/server/src/core/scan-settings.ts b/packages/server/src/core/scan-settings.ts new file mode 100644 index 0000000..686564c --- /dev/null +++ b/packages/server/src/core/scan-settings.ts @@ -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; + } +} diff --git a/packages/server/src/core/worktree-manager.ts b/packages/server/src/core/worktree-manager.ts index 7bdb0c4..fb900c6 100644 --- a/packages/server/src/core/worktree-manager.ts +++ b/packages/server/src/core/worktree-manager.ts @@ -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 { export class WorktreeManager extends EventEmitter { private readonly factsCache = new Map; at: number }>(); private readonly locks = new Map>(); + /** Scan de découverte en cours : coalesce boot + bouton + périodique sur un seul scan. */ + private scanInFlight: Promise | null = null; constructor( private readonly db: Db, @@ -121,6 +130,7 @@ export class WorktreeManager extends EventEmitter { 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 { 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 { + async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean }): Promise { 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 { 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 { + 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 { + 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 { } async listAllWorktrees(): Promise { - 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(); } diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 0c179c3..b8c0cda 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -93,6 +93,12 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [ CREATE INDEX idx_group_repos_repo ON group_repos(repo_id); `, }, + { + // Découverte auto : un repo masqué reste en DB (exclu du dashboard) pour qu'un + // re-scan ne le ressuscite pas. hidden=1 = masqué. + id: 6, + sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`, + }, ]; export type Db = DatabaseSync; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 75301ed..2f1c6c3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -14,11 +14,12 @@ const pkg = JSON.parse( /** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */ export async function runDaemon(config: Config): Promise { const db = openDb(config.dbPath); - const { app, auth, manager, discovery } = buildApp(config, db, pkg.version); + const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version); const bootstrapToken = auth.ensureBootstrapToken(); await app.listen({ port: config.port, host: config.bind }); discovery.start(); // scan initial + rafraîchissement périodique des sessions découvertes + if (config.autoDiscover) repoDiscovery.start(); // découverte auto des repos : scan au boot + re-scan périodique const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`; app.log.info(`Arboretum v${pkg.version} — ${url}`); @@ -35,6 +36,7 @@ export async function runDaemon(config: Config): Promise { shuttingDown = true; app.log.info(`${signal} received — draining sessions then exiting`); discovery.stop(); + repoDiscovery.stop(); manager.shutdown(); setTimeout(() => { void app.close().then(() => process.exit(0)); diff --git a/packages/server/src/routes/repos.ts b/packages/server/src/routes/repos.ts index d6bf2f3..1e9d909 100644 --- a/packages/server/src/routes/repos.ts +++ b/packages/server/src/routes/repos.ts @@ -1,6 +1,8 @@ import type { FastifyInstance, FastifyReply } from 'fastify'; -import type { CreateRepoRequest, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared'; +import type { CreateRepoRequest, DiscoverReposResponse, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared'; import type { WorktreeManager } from '../core/worktree-manager.js'; +import type { Db } from '../db/index.js'; +import { readScanRoots } from '../core/scan-settings.js'; /** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */ export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply { @@ -8,9 +10,19 @@ export function sendManagerError(reply: FastifyReply, err: unknown): FastifyRepl return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } }); } -export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): void { +export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void { app.get('/api/v1/repos', async (): Promise => ({ repos: await wt.listRepos() })); + // Scan manuel : découvre et auto-enregistre les repos sous les racines configurées (settings). + app.post('/api/v1/repos/discover', async (_req, reply) => { + try { + const res: DiscoverReposResponse = await wt.discoverRepos({ roots: readScanRoots(db) }); + return reply.send(res); + } catch (err) { + return sendManagerError(reply, err); + } + }); + app.post('/api/v1/repos', async (req, reply) => { const body = req.body as Partial | null; if (!body || typeof body.path !== 'string') { @@ -38,6 +50,7 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): v ...(body.label !== undefined ? { label: body.label } : {}), ...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}), ...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}), + ...(typeof body.hidden === 'boolean' ? { hidden: body.hidden } : {}), }); const res: RepoResponse = { repo }; return reply.send(res); diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index a669ef2..a3ad9c0 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -6,8 +6,16 @@ import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arbor import type { Config } from '../config.js'; import { type Db, getSetting, setSetting } from '../db/index.js'; import type { PushService } from '../core/push-service.js'; +import { + SCAN_INTERVAL_KEY, + SCAN_ROOTS_KEY, + normalizeScanIntervalMin, + normalizeScanRoots, + readScanIntervalMin, + readScanRoots, +} from '../core/scan-settings.js'; -// Seule clé de `settings` modifiable via l'API. Les secrets ne figurent JAMAIS ici. +// Clés de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici. const GITEA_URL_KEY = 'gitea_url'; /** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */ @@ -42,7 +50,14 @@ export function registerSettingsRoutes( vapidPublicKey: push.publicKey() || null, vapidContact: config.vapidContact, }); - const snapshot = (): SettingsResponse => ({ settings: { giteaUrl: readGiteaUrl() }, server: serverInfo() }); + const snapshot = (): SettingsResponse => ({ + settings: { + giteaUrl: readGiteaUrl(), + scanRoots: readScanRoots(db), + scanIntervalMin: readScanIntervalMin(db), + }, + server: serverInfo(), + }); app.get('/api/v1/settings', async (): Promise => snapshot()); @@ -62,6 +77,20 @@ export function registerSettingsRoutes( return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } }); } } + if ('scanRoots' in body) { + const roots = normalizeScanRoots(body.scanRoots); + if (!roots) { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanRoots must be an array of ≤16 absolute, normalized paths (never "/")' } }); + } + setSetting(db, SCAN_ROOTS_KEY, JSON.stringify(roots)); + } + if ('scanIntervalMin' in body) { + const interval = normalizeScanIntervalMin(body.scanIntervalMin); + if (interval === null) { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanIntervalMin must be an integer between 0 and 1440' } }); + } + setSetting(db, SCAN_INTERVAL_KEY, String(interval)); + } return reply.send(snapshot()); }); } diff --git a/packages/server/test/app.e2e.test.ts b/packages/server/test/app.e2e.test.ts index 939593e..89f6d10 100644 --- a/packages/server/test/app.e2e.test.ts +++ b/packages/server/test/app.e2e.test.ts @@ -7,6 +7,7 @@ import { openDb, type Db } from '../src/db/index.js'; import { munge } from '../src/core/jsonl-discovery.js'; import { readProcStart } from '../src/core/session-registry.js'; import type { Config } from '../src/config.js'; +import type { DiscoverReposResponse, RepoResponse, ReposListResponse } from '@arboretum/shared'; // resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH. vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); @@ -354,3 +355,59 @@ describe('app e2e — découverte, resume & fork (P2)', () => { expect(fork.statusCode).toBe(201); }); }); + +describe('app e2e — découverte auto des repos & masquage', () => { + let t: TestApp; + let scanRoot: string; + const bearer = (): Record => ({ authorization: `Bearer ${t.token}` }); + + beforeAll(() => { + t = makeApp('repos-discover'); + // un « repo » côté scanner = un dossier avec .git (le scanner ne lance pas git). + scanRoot = join(dir, 'scan-root'); + mkdirSync(join(scanRoot, 'alpha', '.git'), { recursive: true }); + mkdirSync(join(scanRoot, 'beta', '.git'), { recursive: true }); + }); + + it('POST /repos/discover enregistre les repos sous les racines configurées', async () => { + // configure la racine de scan via l'allow-list settings, désactive le périodique + const patch = await t.bundle.app.inject({ + method: 'PATCH', + url: '/api/v1/settings', + headers: bearer(), + payload: { scanRoots: [scanRoot], scanIntervalMin: 0 }, + }); + expect(patch.statusCode).toBe(200); + + const disc = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() }); + expect(disc.statusCode).toBe(200); + const body = disc.json() as DiscoverReposResponse; + expect(body.added).toBe(2); + expect(body.scanned).toBe(2); + + const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() }); + const repos = (list.json() as ReposListResponse).repos; + expect(repos.map((r) => r.label).sort()).toEqual(['alpha', 'beta']); + expect(repos.every((r) => r.hidden === false)).toBe(true); + + // re-scan : idempotent (aucun nouveau) + const disc2 = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() }); + expect((disc2.json() as DiscoverReposResponse).added).toBe(0); + }); + + it('PATCH /repos/:id { hidden } masque le repo', async () => { + const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() }); + const repo = (list.json() as ReposListResponse).repos[0]; + const patch = await t.bundle.app.inject({ + method: 'PATCH', + url: `/api/v1/repos/${repo.id}`, + headers: bearer(), + payload: { hidden: true }, + }); + expect(patch.statusCode).toBe(200); + expect((patch.json() as RepoResponse).repo.hidden).toBe(true); + // toujours listé (les masqués restent récupérables) mais avec hidden=true + const after = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() }); + expect((after.json() as ReposListResponse).repos.find((r) => r.id === repo.id)?.hidden).toBe(true); + }); +}); diff --git a/packages/server/test/repo-scanner.test.ts b/packages/server/test/repo-scanner.test.ts new file mode 100644 index 0000000..b453c85 --- /dev/null +++ b/packages/server/test/repo-scanner.test.ts @@ -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()); + }); +}); diff --git a/packages/server/test/settings-routes.test.ts b/packages/server/test/settings-routes.test.ts index 9580b08..b244af7 100644 --- a/packages/server/test/settings-routes.test.ts +++ b/packages/server/test/settings-routes.test.ts @@ -75,6 +75,10 @@ describe('GET /api/v1/settings', () => { expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']); expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer expect(body.settings.giteaUrl).toBeNull(); + // défauts de découverte : home (1 racine) + intervalle 5 min + expect(Array.isArray(body.settings.scanRoots)).toBe(true); + expect(body.settings.scanRoots).toHaveLength(1); + expect(body.settings.scanIntervalMin).toBe(5); }); it('n’expose AUCUN secret (server_secret, clé privée VAPID)', async () => { @@ -124,3 +128,36 @@ describe('PATCH /api/v1/settings', () => { expect(res.statusCode).toBe(401); }); }); + +describe('PATCH /api/v1/settings — découverte des dépôts', () => { + it('enregistre des scanRoots et un intervalle valides et les renvoie', async () => { + const res = await bundle.app.inject({ + method: 'PATCH', + url: '/api/v1/settings', + headers: auth(), + payload: { scanRoots: ['/home/u/work', '/srv/code'], scanIntervalMin: 10 }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as SettingsResponse; + expect(body.settings.scanRoots).toEqual(['/home/u/work', '/srv/code']); + expect(body.settings.scanIntervalMin).toBe(10); + // persisté + const get = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() }); + expect((get.json() as SettingsResponse).settings.scanRoots).toEqual(['/home/u/work', '/srv/code']); + }); + + it('rejette des racines non absolues, "/", avec ".." ou en surnombre (400)', async () => { + const bads: unknown[] = [['relative/path'], ['/'], ['/a/../b'], Array.from({ length: 17 }, (_, i) => `/r${i}`)]; + for (const scanRoots of bads) { + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanRoots } }); + expect(res.statusCode).toBe(400); + } + }); + + it('rejette un intervalle hors borne (400)', async () => { + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanIntervalMin: 5000 } }); + expect(res.statusCode).toBe(400); + const neg = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanIntervalMin: -1 } }); + expect(neg.statusCode).toBe(400); + }); +}); diff --git a/packages/server/test/worktree-manager.test.ts b/packages/server/test/worktree-manager.test.ts index e3be201..36d76ca 100644 --- a/packages/server/test/worktree-manager.test.ts +++ b/packages/server/test/worktree-manager.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, basename, dirname, resolve } from 'node:path'; -import type { WorktreeSummary } from '@arboretum/shared'; +import type { RepoSummary, WorktreeSummary } from '@arboretum/shared'; import { WorktreeManager } from '../src/core/worktree-manager.js'; import { PtyManager } from '../src/core/pty-manager.js'; import { DiscoveryService } from '../src/core/discovery-service.js'; @@ -34,9 +34,7 @@ afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); -function makeTmpRepo(): string { - const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-')); - dirs.push(dir); +function gitInit(dir: string): void { const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); run('init', '-b', 'main'); run('config', 'user.email', 'test@arboretum.dev'); @@ -44,9 +42,21 @@ function makeTmpRepo(): string { writeFileSync(join(dir, 'README.md'), '# test\n'); run('add', '-A'); run('commit', '-m', 'init'); +} + +function makeTmpRepo(): string { + const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-')); + dirs.push(dir); + gitInit(dir); return dir; } +/** Crée un vrai repo git à un chemin donné (sous une racine de scan contrôlée). */ +function makeRepoAt(path: string): void { + mkdirSync(path, { recursive: true }); + gitInit(path); +} + describe('WorktreeManager', () => { let db: Db; let pty: PtyManager; @@ -144,4 +154,79 @@ describe('WorktreeManager', () => { pty.spawn({ cwd: wtPath, command: 'bash' }); await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' }); }); + + it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + expect(r.hidden).toBe(false); + const updates: RepoSummary[] = []; + wt.on('repo_update', (s) => updates.push(s)); + const u = await wt.updateRepo(r.id, { hidden: true }); + expect(u.hidden).toBe(true); + expect(updates.some((s) => s.id === r.id && s.hidden)).toBe(true); + }); + + it('listAllWorktrees exclut les repos masqués', async () => { + const repo = makeTmpRepo(); + const r = await wt.addRepo({ path: repo }); + expect((await wt.listAllWorktrees()).length).toBeGreaterThan(0); // main worktree présent + await wt.updateRepo(r.id, { hidden: true }); + expect(await wt.listAllWorktrees()).toHaveLength(0); + }); + + it('discoverRepos : auto-ajoute les nouveaux, idempotent, masqué non ressuscité, supprimé re-découvrable', async () => { + const root = mkdtempSync(join(tmpdir(), 'arb-scan-')); + dirs.push(root); + makeRepoAt(join(root, 'a')); + makeRepoAt(join(root, 'b')); + + const updates: RepoSummary[] = []; + wt.on('repo_update', (s) => updates.push(s)); + + const res = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); + expect(res.added).toBe(2); + expect(res.scanned).toBe(2); + expect(await wt.listRepos()).toHaveLength(2); + expect(updates).toHaveLength(2); // un repo_update par nouveau + + // re-scan : idempotent (aucun ajout, aucune émission) + updates.length = 0; + const res2 = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); + expect(res2.added).toBe(0); + expect(updates).toHaveLength(0); + expect(await wt.listRepos()).toHaveLength(2); + + // masquer 'a' puis re-scan : reste masqué, jamais ré-ajouté (invariant central) + const repoA = (await wt.listRepos()).find((r) => resolve(r.path) === resolve(join(root, 'a'))); + await wt.updateRepo(repoA!.id, { hidden: true }); + updates.length = 0; + const res3 = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); + expect(res3.added).toBe(0); + expect((await wt.listRepos()).find((r) => r.id === repoA!.id)?.hidden).toBe(true); + + // supprimer 'b' puis re-scan : re-découvert (volontaire) + const repoB = (await wt.listRepos()).find((r) => resolve(r.path) === resolve(join(root, 'b'))); + wt.removeRepo(repoB!.id); + expect(await wt.listRepos()).toHaveLength(1); + const res4 = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); + expect(res4.added).toBe(1); + expect(await wt.listRepos()).toHaveLength(2); + }); + + it('discoverRepos : repo disparu du disque conservé en DB (valid=false), non re-trouvé', async () => { + const root = mkdtempSync(join(tmpdir(), 'arb-scan-')); + dirs.push(root); + makeRepoAt(join(root, 'gone')); + await wt.discoverRepos({ roots: [root], maxDepth: 2 }); + expect(await wt.listRepos()).toHaveLength(1); + + rmSync(join(root, 'gone'), { recursive: true, force: true }); // disparaît du disque + const res = await wt.discoverRepos({ roots: [root], maxDepth: 2 }); + expect(res.added).toBe(0); // plus trouvé par le scan + const repos = await wt.listRepos(); + expect(repos).toHaveLength(1); // mais la ligne est conservée (pas de suppression auto) + expect(repos[0].valid).toBe(false); + // robustesse : un repo dont le chemin a disparu ne fait pas planter /worktrees (git échoue → []) + await expect(wt.listAllWorktrees()).resolves.toEqual([]); + }); }); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 29b6771..39042c9 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -76,6 +76,19 @@ export interface UpdateRepoRequest { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; + /** true = masquer le repo (exclu du dashboard, conservé en DB) ; false = ré-afficher. */ + hidden?: boolean; +} + +/** Résultat d'un scan de découverte (POST /api/v1/repos/discover). */ +export interface DiscoverReposResponse { + /** dossiers-repos trouvés sur disque. */ + scanned: number; + /** repos réellement insérés (nouveaux, non déjà enregistrés). */ + added: number; + durationMs: number; + /** true si la limite (maxRepos / timeout) a été atteinte avant la fin du scan. */ + truncated: boolean; } export interface WorktreesListResponse { @@ -185,10 +198,20 @@ export interface ServerInfo { } export interface SettingsResponse { /** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */ - settings: { giteaUrl: string | null }; + settings: { + giteaUrl: string | null; + /** racines absolues scannées pour la découverte auto des repos (défaut : home). */ + scanRoots: string[]; + /** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */ + scanIntervalMin: number; + }; server: ServerInfo; } export interface UpdateSettingsRequest { /** URL de l'instance Gitea (http/https) ; null ou '' pour effacer. */ giteaUrl?: string | null; + /** racines absolues à scanner (chemins normalisés, ≤ 16) ; remplace la liste. */ + scanRoots?: string[]; + /** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */ + scanIntervalMin?: number; } diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 2806aa4..f00fb3d 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -136,6 +136,8 @@ export interface RepoSummary { createdAt: string; /** false si le chemin n'est plus un repo git accessible. */ valid: boolean; + /** true = masqué du dashboard (conservé en DB → non ré-ajouté au re-scan). */ + hidden: boolean; } export interface WorktreeGitStatus { diff --git a/packages/web/src/components/RepoSection.vue b/packages/web/src/components/RepoSection.vue index 0896712..82bfa2c 100644 --- a/packages/web/src/components/RepoSection.vue +++ b/packages/web/src/components/RepoSection.vue @@ -7,6 +7,13 @@
{{ t('worktrees.new') }} {{ t('worktrees.prune') }} +
@@ -41,7 +48,7 @@ diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 7b2a20b..7d2922f 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -122,8 +122,13 @@ export default { remove: 'Remove', pathLabel: 'Repository path', pathPlaceholder: '/absolute/path/to/repo', - empty: 'No repository registered yet — add one above.', + empty: 'No repository registered yet — add one above, or scan for repos.', invalid: 'unavailable', + scan: 'Scan', + scanResult: 'No new repository found | 1 new repository added | {n} new repositories added', + hide: 'Hide', + show: 'Show', + showHidden: 'Show hidden ({n})', }, worktrees: { new: 'New worktree', @@ -336,6 +341,15 @@ export default { giteaUrlHint: 'Adds a shortcut icon to the navigation. Leave empty to hide it.', save: 'Save', saved: 'Saved', + // Découverte des dépôts + discovery: 'Repository discovery', + discoveryHint: 'Arboretum scans these folders for git repositories and registers them automatically. Hidden repos are kept and never re-added.', + scanRoots: 'Scan folders', + scanRootsEmpty: 'No folder configured — your home directory is used by default.', + addRoot: 'Add folder', + removeRoot: 'Remove', + scanInterval: 'Re-scan interval (minutes)', + scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.', // Serveur (lecture seule) server: 'Server', serverHint: 'Set at startup via CLI flags — changing them requires restarting the daemon.', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index e7f419a..8ac3c0c 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -124,8 +124,13 @@ const fr: typeof en = { remove: 'Retirer', pathLabel: 'Chemin du dépôt', pathPlaceholder: '/chemin/absolu/du/repo', - empty: 'Aucun dépôt enregistré — ajoutez-en un ci-dessus.', + empty: 'Aucun dépôt enregistré — ajoutez-en un ci-dessus, ou lancez un scan.', invalid: 'indisponible', + scan: 'Scanner', + scanResult: 'Aucun nouveau dépôt trouvé | 1 nouveau dépôt ajouté | {n} nouveaux dépôts ajoutés', + hide: 'Masquer', + show: 'Afficher', + showHidden: 'Afficher les masqués ({n})', }, worktrees: { new: 'Nouveau worktree', @@ -339,6 +344,15 @@ const fr: typeof en = { giteaUrlHint: 'Ajoute une icône de raccourci dans la navigation. Laissez vide pour la masquer.', save: 'Enregistrer', saved: 'Enregistré', + // Découverte des dépôts + discovery: 'Découverte des dépôts', + discoveryHint: 'Arboretum scanne ces dossiers à la recherche de dépôts git et les enregistre automatiquement. Les dépôts masqués sont conservés et jamais ré-ajoutés.', + scanRoots: 'Dossiers à scanner', + scanRootsEmpty: 'Aucun dossier configuré — votre dossier personnel est utilisé par défaut.', + addRoot: 'Ajouter un dossier', + removeRoot: 'Retirer', + scanInterval: 'Intervalle de re-scan (minutes)', + scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.', // Serveur (lecture seule) server: 'Serveur', serverHint: 'Définis au démarrage via des flags CLI — les modifier nécessite de redémarrer le daemon.', diff --git a/packages/web/src/stores/settings.ts b/packages/web/src/stores/settings.ts index f71a097..c3b3474 100644 --- a/packages/web/src/stores/settings.ts +++ b/packages/web/src/stores/settings.ts @@ -8,12 +8,16 @@ import { api } from '../lib/api'; export const useSettingsStore = defineStore('settings', () => { const server = ref(null); const giteaUrl = ref(null); + const scanRoots = ref([]); + const scanIntervalMin = ref(0); const loaded = ref(false); const saving = ref(false); function apply(res: SettingsResponse): void { server.value = res.server; giteaUrl.value = res.settings.giteaUrl; + scanRoots.value = res.settings.scanRoots; + scanIntervalMin.value = res.settings.scanIntervalMin; loaded.value = true; } @@ -30,5 +34,5 @@ export const useSettingsStore = defineStore('settings', () => { } } - return { server, giteaUrl, loaded, saving, fetch, save }; + return { server, giteaUrl, scanRoots, scanIntervalMin, loaded, saving, fetch, save }; }); diff --git a/packages/web/src/stores/worktrees.ts b/packages/web/src/stores/worktrees.ts index 5951fc2..b00a425 100644 --- a/packages/web/src/stores/worktrees.ts +++ b/packages/web/src/stores/worktrees.ts @@ -3,6 +3,7 @@ import { ref } from 'vue'; import type { CreateWorktreeRequest, CreateWorktreeResponse, + DiscoverReposResponse, RepoResponse, ReposListResponse, RepoSummary, @@ -84,6 +85,19 @@ export const useWorktreesStore = defineStore('worktrees', () => { removeRepoLocal(id); } + /** Masque/ré-affiche un repo (persiste `hidden` côté serveur ; émis aussi par WS). */ + async function setHidden(id: string, hidden: boolean): Promise { + const res = await api.patch(`/api/v1/repos/${id}`, { hidden }); + upsertRepo(res.repo); + } + + /** Lance un scan de découverte ; les nouveaux repos arrivent par WS, on resynchronise par sûreté. */ + async function discover(): Promise { + const res = await api.post('/api/v1/repos/discover'); + await fetchAll(); + return res; + } + async function createWorktree(repoId: string, req: CreateWorktreeRequest): Promise { const res = await api.post(`/api/v1/repos/${repoId}/worktrees`, req); upsertWorktree(res.worktree); @@ -117,6 +131,9 @@ export const useWorktreesStore = defineStore('worktrees', () => { fetchAll, addRepo, removeRepo, + setHidden, + discover, + refreshRepoWorktrees, createWorktree, deleteWorktree, prune, diff --git a/packages/web/src/views/DashboardView.vue b/packages/web/src/views/DashboardView.vue index 6eed9cd..1a7a03c 100644 --- a/packages/web/src/views/DashboardView.vue +++ b/packages/web/src/views/DashboardView.vue @@ -1,5 +1,8 @@