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

@@ -21,9 +21,12 @@ import { registerProjectRoutes } from './routes/projects.js';
import { registerRepoRoutes } from './routes/repos.js';
import { registerGroupRoutes } from './routes/groups.js';
import { registerWorktreeRoutes } from './routes/worktrees.js';
import { registerGitRoutes } from './routes/git.js';
import { registerFileRoutes } from './routes/files.js';
import { registerPushRoutes } from './routes/push.js';
import { registerSettingsRoutes } from './routes/settings.js';
import { registerFsRoutes } from './routes/fs.js';
import { FsWatcherService } from './core/fs-watcher.js';
import { registerAuditRoutes } from './routes/audit.js';
import { registerDataRoutes } from './routes/data.js';
import { registerWsGateway } from './ws/gateway.js';
@@ -74,6 +77,7 @@ export interface AppBundle {
worktrees: WorktreeManager;
groups: GroupManager;
push: PushService;
fsWatcher: FsWatcherService;
}
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
@@ -90,7 +94,10 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
projectsDir: config.claudeProjectsDir,
sessionsDir: config.claudeSessionsDir,
});
const worktrees = new WorktreeManager(db, manager, discovery);
// Watcher FS temps réel (P7) : armé à la demande sur les worktrees regardés via les commandes
// WS watch/unwatch. closeAll() au drain (libération des descripteurs).
const fsWatcher = new FsWatcherService();
const worktrees = new WorktreeManager(db, manager, discovery, fsWatcher);
// Démarré dans runDaemon() (jamais ici) → le scan ne tourne pas pendant les tests qui appellent buildApp.
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
const groups = new GroupManager(db);
@@ -175,6 +182,8 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerRepoRoutes(app, worktrees, db);
registerGroupRoutes(app, groups, db, worktrees, manager);
registerWorktreeRoutes(app, worktrees, db);
registerGitRoutes(app, worktrees, db);
registerFileRoutes(app, worktrees, db);
registerPushRoutes(app, push, db);
registerSettingsRoutes(app, db, config, serverVersion, push);
registerFsRoutes(app);
@@ -198,5 +207,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
});
}
return { app, auth, manager, discovery, repoDiscovery, worktrees, groups, push };
return { app, auth, manager, discovery, repoDiscovery, worktrees, groups, push, fsWatcher };
}

