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:
@@ -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 };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
86
packages/server/src/routes/files.ts
Normal file
86
packages/server/src/routes/files.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
132
packages/server/src/routes/git.ts
Normal file
132
packages/server/src/routes/git.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user