From be43911dc04ad1e5aaa3fbf26a07300410cd760e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Sat, 27 Jun 2026 12:15:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(p7):=20moteur=20git=20d=C3=A9taill=C3=A9,?= =?UTF-8?q?=20API=20fichiers=20et=20watcher=20FS=20temps=20r=C3=A9el?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- package-lock.json | 29 ++ packages/server/package.json | 1 + packages/server/scripts/acceptance-p7.mjs | 163 ++++++++++ packages/server/src/app.ts | 13 +- packages/server/src/core/fs-watcher.ts | 179 +++++++++++ packages/server/src/core/git.ts | 297 ++++++++++++++++++- packages/server/src/core/worktree-manager.ts | 206 ++++++++++++- packages/server/src/index.ts | 3 +- packages/server/src/routes/files.ts | 86 ++++++ packages/server/src/routes/fs.ts | 18 +- packages/server/src/routes/git.ts | 132 +++++++++ packages/server/src/routes/worktrees.ts | 12 +- packages/server/src/ws/gateway.ts | 31 ++ packages/server/test/fs-watcher.test.ts | 111 +++++++ packages/server/test/git.test.ts | 124 ++++++++ packages/shared/src/api.ts | 98 +++++- packages/shared/src/protocol.ts | 31 ++ packages/shared/test/protocol.test.ts | 12 + packages/web/src/lib/api.ts | 1 + packages/web/src/lib/git-api.ts | 44 +++ packages/web/src/lib/ws-client.ts | 40 +++ 21 files changed, 1610 insertions(+), 21 deletions(-) create mode 100644 packages/server/scripts/acceptance-p7.mjs create mode 100644 packages/server/src/core/fs-watcher.ts create mode 100644 packages/server/src/routes/files.ts create mode 100644 packages/server/src/routes/git.ts create mode 100644 packages/server/test/fs-watcher.test.ts create mode 100644 packages/web/src/lib/git-api.ts diff --git a/package-lock.json b/package-lock.json index 9c7cb00..d817396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3548,6 +3548,21 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -6302,6 +6317,19 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -7872,6 +7900,7 @@ "@fastify/websocket": "^11.0.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.0", "@xterm/headless": "^6.0.0", + "chokidar": "^4.0.3", "fastify": "^5.0.0", "web-push": "^3.6.7" }, diff --git a/packages/server/package.json b/packages/server/package.json index 631a3d9..071b607 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -57,6 +57,7 @@ "@fastify/websocket": "^11.0.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.0", "@xterm/headless": "^6.0.0", + "chokidar": "^4.0.3", "fastify": "^5.0.0", "web-push": "^3.6.7" }, diff --git a/packages/server/scripts/acceptance-p7.mjs b/packages/server/scripts/acceptance-p7.mjs new file mode 100644 index 0000000..bc0f2b8 --- /dev/null +++ b/packages/server/scripts/acceptance-p7.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// Acceptation P7 (sans navigateur, sans quota Claude) : moteur git « IDE » + API fichiers + watcher +// FS temps réel. Vrai daemon + vrai repo git tmp + vrai client WS. Couvre : GET /changes & /diff, +// staging sélectif + commit(staged), API fichiers content GET/PUT (Monaco) + refus de traversal, +// abonnement ciblé watch → worktree_changes à l'édition, checkout externe → worktree_update, unwatch. +import { spawn, execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, appendFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const WebSocket = require('ws'); + +const PORT = 7547; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const results = []; +const check = (name, ok, detail = '') => { + results.push({ name, ok, detail }); + console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p7-')); +const repo = join(tmp, 'demo-repo'); +execFileSync('mkdir', ['-p', repo]); +const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); +git('init', '-b', 'main'); +git('config', 'user.email', 'test@arboretum.dev'); +git('config', 'user.name', 'Test'); +writeFileSync(join(repo, 'README.md'), '# demo\n'); +git('add', '-A'); +git('commit', '-m', 'init'); + +const srv = spawn( + 'node', + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], + { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, +); +let srvOut = ''; +srv.stdout.on('data', (d) => (srvOut += d)); +srv.stderr.on('data', (d) => (srvOut += d)); + +function wsClient(cookie) { + const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } }); + const state = { msgs: [] }; + ws.on('message', (data, isBinary) => { + if (!isBinary) state.msgs.push(JSON.parse(String(data))); + }); + const waitMsg = async (pred, timeout = 8000) => { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + const m = state.msgs.find(pred); + if (m) return m; + await sleep(50); + } + return null; + }; + return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) }; +} + +const j = (path, method, cookie, body) => + fetch(`${ORIGIN}${path}`, { + method, + headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + +try { + await sleep(1500); + const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0]; + check('boot + token bootstrap', !!token); + + const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify({ token }), + }); + const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? ''; + check('login → cookie', login.status === 200); + + const c = wsClient(cookie); + await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej))); + c.send({ type: 'hello', protocol: 1 }); + await c.waitMsg((m) => m.type === 'hello_ok'); + c.send({ type: 'sub', topics: ['worktrees'] }); + + const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo }); + const repoId = (await addRepo.json()).repo.id; + check('POST /repos → 201', addRepo.status === 201 && !!repoId); + const enc = encodeURIComponent(repo); + + // ---- GET /changes : un fichier modifié + un untracked ---- + appendFileSync(join(repo, 'README.md'), 'edited line\n'); + writeFileSync(join(repo, 'untracked.txt'), 'new\n'); + const ch = await (await j(`/api/v1/repos/${repoId}/worktrees/changes?path=${enc}`, 'GET', cookie)).json(); + const byPath = Object.fromEntries((ch.changes ?? []).map((c2) => [c2.path, c2])); + check('GET /changes : README.md modifié non indexé', byPath['README.md']?.unstaged === true && byPath['README.md']?.staged === false); + check('GET /changes : untracked.txt détecté', byPath['untracked.txt']?.untracked === true); + + // ---- GET /diff ---- + const diff = await (await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&file=README.md`, 'GET', cookie)).json(); + check('GET /diff : contient la ligne ajoutée', typeof diff.diff === 'string' && diff.diff.includes('+edited line')); + + // ---- staging sélectif + commit(staged) ---- + const stage = await j(`/api/v1/repos/${repoId}/worktrees/stage`, 'POST', cookie, { path: repo, files: ['README.md'] }); + const staged = await stage.json(); + check('POST /stage → README.md staged', staged.worktree?.git?.stagedCount >= 1); + const commit = await j(`/api/v1/repos/${repoId}/worktrees/commit`, 'POST', cookie, { path: repo, message: 'commit staged only', mode: 'staged' }); + check('POST /commit (mode=staged) → 200', commit.status === 200); + const ch2 = await (await j(`/api/v1/repos/${repoId}/worktrees/changes?path=${enc}`, 'GET', cookie)).json(); + const paths2 = (ch2.changes ?? []).map((c2) => c2.path); + check('après commit staged : README committé, untracked restant', !paths2.includes('README.md') && paths2.includes('untracked.txt')); + + // ---- API fichiers (Monaco) : GET content + PUT + refus traversal ---- + const get1 = await (await j(`/api/v1/repos/${repoId}/files/content?wt=${enc}&path=untracked.txt`, 'GET', cookie)).json(); + check('GET /files/content : contenu lu + langage', get1.content === 'new\n'); + const put = await j(`/api/v1/repos/${repoId}/files/content`, 'PUT', cookie, { wt: repo, path: 'src/app.ts', content: 'export const x = 1\n' }); + check('PUT /files/content (création) → 200', put.status === 200); + const get2 = await (await j(`/api/v1/repos/${repoId}/files/content?wt=${enc}&path=src/app.ts`, 'GET', cookie)).json(); + check('GET après PUT : contenu écrit + langage typescript', get2.content === 'export const x = 1\n' && get2.language === 'typescript'); + const trav = await j(`/api/v1/repos/${repoId}/files/content?wt=${enc}&path=${encodeURIComponent('../escape.txt')}`, 'GET', cookie); + check('GET /files/content : traversal ../ refusé', trav.status === 400 || trav.status === 403); + + // ---- temps réel : watch → worktree_changes à l'édition ---- + c.send({ type: 'watch', repoId, path: repo }); + await sleep(900); // laisse chokidar finir son scan initial + const t0 = Date.now(); + writeFileSync(join(repo, 'live.txt'), 'live edit\n'); + const changesMsg = await c.waitMsg((m) => m.type === 'worktree_changes' && m.path === repo, 6000); + check('watch → worktree_changes ciblé reçu', !!changesMsg, changesMsg ? `${Date.now() - t0}ms` : 'timeout'); + const updMsg = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.path === repo, 3000); + check('édition → worktree_update diffusé', !!updMsg); + + // ---- checkout externe (CLI) → worktree_update avec nouvelle branche ---- + c.state.msgs.length = 0; // reset pour ne capter que les nouveaux events + git('branch', 'feature'); + git('checkout', 'feature'); + const branchMsg = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.isMain && m.worktree?.branch === 'feature', 6000); + check('checkout externe → branche principale mise à jour en temps réel', !!branchMsg); + + // ---- unwatch : plus de worktree_changes ---- + c.send({ type: 'unwatch', repoId, path: repo }); + await sleep(300); + c.state.msgs.length = 0; + writeFileSync(join(repo, 'after-unwatch.txt'), 'x\n'); + const afterUnwatch = await c.waitMsg((m) => m.type === 'worktree_changes', 1500); + check('unwatch → plus de worktree_changes', !afterUnwatch); + + c.ws.close(); +} catch (err) { + check('exception', false, String(err)); +} finally { + srv.kill('SIGTERM'); + await sleep(1500); + check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null); + rmSync(tmp, { recursive: true, force: true }); + const failed = results.filter((r) => !r.ok); + console.log(failed.length === 0 ? '\nACCEPTANCE P7: ALL GREEN' : `\nACCEPTANCE P7: ${failed.length} FAILURE(S)`); + process.exit(failed.length === 0 ? 0 : 1); +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 0482cdc..5c639aa 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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 }; } diff --git a/packages/server/src/core/fs-watcher.ts b/packages/server/src/core/fs-watcher.ts new file mode 100644 index 0000000..e84d165 --- /dev/null +++ b/packages/server/src/core/fs-watcher.ts @@ -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; +} + +/** + * 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 { + private readonly entries = new Map(); + 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 { + 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((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/). 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 { + 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(() => {}); + })); + } +} diff --git a/packages/server/src/core/git.ts b/packages/server/src/core/git.ts index a4ca427..25d6e9f 100644 --- a/packages/server/src/core/git.ts +++ b/packages/server/src/core/git.ts @@ -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 --

