#!/bin/sh
# Style, type and test checks on the commits being pushed, on the newest Python (.python-version); GitHub Actions
# runs the tests on the oldest one the package supports.
# Turn it on once per clone: git config core.hooksPath .githooks

fix='uv run black . && uv run isort . && uv run ruff check --fix .'

# The same install as CI's "Install locked dependencies".
setup() {
    uv sync --frozen || return
}

checks() {
    check uv run black --check .
    check uv run isort --check-only .
    check uv run ruff check .
    check uv run mypy
    # The coverage gate runs with the JIT off: under it, CPython 3.15 reports a loop's exit branch from the wrong
    # instruction (coveragepy/coveragepy#2249). Callers may run Python with the JIT on, so the suite runs again
    # under it, without coverage.
    check env PYTHON_JIT=0 uv run pytest
    check env PYTHON_JIT=1 uv run pytest --no-cov
}

# --- the same in every repo from here ---

set -u
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
common=$(cd "$(git rev-parse --git-common-dir)" && pwd)
checked="$common/pre-push-checked"
# A commit passes for this version of the hook: changing the checks checks it again.
version=$(cksum <"$0" | cut -d' ' -f1)
# One checkout per clone, kept between pushes so its dependencies and tool caches stay installed.
cache="${XDG_CACHE_HOME:-$HOME/.cache}/pre-push"
dir="$cache/$(basename "$(dirname "$common")")-$(printf %s "$common" | cksum | cut -d' ' -f1)"
lock="$dir.lock"

# Run one check and carry on, so a push shows every failure at once.
check() {
    "$@" || failed=1
}

# Another push in this clone may be using the checkout: wait for it, or take over a lock its push left behind.
take_lock() {
    mkdir -p "$cache"
    until mkdir "$lock" 2>/dev/null; do
        owner=$(cat "$lock/pid" 2>/dev/null)
        if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then
            rm -rf "$lock"
            continue
        fi
        echo "pre-push: waiting for another push's checks to finish" >&2
        sleep 5
    done
    echo $$ >"$lock/pid"
}
trap '[ "$(cat "$lock/pid" 2>/dev/null)" = $$ ] && rm -rf "$lock"' EXIT
trap 'exit 1' INT TERM

# Check out the commit itself, not the working tree: uncommitted files don't go out.
checkout() {
    if [ -f "$dir/.git" ] && git -C "$dir" checkout --quiet --force --detach "$1" 2>/dev/null; then
        git -C "$dir" clean -fdq
        return
    fi
    # First push, or the folder or its registration went missing: start it again.
    rm -rf "$dir"
    git worktree add --quiet --force --detach "$dir" "$1"
}

status=0
while read -r _ sha _ _; do
    # A deleted ref sends all zeros, which isn't a commit.
    commit=$(git rev-parse --verify --quiet "$sha^{commit}") || continue
    # Pushing the same commit to the next remote doesn't check it again.
    grep -qsx "$commit $version" "$checked" && continue

    [ -d "$lock" ] && [ "$(cat "$lock/pid" 2>/dev/null)" = $$ ] || take_lock
    short=$(git rev-parse --short "$commit")
    if ! checkout "$commit"; then
        echo "pre-push: couldn't check out $short in $dir" >&2
        status=1
        continue
    fi
    if ! (cd "$dir" && setup) </dev/null >&2; then
        echo "pre-push: installing the dependencies of $short failed" >&2
        status=1
        continue
    fi
    if (cd "$dir" && failed=0 && checks && exit $failed) </dev/null; then
        echo "$commit $version" >>"$checked"
    else
        echo >&2
        echo "pre-push: checks failed on $short. Style fixes itself with: $fix" >&2
        echo "pre-push: type errors need a code change. Commit the fix and push again (git push --no-verify skips the checks)." >&2
        status=1
    fi
done
exit $status
