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).
This commit is contained in:
2026-06-12 18:31:06 +02:00
parent 92c48f0978
commit 08320e166e
16 changed files with 1208 additions and 10 deletions

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