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).
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
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 n’est 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 d’un 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);
|
||
});
|
||
});
|