#!/usr/bin/env bash
# Regression tests for bin/_git-guard-shim.sh (installed as bin/git) — the PATH shim
# that gives codex (`cdx exec`) and cursor-agent the same main-checkout protection
# Claude Code gets from hooks/main-checkout-guard.sh. Neither runtime reads Claude's
# PreToolUse hooks; both resolve `git` via PATH, which is how this is enforced for
# every runtime at one choke point instead of duplicating the check per-runtime.
#
# The shim only acts when AGENT_BUILD_SCOPE_ACTIVE=1 (set unconditionally by
# _agent-build-scope for every claude/codex/cursor-agent launch) — a human typing
# `git restore` in their own shell never has it set, so manual use is untouched.
#
# The "real" git in these tests logs every invocation it receives to GIT_CALL_LOG
# before delegating to the actual system git, so a test can assert whether the
# command that was TYPED actually reached git (allowed) or never did (blocked).
# Run: bash git-guard-shim.test.sh (exit 0 = all pass).
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GIT_SHIM="$ROOT/bin/git"
PASS=0; FAIL=0
ok()  { PASS=$((PASS+1)); printf 'PASS %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n     %s\n' "$1" "$2"; }

TMP=$(mktemp -d "${TMPDIR:-/tmp}/ggs-test-XXXX")
trap 'rm -rf "$TMP"' EXIT

# Resolved with a PATH that excludes the shim dirs: a bare `command -v git` in an
# agent shell returns ~/.claude/bin/git (the shim), and a stub that execs a shim
# makes the shim under test fork between the two copies without bound.
REALGIT="$(PATH=/usr/bin:/bin command -v git)"
if [[ -z "$REALGIT" ]]; then
  echo "no system git in /usr/bin:/bin — cannot build test fixture" >&2
  exit 1
fi
mkdir -p "$TMP/realbin"
cat > "$TMP/realbin/git" <<EOF
#!/usr/bin/env bash
echo "CALL \$*" >> "\$GIT_CALL_LOG"
exec "$REALGIT" "\$@"
EOF
chmod +x "$TMP/realbin/git"

REPO="$TMP/repo"
mkdir -p "$REPO"
git -C "$REPO" init -q
git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t; git -C "$REPO" config commit.gpgsign false
echo x > "$REPO/f"; git -C "$REPO" add -A; git -C "$REPO" commit -qm init
WT="$REPO/.worktrees/wt1"; mkdir -p "$WT"

LOG="$TMP/calls.log"

# run <dir> <agent_active:0|1> <git-args...> -> exit code; sets $LAST_LOG (last
# logged invocation) and $LAST_ERR (stderr) as globals.
run() {
  local dir="$1" active="$2"; shift 2
  : > "$LOG"
  if [[ "$active" == 1 ]]; then
    ( cd "$dir" && env PATH="$TMP/realbin:/usr/bin:/bin" GIT_CALL_LOG="$LOG" AGENT_BUILD_SCOPE_ACTIVE=1 bash "$GIT_SHIM" "$@" )
  else
    ( cd "$dir" && env PATH="$TMP/realbin:/usr/bin:/bin" GIT_CALL_LOG="$LOG" bash "$GIT_SHIM" "$@" )
  fi >"$TMP/out" 2>"$TMP/err"
  local rc=$?
  LAST_ERR="$(cat "$TMP/err")"
  LAST_LOG="$(tail -1 "$LOG" 2>/dev/null || true)"
  return $rc
}

# A. Destructive verb inside the main checkout, agent-scoped: BLOCKED — the real
#    git binary is never invoked with the destructive command (only the shim's own
#    internal rev-parse classification call is logged).
run "$REPO" 1 restore .
rc=$?
if [[ $rc -eq 77 && "$LAST_LOG" != *"CALL restore ."* && "$LAST_ERR" == *"git-guard"* ]]; then
  ok "main-checkout restore blocked (agent-scoped)"
else
  bad "main-checkout restore blocked" "rc=$rc log=$LAST_LOG err=$LAST_ERR"
fi

# B. Same command, NOT agent-scoped (simulates a human typing it by hand): ALLOWED.
run "$REPO" 0 status
[[ $? -eq 0 && "$LAST_LOG" == "CALL status" ]] \
  && ok "human-invoked (no AGENT_BUILD_SCOPE_ACTIVE) passes through" \
  || bad "human-invoked passthrough" "rc=$? log=$LAST_LOG"

