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, commitLog, commitDiff, isValidCommitish, parseLogZ, } 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 l’index', 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(); }); }); describe('P14 · historique (commitLog / commitDiff)', () => { it('parseLogZ : découpe par paquets de champs et tolère un sujet multi-lignes', () => { const stdout = ['h1', 's1', 'auteur', '2026-01-01T00:00:00Z', 'sujet\navec saut', 'h2', 's2', 'a2', 'd2', 'sujet 2'].join('\0'); const recs = parseLogZ(stdout, 5); expect(recs).toHaveLength(2); expect(recs[0]?.[4]).toBe('sujet\navec saut'); expect(recs[1]?.[0]).toBe('h2'); }); it('parseLogZ : ignore un enregistrement final vide (NUL de fin)', () => { expect(parseLogZ(['h', 's', 'a', 'd', 'sub', ''].join('\0'), 5)).toHaveLength(1); }); it('isValidCommitish : hex 4-64 uniquement (refuse une option déguisée)', () => { expect(isValidCommitish('abc1234')).toBe(true); expect(isValidCommitish('ABCDEF12')).toBe(true); expect(isValidCommitish('abc')).toBe(false); expect(isValidCommitish('--upload-pack=x')).toBe(false); expect(isValidCommitish('HEAD')).toBe(false); expect(isValidCommitish('main..HEAD')).toBe(false); }); it('commitLog : ordre récent → ancien, champs remplis, sans upstream tout est local', async () => { const repo = makeTmpRepo(); appendFileSync(join(repo, 'README.md'), 'second\n'); await commitAll(repo, 'deuxième commit'); const { commits, hasUpstream, unpushedCount } = await commitLog(repo); expect(commits).toHaveLength(2); expect(commits[0]?.subject).toBe('deuxième commit'); expect(commits[1]?.subject).toBe('init'); expect(commits[0]?.hash).toMatch(/^[0-9a-f]{40}$/); expect(commits[0]?.shortHash.length).toBeGreaterThanOrEqual(7); expect(commits[0]?.author).toBe('Test'); expect(Number.isNaN(Date.parse(commits[0]?.date ?? ''))).toBe(false); // branche locale sans remote : aucun commit n'est publié expect(hasUpstream).toBe(false); expect(unpushedCount).toBe(0); }); it('commitLog : limit et skip bornent la fenêtre', async () => { const repo = makeTmpRepo(); for (const n of [1, 2, 3]) { appendFileSync(join(repo, 'README.md'), `line ${n}\n`); await commitAll(repo, `commit ${n}`); } expect((await commitLog(repo, { limit: 2 })).commits.map((c) => c.subject)).toEqual(['commit 3', 'commit 2']); expect((await commitLog(repo, { limit: 1, skip: 2 })).commits.map((c) => c.subject)).toEqual(['commit 1']); }); it('commitLog : dépôt sans aucun commit → liste vide, pas d’exception', async () => { const dir = mkdtempSync(join(tmpdir(), 'arb-git-empty-')); dirs.push(dir); execFileSync('git', ['init', '-b', 'main'], { cwd: dir, stdio: 'pipe' }); const res = await commitLog(dir); expect(res.commits).toEqual([]); expect(res.hasUpstream).toBe(false); }); it('commitDiff : diff unifié du commit demandé, hash invalide et inconnu rejetés', async () => { const repo = makeTmpRepo(); writeFileSync(join(repo, 'nouveau.txt'), 'contenu\n'); await commitAll(repo, 'ajout fichier'); const [head] = (await commitLog(repo)).commits; const d = await commitDiff(repo, head?.hash ?? ''); expect(d.binary).toBe(false); expect(d.tooLarge).toBe(false); expect(d.diff).toContain('nouveau.txt'); expect(d.diff).toContain('+contenu'); await expect(commitDiff(repo, 'HEAD')).rejects.toThrow(/Invalid commit hash/); await expect(commitDiff(repo, 'deadbeef')).rejects.toThrow(/Unknown commit/); }); });