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:
@@ -2,7 +2,7 @@
|
||||
// les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant.
|
||||
import { execFile } from 'node:child_process';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode } from '@arboretum/shared';
|
||||
import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode, FileChange } from '@arboretum/shared';
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
// `push` peut dialoguer avec un remote (réseau) : on lui laisse une marge bien plus large.
|
||||
@@ -39,6 +39,24 @@ function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): P
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante tolérante : résout `{ stdout, code }` au lieu de rejeter sur code de sortie non nul.
|
||||
* Utile pour `git diff --no-index` (code 1 = « les fichiers diffèrent », pas une erreur).
|
||||
*/
|
||||
function gitRaw(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<{ stdout: string; code: number }> {
|
||||
return new Promise((resolveP) => {
|
||||
execFile(
|
||||
'git',
|
||||
args,
|
||||
{ cwd, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' } },
|
||||
(err, stdout) => {
|
||||
const code = err ? ((err as GitError).code as number) ?? 1 : 0;
|
||||
resolveP({ stdout: stdout.toString(), code: typeof code === 'number' ? code : 1 });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ParsedWorktree {
|
||||
path: string;
|
||||
head: string | null;
|
||||
@@ -122,6 +140,16 @@ export function isSafeAbsolutePath(p: string): boolean {
|
||||
return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pathspec relatif sûr passé à git (`git add/restore/diff -- <p>`) : non vide, non absolu, sans
|
||||
* segment `..`, ne commençant pas par `-` (anti-flag). Le `--` avant le pathspec reste obligatoire.
|
||||
*/
|
||||
export function isSafeRelativePath(p: string): boolean {
|
||||
if (p.length === 0 || p.startsWith('/') || p.startsWith('-')) return false;
|
||||
const parts = p.split(/[\\/]/);
|
||||
return !parts.includes('..') && !parts.includes('.git');
|
||||
}
|
||||
|
||||
/** Initialise un dépôt git dans `dir` (déjà créé et validé par l'appelant). `git init` est idempotent. */
|
||||
export async function gitInit(dir: string): Promise<void> {
|
||||
await git(dir, ['init']);
|
||||
@@ -219,13 +247,274 @@ export async function worktreeStatus(worktreePath: string): Promise<WorktreeGitS
|
||||
}
|
||||
}
|
||||
let dirtyCount = 0;
|
||||
let stagedCount = 0;
|
||||
let unstagedCount = 0;
|
||||
let conflictCount = 0;
|
||||
try {
|
||||
const status = await git(worktreePath, ['status', '--porcelain=v1', '--untracked-files=all']);
|
||||
dirtyCount = status.split('\n').filter((l) => l.trim() !== '').length;
|
||||
// Un SEUL passage porcelain v2 -z : dirtyCount + compteurs fins (staged/unstaged/conflit).
|
||||
const out = await git(worktreePath, ['status', '--porcelain=v2', '-z', '--untracked-files=all']);
|
||||
const entries = parsePorcelainV2(out);
|
||||
dirtyCount = entries.length;
|
||||
for (const e of entries) {
|
||||
if (e.conflicted) conflictCount++;
|
||||
if (e.staged) stagedCount++;
|
||||
if (e.unstaged) unstagedCount++;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ahead, behind, dirtyCount, upstream };
|
||||
let lastCommitHash: string | null = null;
|
||||
let lastCommitSubject: string | null = null;
|
||||
try {
|
||||
const lc = await lastCommit(worktreePath);
|
||||
if (lc) {
|
||||
lastCommitHash = lc.hash;
|
||||
lastCommitSubject = lc.subject;
|
||||
}
|
||||
} catch {
|
||||
/* repo sans commit : on laisse null */
|
||||
}
|
||||
return { ahead, behind, dirtyCount, upstream, stagedCount, unstagedCount, conflictCount, lastCommitHash, lastCommitSubject };
|
||||
}
|
||||
|
||||
// ---- P7 : statut détaillé / diff / staging / commit sélectif / fetch-pull (IDE worktree) ----
|
||||
|
||||
/** Entrée brute de `git status --porcelain=v2` (avant enrichissement numstat). */
|
||||
export interface PorcelainV2Entry {
|
||||
path: string;
|
||||
indexStatus: string;
|
||||
worktreeStatus: string;
|
||||
staged: boolean;
|
||||
unstaged: boolean;
|
||||
untracked: boolean;
|
||||
conflicted: boolean;
|
||||
renamedFrom?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git status --porcelain=v2 -z --untracked-files=all` (testable sans repo réel). Format -z :
|
||||
* champs séparés par NUL ; une entrée de renommage (type `2`) consomme un champ supplémentaire
|
||||
* (l'ancien chemin). Les chemins ne sont jamais entre guillemets en mode -z (pas d'échappement).
|
||||
*/
|
||||
export function parsePorcelainV2(stdout: string): PorcelainV2Entry[] {
|
||||
const fields = stdout.split('\0');
|
||||
if (fields.length && fields[fields.length - 1] === '') fields.pop();
|
||||
const out: PorcelainV2Entry[] = [];
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const f = fields[i];
|
||||
if (!f) continue;
|
||||
const kind = f[0];
|
||||
if (kind === '1') {
|
||||
const t = f.split(' ');
|
||||
const xy = t[1] ?? '..';
|
||||
out.push(entryFromXy(t.slice(8).join(' '), xy, { conflicted: false }));
|
||||
} else if (kind === '2') {
|
||||
const t = f.split(' ');
|
||||
const xy = t[1] ?? '..';
|
||||
const path = t.slice(9).join(' ');
|
||||
const renamedFrom = fields[++i]; // l'ancien chemin suit dans le champ NUL suivant
|
||||
out.push(entryFromXy(path, xy, { conflicted: false, ...(renamedFrom ? { renamedFrom } : {}) }));
|
||||
} else if (kind === 'u') {
|
||||
const t = f.split(' ');
|
||||
const xy = t[1] ?? '..';
|
||||
out.push(entryFromXy(t.slice(10).join(' '), xy, { conflicted: true }));
|
||||
} else if (kind === '?') {
|
||||
out.push({ path: f.slice(2), indexStatus: '?', worktreeStatus: '?', staged: false, unstaged: true, untracked: true, conflicted: false });
|
||||
}
|
||||
// '!' (ignored) : exclu de la liste des changements.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function entryFromXy(path: string, xy: string, opts: { conflicted: boolean; renamedFrom?: string }): PorcelainV2Entry {
|
||||
const indexStatus = xy[0] ?? '.';
|
||||
const worktreeStatus = xy[1] ?? '.';
|
||||
return {
|
||||
path,
|
||||
indexStatus,
|
||||
worktreeStatus,
|
||||
staged: !opts.conflicted && indexStatus !== '.',
|
||||
unstaged: opts.conflicted || worktreeStatus !== '.',
|
||||
untracked: false,
|
||||
conflicted: opts.conflicted,
|
||||
...(opts.renamedFrom ? { renamedFrom: opts.renamedFrom } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff --numstat -z` → map chemin → {insertions, deletions, binary}. Les fichiers
|
||||
* binaires sont marqués `'-'` par git. Les renommages ont un champ chemin vide suivi de
|
||||
* deux champs NUL (ancien, nouveau) ; on indexe par le nouveau chemin.
|
||||
*/
|
||||
export function parseNumstatZ(stdout: string): Map<string, { insertions: number | null; deletions: number | null; binary: boolean }> {
|
||||
const fields = stdout.split('\0');
|
||||
if (fields.length && fields[fields.length - 1] === '') fields.pop();
|
||||
const map = new Map<string, { insertions: number | null; deletions: number | null; binary: boolean }>();
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const f = fields[i];
|
||||
if (!f) continue;
|
||||
const parts = f.split('\t');
|
||||
if (parts.length < 3) continue;
|
||||
const addRaw = parts[0] ?? '';
|
||||
const delRaw = parts[1] ?? '';
|
||||
let path = parts[2] ?? '';
|
||||
if (path === '') {
|
||||
// renommage : ancien chemin = champ suivant, nouveau chemin = champ d'après.
|
||||
i++; // saute l'ancien chemin
|
||||
path = fields[++i] ?? '';
|
||||
if (path === '') continue;
|
||||
}
|
||||
const binary = addRaw === '-' || delRaw === '-';
|
||||
map.set(path, {
|
||||
insertions: binary ? null : Number(addRaw) || 0,
|
||||
deletions: binary ? null : Number(delRaw) || 0,
|
||||
binary,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const MAX_CHANGES = 5000;
|
||||
|
||||
/** Liste les fichiers modifiés d'un worktree (statut porcelain v2 enrichi des stats numstat). */
|
||||
export async function listChanges(worktreePath: string): Promise<{ changes: FileChange[]; truncated: boolean }> {
|
||||
const [statusOut, unstagedOut, stagedOut] = await Promise.all([
|
||||
git(worktreePath, ['status', '--porcelain=v2', '-z', '--untracked-files=all']),
|
||||
git(worktreePath, ['diff', '--numstat', '-z']),
|
||||
git(worktreePath, ['diff', '--numstat', '-z', '--cached']),
|
||||
]);
|
||||
const entries = parsePorcelainV2(statusOut);
|
||||
const unstaged = parseNumstatZ(unstagedOut);
|
||||
const staged = parseNumstatZ(stagedOut);
|
||||
const truncated = entries.length > MAX_CHANGES;
|
||||
const slice = truncated ? entries.slice(0, MAX_CHANGES) : entries;
|
||||
const changes: FileChange[] = slice.map((e) => {
|
||||
const ns = unstaged.get(e.path) ?? staged.get(e.path);
|
||||
const binary = ns?.binary ?? false;
|
||||
// insertions/deletions = somme staged+unstaged quand disponible ; null pour binaire/untracked.
|
||||
const u = unstaged.get(e.path);
|
||||
const s = staged.get(e.path);
|
||||
const sum = (a: number | null | undefined, b: number | null | undefined): number | null => {
|
||||
if (a == null && b == null) return null;
|
||||
return (a ?? 0) + (b ?? 0);
|
||||
};
|
||||
return {
|
||||
path: e.path,
|
||||
indexStatus: e.indexStatus,
|
||||
worktreeStatus: e.worktreeStatus,
|
||||
staged: e.staged,
|
||||
unstaged: e.unstaged,
|
||||
untracked: e.untracked,
|
||||
conflicted: e.conflicted,
|
||||
insertions: binary ? null : sum(u?.insertions, s?.insertions),
|
||||
deletions: binary ? null : sum(u?.deletions, s?.deletions),
|
||||
binary,
|
||||
...(e.renamedFrom ? { renamedFrom: e.renamedFrom } : {}),
|
||||
};
|
||||
});
|
||||
return { changes, truncated };
|
||||
}
|
||||
|
||||
const MAX_DIFF_BYTES = 512 * 1024;
|
||||
|
||||
/** Diff unifié d'un fichier. `staged` → diff de l'index ; `untracked` → diff vs /dev/null. */
|
||||
export async function fileDiff(
|
||||
worktreePath: string,
|
||||
file: string,
|
||||
opts: { staged?: boolean; untracked?: boolean } = {},
|
||||
): Promise<{ diff: string; binary: boolean; tooLarge: boolean }> {
|
||||
let raw: string;
|
||||
if (opts.untracked) {
|
||||
// --no-index sort en code 1 quand les fichiers diffèrent : on tolère via gitRaw.
|
||||
const r = await gitRaw(worktreePath, ['diff', '--no-index', '--no-color', '--', '/dev/null', file]);
|
||||
raw = r.stdout;
|
||||
} else {
|
||||
raw = await git(worktreePath, ['diff', '--no-color', ...(opts.staged ? ['--cached'] : []), '--', file]);
|
||||
}
|
||||
const binary = /^Binary files .* differ$/m.test(raw) || raw.includes('GIT binary patch');
|
||||
if (binary) return { diff: '', binary: true, tooLarge: false };
|
||||
if (raw.length > MAX_DIFF_BYTES) return { diff: raw.slice(0, MAX_DIFF_BYTES), binary: false, tooLarge: true };
|
||||
return { diff: raw, binary: false, tooLarge: false };
|
||||
}
|
||||
|
||||
/** Indexe des fichiers (`git add -- <files>`). Chaque chemin validé par l'appelant (isSafeRelativePath). */
|
||||
export async function stageFiles(worktreePath: string, files: string[]): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
await git(worktreePath, ['add', '--', ...files]);
|
||||
}
|
||||
|
||||
/** Désindexe des fichiers (`git restore --staged -- <files>`). */
|
||||
export async function unstageFiles(worktreePath: string, files: string[]): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
await git(worktreePath, ['restore', '--staged', '--', ...files]);
|
||||
}
|
||||
|
||||
/** Annule les modifications de l'arbre de travail de fichiers SUIVIS (`git restore -- <files>`). */
|
||||
export async function restoreFiles(worktreePath: string, files: string[]): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
await git(worktreePath, ['restore', '--', ...files]);
|
||||
}
|
||||
|
||||
/** Supprime des fichiers NON SUIVIS (`git clean -f -- <files>`). Destructif : opt-in côté appelant. */
|
||||
export async function cleanFiles(worktreePath: string, files: string[]): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
await git(worktreePath, ['clean', '-f', '--', ...files]);
|
||||
}
|
||||
|
||||
/** Commit de l'index uniquement (contraste avec `commitAll` = `git add -A` + commit). */
|
||||
export async function commitStaged(worktreePath: string, message: string): Promise<void> {
|
||||
await git(worktreePath, ['commit', '-m', message]);
|
||||
}
|
||||
|
||||
/** Réécrit le dernier commit. L'appelant garantit qu'il n'est pas déjà poussé. */
|
||||
export async function amendCommit(worktreePath: string, message?: string): Promise<void> {
|
||||
await git(worktreePath, message ? ['commit', '--amend', '-m', message] : ['commit', '--amend', '--no-edit']);
|
||||
}
|
||||
|
||||
/** `git fetch --all --prune` (réseau → timeout élargi). */
|
||||
export async function fetchRemote(worktreePath: string): Promise<void> {
|
||||
await git(worktreePath, ['fetch', '--all', '--prune'], GIT_PUSH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** `git pull` — `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
|
||||
export async function pull(worktreePath: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<void> {
|
||||
const args = mode === 'rebase' ? ['-c', 'rebase.autoStash=false', 'pull', '--rebase'] : ['pull', '--ff-only'];
|
||||
await git(worktreePath, args, GIT_PUSH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Répertoire git absolu d'un worktree (`git rev-parse --absolute-git-dir`). Pour le checkout
|
||||
* principal : `<path>/.git` ; pour un worktree LIÉ : `<repo>/.git/worktrees/<n>` (le `.git` du
|
||||
* worktree est un fichier pointeur). Sert au watcher FS pour surveiller le bon `HEAD`/`index`.
|
||||
*/
|
||||
export async function resolveGitDir(worktreePath: string): Promise<string | null> {
|
||||
const r = await gitRaw(worktreePath, ['rev-parse', '--absolute-git-dir']);
|
||||
if (r.code !== 0) return null;
|
||||
return r.stdout.trim() || null;
|
||||
}
|
||||
|
||||
/** Dernier commit (HEAD) : hash court + sujet. null si le dépôt n'a aucun commit. */
|
||||
export async function lastCommit(worktreePath: string): Promise<{ hash: string; subject: string } | null> {
|
||||
const r = await gitRaw(worktreePath, ['log', '-1', '--format=%h%x00%s']);
|
||||
if (r.code !== 0) return null;
|
||||
const idx = r.stdout.indexOf('\0');
|
||||
if (idx === -1) return null;
|
||||
return { hash: r.stdout.slice(0, idx), subject: r.stdout.slice(idx + 1).replace(/\n$/, '') };
|
||||
}
|
||||
|
||||
/** true si le HEAD courant n'est pas encore poussé (amend autorisé). Sans upstream → true. */
|
||||
export async function isUnpushed(worktreePath: string): Promise<boolean> {
|
||||
try {
|
||||
await git(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
||||
} catch {
|
||||
return true; // pas d'upstream → rien n'est « partagé »
|
||||
}
|
||||
try {
|
||||
const out = (await git(worktreePath, ['rev-list', '--count', '@{u}..HEAD'])).trim();
|
||||
return (Number(out) || 0) > 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Point de départ d'une branche créée : `baseRef` explicite, sinon la branche par défaut du dépôt
|
||||
|
||||
Reference in New Issue
Block a user