#!/bin/bash
#
# synthesis-engineering git-hook engine (pre-commit) — v2.
#
# Reads policy from ~/.synthesis/git-hook-config.yaml via the Python sidecar
# at _load_config.py. The sidecar classifies the current repo based on its
# push remotes and emits the active pattern regex for this commit's tier set.
#
# Tier 0 (credentials: API keys, private key markers) ALWAYS runs.
# Tier 1 (financial, HR, confidentiality markers, confidential names, etc.)
# runs UNLESS every push remote matches a configured personal-remote pattern.
#
# ── v2: FAIL CLOSED ──────────────────────────────────────────────────────
# v1 evaluated `eval "$(python3 sidecar)"`: a sidecar failure (missing
# interpreter dependency, unparsable config) produced empty output, the
# eval succeeded, and the engine treated "policy engine broken" as
# "nothing to scan" — passing commits UNSCANNED. v2:
#   1. captures the sidecar's exit status explicitly and blocks on nonzero;
#   2. requires the SYNTHESIS_SIDECAR_OK=1 sentinel the v2 sidecar emits as
#      its final line — a partially-emitted var set also blocks;
#   3. treats an empty ACTIVE_REGEX as misconfiguration (Tier 0 is never
#      empty in a valid config) and blocks;
#   4. keeps v1.1's assembled-regex validation (grep -E parse check) so a
#      bad pattern aborts loudly instead of disabling detection.
# The sidecar itself is stdlib-only (no PyYAML), so ANY python3 on PATH
# yields identical policy on every machine and in every environment.
#
# Health check any time:  python3 ~/.synthesis/git-hooks/_load_config.py --doctor
#
# This engine is part of the synthesis-engineering operational layer. The
# distributable form is the `synthesis-git-hooks` skill at
# https://github.com/synthesisengineering/synthesis-skills.
#
# Install: install.sh (in the skill package) sets `core.hooksPath` to
# ~/.synthesis/git-hooks/ and writes an initial config.
#
# Override the config path per-invocation:
#   SYNTHESIS_GIT_HOOK_CONFIG=/path/to/custom.yaml git commit ...
#
# Bypass once (last resort; requires explicit approval per CLAUDE.md):
#   git commit --no-verify
#

set -euo pipefail

CONFIG="${SYNTHESIS_GIT_HOOK_CONFIG:-$HOME/.synthesis/git-hook-config.yaml}"
HOOK_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)"
SIDECAR="$HOOK_DIR/_load_config.py"

fail_closed() {
    cat >&2 <<EOF

==================================================================
  X  COMMIT BLOCKED — synthesis-git-hooks policy engine unavailable
==================================================================

$1

The engine FAILS CLOSED: a commit is never allowed to proceed
unscanned just because the scanner is broken.

Diagnose:   python3 $SIDECAR --doctor
Config:     $CONFIG
Last resort (requires explicit approval per CLAUDE.md):
            git commit --no-verify
EOF
    exit 1
}

[ -f "$CONFIG" ]  || fail_closed "Config not found at $CONFIG. Run the skill's install.sh to set it up."
[ -f "$SIDECAR" ] || fail_closed "Sidecar not found at $SIDECAR."

PYBIN="$(command -v python3 || true)"
[ -n "$PYBIN" ] || fail_closed "No python3 on PATH. Install the Xcode Command Line Tools (xcode-select --install)."

# Run the sidecar, capturing status explicitly — never let eval hide failure.
set +e
SIDECAR_OUT="$(SYNTHESIS_GIT_HOOK_CONFIG="$CONFIG" "$PYBIN" "$SIDECAR" --emit-shell-vars)"
SIDECAR_STATUS=$?
set -e
[ "$SIDECAR_STATUS" -eq 0 ] || fail_closed "Sidecar exited $SIDECAR_STATUS (its message appears above)."

eval "$SIDECAR_OUT"
[ "${SYNTHESIS_SIDECAR_OK:-0}" = "1" ] || fail_closed "Sidecar output incomplete (missing OK sentinel)."
[ -n "${ACTIVE_REGEX:-}" ] || fail_closed "Active pattern set is empty — Tier 0 must never be empty in a valid config."

# Determine which files to scan. Always exclude files that contain pattern
# definitions by design — adding a name to the policy is the opposite of
# leaking it, but the diff still matches.
# Detect exact copies from already-committed files. A canonical-file migration
# (for example CLAUDE.md -> AGENTS.md plus a one-line adapter) must not rescan
# hundreds of unchanged historical lines as newly introduced content. Copy
# targets receive status C and are excluded by AM; genuinely new or modified
# lines remain in scope.
# One unrestricted diff, filtered per file in-process. The previous design
# round-tripped filenames through `echo | xargs`, so a filename containing a
# quote aborted xargs and — via `|| true` — silently emptied the diff,
# passing the ENTIRE commit unscanned. Filenames never touch the shell now.
#
# --diff-filter=AMCR: renames and copies ARE scanned. Under -U0 an exact
# copy or rename contributes zero added lines (the canonical-file migration
# stays exempt), while an edited copy/rename contributes exactly its new
# lines — which is precisely the laundering path that must not pass.
set +e
RAW_DIFF=$(git diff --cached -C --find-copies-harder --diff-filter=AMCR -U0 2>/dev/null)
DIFF_STATUS=$?
set -e
[ "$DIFF_STATUS" -eq 0 ] || fail_closed "git diff exited $DIFF_STATUS while collecting staged changes."

