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').replaceAll('\r\n', '\n'), '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 npmLog = join(tempDir, 'repo', 'npm.log'); const tempRepo = join(tempDir, 'repo'); mkdirSync(tempRepo); 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, 'tracked.txt'), 'baseline\n'); git(tempRepo, 'add', 'tracked.txt'); git( tempRepo, '-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'baseline', ); const localSha = git(tempRepo, 'rev-parse', 'HEAD').trim(); writeFileSync( join(tempRepo, 'git'), `#!/usr/bin/env bash if [[ "$1" == 'rev-parse' && "$2" == 'HEAD' ]]; then printf '%s\\n' '${localSha}' exit 0 fi if [[ "$1" == 'diff' ]]; then exit 0; fi exit 1 `, ); chmodSync(join(tempRepo, 'git'), 0o755); writeFileSync( join(tempRepo, 'pre-push-master.sh'), readFileSync(join(repoRoot, 'scripts', 'pre-push-master.sh'), 'utf8'), ); writeFileSync( join(tempRepo, 'npm'), '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> npm.log\n', ); chmodSync(join(tempRepo, 'npm'), 0o755); const spawnHook = (input) => { writeFileSync(join(tempRepo, 'push.input'), input); return spawnSync( 'bash', [ '-c', 'PATH="$PWD:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; export PATH; source ./pre-push-master.sh origin example.invalid < push.input', ], { cwd: tempRepo, encoding: 'utf8', env: process.env, }, ); }; const featurePush = spawnHook( 'refs/heads/feature 1111111111111111111111111111111111111111 refs/heads/feature 2222222222222222222222222222222222222222\n', ); assert.equal(featurePush.status, 0, featurePush.stderr); assert.equal(readFileOrEmpty(npmLog), ''); const masterPush = spawnHook( `refs/heads/master ${localSha} refs/heads/master 2222222222222222222222222222222222222222\n`, ); assert.equal(masterPush.status, 0, masterPush.stderr); assert.equal( readFileSync(npmLog, 'utf8'), `run check:repository-ci -- 2222222222222222222222222222222222222222 ${localSha}\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 lint[\s\S]*npm run test -- apps\/ai-game-creator-shell\/tests\/appSurface\.test\.ts[\s\S]*npm run build/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 toBashPath(path) { const windowsDrive = /^([A-Za-z]):[\\/](.*)$/u.exec(path); if (windowsDrive) { return `/mnt/${windowsDrive[1].toLowerCase()}/${windowsDrive[2].replaceAll('\\', '/')}`; } return path; } function git(cwd, ...args) { return execFileSync('git', args, { cwd, encoding: 'utf8' }); }