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

@@ -179,6 +179,100 @@ export interface RepoBranchesResponse {
export interface CommitWorktreeRequest {
path: string;
message: string;
/**
* P7 — `all` (défaut, rétrocompat) : `git add -A` + commit (tout l'arbre) ; `staged` : commit
* de l'index uniquement (staging sélectif préalable via /stage). `amend` réécrit le dernier
* commit (refusé s'il est déjà poussé).
*/
mode?: 'all' | 'staged';
amend?: boolean;
}
// ---- P7 : diff par fichier, statut détaillé, contenu de fichier (IDE worktree) ----
/** Un fichier modifié dans un worktree (sortie de `git status --porcelain=v2` + `--numstat`). */
export interface FileChange {
/** chemin relatif à la racine du worktree (POSIX). */
path: string;
/** code d'état dans l'index (X de XY porcelain), '.' si inchangé. */
indexStatus: string;
/** code d'état dans l'arbre de travail (Y de XY porcelain), '.' si inchangé. */
worktreeStatus: string;
/** true si une partie est indexée (staged). */
staged: boolean;
/** true si une partie est non indexée (unstaged) ou non suivie (untracked). */
unstaged: boolean;
/** true pour les fichiers non suivis (untracked). */
untracked: boolean;
/** true pour les fichiers en conflit de merge (XY de type U/AA/DD). */
conflicted: boolean;
/** lignes ajoutées (null si binaire). */
insertions: number | null;
/** lignes supprimées (null si binaire). */
deletions: number | null;
/** true si git considère le fichier comme binaire (numstat = '-'). */
binary: boolean;
/** ancien chemin pour un renommage/copie, sinon absent. */
renamedFrom?: string;
}
/** GET /api/v1/repos/:id/worktrees/changes?path= — liste des fichiers modifiés d'un worktree. */
export interface WorktreeChangesResponse {
repoId: string;
path: string;
changes: FileChange[];
/** true si la liste a été bornée (trop de changements). */
truncated: boolean;
}
/** GET /api/v1/repos/:id/worktrees/diff?path=&file=&staged= — diff unifié d'un fichier. */
export interface FileDiffResponse {
path: string;
file: string;
staged: boolean;
binary: boolean;
/** true si le diff a été tronqué (fichier trop volumineux). */
tooLarge: boolean;
/** texte du diff unifié git (vide si binaire ou tooLarge). */
diff: string;
}
/** GET /api/v1/repos/:id/files/content?wt=&path= — contenu d'un fichier (pour l'éditeur Monaco). */
export interface FileContentResponse {
/** chemin relatif au worktree (POSIX). */
path: string;
content: string;
encoding: 'utf-8';
size: number;
/** langage déduit de l'extension (pour Monaco), si reconnu. */
language?: string;
}
/** PUT /api/v1/repos/:id/files/content — écriture d'un fichier (borné au worktree). */
export interface WriteFileRequest {
/** chemin absolu du worktree. */
wt: string;
/** chemin relatif au worktree (POSIX). */
path: string;
content: string;
}
export interface WriteFileResponse {
ok: true;
size: number;
}
/** P7 — corps commun des mutations de staging/discard. */
export interface WorktreeFilesRequest {
path: string;
/** chemins relatifs au worktree. */
files: string[];
}
/** DELETE des changements locaux ; `includeUntracked` autorise `git clean` (destructif, opt-in). */
export interface DiscardFilesRequest extends WorktreeFilesRequest {
includeUntracked?: boolean;
}
/** POST /api/v1/repos/:id/worktrees/fetch — `git fetch --all --prune`. */
export interface FetchWorktreeRequest {
path: string;
}
/** POST /api/v1/repos/:id/worktrees/pull — `git pull` (ff-only par défaut). */
export interface PullWorktreeRequest {
path: string;
mode?: 'ff-only' | 'rebase';
}
/** POST /api/v1/repos/:id/worktrees/push — pousse la branche du worktree (upstream auto si absent). */
export interface PushWorktreeRequest {
@@ -256,10 +350,12 @@ export interface GroupSessionResponse {
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
export interface FsEntry {
name: string;
/** chemin absolu du dossier */
/** chemin absolu de l'entrée */
path: string;
/** présent (true) uniquement en mode markRepos quand le dossier est un dépôt git */
isRepo?: boolean;
/** P7 — présent (true) en mode includeFiles quand l'entrée est un fichier (pas un dossier) */
isFile?: boolean;
}
export interface FsListResponse {
/** chemin absolu listé (normalisé) */

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: