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:
2026-06-27 12:15:00 +02:00
parent 062bb64d41
commit be43911dc0
21 changed files with 1610 additions and 21 deletions

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { FsWatcherService, isIgnoredPath } from '../src/core/fs-watcher.js';
const dirs: string[] = [];
const services: FsWatcherService[] = [];
afterEach(async () => {
for (const s of services.splice(0)) await s.closeAll();
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
function makeTmpRepo(): string {
const dir = mkdtempSync(join(tmpdir(), 'arb-fsw-'));
dirs.push(dir);
const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
run('init', '-b', 'main');
run('config', 'user.email', 'test@arboretum.dev');
run('config', 'user.name', 'Test');
writeFileSync(join(dir, 'README.md'), '# test\n');
run('add', '-A');
run('commit', '-m', 'init');
return dir;
}
/** Attend le prochain event `worktree_fs_change` (ou rejette au timeout). */
function nextChange(s: FsWatcherService, timeoutMs = 4000): Promise<{ repoId: string; path: string }> {
return new Promise((resolveP, reject) => {
const t = setTimeout(() => reject(new Error('timeout: aucun worktree_fs_change')), timeoutMs);
s.once('worktree_fs_change', (e) => {
clearTimeout(t);
resolveP(e);
});
});
}
describe('isIgnoredPath', () => {
it('ignore .git interne sauf HEAD/index, et node_modules', () => {
expect(isIgnoredPath('/r/.git/objects/ab/cd')).toBe(true);
expect(isIgnoredPath('/r/.git/refs/heads/main')).toBe(true);
expect(isIgnoredPath('/r/.git/HEAD')).toBe(false);
expect(isIgnoredPath('/r/.git/index')).toBe(false);
expect(isIgnoredPath('/r/node_modules/x/y.js')).toBe(true);
expect(isIgnoredPath('/r/src/a.ts')).toBe(false);
});
});
describe('FsWatcherService', () => {
it('refcount : watch ouvre, unwatch ne ferme pas tant que le plafond nest pas atteint', () => {
const repo = makeTmpRepo();
const s = new FsWatcherService();
services.push(s);
expect(s.isWatching('r1', repo)).toBe(false);
s.watch('r1', repo);
expect(s.isWatching('r1', repo)).toBe(true);
expect(s.size()).toBe(1);
s.unwatch('r1', repo);
expect(s.isWatching('r1', repo)).toBe(true); // idle, pas encore évincé
});
it('émet un worktree_fs_change débouncé à lédition dun fichier', async () => {
const repo = makeTmpRepo();
const s = new FsWatcherService({ debounceMs: 50 });
services.push(s);
s.watch('r1', repo);
await s.whenReady('r1', repo);
const p = nextChange(s);
writeFileSync(join(repo, 'edited.txt'), 'hello\n');
const e = await p;
expect(e.repoId).toBe('r1');
});
it('détecte un changement de branche externe (git checkout) via .git/HEAD', async () => {
const repo = makeTmpRepo();
execFileSync('git', ['branch', 'feature'], { cwd: repo });
const s = new FsWatcherService({ debounceMs: 50 });
services.push(s);
s.watch('r1', repo);
await s.whenReady('r1', repo);
const p = nextChange(s);
execFileSync('git', ['checkout', 'feature'], { cwd: repo, stdio: 'pipe' });
const e = await p;
expect(e.path).toContain('arb-fsw-');
});
it('le pool LRU évince les entrées idle au-delà du plafond, jamais les épinglées', () => {
const s = new FsWatcherService({ maxWatchers: 2 });
services.push(s);
const a = makeTmpRepo();
const b = makeTmpRepo();
const c = makeTmpRepo();
s.pinSession('a', a); // épinglé → jamais évincé
s.watch('b', b);
s.unwatch('b', b); // idle
s.watch('c', c); // dépasse le plafond → évince le plus ancien idle (b)
expect(s.isWatching('a', a)).toBe(true);
expect(s.isWatching('c', c)).toBe(true);
expect(s.isWatching('b', b)).toBe(false);
});
it('closeAll libère tout', async () => {
const repo = makeTmpRepo();
const s = new FsWatcherService();
s.watch('r1', repo);
expect(s.size()).toBe(1);
await s.closeAll();
expect(s.size()).toBe(0);
});
});