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);
});
});

View File

@@ -7,6 +7,7 @@ import {
parseWorktreePorcelain,
isValidBranchName,
isSafeAbsolutePath,
isSafeRelativePath,
isRepo,
listWorktrees,
worktreeStatus,
@@ -19,7 +20,20 @@ import {
currentBranch,
listBranches,
commitAll,
parsePorcelainV2,
parseNumstatZ,
listChanges,
fileDiff,
stageFiles,
unstageFiles,
restoreFiles,
cleanFiles,
commitStaged,
amendCommit,
lastCommit,
isUnpushed,
} from '../src/core/git.js';
import { appendFileSync } from 'node:fs';
const dirs: string[] = [];
afterEach(() => {
@@ -170,6 +184,116 @@ describe('branche : existence, liste, courante, commit', () => {
});
});
describe('P7 — parseurs purs (status v2 / numstat)', () => {
it('parsePorcelainV2 : modifié, untracked, renommé', () => {
const z =
'1 .M N... 100644 100644 100644 h1 h2 file.txt\x00' +
'1 A. N... 000000 100644 100644 0000 h3 added.txt\x00' +
'? new.txt\x00' +
'2 R. N... 100644 100644 100644 h4 h5 R100 newname.txt\x00oldname.txt\x00';
const e = parsePorcelainV2(z);
expect(e).toHaveLength(4);
expect(e[0]).toMatchObject({ path: 'file.txt', indexStatus: '.', worktreeStatus: 'M', staged: false, unstaged: true });
expect(e[1]).toMatchObject({ path: 'added.txt', indexStatus: 'A', staged: true, unstaged: false });
expect(e[2]).toMatchObject({ path: 'new.txt', untracked: true, unstaged: true, staged: false });
expect(e[3]).toMatchObject({ path: 'newname.txt', renamedFrom: 'oldname.txt', staged: true });
});
it('parseNumstatZ : normal, binaire, renommé', () => {
const z = '5\t2\tfile.txt\x00-\t-\timg.png\x003\t1\t\x00old.txt\x00new.txt\x00';
const m = parseNumstatZ(z);
expect(m.get('file.txt')).toEqual({ insertions: 5, deletions: 2, binary: false });
expect(m.get('img.png')).toEqual({ insertions: null, deletions: null, binary: true });
expect(m.get('new.txt')).toEqual({ insertions: 3, deletions: 1, binary: false });
});
it('isSafeRelativePath', () => {
expect(isSafeRelativePath('src/a.ts')).toBe(true);
expect(isSafeRelativePath('/abs')).toBe(false);
expect(isSafeRelativePath('../escape')).toBe(false);
expect(isSafeRelativePath('-flag')).toBe(false);
expect(isSafeRelativePath('.git/config')).toBe(false);
expect(isSafeRelativePath('')).toBe(false);
});
});
describe('P7 — changes / diff / staging / commit sélectif (repo tmp)', () => {
it('listChanges : untracked + modifié non indexé + indexé', async () => {
const repo = makeTmpRepo();
writeFileSync(join(repo, 'untracked.txt'), 'x\n');
appendFileSync(join(repo, 'README.md'), 'more\n');
writeFileSync(join(repo, 'staged.txt'), 'y\n');
await stageFiles(repo, ['staged.txt']);
const { changes } = await listChanges(repo);
const byPath = Object.fromEntries(changes.map((c) => [c.path, c]));
expect(byPath['untracked.txt']).toMatchObject({ untracked: true, staged: false });
expect(byPath['README.md']).toMatchObject({ unstaged: true, staged: false });
expect(byPath['staged.txt']).toMatchObject({ staged: true });
expect(byPath['staged.txt'].insertions).toBeGreaterThan(0);
});
it('fileDiff : fichier suivi modifié, refus binaire implicite, untracked via --no-index', async () => {
const repo = makeTmpRepo();
appendFileSync(join(repo, 'README.md'), 'a new line\n');
const d = await fileDiff(repo, 'README.md');
expect(d.binary).toBe(false);
expect(d.diff).toContain('+a new line');
writeFileSync(join(repo, 'fresh.txt'), 'brand new\n');
const u = await fileDiff(repo, 'fresh.txt', { untracked: true });
expect(u.diff).toContain('+brand new');
});
it('stage / unstage / restore / clean', async () => {
const repo = makeTmpRepo();
appendFileSync(join(repo, 'README.md'), 'edit\n');
await stageFiles(repo, ['README.md']);
expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')?.staged).toBe(true);
await unstageFiles(repo, ['README.md']);
expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')?.staged).toBe(false);
await restoreFiles(repo, ['README.md']);
expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')).toBeUndefined();
writeFileSync(join(repo, 'junk.txt'), 'junk\n');
await cleanFiles(repo, ['junk.txt']);
expect((await listChanges(repo)).changes.some((c) => c.path === 'junk.txt')).toBe(false);
});
it('commitStaged : ne commite QUE lindex', async () => {
const repo = makeTmpRepo();
writeFileSync(join(repo, 'a.txt'), 'a\n');
writeFileSync(join(repo, 'b.txt'), 'b\n');
await stageFiles(repo, ['a.txt']);
await commitStaged(repo, 'add a only');
const { changes } = await listChanges(repo);
// a.txt committé (disparu), b.txt toujours non suivi.
expect(changes.some((c) => c.path === 'a.txt')).toBe(false);
expect(changes.some((c) => c.path === 'b.txt' && c.untracked)).toBe(true);
expect((await lastCommit(repo))?.subject).toBe('add a only');
});
it('amendCommit + isUnpushed : HEAD local non poussé', async () => {
const repo = makeTmpRepo();
expect(await isUnpushed(repo)).toBe(true);
await amendCommit(repo, 'init (amended)');
expect((await lastCommit(repo))?.subject).toBe('init (amended)');
});
it('worktreeStatus : compteurs détaillés + dernier commit', async () => {
const repo = makeTmpRepo();
writeFileSync(join(repo, 'staged.txt'), 's\n');
await stageFiles(repo, ['staged.txt']);
appendFileSync(join(repo, 'README.md'), 'unstaged\n');
const st = await worktreeStatus(repo);
expect(st.stagedCount).toBeGreaterThanOrEqual(1);
expect(st.unstagedCount).toBeGreaterThanOrEqual(1);
expect(st.conflictCount).toBe(0);
expect(st.lastCommitSubject).toBe('init');
expect(st.lastCommitHash).toBeTruthy();
});
});
describe('addWorktree : résolution auto (créer / réutiliser)', () => {
it('auto crée la branche si absente, la réutilise si présente', async () => {
const repo = makeTmpRepo();