feat(p7): moteur git détaillé, API fichiers et watcher FS temps réel
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).
This commit is contained in:
179
packages/server/src/core/fs-watcher.ts
Normal file
179
packages/server/src/core/fs-watcher.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// 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(() => {});
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user