Files
arboretum/packages/server/test/fs-watcher.test.ts
Johan LEROY be43911dc0 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).
2026-06-27 12:15:00 +02:00

112 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});