import { describe, expect, test } from 'bun:test'; import { execSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { classifyModulePath } from '../../module/packaging/package-rules'; import { checkGitHygiene, checkModuleStale } from './git-hygiene'; interface TempRepo { dir: string; cleanup: () => void; exec: (cmd: string) => string; } /** * Spin up a fresh git repo in a temp dir. Returns helpers for committing * files and a cleanup function. Tests use this to construct exact git * histories without polluting the surrounding working tree. */ function makeTempRepo(): TempRepo { const dir = mkdtempSync(join(tmpdir(), 'celilo-git-hygiene-')); // Bound every git child. A git call that hangs used to run until bun's // per-test timeout killed it, and bun's dangling-process kill left the // WHOLE run parked (reproduced for celilo#1282; the park itself is // celilo#1246). Erroring the test instead keeps the failure visible and // the run exitable. 30s per command stays under the 60s per-test ceiling // this suite runs with. const exec = (cmd: string): string => execSync(cmd, { cwd: dir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000, }); exec('git init -q'); exec('git config user.email "test@example.com"'); exec('git config user.name "Test"'); exec('git config commit.gpgsign false'); return { dir, exec, cleanup: () => rmSync(dir, { recursive: true, force: true }), }; } describe('checkModuleStale', () => { test('null when manifest.yml is the most recently committed file', () => { const repo = makeTempRepo(); try { writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n'); repo.exec('git add install.sh && git commit -q -m "initial src"'); writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); repo.exec('git add manifest.yml && git commit -q -m "manifest"'); expect(checkModuleStale(repo.dir)).toBeNull(); } finally { repo.cleanup(); } }); test('returns issue when src has commits past last manifest commit', () => { const repo = makeTempRepo(); try { writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); repo.exec('git add manifest.yml && git commit -q -m "manifest"'); writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\necho hi\n'); repo.exec('git add install.sh && git commit -q -m "src change"'); const issue = checkModuleStale(repo.dir); expect(issue).not.toBeNull(); expect(issue?.lastSrcCommit).toMatch(/^[0-9a-f]{40}$/); expect(issue?.lastManifestCommit).toMatch(/^[0-9a-f]{40}$/); expect(issue?.lastSrcCommit).not.toBe(issue?.lastManifestCommit); } finally { repo.cleanup(); } }); test('null when same commit touched both src and manifest', () => { const repo = makeTempRepo(); try { writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n'); repo.exec('git add . && git commit -q -m "both"'); expect(checkModuleStale(repo.dir)).toBeNull(); } finally { repo.cleanup(); } }); test('null when not in a git repo', () => { const dir = mkdtempSync(join(tmpdir(), 'celilo-git-hygiene-bare-')); try { writeFileSync(join(dir, 'manifest.yml'), 'id: x\n'); expect(checkModuleStale(dir)).toBeNull(); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); describe('staleness reach vs the packager (celilo#1270)', () => { // Paths planted to span the classifications classifyModulePath produces. // The expected staleness outcome is NOT hand-written per path: each test // asks classifyModulePath what the packager does with the path and holds // the staleness gate to the same answer. const planted = [ 'e2e/split-horizon-views.test.ts', 'e2e/fixtures/cases.json', 'scripts/install.sh', 'scripts/tsconfig.json', 'ansible/playbook.yml', 'terraform/main.tf', 'schema/tables.sql', 'hooks/run.test.ts', 'generated/report.json', 'state/last-run.json', ]; /** Repo with only manifest.yml committed; returns `commit(rel)` helper. */ function repoWithManifest() { const repo = makeTempRepo(); writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); repo.exec('git add manifest.yml && git commit -q -m manifest'); const commit = (rel: string) => { const abs = join(repo.dir, rel); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, 'planted\n'); repo.exec(`git add ${rel} && git commit -q -m ${JSON.stringify(rel)}`); }; return { repo, commit }; } test( 'a commit touching only e2e/ does not mark the manifest stale', () => { const { repo, commit } = repoWithManifest(); try { commit('e2e/split-horizon-views.test.ts'); expect(checkModuleStale(repo.dir)).toBeNull(); } finally { repo.cleanup(); } }, { timeout: 30_000 }, ); test( 'a commit touching scripts/, ansible/, terraform/ or schema/ still fires', () => { for (const rel of [ 'scripts/install.sh', 'ansible/playbook.yml', 'terraform/main.tf', 'schema/tables.sql', ]) { const { repo, commit } = repoWithManifest(); try { commit(rel); expect(classifyModulePath(rel)).toBe('package'); expect(checkModuleStale(repo.dir)).not.toBeNull(); } finally { repo.cleanup(); } } }, { timeout: 30_000 }, ); test( 'reach parity: the gate fires exactly for paths the packager ships', async () => { for (const rel of planted) { const { repo, commit } = repoWithManifest(); try { commit(rel); const stale = checkModuleStale(repo.dir); if (classifyModulePath(rel) === 'package') { expect(stale).not.toBeNull(); } else { expect(stale).toBeNull(); } } finally { repo.cleanup(); } } }, { timeout: 30_000 }, ); test( 'non-shipped commits between manifest and a shipped change stay quiet until shipped bytes move', () => { const { repo, commit } = repoWithManifest(); try { commit('e2e/split-horizon-views.test.ts'); commit('scripts/tsconfig.json'); expect(checkModuleStale(repo.dir)).toBeNull(); commit('scripts/install.sh'); expect(checkModuleStale(repo.dir)).not.toBeNull(); } finally { repo.cleanup(); } }, { timeout: 30_000 }, ); }); describe('checkGitHygiene', () => { test('all ok on a fresh, clean repo with manifest as latest commit', () => { const repo = makeTempRepo(); try { writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n'); repo.exec('git add . && git commit -q -m "src"'); writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); repo.exec('git add . && git commit -q -m "manifest"'); const checks = checkGitHygiene(repo.dir); expect(checks.every((c) => c.status === 'ok')).toBe(true); } finally { repo.cleanup(); } }); test('fail on stale-version drift, with the same actionable message publish uses', () => { const repo = makeTempRepo(); try { writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); repo.exec('git add . && git commit -q -m "manifest"'); writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n'); repo.exec('git add . && git commit -q -m "src after manifest"'); const checks = checkGitHygiene(repo.dir); const stale = checks.find((c) => c.name === 'stale-version drift'); expect(stale?.status).toBe('fail'); expect(stale?.message).toContain('manifest.yml'); expect(stale?.message).toContain('+N'); } finally { repo.cleanup(); } }); test('skips the stale gate for version_source: changeset (apps order by +N)', () => { const repo = makeTempRepo(); try { // Same stale shape that fails above — src committed after manifest. writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); repo.exec('git add . && git commit -q -m "manifest"'); writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n'); repo.exec('git add . && git commit -q -m "src after manifest"'); const stale = checkGitHygiene(repo.dir, 'changeset').find( (c) => c.name === 'stale-version drift', ); expect(stale?.status).toBe('ok'); // gate N/A, not a fail expect(stale?.message).toContain('changeset'); } finally { repo.cleanup(); } }); test('skips the stale gate for version_source: pin', () => { const repo = makeTempRepo(); try { writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); repo.exec('git add . && git commit -q -m "manifest"'); writeFileSync(join(repo.dir, 'install.sh'), '#!/bin/sh\n'); repo.exec('git add . && git commit -q -m "src after manifest"'); const stale = checkGitHygiene(repo.dir, 'pin').find((c) => c.name === 'stale-version drift'); expect(stale?.status).toBe('ok'); expect(stale?.message).toContain('pin'); } finally { repo.cleanup(); } }); test('warn on dirty working tree', () => { const repo = makeTempRepo(); try { writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n'); repo.exec('git add . && git commit -q -m "manifest"'); // Edit something without committing — dirty tree. writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.1\n'); const checks = checkGitHygiene(repo.dir); const tree = checks.find((c) => c.name === 'working tree'); expect(tree?.status).toBe('warn'); expect(tree?.message).toContain('uncommitted'); } finally { repo.cleanup(); } }); });