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