import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type GitCommandRunner, buildReleaseMetadata, collectGitInfo, makeRealGitRunner, } from './release-metadata'; describe('buildReleaseMetadata', () => { test('produces a stable shape from injected inputs', () => { const meta = buildReleaseMetadata({ moduleId: 'caddy', version: '1.2.0+3', git: { sha: 'abc1234', branch: 'main', dirty: false }, cliVersion: '0.1.5', message: 'Fix DNS race', publishedAt: new Date('2026-04-25T18:30:00Z'), }); expect(meta).toEqual({ module_id: 'caddy', version: '1.2.0+3', git_sha: 'abc1234', git_branch: 'main', git_dirty: false, published_at: '2026-04-25T18:30:00.000Z', published_by_cli_version: '0.1.5', message: 'Fix DNS race', }); }); test('null message is preserved (no defaulting to empty string)', () => { const meta = buildReleaseMetadata({ moduleId: 'x', version: '1.0.0+1', git: { sha: null, branch: null, dirty: false }, cliVersion: '0.1.5', message: null, publishedAt: new Date('2026-04-25T00:00:00Z'), }); expect(meta.message).toBeNull(); }); test('defaults publishedAt to now when omitted', () => { const before = Date.now(); const meta = buildReleaseMetadata({ moduleId: 'x', version: '1.0.0+1', git: { sha: null, branch: null, dirty: false }, cliVersion: '0.1.5', message: null, }); const stamped = new Date(meta.published_at).getTime(); expect(stamped).toBeGreaterThanOrEqual(before); expect(stamped).toBeLessThanOrEqual(Date.now()); }); }); describe('collectGitInfo', () => { test('returns nulls + clean when not in a git checkout', () => { const run: GitCommandRunner = () => null; expect(collectGitInfo('/tmp/x', run)).toEqual({ sha: null, branch: null, dirty: false, }); }); test('reads sha + branch + clean status', () => { const calls: string[][] = []; const run: GitCommandRunner = (args) => { calls.push(args); if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234'; if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main'; if (args[0] === 'status') return ''; return null; }; const info = collectGitInfo('/tmp/x', run); expect(info).toEqual({ sha: 'abc1234', branch: 'main', dirty: false }); expect(calls).toEqual([ ['rev-parse', 'HEAD'], ['rev-parse', '--abbrev-ref', 'HEAD'], ['status', '--porcelain', '--', '.'], ]); }); test('detached HEAD reports null branch', () => { const run: GitCommandRunner = (args) => { if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234'; if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'HEAD'; if (args[0] === 'status') return ''; return null; }; expect(collectGitInfo('/tmp/x', run).branch).toBeNull(); }); test('non-empty status output → dirty=true', () => { const run: GitCommandRunner = (args) => { if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'abc1234'; if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'main'; if (args[0] === 'status') return ' M src/foo.ts\n?? new-file.txt'; return null; }; expect(collectGitInfo('/tmp/x', run).dirty).toBe(true); }); }); /** * Recurrence gate for #544, against a REAL git repo rather than a fake runner. * * The tests above pin the argv `collectGitInfo` constructs. That is what let the * bug live: they asserted which command was built, never what it concluded, so * they stayed green while the function reported the whole repository's dirt for * every module. A fake `GitCommandRunner` cannot catch this at all — the defect * IS git's real scoping behavior, which a stub by definition does not model. * * So this drives the real `makeRealGitRunner()` against a real checkout. Delete * the `-- .` pathspec and the first test here fails; that is the whole point. */ describe('collectGitInfo scopes dirt to sourceDir (#544, real git)', () => { let repo: string; let moduleDir: string; const git = (args: string[], cwd: string) => execFileSync('git', args, { cwd, encoding: 'utf-8' }); beforeEach(() => { repo = mkdtempSync(join(tmpdir(), 'celilo-gitinfo-')); moduleDir = join(repo, 'modules', 'probe'); mkdirSync(moduleDir, { recursive: true }); writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\n'); writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\n'); git(['init', '-q'], repo); git(['config', 'user.email', 'test@celilo.test'], repo); git(['config', 'user.name', 'Test'], repo); git(['add', '-A'], repo); git(['commit', '-qm', 'init'], repo); }); afterEach(() => rmSync(repo, { recursive: true, force: true })); test('a tracked file rewritten OUTSIDE the module does not make it dirty', () => { // Exactly what broke the release: `bun install` rewrites the tracked root // `bun.lock` between module publishes. The module itself is untouched. writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\nrewritten by bun install\n'); expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false); }); test("a sibling module's dirt does not make this module dirty", () => { const sibling = join(repo, 'modules', 'other'); mkdirSync(sibling, { recursive: true }); writeFileSync(join(sibling, 'manifest.yml'), 'id: other\n'); expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false); }); test("the module's OWN dirt still blocks the publish", () => { // The control. Scoping must not have simply disabled the check — this is // the case the guard exists for, and it must still fire. writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\nversion: 9.9.9\n'); expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true); }); test('an untracked file inside the module still blocks the publish', () => { writeFileSync(join(moduleDir, 'stray.txt'), 'oops\n'); expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true); }); });