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:
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user