#!/usr/bin/env bash set -euo pipefail # ECC Codex Git Hook: pre-push # Runs a lightweight verification flow before pushes. if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PREPUSH:-0}" == "1" ]]; then exit 0 fi if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then exit 0 fi if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0 fi # Skip checks for branch deletion pushes (e.g., git push origin --delete ). # The pre-push hook receives lines on stdin: . # For deletions, the local sha is the zero OID. is_delete_only=true while read -r _local_ref local_sha _remote_ref _remote_sha; do if [[ "$local_sha" != "0000000000000000000000000000000000000000" ]]; then is_delete_only=false break fi done if [[ "$is_delete_only" == "true" ]]; then exit 0 fi ran_any_check=0 log() { printf '[ECC pre-push] %s\n' "$*" } fail() { printf '[ECC pre-push] FAILED: %s\n' "$*" >&2 exit 1 } detect_pm() { if [[ -f "pnpm-lock.yaml" ]]; then echo "pnpm" elif [[ -f "bun.lockb" ]]; then echo "bun" elif [[ -f "yarn.lock" ]]; then echo "yarn" elif [[ -f "package-lock.json" ]]; then echo "npm" else echo "npm" fi } has_node_script() { local script_name="$1" node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); process.exit(p.scripts && p.scripts[process.argv[1]] ? 0 : 1)' "$script_name" >/dev/null 2>&1 } run_pnpm() { if command -v corepack >/dev/null 2>&1; then # Corepack may download the pinned pnpm version on a cache miss. Set # COREPACK_ENABLE_NETWORK=0 to make an offline cache miss fail immediately. corepack pnpm "$@" elif command -v pnpm >/dev/null 2>&1; then pnpm "$@" else fail "pnpm could not be resolved from PATH or Corepack" fi } run_node_script() { local pm="$1" local script_name="$2" case "$pm" in pnpm) run_pnpm run "$script_name" ;; bun) bun run "$script_name" ;; yarn) yarn "$script_name" ;; npm) npm run "$script_name" ;; *) npm run "$script_name" ;; esac } if [[ -f "package.json" ]]; then pm="$(detect_pm)" log "Node project detected (package manager: $pm)" for script_name in lint typecheck test build; do if has_node_script "$script_name"; then ran_any_check=1 log "Running: $script_name" run_node_script "$pm" "$script_name" || fail "$script_name failed" else log "Skipping missing script: $script_name" fi done if [[ "${ECC_PREPUSH_AUDIT:-0}" == "1" ]]; then ran_any_check=1 log "Running dependency audit (ECC_PREPUSH_AUDIT=1)" case "$pm" in pnpm) run_pnpm audit --prod || fail "pnpm audit failed" ;; bun) bun audit || fail "bun audit failed" ;; yarn) yarn npm audit --recursive || fail "yarn audit failed" ;; npm) npm audit --omit=dev || fail "npm audit failed" ;; *) npm audit --omit=dev || fail "npm audit failed" ;; esac fi fi if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then ran_any_check=1 log "Go project detected. Running: go test ./..." go test ./... || fail "go test failed" fi # Resolve how this project runs pytest, into PYTEST_CMD as an argv array. # # Looking only for `pytest` on PATH meant the hook skipped every project that keeps # its tools in a virtualenv -- which is most of them -- and reported "pytest is not # installed" while sitting next to a .venv with pytest in it. A gate that silently # declines to gate is worse than no gate, because the skip line reads like a pass. # # An array rather than one string, because a virtualenv path may contain spaces: # a scalar command splits `/home/me/my env/bin/python` into two paths that do not # exist, and the hook then rejects the push for a reason that has nothing to do # with the code being pushed. # # Echoes the command it will run, so the reason for a skip is always visible. PYTEST_CMD=() # Does this command actually run pytest? Accepting `--version` is not evidence -- # plenty of programs take it and exit 0 -- so the output has to name pytest. The # version is captured rather than piped: under `set -o pipefail` a `| grep -q` can # report the SIGPIPE of the program it just matched. # # Only ever called on a command this script composed itself. Probing an arbitrary # operator-supplied command is not safe: a wrapper that ignores `--version` and # execs pytest runs the entire suite during the probe, and is then rejected for # not having printed a version. is_pytest() { local version version="$("$@" --version 2>&1)" || return 1 grep -qiE 'pytest[[:space:]]+(version[[:space:]]+)?[0-9]' <<<"$version" } resolve_pytest() { if [[ -n "${ECC_PYTEST_CMD:-}" ]]; then # Taken as given. This is a deliberate override, and the hook cannot inspect it # without running it -- a wrapper script may ignore `--version` and run the # suite, so probing costs a duplicate test run and then blocks the push anyway. # Pointing this at something that is not pytest turns the gate off, and that is # the operator's call to make, not a misconfiguration for the hook to second # guess. Word-split, so the command names something on PATH or an interpreter # whose path has no spaces; a venv with spaces is found by the loop below. read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true [[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but empty" return 0 fi local venv for venv in "${VIRTUAL_ENV:-}" .venv venv env; do if [[ -n "$venv" && -x "$venv/bin/python" ]]; then if "$venv/bin/python" -c "import pytest" >/dev/null 2>&1; then PYTEST_CMD=("$venv/bin/python" -m pytest) return 0 fi fi done if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then if uv run --no-sync python -c "import pytest" >/dev/null 2>&1; then PYTEST_CMD=(uv run --no-sync pytest) return 0 fi fi if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then if poetry run python -c "import pytest" >/dev/null 2>&1; then PYTEST_CMD=(poetry run pytest) return 0 fi fi # `command -v` proves only that a file of that name exists on PATH. This one the # script composed itself, so confirming it costs a harmless `pytest --version`. if command -v pytest >/dev/null 2>&1 && is_pytest pytest; then PYTEST_CMD=(pytest) return 0 fi PYTEST_CMD=() return 1 } if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then if resolve_pytest; then ran_any_check=1 log "Python project detected. Running: ${PYTEST_CMD[*]} -q" pytest_status=0 "${PYTEST_CMD[@]}" -q || pytest_status=$? case "$pytest_status" in 0) ;; # pytest reserves 5 for NO_TESTS_COLLECTED, which is not a red suite. A # pyproject.toml that only configures ruff or black is still a Python project # by this hook's test, and blocking those pushes would make the gate something # people switch off. Never silent, though: a bad rootdir, testpaths or a # conftest that fails to import also collects nothing, and swallowing that is # the same skip-reads-like-a-pass hole this resolver exists to close. 5) log "pytest collected no tests (exit 5). Not gating this push." log " If this repository is supposed to have tests, that is the bug:" log " check rootdir, testpaths, and conftest.py import errors." ;; # The code is in the message because 1 (tests failed) and 4 (usage error) # need different responses, and "pytest failed" alone cannot tell them apart. *) fail "pytest failed (exit $pytest_status)" ;; esac else log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv," log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it." fi fi if [[ "$ran_any_check" -eq 0 ]]; then log "No supported checks found in this repository. Skipping." else log "Verification checks passed." fi exit 0