# C. Destructive verb inside a WORKTREE, agent-scoped: ALLOWED (git-guard only
#    protects the main checkout; per-worktree ownership is worktree-lock-gate's job).
run "$WT" 1 reset --hard
[[ $? -eq 0 && "$LAST_LOG" == "CALL reset --hard" ]] \
  && ok "destructive verb in worktree allowed" \
  || bad "destructive in worktree allowed" "rc=$? log=$LAST_LOG"

# D. Read-only git inside the main checkout, agent-scoped: ALLOWED.
run "$REPO" 1 status
[[ $? -eq 0 && "$LAST_LOG" == "CALL status" ]] \
  && ok "read-only git in main checkout allowed" \
  || bad "read-only allowed" "rc=$? log=$LAST_LOG"

# E. `-C <main_root>` from inside a worktree still resolves to the main checkout —
#    an agent cannot dodge the guard by redirecting via -C instead of `cd`.
run "$WT" 1 -C "$REPO" restore .
rc=$?
[[ $rc -eq 77 && "$LAST_ERR" == *"git-guard"* ]] \
  && ok "-C redirect into main checkout blocked" \
  || bad "-C redirect blocked" "rc=$rc err=$LAST_ERR"

# F. A repo that never opted into worktree isolation (no .worktrees dir) is untouched.
REPO2="$TMP/repo2"; mkdir -p "$REPO2"; git -C "$REPO2" init -q
git -C "$REPO2" config user.email t@t; git -C "$REPO2" config user.name t; git -C "$REPO2" config commit.gpgsign false
echo x > "$REPO2/f"; git -C "$REPO2" add -A; git -C "$REPO2" commit -qm init
run "$REPO2" 1 restore .
[[ $? -eq 0 && "$LAST_LOG" == "CALL restore ." ]] \
  && ok "non-opted-in repo left alone" \
  || bad "non-opted-in repo left alone" "rc=$? log=$LAST_LOG"

# G. Static half — safe anywhere: the resolver identifies a shim by marker, and a
#    resolution that finds no real binary has a 127 path to take.
grep -qF 'GIT_GUARD_SHIM_MARKER' "$GIT_SHIM" && grep -qF 'no real binary' "$GIT_SHIM" \
  && ok "resolver rejects shim candidates by marker" \
  || bad "resolver rejects shim candidates by marker" "marker or 127 path missing from $GIT_SHIM"

# H. Behavioral half — the pre-fix shim forks without bound here, so it runs ONLY in the
#    disposable dangerlab guest. See "Projects/0 DOCS/dangerlab.md":
#      ssh debian1 '/home/user/dangerlab/dangerlab-run --payload <dir> --timeout 300 \
#                     -- bash claude/tests/git-guard-shim.test.sh'
if [[ "$(hostname 2>/dev/null)" != dangerlab ]]; then
  printf 'SKIP shim-only PATH fork regression (dangerlab only — never on a workstation)\n'
else
  mkdir -p "$TMP/shimA" "$TMP/shimB" "$TMP/nogit"
  cp "$GIT_SHIM" "$TMP/shimA/git"; cp "$GIT_SHIM" "$TMP/shimB/git"
  chmod +x "$TMP/shimA/git" "$TMP/shimB/git"
  for _d in /usr/bin /bin; do
    [[ -d "$_d" ]] || continue
    for _f in "$_d"/*; do
      _b="${_f##*/}"
      [[ "$_b" == git || "$_b" == git-* ]] && continue
      [[ -e "$TMP/nogit/$_b" ]] || ln -s "$_f" "$TMP/nogit/$_b"
    done
  done
  NPROC_CAP=$(( $(ps -u "$(id -u)" --no-headers 2>/dev/null | wc -l) + 150 ))
  (
    ulimit -u "$NPROC_CAP" 2>/dev/null
    cd "$REPO" && exec timeout 20 env PATH="$TMP/shimA:$TMP/shimB:$TMP/nogit" \
      GIT_CALL_LOG="$LOG" AGENT_BUILD_SCOPE_ACTIVE=1 bash "$TMP/shimA/git" status
  ) >"$TMP/out" 2>"$TMP/err"
  rc=$?
  [[ $rc -eq 127 && "$(cat "$TMP/err")" == *"no real binary"* ]] \
    && ok "shim-only PATH exits 127 (no fork ping-pong between shim copies)" \
    || bad "shim-only PATH exits 127" "rc=$rc err=$(cat "$TMP/err")"
fi

echo
echo "PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]]
