Fondations de la refonte « vrai IDE IA worktree » (chantier P7→P12).
- git.ts : listChanges (status --porcelain=v2 -z + --numstat fusionnés),
fileDiff (diff unifié borné, refus binaire, untracked via --no-index),
stage/unstage/restore/clean, commitStaged, amendCommit (refus si poussé),
fetchRemote, pull (ff-only|rebase), lastCommit, resolveGitDir,
isSafeRelativePath ; worktreeStatus enrichi (compteurs staged/unstaged/
conflict + dernier commit), champs additifs WorktreeGitStatus.
- core/fs-watcher.ts : FsWatcherService (chokidar, refcount + pool LRU borné,
ignore .git sauf HEAD/index, débounce) → invalide factsCache, rediffuse
worktree_update et émet worktree_changes.
- WorktreeManager : getWorktreeChanges/getFileDiff/stage/unstage/discard/
fetch/pull/watch/unwatch/assertPathInWorktree ; commitWorktree { mode, amend }.
- routes/git.ts (changes/diff/stage/unstage/discard/fetch/pull) et
routes/files.ts (GET/PUT contenu fichier, bornage strict au worktree) ;
fs/list?includeFiles=1 (flag isFile).
- protocole ADDITIF (PROTOCOL_VERSION inchangé) : messages WS watch/unwatch
(validés dans parseClientMessage) + worktree_changes poussé ciblé par la
gateway ; front wsClient.watchWorktree + lib/git-api.ts + api.put.
- tests git/fs-watcher/protocole + acceptance-p7.mjs (ALL GREEN).
- dépendance : chokidar (serveur).
180 lines
6.7 KiB
TypeScript
180 lines
6.7 KiB
TypeScript
// 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;
|
|
lastUsed: number;
|
|
debounce: NodeJS.Timeout | null;
|
|
/** résolue quand chokidar a fini son scan initial (les events deviennent fiables). */
|
|
ready: Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* 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<FsWatcherEvents> {
|
|
private readonly entries = new Map<string, WatchEntry>();
|
|
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();
|
|
}
|
|
|
|
/** 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<void> {
|
|
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<void>((r) => (resolveReady = r));
|
|
watcher.once('ready', () => resolveReady());
|
|
const entry: WatchEntry = { repoId, path: abs, watcher, refCount: 0, sessionPins: 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/<n>). 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)
|
|
.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<void> {
|
|
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(() => {});
|
|
}));
|
|
}
|
|
}
|