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
packages/server/src/core/fs-watcher.ts
Normal file
179
packages/server/src/core/fs-watcher.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// Watcher FS des worktrees ACTIFS (regardés par un client OU portant une session vivante).
|
||||
// Émet un signal débouncé `worktree_fs_change` que le WorktreeManager traduit en recalcul du
|
||||
// statut git + broadcast WS. Pool LRU borné + refcount pour ne jamais épuiser les descripteurs :
|
||||
// un worktree n'est watché que tant qu'il est observé/épinglé, et la taille totale est plafonnée.
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { resolve, sep, join } from 'node:path';
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import { resolveGitDir } from './git.js';
|
||||
|
||||
const DEFAULT_MAX_WATCHERS = 32;
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
export interface FsWatcherEvents {
|
||||
/** le contenu d'un worktree surveillé a changé (édition, staging, checkout externe…). */
|
||||
worktree_fs_change: [{ repoId: string; path: string }];
|
||||
}
|
||||
|
||||
interface WatchEntry {
|
||||
repoId: string;
|
||||
path: string;
|
||||
watcher: FSWatcher;
|
||||
/** nombre de clients qui « regardent » ce worktree. */
|
||||
refCount: number;
|
||||
/** nombre de sessions vivantes épinglant ce worktree. */
|
||||
sessionPins: number;
|
||||
lastUsed: number;
|
||||
debounce: NodeJS.Timeout | null;
|
||||
/** résolue quand chokidar a fini son scan initial (les events deviennent fiables). */
|
||||
ready: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore tout sous `.git/` SAUF `HEAD` et `index` (⇒ on détecte le `git checkout` externe et le
|
||||
* staging) ainsi que `node_modules`. chokidar n'ignore pas le dossier `.git` lui-même afin de
|
||||
* pouvoir descendre jusqu'à `HEAD`/`index`, mais saute ses sous-dossiers volumineux (objects…).
|
||||
*/
|
||||
export function isIgnoredPath(p: string): boolean {
|
||||
if (p.includes(`${sep}node_modules${sep}`) || p.endsWith(`${sep}node_modules`)) return true;
|
||||
if (p.includes(`${sep}.git${sep}`)) {
|
||||
return !(p.endsWith(`${sep}HEAD`) || p.endsWith(`${sep}index`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface FsWatcherOptions {
|
||||
maxWatchers?: number;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export class FsWatcherService extends EventEmitter<FsWatcherEvents> {
|
||||
private readonly entries = new Map<string, WatchEntry>();
|
||||
private readonly maxWatchers: number;
|
||||
private readonly debounceMs: number;
|
||||
|
||||
constructor(opts: FsWatcherOptions = {}) {
|
||||
super();
|
||||
this.maxWatchers = opts.maxWatchers ?? DEFAULT_MAX_WATCHERS;
|
||||
this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
private key(repoId: string, path: string): string {
|
||||
return `${repoId}\0${resolve(path)}`;
|
||||
}
|
||||
|
||||
/** Un client commence à observer un worktree (vue IDE ouverte). */
|
||||
watch(repoId: string, path: string): void {
|
||||
const e = this.ensure(repoId, path);
|
||||
e.refCount++;
|
||||
e.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
/** Un client cesse d'observer ; le watcher reste (idle) jusqu'à éviction LRU. */
|
||||
unwatch(repoId: string, path: string): void {
|
||||
const e = this.entries.get(this.key(repoId, path));
|
||||
if (!e) return;
|
||||
e.refCount = Math.max(0, e.refCount - 1);
|
||||
e.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
/** Épingle un worktree tant qu'une session y est vivante (jamais évincé). */
|
||||
pinSession(repoId: string, path: string): void {
|
||||
const e = this.ensure(repoId, path);
|
||||
e.sessionPins++;
|
||||
e.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
unpinSession(repoId: string, path: string): void {
|
||||
const e = this.entries.get(this.key(repoId, path));
|
||||
if (!e) return;
|
||||
e.sessionPins = Math.max(0, e.sessionPins - 1);
|
||||
e.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
/** Nombre de watchers actifs (test/diagnostic). */
|
||||
size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
isWatching(repoId: string, path: string): boolean {
|
||||
return this.entries.has(this.key(repoId, path));
|
||||
}
|
||||
|
||||
/** Résout quand le watcher de ce worktree a fini son scan initial (utile aux tests). */
|
||||
whenReady(repoId: string, path: string): Promise<void> {
|
||||
return this.entries.get(this.key(repoId, path))?.ready ?? Promise.resolve();
|
||||
}
|
||||
|
||||
private ensure(repoId: string, path: string): WatchEntry {
|
||||
const key = this.key(repoId, path);
|
||||
const existing = this.entries.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const abs = resolve(path);
|
||||
const watcher = chokidar.watch(abs, {
|
||||
ignored: (p: string) => isIgnoredPath(p),
|
||||
ignoreInitial: true,
|
||||
// coalesce les écritures rapides (build, génération) avant d'émettre.
|
||||
awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 40 },
|
||||
});
|
||||
let resolveReady: () => void = () => {};
|
||||
const ready = new Promise<void>((r) => (resolveReady = r));
|
||||
watcher.once('ready', () => resolveReady());
|
||||
const entry: WatchEntry = { repoId, path: abs, watcher, refCount: 0, sessionPins: 0, lastUsed: Date.now(), debounce: null, ready };
|
||||
const onChange = (): void => this.schedule(entry);
|
||||
watcher.on('add', onChange).on('change', onChange).on('unlink', onChange).on('addDir', onChange).on('unlinkDir', onChange);
|
||||
this.entries.set(key, entry);
|
||||
|
||||
// Worktree LIÉ : HEAD/index vivent hors du worktree (dans .git/worktrees/<n>). On les ajoute
|
||||
// explicitement pour capter un changement de branche externe (git checkout en CLI).
|
||||
void resolveGitDir(abs).then((gitDir) => {
|
||||
if (!gitDir || !this.entries.has(key)) return;
|
||||
if (gitDir === join(abs, '.git') || gitDir.startsWith(abs + sep)) return; // déjà couvert
|
||||
watcher.add([join(gitDir, 'HEAD'), join(gitDir, 'index')]);
|
||||
}).catch(() => {});
|
||||
|
||||
this.evictIfNeeded();
|
||||
return entry;
|
||||
}
|
||||
|
||||
private schedule(entry: WatchEntry): void {
|
||||
if (entry.debounce) clearTimeout(entry.debounce);
|
||||
entry.debounce = setTimeout(() => {
|
||||
entry.debounce = null;
|
||||
entry.lastUsed = Date.now();
|
||||
this.emit('worktree_fs_change', { repoId: entry.repoId, path: entry.path });
|
||||
}, this.debounceMs);
|
||||
entry.debounce.unref();
|
||||
}
|
||||
|
||||
/** Ferme les watchers idle (refCount===0 && sessionPins===0) les moins récents au-delà du plafond. */
|
||||
private evictIfNeeded(): void {
|
||||
if (this.entries.size <= this.maxWatchers) return;
|
||||
const idle = [...this.entries.entries()]
|
||||
.filter(([, e]) => e.refCount === 0 && e.sessionPins === 0)
|
||||
.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
|
||||
for (const [key, e] of idle) {
|
||||
if (this.entries.size <= this.maxWatchers) break;
|
||||
this.close(key, e);
|
||||
}
|
||||
// Si tout est actif (reffé/épinglé), on dépasse le plafond volontairement : un worktree
|
||||
// explicitement observé ne doit jamais perdre son temps réel.
|
||||
}
|
||||
|
||||
private close(key: string, e: WatchEntry): void {
|
||||
if (e.debounce) clearTimeout(e.debounce);
|
||||
void e.watcher.close().catch(() => {});
|
||||
this.entries.delete(key);
|
||||
}
|
||||
|
||||
/** Libère tous les descripteurs (drain SIGTERM/SIGINT). */
|
||||
async closeAll(): Promise<void> {
|
||||
const all = [...this.entries.entries()];
|
||||
this.entries.clear();
|
||||
await Promise.all(all.map(([, e]) => {
|
||||
if (e.debounce) clearTimeout(e.debounce);
|
||||
return e.watcher.close().catch(() => {});
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path';
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
import type {
|
||||
DiscoverReposResponse,
|
||||
HookRunResult,
|
||||
@@ -24,21 +24,35 @@ import { scanForRepos } from './repo-scanner.js';
|
||||
import { preTrustProject } from './claude-trust.js';
|
||||
import {
|
||||
addWorktree,
|
||||
amendCommit,
|
||||
cleanFiles,
|
||||
commitAll,
|
||||
commitStaged,
|
||||
defaultBranch,
|
||||
fetchRemote,
|
||||
fileDiff,
|
||||
isDirtyWorktreeError,
|
||||
isRepo,
|
||||
isSafeAbsolutePath,
|
||||
isSafeRelativePath,
|
||||
isUnpushed,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
listChanges,
|
||||
listWorktrees,
|
||||
pruneWorktrees,
|
||||
pull,
|
||||
push,
|
||||
removeWorktree,
|
||||
restoreFiles,
|
||||
stageFiles,
|
||||
switchBranch,
|
||||
unstageFiles,
|
||||
worktreeStatus,
|
||||
type ParsedWorktree,
|
||||
} from './git.js';
|
||||
import type { FsWatcherService } from './fs-watcher.js';
|
||||
import type { FileChange, FileDiffResponse } from '@arboretum/shared';
|
||||
|
||||
const FACTS_TTL_MS = 2500;
|
||||
const HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||
@@ -64,6 +78,9 @@ export interface WorktreeManagerEvents {
|
||||
repo_removed: [string];
|
||||
worktree_update: [{ repoId: string; worktree: WorktreeSummary }];
|
||||
worktree_removed: [{ repoId: string; path: string }];
|
||||
/** P7 — le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
|
||||
* ciblé `worktree_changes` aux seules connexions ayant `watch`é cette clé. */
|
||||
worktree_changes: [{ repoId: string; path: string }];
|
||||
}
|
||||
|
||||
/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes. */
|
||||
@@ -116,8 +133,18 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
private readonly db: Db,
|
||||
private readonly ptyManager: PtyManager,
|
||||
private readonly discovery: DiscoveryService,
|
||||
private readonly fsWatcher?: FsWatcherService,
|
||||
) {
|
||||
super();
|
||||
// P7 — un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
|
||||
// (worktree_update : compteurs légers pour tout le dashboard) et signale aux clients qui le
|
||||
// regardent de re-fetcher le détail (worktree_changes ciblé). On résout repoId → row à la volée.
|
||||
this.fsWatcher?.on('worktree_fs_change', ({ repoId, path }) => {
|
||||
this.factsCache.delete(repoId);
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (row) void this.emitWorktree(row, path).catch(() => {});
|
||||
this.emit('worktree_changes', { repoId, path });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- repos ----
|
||||
@@ -377,18 +404,39 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
return listBranches(row.path);
|
||||
}
|
||||
|
||||
/** `git add -A` + commit dans le worktree visé (le checkout principal est un worktree valide ici). */
|
||||
async commitWorktree(repoId: string, path: string, message: string): Promise<WorktreeSummary> {
|
||||
/**
|
||||
* Commit dans le worktree visé. `mode='all'` (défaut, rétrocompat) = `git add -A` + commit ;
|
||||
* `mode='staged'` = commit de l'index uniquement (staging sélectif préalable) ; `amend` réécrit
|
||||
* le dernier commit (refusé s'il est déjà poussé). Le checkout principal est un worktree valide ici.
|
||||
*/
|
||||
async commitWorktree(
|
||||
repoId: string,
|
||||
path: string,
|
||||
opts: { message: string; mode?: 'all' | 'staged'; amend?: boolean },
|
||||
): Promise<WorktreeSummary> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
const w = await this.findWorktree(row, path);
|
||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||
const mode = opts.mode ?? 'all';
|
||||
return this.withLock(repoId, async () => {
|
||||
if ((await worktreeStatus(w.path)).dirtyCount === 0) {
|
||||
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit — working tree is clean');
|
||||
if (opts.amend && !(await isUnpushed(w.path))) {
|
||||
throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed — amend would rewrite shared history');
|
||||
}
|
||||
const st = await worktreeStatus(w.path);
|
||||
// Garde-fou « rien à committer » (sauf amend, qui peut ne changer que le message).
|
||||
if (!opts.amend) {
|
||||
if (mode === 'all' && st.dirtyCount === 0) {
|
||||
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit — working tree is clean');
|
||||
}
|
||||
if (mode === 'staged' && (st.stagedCount ?? 0) === 0) {
|
||||
throw httpError(409, 'NOTHING_STAGED', 'Nothing staged — stage files first');
|
||||
}
|
||||
}
|
||||
try {
|
||||
await commitAll(w.path, message);
|
||||
if (mode === 'all' && !opts.amend) await commitAll(w.path, opts.message);
|
||||
else if (opts.amend) await amendCommit(w.path, opts.message);
|
||||
else await commitStaged(w.path, opts.message);
|
||||
} catch (err) {
|
||||
throw httpError(400, 'COMMIT_FAILED', (err as Error).message);
|
||||
}
|
||||
@@ -397,6 +445,150 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- P7 : changes / diff / staging / fetch-pull / contenu fichier / watch ----
|
||||
|
||||
/** Résout et valide un worktree enregistré ; lève 404 si le repo/worktree n'existe pas. */
|
||||
private async requireWorktree(repoId: string, path: string): Promise<{ row: RepoRow; w: ParsedWorktree }> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
const w = await this.findWorktree(row, path);
|
||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||
return { row, w };
|
||||
}
|
||||
|
||||
/** Liste des fichiers modifiés d'un worktree (lecture, hors lock). */
|
||||
async getWorktreeChanges(repoId: string, path: string): Promise<{ changes: FileChange[]; truncated: boolean }> {
|
||||
const { w } = await this.requireWorktree(repoId, path);
|
||||
return listChanges(w.path);
|
||||
}
|
||||
|
||||
/** Diff unifié d'un fichier (détecte untracked → `git diff --no-index`). Lecture, hors lock. */
|
||||
async getFileDiff(repoId: string, path: string, file: string, staged: boolean): Promise<FileDiffResponse> {
|
||||
const { w } = await this.requireWorktree(repoId, path);
|
||||
if (!isSafeRelativePath(file)) throw httpError(400, 'BAD_PATH', 'Invalid file path');
|
||||
const { changes } = await listChanges(w.path);
|
||||
const rec = changes.find((c) => c.path === file);
|
||||
const untracked = !staged && (rec?.untracked ?? false);
|
||||
const d = await fileDiff(w.path, file, { staged, untracked });
|
||||
return { path: w.path, file, staged, binary: d.binary, tooLarge: d.tooLarge, diff: d.diff };
|
||||
}
|
||||
|
||||
private validateFiles(files: string[]): void {
|
||||
if (!Array.isArray(files) || files.length === 0) throw httpError(400, 'BAD_REQUEST', 'files (non-empty array) is required');
|
||||
for (const f of files) {
|
||||
if (typeof f !== 'string' || !isSafeRelativePath(f)) throw httpError(400, 'BAD_PATH', `Invalid file path: ${String(f)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Indexe des fichiers, puis rediffuse le statut. */
|
||||
async stage(repoId: string, path: string, files: string[]): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
this.validateFiles(files);
|
||||
return this.withLock(repoId, async () => {
|
||||
await stageFiles(w.path, files).catch((err) => { throw httpError(400, 'STAGE_FAILED', (err as Error).message); });
|
||||
this.factsCache.delete(repoId);
|
||||
this.emit('worktree_changes', { repoId, path: w.path });
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/** Désindexe des fichiers, puis rediffuse le statut. */
|
||||
async unstage(repoId: string, path: string, files: string[]): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
this.validateFiles(files);
|
||||
return this.withLock(repoId, async () => {
|
||||
await unstageFiles(w.path, files).catch((err) => { throw httpError(400, 'UNSTAGE_FAILED', (err as Error).message); });
|
||||
this.factsCache.delete(repoId);
|
||||
this.emit('worktree_changes', { repoId, path: w.path });
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule les changements locaux. Les fichiers SUIVIS sont restaurés (`git restore`) ; les fichiers
|
||||
* NON SUIVIS ne sont supprimés (`git clean`) QUE si `includeUntracked` (destructif, opt-in).
|
||||
*/
|
||||
async discard(repoId: string, path: string, files: string[], includeUntracked: boolean): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
this.validateFiles(files);
|
||||
return this.withLock(repoId, async () => {
|
||||
const { changes } = await listChanges(w.path);
|
||||
const set = new Set(files);
|
||||
const untracked = changes.filter((c) => set.has(c.path) && c.untracked).map((c) => c.path);
|
||||
const tracked = files.filter((f) => !untracked.includes(f));
|
||||
try {
|
||||
await restoreFiles(w.path, tracked);
|
||||
if (includeUntracked && untracked.length > 0) await cleanFiles(w.path, untracked);
|
||||
} catch (err) {
|
||||
throw httpError(400, 'DISCARD_FAILED', (err as Error).message);
|
||||
}
|
||||
this.factsCache.delete(repoId);
|
||||
this.emit('worktree_changes', { repoId, path: w.path });
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/** `git fetch --all --prune` puis rediffuse le statut (ahead/behind à jour). */
|
||||
async fetch(repoId: string, path: string): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
return this.withLock(repoId, async () => {
|
||||
await fetchRemote(w.path).catch((err) => { throw httpError(400, 'FETCH_FAILED', (err as Error).message); });
|
||||
this.factsCache.delete(repoId);
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/** `git pull` (ff-only par défaut). */
|
||||
async pull(repoId: string, path: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
return this.withLock(repoId, async () => {
|
||||
await pull(w.path, mode).catch((err) => { throw httpError(409, 'PULL_FAILED', (err as Error).message); });
|
||||
this.factsCache.delete(repoId);
|
||||
this.emit('worktree_changes', { repoId, path: w.path });
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide qu'un chemin relatif désigne bien un fichier DANS un worktree enregistré et renvoie son
|
||||
* chemin absolu résolu. Défense en profondeur (anti `..`, anti symlink sortant), même si un
|
||||
* terminal web est déjà du RCE par conception. Utilisé par l'API fichiers (lecture/écriture Monaco).
|
||||
*/
|
||||
async assertPathInWorktree(repoId: string, worktreeAbsPath: string, relPath: string): Promise<string> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
if (!isSafeAbsolutePath(worktreeAbsPath)) throw httpError(400, 'BAD_PATH', 'Worktree path must be absolute and normalized');
|
||||
const w = await this.findWorktree(row, worktreeAbsPath);
|
||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||
if (!isSafeRelativePath(relPath)) throw httpError(400, 'BAD_PATH', 'Invalid file path');
|
||||
const base = resolve(w.path);
|
||||
const abs = resolve(join(base, relPath));
|
||||
if (abs !== base && !abs.startsWith(base + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Path escapes the worktree');
|
||||
// Anti symlink-escape : pour un fichier existant, le realpath doit rester sous le worktree.
|
||||
if (existsSync(abs)) {
|
||||
let real: string;
|
||||
try {
|
||||
real = realpathSync(abs);
|
||||
} catch {
|
||||
throw httpError(400, 'BAD_PATH', 'Cannot resolve path');
|
||||
}
|
||||
const realBase = realpathSync(base);
|
||||
if (real !== realBase && !real.startsWith(realBase + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Path escapes the worktree (symlink)');
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** Arme le watcher FS sur un worktree (validé) pour le push temps réel du détail. */
|
||||
async watch(repoId: string, path: string): Promise<void> {
|
||||
if (!this.fsWatcher) return;
|
||||
await this.requireWorktree(repoId, path);
|
||||
this.fsWatcher.watch(repoId, resolve(path));
|
||||
}
|
||||
|
||||
unwatch(repoId: string, path: string): void {
|
||||
this.fsWatcher?.unwatch(repoId, resolve(path));
|
||||
}
|
||||
|
||||
/** Pousse la branche du worktree visé (upstream auto si absent). */
|
||||
async pushWorktree(repoId: string, path: string): Promise<WorktreeSummary> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
|
||||
Reference in New Issue
Block a user