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:
@@ -10,8 +10,11 @@ import type { Db } from './db/index.js';
|
|||||||
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
||||||
import { PtyManager } from './core/pty-manager.js';
|
import { PtyManager } from './core/pty-manager.js';
|
||||||
import { DiscoveryService } from './core/discovery-service.js';
|
import { DiscoveryService } from './core/discovery-service.js';
|
||||||
|
import { WorktreeManager } from './core/worktree-manager.js';
|
||||||
import { registerAuthRoutes } from './routes/auth.js';
|
import { registerAuthRoutes } from './routes/auth.js';
|
||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
|
import { registerRepoRoutes } from './routes/repos.js';
|
||||||
|
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||||
import { registerWsGateway } from './ws/gateway.js';
|
import { registerWsGateway } from './ws/gateway.js';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -28,6 +31,7 @@ export interface AppBundle {
|
|||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
manager: PtyManager;
|
manager: PtyManager;
|
||||||
discovery: DiscoveryService;
|
discovery: DiscoveryService;
|
||||||
|
worktrees: WorktreeManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||||
@@ -40,6 +44,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
projectsDir: config.claudeProjectsDir,
|
projectsDir: config.claudeProjectsDir,
|
||||||
sessionsDir: config.claudeSessionsDir,
|
sessionsDir: config.claudeSessionsDir,
|
||||||
});
|
});
|
||||||
|
const worktrees = new WorktreeManager(db, manager, discovery);
|
||||||
|
|
||||||
void app.register(fastifyCookie);
|
void app.register(fastifyCookie);
|
||||||
void app.register(fastifyWebsocket, {
|
void app.register(fastifyWebsocket, {
|
||||||
@@ -80,10 +85,12 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
|
|
||||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||||
registerSessionRoutes(app, manager, discovery);
|
registerSessionRoutes(app, manager, discovery);
|
||||||
|
registerRepoRoutes(app, worktrees);
|
||||||
|
registerWorktreeRoutes(app, worktrees);
|
||||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||||
void app.register(async (scoped) => {
|
void app.register(async (scoped) => {
|
||||||
registerWsGateway(scoped, manager, discovery, serverVersion);
|
registerWsGateway(scoped, manager, discovery, worktrees, serverVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||||
@@ -98,5 +105,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { app, auth, manager, discovery };
|
return { app, auth, manager, discovery, worktrees };
|
||||||
}
|
}
|
||||||
|
|||||||
41
packages/server/src/core/claude-trust.ts
Normal file
41
packages/server/src/core/claude-trust.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Pré-trust programmatique (spike S3) : écrit projects["<worktree>"].hasTrustDialogAccepted = true
|
||||||
|
// dans ~/.claude.json pour éviter le dialogue Trust au 1er lancement de `claude` dans un worktree neuf.
|
||||||
|
// Écriture ATOMIQUE (tmp + rename). Le chemin est injectable pour les tests (jamais le vrai HOME).
|
||||||
|
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export function claudeConfigPath(): string {
|
||||||
|
return join(homedir(), '.claude.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque un worktree comme déjà approuvé. Tolérant : fichier absent → on le crée ; fichier corrompu
|
||||||
|
* → on ABANDONNE le pré-trust (on ne l'écrase pas, pour ne pas perdre la config existante). Retourne
|
||||||
|
* true si le pré-trust a été écrit, false sinon (l'appelant continue : ce n'est pas bloquant).
|
||||||
|
*/
|
||||||
|
export function preTrustProject(worktreePath: string, filePath = claudeConfigPath()): boolean {
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown;
|
||||||
|
if (typeof parsed !== 'object' || parsed === null) return false; // contenu inattendu : on n'écrase pas
|
||||||
|
data = parsed as Record<string, unknown>;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false; // corrompu/illisible : abandon
|
||||||
|
data = {}; // fichier absent : on part d'un objet vide
|
||||||
|
}
|
||||||
|
const projects =
|
||||||
|
typeof data.projects === 'object' && data.projects !== null
|
||||||
|
? (data.projects as Record<string, Record<string, unknown>>)
|
||||||
|
: {};
|
||||||
|
projects[worktreePath] = { ...(projects[worktreePath] ?? {}), hasTrustDialogAccepted: true };
|
||||||
|
data.projects = projects;
|
||||||
|
try {
|
||||||
|
const tmp = `${filePath}.arb-tmp`;
|
||||||
|
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
||||||
|
renameSync(tmp, filePath); // rename atomique : jamais de fichier à moitié écrit
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
205
packages/server/src/core/git.ts
Normal file
205
packages/server/src/core/git.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Couche git sûre : tout passe par execFile (JAMAIS de shell), arguments en tableau, `--` avant
|
||||||
|
// 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 } from '@arboretum/shared';
|
||||||
|
|
||||||
|
const GIT_TIMEOUT_MS = 10_000;
|
||||||
|
const GIT_MAX_BUFFER = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface GitError extends Error {
|
||||||
|
stderr?: string;
|
||||||
|
code?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(cwd: string, args: string[]): Promise<string> {
|
||||||
|
return new Promise((resolveP, reject) => {
|
||||||
|
execFile(
|
||||||
|
'git',
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
timeout: GIT_TIMEOUT_MS,
|
||||||
|
maxBuffer: GIT_MAX_BUFFER,
|
||||||
|
// GIT_OPTIONAL_LOCKS=0 : pas de prise de verrou par `status` (perf + concurrence avec une
|
||||||
|
// session claude active) ; LC_ALL=C : sortie stable pour le parsing.
|
||||||
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' },
|
||||||
|
},
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
(err as GitError).stderr = String(stderr);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolveP(stdout.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedWorktree {
|
||||||
|
path: string;
|
||||||
|
head: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
prunable: boolean;
|
||||||
|
bare: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse la sortie `git worktree list --porcelain` (testable isolément, sans repo réel). */
|
||||||
|
export function parseWorktreePorcelain(stdout: string): ParsedWorktree[] {
|
||||||
|
const out: ParsedWorktree[] = [];
|
||||||
|
let cur: Partial<ParsedWorktree> | null = null;
|
||||||
|
const flush = (): void => {
|
||||||
|
if (cur?.path) {
|
||||||
|
out.push({
|
||||||
|
path: cur.path,
|
||||||
|
head: cur.head ?? null,
|
||||||
|
branch: cur.branch ?? null,
|
||||||
|
detached: cur.detached ?? false,
|
||||||
|
locked: cur.locked ?? false,
|
||||||
|
prunable: cur.prunable ?? false,
|
||||||
|
bare: cur.bare ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cur = null;
|
||||||
|
};
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
if (line === '') {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sp = line.indexOf(' ');
|
||||||
|
const key = sp === -1 ? line : line.slice(0, sp);
|
||||||
|
const val = sp === -1 ? '' : line.slice(sp + 1);
|
||||||
|
switch (key) {
|
||||||
|
case 'worktree':
|
||||||
|
flush();
|
||||||
|
cur = { path: val };
|
||||||
|
break;
|
||||||
|
case 'HEAD':
|
||||||
|
if (cur) cur.head = val;
|
||||||
|
break;
|
||||||
|
case 'branch':
|
||||||
|
if (cur) cur.branch = val.replace(/^refs\/heads\//, '');
|
||||||
|
break;
|
||||||
|
case 'detached':
|
||||||
|
if (cur) cur.detached = true;
|
||||||
|
break;
|
||||||
|
case 'bare':
|
||||||
|
if (cur) cur.bare = true;
|
||||||
|
break;
|
||||||
|
case 'locked':
|
||||||
|
if (cur) cur.locked = true;
|
||||||
|
break;
|
||||||
|
case 'prunable':
|
||||||
|
if (cur) cur.prunable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refuse les noms de branche dangereux (défense en profondeur ; git valide déjà côté lui). */
|
||||||
|
export function isValidBranchName(name: string): boolean {
|
||||||
|
return (
|
||||||
|
/^[A-Za-z0-9._/-]+$/.test(name) &&
|
||||||
|
!name.startsWith('-') &&
|
||||||
|
!name.startsWith('/') &&
|
||||||
|
!name.endsWith('/') &&
|
||||||
|
!name.endsWith('.lock') &&
|
||||||
|
!name.includes('..') &&
|
||||||
|
!name.includes('//')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chemin absolu sans segment `..` après résolution (évite l'échappement d'arborescence). */
|
||||||
|
export function isSafeAbsolutePath(p: string): boolean {
|
||||||
|
return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true si `path` est la racine d'un dépôt git (main worktree) accessible. */
|
||||||
|
export async function isRepo(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const top = (await git(path, ['rev-parse', '--show-toplevel'])).trim();
|
||||||
|
return resolve(top) === resolve(path);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branche par défaut (origin/HEAD) en nom court, ou null si indéterminée. */
|
||||||
|
export async function defaultBranch(repoPath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const ref = (await git(repoPath, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).trim();
|
||||||
|
return ref.replace(/^refs\/remotes\/origin\//, '') || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorktrees(repoPath: string): Promise<ParsedWorktree[]> {
|
||||||
|
return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** État git d'un worktree : ahead/behind vs upstream + nombre de fichiers modifiés. */
|
||||||
|
export async function worktreeStatus(worktreePath: string): Promise<WorktreeGitStatus> {
|
||||||
|
let upstream: string | null = null;
|
||||||
|
let ahead = 0;
|
||||||
|
let behind = 0;
|
||||||
|
try {
|
||||||
|
upstream = (await git(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim() || null;
|
||||||
|
} catch {
|
||||||
|
upstream = null;
|
||||||
|
}
|
||||||
|
if (upstream) {
|
||||||
|
try {
|
||||||
|
// left = commits de l'upstream absents de HEAD (behind) ; right = HEAD non poussés (ahead).
|
||||||
|
const out = (await git(worktreePath, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).trim();
|
||||||
|
const [left, right] = out.split(/\s+/);
|
||||||
|
behind = Number(left) || 0;
|
||||||
|
ahead = Number(right) || 0;
|
||||||
|
} catch {
|
||||||
|
/* refs inaccessibles : on laisse 0/0 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dirtyCount = 0;
|
||||||
|
try {
|
||||||
|
const status = await git(worktreePath, ['status', '--porcelain=v1', '--untracked-files=all']);
|
||||||
|
dirtyCount = status.split('\n').filter((l) => l.trim() !== '').length;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return { ahead, behind, dirtyCount, upstream };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addWorktree(
|
||||||
|
repoPath: string,
|
||||||
|
opts: { path: string; branch: string; newBranch: boolean; baseRef?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const args = ['worktree', 'add'];
|
||||||
|
if (opts.newBranch) args.push('-b', opts.branch);
|
||||||
|
args.push('--', opts.path);
|
||||||
|
if (opts.newBranch) {
|
||||||
|
if (opts.baseRef) args.push(opts.baseRef);
|
||||||
|
} else {
|
||||||
|
args.push(opts.branch);
|
||||||
|
}
|
||||||
|
await git(repoPath, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> {
|
||||||
|
await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneWorktrees(repoPath: string): Promise<void> {
|
||||||
|
await git(repoPath, ['worktree', 'prune']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true si l'erreur git d'un `worktree remove` est due à des changements non sauvegardés. */
|
||||||
|
export function isDirtyWorktreeError(err: unknown): boolean {
|
||||||
|
const msg = `${(err as GitError)?.stderr ?? ''} ${(err as Error)?.message ?? ''}`;
|
||||||
|
return /contains modified or untracked files|is dirty|use --force/i.test(msg);
|
||||||
|
}
|
||||||
339
packages/server/src/core/worktree-manager.ts
Normal file
339
packages/server/src/core/worktree-manager.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
// Gestion des repos enregistrés et de leurs worktrees git.
|
||||||
|
// Source de vérité des worktrees = git (dérivés à la volée + cache court par repo) ; seuls les repos
|
||||||
|
// sont persistés. Corrélation worktree ↔ sessions par cwd. Mutations sérialisées par repo.
|
||||||
|
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 type {
|
||||||
|
HookRunResult,
|
||||||
|
PostCreateHook,
|
||||||
|
RepoSummary,
|
||||||
|
SessionSummary,
|
||||||
|
WorktreeGitStatus,
|
||||||
|
WorktreeSummary,
|
||||||
|
} from '@arboretum/shared';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import type { PtyManager } from './pty-manager.js';
|
||||||
|
import { DiscoveryService, mergeSessions } from './discovery-service.js';
|
||||||
|
import { preTrustProject } from './claude-trust.js';
|
||||||
|
import {
|
||||||
|
addWorktree,
|
||||||
|
defaultBranch,
|
||||||
|
isDirtyWorktreeError,
|
||||||
|
isRepo,
|
||||||
|
isSafeAbsolutePath,
|
||||||
|
isValidBranchName,
|
||||||
|
listWorktrees,
|
||||||
|
pruneWorktrees,
|
||||||
|
removeWorktree,
|
||||||
|
worktreeStatus,
|
||||||
|
type ParsedWorktree,
|
||||||
|
} from './git.js';
|
||||||
|
|
||||||
|
const FACTS_TTL_MS = 2500;
|
||||||
|
const HOOK_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
const HOOK_OUTPUT_MAX = 64 * 1024;
|
||||||
|
|
||||||
|
interface RepoRow {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
default_branch: string | null;
|
||||||
|
post_create_hooks: string;
|
||||||
|
pre_trust: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeManagerEvents {
|
||||||
|
repo_update: [RepoSummary];
|
||||||
|
repo_removed: [string];
|
||||||
|
worktree_update: [{ repoId: string; worktree: WorktreeSummary }];
|
||||||
|
worktree_removed: [{ repoId: string; path: string }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes. */
|
||||||
|
function httpError(statusCode: number, code: string, message: string): Error {
|
||||||
|
return Object.assign(new Error(message), { statusCode, code });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHooks(json: string): PostCreateHook[] {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(json) as unknown;
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr.filter(
|
||||||
|
(h): h is PostCreateHook =>
|
||||||
|
!!h && typeof h.id === 'string' && typeof h.label === 'string' && typeof h.run === 'string' && typeof h.enabled === 'boolean',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
|
||||||
|
return new Promise((resolveP) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
execFile(
|
||||||
|
'bash',
|
||||||
|
['-lc', hook.run],
|
||||||
|
{ cwd, timeout: HOOK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, env: process.env },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
const output = `${stdout ?? ''}${stderr ?? ''}`.slice(-HOOK_OUTPUT_MAX);
|
||||||
|
const e = err as (Error & { code?: number | string }) | null;
|
||||||
|
resolveP({
|
||||||
|
hookId: hook.id,
|
||||||
|
label: hook.label,
|
||||||
|
exitCode: e ? (typeof e.code === 'number' ? e.code : 1) : 0,
|
||||||
|
output,
|
||||||
|
durationMs: Date.now() - t0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||||
|
private readonly factsCache = new Map<string, { facts: Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>; at: number }>();
|
||||||
|
private readonly locks = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: Db,
|
||||||
|
private readonly ptyManager: PtyManager,
|
||||||
|
private readonly discovery: DiscoveryService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- repos ----
|
||||||
|
|
||||||
|
private getRepoRow(id: string): RepoRow | null {
|
||||||
|
return (this.db.prepare('SELECT * FROM repos WHERE id = ?').get(id) as unknown as RepoRow | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rowToSummary(row: RepoRow): Promise<RepoSummary> {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
path: row.path,
|
||||||
|
label: row.label,
|
||||||
|
defaultBranch: row.default_branch,
|
||||||
|
postCreateHooks: parseHooks(row.post_create_hooks),
|
||||||
|
preTrust: row.pre_trust === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
valid: await isRepo(row.path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRepos(): Promise<RepoSummary[]> {
|
||||||
|
const rows = this.db.prepare('SELECT * FROM repos ORDER BY created_at ASC').all() as unknown as RepoRow[];
|
||||||
|
return Promise.all(rows.map((r) => this.rowToSummary(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
|
||||||
|
const path = opts.path;
|
||||||
|
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_REQUEST', 'path must be an absolute, normalized path');
|
||||||
|
if (!(await isRepo(path))) throw httpError(400, 'NOT_A_REPO', `Not a git repository root: ${path}`);
|
||||||
|
const existing = this.db.prepare('SELECT id FROM repos WHERE path = ?').get(path) as { id: string } | undefined;
|
||||||
|
if (existing) throw httpError(409, 'ALREADY_REGISTERED', 'This repository is already registered');
|
||||||
|
const row: RepoRow = {
|
||||||
|
id: randomUUID(),
|
||||||
|
path,
|
||||||
|
label: opts.label?.trim() || basename(path),
|
||||||
|
default_branch: await defaultBranch(path),
|
||||||
|
post_create_hooks: JSON.stringify(opts.postCreateHooks ?? []),
|
||||||
|
pre_trust: opts.preTrust ? 1 : 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.db
|
||||||
|
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at);
|
||||||
|
const summary = await this.rowToSummary(row);
|
||||||
|
this.emit('repo_update', summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
|
||||||
|
const row = this.getRepoRow(id);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
|
||||||
|
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
|
||||||
|
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
|
||||||
|
this.db
|
||||||
|
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ? WHERE id = ?')
|
||||||
|
.run(row.label, row.post_create_hooks, row.pre_trust, id);
|
||||||
|
const summary = await this.rowToSummary(row);
|
||||||
|
this.emit('repo_update', summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRepo(id: string): boolean {
|
||||||
|
const res = this.db.prepare('DELETE FROM repos WHERE id = ?').run(id);
|
||||||
|
if (res.changes === 0) return false;
|
||||||
|
this.factsCache.delete(id);
|
||||||
|
this.emit('repo_removed', id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- worktrees ----
|
||||||
|
|
||||||
|
/** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */
|
||||||
|
private sessionsForCwd(path: string): SessionSummary[] {
|
||||||
|
const rp = resolve(path);
|
||||||
|
return mergeSessions(this.ptyManager.list(), this.discovery.list()).filter((s) => resolve(s.cwd) === rp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSummary(repoId: string, repoPath: string, w: ParsedWorktree, status: WorktreeGitStatus): WorktreeSummary {
|
||||||
|
return {
|
||||||
|
repoId,
|
||||||
|
path: w.path,
|
||||||
|
branch: w.branch,
|
||||||
|
head: w.head ?? '',
|
||||||
|
detached: w.detached,
|
||||||
|
locked: w.locked,
|
||||||
|
prunable: w.prunable,
|
||||||
|
isMain: resolve(w.path) === resolve(repoPath),
|
||||||
|
git: status,
|
||||||
|
sessions: this.sessionsForCwd(w.path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async repoFacts(row: RepoRow, noCache = false): Promise<Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>> {
|
||||||
|
const cached = this.factsCache.get(row.id);
|
||||||
|
if (!noCache && cached && Date.now() - cached.at < FACTS_TTL_MS) return cached.facts;
|
||||||
|
const parsed = (await listWorktrees(row.path)).filter((w) => !w.bare);
|
||||||
|
const facts = await Promise.all(parsed.map(async (w) => ({ w, status: await worktreeStatus(w.path) })));
|
||||||
|
this.factsCache.set(row.id, { facts, at: Date.now() });
|
||||||
|
return facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRepoWorktrees(repoId: string, noCache = false): Promise<WorktreeSummary[]> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) return [];
|
||||||
|
const facts = await this.repoFacts(row, noCache);
|
||||||
|
return facts.map(({ w, status }) => this.toSummary(row.id, row.path, w, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAllWorktrees(): Promise<WorktreeSummary[]> {
|
||||||
|
const rows = this.db.prepare('SELECT id FROM repos ORDER BY created_at ASC').all() as Array<{ id: string }>;
|
||||||
|
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id)));
|
||||||
|
return lists.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sérialise les mutations d'un même repo (évite les courses sur .git/worktrees). */
|
||||||
|
private withLock<T>(repoId: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prev = this.locks.get(repoId) ?? Promise.resolve();
|
||||||
|
const next = prev.then(fn, fn);
|
||||||
|
this.locks.set(
|
||||||
|
repoId,
|
||||||
|
next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findWorktree(row: RepoRow, path: string): Promise<ParsedWorktree | null> {
|
||||||
|
const rp = resolve(path);
|
||||||
|
return (await listWorktrees(row.path)).find((w) => resolve(w.path) === rp) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emitWorktree(row: RepoRow, path: string): Promise<WorktreeSummary | null> {
|
||||||
|
const w = await this.findWorktree(row, path);
|
||||||
|
if (!w) return null;
|
||||||
|
const summary = this.toSummary(row.id, row.path, w, await worktreeStatus(w.path));
|
||||||
|
this.emit('worktree_update', { repoId: row.id, worktree: summary });
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWorktree(
|
||||||
|
repoId: string,
|
||||||
|
req: { branch: string; newBranch: boolean; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | null },
|
||||||
|
): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null }> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
if (!isValidBranchName(req.branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${req.branch}`);
|
||||||
|
const path = req.path ?? join(dirname(row.path), `${basename(row.path)}-wt-${req.branch.replace(/\//g, '-')}`);
|
||||||
|
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_PATH', 'Worktree path must be absolute and normalized');
|
||||||
|
if (existsSync(path)) throw httpError(409, 'PATH_EXISTS', `Path already exists: ${path}`);
|
||||||
|
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
try {
|
||||||
|
await addWorktree(row.path, { path, branch: req.branch, newBranch: req.newBranch, ...(req.baseRef ? { baseRef: req.baseRef } : {}) });
|
||||||
|
} catch (err) {
|
||||||
|
throw httpError(400, 'WORKTREE_ADD_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
|
||||||
|
if (req.preTrust ?? row.pre_trust === 1) preTrustProject(path);
|
||||||
|
|
||||||
|
const hookResults: HookRunResult[] = [];
|
||||||
|
if (req.runHooks ?? true) {
|
||||||
|
for (const hook of parseHooks(row.post_create_hooks)) {
|
||||||
|
if (hook.enabled) hookResults.push(await runHook(path, hook));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: SessionSummary | null = null;
|
||||||
|
if (req.startSession) session = this.ptyManager.spawn({ cwd: path, command: req.startSession });
|
||||||
|
|
||||||
|
const worktree = (await this.emitWorktree(row, path)) ?? this.toSummary(row.id, row.path, { path, head: null, branch: req.branch, detached: false, locked: false, prunable: false, bare: false }, { ahead: 0, behind: 0, dirtyCount: 0, upstream: null });
|
||||||
|
return { worktree, hookResults, session };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const w = await this.findWorktree(row, req.path);
|
||||||
|
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||||
|
|
||||||
|
if (req.preTrust ?? row.pre_trust === 1) preTrustProject(w.path);
|
||||||
|
const hookResults: HookRunResult[] = [];
|
||||||
|
if (req.runHooks) {
|
||||||
|
for (const hook of parseHooks(row.post_create_hooks)) {
|
||||||
|
if (hook.enabled) hookResults.push(await runHook(w.path, hook));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const worktree = (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||||
|
return { worktree, hookResults };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWorktree(repoId: string, path: string, force: boolean): Promise<void> {
|
||||||
|
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');
|
||||||
|
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
|
||||||
|
// garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
|
||||||
|
if (!force && this.sessionsForCwd(w.path).some((s) => s.live)) {
|
||||||
|
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree — pass force to delete anyway');
|
||||||
|
}
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
try {
|
||||||
|
await removeWorktree(row.path, w.path, force);
|
||||||
|
} catch (err) {
|
||||||
|
if (!force && isDirtyWorktreeError(err)) {
|
||||||
|
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — pass force to delete anyway');
|
||||||
|
}
|
||||||
|
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
this.emit('worktree_removed', { repoId, path: w.path });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async prune(repoId: string): Promise<void> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
const before = await listWorktrees(row.path);
|
||||||
|
await pruneWorktrees(row.path);
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
const after = new Set((await listWorktrees(row.path)).map((w) => resolve(w.path)));
|
||||||
|
for (const w of before) {
|
||||||
|
if (!after.has(resolve(w.path))) this.emit('worktree_removed', { repoId, path: w.path });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,21 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
CREATE INDEX idx_sessions_claude_session_id ON sessions(claude_session_id);
|
CREATE INDEX idx_sessions_claude_session_id ON sessions(claude_session_id);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// P3 — repos enregistrés. Les worktrees sont dérivés à la volée de git (non persistés).
|
||||||
|
id: 3,
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE repos (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
default_branch TEXT,
|
||||||
|
post_create_hooks TEXT NOT NULL DEFAULT '[]',
|
||||||
|
pre_trust INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
|
|||||||
56
packages/server/src/routes/repos.ts
Normal file
56
packages/server/src/routes/repos.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
|
import type { CreateRepoRequest, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared';
|
||||||
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
|
||||||
|
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
|
||||||
|
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||||
|
const e = err as { statusCode?: number; code?: string; message?: string };
|
||||||
|
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): void {
|
||||||
|
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
|
||||||
|
|
||||||
|
app.post('/api/v1/repos', async (req, reply) => {
|
||||||
|
const body = req.body as Partial<CreateRepoRequest> | null;
|
||||||
|
if (!body || typeof body.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path (absolute) is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const repo = await wt.addRepo({
|
||||||
|
path: body.path,
|
||||||
|
...(body.label !== undefined ? { label: body.label } : {}),
|
||||||
|
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||||
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
});
|
||||||
|
const res: RepoResponse = { repo };
|
||||||
|
return reply.status(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/v1/repos/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = (req.body as Partial<UpdateRepoRequest> | null) ?? {};
|
||||||
|
try {
|
||||||
|
const repo = await wt.updateRepo(id, {
|
||||||
|
...(body.label !== undefined ? { label: body.label } : {}),
|
||||||
|
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||||
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
});
|
||||||
|
const res: RepoResponse = { repo };
|
||||||
|
return reply.send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/repos/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
if (!wt.removeRepo(id)) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No repo with this id' } });
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
88
packages/server/src/routes/worktrees.ts
Normal file
88
packages/server/src/routes/worktrees.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type {
|
||||||
|
AdoptWorktreeRequest,
|
||||||
|
CreateWorktreeRequest,
|
||||||
|
CreateWorktreeResponse,
|
||||||
|
WorktreeResponse,
|
||||||
|
WorktreesListResponse,
|
||||||
|
} from '@arboretum/shared';
|
||||||
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import { sendManagerError } from './repos.js';
|
||||||
|
|
||||||
|
export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager): void {
|
||||||
|
app.get('/api/v1/worktrees', async (): Promise<WorktreesListResponse> => ({ worktrees: await wt.listAllWorktrees() }));
|
||||||
|
|
||||||
|
app.get('/api/v1/repos/:id/worktrees', async (req): Promise<WorktreesListResponse> => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
return { worktrees: await wt.listRepoWorktrees(id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/repos/:id/worktrees', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = req.body as Partial<CreateWorktreeRequest> | null;
|
||||||
|
if (!body || typeof body.branch !== 'string' || body.branch.trim() === '') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'branch is required' } });
|
||||||
|
}
|
||||||
|
if (body.startSession != null && body.startSession !== 'claude' && body.startSession !== 'bash') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'startSession must be claude, bash or null' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await wt.createWorktree(id, {
|
||||||
|
branch: body.branch,
|
||||||
|
newBranch: body.newBranch !== false, // défaut : créer la branche
|
||||||
|
...(body.baseRef !== undefined ? { baseRef: body.baseRef } : {}),
|
||||||
|
...(body.path !== undefined ? { path: body.path } : {}),
|
||||||
|
...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}),
|
||||||
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
...(body.startSession !== undefined ? { startSession: body.startSession } : {}),
|
||||||
|
});
|
||||||
|
const res: CreateWorktreeResponse = out;
|
||||||
|
return reply.status(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = req.body as Partial<AdoptWorktreeRequest> | null;
|
||||||
|
if (!body || typeof body.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await wt.adoptWorktree(id, {
|
||||||
|
path: body.path,
|
||||||
|
...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}),
|
||||||
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
});
|
||||||
|
const res: WorktreeResponse & { hookResults: typeof out.hookResults } = { worktree: out.worktree, hookResults: out.hookResults };
|
||||||
|
return reply.send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/repos/:id/worktrees/prune', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
try {
|
||||||
|
await wt.prune(id);
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/repos/:id/worktrees', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const query = req.query as { path?: string; force?: string };
|
||||||
|
if (typeof query.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path query param is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await wt.deleteWorktree(id, query.path, query.force === 'true');
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ import {
|
|||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
encodeBinaryFrame,
|
encodeBinaryFrame,
|
||||||
parseClientMessage,
|
parseClientMessage,
|
||||||
|
type RepoSummary,
|
||||||
type ServerMessage,
|
type ServerMessage,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
|
type WorktreeSummary,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||||
import type { DiscoveryService } from '../core/discovery-service.js';
|
import type { DiscoveryService } from '../core/discovery-service.js';
|
||||||
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
|
||||||
const HEARTBEAT_MS = 30_000;
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ export function registerWsGateway(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
manager: PtyManager,
|
manager: PtyManager,
|
||||||
discovery: DiscoveryService,
|
discovery: DiscoveryService,
|
||||||
|
worktrees: WorktreeManager,
|
||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
): void {
|
): void {
|
||||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||||
@@ -30,6 +34,7 @@ export function registerWsGateway(
|
|||||||
let nextChannel = 1;
|
let nextChannel = 1;
|
||||||
let helloDone = false;
|
let helloDone = false;
|
||||||
let subscribedSessions = false;
|
let subscribedSessions = false;
|
||||||
|
let subscribedWorktrees = false;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
|
||||||
const send = (msg: ServerMessage): void => {
|
const send = (msg: ServerMessage): void => {
|
||||||
@@ -49,9 +54,25 @@ export function registerWsGateway(
|
|||||||
const onDiscoveryUpdate = (session: SessionSummary): void => {
|
const onDiscoveryUpdate = (session: SessionSummary): void => {
|
||||||
if (subscribedSessions) send({ type: 'session_update', session });
|
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_update', onSessionUpdate);
|
||||||
manager.on('session_exit', onSessionExit);
|
manager.on('session_exit', onSessionExit);
|
||||||
discovery.on('discovery_update', onDiscoveryUpdate);
|
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(() => {
|
const heartbeat = setInterval(() => {
|
||||||
if (!alive) {
|
if (!alive) {
|
||||||
@@ -89,6 +110,7 @@ export function registerWsGateway(
|
|||||||
}
|
}
|
||||||
case 'sub': {
|
case 'sub': {
|
||||||
subscribedSessions = msg.topics.includes('sessions');
|
subscribedSessions = msg.topics.includes('sessions');
|
||||||
|
subscribedWorktrees = msg.topics.includes('worktrees');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'attach': {
|
case 'attach': {
|
||||||
@@ -160,6 +182,10 @@ export function registerWsGateway(
|
|||||||
manager.off('session_update', onSessionUpdate);
|
manager.off('session_update', onSessionUpdate);
|
||||||
manager.off('session_exit', onSessionExit);
|
manager.off('session_exit', onSessionExit);
|
||||||
discovery.off('discovery_update', onDiscoveryUpdate);
|
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);
|
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||||
channels.clear();
|
channels.clear();
|
||||||
});
|
});
|
||||||
|
|||||||
39
packages/server/test/claude-trust.test.ts
Normal file
39
packages/server/test/claude-trust.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { preTrustProject } from '../src/core/claude-trust.js';
|
||||||
|
|
||||||
|
describe('preTrustProject', () => {
|
||||||
|
let dir: string;
|
||||||
|
let cfg: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arb-trust-'));
|
||||||
|
cfg = join(dir, '.claude.json');
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crée le fichier s’il est absent et marque le worktree approuvé', () => {
|
||||||
|
expect(preTrustProject('/home/u/proj-wt-x', cfg)).toBe(true);
|
||||||
|
const data = JSON.parse(readFileSync(cfg, 'utf8'));
|
||||||
|
expect(data.projects['/home/u/proj-wt-x'].hasTrustDialogAccepted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('préserve la config existante (autres clés et projets)', () => {
|
||||||
|
writeFileSync(cfg, JSON.stringify({ theme: 'dark', projects: { '/other': { foo: 1 } } }));
|
||||||
|
expect(preTrustProject('/home/u/new', cfg)).toBe(true);
|
||||||
|
const data = JSON.parse(readFileSync(cfg, 'utf8'));
|
||||||
|
expect(data.theme).toBe('dark');
|
||||||
|
expect(data.projects['/other']).toEqual({ foo: 1 });
|
||||||
|
expect(data.projects['/home/u/new'].hasTrustDialogAccepted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fichier corrompu → abandon SANS écraser (retourne false)', () => {
|
||||||
|
writeFileSync(cfg, '{ this is not json');
|
||||||
|
expect(preTrustProject('/home/u/x', cfg)).toBe(false);
|
||||||
|
expect(readFileSync(cfg, 'utf8')).toBe('{ this is not json'); // intact
|
||||||
|
expect(existsSync(`${cfg}.arb-tmp`)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
129
packages/server/test/git.test.ts
Normal file
129
packages/server/test/git.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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, resolve, basename, dirname } from 'node:path';
|
||||||
|
import {
|
||||||
|
parseWorktreePorcelain,
|
||||||
|
isValidBranchName,
|
||||||
|
isSafeAbsolutePath,
|
||||||
|
isRepo,
|
||||||
|
listWorktrees,
|
||||||
|
worktreeStatus,
|
||||||
|
addWorktree,
|
||||||
|
removeWorktree,
|
||||||
|
pruneWorktrees,
|
||||||
|
isDirtyWorktreeError,
|
||||||
|
} from '../src/core/git.js';
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTmpRepo(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-git-'));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseWorktreePorcelain', () => {
|
||||||
|
it('parse le bloc principal, détaché, locked et prunable', () => {
|
||||||
|
const out = [
|
||||||
|
'worktree /repo',
|
||||||
|
'HEAD abc123',
|
||||||
|
'branch refs/heads/main',
|
||||||
|
'',
|
||||||
|
'worktree /repo-wt-x',
|
||||||
|
'HEAD def456',
|
||||||
|
'detached',
|
||||||
|
'locked reason here',
|
||||||
|
'',
|
||||||
|
'worktree /repo-wt-gone',
|
||||||
|
'HEAD 000',
|
||||||
|
'prunable gitdir file points to non-existent location',
|
||||||
|
'', // bloc final terminé par une ligne vide
|
||||||
|
].join('\n');
|
||||||
|
const wts = parseWorktreePorcelain(out);
|
||||||
|
expect(wts).toHaveLength(3);
|
||||||
|
expect(wts[0]).toMatchObject({ path: '/repo', branch: 'main', detached: false });
|
||||||
|
expect(wts[1]).toMatchObject({ path: '/repo-wt-x', detached: true, locked: true, branch: null });
|
||||||
|
expect(wts[2]).toMatchObject({ path: '/repo-wt-gone', prunable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolère un bloc final sans ligne vide', () => {
|
||||||
|
const wts = parseWorktreePorcelain('worktree /a\nHEAD x\nbranch refs/heads/dev');
|
||||||
|
expect(wts).toEqual([
|
||||||
|
{ path: '/a', head: 'x', branch: 'dev', detached: false, locked: false, prunable: false, bare: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('isValidBranchName', () => {
|
||||||
|
expect(isValidBranchName('feature/foo-1.2')).toBe(true);
|
||||||
|
expect(isValidBranchName('-foo')).toBe(false);
|
||||||
|
expect(isValidBranchName('a..b')).toBe(false);
|
||||||
|
expect(isValidBranchName('a/')).toBe(false);
|
||||||
|
expect(isValidBranchName('x.lock')).toBe(false);
|
||||||
|
expect(isValidBranchName('a b')).toBe(false);
|
||||||
|
});
|
||||||
|
it('isSafeAbsolutePath', () => {
|
||||||
|
expect(isSafeAbsolutePath('/home/u/proj-wt-x')).toBe(true);
|
||||||
|
expect(isSafeAbsolutePath('relative/x')).toBe(false);
|
||||||
|
expect(isSafeAbsolutePath('/home/u/../etc')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('opérations git (repo tmp réel)', () => {
|
||||||
|
it('isRepo : racine vs non-repo', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
expect(await isRepo(repo)).toBe(true);
|
||||||
|
const notRepo = mkdtempSync(join(tmpdir(), 'arb-nogit-'));
|
||||||
|
dirs.push(notRepo);
|
||||||
|
expect(await isRepo(notRepo)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add → list → status (dirty) → remove (refus dirty puis force)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await addWorktree(repo, { path: wtPath, branch: 'feat', newBranch: true });
|
||||||
|
|
||||||
|
const list = await listWorktrees(repo);
|
||||||
|
const wt = list.find((w) => resolve(w.path) === resolve(wtPath));
|
||||||
|
expect(wt?.branch).toBe('feat');
|
||||||
|
|
||||||
|
// worktree propre
|
||||||
|
expect((await worktreeStatus(wtPath)).dirtyCount).toBe(0);
|
||||||
|
// un fichier non suivi → dirty
|
||||||
|
writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n');
|
||||||
|
expect((await worktreeStatus(wtPath)).dirtyCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// remove sans --force refusé (worktree sale)
|
||||||
|
let dirtyErr: unknown;
|
||||||
|
await removeWorktree(repo, wtPath, false).catch((e) => (dirtyErr = e));
|
||||||
|
expect(dirtyErr).toBeDefined();
|
||||||
|
expect(isDirtyWorktreeError(dirtyErr)).toBe(true);
|
||||||
|
|
||||||
|
// remove --force réussit
|
||||||
|
await removeWorktree(repo, wtPath, true);
|
||||||
|
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prune retire un worktree dont le dossier a disparu', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`);
|
||||||
|
await addWorktree(repo, { path: wtPath, branch: 'gone', newBranch: true });
|
||||||
|
rmSync(wtPath, { recursive: true, force: true }); // suppression "à la main"
|
||||||
|
await pruneWorktrees(repo);
|
||||||
|
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
packages/server/test/worktree-manager.test.ts
Normal file
147
packages/server/test/worktree-manager.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, basename, dirname, resolve } from 'node:path';
|
||||||
|
import type { WorktreeSummary } from '@arboretum/shared';
|
||||||
|
import { WorktreeManager } from '../src/core/worktree-manager.js';
|
||||||
|
import { PtyManager } from '../src/core/pty-manager.js';
|
||||||
|
import { DiscoveryService } from '../src/core/discovery-service.js';
|
||||||
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
|
// node-pty inerte (la corrélation de session n'a besoin que d'un pid et d'un cwd).
|
||||||
|
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||||
|
let pid = 50_000;
|
||||||
|
class FakePty {
|
||||||
|
pid = pid++;
|
||||||
|
write = vi.fn();
|
||||||
|
resize = vi.fn();
|
||||||
|
pause = vi.fn();
|
||||||
|
resume = vi.fn();
|
||||||
|
kill = vi.fn();
|
||||||
|
onData(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
onExit(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { default: { spawn: (): FakePty => new FakePty() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTmpRepo(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-'));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorktreeManager', () => {
|
||||||
|
let db: Db;
|
||||||
|
let pty: PtyManager;
|
||||||
|
let discovery: DiscoveryService;
|
||||||
|
let wt: WorktreeManager;
|
||||||
|
let claudeHome: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = openDb(':memory:');
|
||||||
|
claudeHome = mkdtempSync(join(tmpdir(), 'arb-ch-'));
|
||||||
|
dirs.push(claudeHome);
|
||||||
|
pty = new PtyManager(db, join(claudeHome, 'sessions'));
|
||||||
|
discovery = new DiscoveryService({ ptyManager: pty, projectsDir: join(claudeHome, 'projects'), sessionsDir: join(claudeHome, 'sessions') });
|
||||||
|
wt = new WorktreeManager(db, pty, discovery);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addRepo : repo valide enregistré ; non-repo rejeté (400)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const summary = await wt.addRepo({ path: repo });
|
||||||
|
expect(summary).toMatchObject({ path: repo, label: basename(repo), valid: true });
|
||||||
|
expect((await wt.listRepos())).toHaveLength(1);
|
||||||
|
|
||||||
|
const notRepo = mkdtempSync(join(tmpdir(), 'arb-nogit-'));
|
||||||
|
dirs.push(notRepo);
|
||||||
|
await expect(wt.addRepo({ path: notRepo })).rejects.toMatchObject({ statusCode: 400 });
|
||||||
|
// doublon
|
||||||
|
await expect(wt.addRepo({ path: repo })).rejects.toMatchObject({ statusCode: 409 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createWorktree : worktree + hook exécuté + event worktree_update', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({
|
||||||
|
path: repo,
|
||||||
|
postCreateHooks: [{ id: 'h1', label: 'touch', run: 'touch hook-ran.txt', enabled: true }],
|
||||||
|
});
|
||||||
|
const events: Array<{ repoId: string; worktree: WorktreeSummary }> = [];
|
||||||
|
wt.on('worktree_update', (e) => events.push(e));
|
||||||
|
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
const out = await wt.createWorktree(r.id, { branch: 'feat', newBranch: true, runHooks: true });
|
||||||
|
|
||||||
|
expect(resolve(out.worktree.path)).toBe(resolve(wtPath));
|
||||||
|
expect(out.worktree.branch).toBe('feat');
|
||||||
|
expect(out.hookResults).toHaveLength(1);
|
||||||
|
expect(out.hookResults[0]).toMatchObject({ exitCode: 0 });
|
||||||
|
expect(existsSync(join(wtPath, 'hook-ran.txt'))).toBe(true);
|
||||||
|
expect(events.some((e) => resolve(e.worktree.path) === resolve(wtPath))).toBe(true);
|
||||||
|
|
||||||
|
const list = await wt.listRepoWorktrees(r.id, true);
|
||||||
|
expect(list.some((w) => resolve(w.path) === resolve(wtPath))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createWorktree : branche invalide → 400', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteWorktree : main refusé (400), dirty refusé (409) puis force OK', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
await expect(wt.deleteWorktree(r.id, repo, false)).rejects.toMatchObject({ statusCode: 400, code: 'IS_MAIN_WORKTREE' });
|
||||||
|
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-x`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await wt.createWorktree(r.id, { branch: 'x', newBranch: true, runHooks: false });
|
||||||
|
writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n'); // worktree sale
|
||||||
|
|
||||||
|
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'WORKTREE_DIRTY' });
|
||||||
|
await wt.deleteWorktree(r.id, wtPath, true);
|
||||||
|
expect((await wt.listRepoWorktrees(r.id, true)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('corrélation : une session dont le cwd = worktree apparaît dans worktree.sessions', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await wt.createWorktree(r.id, { branch: 'sess', newBranch: true, runHooks: false });
|
||||||
|
|
||||||
|
pty.spawn({ cwd: wtPath, command: 'bash' });
|
||||||
|
const list = await wt.listRepoWorktrees(r.id, true);
|
||||||
|
const target = list.find((w) => resolve(w.path) === resolve(wtPath));
|
||||||
|
expect(target?.sessions).toHaveLength(1);
|
||||||
|
expect(target?.sessions[0]).toMatchObject({ source: 'managed', live: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteWorktree : session live dans le worktree → 409 sans force', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await wt.createWorktree(r.id, { branch: 'busy', newBranch: true, runHooks: false });
|
||||||
|
pty.spawn({ cwd: wtPath, command: 'bash' });
|
||||||
|
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' });
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
// Types REST partagés (préfixe /api/v1) — sous-ensemble P1.
|
// Types REST partagés (préfixe /api/v1).
|
||||||
import type { SessionSummary } from './protocol.js';
|
import type { PostCreateHook, RepoSummary, SessionSummary, WorktreeSummary } from './protocol.js';
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: { code: string; message: string; details?: unknown };
|
error: { code: string; message: string; details?: unknown };
|
||||||
@@ -34,3 +34,59 @@ export interface SessionResponse {
|
|||||||
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
||||||
export type ResumeSessionRequest = Record<string, never>;
|
export type ResumeSessionRequest = Record<string, never>;
|
||||||
export type ForkSessionRequest = Record<string, never>;
|
export type ForkSessionRequest = Record<string, never>;
|
||||||
|
|
||||||
|
// ---- Repos & worktrees (P3) ----
|
||||||
|
export interface ReposListResponse {
|
||||||
|
repos: RepoSummary[];
|
||||||
|
}
|
||||||
|
export interface RepoResponse {
|
||||||
|
repo: RepoSummary;
|
||||||
|
}
|
||||||
|
export interface CreateRepoRequest {
|
||||||
|
path: string;
|
||||||
|
label?: string;
|
||||||
|
postCreateHooks?: PostCreateHook[];
|
||||||
|
preTrust?: boolean;
|
||||||
|
}
|
||||||
|
export interface UpdateRepoRequest {
|
||||||
|
label?: string;
|
||||||
|
postCreateHooks?: PostCreateHook[];
|
||||||
|
preTrust?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreesListResponse {
|
||||||
|
worktrees: WorktreeSummary[];
|
||||||
|
}
|
||||||
|
export interface WorktreeResponse {
|
||||||
|
worktree: WorktreeSummary;
|
||||||
|
}
|
||||||
|
export interface HookRunResult {
|
||||||
|
hookId: string;
|
||||||
|
label: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
output: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
export interface CreateWorktreeRequest {
|
||||||
|
branch: string;
|
||||||
|
/** true : créer la branche (`-b`) ; false : checkout d'une branche existante. */
|
||||||
|
newBranch: boolean;
|
||||||
|
/** point de départ de la nouvelle branche (défaut : HEAD courant du repo). */
|
||||||
|
baseRef?: string;
|
||||||
|
/** chemin cible du worktree (défaut : `<parent>/<repo>-wt-<branch>`). */
|
||||||
|
path?: string;
|
||||||
|
runHooks?: boolean;
|
||||||
|
preTrust?: boolean;
|
||||||
|
/** lancer une session dans le worktree créé (défaut : aucune). */
|
||||||
|
startSession?: 'claude' | 'bash' | null;
|
||||||
|
}
|
||||||
|
export interface CreateWorktreeResponse {
|
||||||
|
worktree: WorktreeSummary;
|
||||||
|
hookResults: HookRunResult[];
|
||||||
|
session: SessionSummary | null;
|
||||||
|
}
|
||||||
|
export interface AdoptWorktreeRequest {
|
||||||
|
path: string;
|
||||||
|
runHooks?: boolean;
|
||||||
|
preTrust?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,51 @@ export interface SessionSummary {
|
|||||||
registryStatus: SessionRegistryStatus | null;
|
registryStatus: SessionRegistryStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Worktrees & repos (P3) ----
|
||||||
|
/** Hook lancé après création d'un worktree (commande shell exécutée dans le nouveau worktree). */
|
||||||
|
export interface PostCreateHook {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
run: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoSummary {
|
||||||
|
id: string;
|
||||||
|
/** chemin absolu de la racine du repo (main worktree). */
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
defaultBranch: string | null;
|
||||||
|
postCreateHooks: PostCreateHook[];
|
||||||
|
/** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */
|
||||||
|
preTrust: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
/** false si le chemin n'est plus un repo git accessible. */
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeGitStatus {
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
dirtyCount: number;
|
||||||
|
upstream: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeSummary {
|
||||||
|
repoId: string;
|
||||||
|
/** chemin absolu du worktree (clé de corrélation avec le cwd des sessions). */
|
||||||
|
path: string;
|
||||||
|
branch: string | null;
|
||||||
|
head: string;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
prunable: boolean;
|
||||||
|
isMain: boolean;
|
||||||
|
git: WorktreeGitStatus;
|
||||||
|
/** sessions corrélées par cwd (managées + découvertes) ; leur `activity` est remplie en P3-B. */
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Messages client → serveur ----
|
// ---- Messages client → serveur ----
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'hello'; protocol: number }
|
| { type: 'hello'; protocol: number }
|
||||||
@@ -102,7 +147,7 @@ export type ClientMessage =
|
|||||||
| { type: 'stdin'; channel: number; data: string }
|
| { type: 'stdin'; channel: number; data: string }
|
||||||
| { type: 'resize'; channel: number; cols: number; rows: number }
|
| { type: 'resize'; channel: number; cols: number; rows: number }
|
||||||
| { type: 'ack'; channel: number; bytes: number }
|
| { type: 'ack'; channel: number; bytes: number }
|
||||||
| { type: 'sub'; topics: Array<'sessions'> }
|
| { type: 'sub'; topics: Array<'sessions' | 'worktrees'> }
|
||||||
| { type: 'ping' };
|
| { type: 'ping' };
|
||||||
|
|
||||||
// ---- Messages serveur → client ----
|
// ---- Messages serveur → client ----
|
||||||
@@ -113,6 +158,10 @@ export type ServerMessage =
|
|||||||
| { type: 'control_changed'; channel: number; controlling: boolean }
|
| { type: 'control_changed'; channel: number; controlling: boolean }
|
||||||
| { type: 'session_update'; session: SessionSummary }
|
| { type: 'session_update'; session: SessionSummary }
|
||||||
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
|
| { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null }
|
||||||
|
| { type: 'repo_update'; repo: RepoSummary }
|
||||||
|
| { type: 'repo_removed'; repoId: string }
|
||||||
|
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
||||||
|
| { type: 'worktree_removed'; repoId: string; path: string }
|
||||||
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
||||||
| { type: 'pong' };
|
| { type: 'pong' };
|
||||||
|
|
||||||
@@ -159,8 +208,8 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
|||||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||||
: null;
|
: null;
|
||||||
case 'sub':
|
case 'sub':
|
||||||
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions')
|
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees')
|
||||||
? { type: 'sub', topics: m.topics as Array<'sessions'> }
|
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees'> }
|
||||||
: null;
|
: null;
|
||||||
case 'ping':
|
case 'ping':
|
||||||
return { type: 'ping' };
|
return { type: 'ping' };
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ describe('parseClientMessage — cas valides', () => {
|
|||||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sub avec topics sessions (et tableau vide accepté)', () => {
|
it('sub avec topics sessions/worktrees (et tableau vide accepté)', () => {
|
||||||
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
|
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
|
||||||
|
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] });
|
||||||
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user