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:
@@ -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é) */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -128,6 +128,11 @@ describe('parseClientMessage — cas valides', () => {
|
||||
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
||||
});
|
||||
|
||||
it('watch / unwatch (P7) avec repoId + path', () => {
|
||||
expect(parseClientMessage('{"type":"watch","repoId":"r1","path":"/home/u/wt"}')).toEqual({ type: 'watch', repoId: 'r1', path: '/home/u/wt' });
|
||||
expect(parseClientMessage('{"type":"unwatch","repoId":"r1","path":"/home/u/wt"}')).toEqual({ type: 'unwatch', repoId: 'r1', path: '/home/u/wt' });
|
||||
});
|
||||
|
||||
it('ping (champs superflus ignorés)', () => {
|
||||
expect(parseClientMessage('{"type":"ping","extra":42}')).toEqual({ type: 'ping' });
|
||||
});
|
||||
@@ -152,6 +157,13 @@ describe('parseClientMessage — cas malformés', () => {
|
||||
expect(parseClientMessage('{"type":"hello","protocol":"1"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('watch / unwatch : repoId ou path manquant / mal typé', () => {
|
||||
expect(parseClientMessage('{"type":"watch","path":"/x"}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"watch","repoId":"r1"}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"watch","repoId":"","path":"/x"}')).toBeNull();
|
||||
expect(parseClientMessage('{"type":"unwatch","repoId":"r1","path":42}')).toBeNull();
|
||||
});
|
||||
|
||||
it('attach : dimensions hors bornes ou non entières', () => {
|
||||
const make = (over: Record<string, unknown>): string =>
|
||||
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, ...over });
|
||||
|
||||
Reference in New Issue
Block a user