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

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