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

@@ -40,6 +40,9 @@ export function registerWsGateway(
let subscribedWorktrees = false;
let subscribedGroups = false;
let alive = true;
// Worktrees « regardés » par CETTE connexion (clé repoId\0path) → push ciblé de worktree_changes.
const watched = new Set<string>();
const watchKey = (repoId: string, path: string): string => `${repoId}\0${path}`;
const send = (msg: ServerMessage): void => {
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
@@ -76,6 +79,10 @@ export function registerWsGateway(
const onGroupRemoved = (groupId: string): void => {
if (subscribedGroups) send({ type: 'group_removed', groupId });
};
// P7 — détail d'un worktree modifié : poussé UNIQUEMENT si cette connexion le regarde.
const onWorktreeChanges = (e: { repoId: string; path: string }): void => {
if (watched.has(watchKey(e.repoId, e.path))) send({ type: 'worktree_changes', ...e });
};
manager.on('session_update', onSessionUpdate);
manager.on('session_exit', onSessionExit);
discovery.on('discovery_update', onDiscoveryUpdate);
@@ -83,6 +90,7 @@ export function registerWsGateway(
worktrees.on('repo_removed', onRepoRemoved);
worktrees.on('worktree_update', onWorktreeUpdate);
worktrees.on('worktree_removed', onWorktreeRemoved);
worktrees.on('worktree_changes', onWorktreeChanges);
groups.on('group_update', onGroupUpdate);
groups.on('group_removed', onGroupRemoved);
@@ -126,6 +134,22 @@ export function registerWsGateway(
subscribedGroups = msg.topics.includes('groups');
return;
}
case 'watch': {
const key = watchKey(msg.repoId, msg.path);
if (watched.has(key)) return;
watched.add(key);
// Validation repo/worktree + armement du watcher FS (asynchrone, best-effort).
void worktrees.watch(msg.repoId, msg.path).catch((err) => {
watched.delete(key);
send({ type: 'error', code: 'NOT_FOUND', message: (err as Error).message });
});
return;
}
case 'unwatch': {
watched.delete(watchKey(msg.repoId, msg.path));
worktrees.unwatch(msg.repoId, msg.path);
return;
}
case 'attach': {
const channel = nextChannel++;
const binding: ClientBinding = {
@@ -215,10 +239,17 @@ export function registerWsGateway(
worktrees.off('repo_removed', onRepoRemoved);
worktrees.off('worktree_update', onWorktreeUpdate);
worktrees.off('worktree_removed', onWorktreeRemoved);
worktrees.off('worktree_changes', onWorktreeChanges);
groups.off('group_update', onGroupUpdate);
groups.off('group_removed', onGroupRemoved);
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
channels.clear();
// Libère le refcount du watcher pour chaque worktree regardé par cette connexion.
for (const key of watched) {
const sep = key.indexOf('\0');
worktrees.unwatch(key.slice(0, sep), key.slice(sep + 1));
}
watched.clear();
});
void req;