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 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(); }); });