Files
ECC/scripts/codex-git-hooks/pre-push
T
Juan Garibay fc6fe5e5df fix(hooks): pre-push skipped every Python project that uses a virtualenv
The Python block gates on `command -v pytest`, so it only runs when pytest is
on PATH. Installing a project's tools into a virtualenv is the norm rather
than the exception, so in practice the hook printed

    [ECC pre-push] Python project detected but pytest is not installed. Skipping.

while standing in a directory with `.venv/bin/pytest` in it, and pushed.

The failure mode is worse than not having the hook. A skip line reads like a
pass: the push succeeds, the output looks healthy, and nothing indicates the
gate declined to gate. A repository can sit behind it for months believing
its tests run on every push. Found on a project with 893 tests, none of which
the hook had ever executed.

`resolve_pytest` now looks, in order, at `ECC_PYTEST_CMD`, `$VIRTUAL_ENV`,
`.venv`, `venv`, `env`, `uv run` when a `uv.lock` is present, `poetry run`
when a `poetry.lock` is, and finally PATH. Each candidate is confirmed by
importing pytest rather than by the path existing, so a half-built venv falls
through to the next one instead of failing the push.

Two deliberate choices:

The log line names the command it resolved -- `Running: .venv/bin/python -m
pytest -q` -- so which interpreter ran is visible in the push output rather
than inferred. When nothing resolves, the message says where it looked and
names `ECC_PYTEST_CMD`, instead of asserting pytest is not installed when it
may well be.

`uv run` passes `--no-sync` so the hook cannot mutate the developer's
environment on its way to running the tests.

Behaviour change worth flagging for the release note: on any Python project
with a working virtualenv this hook now actually runs the suite, and will
block a push whose tests fail. That is the intent, but it is new behaviour
for every such repository, and `ECC_SKIP_PREPUSH=1` remains the escape.

Verified on two real repositories: a uv/venv Python project (resolves
`.venv/bin/python -m pytest`, 893 tests, exits 0; exits 1 when the suite
fails) and a Node project (unchanged, still runs lint/typecheck/test/build).
2026-09-17 15:06:49 -04:00

182 lines
5.0 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.
#
# 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.
#
# Echoes the command it will run, so the reason for a skip is always visible.
resolve_pytest() {
if [[ -n "${ECC_PYTEST_CMD:-}" ]]; then
echo "$ECC_PYTEST_CMD"
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
echo "$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
echo "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
echo "poetry run pytest"
return 0
fi
fi
if command -v pytest >/dev/null 2>&1; then
echo "pytest"
return 0
fi
return 1
}
if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then
if pytest_cmd="$(resolve_pytest)"; then
ran_any_check=1
log "Python project detected. Running: $pytest_cmd -q"
# Unquoted on purpose: the resolver returns a command with arguments.
# shellcheck disable=SC2086
$pytest_cmd -q || fail "pytest failed"
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