`) : 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 { await git(dir, ['init']); @@ -219,13 +247,274 @@ export async function worktreeStatus(worktreePath: string): Promise 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 { + const fields = stdout.split('\0'); + if (fields.length && fields[fields.length - 1] === '') fields.pop(); + const map = new Map(); + 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 -- `). Chaque chemin validé par l'appelant (isSafeRelativePath). */ +export async function stageFiles(worktreePath: string, files: string[]): Promise { + if (files.length === 0) return; + await git(worktreePath, ['add', '--', ...files]); +} + +/** Désindexe des fichiers (`git restore --staged -- `). */ +export async function unstageFiles(worktreePath: string, files: string[]): Promise { + if (files.length === 0) return; + await git(worktreePath, ['restore', '--staged', '--', ...files]); +} + +/** Annule les modifications de l'arbre de travail de fichiers SUIVIS (`git restore -- `). */ +export async function restoreFiles(worktreePath: string, files: string[]): Promise { + if (files.length === 0) return; + await git(worktreePath, ['restore', '--', ...files]); +} + +/** Supprime des fichiers NON SUIVIS (`git clean -f -- `). Destructif : opt-in côté appelant. */ +export async function cleanFiles(worktreePath: string, files: string[]): Promise { + 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 { + 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 { + 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 { + 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 { + 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 : `/.git` ; pour un worktree LIÉ : `/.git/worktrees/` (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 { + 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 { + 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 diff --git a/packages/server/src/core/worktree-manager.ts b/packages/server/src/core/worktree-manager.ts index b3e6ddd..d2db26c 100644 --- a/packages/server/src/core/worktree-manager.ts +++ b/packages/server/src/core/worktree-manager.ts @@ -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 { 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 { 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 { + /** + * 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 { 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 { }); } + // ---- 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { const row = this.getRepoRow(repoId); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7035d2f..199ef43 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -30,7 +30,7 @@ function applyClaudeHomeOverride(config: Config, db: Db): void { export async function runDaemon(config: Config): Promise { 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 { 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)); diff --git a/packages/server/src/routes/files.ts b/packages/server/src/routes/files.ts new file mode 100644 index 0000000..db1402d --- /dev/null +++ b/packages/server/src/routes/files.ts @@ -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 = { + '.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 | 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); + } + }); +} diff --git a/packages/server/src/routes/fs.ts b/packages/server/src/routes/fs.ts index e6992fd..cf0c17b 100644 --- a/packages/server/src/routes/fs.ts +++ b/packages/server/src/routes/fs.ts @@ -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, diff --git a/packages/server/src/routes/git.ts b/packages/server/src/routes/git.ts new file mode 100644 index 0000000..2ea04d4 --- /dev/null +++ b/packages/server/src/routes/git.ts @@ -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 | 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 | 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 | 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 | 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 | 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); + } + }); +} diff --git a/packages/server/src/routes/worktrees.ts b/packages/server/src/routes/worktrees.ts index 10022fe..45e2b52 100644 --- a/packages/server/src/routes/worktrees.ts +++ b/packages/server/src/routes/worktrees.ts @@ -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); diff --git a/packages/server/src/ws/gateway.ts b/packages/server/src/ws/gateway.ts index 5f40534..7a5ba81 100644 --- a/packages/server/src/ws/gateway.ts +++ b/packages/server/src/ws/gateway.ts @@ -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(); + 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; diff --git a/packages/server/test/fs-watcher.test.ts b/packages/server/test/fs-watcher.test.ts new file mode 100644 index 0000000..5d96fe8 --- /dev/null +++ b/packages/server/test/fs-watcher.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FsWatcherService, isIgnoredPath } from '../src/core/fs-watcher.js'; + +const dirs: string[] = []; +const services: FsWatcherService[] = []; +afterEach(async () => { + for (const s of services.splice(0)) await s.closeAll(); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function makeTmpRepo(): string { + const dir = mkdtempSync(join(tmpdir(), 'arb-fsw-')); + dirs.push(dir); + const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); + run('init', '-b', 'main'); + run('config', 'user.email', 'test@arboretum.dev'); + run('config', 'user.name', 'Test'); + writeFileSync(join(dir, 'README.md'), '# test\n'); + run('add', '-A'); + run('commit', '-m', 'init'); + return dir; +} + +/** Attend le prochain event `worktree_fs_change` (ou rejette au timeout). */ +function nextChange(s: FsWatcherService, timeoutMs = 4000): Promise<{ repoId: string; path: string }> { + return new Promise((resolveP, reject) => { + const t = setTimeout(() => reject(new Error('timeout: aucun worktree_fs_change')), timeoutMs); + s.once('worktree_fs_change', (e) => { + clearTimeout(t); + resolveP(e); + }); + }); +} + +describe('isIgnoredPath', () => { + it('ignore .git interne sauf HEAD/index, et node_modules', () => { + expect(isIgnoredPath('/r/.git/objects/ab/cd')).toBe(true); + expect(isIgnoredPath('/r/.git/refs/heads/main')).toBe(true); + expect(isIgnoredPath('/r/.git/HEAD')).toBe(false); + expect(isIgnoredPath('/r/.git/index')).toBe(false); + expect(isIgnoredPath('/r/node_modules/x/y.js')).toBe(true); + expect(isIgnoredPath('/r/src/a.ts')).toBe(false); + }); +}); + +describe('FsWatcherService', () => { + it('refcount : watch ouvre, unwatch ne ferme pas tant que le plafond n’est pas atteint', () => { + const repo = makeTmpRepo(); + const s = new FsWatcherService(); + services.push(s); + expect(s.isWatching('r1', repo)).toBe(false); + s.watch('r1', repo); + expect(s.isWatching('r1', repo)).toBe(true); + expect(s.size()).toBe(1); + s.unwatch('r1', repo); + expect(s.isWatching('r1', repo)).toBe(true); // idle, pas encore évincé + }); + + it('émet un worktree_fs_change débouncé à l’édition d’un fichier', async () => { + const repo = makeTmpRepo(); + const s = new FsWatcherService({ debounceMs: 50 }); + services.push(s); + s.watch('r1', repo); + await s.whenReady('r1', repo); + const p = nextChange(s); + writeFileSync(join(repo, 'edited.txt'), 'hello\n'); + const e = await p; + expect(e.repoId).toBe('r1'); + }); + + it('détecte un changement de branche externe (git checkout) via .git/HEAD', async () => { + const repo = makeTmpRepo(); + execFileSync('git', ['branch', 'feature'], { cwd: repo }); + const s = new FsWatcherService({ debounceMs: 50 }); + services.push(s); + s.watch('r1', repo); + await s.whenReady('r1', repo); + const p = nextChange(s); + execFileSync('git', ['checkout', 'feature'], { cwd: repo, stdio: 'pipe' }); + const e = await p; + expect(e.path).toContain('arb-fsw-'); + }); + + it('le pool LRU évince les entrées idle au-delà du plafond, jamais les épinglées', () => { + const s = new FsWatcherService({ maxWatchers: 2 }); + services.push(s); + const a = makeTmpRepo(); + const b = makeTmpRepo(); + const c = makeTmpRepo(); + s.pinSession('a', a); // épinglé → jamais évincé + s.watch('b', b); + s.unwatch('b', b); // idle + s.watch('c', c); // dépasse le plafond → évince le plus ancien idle (b) + expect(s.isWatching('a', a)).toBe(true); + expect(s.isWatching('c', c)).toBe(true); + expect(s.isWatching('b', b)).toBe(false); + }); + + it('closeAll libère tout', async () => { + const repo = makeTmpRepo(); + const s = new FsWatcherService(); + s.watch('r1', repo); + expect(s.size()).toBe(1); + await s.closeAll(); + expect(s.size()).toBe(0); + }); +}); diff --git a/packages/server/test/git.test.ts b/packages/server/test/git.test.ts index f2a9a61..72547c8 100644 --- a/packages/server/test/git.test.ts +++ b/packages/server/test/git.test.ts @@ -7,6 +7,7 @@ import { parseWorktreePorcelain, isValidBranchName, isSafeAbsolutePath, + isSafeRelativePath, isRepo, listWorktrees, worktreeStatus, @@ -19,7 +20,20 @@ import { currentBranch, listBranches, commitAll, + parsePorcelainV2, + parseNumstatZ, + listChanges, + fileDiff, + stageFiles, + unstageFiles, + restoreFiles, + cleanFiles, + commitStaged, + amendCommit, + lastCommit, + isUnpushed, } from '../src/core/git.js'; +import { appendFileSync } from 'node:fs'; const dirs: string[] = []; afterEach(() => { @@ -170,6 +184,116 @@ describe('branche : existence, liste, courante, commit', () => { }); }); +describe('P7 — parseurs purs (status v2 / numstat)', () => { + it('parsePorcelainV2 : modifié, untracked, renommé', () => { + const z = + '1 .M N... 100644 100644 100644 h1 h2 file.txt\x00' + + '1 A. N... 000000 100644 100644 0000 h3 added.txt\x00' + + '? new.txt\x00' + + '2 R. N... 100644 100644 100644 h4 h5 R100 newname.txt\x00oldname.txt\x00'; + const e = parsePorcelainV2(z); + expect(e).toHaveLength(4); + expect(e[0]).toMatchObject({ path: 'file.txt', indexStatus: '.', worktreeStatus: 'M', staged: false, unstaged: true }); + expect(e[1]).toMatchObject({ path: 'added.txt', indexStatus: 'A', staged: true, unstaged: false }); + expect(e[2]).toMatchObject({ path: 'new.txt', untracked: true, unstaged: true, staged: false }); + expect(e[3]).toMatchObject({ path: 'newname.txt', renamedFrom: 'oldname.txt', staged: true }); + }); + + it('parseNumstatZ : normal, binaire, renommé', () => { + const z = '5\t2\tfile.txt\x00-\t-\timg.png\x003\t1\t\x00old.txt\x00new.txt\x00'; + const m = parseNumstatZ(z); + expect(m.get('file.txt')).toEqual({ insertions: 5, deletions: 2, binary: false }); + expect(m.get('img.png')).toEqual({ insertions: null, deletions: null, binary: true }); + expect(m.get('new.txt')).toEqual({ insertions: 3, deletions: 1, binary: false }); + }); + + it('isSafeRelativePath', () => { + expect(isSafeRelativePath('src/a.ts')).toBe(true); + expect(isSafeRelativePath('/abs')).toBe(false); + expect(isSafeRelativePath('../escape')).toBe(false); + expect(isSafeRelativePath('-flag')).toBe(false); + expect(isSafeRelativePath('.git/config')).toBe(false); + expect(isSafeRelativePath('')).toBe(false); + }); +}); + +describe('P7 — changes / diff / staging / commit sélectif (repo tmp)', () => { + it('listChanges : untracked + modifié non indexé + indexé', async () => { + const repo = makeTmpRepo(); + writeFileSync(join(repo, 'untracked.txt'), 'x\n'); + appendFileSync(join(repo, 'README.md'), 'more\n'); + writeFileSync(join(repo, 'staged.txt'), 'y\n'); + await stageFiles(repo, ['staged.txt']); + + const { changes } = await listChanges(repo); + const byPath = Object.fromEntries(changes.map((c) => [c.path, c])); + expect(byPath['untracked.txt']).toMatchObject({ untracked: true, staged: false }); + expect(byPath['README.md']).toMatchObject({ unstaged: true, staged: false }); + expect(byPath['staged.txt']).toMatchObject({ staged: true }); + expect(byPath['staged.txt'].insertions).toBeGreaterThan(0); + }); + + it('fileDiff : fichier suivi modifié, refus binaire implicite, untracked via --no-index', async () => { + const repo = makeTmpRepo(); + appendFileSync(join(repo, 'README.md'), 'a new line\n'); + const d = await fileDiff(repo, 'README.md'); + expect(d.binary).toBe(false); + expect(d.diff).toContain('+a new line'); + + writeFileSync(join(repo, 'fresh.txt'), 'brand new\n'); + const u = await fileDiff(repo, 'fresh.txt', { untracked: true }); + expect(u.diff).toContain('+brand new'); + }); + + it('stage / unstage / restore / clean', async () => { + const repo = makeTmpRepo(); + appendFileSync(join(repo, 'README.md'), 'edit\n'); + await stageFiles(repo, ['README.md']); + expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')?.staged).toBe(true); + await unstageFiles(repo, ['README.md']); + expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')?.staged).toBe(false); + await restoreFiles(repo, ['README.md']); + expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')).toBeUndefined(); + + writeFileSync(join(repo, 'junk.txt'), 'junk\n'); + await cleanFiles(repo, ['junk.txt']); + expect((await listChanges(repo)).changes.some((c) => c.path === 'junk.txt')).toBe(false); + }); + + it('commitStaged : ne commite QUE l’index', async () => { + const repo = makeTmpRepo(); + writeFileSync(join(repo, 'a.txt'), 'a\n'); + writeFileSync(join(repo, 'b.txt'), 'b\n'); + await stageFiles(repo, ['a.txt']); + await commitStaged(repo, 'add a only'); + const { changes } = await listChanges(repo); + // a.txt committé (disparu), b.txt toujours non suivi. + expect(changes.some((c) => c.path === 'a.txt')).toBe(false); + expect(changes.some((c) => c.path === 'b.txt' && c.untracked)).toBe(true); + expect((await lastCommit(repo))?.subject).toBe('add a only'); + }); + + it('amendCommit + isUnpushed : HEAD local non poussé', async () => { + const repo = makeTmpRepo(); + expect(await isUnpushed(repo)).toBe(true); + await amendCommit(repo, 'init (amended)'); + expect((await lastCommit(repo))?.subject).toBe('init (amended)'); + }); + + it('worktreeStatus : compteurs détaillés + dernier commit', async () => { + const repo = makeTmpRepo(); + writeFileSync(join(repo, 'staged.txt'), 's\n'); + await stageFiles(repo, ['staged.txt']); + appendFileSync(join(repo, 'README.md'), 'unstaged\n'); + const st = await worktreeStatus(repo); + expect(st.stagedCount).toBeGreaterThanOrEqual(1); + expect(st.unstagedCount).toBeGreaterThanOrEqual(1); + expect(st.conflictCount).toBe(0); + expect(st.lastCommitSubject).toBe('init'); + expect(st.lastCommitHash).toBeTruthy(); + }); +}); + describe('addWorktree : résolution auto (créer / réutiliser)', () => { it('auto crée la branche si absente, la réutilise si présente', async () => { const repo = makeTmpRepo(); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index a335e96..1a7796b 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -179,6 +179,100 @@ export interface RepoBranchesResponse { export interface CommitWorktreeRequest { path: string; message: string; + /** + * P7 — `all` (défaut, rétrocompat) : `git add -A` + commit (tout l'arbre) ; `staged` : commit + * de l'index uniquement (staging sélectif préalable via /stage). `amend` réécrit le dernier + * commit (refusé s'il est déjà poussé). + */ + mode?: 'all' | 'staged'; + amend?: boolean; +} + +// ---- P7 : diff par fichier, statut détaillé, contenu de fichier (IDE worktree) ---- +/** Un fichier modifié dans un worktree (sortie de `git status --porcelain=v2` + `--numstat`). */ +export interface FileChange { + /** chemin relatif à la racine du worktree (POSIX). */ + path: string; + /** code d'état dans l'index (X de XY porcelain), '.' si inchangé. */ + indexStatus: string; + /** code d'état dans l'arbre de travail (Y de XY porcelain), '.' si inchangé. */ + worktreeStatus: string; + /** true si une partie est indexée (staged). */ + staged: boolean; + /** true si une partie est non indexée (unstaged) ou non suivie (untracked). */ + unstaged: boolean; + /** true pour les fichiers non suivis (untracked). */ + untracked: boolean; + /** true pour les fichiers en conflit de merge (XY de type U/AA/DD). */ + conflicted: boolean; + /** lignes ajoutées (null si binaire). */ + insertions: number | null; + /** lignes supprimées (null si binaire). */ + deletions: number | null; + /** true si git considère le fichier comme binaire (numstat = '-'). */ + binary: boolean; + /** ancien chemin pour un renommage/copie, sinon absent. */ + renamedFrom?: string; +} +/** GET /api/v1/repos/:id/worktrees/changes?path= — liste des fichiers modifiés d'un worktree. */ +export interface WorktreeChangesResponse { + repoId: string; + path: string; + changes: FileChange[]; + /** true si la liste a été bornée (trop de changements). */ + truncated: boolean; +} +/** GET /api/v1/repos/:id/worktrees/diff?path=&file=&staged= — diff unifié d'un fichier. */ +export interface FileDiffResponse { + path: string; + file: string; + staged: boolean; + binary: boolean; + /** true si le diff a été tronqué (fichier trop volumineux). */ + tooLarge: boolean; + /** texte du diff unifié git (vide si binaire ou tooLarge). */ + diff: string; +} +/** GET /api/v1/repos/:id/files/content?wt=&path= — contenu d'un fichier (pour l'éditeur Monaco). */ +export interface FileContentResponse { + /** chemin relatif au worktree (POSIX). */ + path: string; + content: string; + encoding: 'utf-8'; + size: number; + /** langage déduit de l'extension (pour Monaco), si reconnu. */ + language?: string; +} +/** PUT /api/v1/repos/:id/files/content — écriture d'un fichier (borné au worktree). */ +export interface WriteFileRequest { + /** chemin absolu du worktree. */ + wt: string; + /** chemin relatif au worktree (POSIX). */ + path: string; + content: string; +} +export interface WriteFileResponse { + ok: true; + size: number; +} +/** P7 — corps commun des mutations de staging/discard. */ +export interface WorktreeFilesRequest { + path: string; + /** chemins relatifs au worktree. */ + files: string[]; +} +/** DELETE des changements locaux ; `includeUntracked` autorise `git clean` (destructif, opt-in). */ +export interface DiscardFilesRequest extends WorktreeFilesRequest { + includeUntracked?: boolean; +} +/** POST /api/v1/repos/:id/worktrees/fetch — `git fetch --all --prune`. */ +export interface FetchWorktreeRequest { + path: string; +} +/** POST /api/v1/repos/:id/worktrees/pull — `git pull` (ff-only par défaut). */ +export interface PullWorktreeRequest { + path: string; + mode?: 'ff-only' | 'rebase'; } /** POST /api/v1/repos/:id/worktrees/push — pousse la branche du worktree (upstream auto si absent). */ export interface PushWorktreeRequest { @@ -256,10 +350,12 @@ export interface GroupSessionResponse { // ---- Navigateur de répertoires (sélecteur de dossier côté web) ---- export interface FsEntry { name: string; - /** chemin absolu du dossier */ + /** chemin absolu de l'entrée */ path: string; /** présent (true) uniquement en mode markRepos quand le dossier est un dépôt git */ isRepo?: boolean; + /** P7 — présent (true) en mode includeFiles quand l'entrée est un fichier (pas un dossier) */ + isFile?: boolean; } export interface FsListResponse { /** chemin absolu listé (normalisé) */ diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 4c058fb..36909ba 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -157,6 +157,20 @@ export interface WorktreeGitStatus { behind: number; dirtyCount: number; upstream: string | null; + // ---- Champs additifs P7 (optionnels, rétrocompat) — compteurs bon marché calculés dans le + // même passage `status --porcelain=v2` que dirtyCount. La liste détaillée des changements + // (FileChange[]) n'est JAMAIS diffusée ici : elle est servie à la demande (REST) et poussée + // uniquement aux clients qui « regardent » le worktree (voir `worktree_changes`). + /** fichiers avec des modifications indexées (staged). */ + stagedCount?: number; + /** fichiers avec des modifications non indexées (unstaged, untracked inclus). */ + unstagedCount?: number; + /** fichiers en conflit de merge. */ + conflictCount?: number; + /** hash court du dernier commit (HEAD), null si le dépôt n'a aucun commit. */ + lastCommitHash?: string | null; + /** sujet (première ligne) du dernier commit. */ + lastCommitSubject?: string | null; } export interface WorktreeSummary { @@ -203,6 +217,11 @@ export type ClientMessage = | { type: 'resize'; channel: number; cols: number; rows: number } | { type: 'ack'; channel: number; bytes: number } | { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups'> } + // P7 — abonnement ciblé au détail d'un worktree (diff/statut fin) : on ne pousse le détail + // qu'aux connexions qui « regardent » ce worktree (clé = repoId + path), indépendamment du + // topic global 'worktrees' (qui ne transporte que les compteurs légers). + | { type: 'watch'; repoId: string; path: string } + | { type: 'unwatch'; repoId: string; path: string } | { type: 'ping' }; // ---- Messages serveur → client ---- @@ -217,6 +236,10 @@ export type ServerMessage = | { type: 'repo_removed'; repoId: string } | { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary } | { type: 'worktree_removed'; repoId: string; path: string } + // P7 — signal léger « le détail (diff/fichiers modifiés) de ce worktree a changé, refais un + // GET /changes ou /diff ». Envoyé UNIQUEMENT aux connexions ayant `watch`é cette clé. On ne + // pousse pas le diff complet dans la frame (taille/flow control) : le client re-fetch en REST. + | { type: 'worktree_changes'; repoId: string; path: string } | { type: 'group_update'; group: GroupSummary } | { type: 'group_removed'; groupId: string } | { type: 'error'; code: ErrorCode; message: string; channel?: number } @@ -279,6 +302,14 @@ export function parseClientMessage(raw: string): ClientMessage | null { return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups') ? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups'> } : null; + case 'watch': + case 'unwatch': + // repoId/path bornés ici (typage + longueur) ; l'existence repo/worktree et la sûreté du + // chemin sont re-validées côté serveur (isSafeAbsolutePath + findWorktree) avant d'armer. + return typeof m.repoId === 'string' && m.repoId.length > 0 && m.repoId.length <= 128 && + typeof m.path === 'string' && m.path.length > 0 && m.path.length <= 4096 + ? { type: m.type, repoId: m.repoId, path: m.path } + : null; case 'ping': return { type: 'ping' }; default: diff --git a/packages/shared/test/protocol.test.ts b/packages/shared/test/protocol.test.ts index f81a3df..ce8c34d 100644 --- a/packages/shared/test/protocol.test.ts +++ b/packages/shared/test/protocol.test.ts @@ -128,6 +128,11 @@ describe('parseClientMessage — cas valides', () => { expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] }); }); + it('watch / unwatch (P7) avec repoId + path', () => { + expect(parseClientMessage('{"type":"watch","repoId":"r1","path":"/home/u/wt"}')).toEqual({ type: 'watch', repoId: 'r1', path: '/home/u/wt' }); + expect(parseClientMessage('{"type":"unwatch","repoId":"r1","path":"/home/u/wt"}')).toEqual({ type: 'unwatch', repoId: 'r1', path: '/home/u/wt' }); + }); + it('ping (champs superflus ignorés)', () => { expect(parseClientMessage('{"type":"ping","extra":42}')).toEqual({ type: 'ping' }); }); @@ -152,6 +157,13 @@ describe('parseClientMessage — cas malformés', () => { expect(parseClientMessage('{"type":"hello","protocol":"1"}')).toBeNull(); }); + it('watch / unwatch : repoId ou path manquant / mal typé', () => { + expect(parseClientMessage('{"type":"watch","path":"/x"}')).toBeNull(); + expect(parseClientMessage('{"type":"watch","repoId":"r1"}')).toBeNull(); + expect(parseClientMessage('{"type":"watch","repoId":"","path":"/x"}')).toBeNull(); + expect(parseClientMessage('{"type":"unwatch","repoId":"r1","path":42}')).toBeNull(); + }); + it('attach : dimensions hors bornes ou non entières', () => { const make = (over: Record): string => JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, ...over }); diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 9269d04..4ff16a8 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -35,5 +35,6 @@ export const api = { get: (path: string): Promise => request(path, 'GET'), post: (path: string, body?: unknown): Promise => request(path, 'POST', body), patch: (path: string, body?: unknown): Promise => request(path, 'PATCH', body), + put: (path: string, body?: unknown): Promise => request(path, 'PUT', body), delete: (path: string): Promise => request(path, 'DELETE'), }; diff --git a/packages/web/src/lib/git-api.ts b/packages/web/src/lib/git-api.ts new file mode 100644 index 0000000..888c212 --- /dev/null +++ b/packages/web/src/lib/git-api.ts @@ -0,0 +1,44 @@ +// Wrappers REST typés pour le moteur git « IDE » (P7) + l'API fichiers (Monaco). Consommés par la +// vue Workspace (P8) et le panneau de commit (P9). Le worktree est désigné par son chemin absolu. +import type { + WorktreeChangesResponse, + FileDiffResponse, + FileContentResponse, + WriteFileResponse, + WorktreeResponse, +} from '@arboretum/shared'; +import { api } from './api.js'; + +const enc = encodeURIComponent; + +export const gitApi = { + changes: (repoId: string, wt: string): Promise => + api.get(`/api/v1/repos/${repoId}/worktrees/changes?path=${enc(wt)}`), + + diff: (repoId: string, wt: string, file: string, staged = false): Promise => + api.get(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc(wt)}&file=${enc(file)}&staged=${staged ? '1' : '0'}`), + + stage: (repoId: string, wt: string, files: string[]): Promise => + api.post(`/api/v1/repos/${repoId}/worktrees/stage`, { path: wt, files }), + + unstage: (repoId: string, wt: string, files: string[]): Promise => + api.post(`/api/v1/repos/${repoId}/worktrees/unstage`, { path: wt, files }), + + discard: (repoId: string, wt: string, files: string[], includeUntracked = false): Promise => + api.post(`/api/v1/repos/${repoId}/worktrees/discard`, { path: wt, files, includeUntracked }), + + commit: (repoId: string, wt: string, message: string, opts: { mode?: 'all' | 'staged'; amend?: boolean } = {}): Promise => + api.post(`/api/v1/repos/${repoId}/worktrees/commit`, { path: wt, message, ...opts }), + + fetch: (repoId: string, wt: string): Promise => + api.post(`/api/v1/repos/${repoId}/worktrees/fetch`, { path: wt }), + + pull: (repoId: string, wt: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise => + api.post(`/api/v1/repos/${repoId}/worktrees/pull`, { path: wt, mode }), + + readFile: (repoId: string, wt: string, path: string): Promise => + api.get(`/api/v1/repos/${repoId}/files/content?wt=${enc(wt)}&path=${enc(path)}`), + + writeFile: (repoId: string, wt: string, path: string, content: string): Promise => + api.put(`/api/v1/repos/${repoId}/files/content`, { wt, path, content }), +}; diff --git a/packages/web/src/lib/ws-client.ts b/packages/web/src/lib/ws-client.ts index 6a74e75..8c4fc2d 100644 --- a/packages/web/src/lib/ws-client.ts +++ b/packages/web/src/lib/ws-client.ts @@ -29,6 +29,8 @@ export interface TerminalSink { export type SessionEvent = Extract; export type WorktreeEvent = Extract; export type GroupEvent = Extract; +/** P7 — signal ciblé « le détail (changes/diff) d'un worktree regardé a changé ». */ +export type WorktreeChangesEvent = Extract; export interface AttachOptions { sessionId: string; @@ -125,6 +127,8 @@ export class WsClient { private readonly sessionListeners = new Set<(e: SessionEvent) => void>(); private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>(); private readonly groupListeners = new Set<(e: GroupEvent) => void>(); + /** P7 — abonnements ciblés au détail d'un worktree (clé repoId\0path → listeners). */ + private readonly worktreeChangesListeners = new Map void>>(); connect(): void { this.stopped = false; @@ -215,6 +219,32 @@ export class WsClient { }; } + /** + * P7 — observe le détail (changes/diff) d'un worktree précis : envoie `watch`, route les + * `worktree_changes` correspondants vers `listener`, et ré-arme automatiquement après reconnexion. + * La désinscription envoie `unwatch` quand plus aucun listener ne regarde ce worktree. + */ + watchWorktree(repoId: string, path: string, listener: (e: WorktreeChangesEvent) => void): () => void { + const key = `${repoId}\0${path}`; + let set = this.worktreeChangesListeners.get(key); + if (!set) { + set = new Set(); + this.worktreeChangesListeners.set(key, set); + this.connect(); + this.sendControl({ type: 'watch', repoId, path }); + } + set.add(listener); + return () => { + const s = this.worktreeChangesListeners.get(key); + if (!s) return; + s.delete(listener); + if (s.size === 0) { + this.worktreeChangesListeners.delete(key); + this.sendControl({ type: 'unwatch', repoId, path }); + } + }; + } + sendControl(msg: ClientMessage): void { if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return; this.socket.send(JSON.stringify(msg)); @@ -389,6 +419,11 @@ export class WsClient { this.backoffMs = BACKOFF_MIN_MS; this.status.value = 'open'; this.sendSub(); + // P7 — ré-arme les watchers de worktree (clé repoId\0path) après reconnexion. + for (const key of this.worktreeChangesListeners.keys()) { + const sep = key.indexOf('\0'); + this.sendControl({ type: 'watch', repoId: key.slice(0, sep), path: key.slice(sep + 1) }); + } // ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur) for (const att of this.attachments) { if (!att.closed) this.sendAttach(att); @@ -451,6 +486,11 @@ export class WsClient { for (const cb of this.groupListeners) cb(msg); return; } + case 'worktree_changes': { + const set = this.worktreeChangesListeners.get(`${msg.repoId}\0${msg.path}`); + if (set) for (const cb of set) cb(msg); + return; + } case 'error': { if (msg.channel !== undefined) { console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);