mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
The hook is global and this message blocks a push, so "ECC_PYTEST_CMD is set but names no command" left the operator holding a refusal with no next step. It now says to point the variable at a runner or unset it to fall back to discovery, which is the same advice the no-pytest-found branch already gives from the other direction.
265 lines
10 KiB
Bash
Executable File
265 lines
10 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.\
|
|
Point it at your test runner, or unset it to fall back to discovery."
|
|
return 0
|
|
fi
|
|
local venv
|
|
for venv in "${VIRTUAL_ENV:-}" .venv venv env; do
|
|
if [[ -n "$venv" && -x "$venv/bin/python" ]]; then
|
|
# A virtualenv is never committed -- it is platform-specific binaries, and
|
|
# every Python project gitignores it. One that IS tracked is the repository
|
|
# handing this hook an executable and asking it to run. The hook is installed
|
|
# globally, so cloning a hostile repository and pushing it to your own fork
|
|
# would be enough, and on a machine with no pytest on PATH this arm is the
|
|
# only thing that would run at all. Skipping costs nothing legitimate,
|
|
# because a developer's own venv is untracked -- and it says why rather than
|
|
# going quiet about it.
|
|
if git ls-files --error-unmatch -- "$venv/bin/python" >/dev/null 2>&1; then
|
|
log "Ignoring $venv/bin/python: it is tracked in this repository."
|
|
log " A committed virtualenv is an executable the repository controls, and"
|
|
log " this hook runs on every push in every repository."
|
|
continue
|
|
fi
|
|
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
|