import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; import { homedir } 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', () => { assertGuardChildInheritsPoisonedEnvironment(); 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', ], '*.rs': ['node scripts/lint-staged-rustfmt.mjs'], }); assertHookClearsGitEnvironment( 'pre-commit', readFileSync(join(repoRoot, '.husky', 'pre-commit'), 'utf8'), 'npm run format:staged', ); const tempRepo = createTempDirectory('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'), // Windows directory symlinks require an elevated token unless Developer // Mode is enabled; junctions provide the same fixture semantics without // that privilege requirement. process.platform === 'win32' ? 'junction' : '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: { ...isolatedGitEnvironment(), 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', () => { assertGuardChildInheritsPoisonedEnvironment(); const tempDir = createTempDirectory('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: isolatedGitEnvironment(), }, ); }; 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('hook fixtures do not mutate the calling linked worktree or its index', () => { const tempDir = createTempDirectory('genarrative-hook-isolation-'); try { const outerRepo = join(tempDir, 'outer'); const worktree = join(tempDir, 'worktree'); mkdirSync(outerRepo); git(outerRepo, 'init', '--quiet'); git(outerRepo, 'config', 'user.email', 'outer@example.invalid'); git(outerRepo, 'config', 'user.name', 'Outer Repository'); writeFileSync(join(outerRepo, 'sentinel.txt'), 'committed\n'); git(outerRepo, 'add', 'sentinel.txt'); git( outerRepo, '-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'sentinel', ); git( outerRepo, 'worktree', 'add', '--quiet', '-b', 'fixture-caller', worktree, ); writeFileSync(join(worktree, 'sentinel.txt'), 'staged\n'); git(worktree, 'add', 'sentinel.txt'); writeFileSync(join(worktree, 'sentinel.txt'), 'unstaged\n'); const gitDir = git(worktree, 'rev-parse', '--absolute-git-dir').trim(); const indexPath = join(gitDir, 'index'); const before = { refs: git(outerRepo, 'show-ref'), config: readFileSync(join(outerRepo, '.git', 'config'), 'utf8'), index: readFileSync(indexPath), status: git(worktree, 'status', '--porcelain'), }; const result = spawnSync( process.execPath, [ '--test', '--test-reporter=tap', '--test-name-pattern=^pre-(commit|push)', fileURLToPath(import.meta.url), ], { cwd: worktree, encoding: 'utf8', env: { ...isolatedGitEnvironment(), NODE_TEST_CONTEXT: undefined, GENARRATIVE_HOOK_GUARD_CHILD: '1', GIT_DIR: gitDir, GIT_COMMON_DIR: join(outerRepo, '.git'), GIT_WORK_TREE: worktree, GIT_INDEX_FILE: indexPath, GIT_PREFIX: '', GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.worktree', GIT_CONFIG_VALUE_0: worktree, }, }, ); assert.equal( result.status, 0, `${result.stdout ?? ''}${result.stderr ?? ''}`, ); assert.match(result.stdout, /# pass 2\b/u); assert.equal(git(outerRepo, 'show-ref'), before.refs); assert.equal( readFileSync(join(outerRepo, '.git', 'config'), 'utf8'), before.config, ); assert.deepEqual(readFileSync(indexPath), before.index); assert.equal(git(worktree, 'status', '--porcelain'), before.status); assert.equal( readFileSync(join(worktree, 'sentinel.txt'), 'utf8'), 'unstaged\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, /git diff --check/u); assertHookClearsGitEnvironment( 'pre-push', prePushHook, 'npm run check:pre-push-master -- "$@"', ); assert.match( repositoryScript, /^unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR/mu, ); }); 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) { const env = isolatedGitEnvironment(); assertFixtureRepository(cwd, env); return execFileSync( 'git', ['--no-pager', '-c', `core.hooksPath=${nullDevice}`, ...args], { cwd, encoding: 'utf8', env, }, ); } // 夹具命令必须落在夹具自己的仓库:继承的 GIT_DIR/GIT_WORK_TREE 优先级高于 cwd,曾让夹具把 // 身份、core.bare 与 core.worktree 写进调用方的真实仓库。 function assertFixtureRepository(cwd, env) { const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', env, }); if (probe.status !== 0) { // 此时夹具目录还不是仓库(例如首次 git init),命令本身会把它建起来。 return; } const toplevel = realpathSync.native(probe.stdout.trim()); const expected = realpathSync.native(resolve(cwd)); assert.equal( toplevel, expected, `夹具 Git 命令会落到外部仓库:cwd=${expected} toplevel=${toplevel}`, ); } function isolatedGitEnvironment() { // Git hooks export repository/index paths that override cwd, including in // linked worktrees. Fixture Git and lint-staged must never inherit them. return Object.fromEntries( Object.entries(process.env).filter(([name]) => !/^GIT_/iu.test(name)), ); } // 守卫用例把本文件跑在毒化环境里;子进程必须真的继承 GIT_DIR,否则断言会空转。 function assertGuardChildInheritsPoisonedEnvironment() { if (process.env.GENARRATIVE_HOOK_GUARD_CHILD !== '1') { return; } assert.ok(process.env.GIT_DIR, '守卫子进程必须继承 GIT_DIR'); assert.equal( Object.hasOwn(isolatedGitEnvironment(), 'GIT_DIR'), false, '夹具子进程环境必须清除 GIT_DIR', ); } function assertHookClearsGitEnvironment(hookName, contents, command) { const lines = contents.trimEnd().split('\n'); assert.equal( lines.at(-1), command, `${hookName} 最后一行必须保持原有钩子命令`, ); const unsetLine = lines.find((line) => line.startsWith('unset ')); assert.ok(unsetLine, `${hookName} 必须先清除 Git 注入的仓库定位变量`); const cleared = new Set(unsetLine.split(/\s+/u)); for (const variable of [ 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', ]) { assert.ok(cleared.has(variable), `${hookName} 必须清除 ${variable}`); } } const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null'; function createTempDirectory(prefix) { const tempRoot = join(homedir(), 'data', 'tmp'); mkdirSync(tempRoot, { recursive: true }); return mkdtempSync(join(tempRoot, prefix)); }