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

@@ -35,5 +35,6 @@ export const api = {
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
patch: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'PATCH', body),
put: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'PUT', body),
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
};

View File

@@ -0,0 +1,44 @@
// Wrappers REST typés pour le moteur git « IDE » (P7) + l'API fichiers (Monaco). Consommés par la
// vue Workspace (P8) et le panneau de commit (P9). Le worktree est désigné par son chemin absolu.
import type {
WorktreeChangesResponse,
FileDiffResponse,
FileContentResponse,
WriteFileResponse,
WorktreeResponse,
} from '@arboretum/shared';
import { api } from './api.js';
const enc = encodeURIComponent;
export const gitApi = {
changes: (repoId: string, wt: string): Promise<WorktreeChangesResponse> =>
api.get(`/api/v1/repos/${repoId}/worktrees/changes?path=${enc(wt)}`),
diff: (repoId: string, wt: string, file: string, staged = false): Promise<FileDiffResponse> =>
api.get(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc(wt)}&file=${enc(file)}&staged=${staged ? '1' : '0'}`),
stage: (repoId: string, wt: string, files: string[]): Promise<WorktreeResponse> =>
api.post(`/api/v1/repos/${repoId}/worktrees/stage`, { path: wt, files }),
unstage: (repoId: string, wt: string, files: string[]): Promise<WorktreeResponse> =>
api.post(`/api/v1/repos/${repoId}/worktrees/unstage`, { path: wt, files }),
discard: (repoId: string, wt: string, files: string[], includeUntracked = false): Promise<WorktreeResponse> =>
api.post(`/api/v1/repos/${repoId}/worktrees/discard`, { path: wt, files, includeUntracked }),
commit: (repoId: string, wt: string, message: string, opts: { mode?: 'all' | 'staged'; amend?: boolean } = {}): Promise<WorktreeResponse> =>
api.post(`/api/v1/repos/${repoId}/worktrees/commit`, { path: wt, message, ...opts }),
fetch: (repoId: string, wt: string): Promise<WorktreeResponse> =>
api.post(`/api/v1/repos/${repoId}/worktrees/fetch`, { path: wt }),
pull: (repoId: string, wt: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<WorktreeResponse> =>
api.post(`/api/v1/repos/${repoId}/worktrees/pull`, { path: wt, mode }),
readFile: (repoId: string, wt: string, path: string): Promise<FileContentResponse> =>
api.get(`/api/v1/repos/${repoId}/files/content?wt=${enc(wt)}&path=${enc(path)}`),
writeFile: (repoId: string, wt: string, path: string, content: string): Promise<WriteFileResponse> =>
api.put(`/api/v1/repos/${repoId}/files/content`, { wt, path, content }),
};

View File

@@ -29,6 +29,8 @@ export interface TerminalSink {
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
/** P7 — signal ciblé « le détail (changes/diff) d'un worktree regardé a changé ». */
export type WorktreeChangesEvent = Extract<ServerMessage, { type: 'worktree_changes' }>;
export interface AttachOptions {
sessionId: string;
@@ -125,6 +127,8 @@ export class WsClient {
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
private readonly groupListeners = new Set<(e: GroupEvent) => void>();
/** P7 — abonnements ciblés au détail d'un worktree (clé repoId\0path → listeners). */
private readonly worktreeChangesListeners = new Map<string, Set<(e: WorktreeChangesEvent) => void>>();
connect(): void {
this.stopped = false;
@@ -215,6 +219,32 @@ export class WsClient {
};
}
/**
* P7 — observe le détail (changes/diff) d'un worktree précis : envoie `watch`, route les
* `worktree_changes` correspondants vers `listener`, et ré-arme automatiquement après reconnexion.
* La désinscription envoie `unwatch` quand plus aucun listener ne regarde ce worktree.
*/
watchWorktree(repoId: string, path: string, listener: (e: WorktreeChangesEvent) => void): () => void {
const key = `${repoId}\0${path}`;
let set = this.worktreeChangesListeners.get(key);
if (!set) {
set = new Set();
this.worktreeChangesListeners.set(key, set);
this.connect();
this.sendControl({ type: 'watch', repoId, path });
}
set.add(listener);
return () => {
const s = this.worktreeChangesListeners.get(key);
if (!s) return;
s.delete(listener);
if (s.size === 0) {
this.worktreeChangesListeners.delete(key);
this.sendControl({ type: 'unwatch', repoId, path });
}
};
}
sendControl(msg: ClientMessage): void {
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
this.socket.send(JSON.stringify(msg));
@@ -389,6 +419,11 @@ export class WsClient {
this.backoffMs = BACKOFF_MIN_MS;
this.status.value = 'open';
this.sendSub();
// P7 — ré-arme les watchers de worktree (clé repoId\0path) après reconnexion.
for (const key of this.worktreeChangesListeners.keys()) {
const sep = key.indexOf('\0');
this.sendControl({ type: 'watch', repoId: key.slice(0, sep), path: key.slice(sep + 1) });
}
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
for (const att of this.attachments) {
if (!att.closed) this.sendAttach(att);
@@ -451,6 +486,11 @@ export class WsClient {
for (const cb of this.groupListeners) cb(msg);
return;
}
case 'worktree_changes': {
const set = this.worktreeChangesListeners.get(`${msg.repoId}\0${msg.path}`);
if (set) for (const cb of set) cb(msg);
return;
}
case 'error': {
if (msg.channel !== undefined) {
console.warn(`[ws] channel ${msg.channel}: ${msg.code}${msg.message}`);