- addWorktree résout la branche par dépôt (branchExists) : checkout si présente, suivi de origin/<b> si seulement remote, sinon création (-b) depuis la branche par défaut. Corrige le 'fatal: invalid reference' sur les groupes hétérogènes ; newBranch -> mode (déprécié, mappé en route).
- Nouvelles opérations git : commit (add -A), push (upstream auto), promotion « en principal » (la branche du worktree devient le checkout principal, sans merge ni conflit, ancienne branche conservée). Routes /worktrees/{commit,push,promote} auditées + GET /repos/:id/branches.
- Web : actions Commit/Push/Promouvoir sur WorktreeCard, sélecteur de branche de base (datalist) dans RepoSection et GroupSessionModal, agrandissement d'un terminal au choix dans TerminalGrid.
- Court-circuite la session de groupe quand aucun worktree n'a pu être créé (fini le masquage par « no resolvable worktree »).
- Tests : git.ts (branchExists/listBranches/commitAll/auto), worktree-manager (commit/promote/branches) ; acceptance P5 étend le scénario worktree de groupe sur branche neuve + commit + promotion.
312 lines
14 KiB
TypeScript
312 lines
14 KiB
TypeScript
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, basename, dirname, resolve } from 'node:path';
|
|
import type { RepoSummary, 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 gitInit(dir: string): void {
|
|
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');
|
|
}
|
|
|
|
function makeTmpRepo(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-'));
|
|
dirs.push(dir);
|
|
gitInit(dir);
|
|
return dir;
|
|
}
|
|
|
|
/** Crée un vrai repo git à un chemin donné (sous une racine de scan contrôlée). */
|
|
function makeRepoAt(path: string): void {
|
|
mkdirSync(path, { recursive: true });
|
|
gitInit(path);
|
|
}
|
|
|
|
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({ db, 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', mode: 'create', runHooks: true });
|
|
expect(out.action).toBe('created');
|
|
|
|
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', mode: 'create' })).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
const out = await wt.startMainSession(r.id, { command: 'bash' });
|
|
expect(resolve(out.session.cwd)).toBe(resolve(repo));
|
|
expect(out.session).toMatchObject({ command: 'bash', live: true });
|
|
// pas de bascule de branche → toujours sur main.
|
|
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main');
|
|
const main = (await wt.listRepoWorktrees(r.id, true)).find((w) => w.isMain);
|
|
expect(main?.sessions.some((s) => s.id === out.session.id)).toBe(true);
|
|
});
|
|
|
|
it('startMainSession : newBranch crée et bascule la branche dans le checkout principal + event', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
const events: Array<{ worktree: WorktreeSummary }> = [];
|
|
wt.on('worktree_update', (e) => events.push(e));
|
|
const out = await wt.startMainSession(r.id, { command: 'bash', branch: 'feature/x', newBranch: true });
|
|
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('feature/x');
|
|
expect(out.worktree?.branch).toBe('feature/x');
|
|
expect(events.some((e) => e.worktree.isMain && e.worktree.branch === 'feature/x')).toBe(true);
|
|
});
|
|
|
|
it('startMainSession : checkout principal sale → 409 DIRTY_TREE (pas de bascule)', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
writeFileSync(join(repo, 'scratch.txt'), 'wip\n'); // arbre sale
|
|
await expect(wt.startMainSession(r.id, { command: 'bash', branch: 'feature/y', newBranch: true })).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
code: 'DIRTY_TREE',
|
|
});
|
|
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main');
|
|
});
|
|
|
|
it('startMainSession : branche invalide → 400', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
await expect(wt.startMainSession(r.id, { command: 'bash', 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', mode: 'create', 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', mode: 'create', 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', mode: 'create', 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' });
|
|
});
|
|
|
|
it('listRepoBranches : renvoie les branches locales (dont main)', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
const b = await wt.listRepoBranches(r.id);
|
|
expect(b.local).toContain('main');
|
|
});
|
|
|
|
it('commitWorktree : arbre propre → 409 NOTHING_TO_COMMIT ; arbre sale → commit (dirty=0)', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
await expect(wt.commitWorktree(r.id, repo, 'noop')).rejects.toMatchObject({ statusCode: 409, code: 'NOTHING_TO_COMMIT' });
|
|
writeFileSync(join(repo, 'f.txt'), 'x\n');
|
|
const w = await wt.commitWorktree(r.id, repo, 'add f');
|
|
expect(w.git.dirtyCount).toBe(0);
|
|
});
|
|
|
|
it('promoteWorktree : la branche du worktree devient le checkout principal, worktree supprimé, ancienne branche conservée', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-prom`);
|
|
dirs.push(wtPath);
|
|
await wt.createWorktree(r.id, { branch: 'prom', mode: 'create', runHooks: false });
|
|
|
|
await wt.promoteWorktree(r.id, wtPath);
|
|
|
|
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('prom');
|
|
expect((await wt.listRepoWorktrees(r.id, true)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
|
const branches = await wt.listRepoBranches(r.id);
|
|
expect(branches.local).toContain('main'); // ancienne branche principale conservée
|
|
expect(branches.local).toContain('prom');
|
|
});
|
|
|
|
it('promoteWorktree : checkout principal refusé (400 IS_MAIN_WORKTREE)', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
await expect(wt.promoteWorktree(r.id, repo)).rejects.toMatchObject({ statusCode: 400, code: 'IS_MAIN_WORKTREE' });
|
|
});
|
|
|
|
it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
expect(r.hidden).toBe(false);
|
|
const updates: RepoSummary[] = [];
|
|
wt.on('repo_update', (s) => updates.push(s));
|
|
const u = await wt.updateRepo(r.id, { hidden: true });
|
|
expect(u.hidden).toBe(true);
|
|
expect(updates.some((s) => s.id === r.id && s.hidden)).toBe(true);
|
|
});
|
|
|
|
it('listAllWorktrees exclut les repos masqués', async () => {
|
|
const repo = makeTmpRepo();
|
|
const r = await wt.addRepo({ path: repo });
|
|
expect((await wt.listAllWorktrees()).length).toBeGreaterThan(0); // main worktree présent
|
|
await wt.updateRepo(r.id, { hidden: true });
|
|
expect(await wt.listAllWorktrees()).toHaveLength(0);
|
|
});
|
|
|
|
it('discoverRepos : auto-ajoute les nouveaux, idempotent, masqué non ressuscité, supprimé re-découvrable', async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'arb-scan-'));
|
|
dirs.push(root);
|
|
makeRepoAt(join(root, 'a'));
|
|
makeRepoAt(join(root, 'b'));
|
|
|
|
const updates: RepoSummary[] = [];
|
|
wt.on('repo_update', (s) => updates.push(s));
|
|
|
|
const res = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
|
|
expect(res.added).toBe(2);
|
|
expect(res.scanned).toBe(2);
|
|
expect(await wt.listRepos()).toHaveLength(2);
|
|
expect(updates).toHaveLength(2); // un repo_update par nouveau
|
|
|
|
// re-scan : idempotent (aucun ajout, aucune émission)
|
|
updates.length = 0;
|
|
const res2 = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
|
|
expect(res2.added).toBe(0);
|
|
expect(updates).toHaveLength(0);
|
|
expect(await wt.listRepos()).toHaveLength(2);
|
|
|
|
// masquer 'a' puis re-scan : reste masqué, jamais ré-ajouté (invariant central)
|
|
const repoA = (await wt.listRepos()).find((r) => resolve(r.path) === resolve(join(root, 'a')));
|
|
await wt.updateRepo(repoA!.id, { hidden: true });
|
|
updates.length = 0;
|
|
const res3 = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
|
|
expect(res3.added).toBe(0);
|
|
expect((await wt.listRepos()).find((r) => r.id === repoA!.id)?.hidden).toBe(true);
|
|
|
|
// supprimer 'b' puis re-scan : re-découvert (volontaire)
|
|
const repoB = (await wt.listRepos()).find((r) => resolve(r.path) === resolve(join(root, 'b')));
|
|
wt.removeRepo(repoB!.id);
|
|
expect(await wt.listRepos()).toHaveLength(1);
|
|
const res4 = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
|
|
expect(res4.added).toBe(1);
|
|
expect(await wt.listRepos()).toHaveLength(2);
|
|
});
|
|
|
|
it('discoverRepos : repo disparu du disque conservé en DB (valid=false), non re-trouvé', async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'arb-scan-'));
|
|
dirs.push(root);
|
|
makeRepoAt(join(root, 'gone'));
|
|
await wt.discoverRepos({ roots: [root], maxDepth: 2 });
|
|
expect(await wt.listRepos()).toHaveLength(1);
|
|
|
|
rmSync(join(root, 'gone'), { recursive: true, force: true }); // disparaît du disque
|
|
const res = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
|
|
expect(res.added).toBe(0); // plus trouvé par le scan
|
|
const repos = await wt.listRepos();
|
|
expect(repos).toHaveLength(1); // mais la ligne est conservée (pas de suppression auto)
|
|
expect(repos[0].valid).toBe(false);
|
|
// robustesse : un repo dont le chemin a disparu ne fait pas planter /worktrees (git échoue → [])
|
|
await expect(wt.listAllWorktrees()).resolves.toEqual([]);
|
|
});
|
|
});
|