// Watcher FS des worktrees ACTIFS (regardés par un client OU portant une session vivante). // Émet un signal débouncé `worktree_fs_change` que le WorktreeManager traduit en recalcul du // statut git + broadcast WS. Pool LRU borné + refcount pour ne jamais épuiser les descripteurs : // un worktree n'est watché que tant qu'il est observé/épinglé, et la taille totale est plafonnée. import { EventEmitter } from 'node:events'; import { resolve, sep, join } from 'node:path'; import chokidar, { type FSWatcher } from 'chokidar'; import { resolveGitDir } from './git.js'; const DEFAULT_MAX_WATCHERS = 32; const DEBOUNCE_MS = 200; export interface FsWatcherEvents { /** le contenu d'un worktree surveillé a changé (édition, staging, checkout externe…). */ worktree_fs_change: [{ repoId: string; path: string }]; } interface WatchEntry { repoId: string; path: string; watcher: FSWatcher; /** nombre de clients qui « regardent » ce worktree. */ refCount: number; /** nombre de sessions vivantes épinglant ce worktree. */ sessionPins: number; /** épingle « permanente » (checkout principal d'un repo enregistré) — jamais évincée (P11). */ repoPins: number; lastUsed: number; debounce: NodeJS.Timeout | null; /** résolue quand chokidar a fini son scan initial (les events deviennent fiables). */ ready: Promise; } /** * Ignore tout sous `.git/` SAUF `HEAD` et `index` (⇒ on détecte le `git checkout` externe et le * staging) ainsi que `node_modules`. chokidar n'ignore pas le dossier `.git` lui-même afin de * pouvoir descendre jusqu'à `HEAD`/`index`, mais saute ses sous-dossiers volumineux (objects…). */ export function isIgnoredPath(p: string): boolean { if (p.includes(`${sep}node_modules${sep}`) || p.endsWith(`${sep}node_modules`)) return true; if (p.includes(`${sep}.git${sep}`)) { return !(p.endsWith(`${sep}HEAD`) || p.endsWith(`${sep}index`)); } return false; } export interface FsWatcherOptions { maxWatchers?: number; debounceMs?: number; } export class FsWatcherService extends EventEmitter { private readonly entries = new Map(); private readonly maxWatchers: number; private readonly debounceMs: number; constructor(opts: FsWatcherOptions = {}) { super(); this.maxWatchers = opts.maxWatchers ?? DEFAULT_MAX_WATCHERS; this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS; } private key(repoId: string, path: string): string { return `${repoId}\0${resolve(path)}`; } /** Un client commence à observer un worktree (vue IDE ouverte). */ watch(repoId: string, path: string): void { const e = this.ensure(repoId, path); e.refCount++; e.lastUsed = Date.now(); } /** Un client cesse d'observer ; le watcher reste (idle) jusqu'à éviction LRU. */ unwatch(repoId: string, path: string): void { const e = this.entries.get(this.key(repoId, path)); if (!e) return; e.refCount = Math.max(0, e.refCount - 1); e.lastUsed = Date.now(); } /** Épingle un worktree tant qu'une session y est vivante (jamais évincé). */ pinSession(repoId: string, path: string): void { const e = this.ensure(repoId, path); e.sessionPins++; e.lastUsed = Date.now(); } unpinSession(repoId: string, path: string): void { const e = this.entries.get(this.key(repoId, path)); if (!e) return; e.sessionPins = Math.max(0, e.sessionPins - 1); e.lastUsed = Date.now(); } /** * Épingle en PERMANENCE le checkout principal d'un repo enregistré (P11) : ainsi un `git checkout` * en CLI sur le principal est détecté et rediffusé sans qu'aucun client n'ait « regardé » ce * worktree. Idempotent (un seul pin par repo+path conservé). Jamais évincé par la LRU. */ pinRepo(repoId: string, path: string): void { const e = this.ensure(repoId, path); e.repoPins = 1; // idempotent : on ne cumule pas (un seul checkout principal par repo) e.lastUsed = Date.now(); } unpinRepo(repoId: string, path: string): void { const e = this.entries.get(this.key(repoId, path)); if (!e) return; e.repoPins = 0; e.lastUsed = Date.now(); } /** Nombre de watchers actifs (test/diagnostic). */ size(): number { return this.entries.size; } isWatching(repoId: string, path: string): boolean { return this.entries.has(this.key(repoId, path)); } /** Résout quand le watcher de ce worktree a fini son scan initial (utile aux tests). */ whenReady(repoId: string, path: string): Promise { return this.entries.get(this.key(repoId, path))?.ready ?? Promise.resolve(); } private ensure(repoId: string, path: string): WatchEntry { const key = this.key(repoId, path); const existing = this.entries.get(key); if (existing) return existing; const abs = resolve(path); const watcher = chokidar.watch(abs, { ignored: (p: string) => isIgnoredPath(p), ignoreInitial: true, // coalesce les écritures rapides (build, génération) avant d'émettre. awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 40 }, }); let resolveReady: () => void = () => {}; const ready = new Promise((r) => (resolveReady = r)); watcher.once('ready', () => resolveReady()); const entry: WatchEntry = { repoId, path: abs, watcher, refCount: 0, sessionPins: 0, repoPins: 0, lastUsed: Date.now(), debounce: null, ready }; const onChange = (): void => this.schedule(entry); watcher.on('add', onChange).on('change', onChange).on('unlink', onChange).on('addDir', onChange).on('unlinkDir', onChange); this.entries.set(key, entry); // Worktree LIÉ : HEAD/index vivent hors du worktree (dans .git/worktrees/). On les ajoute // explicitement pour capter un changement de branche externe (git checkout en CLI). void resolveGitDir(abs).then((gitDir) => { if (!gitDir || !this.entries.has(key)) return; if (gitDir === join(abs, '.git') || gitDir.startsWith(abs + sep)) return; // déjà couvert watcher.add([join(gitDir, 'HEAD'), join(gitDir, 'index')]); }).catch(() => {}); this.evictIfNeeded(); return entry; } private schedule(entry: WatchEntry): void { if (entry.debounce) clearTimeout(entry.debounce); entry.debounce = setTimeout(() => { entry.debounce = null; entry.lastUsed = Date.now(); this.emit('worktree_fs_change', { repoId: entry.repoId, path: entry.path }); }, this.debounceMs); entry.debounce.unref(); } /** Ferme les watchers idle (refCount===0 && sessionPins===0) les moins récents au-delà du plafond. */ private evictIfNeeded(): void { if (this.entries.size <= this.maxWatchers) return; const idle = [...this.entries.entries()] .filter(([, e]) => e.refCount === 0 && e.sessionPins === 0 && e.repoPins === 0) .sort((a, b) => a[1].lastUsed - b[1].lastUsed); for (const [key, e] of idle) { if (this.entries.size <= this.maxWatchers) break; this.close(key, e); } // Si tout est actif (reffé/épinglé), on dépasse le plafond volontairement : un worktree // explicitement observé ne doit jamais perdre son temps réel. } private close(key: string, e: WatchEntry): void { if (e.debounce) clearTimeout(e.debounce); void e.watcher.close().catch(() => {}); this.entries.delete(key); } /** Libère tous les descripteurs (drain SIGTERM/SIGINT). */ async closeAll(): Promise { const all = [...this.entries.entries()]; this.entries.clear(); await Promise.all(all.map(([, e]) => { if (e.debounce) clearTimeout(e.debounce); return e.watcher.close().catch(() => {}); })); } }