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:
2026-06-12 18:31:06 +02:00
parent fb175bc7c5
commit acd920ebcd
16 changed files with 1208 additions and 10 deletions

View File

@@ -10,8 +10,11 @@ import type { Db } from './db/index.js';
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
import { PtyManager } from './core/pty-manager.js';
import { DiscoveryService } from './core/discovery-service.js';
import { WorktreeManager } from './core/worktree-manager.js';
import { registerAuthRoutes } from './routes/auth.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';
declare module 'fastify' {
@@ -28,6 +31,7 @@ export interface AppBundle {
auth: AuthService;
manager: PtyManager;
discovery: DiscoveryService;
worktrees: WorktreeManager;
}
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,
sessionsDir: config.claudeSessionsDir,
});
const worktrees = new WorktreeManager(db, manager, discovery);
void app.register(fastifyCookie);
void app.register(fastifyWebsocket, {
@@ -80,10 +85,12 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerAuthRoutes(app, auth, limiter, serverVersion);
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
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
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)
@@ -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 };
}

View 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;
}
}

View 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);
}

View 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 });
}
});
}
}

View File

@@ -36,6 +36,21 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
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;

View 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 });
});
}

View 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);
}
});
}

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();
});

View 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 sil 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);
});
});

View 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);
});
});

View 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