P3-A: worktrees multi-repo & cycle de vie (backend)

Enregistrement de repos et gestion de leurs worktrees git, source de vérité
= git (worktrees dérivés + cache court par repo), corrélation worktree ↔
sessions par cwd, mutations sérialisées par repo.

- shared: RepoSummary, PostCreateHook, WorktreeSummary, WorktreeGitStatus ;
  messages WS repo_update/worktree_update/*_removed ; topic sub 'worktrees'
  (+ parseClientMessage) ; DTOs REST repos/worktrees.
- db: migration id:3 (table repos).
- core/git.ts: couche git sûre (execFile, jamais de shell, -- avant chemins,
  GIT_OPTIONAL_LOCKS=0), parseWorktreePorcelain, list/add/remove/prune/status/
  ahead-behind, validation branche + chemin.
- core/claude-trust.ts: pré-trust atomique de ~/.claude.json (spike S3).
- core/worktree-manager.ts: repos CRUD, create (worktree add + pré-trust +
  hooks post-create + startSession optionnel), adopt, delete (garde-fous 409
  dirty / 400 main / 409 session live), prune ; events.
- routes/repos.ts + routes/worktrees.ts, câblage app.ts, gateway topic worktrees.
- tests: git (repos tmp réels), claude-trust, worktree-manager (146 verts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-12 18:31:06 +02:00
parent c177eeea07
commit bad1230a21
16 changed files with 1208 additions and 10 deletions

View File

@@ -5,11 +5,14 @@ import {
PROTOCOL_VERSION,
encodeBinaryFrame,
parseClientMessage,
type RepoSummary,
type ServerMessage,
type SessionSummary,
type WorktreeSummary,
} from '@arboretum/shared';
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
import type { DiscoveryService } from '../core/discovery-service.js';
import type { WorktreeManager } from '../core/worktree-manager.js';
const HEARTBEAT_MS = 30_000;
@@ -22,6 +25,7 @@ export function registerWsGateway(
app: FastifyInstance,
manager: PtyManager,
discovery: DiscoveryService,
worktrees: WorktreeManager,
serverVersion: string,
): void {
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
@@ -30,6 +34,7 @@ export function registerWsGateway(
let nextChannel = 1;
let helloDone = false;
let subscribedSessions = false;
let subscribedWorktrees = false;
let alive = true;
const send = (msg: ServerMessage): void => {
@@ -49,9 +54,25 @@ export function registerWsGateway(
const onDiscoveryUpdate = (session: SessionSummary): void => {
if (subscribedSessions) send({ type: 'session_update', session });
};
const onRepoUpdate = (repo: RepoSummary): void => {
if (subscribedWorktrees) send({ type: 'repo_update', repo });
};
const onRepoRemoved = (repoId: string): void => {
if (subscribedWorktrees) send({ type: 'repo_removed', repoId });
};
const onWorktreeUpdate = (e: { repoId: string; worktree: WorktreeSummary }): void => {
if (subscribedWorktrees) send({ type: 'worktree_update', ...e });
};
const onWorktreeRemoved = (e: { repoId: string; path: string }): void => {
if (subscribedWorktrees) send({ type: 'worktree_removed', ...e });
};
manager.on('session_update', onSessionUpdate);
manager.on('session_exit', onSessionExit);
discovery.on('discovery_update', onDiscoveryUpdate);
worktrees.on('repo_update', onRepoUpdate);
worktrees.on('repo_removed', onRepoRemoved);
worktrees.on('worktree_update', onWorktreeUpdate);
worktrees.on('worktree_removed', onWorktreeRemoved);
const heartbeat = setInterval(() => {
if (!alive) {
@@ -89,6 +110,7 @@ export function registerWsGateway(
}
case 'sub': {
subscribedSessions = msg.topics.includes('sessions');
subscribedWorktrees = msg.topics.includes('worktrees');
return;
}
case 'attach': {
@@ -160,6 +182,10 @@ export function registerWsGateway(
manager.off('session_update', onSessionUpdate);
manager.off('session_exit', onSessionExit);
discovery.off('discovery_update', onDiscoveryUpdate);
worktrees.off('repo_update', onRepoUpdate);
worktrees.off('repo_removed', onRepoRemoved);
worktrees.off('worktree_update', onWorktreeUpdate);
worktrees.off('worktree_removed', onWorktreeRemoved);
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
channels.clear();
});