- 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.
197 lines
7.5 KiB
TypeScript
197 lines
7.5 KiB
TypeScript
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,
|
|
switchBranch,
|
|
isDirtyWorktreeError,
|
|
branchExists,
|
|
currentBranch,
|
|
listBranches,
|
|
commitAll,
|
|
} 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', mode: 'create' });
|
|
|
|
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('switchBranch : create=true crée une branche, create=false bascule sur une existante', async () => {
|
|
const repo = makeTmpRepo();
|
|
const cur = (): string => execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim();
|
|
|
|
await switchBranch(repo, { branch: 'feature/new', create: true });
|
|
expect(cur()).toBe('feature/new');
|
|
|
|
await switchBranch(repo, { branch: 'main', create: false });
|
|
expect(cur()).toBe('main');
|
|
|
|
// créer une branche déjà existante échoue (git refuse).
|
|
await expect(switchBranch(repo, { branch: 'feature/new', create: true })).rejects.toBeDefined();
|
|
});
|
|
|
|
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', mode: 'create' });
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('branche : existence, liste, courante, commit', () => {
|
|
it('branchExists / currentBranch / listBranches', async () => {
|
|
const repo = makeTmpRepo();
|
|
expect(await currentBranch(repo)).toBe('main');
|
|
expect(await branchExists(repo, 'main')).toEqual({ local: true, remote: false });
|
|
expect(await branchExists(repo, 'nope')).toEqual({ local: false, remote: false });
|
|
|
|
execFileSync('git', ['branch', 'dev'], { cwd: repo });
|
|
const b = await listBranches(repo);
|
|
expect(b.local).toContain('main');
|
|
expect(b.local).toContain('dev');
|
|
expect(b.remote).toEqual([]);
|
|
});
|
|
|
|
it('commitAll : add -A + commit, fait baisser dirtyCount à 0', async () => {
|
|
const repo = makeTmpRepo();
|
|
writeFileSync(join(repo, 'new.txt'), 'hello\n');
|
|
expect((await worktreeStatus(repo)).dirtyCount).toBeGreaterThan(0);
|
|
await commitAll(repo, 'add new.txt');
|
|
expect((await worktreeStatus(repo)).dirtyCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('addWorktree : résolution auto (créer / réutiliser)', () => {
|
|
it('auto crée la branche si absente, la réutilise si présente', async () => {
|
|
const repo = makeTmpRepo();
|
|
// branche absente → création (renvoie "created")
|
|
const wt1 = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
|
dirs.push(wt1);
|
|
expect(await addWorktree(repo, { path: wt1, branch: 'feat', mode: 'auto' })).toBe('created');
|
|
expect((await listWorktrees(repo)).find((w) => resolve(w.path) === resolve(wt1))?.branch).toBe('feat');
|
|
|
|
// la même branche existe désormais ; un AUTRE worktree dessus → checkout (renvoie "reused")
|
|
await removeWorktree(repo, wt1, true); // libère la branche
|
|
const wt2 = join(dirname(repo), `${basename(repo)}-wt-feat2`);
|
|
dirs.push(wt2);
|
|
expect(await addWorktree(repo, { path: wt2, branch: 'feat', mode: 'auto' })).toBe('reused');
|
|
});
|
|
|
|
it('mode create échoue si la branche existe déjà', async () => {
|
|
const repo = makeTmpRepo();
|
|
execFileSync('git', ['branch', 'dup'], { cwd: repo });
|
|
const wt = join(dirname(repo), `${basename(repo)}-wt-dup`);
|
|
dirs.push(wt);
|
|
await expect(addWorktree(repo, { path: wt, branch: 'dup', mode: 'create' })).rejects.toBeDefined();
|
|
});
|
|
});
|