mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
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.
231 lines
7.8 KiB
Bash
Executable File
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
|