import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, dirname, join, resolve } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const packageJson = JSON.parse( readFileSync(join(repoRoot, 'package.json'), 'utf8'), ); test('pre-commit hook fixes staged imports and formatting without swallowing unstaged work', () => { assert.equal(packageJson.scripts.prepare, 'husky'); assert.equal(packageJson.scripts['format:staged'], 'lint-staged'); assert.deepEqual(packageJson['lint-staged'], { '*.{js,mjs,cjs,ts,tsx}': [ 'node scripts/lint-staged-eslint.mjs', 'prettier --write', ], }); assert.equal( readFileSync(join(repoRoot, '.husky', 'pre-commit'), 'utf8'), 'npm run format:staged\n', ); const tempRepo = mkdtempSync(join(tmpdir(), 'genarrative-git-hooks-')); try { git(tempRepo, 'init', '--quiet'); git(tempRepo, 'config', 'user.email', 'git-hooks-test@example.invalid'); git(tempRepo, 'config', 'user.name', 'Git Hooks Test'); writeFileSync( join(tempRepo, '.eslintrc.cjs'), `module.exports = ${JSON.stringify({ root: true, ignorePatterns: ['ignored/**'], parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, plugins: ['simple-import-sort'], rules: { 'simple-import-sort/imports': 'error' }, })};\n`, ); writeFileSync( join(tempRepo, '.prettierrc.json'), JSON.stringify({ singleQuote: true, semi: true, trailingComma: 'all' }), ); symlinkSync( join(repoRoot, 'node_modules'), join(tempRepo, 'node_modules'), 'dir', ); const sourcePath = join(tempRepo, 'sample.ts'); const partialPath = join(tempRepo, 'partial.ts'); const ignoredDir = join(tempRepo, 'ignored'); const ignoredPath = join(ignoredDir, 'legacy.ts'); mkdirSync(ignoredDir); writeFileSync( sourcePath, "import { alpha } from './alpha';\nimport { zebra } from './zebra';\n\nvoid alpha;\nvoid zebra;\n", ); writeFileSync(partialPath, 'const original = 1;\nconst keep = 2;\n'); writeFileSync( ignoredPath, "import { zebra } from './zebra';\nimport { alpha } from './alpha';\nvoid zebra; void alpha;\n", ); git( tempRepo, 'add', 'sample.ts', 'partial.ts', 'ignored/legacy.ts', '.eslintrc.cjs', '.prettierrc.json', ); git( tempRepo, '-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'baseline', ); writeFileSync( sourcePath, "import { zebra } from './zebra';\nimport { alpha } from './alpha';\nconst staged={value:1}\nvoid zebra; void alpha; void staged;\n", ); writeFileSync(partialPath, 'const original={value:1}\nconst keep = 2;\n'); writeFileSync( ignoredPath, "import { zebra } from './zebra';\nimport { alpha } from './alpha';\nvoid zebra; void alpha;\n// staged ignored change\n", ); git(tempRepo, 'add', 'sample.ts', 'partial.ts', 'ignored/legacy.ts'); writeFileSync( partialPath, 'const original={value:1}\nconst keep={unstaged:true}\n', ); const lintStagedConfig = { '*.{js,mjs,cjs,ts,tsx}': [ `node ${JSON.stringify(join(repoRoot, 'scripts', 'lint-staged-eslint.mjs'))}`, 'prettier --write', ], }; const lintStaged = spawnSync( process.execPath, [ join(repoRoot, 'node_modules', 'lint-staged', 'bin', 'lint-staged.js'), '--config', '-', ], { cwd: tempRepo, encoding: 'utf8', env: { ...process.env, PATH: `${join(repoRoot, 'node_modules', '.bin')}${delimiter}${process.env.PATH ?? ''}`, }, input: JSON.stringify(lintStagedConfig), }, ); assert.equal( lintStaged.status, 0, `${lintStaged.stdout ?? ''}${lintStaged.stderr ?? ''}`, ); assert.equal( git(tempRepo, 'show', ':sample.ts'), "import { alpha } from './alpha';\nimport { zebra } from './zebra';\nconst staged = { value: 1 };\nvoid zebra;\nvoid alpha;\nvoid staged;\n", ); assert.equal( git(tempRepo, 'show', ':partial.ts'), 'const original = { value: 1 };\nconst keep = 2;\n', ); assert.equal( readFileSync(partialPath, 'utf8'), 'const original = { value: 1 };\nconst keep={unstaged:true}\n', ); assert.equal( git(tempRepo, 'show', ':ignored/legacy.ts'), "import { zebra } from './zebra';\nimport { alpha } from './alpha';\nvoid zebra;\nvoid alpha;\n// staged ignored change\n", ); } finally { rmSync(tempRepo, { force: true, recursive: true }); } }); test('pre-push runs repository parity only for master updates', () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-pre-push-')); try { const binDir = join(tempDir, 'bin'); mkdirSync(binDir); const npmLog = join(tempDir, 'npm.log'); const fakeNpm = join(binDir, 'npm'); const fakeGit = join(binDir, 'git'); writeFileSync( fakeNpm, `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "${npmLog}"\n`, ); chmodSync(fakeNpm, 0o755); writeFileSync( fakeGit, '#!/usr/bin/env bash\n' + 'if [[ "$1" == "rev-parse" && "$2" == "HEAD" ]]; then\n' + ' printf "%s\\n" "1111111111111111111111111111111111111111"\n' + ' exit 0\n' + 'fi\n' + 'if [[ "$1" == "diff" ]]; then exit 0; fi\n' + 'exit 1\n', ); chmodSync(fakeGit, 0o755); const env = { ...process.env, PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}`, }; const hook = join(repoRoot, 'scripts', 'pre-push-master.sh'); const featurePush = spawnSync('bash', [hook, 'origin', 'example.invalid'], { cwd: repoRoot, encoding: 'utf8', env, input: 'refs/heads/feature 1111111111111111111111111111111111111111 refs/heads/feature 2222222222222222222222222222222222222222\n', }); assert.equal(featurePush.status, 0, featurePush.stderr); assert.equal(readFileOrEmpty(npmLog), ''); const masterPush = spawnSync('bash', [hook, 'origin', 'example.invalid'], { cwd: repoRoot, encoding: 'utf8', env, input: 'refs/heads/master 1111111111111111111111111111111111111111 refs/heads/master 2222222222222222222222222222222222222222\n', }); assert.equal(masterPush.status, 0, masterPush.stderr); assert.equal( readFileSync(npmLog, 'utf8'), 'run check:repository-ci -- 2222222222222222222222222222222222222222 1111111111111111111111111111111111111111\n', ); } finally { rmSync(tempDir, { force: true, recursive: true }); } }); test('Gitea Repository checks and master pre-push share the same repository command', () => { const workflow = readFileSync( join(repoRoot, '.gitea', 'workflows', 'project-ci.yml'), 'utf8', ); const repositoryScript = readFileSync( join(repoRoot, 'scripts', 'check-repository-ci.sh'), 'utf8', ); const prePushHook = readFileSync( join(repoRoot, '.husky', 'pre-push'), 'utf8', ); assert.match(workflow, /run: npm run check:repository-ci/u); assert.equal( (workflow.match(/npm run check:repository-ci/gu) ?? []).length, 1, ); assert.match(repositoryScript, /npm run lint/u); assert.match( repositoryScript, /SPACETIME_SCHEMA_BASE_REF="\$\{base_ref\}" npm run lint/u, ); assert.match(repositoryScript, /npm run build/u); assert.match(repositoryScript, /npm run check:content/u); assert.match(repositoryScript, /git diff --check/u); assert.equal(prePushHook, 'npm run check:pre-push-master -- "$@"\n'); }); function readFileOrEmpty(path) { try { return readFileSync(path, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') { return ''; } throw error; } } function git(cwd, ...args) { return execFileSync('git', args, { cwd, encoding: 'utf8' }); }