View 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(() => {});
}));
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -30,7 +30,7 @@ function applyClaudeHomeOverride(config: Config, db: Db): void {
export async function runDaemon(config: Config): Promise<void> {
const db = openDb(config.dbPath);
applyClaudeHomeOverride(config, db); // override claude_home (réglage UI) avant de câbler les services
const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version);
const { app, auth, manager, discovery, repoDiscovery, fsWatcher } = buildApp(config, db, pkg.version);
const bootstrapToken = auth.ensureBootstrapToken();
await app.listen({ port: config.port, host: config.bind });
@@ -53,6 +53,7 @@ export async function runDaemon(config: Config): Promise<void> {
app.log.info(`${signal} received — draining sessions then exiting`);
discovery.stop();
repoDiscovery.stop();
void fsWatcher.closeAll(); // libère les descripteurs des watchers FS
manager.shutdown();
setTimeout(() => {
void app.close().then(() => process.exit(0));

View File

@@ -0,0 +1,86 @@
// API fichiers (P7) pour l'éditeur Monaco : lecture/écriture du contenu d'un fichier, strictement
// bornées à un worktree ENREGISTRÉ (WorktreeManager.assertPathInWorktree : anti `..`, anti symlink
// sortant). Authentifiée par le hook preValidation global. Un terminal web est déjà du RCE par
// conception, mais on borne quand même (défense en profondeur + moindre étonnement).
import type { FastifyInstance } from 'fastify';
import { readFile, writeFile, stat, mkdir } from 'node:fs/promises';
import { dirname, extname } from 'node:path';
import type { FileContentResponse, WriteFileRequest, WriteFileResponse } from '@arboretum/shared';
import type { WorktreeManager } from '../core/worktree-manager.js';
import type { Db } from '../db/index.js';
import { recordAudit } from '../core/audit-log.js';
import { sendManagerError } from './repos.js';
const FILE_MAX_BYTES = 2 * 1024 * 1024;
/** Langage Monaco déduit de l'extension (sous-ensemble courant ; Monaco a un fallback 'plaintext'). */
const LANG_BY_EXT: Record<string, string> = {
'.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
'.json': 'json', '.vue': 'vue', '.css': 'css', '.scss': 'scss', '.less': 'less', '.html': 'html', '.md': 'markdown',
'.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.java': 'java', '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp',
'.cs': 'csharp', '.php': 'php', '.sh': 'shell', '.bash': 'shell', '.zsh': 'shell', '.yml': 'yaml', '.yaml': 'yaml',
'.toml': 'toml', '.xml': 'xml', '.sql': 'sql', '.swift': 'swift', '.kt': 'kotlin', '.dart': 'dart', '.lua': 'lua',
};
function languageFor(path: string): string | undefined {
return LANG_BY_EXT[extname(path).toLowerCase()];
}
/** Détecte un fichier binaire par présence d'un octet NUL dans le buffer. */
function looksBinary(buf: Buffer): boolean {
const n = Math.min(buf.length, 8192);
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
return false;
}
export function registerFileRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
// Lecture du contenu d'un fichier (UTF-8) d'un worktree.
app.get('/api/v1/repos/:id/files/content', async (req, reply) => {
const { id } = req.params as { id: string };
const q = req.query as { wt?: string; path?: string };
if (typeof q.wt !== 'string' || q.wt === '' || typeof q.path !== 'string' || q.path === '') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'wt and path are required' } });
}
try {
const abs = await wt.assertPathInWorktree(id, q.wt, q.path);
const st = await stat(abs).catch(() => null);
if (!st || !st.isFile()) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No such file' } });
if (st.size > FILE_MAX_BYTES) return reply.status(413).send({ error: { code: 'FILE_TOO_LARGE', message: `File exceeds ${FILE_MAX_BYTES} bytes` } });
const buf = await readFile(abs);
if (looksBinary(buf)) return reply.status(415).send({ error: { code: 'BINARY_FILE', message: 'Binary files are not editable' } });
const lang = languageFor(q.path);
const res: FileContentResponse = {
path: q.path,
content: buf.toString('utf-8'),
encoding: 'utf-8',
size: st.size,
...(lang ? { language: lang } : {}),
};
return reply.send(res);
} catch (err) {
return sendManagerError(reply, err);
}
});
// Écriture (création/modification) du contenu d'un fichier d'un worktree.
app.put('/api/v1/repos/:id/files/content', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Partial<WriteFileRequest> | null;
if (!body || typeof body.wt !== 'string' || typeof body.path !== 'string' || typeof body.content !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'wt, path and content are required' } });
}
if (Buffer.byteLength(body.content, 'utf-8') > FILE_MAX_BYTES) {
return reply.status(413).send({ error: { code: 'FILE_TOO_LARGE', message: `Content exceeds ${FILE_MAX_BYTES} bytes` } });
}
try {
const abs = await wt.assertPathInWorktree(id, body.wt, body.path);
await mkdir(dirname(abs), { recursive: true }); // dirname reste sous le worktree (abs validé)
await writeFile(abs, body.content, 'utf-8');
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'file.write', resourceId: id, details: { path: body.path } });
const res: WriteFileResponse = { ok: true, size: Buffer.byteLength(body.content, 'utf-8') };
return reply.send(res);
} catch (err) {
return sendManagerError(reply, err);
}
});
}

View File

