Files
arboretum/packages/server/test/git.test.ts
Johan LEROY 65ef616867
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
2026-07-17 16:44:00 +02:00

321 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
isSafeRelativePath,
isRepo,
listWorktrees,
worktreeStatus,
addWorktree,
removeWorktree,
pruneWorktrees,
switchBranch,
isDirtyWorktreeError,
branchExists,
currentBranch,
listBranches,
commitAll,
parsePorcelainV2,
parseNumstatZ,
listChanges,
fileDiff,
stageFiles,
unstageFiles,
restoreFiles,
cleanFiles,
commitStaged,
amendCommit,
lastCommit,
isUnpushed,
} from '../src/core/git.js';
import { appendFileSync } from 'node:fs';
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('P7 · parseurs purs (status v2 / numstat)', () => {
it('parsePorcelainV2 : modifié, untracked, renommé', () => {
const z =
'1 .M N... 100644 100644 100644 h1 h2 file.txt\x00' +
'1 A. N... 000000 100644 100644 0000 h3 added.txt\x00' +
'? new.txt\x00' +
'2 R. N... 100644 100644 100644 h4 h5 R100 newname.txt\x00oldname.txt\x00';
const e = parsePorcelainV2(z);
expect(e).toHaveLength(4);
expect(e[0]).toMatchObject({ path: 'file.txt', indexStatus: '.', worktreeStatus: 'M', staged: false, unstaged: true });
expect(e[1]).toMatchObject({ path: 'added.txt', indexStatus: 'A', staged: true, unstaged: false });
expect(e[2]).toMatchObject({ path: 'new.txt', untracked: true, unstaged: true, staged: false });
expect(e[3]).toMatchObject({ path: 'newname.txt', renamedFrom: 'oldname.txt', staged: true });
});
it('parseNumstatZ : normal, binaire, renommé', () => {
const z = '5\t2\tfile.txt\x00-\t-\timg.png\x003\t1\t\x00old.txt\x00new.txt\x00';
const m = parseNumstatZ(z);
expect(m.get('file.txt')).toEqual({ insertions: 5, deletions: 2, binary: false });
expect(m.get('img.png')).toEqual({ insertions: null, deletions: null, binary: true });
expect(m.get('new.txt')).toEqual({ insertions: 3, deletions: 1, binary: false });
});
it('isSafeRelativePath', () => {
expect(isSafeRelativePath('src/a.ts')).toBe(true);
expect(isSafeRelativePath('/abs')).toBe(false);
expect(isSafeRelativePath('../escape')).toBe(false);
expect(isSafeRelativePath('-flag')).toBe(false);
expect(isSafeRelativePath('.git/config')).toBe(false);
expect(isSafeRelativePath('')).toBe(false);
});
});
describe('P7 · changes / diff / staging / commit sélectif (repo tmp)', () => {
it('listChanges : untracked + modifié non indexé + indexé', async () => {
const repo = makeTmpRepo();
writeFileSync(join(repo, 'untracked.txt'), 'x\n');
appendFileSync(join(repo, 'README.md'), 'more\n');
writeFileSync(join(repo, 'staged.txt'), 'y\n');
await stageFiles(repo, ['staged.txt']);
const { changes } = await listChanges(repo);
const byPath = Object.fromEntries(changes.map((c) => [c.path, c]));
expect(byPath['untracked.txt']).toMatchObject({ untracked: true, staged: false });
expect(byPath['README.md']).toMatchObject({ unstaged: true, staged: false });
expect(byPath['staged.txt']).toMatchObject({ staged: true });
expect(byPath['staged.txt'].insertions).toBeGreaterThan(0);
});
it('fileDiff : fichier suivi modifié, refus binaire implicite, untracked via --no-index', async () => {
const repo = makeTmpRepo();
appendFileSync(join(repo, 'README.md'), 'a new line\n');
const d = await fileDiff(repo, 'README.md');
expect(d.binary).toBe(false);
expect(d.diff).toContain('+a new line');
writeFileSync(join(repo, 'fresh.txt'), 'brand new\n');
const u = await fileDiff(repo, 'fresh.txt', { untracked: true });
expect(u.diff).toContain('+brand new');
});
it('stage / unstage / restore / clean', async () => {
const repo = makeTmpRepo();
appendFileSync(join(repo, 'README.md'), 'edit\n');
await stageFiles(repo, ['README.md']);
expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')?.staged).toBe(true);
await unstageFiles(repo, ['README.md']);
expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')?.staged).toBe(false);
await restoreFiles(repo, ['README.md']);
expect((await listChanges(repo)).changes.find((c) => c.path === 'README.md')).toBeUndefined();
writeFileSync(join(repo, 'junk.txt'), 'junk\n');
await cleanFiles(repo, ['junk.txt']);
expect((await listChanges(repo)).changes.some((c) => c.path === 'junk.txt')).toBe(false);
});
it('commitStaged : ne commite QUE lindex', async () => {
const repo = makeTmpRepo();
writeFileSync(join(repo, 'a.txt'), 'a\n');
writeFileSync(join(repo, 'b.txt'), 'b\n');
await stageFiles(repo, ['a.txt']);
await commitStaged(repo, 'add a only');
const { changes } = await listChanges(repo);
// a.txt committé (disparu), b.txt toujours non suivi.
expect(changes.some((c) => c.path === 'a.txt')).toBe(false);
expect(changes.some((c) => c.path === 'b.txt' && c.untracked)).toBe(true);
expect((await lastCommit(repo))?.subject).toBe('add a only');
});
it('amendCommit + isUnpushed : HEAD local non poussé', async () => {
const repo = makeTmpRepo();
expect(await isUnpushed(repo)).toBe(true);
await amendCommit(repo, 'init (amended)');
expect((await lastCommit(repo))?.subject).toBe('init (amended)');
});
it('worktreeStatus : compteurs détaillés + dernier commit', async () => {
const repo = makeTmpRepo();
writeFileSync(join(repo, 'staged.txt'), 's\n');
await stageFiles(repo, ['staged.txt']);
appendFileSync(join(repo, 'README.md'), 'unstaged\n');
const st = await worktreeStatus(repo);
expect(st.stagedCount).toBeGreaterThanOrEqual(1);
expect(st.unstagedCount).toBeGreaterThanOrEqual(1);
expect(st.conflictCount).toBe(0);
expect(st.lastCommitSubject).toBe('init');
expect(st.lastCommitHash).toBeTruthy();
});
});
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();
});
});