Files
ECC/scripts/codex-git-hooks/pre-push
T
Juan Garibay c6195edb2f fix(hooks): stop probing the pytest override, and stop failing on exit 5
Three defects, found by reviewing this branch against a running pytest rather
than by reading it.

Exit 5 is not a failure. pytest reserves it for NO_TESTS_COLLECTED, and
`|| fail "pytest failed"` collapsed it into a blocked push. The `|| fail`
predates this branch, but this branch is what makes it reachable: a repository
whose pyproject.toml only configures ruff or black, with pytest in its venv and
no test files, used to hit the "pytest is not installed" skip and now gets
gated. $VIRTUAL_ENV is the first candidate, so merely having a venv activated in
the pushing shell drags any requirements.txt repository into this path, and the
hook is installed globally. Reproduced with pytest 9.1.1. Exit 5 is now
non-blocking but loud -- a bad rootdir, testpaths or an unimportable conftest
also collects nothing, and swallowing that silently would reopen the hole this
resolver exists to close. Other non-zero codes now carry the code, because 1
(tests failed) and 4 (usage error) call for different responses.

The ECC_PYTEST_CMD probe ran the operator's command. Validating the override
with `--version` assumed it would answer like pytest. A wrapper that sets an
environment variable and execs pytest ignores the flag and runs the whole suite,
so the probe executed the tests, then rejected the command for not printing a
version, then blocked the push -- with the suite green. That is worse than the
silent gate the probe was added to close, so the override is taken as given
again: it is a deliberate setting, the hook cannot inspect it without running
it, and pointing it at something that is not pytest is the operator's call.
`is_pytest` still guards the PATH candidate, which this script composes itself,
where `pytest --version` is harmless. An empty override still fails closed.

The tests inherited the ambient environment. `runHermeticPythonPrePush` passed
process.env through, so an exported ECC_PYTEST_CMD or an activated virtualenv
resolved a pytest the fixture never created and the venv test failed for anyone
who runs the suite that way. Both variables are now neutralised in the base env.

Coverage: the gate had no test proving it blocks. Changing the run line to
`|| true` left all three previous tests green. Seven now cover a spaced venv
path, a red suite, exit 5, an override invoked exactly once with no probe, an
empty override, and the PATH candidate in both directions.
2026-09-17 16:19:57 -04:00

231 lines
7.8 KiB
Bash
Executable File

#!/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 <branch>).
# The pre-push hook receives lines on stdin: <local ref> <local sha> <remote ref> <remote sha>.
# 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