#!/bin/sh
#
# tessera pre-commit hook. Install it with `make hook`.
#
# Every check reads the staged content only, and the content checks look at
# added lines, so removing an offending line stays committable.
#
# This file ships in a public repository, so it carries generic rules only.
# Strings that are specific to one deployment (host names, realms, an
# organization name) belong in an untracked local file, read below.
#
# Never bypass this hook with --no-verify: it also guards the checks that a
# reviewer cannot catch by reading a diff.

set -u

# One pattern per line, extended regular expressions, blanks and # comments
# ignored. The default sits among the dot entries the repository already
# ignores, so it is never committed and never shipped. Point the variable
# elsewhere to keep the list with your own notes.
PATTERNS_FILE="${TESSERA_LOCAL_PATTERNS:-.tessera-patterns.local}"
MARKER=".github/.tests-passed"
EM_DASH=$(printf '\342\200\224')

# Files whose content decides whether the test suites still hold. Workflow
# files are deliberately absent: none of them takes part in a local suite, and
# a workflow cannot even run locally, so demanding a full suite for a file that
# suite never reads would teach people to walk around the hook. The CI is its
# own gate on push.
FUNCTIONAL='\.(py|ts|tsx)$|(^|/)(pyproject\.toml|package\.json|package-lock\.json|uv\.lock|pylock\.toml|tox\.ini|Makefile|tsconfig\.json|eslint\.config\.mjs)$'

status=0

refuse() {
    printf 'pre-commit: REFUSED, %s\n' "$1" >&2
    printf '%s\n' "$2" | sed 's/^/  /' >&2
    if [ -n "${3:-}" ]; then
        printf '  fix: %s\n' "$3" >&2
    fi
    status=1
    return 0
}

# Staged files whose ADDED lines match an extended regex. Any argument after
# the pattern restricts the scan to that pathspec.
staged_matching() {
    pattern=$1
    shift
    git diff --cached --name-only --diff-filter=ACM -- "$@" | while IFS= read -r file; do
        if git diff --cached --no-color -U0 -- "$file" |
            grep '^+' | grep -v '^+++' | grep -Eq -- "$pattern"; then
            printf '%s\n' "$file"
        fi
    done
}

staged=$(git diff --cached --name-only --diff-filter=ACM)
if [ -z "$staged" ]; then
    printf 'pre-commit: nothing staged\n'
    exit 0
fi

# --- Em dash ---------------------------------------------------------------
hits=$(staged_matching "$EM_DASH")
if [ -n "$hits" ]; then
    refuse "an em dash was added" "$hits" "use a comma, a colon, or two sentences"
fi

# --- Files that must never be committed ------------------------------------
hits=$(printf '%s\n' "$staged" |
    grep -E '\.(db|sqlite[0-9]*|key|pem)$|(^|/)(\.env|\.tests-passed)$' || true)
if [ -n "$hits" ]; then
    refuse "a store, a key, or a local-only file is staged" "$hits" \
        "git restore --staged the paths above"
fi

# --- Secrets, by shape -----------------------------------------------------
#
# Only shapes that are never anything but a credential. Naming a field is not
# a leak: this project documents client_secret_env and seeds deliberately
# fabricated fixtures, and both have to stay committable. What is specific to
# a deployment is handled by the confidential pattern check below instead.
#
# The matching value is never printed back, only the file it sits in.
hits=$(staged_matching 'BEGIN [A-Z ]*PRIVATE KEY')
if [ -n "$hits" ]; then
    refuse "a private key block is staged" "$hits" "keep keys out of the tree"
fi

hits=$(staged_matching 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}')
if [ -n "$hits" ]; then
    refuse "something shaped like a signed token is staged" "$hits" \
        "drop the value, a length or a redaction is enough"
fi

hits=$(staged_matching 'Bearer[[:space:]]+[A-Za-z0-9._~+/-]{20,}')
if [ -n "$hits" ]; then
    refuse "an Authorization value is staged" "$hits" \
        "drop the value, a length or a redaction is enough"
fi

hits=$(staged_matching '(AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{50,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,})')
if [ -n "$hits" ]; then
    refuse "a provider credential is staged" "$hits" "revoke it, then remove it"
fi

hits=$(staged_matching '(SECRET|_KEY|_TOKEN)=[A-Za-z0-9/+_-]{12,}')
if [ -n "$hits" ]; then
    refuse "a secret is assigned a literal value" "$hits" \
        "read it from the environment or a file instead"
fi

# The service refuses a cleartext client_secret at load time. Catch it here
# too, before it reaches a commit.
hits=$(staged_matching '^\+[[:space:]]*client_secret:[[:space:]]*[^[:space:]#]' '*.yml' '*.yaml')
if [ -n "$hits" ]; then
    refuse "a cleartext client_secret is staged" "$hits" \
        "use client_secret_env or client_secret_file"
fi

# --- Confidential patterns, from an untracked local file -------------------
#
# The patterns are folded into one alternation rather than passed as a pattern
# file: a temporary file is not writable everywhere, and a check that skips
# itself when it cannot write is worse than no check at all. Blank lines and
# comments are dropped (a blank pattern matches every line) and carriage
# returns are stripped so a file saved on Windows still matches.
if [ -f "$PATTERNS_FILE" ]; then
    patterns=$(grep -Ev '^[[:space:]]*(#|$)' "$PATTERNS_FILE" 2>/dev/null |
        tr -d '\r' | tr '\n' '|' | sed 's/|$//')
    if [ -n "$patterns" ]; then
        hits=$(git diff --cached --name-only --diff-filter=ACM | while IFS= read -r file; do
            if git diff --cached --no-color -U0 -- "$file" |
                grep '^+' | grep -v '^+++' | grep -Eiq -- "$patterns"; then
                printf '%s\n' "$file"
            fi
        done)
        if [ -n "$hits" ]; then
            refuse "a local confidential pattern matched" "$hits" \
                "rewrite it generically, this repository is public"
        fi
    else
        printf 'pre-commit: %s holds no pattern, confidential scan skipped\n' "$PATTERNS_FILE"
    fi
