diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 0b142295..c1daf209 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -5,6 +5,19 @@ set -e +# Git exports repository-local environment variables to hooks. Clear them so +# child processes can discover or create repositories outside this worktree. +if ! git_local_env_vars="$(git rev-parse --local-env-vars)"; then + echo "Failed to discover repository-local Git environment variables." >&2 + exit 1 +fi + +while IFS= read -r git_var; do + if [[ -n "$git_var" ]]; then + unset "$git_var" + fi +done <<< "$git_local_env_vars" + echo "Running pre-push quality gates..." echo "→ Linting..." diff --git a/test/unit/scripts/pre-push-hook.test.js b/test/unit/scripts/pre-push-hook.test.js new file mode 100644 index 00000000..2b90b9a4 --- /dev/null +++ b/test/unit/scripts/pre-push-hook.test.js @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../'); +const hookPath = path.join(repoRoot, 'scripts/hooks/pre-push'); + +describe('pre-push hook', () => { + it('clears repository-local Git variables before invoking quality gates', () => { + const fixture = createFixture({ gitExitCode: 0 }); + + try { + const result = runHook(fixture); + + expect(result.status).toBe(0); + expect(readFileSync(fixture.logPath, 'utf8').trim().split('\n')).toEqual([ + 'unset|unset|run lint', + 'unset|unset|test', + ]); + } finally { + fixture.cleanup(); + } + }); + + it('fails closed before quality gates when Git variable discovery fails', () => { + const fixture = createFixture({ gitExitCode: 42 }); + + try { + const result = runHook(fixture); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Failed to discover repository-local Git environment variables.'); + expect(existsSync(fixture.logPath)).toBe(false); + } finally { + fixture.cleanup(); + } + }); +}); + +function createFixture({ gitExitCode }) { + const root = mkdtempSync(path.join(os.tmpdir(), 'git-cas-pre-push-')); + const binPath = path.join(root, 'bin'); + const logPath = path.join(root, 'pnpm.log'); + const gitPath = path.join(binPath, 'git'); + const pnpmPath = path.join(binPath, 'pnpm'); + + writeExecutable( + gitPath, + `#!/usr/bin/env bash\nif [[ ${gitExitCode} -ne 0 ]]; then exit ${gitExitCode}; fi\nprintf 'GIT_DIR\\nGIT_WORK_TREE\\n'\n`, + ); + writeExecutable( + pnpmPath, + `#!/usr/bin/env bash\nprintf '%s|%s|%s\\n' "\${GIT_DIR-unset}" "\${GIT_WORK_TREE-unset}" "$*" >> "$HOOK_LOG"\n`, + ); + + return { + binPath, + logPath, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +function runHook(fixture) { + return spawnSync('bash', [hookPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${fixture.binPath}:${process.env.PATH}`, + GIT_DIR: '/contaminating/repository', + GIT_WORK_TREE: '/contaminating/worktree', + HOOK_LOG: fixture.logPath, + }, + }); +} + +function writeExecutable(filePath, source) { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, source); + chmodSync(filePath, 0o755); +}