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

@@ -157,6 +157,20 @@ export interface WorktreeGitStatus {
behind: number;
dirtyCount: number;
upstream: string | null;
// ---- Champs additifs P7 (optionnels, rétrocompat) — compteurs bon marché calculés dans le
// même passage `status --porcelain=v2` que dirtyCount. La liste détaillée des changements
// (FileChange[]) n'est JAMAIS diffusée ici : elle est servie à la demande (REST) et poussée
// uniquement aux clients qui « regardent » le worktree (voir `worktree_changes`).
/** fichiers avec des modifications indexées (staged). */
stagedCount?: number;
/** fichiers avec des modifications non indexées (unstaged, untracked inclus). */
unstagedCount?: number;
/** fichiers en conflit de merge. */
conflictCount?: number;
/** hash court du dernier commit (HEAD), null si le dépôt n'a aucun commit. */
lastCommitHash?: string | null;
/** sujet (première ligne) du dernier commit. */
lastCommitSubject?: string | null;
}
export interface WorktreeSummary {
@@ -203,6 +217,11 @@ export type ClientMessage =
| { type: 'resize'; channel: number; cols: number; rows: number }
| { type: 'ack'; channel: number; bytes: number }
| { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups'> }
// P7 — abonnement ciblé au détail d'un worktree (diff/statut fin) : on ne pousse le détail
// qu'aux connexions qui « regardent » ce worktree (clé = repoId + path), indépendamment du
// topic global 'worktrees' (qui ne transporte que les compteurs légers).
| { type: 'watch'; repoId: string; path: string }
| { type: 'unwatch'; repoId: string; path: string }
| { type: 'ping' };
// ---- Messages serveur → client ----
@@ -217,6 +236,10 @@ export type ServerMessage =
| { type: 'repo_removed'; repoId: string }
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
| { type: 'worktree_removed'; repoId: string; path: string }
// P7 — signal léger « le détail (diff/fichiers modifiés) de ce worktree a changé, refais un
// GET /changes ou /diff ». Envoyé UNIQUEMENT aux connexions ayant `watch`é cette clé. On ne
// pousse pas le diff complet dans la frame (taille/flow control) : le client re-fetch en REST.
| { type: 'worktree_changes'; repoId: string; path: string }
| { type: 'group_update'; group: GroupSummary }
| { type: 'group_removed'; groupId: string }
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
@@ -279,6 +302,14 @@ export function parseClientMessage(raw: string): ClientMessage | null {
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups')
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups'> }
: null;
case 'watch':
case 'unwatch':
// repoId/path bornés ici (typage + longueur) ; l'existence repo/worktree et la sûreté du
// chemin sont re-validées côté serveur (isSafeAbsolutePath + findWorktree) avant d'armer.
return typeof m.repoId === 'string' && m.repoId.length > 0 && m.repoId.length <= 128 &&
typeof m.path === 'string' && m.path.length > 0 && m.path.length <= 4096
? { type: m.type, repoId: m.repoId, path: m.path }
: null;
case 'ping':
return { type: 'ping' };
default: