import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { execFileSync } from 'child_process'; import { installHook, uninstallHook } from '../src/commands/frontmatter-install-hook.ts'; function gitInit(dir: string) { execFileSync('git', ['init', '-q', dir]); execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@example.com']); execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']); } describe('frontmatter install-hook (B13)', () => { let tmp: string; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'fm-hook-')); gitInit(tmp); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); test('installHook writes executable .githooks/pre-commit and sets core.hooksPath', () => { const result = installHook(tmp, false); expect(result).toBe('installed'); const hookPath = join(tmp, '.githooks', 'pre-commit'); expect(existsSync(hookPath)).toBe(true); const content = readFileSync(hookPath, 'utf8'); expect(content).toContain('gbrain frontmatter'); expect(content).toContain('git diff --cached'); // installHook's contract is "set core.hooksPath unless it's already set // elsewhere". Test BOTH branches deterministically by reading the local // scope only: clean CI → local should be `.githooks`; developer with a // global core.hooksPath (e.g. dotfiles → ~/.config/git/hooks) → local // should be empty because installHook correctly skipped clobbering. // Reading via `--get` without `--local` falls back to global scope when // local is unset, which made this test environmentally fragile. let globalHooksPath = ''; try { globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim(); } catch { /* unset is the expected clean-env case */ } let localHooksPath = ''; try { localHooksPath = execFileSync('git', ['-C', tmp, 'config', '--local', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim(); } catch { /* unset is fine when global was present */ } if (globalHooksPath) { expect(localHooksPath).toBe(''); } else { expect(localHooksPath).toBe('.githooks'); } }); test('#1840 — generated hook matches .md/.mdx (single-backslash regex, not over-escaped)', () => { installHook(tmp, false); const content = readFileSync(join(tmp, '.githooks', 'pre-commit'), 'utf8'); // The shell must see `grep -E '\.mdx?$'`. Pre-fix it emitted `'\\.mdx?$'` // (literal backslash), so the hook matched nothing and silently no-opped. expect(content).toContain("grep -E '\\.mdx?$'"); expect(content).not.toContain("grep -E '\\\\.mdx?$'"); // Prove the emitted pattern actually selects markdown files. Extract the // exact pattern between the single quotes and run it through ripgrep-free // JS regex parity (POSIX ERE `\.mdx?$` == JS `/\.mdx?$/`). const m = content.match(/grep -E '([^']+)'/); expect(m).not.toBeNull(); const re = new RegExp(m![1]); expect(re.test('notes/thing.md')).toBe(true); expect(re.test('notes/thing.mdx')).toBe(true); expect(re.test('notes/thing.txt')).toBe(false); }); test('installHook refuses to clobber existing hook without --force', () => { const hooksDir = join(tmp, '.githooks'); mkdirSync(hooksDir, { recursive: true }); const hookPath = join(hooksDir, 'pre-commit'); writeFileSync(hookPath, '#!/bin/sh\necho "user hook"'); const result = installHook(tmp, false); expect(result).toBe('skipped_existing'); // Original survives. expect(readFileSync(hookPath, 'utf8')).toContain('user hook'); expect(existsSync(hookPath + '.bak')).toBe(false); }); test('installHook with force overwrites and saves .bak', () => { const hooksDir = join(tmp, '.githooks'); mkdirSync(hooksDir, { recursive: true }); const hookPath = join(hooksDir, 'pre-commit'); writeFileSync(hookPath, '#!/bin/sh\necho "user hook"'); const result = installHook(tmp, true); expect(result).toBe('installed'); expect(existsSync(hookPath + '.bak')).toBe(true); expect(readFileSync(hookPath + '.bak', 'utf8')).toContain('user hook'); expect(readFileSync(hookPath, 'utf8')).toContain('gbrain frontmatter'); }); test('installHook on existing gbrain hook refreshes silently (no .bak)', () => { installHook(tmp, false); const hookPath = join(tmp, '.githooks', 'pre-commit'); expect(existsSync(hookPath + '.bak')).toBe(false); // Re-run; should be 'unchanged' (banner already present). const second = installHook(tmp, false); expect(second).toBe('unchanged'); }); test('uninstallHook removes the gbrain hook and restores .bak when present', () => { const hooksDir = join(tmp, '.githooks'); mkdirSync(hooksDir, { recursive: true }); const hookPath = join(hooksDir, 'pre-commit'); writeFileSync(hookPath, '#!/bin/sh\necho "user hook"'); installHook(tmp, true); expect(existsSync(hookPath + '.bak')).toBe(true); const removed = uninstallHook(tmp); expect(removed).toBe(true); // .bak content restored as the active hook. expect(readFileSync(hookPath, 'utf8')).toContain('user hook'); expect(existsSync(hookPath + '.bak')).toBe(false); }); test('uninstallHook on a non-gbrain hook returns false (does not remove user hook)', () => { const hooksDir = join(tmp, '.githooks'); mkdirSync(hooksDir, { recursive: true }); const hookPath = join(hooksDir, 'pre-commit'); writeFileSync(hookPath, '#!/bin/sh\necho "user hook"'); const removed = uninstallHook(tmp); expect(removed).toBe(false); expect(readFileSync(hookPath, 'utf8')).toContain('user hook'); }); });