else
    printf 'pre-commit: no %s, confidential scan skipped\n' "$PATTERNS_FILE"
fi

# --- Lock files ------------------------------------------------------------
if command -v uv >/dev/null 2>&1; then
    if ! uv lock --check >/dev/null 2>&1; then
        refuse "uv.lock no longer matches pyproject.toml" "uv.lock" \
            "run: uv lock && uv export --format pylock.toml --all-extras -o pylock.toml"
    fi
else
    printf 'pre-commit: uv not found, lock check skipped (the CI job is the authority)\n'
fi

# --- Declared dependencies -------------------------------------------------
#
# The check itself is scripts/check_declared_deps.py, and the CI runs the very
# same file. Only the way it is invoked differs, deliberately.
#
# This hook runs in an environment nobody controls, so it never calls uv at
# all: it resolves an interpreter itself and runs the check with that. Asked
# to run something, uv builds an environment when none is there, silently and
# empty, either inside the repository or at whatever path the variable names;
# the check would then find no distribution and refuse every commit on a fresh
# clone. Holding that off with a condition worked only for as long as nobody
# relaxed the condition. Not calling uv removes the hazard instead of watching
# it, so do not reintroduce a `uv run` here. The CI needs no such care: its
# job builds the environment first, and a check that can skip itself where it
# is authoritative is not a check. Do not unify the two call sites.
#
# Both layouts are accepted, because both are legitimate: the environment
# named by UV_PROJECT_ENVIRONMENT, which is how a synced working tree keeps
# its environment out of the repository, and the .venv uv creates by default.
# Accepting only the first made this guard skip forever for anyone who had not
# adopted that local convention.
project_python() {
    for candidate_base in "${UV_PROJECT_ENVIRONMENT:-}" .venv; do
        [ -n "$candidate_base" ] || continue
        for candidate_python in "$candidate_base/bin/python" \
            "$candidate_base/Scripts/python.exe"; do
            if [ -x "$candidate_python" ]; then
                printf '%s\n' "$candidate_python"
                return 0
            fi
        done
    done
    return 1
}

# An interpreter alone is not enough, and skipping here is not laziness. The
# check resolves an import root to a distribution through the installed
# environment, and that index only tells the truth once the project's own
# dependency graph is installed. In a bare environment every root whose name
# matches its distribution still resolves, so the gap is silent, except for
# `yaml`: it is shipped by pyyaml, so it would be reported as undeclared while
# pyproject.toml declares pyyaml plainly. A refusal that lies is worse than a
# skip, and the documented path goes straight through that state, between
# creating an environment and installing into it.
#
# The TOML reader is probed for the same reason: the check imports tomli below
# 3.11, which only the dev extra declares, and an ImportError would surface as
# a refusal about something else entirely.
USABLE_PROBE='import sys, importlib.metadata as m
m.version("jupyterhub-tessera")
__import__("tomllib" if sys.version_info >= (3, 11) else "tomli")'

# Whole-tree analysis, so it only runs when the staged files could change its
# answer: the sources it reads, or the file declaring the dependencies. That
# list is binding. Should the check ever read another path, add it here too,
# or this filter turns the whole thing silent without saying so.
if printf '%s\n' "$staged" | grep -Eq '^src/tessera/.*\.py$|^pyproject\.toml$'; then
    interpreter=$(project_python || true)
    if [ -z "$interpreter" ]; then
        printf 'pre-commit: no project environment (UV_PROJECT_ENVIRONMENT unset'
        printf ' or pointing nowhere, and no .venv here), declared-dependency'
        printf ' check skipped (the CI job is the authority)\n'
    elif ! "$interpreter" -c "$USABLE_PROBE" >/dev/null 2>&1; then
        printf 'pre-commit: %s cannot answer this check (the project is not' "$interpreter"
        printf ' installed there, or its TOML reader is missing),'
        printf ' declared-dependency check skipped (the CI job is the authority)\n'
    elif ! report=$("$interpreter" scripts/check_declared_deps.py 2>&1); then
        refuse "the declared-dependency check failed" "$report"
    fi
fi

# --- The full suite marker -------------------------------------------------
#
# This hook never runs the suites: it only refuses to believe they passed.
functional=$(printf '%s\n' "$staged" | grep -E "$FUNCTIONAL" || true)
if [ -n "$functional" ]; then
    if [ ! -f "$MARKER" ]; then
        refuse "functional files are staged and the full suite never passed here" \
            "$functional" "run: make green"
    else
        stale=$(printf '%s\n' "$functional" | while IFS= read -r file; do
            [ -f "$file" ] || continue
            if [ -n "$(find "$file" -newer "$MARKER" 2>/dev/null)" ]; then
                printf '%s\n' "$file"
            fi
        done)
        if [ -n "$stale" ]; then
            refuse "these staged files are newer than the full suite marker" \
                "$stale" "run: make green again"
        fi
    fi
fi

if [ "$status" -ne 0 ]; then
    printf 'pre-commit: commit blocked. Fix the above rather than passing --no-verify.\n' >&2
fi

exit "$status"
