#!/usr/bin/env bash set -euo pipefail set -o errtrace trap 'echo "[pre-push] ERROR at line ${LINENO}: ${BASH_COMMAND}" >&2' ERR ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" echo "[pre-push] repo: $ROOT_DIR" # Pre-commit cache strategy: # - Always use repo-local cache to avoid readonly / locked user-level caches. # - Windows is especially prone to cache lock issues (WinError 5), so we keep # the cache local and aggressively reset it when manifest/permission errors occur. UNAME_S="$(uname -s || true)" case "$UNAME_S" in MINGW*|MSYS*|CYGWIN*) # CRITICAL (Windows): do NOT use the user-level ~/.cache/pre-commit path. # It frequently leaves locked .exe files (WinError 5) and blocks hook cleanup. export PRE_COMMIT_HOME="${PRE_COMMIT_HOME:-$ROOT_DIR/.tmp/pre-commit-win}" mkdir -p "$PRE_COMMIT_HOME" # Keep Black cache local to avoid AppData lock/permission errors. export BLACK_CACHE_DIR="${BLACK_CACHE_DIR:-$ROOT_DIR/.tmp/black-cache}" mkdir -p "$BLACK_CACHE_DIR" ;; *) export PRE_COMMIT_HOME="${PRE_COMMIT_HOME:-$ROOT_DIR/.tmp/pre-commit}" mkdir -p "$PRE_COMMIT_HOME" export BLACK_CACHE_DIR="${BLACK_CACHE_DIR:-$ROOT_DIR/.tmp/black-cache}" mkdir -p "$BLACK_CACHE_DIR" ;; esac require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then echo "[pre-push] ERROR: missing command: $cmd" >&2 exit 1 fi } pip_install_or_fail() { local why="$1" shift if "$VENV_PY" -m pip install "$@"; then return 0 fi echo "[pre-push] ERROR: failed to install dependency ($why): $*" >&2 exit 1 } capture_precommit_snapshots() { # IMPORTANT: compare both worktree and index; pre-commit can mutate staged files while exiting 0. PRECOMMIT_WORKTREE_SNAPSHOT="$(mktemp)" PRECOMMIT_INDEX_SNAPSHOT="$(mktemp)" git diff --binary -- . >"$PRECOMMIT_WORKTREE_SNAPSHOT" git diff --cached --binary -- . >"$PRECOMMIT_INDEX_SNAPSHOT" } cleanup_precommit_snapshots() { rm -f "${PRECOMMIT_WORKTREE_SNAPSHOT:-}" "${PRECOMMIT_INDEX_SNAPSHOT:-}" \ "${PRECOMMIT_WORKTREE_SNAPSHOT_AFTER:-}" "${PRECOMMIT_INDEX_SNAPSHOT_AFTER:-}" } precommit_changed_repo_state() { PRECOMMIT_WORKTREE_SNAPSHOT_AFTER="$(mktemp)" PRECOMMIT_INDEX_SNAPSHOT_AFTER="$(mktemp)" git diff --binary -- . >"$PRECOMMIT_WORKTREE_SNAPSHOT_AFTER" git diff --cached --binary -- . >"$PRECOMMIT_INDEX_SNAPSHOT_AFTER" ! cmp -s "$PRECOMMIT_WORKTREE_SNAPSHOT" "$PRECOMMIT_WORKTREE_SNAPSHOT_AFTER" || \ ! cmp -s "$PRECOMMIT_INDEX_SNAPSHOT" "$PRECOMMIT_INDEX_SNAPSHOT_AFTER" } report_precommit_repo_drift_and_exit() { echo "[pre-push] ERROR: pre-commit hooks modified tracked files (worktree or index)." >&2 echo "[pre-push] Please review/stage the hook fixes, then push again." >&2 git status --short cleanup_precommit_snapshots exit 1 } is_wsl() { grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null } select_venv_dir() { # Explicit override for advanced/local setups. if [ -n "${OPENCLAW_TEST_VENV:-}" ]; then echo "$OPENCLAW_TEST_VENV" return 0 fi case "$UNAME_S" in MINGW*|MSYS*|CYGWIN*) echo "$ROOT_DIR/.venv" ;; *) # IMPORTANT: # In WSL, prefer dedicated Linux venv to avoid mixing with Windows .venv. if is_wsl; then echo "$ROOT_DIR/.venv-wsl" else echo "$ROOT_DIR/.venv" fi ;; esac } resolve_venv_python() { case "$UNAME_S" in MINGW*|MSYS*|CYGWIN*) echo "$VENV_DIR/Scripts/python.exe" ;; *) echo "$VENV_DIR/bin/python" ;; esac } is_venv_python_healthy() { local venv_py="$1" # CRITICAL: on Git Bash/Windows, `test -x` is unreliable for `.exe`. # Use existence + actual interpreter execution probe instead. # DO NOT replace with `-x` checks; that regression previously caused false # "invalid venv" detection and fallback to the wrong interpreter. [ -f "$venv_py" ] || return 1 "$venv_py" -c "import sys; print(sys.executable)" >/dev/null 2>&1 } bootstrap_venv() { local venv_py venv_py="$(resolve_venv_python)" if is_venv_python_healthy "$venv_py"; then echo "$venv_py" return 0 fi if [ -e "$venv_py" ]; then echo "[pre-push] WARN: existing venv is invalid; recreating: $VENV_DIR" >&2 rm -rf "$VENV_DIR" fi echo "[pre-push] INFO: creating project venv at $VENV_DIR ..." >&2 case "$UNAME_S" in MINGW*|MSYS*|CYGWIN*) # CRITICAL: on Git Bash, `python3` may resolve to MSYS `/usr/bin/python`, # which creates a broken Windows venv (`No Python at "/usr/bin\python.exe"`). # Always prefer Windows-native launchers/interpreters. if command -v py.exe >/dev/null 2>&1; then py.exe -3 -m venv "$VENV_DIR" elif [ -x "/c/Windows/py.exe" ]; then /c/Windows/py.exe -3 -m venv "$VENV_DIR" elif command -v python.exe >/dev/null 2>&1; then python.exe -m venv "$VENV_DIR" elif command -v py >/dev/null 2>&1; then py -3 -m venv "$VENV_DIR" else echo "[pre-push] ERROR: no Windows Python launcher found (py.exe/python.exe)." >&2 exit 1 fi ;; *) if command -v python3 >/dev/null 2>&1; then python3 -m venv "$VENV_DIR" elif command -v python >/dev/null 2>&1; then python -m venv "$VENV_DIR" else echo "[pre-push] ERROR: no bootstrap Python found (python3/python)." >&2 exit 1 fi ;; esac if ! is_venv_python_healthy "$venv_py"; then echo "[pre-push] ERROR: failed to initialize project venv: $VENV_DIR" >&2 exit 1 fi echo "$venv_py" } pre_commit_cmd() { "$VENV_PY" -m pre_commit "$@" } # CRITICAL: pre-push must always run pre-commit from project venv. # Do not switch this back to global `pre-commit` command lookup. # This prevents mixed global/user installs from hijacking hook execution. VENV_DIR="$(select_venv_dir)" VENV_PY="$(bootstrap_venv)" if ! "$VENV_PY" -m pre_commit --version >/dev/null 2>&1; then echo "[pre-push] INFO: installing pre-commit into project venv ($VENV_DIR) ..." >&2 pip_install_or_fail "required for pre-commit hooks" -U pip pre-commit fi if ! "$VENV_PY" -c "import black" >/dev/null 2>&1; then # Keep black in the same interpreter used by local black-single hook. echo "[pre-push] INFO: installing black into project venv ($VENV_DIR) ..." >&2 pip_install_or_fail "required by black-single hook" black==24.1.1 fi # IMPORTANT: # Pre-push now runs backend unit tests. Keep minimal runtime deps aligned with # CI unit-test job to avoid "passes locally, fails on GitHub" drift. if ! "$VENV_PY" -c "import numpy, PIL" >/dev/null 2>&1; then echo "[pre-push] INFO: installing numpy/pillow into project venv ($VENV_DIR) ..." >&2 pip_install_or_fail "required by unit tests" numpy pillow fi if ! "$VENV_PY" -c "import aiohttp" >/dev/null 2>&1; then echo "[pre-push] INFO: installing aiohttp into project venv ($VENV_DIR) ..." >&2 pip_install_or_fail "required by unit tests/import paths" aiohttp fi if ! "$VENV_PY" -c "import cryptography" >/dev/null 2>&1; then echo "[pre-push] INFO: installing cryptography into project venv ($VENV_DIR) ..." >&2 pip_install_or_fail "required for S57 secrets-at-rest encryption paths/tests" cryptography fi if ! "$VENV_PY" -c "import defusedxml" >/dev/null 2>&1; then # IMPORTANT: keep local/CI parity with requirements.txt and WeChat ingress tests. echo "[pre-push] INFO: installing defusedxml into project venv ($VENV_DIR) ..." >&2 pip_install_or_fail "required for S85 fail-closed XML parsing paths/tests" defusedxml fi require_cmd npm run_pre_commit_safe() { local tmp_log tmp_log="$(mktemp)" local lower_log lower_log="$(mktemp)" reset_cache() { echo "[pre-push] WARN: resetting pre-commit cache: ${PRE_COMMIT_HOME:-}" >&2 if [ -n "${PRE_COMMIT_HOME:-}" ] && [ -d "$PRE_COMMIT_HOME" ]; then rm -rf "$PRE_COMMIT_HOME" 2>/dev/null || true # On Git for Windows, rm may fail on locked files. Retry via cmd.exe. if [ -d "$PRE_COMMIT_HOME" ] && command -v cygpath >/dev/null 2>&1 && command -v cmd.exe >/dev/null 2>&1; then local pre_commit_home_win pre_commit_home_win="$(cygpath -w "$PRE_COMMIT_HOME")" cmd.exe /c "rmdir /s /q \"$pre_commit_home_win\"" >/dev/null 2>&1 || true fi mkdir -p "$PRE_COMMIT_HOME" fi } if pre_commit_cmd "$@" 2>&1 | tee "$tmp_log"; then rm -f "$tmp_log" rm -f "$lower_log" return 0 fi tr '[:upper:]' '[:lower:]' < "$tmp_log" > "$lower_log" || true if grep -q "invalidmanifesterror" "$lower_log"; then echo "[pre-push] WARN: pre-commit cache manifest is corrupted; running clean + cache reset + single retry." >&2 if ! pre_commit_cmd clean; then echo "[pre-push] WARN: 'pre-commit clean' failed; trying manual cache reset." >&2 reset_cache fi reset_cache pre_commit_cmd "$@" rm -f "$tmp_log" rm -f "$lower_log" return 0 fi if grep -q "permissionerror" "$lower_log" || grep -q "winerror 5" "$lower_log" || grep -q "access is denied" "$lower_log"; then # CRITICAL: treat lock-file errors as cache corruption and self-heal once. # Avoid removing this block; without it, pre-push can hang/fail repeatedly on Windows. echo "[pre-push] WARN: pre-commit cache appears locked by another process; running cache reset + single retry." >&2 reset_cache if [ -n "${BLACK_CACHE_DIR:-}" ] && [ -d "$BLACK_CACHE_DIR" ]; then rm -rf "$BLACK_CACHE_DIR" 2>/dev/null || true mkdir -p "$BLACK_CACHE_DIR" fi pre_commit_cmd "$@" rm -f "$tmp_log" rm -f "$lower_log" return 0 fi rm -f "$tmp_log" rm -f "$lower_log" return 1 } # Ensure Node 18+ for Playwright/E2E. # CI uses Node 20; local baseline is Node 18. if [ -n "${NVM_DIR:-}" ] && [ -s "${NVM_DIR}/nvm.sh" ]; then # shellcheck disable=SC1090 . "${NVM_DIR}/nvm.sh" elif [ -s "${HOME}/.nvm/nvm.sh" ]; then # shellcheck disable=SC1091 . "${HOME}/.nvm/nvm.sh" fi if command -v nvm >/dev/null 2>&1; then if [ -f ".nvmrc" ]; then if ! nvm use >/dev/null 2>&1; then echo "[pre-push] WARN: nvm use (.nvmrc) failed; using current node in PATH." >&2 fi else if ! nvm use 18 >/dev/null 2>&1; then echo "[pre-push] WARN: nvm use 18 failed; using current node in PATH." >&2 fi fi fi require_cmd node NODE_MAJOR="$(node -p "process.versions.node.split('.')[0]")" if [ "$NODE_MAJOR" -lt 18 ]; then echo "[pre-push] ERROR: Node >=18 required, current=$(node -v)" >&2 echo "[pre-push] Hint: install nvm and run 'nvm use 18'." >&2 exit 1 fi can_bind_local_port() { local port="$1" node -e "const net=require('net'); const port=Number(process.argv[1]); const server=net.createServer(); server.unref(); server.once('error', ()=>process.exit(1)); server.listen({host:'127.0.0.1', port, exclusive:true}, ()=>server.close(()=>process.exit(0)));" "$port" >/dev/null 2>&1 } resolve_e2e_port() { if [ -n "${OPENCLAW_E2E_PORT:-}" ]; then if ! can_bind_local_port "$OPENCLAW_E2E_PORT"; then echo "[pre-push] ERROR: OPENCLAW_E2E_PORT=$OPENCLAW_E2E_PORT is not bindable on 127.0.0.1." >&2 echo "[pre-push] Hint: unset OPENCLAW_E2E_PORT or set another port (example: 3300)." >&2 exit 1 fi echo "$OPENCLAW_E2E_PORT" return 0 fi local candidate for candidate in 3000 3300 3400 3500 3600; do if can_bind_local_port "$candidate"; then echo "$candidate" return 0 fi done echo "[pre-push] ERROR: no bindable local port found for Playwright webServer (tried 3000,3300,3400,3500,3600)." >&2 exit 1 } E2E_PORT="$(resolve_e2e_port)" export OPENCLAW_E2E_PORT="$E2E_PORT" if [ "$E2E_PORT" != "3000" ]; then echo "[pre-push] WARN: port 3000 unavailable; using OPENCLAW_E2E_PORT=$E2E_PORT for Playwright." else echo "[pre-push] INFO: using OPENCLAW_E2E_PORT=$E2E_PORT for Playwright." fi echo "[pre-push] Node version: $(node -v)" echo "[pre-push] 0/7 R120 dependency preflight" "$VENV_PY" scripts/preflight_check.py --strict echo "[pre-push] 1/7 detect-secrets" run_pre_commit_safe run detect-secrets --all-files echo "[pre-push] 2/7 pre-commit all hooks (pass 1)" capture_precommit_snapshots if run_pre_commit_safe run --all-files --show-diff-on-failure; then : else echo "[pre-push] INFO: pre-commit reported changes/issues; running pass 2 verification..." >&2 if run_pre_commit_safe run --all-files --show-diff-on-failure; then if ! git diff --quiet -- .; then echo "[pre-push] ERROR: hooks auto-fixed files during pre-push." >&2 echo "[pre-push] Please review, commit the fixes, then push again." >&2 git status --short exit 1 fi echo "[pre-push] WARN: first pass failed but second pass succeeded without local file changes." >&2 else exit 1 fi fi if precommit_changed_repo_state; then report_precommit_repo_drift_and_exit fi cleanup_precommit_snapshots # IMPORTANT: generated spec drift must fail before backend tests so docs-only # edits cannot hide until deep in the pre-push unit suite. "$VENV_PY" scripts/check_openapi_sync.py echo "[pre-push] 3/8 coverage governance check" "$VENV_PY" scripts/verify_quality_governance.py echo "[pre-push] 4/8 backend unit tests" MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_unit" \ "$VENV_PY" scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json if [ -n "${OPENCLAW_IMPL_RECORD_PATH:-}" ]; then echo "[pre-push] 4.5/8 implementation record lint (strict)" "$VENV_PY" scripts/lint_implementation_record.py --path "$OPENCLAW_IMPL_RECORD_PATH" --strict fi echo "[pre-push] 5/8 backend real E2E lanes (R122/R123)" MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_backend_e2e_real" \ "$VENV_PY" scripts/run_unittests.py --module tests.test_r122_real_backend_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0 MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_backend_e2e_real" \ "$VENV_PY" scripts/run_unittests.py --module tests.test_r123_real_backend_model_list_lane --enforce-skip-policy tests/skip_policy.json --max-skipped 0 echo "[pre-push] 6/8 R121 retry partition contract" MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_retry_partition" \ "$VENV_PY" scripts/run_unittests.py --module tests.test_r121_retry_partition_contract --enforce-skip-policy tests/skip_policy.json --max-skipped 0 echo "[pre-push] 7/8 R118 adversarial gate (adaptive: smoke/extended)" MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_adversarial" \ "$VENV_PY" scripts/run_adversarial_gate.py --profile auto --seed 42 --artifact-dir .tmp/adversarial echo "[pre-push] 8/8 npm test (Playwright)" npm test echo "[pre-push] PASS"