if [ -n "${DIFF_EXCLUDE_REGEX:-}" ]; then
    EXCLUDE_CHECK_ERR=$(echo "" | grep -E "$DIFF_EXCLUDE_REGEX" 2>&1 >/dev/null || true)
    if echo "$EXCLUDE_CHECK_ERR" | grep -qi -E 'grep:|invalid|unrecognized|error'; then
        fail_closed "diff_exclude_paths assembled an unparsable regex:

  $EXCLUDE_CHECK_ERR

An invalid exclusion regex would drop every file from the scan."
    fi
fi

# Drop hunks belonging to excluded paths; keep everything else.
DIFF=$(
    DIFF_EXCLUDE_REGEX="${DIFF_EXCLUDE_REGEX:-}" awk '
        /^\+\+\+ b\// {
            path = substr($0, 7)
            skip = 0
            if (ENVIRON["DIFF_EXCLUDE_REGEX"] != "") {
                cmd = "printf %s " sprintf("%c%s%c", 39, path, 39) \
                      " | grep -qE " sprintf("%c%s%c", 39, ENVIRON["DIFF_EXCLUDE_REGEX"], 39)
                skip = (system(cmd) == 0)
            }
            next
        }
        skip { next }
        { print }
    ' <<< "$RAW_DIFF"
)

MATCHES=""

# Tier 0 first, against the UNFILTERED diff: path exclusions never apply to
# credentials.
if [ -n "$RAW_DIFF" ] && [ -n "${TIER0_REGEX:-}" ]; then
    TIER0_HITS=$(echo "$RAW_DIFF" | grep '^+' | grep -v '^+++' | grep -i -E "$TIER0_REGEX" || true)
    if [ -n "$TIER0_HITS" ]; then
        MATCHES=$(echo "$TIER0_HITS" | head -20)
    fi
fi

if [ -z "$MATCHES" ] && [ -n "$RAW_DIFF" ]; then
    if [ -n "$DIFF" ]; then
        # Stage 1: collect added lines that hit the active regex. Validate the
        # regex against an empty string first — if grep rejects the pattern
        # (PCRE syntax slipped into POSIX ERE, repetition-operator errors,
        # etc.), abort the commit with an explicit diagnostic rather than
        # falling through `|| true` to silent no-detection. A misconfigured
        # hook that silently passes is worse than a hook that loudly fails.
        REGEX_CHECK_ERR=$(echo "" | grep -i -E "$ACTIVE_REGEX" 2>&1 >/dev/null || true)
        if echo "$REGEX_CHECK_ERR" | grep -qi -E 'grep:|invalid|unrecognized|error'; then
            fail_closed "The active regex assembled from $CONFIG cannot be parsed by grep -E:

  $REGEX_CHECK_ERR

This usually means a PCRE-only construct (lookbehind, lookahead,
non-capturing groups) slipped into a POSIX ERE pattern. Rewrite the
pattern in ERE-compatible form."
        fi

        STAGE1=$(echo "$DIFF" | grep '^+' | grep -v '^+++' | grep -i -E "$ACTIVE_REGEX" || true)
        # Stage 2: filter out allowlisted lines (e.g., SPDX license declarations).
        if [ -n "$STAGE1" ] && [ -n "${ALLOWLIST_REGEX:-}" ]; then
            ALLOWLIST_CHECK_ERR=$(echo "" | grep -iE "$ALLOWLIST_REGEX" 2>&1 >/dev/null || true)
            if echo "$ALLOWLIST_CHECK_ERR" | grep -qi -E 'grep:|invalid|unrecognized|error'; then
                fail_closed "allowlist_lines assembled an unparsable regex:

  $ALLOWLIST_CHECK_ERR

An invalid allowlist regex would suppress every detection."
            fi
            STAGE1=$(echo "$STAGE1" | grep -ivE "$ALLOWLIST_REGEX" || true)
        fi
        MATCHES=$(echo "$STAGE1" | head -20)
    fi
fi

if [ -n "$MATCHES" ]; then
    cat <<EOF

==================================================================
  !  SENSITIVE PATTERN DETECTED (synthesis-git-hooks)
==================================================================

Repo class: ${REPO_CLASS:-strict}
Config: $CONFIG

Matched lines:
$MATCHES

Sanitize the change, or — if this is intentional documentation about
the patterns themselves — bypass once:
  git commit --no-verify
EOF
    exit 1
fi

# Commit-message scanning is handled by the sibling `commit-msg` hook in this
# directory (v2.1+), driven by the same sidecar's CHECK_COMMIT_MSG flag.

# Export class for any delegated per-repo hook, then chain.
export SYNTHESIS_REPO_CLASS="$REPO_CLASS"
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || true
if [ -n "$REPO_ROOT" ]; then
    REPO_HOOK="$REPO_ROOT/.githooks/pre-commit"
    if [ -f "$REPO_HOOK" ] && [ -x "$REPO_HOOK" ]; then
        exec "$REPO_HOOK"
    fi
fi

exit 0
