feat(p7): moteur git détaillé, API fichiers et watcher FS temps réel
Fondations de la refonte « vrai IDE IA worktree » (chantier P7→P12).
- git.ts : listChanges (status --porcelain=v2 -z + --numstat fusionnés),
fileDiff (diff unifié borné, refus binaire, untracked via --no-index),
stage/unstage/restore/clean, commitStaged, amendCommit (refus si poussé),
fetchRemote, pull (ff-only|rebase), lastCommit, resolveGitDir,
isSafeRelativePath ; worktreeStatus enrichi (compteurs staged/unstaged/
conflict + dernier commit), champs additifs WorktreeGitStatus.
- core/fs-watcher.ts : FsWatcherService (chokidar, refcount + pool LRU borné,
ignore .git sauf HEAD/index, débounce) → invalide factsCache, rediffuse
worktree_update et émet worktree_changes.
- WorktreeManager : getWorktreeChanges/getFileDiff/stage/unstage/discard/
fetch/pull/watch/unwatch/assertPathInWorktree ; commitWorktree { mode, amend }.
- routes/git.ts (changes/diff/stage/unstage/discard/fetch/pull) et
routes/files.ts (GET/PUT contenu fichier, bornage strict au worktree) ;
fs/list?includeFiles=1 (flag isFile).
- protocole ADDITIF (PROTOCOL_VERSION inchangé) : messages WS watch/unwatch
(validés dans parseClientMessage) + worktree_changes poussé ciblé par la
gateway ; front wsClient.watchWorktree + lib/git-api.ts + api.put.
- tests git/fs-watcher/protocole + acceptance-p7.mjs (ALL GREEN).
- dépendance : chokidar (serveur).
This commit is contained in:
29
package-lock.json
generated
29
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
163
packages/server/scripts/acceptance-p7.mjs
Normal file
163
packages/server/scripts/acceptance-p7.mjs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -21,9 +21,12 @@ import { registerProjectRoutes } from './routes/projects.js';
|
||||
import { registerRepoRoutes } from './routes/repos.js';
|
||||
import { registerGroupRoutes } from './routes/groups.js';
|
||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
import { registerGitRoutes } from './routes/git.js';
|
||||
import { registerFileRoutes } from './routes/files.js';
|
||||
import { registerPushRoutes } from './routes/push.js';
|
||||
import { registerSettingsRoutes } from './routes/settings.js';
|
||||
import { registerFsRoutes } from './routes/fs.js';
|
||||
import { FsWatcherService } from './core/fs-watcher.js';
|
||||
import { registerAuditRoutes } from './routes/audit.js';
|
||||
import { registerDataRoutes } from './routes/data.js';
|
||||
import { registerWsGateway } from './ws/gateway.js';
|
||||
@@ -74,6 +77,7 @@ export interface AppBundle {
|
||||
worktrees: WorktreeManager;
|
||||
groups: GroupManager;
|
||||
push: PushService;
|
||||
fsWatcher: FsWatcherService;
|
||||
}
|
||||
|
||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||
@@ -90,7 +94,10 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
projectsDir: config.claudeProjectsDir,
|
||||
sessionsDir: config.claudeSessionsDir,
|
||||
});
|
||||
const worktrees = new WorktreeManager(db, manager, discovery);
|
||||
// Watcher FS temps réel (P7) : armé à la demande sur les worktrees regardés via les commandes
|
||||
// WS watch/unwatch. closeAll() au drain (libération des descripteurs).
|
||||
const fsWatcher = new FsWatcherService();
|
||||
const worktrees = new WorktreeManager(db, manager, discovery, fsWatcher);
|
||||
// Démarré dans runDaemon() (jamais ici) → le scan ne tourne pas pendant les tests qui appellent buildApp.
|
||||
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
|
||||
const groups = new GroupManager(db);
|
||||
@@ -175,6 +182,8 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
registerRepoRoutes(app, worktrees, db);
|
||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||
registerWorktreeRoutes(app, worktrees, db);
|
||||
registerGitRoutes(app, worktrees, db);
|
||||
registerFileRoutes(app, worktrees, db);
|
||||
registerPushRoutes(app, push, db);
|
||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||
registerFsRoutes(app);
|
||||
@@ -198,5 +207,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
});
|
||||
}
|
||||
|
||||
return { app, auth, manager, discovery, repoDiscovery, worktrees, groups, push };
|
||||
return { app, auth, manager, discovery, repoDiscovery, worktrees, groups, push, fsWatcher };
|
||||
}
|
||||
|
||||
179
packages/server/src/core/fs-watcher.ts
Normal file
179
packages/server/src/core/fs-watcher.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// Watcher FS des worktrees ACTIFS (regardés par un client OU portant une session vivante).
|
||||
// Émet un signal débouncé `worktree_fs_change` que le WorktreeManager traduit en recalcul du
|
||||
// statut git + broadcast WS. Pool LRU borné + refcount pour ne jamais épuiser les descripteurs :
|
||||
// un worktree n'est watché que tant qu'il est observé/épinglé, et la taille totale est plafonnée.
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { resolve, sep, join } from 'node:path';
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import { resolveGitDir } from './git.js';
|
||||
|
||||
const DEFAULT_MAX_WATCHERS = 32;
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
export interface FsWatcherEvents {
|
||||
/** le contenu d'un worktree surveillé a changé (édition, staging, checkout externe…). */
|
||||
worktree_fs_change: [{ repoId: string; path: string }];
|
||||
}
|
||||
|
||||
interface WatchEntry {
|
||||
repoId: string;
|
||||
path: string;
|
||||
watcher: FSWatcher;
|
||||
/** nombre de clients qui « regardent » ce worktree. */
|
||||
refCount: number;
|
||||
/** nombre de sessions vivantes épinglant ce worktree. */
|
||||
sessionPins: number;
|
||||
lastUsed: number;
|
||||
debounce: NodeJS.Timeout | null;
|
||||
/** résolue quand chokidar a fini son scan initial (les events deviennent fiables). */
|
||||
ready: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore tout sous `.git/` SAUF `HEAD` et `index` (⇒ on détecte le `git checkout` externe et le
|
||||
* staging) ainsi que `node_modules`. chokidar n'ignore pas le dossier `.git` lui-même afin de
|
||||
* pouvoir descendre jusqu'à `HEAD`/`index`, mais saute ses sous-dossiers volumineux (objects…).
|
||||
*/
|
||||
export function isIgnoredPath(p: string): boolean {
|
||||
if (p.includes(`${sep}node_modules${sep}`) || p.endsWith(`${sep}node_modules`)) return true;
|
||||
if (p.includes(`${sep}.git${sep}`)) {
|
||||
return !(p.endsWith(`${sep}HEAD`) || p.endsWith(`${sep}index`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface FsWatcherOptions {
|
||||
maxWatchers?: number;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export class FsWatcherService extends EventEmitter<FsWatcherEvents> {
|
||||
private readonly entries = new Map<string, WatchEntry>();
|
||||
private readonly maxWatchers: number;
|
||||
private readonly debounceMs: number;
|
||||
|
||||
constructor(opts: FsWatcherOptions = {}) {
|
||||
super();
|
||||
this.maxWatchers = opts.maxWatchers ?? DEFAULT_MAX_WATCHERS;
|
||||
this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
private key(repoId: string, path: string): string {
|
||||
return `${repoId}\0${resolve(path)}`;
|
||||
}
|
||||
|
||||
/** Un client commence à observer un worktree (vue IDE ouverte). */
|
||||
watch(repoId: string, path: string): void {
|
||||
const e = this.ensure(repoId, path);
|
||||
e.refCount++;
|
||||
e.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
/** Un client cesse d'observer ; le watcher reste (idle) jusqu'à éviction LRU. */
|
||||
unwatch(repoId: string, path: string): void {
|
||||
const e = this.entries.get(this.key(repoId, path));
|
||||
if (!e) return;
|
||||
e.refCount = Math.max(0, e.refCount - 1);
|
||||
e.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
/** Épingle un worktree tant qu'une session y est vivante (jamais évincé). */
|
||||
pinSession(repoId: string, path: string): void {
|
||||
const e = this.ensure(repoId, path);
|
||||
e.sessionPins++;
|
||||
e.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
unpinSession(repoId: string, path: string): void {
|
||||
const e = this.entries.get(this.key(repoId, path));
|
||||
if (!e) return;
|
||||
e.sessionPins = Math.max(0, e.sessionPins - 1);
|
||||
e.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
/** Nombre de watchers actifs (test/diagnostic). */
|
||||
size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
isWatching(repoId: string, path: string): boolean {
|
||||
return this.entries.has(this.key(repoId, path));
|
||||
}
|
||||
|
||||
/** Résout quand le watcher de ce worktree a fini son scan initial (utile aux tests). */
|
||||
whenReady(repoId: string, path: string): Promise<void> {
|
||||
return this.entries.get(this.key(repoId, path))?.ready ?? Promise.resolve();
|
||||
}
|
||||
|
||||
private ensure(repoId: string, path: string): WatchEntry {
|
||||
const key = this.key(repoId, path);
|
||||
const existing = this.entries.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const abs = resolve(path);
|
||||
const watcher = chokidar.watch(abs, {
|
||||
ignored: (p: string) => isIgnoredPath(p),
|
||||
ignoreInitial: true,
|
||||
// coalesce les écritures rapides (build, génération) avant d'émettre.
|
||||
awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 40 },
|
||||
});
|
||||
let resolveReady: () => void = () => {};
|
||||
const ready = new Promise<void>((r) => (resolveReady = r));
|
||||
watcher.once('ready', () => resolveReady());
|
||||
const entry: WatchEntry = { repoId, path: abs, watcher, refCount: 0, sessionPins: 0, lastUsed: Date.now(), debounce: null, ready };
|
||||
const onChange = (): void => this.schedule(entry);
|
||||
watcher.on('add', onChange).on('change', onChange).on('unlink', onChange).on('addDir', onChange).on('unlinkDir', onChange);
|
||||
this.entries.set(key, entry);
|
||||
|
||||
// Worktree LIÉ : HEAD/index vivent hors du worktree (dans .git/worktrees/<n>). On les ajoute
|
||||
// explicitement pour capter un changement de branche externe (git checkout en CLI).
|
||||
void resolveGitDir(abs).then((gitDir) => {
|
||||
if (!gitDir || !this.entries.has(key)) return;
|
||||
if (gitDir === join(abs, '.git') || gitDir.startsWith(abs + sep)) return; // déjà couvert
|
||||
watcher.add([join(gitDir, 'HEAD'), join(gitDir, 'index')]);
|
||||
}).catch(() => {});
|
||||
|
||||
this.evictIfNeeded();
|
||||
return entry;
|
||||
}
|
||||
|
||||
private schedule(entry: WatchEntry): void {
|
||||
if (entry.debounce) clearTimeout(entry.debounce);
|
||||
entry.debounce = setTimeout(() => {
|
||||
entry.debounce = null;
|
||||
entry.lastUsed = Date.now();
|
||||
this.emit('worktree_fs_change', { repoId: entry.repoId, path: entry.path });
|
||||
}, this.debounceMs);
|
||||
entry.debounce.unref();
|
||||
}
|
||||
|
||||
/** Ferme les watchers idle (refCount===0 && sessionPins===0) les moins récents au-delà du plafond. */
|
||||
private evictIfNeeded(): void {
|
||||
if (this.entries.size <= this.maxWatchers) return;
|
||||
const idle = [...this.entries.entries()]
|
||||
.filter(([, e]) => e.refCount === 0 && e.sessionPins === 0)
|
||||
.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
|
||||
for (const [key, e] of idle) {
|
||||
if (this.entries.size <= this.maxWatchers) break;
|
||||
this.close(key, e);
|
||||
}
|
||||
// Si tout est actif (reffé/épinglé), on dépasse le plafond volontairement : un worktree
|
||||
// explicitement observé ne doit jamais perdre son temps réel.
|
||||
}
|
||||
|
||||
private close(key: string, e: WatchEntry): void {
|
||||
if (e.debounce) clearTimeout(e.debounce);
|
||||
void e.watcher.close().catch(() => {});
|
||||
this.entries.delete(key);
|
||||
}
|
||||
|
||||
/** Libère tous les descripteurs (drain SIGTERM/SIGINT). */
|
||||
async closeAll(): Promise<void> {
|
||||
const all = [...this.entries.entries()];
|
||||
this.entries.clear();
|
||||
await Promise.all(all.map(([, e]) => {
|
||||
if (e.debounce) clearTimeout(e.debounce);
|
||||
return e.watcher.close().catch(() => {});
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant.
|
||||
import { execFile } from 'node:child_process';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode } from '@arboretum/shared';
|
||||
import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode, FileChange } from '@arboretum/shared';
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
// `push` peut dialoguer avec un remote (réseau) : on lui laisse une marge bien plus large.
|
||||
@@ -39,6 +39,24 @@ function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): P
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante tolérante : résout `{ stdout, code }` au lieu de rejeter sur code de sortie non nul.
|
||||
* Utile pour `git diff --no-index` (code 1 = « les fichiers diffèrent », pas une erreur).
|
||||
*/
|
||||
function gitRaw(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<{ stdout: string; code: number }> {
|
||||
return new Promise((resolveP) => {
|
||||
execFile(
|
||||
'git',
|
||||
args,
|
||||
{ cwd, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' } },
|
||||
(err, stdout) => {
|
||||
const code = err ? ((err as GitError).code as number) ?? 1 : 0;
|
||||
resolveP({ stdout: stdout.toString(), code: typeof code === 'number' ? code : 1 });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ParsedWorktree {
|
||||
path: string;
|
||||
head: string | null;
|
||||
@@ -122,6 +140,16 @@ export function isSafeAbsolutePath(p: string): boolean {
|
||||
return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pathspec relatif sûr passé à git (`git add/restore/diff -- <p>`) : non vide, non absolu, sans
|
||||
* segment `..`, ne commençant pas par `-` (anti-flag). Le `--` avant le pathspec reste obligatoire.
|
||||
*/
|
||||
export function isSafeRelativePath(p: string): boolean {
|
||||
if (p.length === 0 || p.startsWith('/') || p.startsWith('-')) return false;
|
||||
const parts = p.split(/[\\/]/);
|
||||
return !parts.includes('..') && !parts.includes('.git');
|
||||
}
|
||||
|
||||
/** Initialise un dépôt git dans `dir` (déjà créé et validé par l'appelant). `git init` est idempotent. */
|
||||
export async function gitInit(dir: string): Promise<void> {
|
||||
await git(dir, ['init']);
|
||||
@@ -219,13 +247,274 @@ export async function worktreeStatus(worktreePath: string): Promise<WorktreeGitS
|
||||
}
|
||||
}
|
||||
let dirtyCount = 0;
|
||||
let stagedCount = 0;
|
||||
let unstagedCount = 0;
|
||||
let conflictCount = 0;
|
||||
try {
|
||||
const status = await git(worktreePath, ['status', '--porcelain=v1', '--untracked-files=all']);
|
||||
dirtyCount = status.split('\n').filter((l) => l.trim() !== '').length;
|
||||
// Un SEUL passage porcelain v2 -z : dirtyCount + compteurs fins (staged/unstaged/conflit).
|
||||
const out = await git(worktreePath, ['status', '--porcelain=v2', '-z', '--untracked-files=all']);
|
||||
const entries = parsePorcelainV2(out);
|
||||
dirtyCount = entries.length;
|
||||
for (const e of entries) {
|
||||
if (e.conflicted) conflictCount++;
|
||||
if (e.staged) stagedCount++;
|
||||
if (e.unstaged) unstagedCount++;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ahead, behind, dirtyCount, upstream };
|
||||
let lastCommitHash: string | null = null;
|
||||
let lastCommitSubject: string | null = null;
|
||||
try {
|
||||
const lc = await lastCommit(worktreePath);
|
||||
if (lc) {
|
||||
lastCommitHash = lc.hash;
|
||||
lastCommitSubject = lc.subject;
|
||||
}
|
||||
} catch {
|
||||
/* repo sans commit : on laisse null */
|
||||
}
|
||||
return { ahead, behind, dirtyCount, upstream, stagedCount, unstagedCount, conflictCount, lastCommitHash, lastCommitSubject };
|
||||
}
|
||||
|
||||
// ---- P7 : statut détaillé / diff / staging / commit sélectif / fetch-pull (IDE worktree) ----
|
||||
|
||||
/** Entrée brute de `git status --porcelain=v2` (avant enrichissement numstat). */
|
||||
export interface PorcelainV2Entry {
|
||||
path: string;
|
||||
indexStatus: string;
|
||||
worktreeStatus: string;
|
||||
staged: boolean;
|
||||
unstaged: boolean;
|
||||
untracked: boolean;
|
||||
conflicted: boolean;
|
||||
renamedFrom?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git status --porcelain=v2 -z --untracked-files=all` (testable sans repo réel). Format -z :
|
||||
* champs séparés par NUL ; une entrée de renommage (type `2`) consomme un champ supplémentaire
|
||||
* (l'ancien chemin). Les chemins ne sont jamais entre guillemets en mode -z (pas d'échappement).
|
||||
*/
|
||||
export function parsePorcelainV2(stdout: string): PorcelainV2Entry[] {
|
||||
const fields = stdout.split('\0');
|
||||
if (fields.length && fields[fields.length - 1] === '') fields.pop();
|
||||
const out: PorcelainV2Entry[] = [];
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const f = fields[i];
|
||||
if (!f) continue;
|
||||
const kind = f[0];
|
||||
if (kind === '1') {
|
||||
const t = f.split(' ');
|
||||
const xy = t[1] ?? '..';
|
||||
out.push(entryFromXy(t.slice(8).join(' '), xy, { conflicted: false }));
|
||||
} else if (kind === '2') {
|
||||
const t = f.split(' ');
|
||||
const xy = t[1] ?? '..';
|
||||
const path = t.slice(9).join(' ');
|
||||
const renamedFrom = fields[++i]; // l'ancien chemin suit dans le champ NUL suivant
|
||||
out.push(entryFromXy(path, xy, { conflicted: false, ...(renamedFrom ? { renamedFrom } : {}) }));
|
||||
} else if (kind === 'u') {
|
||||
const t = f.split(' ');
|
||||
const xy = t[1] ?? '..';
|
||||
out.push(entryFromXy(t.slice(10).join(' '), xy, { conflicted: true }));
|
||||
} else if (kind === '?') {
|
||||
out.push({ path: f.slice(2), indexStatus: '?', worktreeStatus: '?', staged: false, unstaged: true, untracked: true, conflicted: false });
|
||||
}
|
||||
// '!' (ignored) : exclu de la liste des changements.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function entryFromXy(path: string, xy: string, opts: { conflicted: boolean; renamedFrom?: string }): PorcelainV2Entry {
|
||||
const indexStatus = xy[0] ?? '.';
|
||||
const worktreeStatus = xy[1] ?? '.';
|
||||
return {
|
||||
path,
|
||||
indexStatus,
|
||||
worktreeStatus,
|
||||
staged: !opts.conflicted && indexStatus !== '.',
|
||||
unstaged: opts.conflicted || worktreeStatus !== '.',
|
||||
untracked: false,
|
||||
conflicted: opts.conflicted,
|
||||
...(opts.renamedFrom ? { renamedFrom: opts.renamedFrom } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff --numstat -z` → map chemin → {insertions, deletions, binary}. Les fichiers
|
||||
* binaires sont marqués `'-'` par git. Les renommages ont un champ chemin vide suivi de
|
||||
* deux champs NUL (ancien, nouveau) ; on indexe par le nouveau chemin.
|
||||
*/
|
||||
export function parseNumstatZ(stdout: string): Map<string, { insertions: number | null; deletions: number | null; binary: boolean }> {
|
||||
const fields = stdout.split('\0');
|
||||
if (fields.length && fields[fields.length - 1] === '') fields.pop();
|
||||
const map = new Map<string, { insertions: number | null; deletions: number | null; binary: boolean }>();
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const f = fields[i];
|
||||
if (!f) continue;
|
||||
const parts = f.split('\t');
|
||||
if (parts.length < 3) continue;
|
||||
const addRaw = parts[0] ?? '';
|
||||
const delRaw = parts[1] ?? '';
|
||||
let path = parts[2] ?? '';
|
||||
if (path === '') {
|
||||
// renommage : ancien chemin = champ suivant, nouveau chemin = champ d'après.
|
||||
i++; // saute l'ancien chemin
|
||||
path = fields[++i] ?? '';
|
||||
if (path === '') continue;
|
||||
}
|
||||
const binary = addRaw === '-' || delRaw === '-';
|
||||
map.set(path, {
|
||||
insertions: binary ? null : Number(addRaw) || 0,
|
||||
deletions: binary ? null : Number(delRaw) || 0,
|
||||
binary,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const MAX_CHANGES = 5000;
|
||||
|
||||
/** Liste les fichiers modifiés d'un worktree (statut porcelain v2 enrichi des stats numstat). */
|
||||
export async function listChanges(worktreePath: string): Promise<{ changes: FileChange[]; truncated: boolean }> {
|
||||
const [statusOut, unstagedOut, stagedOut] = await Promise.all([
|
||||
git(worktreePath, ['status', '--porcelain=v2', '-z', '--untracked-files=all']),
|
||||
git(worktreePath, ['diff', '--numstat', '-z']),
|
||||
git(worktreePath, ['diff', '--numstat', '-z', '--cached']),
|
||||
]);
|
||||
const entries = parsePorcelainV2(statusOut);
|
||||
const unstaged = parseNumstatZ(unstagedOut);
|
||||
const staged = parseNumstatZ(stagedOut);
|
||||
const truncated = entries.length > MAX_CHANGES;
|
||||
const slice = truncated ? entries.slice(0, MAX_CHANGES) : entries;
|
||||
const changes: FileChange[] = slice.map((e) => {
|
||||
const ns = unstaged.get(e.path) ?? staged.get(e.path);
|
||||
const binary = ns?.binary ?? false;
|
||||
// insertions/deletions = somme staged+unstaged quand disponible ; null pour binaire/untracked.
|
||||
const u = unstaged.get(e.path);
|
||||
const s = staged.get(e.path);
|
||||
const sum = (a: number | null | undefined, b: number | null | undefined): number | null => {
|
||||
if (a == null && b == null) return null;
|
||||
return (a ?? 0) + (b ?? 0);
|
||||
};
|
||||
return {
|
||||
path: e.path,
|
||||
indexStatus: e.indexStatus,
|
||||
worktreeStatus: e.worktreeStatus,
|
||||
staged: e.staged,
|
||||
unstaged: e.unstaged,
|
||||
untracked: e.untracked,
|
||||
conflicted: e.conflicted,
|
||||
insertions: binary ? null : sum(u?.insertions, s?.insertions),
|
||||
deletions: binary ? null : sum(u?.deletions, s?.deletions),
|
||||
binary,
|
||||
...(e.renamedFrom ? { renamedFrom: e.renamedFrom } : {}),
|
||||
};
|
||||
});
|
||||
return { changes, truncated };
|
||||
}
|
||||
|
||||
const MAX_DIFF_BYTES = 512 * 1024;
|
||||
|
||||
/** Diff unifié d'un fichier. `staged` → diff de l'index ; `untracked` → diff vs /dev/null. */
|
||||
export async function fileDiff(
|
||||
worktreePath: string,
|
||||
file: string,
|
||||
opts: { staged?: boolean; untracked?: boolean } = {},
|
||||
): Promise<{ diff: string; binary: boolean; tooLarge: boolean }> {
|
||||
let raw: string;
|
||||
if (opts.untracked) {
|
||||
// --no-index sort en code 1 quand les fichiers diffèrent : on tolère via gitRaw.
|
||||
const r = await gitRaw(worktreePath, ['diff', '--no-index', '--no-color', '--', '/dev/null', file]);
|
||||
raw = r.stdout;
|
||||
} else {
|
||||
raw = await git(worktreePath, ['diff', '--no-color', ...(opts.staged ? ['--cached'] : []), '--', file]);
|
||||
}
|
||||
const binary = /^Binary files .* differ$/m.test(raw) || raw.includes('GIT binary patch');
|
||||
if (binary) return { diff: '', binary: true, tooLarge: false };
|
||||
if (raw.length > MAX_DIFF_BYTES) return { diff: raw.slice(0, MAX_DIFF_BYTES), binary: false, tooLarge: true };
|
||||
return { diff: raw, binary: false, tooLarge: false };
|
||||
}
|
||||
|
||||
/** Indexe des fichiers (`git add -- <files>`). Chaque chemin validé par l'appelant (isSafeRelativePath). */
|
||||
export async function stageFiles(worktreePath: string, files: string[]): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
await git(worktreePath, ['add', '--', ...files]);
|
||||
}
|
||||
|
||||
/** Désindexe des fichiers (`git restore --staged -- <files>`). */
|
||||
export async function unstageFiles(worktreePath: string, files: string[]): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
await git(worktreePath, ['restore', '--staged', '--', ...files]);
|
||||
}
|
||||
|
||||
/** Annule les modifications de l'arbre de travail de fichiers SUIVIS (`git restore -- <files>`). */
|
||||
export async function restoreFiles(worktreePath: string, files: string[]): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
await git(worktreePath, ['restore', '--', ...files]);
|
||||
}
|
||||
|
||||
/** Supprime des fichiers NON SUIVIS (`git clean -f -- <files>`). Destructif : opt-in côté appelant. */
|
||||
export async function cleanFiles(worktreePath: string, files: string[]): Promise<void> {
|
||||
if (files.length === 0) return;
|
||||
await git(worktreePath, ['clean', '-f', '--', ...files]);
|
||||
}
|
||||
|
||||
/** Commit de l'index uniquement (contraste avec `commitAll` = `git add -A` + commit). */
|
||||
export async function commitStaged(worktreePath: string, message: string): Promise<void> {
|
||||
await git(worktreePath, ['commit', '-m', message]);
|
||||
}
|
||||
|
||||
/** Réécrit le dernier commit. L'appelant garantit qu'il n'est pas déjà poussé. */
|
||||
export async function amendCommit(worktreePath: string, message?: string): Promise<void> {
|
||||
await git(worktreePath, message ? ['commit', '--amend', '-m', message] : ['commit', '--amend', '--no-edit']);
|
||||
}
|
||||
|
||||
/** `git fetch --all --prune` (réseau → timeout élargi). */
|
||||
export async function fetchRemote(worktreePath: string): Promise<void> {
|
||||
await git(worktreePath, ['fetch', '--all', '--prune'], GIT_PUSH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** `git pull` — `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
|
||||
export async function pull(worktreePath: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<void> {
|
||||
const args = mode === 'rebase' ? ['-c', 'rebase.autoStash=false', 'pull', '--rebase'] : ['pull', '--ff-only'];
|
||||
await git(worktreePath, args, GIT_PUSH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Répertoire git absolu d'un worktree (`git rev-parse --absolute-git-dir`). Pour le checkout
|
||||
* principal : `<path>/.git` ; pour un worktree LIÉ : `<repo>/.git/worktrees/<n>` (le `.git` du
|
||||
* worktree est un fichier pointeur). Sert au watcher FS pour surveiller le bon `HEAD`/`index`.
|
||||
*/
|
||||
export async function resolveGitDir(worktreePath: string): Promise<string | null> {
|
||||
const r = await gitRaw(worktreePath, ['rev-parse', '--absolute-git-dir']);
|
||||
if (r.code !== 0) return null;
|
||||
return r.stdout.trim() || null;
|
||||
}
|
||||
|
||||
/** Dernier commit (HEAD) : hash court + sujet. null si le dépôt n'a aucun commit. */
|
||||
export async function lastCommit(worktreePath: string): Promise<{ hash: string; subject: string } | null> {
|
||||
const r = await gitRaw(worktreePath, ['log', '-1', '--format=%h%x00%s']);
|
||||
if (r.code !== 0) return null;
|
||||
const idx = r.stdout.indexOf('\0');
|
||||
if (idx === -1) return null;
|
||||
return { hash: r.stdout.slice(0, idx), subject: r.stdout.slice(idx + 1).replace(/\n$/, '') };
|
||||
}
|
||||
|
||||
/** true si le HEAD courant n'est pas encore poussé (amend autorisé). Sans upstream → true. */
|
||||
export async function isUnpushed(worktreePath: string): Promise<boolean> {
|
||||
try {
|
||||
await git(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
||||
} catch {
|
||||
return true; // pas d'upstream → rien n'est « partagé »
|
||||
}
|
||||
try {
|
||||
const out = (await git(worktreePath, ['rev-list', '--count', '@{u}..HEAD'])).trim();
|
||||
return (Number(out) || 0) > 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Point de départ d'une branche créée : `baseRef` explicite, sinon la branche par défaut du dépôt
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path';
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
import type {
|
||||
DiscoverReposResponse,
|
||||
HookRunResult,
|
||||
@@ -24,21 +24,35 @@ import { scanForRepos } from './repo-scanner.js';
|
||||
import { preTrustProject } from './claude-trust.js';
|
||||
import {
|
||||
addWorktree,
|
||||
amendCommit,
|
||||
cleanFiles,
|
||||
commitAll,
|
||||
commitStaged,
|
||||
defaultBranch,
|
||||
fetchRemote,
|
||||
fileDiff,
|
||||
isDirtyWorktreeError,
|
||||
isRepo,
|
||||
isSafeAbsolutePath,
|
||||
isSafeRelativePath,
|
||||
isUnpushed,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
listChanges,
|
||||
listWorktrees,
|
||||
pruneWorktrees,
|
||||
pull,
|
||||
push,
|
||||
removeWorktree,
|
||||
restoreFiles,
|
||||
stageFiles,
|
||||
switchBranch,
|
||||
unstageFiles,
|
||||
worktreeStatus,
|
||||
type ParsedWorktree,
|
||||
} from './git.js';
|
||||
import type { FsWatcherService } from './fs-watcher.js';
|
||||
import type { FileChange, FileDiffResponse } from '@arboretum/shared';
|
||||
|
||||
const FACTS_TTL_MS = 2500;
|
||||
const HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||
@@ -64,6 +78,9 @@ export interface WorktreeManagerEvents {
|
||||
repo_removed: [string];
|
||||
worktree_update: [{ repoId: string; worktree: WorktreeSummary }];
|
||||
worktree_removed: [{ repoId: string; path: string }];
|
||||
/** P7 — le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
|
||||
* ciblé `worktree_changes` aux seules connexions ayant `watch`é cette clé. */
|
||||
worktree_changes: [{ repoId: string; path: string }];
|
||||
}
|
||||
|
||||
/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes. */
|
||||
@@ -116,8 +133,18 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
private readonly db: Db,
|
||||
private readonly ptyManager: PtyManager,
|
||||
private readonly discovery: DiscoveryService,
|
||||
private readonly fsWatcher?: FsWatcherService,
|
||||
) {
|
||||
super();
|
||||
// P7 — un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
|
||||
// (worktree_update : compteurs légers pour tout le dashboard) et signale aux clients qui le
|
||||
// regardent de re-fetcher le détail (worktree_changes ciblé). On résout repoId → row à la volée.
|
||||
this.fsWatcher?.on('worktree_fs_change', ({ repoId, path }) => {
|
||||
this.factsCache.delete(repoId);
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (row) void this.emitWorktree(row, path).catch(() => {});
|
||||
this.emit('worktree_changes', { repoId, path });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- repos ----
|
||||
@@ -377,18 +404,39 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
return listBranches(row.path);
|
||||
}
|
||||
|
||||
/** `git add -A` + commit dans le worktree visé (le checkout principal est un worktree valide ici). */
|
||||
async commitWorktree(repoId: string, path: string, message: string): Promise<WorktreeSummary> {
|
||||
/**
|
||||
* Commit dans le worktree visé. `mode='all'` (défaut, rétrocompat) = `git add -A` + commit ;
|
||||
* `mode='staged'` = commit de l'index uniquement (staging sélectif préalable) ; `amend` réécrit
|
||||
* le dernier commit (refusé s'il est déjà poussé). Le checkout principal est un worktree valide ici.
|
||||
*/
|
||||
async commitWorktree(
|
||||
repoId: string,
|
||||
path: string,
|
||||
opts: { message: string; mode?: 'all' | 'staged'; amend?: boolean },
|
||||
): Promise<WorktreeSummary> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
const w = await this.findWorktree(row, path);
|
||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||
const mode = opts.mode ?? 'all';
|
||||
return this.withLock(repoId, async () => {
|
||||
if ((await worktreeStatus(w.path)).dirtyCount === 0) {
|
||||
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit — working tree is clean');
|
||||
if (opts.amend && !(await isUnpushed(w.path))) {
|
||||
throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed — amend would rewrite shared history');
|
||||
}
|
||||
const st = await worktreeStatus(w.path);
|
||||
// Garde-fou « rien à committer » (sauf amend, qui peut ne changer que le message).
|
||||
if (!opts.amend) {
|
||||
if (mode === 'all' && st.dirtyCount === 0) {
|
||||
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit — working tree is clean');
|
||||
}
|
||||
if (mode === 'staged' && (st.stagedCount ?? 0) === 0) {
|
||||
throw httpError(409, 'NOTHING_STAGED', 'Nothing staged — stage files first');
|
||||
}
|
||||
}
|
||||
try {
|
||||
await commitAll(w.path, message);
|
||||
if (mode === 'all' && !opts.amend) await commitAll(w.path, opts.message);
|
||||
else if (opts.amend) await amendCommit(w.path, opts.message);
|
||||
else await commitStaged(w.path, opts.message);
|
||||
} catch (err) {
|
||||
throw httpError(400, 'COMMIT_FAILED', (err as Error).message);
|
||||
}
|
||||
@@ -397,6 +445,150 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- P7 : changes / diff / staging / fetch-pull / contenu fichier / watch ----
|
||||
|
||||
/** Résout et valide un worktree enregistré ; lève 404 si le repo/worktree n'existe pas. */
|
||||
private async requireWorktree(repoId: string, path: string): Promise<{ row: RepoRow; w: ParsedWorktree }> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
const w = await this.findWorktree(row, path);
|
||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||
return { row, w };
|
||||
}
|
||||
|
||||
/** Liste des fichiers modifiés d'un worktree (lecture, hors lock). */
|
||||
async getWorktreeChanges(repoId: string, path: string): Promise<{ changes: FileChange[]; truncated: boolean }> {
|
||||
const { w } = await this.requireWorktree(repoId, path);
|
||||
return listChanges(w.path);
|
||||
}
|
||||
|
||||
/** Diff unifié d'un fichier (détecte untracked → `git diff --no-index`). Lecture, hors lock. */
|
||||
async getFileDiff(repoId: string, path: string, file: string, staged: boolean): Promise<FileDiffResponse> {
|
||||
const { w } = await this.requireWorktree(repoId, path);
|
||||
if (!isSafeRelativePath(file)) throw httpError(400, 'BAD_PATH', 'Invalid file path');
|
||||
const { changes } = await listChanges(w.path);
|
||||
const rec = changes.find((c) => c.path === file);
|
||||
const untracked = !staged && (rec?.untracked ?? false);
|
||||
const d = await fileDiff(w.path, file, { staged, untracked });
|
||||
return { path: w.path, file, staged, binary: d.binary, tooLarge: d.tooLarge, diff: d.diff };
|
||||
}
|
||||
|
||||
private validateFiles(files: string[]): void {
|
||||
if (!Array.isArray(files) || files.length === 0) throw httpError(400, 'BAD_REQUEST', 'files (non-empty array) is required');
|
||||
for (const f of files) {
|
||||
if (typeof f !== 'string' || !isSafeRelativePath(f)) throw httpError(400, 'BAD_PATH', `Invalid file path: ${String(f)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Indexe des fichiers, puis rediffuse le statut. */
|
||||
async stage(repoId: string, path: string, files: string[]): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
this.validateFiles(files);
|
||||
return this.withLock(repoId, async () => {
|
||||
await stageFiles(w.path, files).catch((err) => { throw httpError(400, 'STAGE_FAILED', (err as Error).message); });
|
||||
this.factsCache.delete(repoId);
|
||||
this.emit('worktree_changes', { repoId, path: w.path });
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/** Désindexe des fichiers, puis rediffuse le statut. */
|
||||
async unstage(repoId: string, path: string, files: string[]): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
this.validateFiles(files);
|
||||
return this.withLock(repoId, async () => {
|
||||
await unstageFiles(w.path, files).catch((err) => { throw httpError(400, 'UNSTAGE_FAILED', (err as Error).message); });
|
||||
this.factsCache.delete(repoId);
|
||||
this.emit('worktree_changes', { repoId, path: w.path });
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule les changements locaux. Les fichiers SUIVIS sont restaurés (`git restore`) ; les fichiers
|
||||
* NON SUIVIS ne sont supprimés (`git clean`) QUE si `includeUntracked` (destructif, opt-in).
|
||||
*/
|
||||
async discard(repoId: string, path: string, files: string[], includeUntracked: boolean): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
this.validateFiles(files);
|
||||
return this.withLock(repoId, async () => {
|
||||
const { changes } = await listChanges(w.path);
|
||||
const set = new Set(files);
|
||||
const untracked = changes.filter((c) => set.has(c.path) && c.untracked).map((c) => c.path);
|
||||
const tracked = files.filter((f) => !untracked.includes(f));
|
||||
try {
|
||||
await restoreFiles(w.path, tracked);
|
||||
if (includeUntracked && untracked.length > 0) await cleanFiles(w.path, untracked);
|
||||
} catch (err) {
|
||||
throw httpError(400, 'DISCARD_FAILED', (err as Error).message);
|
||||
}
|
||||
this.factsCache.delete(repoId);
|
||||
this.emit('worktree_changes', { repoId, path: w.path });
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/** `git fetch --all --prune` puis rediffuse le statut (ahead/behind à jour). */
|
||||
async fetch(repoId: string, path: string): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
return this.withLock(repoId, async () => {
|
||||
await fetchRemote(w.path).catch((err) => { throw httpError(400, 'FETCH_FAILED', (err as Error).message); });
|
||||
this.factsCache.delete(repoId);
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/** `git pull` (ff-only par défaut). */
|
||||
async pull(repoId: string, path: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<WorktreeSummary> {
|
||||
const { row, w } = await this.requireWorktree(repoId, path);
|
||||
return this.withLock(repoId, async () => {
|
||||
await pull(w.path, mode).catch((err) => { throw httpError(409, 'PULL_FAILED', (err as Error).message); });
|
||||
this.factsCache.delete(repoId);
|
||||
this.emit('worktree_changes', { repoId, path: w.path });
|
||||
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide qu'un chemin relatif désigne bien un fichier DANS un worktree enregistré et renvoie son
|
||||
* chemin absolu résolu. Défense en profondeur (anti `..`, anti symlink sortant), même si un
|
||||
* terminal web est déjà du RCE par conception. Utilisé par l'API fichiers (lecture/écriture Monaco).
|
||||
*/
|
||||
async assertPathInWorktree(repoId: string, worktreeAbsPath: string, relPath: string): Promise<string> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
if (!isSafeAbsolutePath(worktreeAbsPath)) throw httpError(400, 'BAD_PATH', 'Worktree path must be absolute and normalized');
|
||||
const w = await this.findWorktree(row, worktreeAbsPath);
|
||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||
if (!isSafeRelativePath(relPath)) throw httpError(400, 'BAD_PATH', 'Invalid file path');
|
||||
const base = resolve(w.path);
|
||||
const abs = resolve(join(base, relPath));
|
||||
if (abs !== base && !abs.startsWith(base + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Path escapes the worktree');
|
||||
// Anti symlink-escape : pour un fichier existant, le realpath doit rester sous le worktree.
|
||||
if (existsSync(abs)) {
|
||||
let real: string;
|
||||
try {
|
||||
real = realpathSync(abs);
|
||||
} catch {
|
||||
throw httpError(400, 'BAD_PATH', 'Cannot resolve path');
|
||||
}
|
||||
const realBase = realpathSync(base);
|
||||
if (real !== realBase && !real.startsWith(realBase + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Path escapes the worktree (symlink)');
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** Arme le watcher FS sur un worktree (validé) pour le push temps réel du détail. */
|
||||
async watch(repoId: string, path: string): Promise<void> {
|
||||
if (!this.fsWatcher) return;
|
||||
await this.requireWorktree(repoId, path);
|
||||
this.fsWatcher.watch(repoId, resolve(path));
|
||||
}
|
||||
|
||||
unwatch(repoId: string, path: string): void {
|
||||
this.fsWatcher?.unwatch(repoId, resolve(path));
|
||||
}
|
||||
|
||||
/** Pousse la branche du worktree visé (upstream auto si absent). */
|
||||
async pushWorktree(repoId: string, path: string): Promise<WorktreeSummary> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
|
||||
@@ -30,7 +30,7 @@ function applyClaudeHomeOverride(config: Config, db: Db): void {
|
||||
export async function runDaemon(config: Config): Promise<void> {
|
||||
const db = openDb(config.dbPath);
|
||||
applyClaudeHomeOverride(config, db); // override claude_home (réglage UI) avant de câbler les services
|
||||
const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version);
|
||||
const { app, auth, manager, discovery, repoDiscovery, fsWatcher } = buildApp(config, db, pkg.version);
|
||||
|
||||
const bootstrapToken = auth.ensureBootstrapToken();
|
||||
await app.listen({ port: config.port, host: config.bind });
|
||||
@@ -53,6 +53,7 @@ export async function runDaemon(config: Config): Promise<void> {
|
||||
app.log.info(`${signal} received — draining sessions then exiting`);
|
||||
discovery.stop();
|
||||
repoDiscovery.stop();
|
||||
void fsWatcher.closeAll(); // libère les descripteurs des watchers FS
|
||||
manager.shutdown();
|
||||
setTimeout(() => {
|
||||
void app.close().then(() => process.exit(0));
|
||||
|
||||
86
packages/server/src/routes/files.ts
Normal file
86
packages/server/src/routes/files.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// API fichiers (P7) pour l'éditeur Monaco : lecture/écriture du contenu d'un fichier, strictement
|
||||
// bornées à un worktree ENREGISTRÉ (WorktreeManager.assertPathInWorktree : anti `..`, anti symlink
|
||||
// sortant). Authentifiée par le hook preValidation global. Un terminal web est déjà du RCE par
|
||||
// conception, mais on borne quand même (défense en profondeur + moindre étonnement).
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { readFile, writeFile, stat, mkdir } from 'node:fs/promises';
|
||||
import { dirname, extname } from 'node:path';
|
||||
import type { FileContentResponse, WriteFileRequest, WriteFileResponse } from '@arboretum/shared';
|
||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
import { sendManagerError } from './repos.js';
|
||||
|
||||
const FILE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** Langage Monaco déduit de l'extension (sous-ensemble courant ; Monaco a un fallback 'plaintext'). */
|
||||
const LANG_BY_EXT: Record<string, string> = {
|
||||
'.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
|
||||
'.json': 'json', '.vue': 'vue', '.css': 'css', '.scss': 'scss', '.less': 'less', '.html': 'html', '.md': 'markdown',
|
||||
'.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.java': 'java', '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp',
|
||||
'.cs': 'csharp', '.php': 'php', '.sh': 'shell', '.bash': 'shell', '.zsh': 'shell', '.yml': 'yaml', '.yaml': 'yaml',
|
||||
'.toml': 'toml', '.xml': 'xml', '.sql': 'sql', '.swift': 'swift', '.kt': 'kotlin', '.dart': 'dart', '.lua': 'lua',
|
||||
};
|
||||
|
||||
function languageFor(path: string): string | undefined {
|
||||
return LANG_BY_EXT[extname(path).toLowerCase()];
|
||||
}
|
||||
|
||||
/** Détecte un fichier binaire par présence d'un octet NUL dans le buffer. */
|
||||
function looksBinary(buf: Buffer): boolean {
|
||||
const n = Math.min(buf.length, 8192);
|
||||
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
|
||||
// Lecture du contenu d'un fichier (UTF-8) d'un worktree.
|
||||
app.get('/api/v1/repos/:id/files/content', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const q = req.query as { wt?: string; path?: string };
|
||||
if (typeof q.wt !== 'string' || q.wt === '' || typeof q.path !== 'string' || q.path === '') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'wt and path are required' } });
|
||||
}
|
||||
try {
|
||||
const abs = await wt.assertPathInWorktree(id, q.wt, q.path);
|
||||
const st = await stat(abs).catch(() => null);
|
||||
if (!st || !st.isFile()) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No such file' } });
|
||||
if (st.size > FILE_MAX_BYTES) return reply.status(413).send({ error: { code: 'FILE_TOO_LARGE', message: `File exceeds ${FILE_MAX_BYTES} bytes` } });
|
||||
const buf = await readFile(abs);
|
||||
if (looksBinary(buf)) return reply.status(415).send({ error: { code: 'BINARY_FILE', message: 'Binary files are not editable' } });
|
||||
const lang = languageFor(q.path);
|
||||
const res: FileContentResponse = {
|
||||
path: q.path,
|
||||
content: buf.toString('utf-8'),
|
||||
encoding: 'utf-8',
|
||||
size: st.size,
|
||||
...(lang ? { language: lang } : {}),
|
||||
};
|
||||
return reply.send(res);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Écriture (création/modification) du contenu d'un fichier d'un worktree.
|
||||
app.put('/api/v1/repos/:id/files/content', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<WriteFileRequest> | null;
|
||||
if (!body || typeof body.wt !== 'string' || typeof body.path !== 'string' || typeof body.content !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'wt, path and content are required' } });
|
||||
}
|
||||
if (Buffer.byteLength(body.content, 'utf-8') > FILE_MAX_BYTES) {
|
||||
return reply.status(413).send({ error: { code: 'FILE_TOO_LARGE', message: `Content exceeds ${FILE_MAX_BYTES} bytes` } });
|
||||
}
|
||||
try {
|
||||
const abs = await wt.assertPathInWorktree(id, body.wt, body.path);
|
||||
await mkdir(dirname(abs), { recursive: true }); // dirname reste sous le worktree (abs validé)
|
||||
await writeFile(abs, body.content, 'utf-8');
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'file.write', resourceId: id, details: { path: body.path } });
|
||||
const res: WriteFileResponse = { ok: true, size: Buffer.byteLength(body.content, 'utf-8') };
|
||||
return reply.send(res);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
||||
*/
|
||||
export function registerFsRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/v1/fs/list', async (req, reply) => {
|
||||
const q = req.query as { path?: string; markRepos?: string; showHidden?: string };
|
||||
const q = req.query as { path?: string; markRepos?: string; showHidden?: string; includeFiles?: string };
|
||||
const raw = q.path && q.path.length > 0 ? q.path : homedir();
|
||||
if (!raw.startsWith('/')) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_PATH', message: 'path must be absolute' } });
|
||||
@@ -41,6 +41,7 @@ export function registerFsRoutes(app: FastifyInstance): void {
|
||||
|
||||
const markRepos = q.markRepos === '1' || q.markRepos === 'true';
|
||||
const showHidden = q.showHidden === '1' || q.showHidden === 'true';
|
||||
const includeFiles = q.includeFiles === '1' || q.includeFiles === 'true';
|
||||
|
||||
let dirents;
|
||||
try {
|
||||
@@ -63,13 +64,24 @@ export function registerFsRoutes(app: FastifyInstance): void {
|
||||
isDir = false;
|
||||
}
|
||||
}
|
||||
if (!isDir) continue;
|
||||
const full = join(abs, d.name);
|
||||
if (!isDir) {
|
||||
// En mode includeFiles, on remonte aussi les fichiers (arbre de l'IDE worktree).
|
||||
if (!includeFiles) continue;
|
||||
entries.push({ name: d.name, path: full, isFile: true });
|
||||
continue;
|
||||
}
|
||||
const entry: FsEntry = { name: d.name, path: full };
|
||||
if (markRepos && existsSync(join(full, '.git'))) entry.isRepo = true;
|
||||
entries.push(entry);
|
||||
}
|
||||
entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||
// Dossiers d'abord, puis fichiers ; tri insensible à la casse dans chaque groupe.
|
||||
entries.sort((a, b) => {
|
||||
const af = a.isFile ? 1 : 0;
|
||||
const bf = b.isFile ? 1 : 0;
|
||||
if (af !== bf) return af - bf;
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const res: FsListResponse = {
|
||||
path: abs,
|
||||
|
||||
132
packages/server/src/routes/git.ts
Normal file
132
packages/server/src/routes/git.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// Routes git « IDE » (P7) : diff par fichier, statut détaillé, staging sélectif, discard, fetch, pull.
|
||||
// Toutes authentifiées par le hook preValidation global (/api/**), validées strictement et auditées.
|
||||
// Le commit (sélectif/amend) reste sur POST /worktrees/commit (routes/worktrees.ts) pour ne pas
|
||||
// dupliquer la sémantique.
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type {
|
||||
WorktreeChangesResponse,
|
||||
FileDiffResponse,
|
||||
WorktreeFilesRequest,
|
||||
DiscardFilesRequest,
|
||||
FetchWorktreeRequest,
|
||||
PullWorktreeRequest,
|
||||
WorktreeResponse,
|
||||
} from '@arboretum/shared';
|
||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
import { sendManagerError } from './repos.js';
|
||||
|
||||
export function registerGitRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
|
||||
// Liste des fichiers modifiés d'un worktree (statut détaillé + stats +/-).
|
||||
app.get('/api/v1/repos/:id/worktrees/changes', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const q = req.query as { path?: string };
|
||||
if (typeof q.path !== 'string' || q.path === '') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||
}
|
||||
try {
|
||||
const { changes, truncated } = await wt.getWorktreeChanges(id, q.path);
|
||||
return reply.send({ repoId: id, path: q.path, changes, truncated } satisfies WorktreeChangesResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Diff unifié d'un fichier (staged ou non ; untracked détecté côté manager).
|
||||
app.get('/api/v1/repos/:id/worktrees/diff', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const q = req.query as { path?: string; file?: string; staged?: string };
|
||||
if (typeof q.path !== 'string' || q.path === '' || typeof q.file !== 'string' || q.file === '') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and file are required' } });
|
||||
}
|
||||
const staged = q.staged === '1' || q.staged === 'true';
|
||||
try {
|
||||
const res = await wt.getFileDiff(id, q.path, q.file, staged);
|
||||
return reply.send(res satisfies FileDiffResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Staging sélectif.
|
||||
app.post('/api/v1/repos/:id/worktrees/stage', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<WorktreeFilesRequest> | null;
|
||||
if (!body || typeof body.path !== 'string' || !Array.isArray(body.files)) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and files are required' } });
|
||||
}
|
||||
try {
|
||||
const worktree = await wt.stage(id, body.path, body.files);
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.stage', resourceId: id, details: { path: body.path, count: body.files.length } });
|
||||
return reply.send({ worktree } satisfies WorktreeResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Désindexation sélective.
|
||||
app.post('/api/v1/repos/:id/worktrees/unstage', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<WorktreeFilesRequest> | null;
|
||||
if (!body || typeof body.path !== 'string' || !Array.isArray(body.files)) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and files are required' } });
|
||||
}
|
||||
try {
|
||||
const worktree = await wt.unstage(id, body.path, body.files);
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.unstage', resourceId: id, details: { path: body.path, count: body.files.length } });
|
||||
return reply.send({ worktree } satisfies WorktreeResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Annulation des changements locaux (restore ; clean des untracked uniquement si includeUntracked).
|
||||
app.post('/api/v1/repos/:id/worktrees/discard', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<DiscardFilesRequest> | null;
|
||||
if (!body || typeof body.path !== 'string' || !Array.isArray(body.files)) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path and files are required' } });
|
||||
}
|
||||
try {
|
||||
const worktree = await wt.discard(id, body.path, body.files, body.includeUntracked === true);
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.discard', resourceId: id, details: { path: body.path, count: body.files.length, includeUntracked: body.includeUntracked === true } });
|
||||
return reply.send({ worktree } satisfies WorktreeResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch (--all --prune) : actualise ahead/behind.
|
||||
app.post('/api/v1/repos/:id/worktrees/fetch', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<FetchWorktreeRequest> | null;
|
||||
if (!body || typeof body.path !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||
}
|
||||
try {
|
||||
const worktree = await wt.fetch(id, body.path);
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.fetch', resourceId: id, details: { path: body.path } });
|
||||
return reply.send({ worktree } satisfies WorktreeResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Pull (ff-only par défaut, rebase optionnel).
|
||||
app.post('/api/v1/repos/:id/worktrees/pull', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<PullWorktreeRequest> | null;
|
||||
if (!body || typeof body.path !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||
}
|
||||
const mode = body.mode === 'rebase' ? 'rebase' : 'ff-only';
|
||||
try {
|
||||
const worktree = await wt.pull(id, body.path, mode);
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'git.pull', resourceId: id, details: { path: body.path, mode } });
|
||||
return reply.send({ worktree } satisfies WorktreeResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -134,12 +134,18 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
||||
if (!body || typeof body.path !== 'string') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||
}
|
||||
if (typeof body.message !== 'string' || body.message.trim() === '') {
|
||||
const amend = body.amend === true;
|
||||
if (!amend && (typeof body.message !== 'string' || body.message.trim() === '')) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'message is required' } });
|
||||
}
|
||||
const mode = body.mode === 'staged' ? 'staged' : 'all';
|
||||
try {
|
||||
const worktree = await wt.commitWorktree(id, body.path, body.message.trim());
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.commit', resourceId: id, details: { path: body.path } });
|
||||
const worktree = await wt.commitWorktree(id, body.path, {
|
||||
message: typeof body.message === 'string' ? body.message.trim() : '',
|
||||
mode,
|
||||
amend,
|
||||
});
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.commit', resourceId: id, details: { path: body.path, mode, amend } });
|
||||
return reply.send({ worktree } satisfies WorktreeResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
|
||||
@@ -40,6 +40,9 @@ export function registerWsGateway(
|
||||
let subscribedWorktrees = false;
|
||||
let subscribedGroups = false;
|
||||
let alive = true;
|
||||
// Worktrees « regardés » par CETTE connexion (clé repoId\0path) → push ciblé de worktree_changes.
|
||||
const watched = new Set<string>();
|
||||
const watchKey = (repoId: string, path: string): string => `${repoId}\0${path}`;
|
||||
|
||||
const send = (msg: ServerMessage): void => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
|
||||
@@ -76,6 +79,10 @@ export function registerWsGateway(
|
||||
const onGroupRemoved = (groupId: string): void => {
|
||||
if (subscribedGroups) send({ type: 'group_removed', groupId });
|
||||
};
|
||||
// P7 — détail d'un worktree modifié : poussé UNIQUEMENT si cette connexion le regarde.
|
||||
const onWorktreeChanges = (e: { repoId: string; path: string }): void => {
|
||||
if (watched.has(watchKey(e.repoId, e.path))) send({ type: 'worktree_changes', ...e });
|
||||
};
|
||||
manager.on('session_update', onSessionUpdate);
|
||||
manager.on('session_exit', onSessionExit);
|
||||
discovery.on('discovery_update', onDiscoveryUpdate);
|
||||
@@ -83,6 +90,7 @@ export function registerWsGateway(
|
||||
worktrees.on('repo_removed', onRepoRemoved);
|
||||
worktrees.on('worktree_update', onWorktreeUpdate);
|
||||
worktrees.on('worktree_removed', onWorktreeRemoved);
|
||||
worktrees.on('worktree_changes', onWorktreeChanges);
|
||||
groups.on('group_update', onGroupUpdate);
|
||||
groups.on('group_removed', onGroupRemoved);
|
||||
|
||||
@@ -126,6 +134,22 @@ export function registerWsGateway(
|
||||
subscribedGroups = msg.topics.includes('groups');
|
||||
return;
|
||||
}
|
||||
case 'watch': {
|
||||
const key = watchKey(msg.repoId, msg.path);
|
||||
if (watched.has(key)) return;
|
||||
watched.add(key);
|
||||
// Validation repo/worktree + armement du watcher FS (asynchrone, best-effort).
|
||||
void worktrees.watch(msg.repoId, msg.path).catch((err) => {
|
||||
watched.delete(key);
|
||||
send({ type: 'error', code: 'NOT_FOUND', message: (err as Error).message });
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'unwatch': {
|
||||
watched.delete(watchKey(msg.repoId, msg.path));
|
||||
worktrees.unwatch(msg.repoId, msg.path);
|
||||
return;
|
||||
}
|
||||
case 'attach': {
|
||||
const channel = nextChannel++;
|
||||
const binding: ClientBinding = {
|
||||
@@ -215,10 +239,17 @@ export function registerWsGateway(
|
||||
worktrees.off('repo_removed', onRepoRemoved);
|
||||
worktrees.off('worktree_update', onWorktreeUpdate);
|
||||
worktrees.off('worktree_removed', onWorktreeRemoved);
|
||||
worktrees.off('worktree_changes', onWorktreeChanges);
|
||||
groups.off('group_update', onGroupUpdate);
|
||||
groups.off('group_removed', onGroupRemoved);
|
||||
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||
channels.clear();
|
||||
// Libère le refcount du watcher pour chaque worktree regardé par cette connexion.
|
||||
for (const key of watched) {
|
||||
const sep = key.indexOf('\0');
|
||||
worktrees.unwatch(key.slice(0, sep), key.slice(sep + 1));
|
||||
}
|
||||
watched.clear();
|
||||
});
|
||||
|
||||
void req;
|
||||
|
||||
111
packages/server/test/fs-watcher.test.ts
Normal file
111
packages/server/test/fs-watcher.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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é) */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, unknown>): string =>
|
||||
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, ...over });
|
||||
|
||||
@@ -35,5 +35,6 @@ export const api = {
|
||||
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
||||
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
|
||||
patch: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'PATCH', body),
|
||||
put: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'PUT', body),
|
||||
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
|
||||
};
|
||||
|
||||
44
packages/web/src/lib/git-api.ts
Normal file
44
packages/web/src/lib/git-api.ts
Normal file
@@ -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<WorktreeChangesResponse> =>
|
||||
api.get(`/api/v1/repos/${repoId}/worktrees/changes?path=${enc(wt)}`),
|
||||
|
||||
diff: (repoId: string, wt: string, file: string, staged = false): Promise<FileDiffResponse> =>
|
||||
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<WorktreeResponse> =>
|
||||
api.post(`/api/v1/repos/${repoId}/worktrees/stage`, { path: wt, files }),
|
||||
|
||||
unstage: (repoId: string, wt: string, files: string[]): Promise<WorktreeResponse> =>
|
||||
api.post(`/api/v1/repos/${repoId}/worktrees/unstage`, { path: wt, files }),
|
||||
|
||||
discard: (repoId: string, wt: string, files: string[], includeUntracked = false): Promise<WorktreeResponse> =>
|
||||
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<WorktreeResponse> =>
|
||||
api.post(`/api/v1/repos/${repoId}/worktrees/commit`, { path: wt, message, ...opts }),
|
||||
|
||||
fetch: (repoId: string, wt: string): Promise<WorktreeResponse> =>
|
||||
api.post(`/api/v1/repos/${repoId}/worktrees/fetch`, { path: wt }),
|
||||
|
||||
pull: (repoId: string, wt: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<WorktreeResponse> =>
|
||||
api.post(`/api/v1/repos/${repoId}/worktrees/pull`, { path: wt, mode }),
|
||||
|
||||
readFile: (repoId: string, wt: string, path: string): Promise<FileContentResponse> =>
|
||||
api.get(`/api/v1/repos/${repoId}/files/content?wt=${enc(wt)}&path=${enc(path)}`),
|
||||
|
||||
writeFile: (repoId: string, wt: string, path: string, content: string): Promise<WriteFileResponse> =>
|
||||
api.put(`/api/v1/repos/${repoId}/files/content`, { wt, path, content }),
|
||||
};
|
||||
@@ -29,6 +29,8 @@ export interface TerminalSink {
|
||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
||||
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
||||
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
|
||||
/** P7 — signal ciblé « le détail (changes/diff) d'un worktree regardé a changé ». */
|
||||
export type WorktreeChangesEvent = Extract<ServerMessage, { type: 'worktree_changes' }>;
|
||||
|
||||
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<string, Set<(e: WorktreeChangesEvent) => 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}`);
|
||||
|
||||
Reference in New Issue
Block a user