@@ -18,7 +18,7 @@ import type { FsEntry, FsListResponse } from '@arboretum/shared';
*/
export function registerFsRoutes(app: FastifyInstance): void {
app.get('/api/v1/fs/list', async (req, reply) => {
const q = req.query as { path?: string; markRepos?: string; showHidden?: string };
const q = req.query as { path?: string; markRepos?: string; showHidden?: string; includeFiles?: string };
const raw = q.path && q.path.length > 0 ? q.path : homedir();
if (!raw.startsWith('/')) {
return reply.status(400).send({ error: { code: 'BAD_PATH', message: 'path must be absolute' } });
@@ -41,6 +41,7 @@ export function registerFsRoutes(app: FastifyInstance): void {
const markRepos = q.markRepos === '1' || q.markRepos === 'true';
const showHidden = q.showHidden === '1' || q.showHidden === 'true';
const includeFiles = q.includeFiles === '1' || q.includeFiles === 'true';
let dirents;
try {
@@ -63,13 +64,24 @@ export function registerFsRoutes(app: FastifyInstance): void {
isDir = false;
}
}
if (!isDir) continue;
const full = join(abs, d.name);
if (!isDir) {
// En mode includeFiles, on remonte aussi les fichiers (arbre de l'IDE worktree).
if (!includeFiles) continue;
entries.push({ name: d.name, path: full, isFile: true });
continue;
}
const entry: FsEntry = { name: d.name, path: full };
if (markRepos && existsSync(join(full, '.git'))) entry.isRepo = true;
entries.push(entry);
}
entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
// Dossiers d'abord, puis fichiers ; tri insensible à la casse dans chaque groupe.
entries.sort((a, b) => {
const af = a.isFile ? 1 : 0;
const bf = b.isFile ? 1 : 0;
if (af !== bf) return af - bf;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const res: FsListResponse = {
path: abs,

View File

@@ -0,0 +1,132 @@
// Routes git « IDE » (P7) : diff par fichier, statut détaillé, staging sélectif, discard, fetch, pull.
// Toutes authentifiées par le hook preValidation global (/api/**), validées strictement et auditées.
// Le commit (sélectif/amend) reste sur POST /worktrees/commit (routes/worktrees.ts) pour ne pas
// dupliquer la sémantique.
import type { FastifyInstance } from 'fastify';
import type {
WorktreeChangesResponse,
FileDiffResponse,
WorktreeFilesRequest,
DiscardFilesRequest,
FetchWorktreeRequest,
PullWorktreeRequest,
WorktreeResponse,
} from '@arboretum/shared';
import type { WorktreeManager } from '../core/worktree-manager.js';
import type { Db } from '../db/index.js';
import { recordAudit } from '../core/audit-log.js';
import { sendManagerError } from './repos.js';
export function registerGitRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
// Liste des fichiers modifiés d'un worktree (statut détaillé + stats +/-).
app.get('/api/v1/repos/:id/worktrees/changes', async (req, reply) => {
const { id } = req.params as { id: string };
const q = req.query as { path?: string };
if (typeof q.path !== 'string' || q.path === '') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
}
try {
const { changes, truncated } = await wt.getWorktreeChanges(id, q.path);
return reply.send({ repoId: id, path: q.path, changes, truncated } satisfies WorktreeChangesResponse);
} catch (err) {
return sendManagerError(reply, err);
}
});
// Diff unifié d'un fichier (staged ou non ; untracked détecté côté manager).
app.get('/api/v1/repos/:id/worktrees/diff', async (req, reply) => {
const { id } = req.params as { id: string };
const q = req.query as { path?: string; file?: string; staged?: string };
if (typeof q.path !== 'string' || q.path === '' || typeof q.file !== 'string' || q.file === '') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and file are required' } });
}
const staged = q.staged === '1' || q.staged === 'true';
try {
const res = await wt.getFileDiff(id, q.path, q.file, staged);
return reply.send(res satisfies FileDiffResponse);
} catch (err) {
return sendManagerError(reply, err);
}
});
// Staging sélectif.
app.post('/api/v1/repos/:id/worktrees/stage', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Partial<WorktreeFilesRequest> | null;
if (!body || typeof body.path !== 'string' || !Array.isArray(body.files)) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and files are required' } });
}
try {
const worktree = await wt.stage(id, body.path, body.files);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.stage', resourceId: id, details: { path: body.path, count: body.files.length } });
return reply.send({ worktree } satisfies WorktreeResponse);
} catch (err) {
return sendManagerError(reply, err);
}
});
// Désindexation sélective.
app.post('/api/v1/repos/:id/worktrees/unstage', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Partial<WorktreeFilesRequest> | null;
if (!body || typeof body.path !== 'string' || !Array.isArray(body.files)) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and files are required' } });
}
try {
const worktree = await wt.unstage(id, body.path, body.files);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.unstage', resourceId: id, details: { path: body.path, count: body.files.length } });
return reply.send({ worktree } satisfies WorktreeResponse);
} catch (err) {
return sendManagerError(reply, err);
}
});
// Annulation des changements locaux (restore ; clean des untracked uniquement si includeUntracked).
app.post('/api/v1/repos/:id/worktrees/discard', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Partial<DiscardFilesRequest> | null;
if (!body || typeof body.path !== 'string' || !Array.isArray(body.files)) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and files are required' } });
}
try {
const worktree = await wt.discard(id, body.path, body.files, body.includeUntracked === true);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.discard', resourceId: id, details: { path: body.path, count: body.files.length, includeUntracked: body.includeUntracked === true } });
return reply.send({ worktree } satisfies WorktreeResponse);
} catch (err) {
return sendManagerError(reply, err);
}
});
// Fetch (--all --prune) : actualise ahead/behind.
app.post('/api/v1/repos/:id/worktrees/fetch', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Partial<FetchWorktreeRequest> | null;
if (!body || typeof body.path !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
}
try {
const worktree = await wt.fetch(id, body.path);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.fetch', resourceId: id, details: { path: body.path } });
return reply.send({ worktree } satisfies WorktreeResponse);
} catch (err) {
return sendManagerError(reply, err);
}
});
// Pull (ff-only par défaut, rebase optionnel).
app.post('/api/v1/repos/:id/worktrees/pull', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Partial<PullWorktreeRequest> | null;
if (!body || typeof body.path !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
}
const mode = body.mode === 'rebase' ? 'rebase' : 'ff-only';
try {
const worktree = await wt.pull(id, body.path, mode);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.pull', resourceId: id, details: { path: body.path, mode } });
return reply.send({ worktree } satisfies WorktreeResponse);
} catch (err) {
return sendManagerError(reply, err);
}
});
}

