mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
`[[ -n "${ECC_PYTEST_CMD:-}" ]]` asked whether the variable had a value, not
whether it was set, so `ECC_PYTEST_CMD=` fell through to virtualenv discovery
while `ECC_PYTEST_CMD=" "` failed the push. Two spellings of the same mistake,
two behaviours. Falling through is the wrong one: an override that evaluated to
nothing -- a command substitution that found no pytest, say -- then silently ran
a different runner than the operator named, which is exactly the substitution
this resolver refuses to make anywhere else. Both now fail closed.
`${ECC_PYTEST_CMD+set}` rather than `[[ -v ECC_PYTEST_CMD ]]`, because `-v` is
bash 4.2 and a stock macOS /bin/bash is 3.2, where it is not a false but a
syntax error. The hook runs under whatever `env bash` resolves to.
The override is still not probed -- probing runs the operator's command, and a
wrapper that ignores `--version` executes the whole suite and is then rejected
for not printing a version. What the gate can honestly do about a stale override
is refuse to be quiet about it, so a push that uses one now says so, every time,
and says the hook has not checked that it is pytest. A bypass that announces
itself is not the silent gate this resolver exists to prevent.
The fixture env is built from nothing instead of inheriting process.env with two
keys blanked. Blanking is no longer neutral: a blanked ECC_PYTEST_CMD is now an
override, and every one of these tests would have taken that branch.
250 lines
9.1 KiB
Bash
Executable File
250 lines
9.1 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() {
|
|
# `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is
|
|
# still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave
|
|
# alike, where the first used to fall through to discovery and the second failed
|
|
# the push. Falling through is the wrong half of that pair -- an override that
|
|
# evaluated empty (a command substitution that found nothing, say) would silently
|
|
# run a different runner than the operator asked for, which is the substitution
|
|
# this resolver refuses to make anywhere else.
|
|
#
|
|
# Not `[[ -v ECC_PYTEST_CMD ]]`: that is bash 4.2, and a stock macOS `/bin/bash`
|
|
# is 3.2, where it is a syntax error rather than a false. This hook ships to
|
|
# whatever `env bash` finds.
|
|
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; 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 names no command"
|
|
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"
|
|
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
|
|
# resolve_pytest deliberately does not verify the override is pytest, because
|
|
# probing it can run the operator's suite. What this gate can honestly do
|
|
# about a stale override is refuse to be quiet about it: a bypass announced
|
|
# on every push is not the silent gate this resolver exists to prevent.
|
|
log " via ECC_PYTEST_CMD -- the hook runs what you pointed it at, and does"
|
|
log " not check that it is pytest. Unset it to gate on the real suite."
|
|
fi
|
|
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
|