View File

@@ -134,12 +134,18 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
if (!body || typeof body.path !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
}
if (typeof body.message !== 'string' || body.message.trim() === '') {
const amend = body.amend === true;
if (!amend && (typeof body.message !== 'string' || body.message.trim() === '')) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'message is required' } });
}
const mode = body.mode === 'staged' ? 'staged' : 'all';
try {
const worktree = await wt.commitWorktree(id, body.path, body.message.trim());
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.commit', resourceId: id, details: { path: body.path } });
const worktree = await wt.commitWorktree(id, body.path, {
message: typeof body.message === 'string' ? body.message.trim() : '',
mode,
amend,
});
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.commit', resourceId: id, details: { path: body.path, mode, amend } });
return reply.send({ worktree } satisfies WorktreeResponse);
} catch (err) {
return sendManagerError(reply, err);

View File

@@ -40,6 +40,9 @@ export function registerWsGateway(
let subscribedWorktrees = false;
let subscribedGroups = false;
let alive = true;
// Worktrees « regardés » par CETTE connexion (clé repoId\0path) → push ciblé de worktree_changes.
const watched = new Set<string>();
const watchKey = (repoId: string, path: string): string => `${repoId}\0${path}`;
const send = (msg: ServerMessage): void => {
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
@@ -76,6 +79,10 @@ export function registerWsGateway(
const onGroupRemoved = (groupId: string): void => {
if (subscribedGroups) send({ type: 'group_removed', groupId });
};
// P7 — détail d'un worktree modifié : poussé UNIQUEMENT si cette connexion le regarde.
const onWorktreeChanges = (e: { repoId: string; path: string }): void => {
if (watched.has(watchKey(e.repoId, e.path))) send({ type: 'worktree_changes', ...e });
};
manager.on('session_update', onSessionUpdate);
manager.on('session_exit', onSessionExit);
discovery.on('discovery_update', onDiscoveryUpdate);
@@ -83,6 +90,7 @@ export function registerWsGateway(
worktrees.on('repo_removed', onRepoRemoved);
worktrees.on('worktree_update', onWorktreeUpdate);
worktrees.on('worktree_removed', onWorktreeRemoved);
worktrees.on('worktree_changes', onWorktreeChanges);
groups.on('group_update', onGroupUpdate);
groups.on('group_removed', onGroupRemoved);
@@ -126,6 +134,22 @@ export function registerWsGateway(
subscribedGroups = msg.topics.includes('groups');
return;
}
case 'watch': {
const key = watchKey(msg.repoId, msg.path);
if (watched.has(key)) return;
watched.add(key);
// Validation repo/worktree + armement du watcher FS (asynchrone, best-effort).
void worktrees.watch(msg.repoId, msg.path).catch((err) => {
watched.delete(key);
send({ type: 'error', code: 'NOT_FOUND', message: (err as Error).message });
});
return;
}
case 'unwatch': {
watched.delete(watchKey(msg.repoId, msg.path));
worktrees.unwatch(msg.repoId, msg.path);
return;
}
case 'attach': {
const channel = nextChannel++;
const binding: ClientBinding = {
@@ -215,10 +239,17 @@ export function registerWsGateway(
worktrees.off('repo_removed', onRepoRemoved);
worktrees.off('worktree_update', onWorktreeUpdate);
worktrees.off('worktree_removed', onWorktreeRemoved);
worktrees.off('worktree_changes', onWorktreeChanges);
groups.off('group_update', onGroupUpdate);
groups.off('group_removed', onGroupRemoved);
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
channels.clear();
// Libère le refcount du watcher pour chaque worktree regardé par cette connexion.
for (const key of watched) {
const sep = key.indexOf('\0');
worktrees.unwatch(key.slice(0, sep), key.slice(sep + 1));
}
watched.clear();
});
void req;