#!/usr/bin/env bash
# Frozen copy of the gate script on main before the work tree target
# support. Only the differential test in setup_hooks_gate_tests.rs uses it,
# as the baseline that the new gate must never audit less than. Do not edit.
set -euo pipefail

# Generated by fallow setup-hooks.
# Installer version: @@FALLOW_INSTALLER_VERSION@@
# Requires bash and jq. On Windows run via git-bash or WSL.
# Blocks Claude Code and Codex git commit and git push when fallow audit
# returns verdict fail. Both harnesses send the PreToolUse payload on stdin and
# treat exit 2 as a block.
# Runtime errors fail open with a single stderr notice so skips stay visible.
# Set FALLOW_GATE_DEBUG=1 to also log when a command is skipped because it is
# not classified as a git commit or push.
#
# Version floor (FALLOW_GATE_MIN_VERSION, default 2.85.0). The gate passes
# --gate-marker agent (added in v2.85.0) so Impact can record containment;
# older binaries reject the flag entirely, which would make every audit fail
# open. The floor also covers the older uncommitted-changes inclusion fix
# (aabb8e1b, v2.46.0). Set the env var to the empty string to disable.
# Floor comparison uses `sort -V`; GNU and BSD agree on plain semver but
# diverge on prereleases (BSD sorts `2.48.0-alpha.1` ABOVE `2.48.0`, GNU below).
# If you set a prerelease floor explicitly, verify the behavior on the target OS.

if ! command -v jq >/dev/null 2>&1; then
  echo "fallow-gate: jq not on PATH, skipping audit." >&2
  exit 0
fi

INPUT="$(cat)"
CMD="$(jq -r '.tool_input.command // empty' <<<"$INPUT")"

# Tokenize instead of matching one regex so git-level options between `git`
# and the subcommand (git -c k=v commit, git -C dir push, git --no-pager
# commit, git --git-dir=/x push) still route into the audit, while subcommand
# lookalikes in arguments (git log commit-message.txt) do not. See issue #2106.
is_git_write_command() {
  local cmd="$1" segment
  # Control operators separate simple commands; each becomes its own line.
  while IFS= read -r segment; do
    # Intentional word splitting; globbing is disabled below.
    # shellcheck disable=SC2086
    set -- $segment
    while [ "$#" -gt 0 ]; do
      if [ "$1" != "git" ]; then
        shift
        continue
      fi
      shift
      while [ "$#" -gt 0 ]; do
        case "$1" in
          commit | push)
            return 0
            ;;
          -c | -C | --git-dir | --work-tree | --namespace | --config-env | --super-prefix | --exec-path | --list-cmds | --attr-source)
            # Global option whose value arrives as the next word.
            shift
            [ "$#" -gt 0 ] && shift
            ;;
          -*)
            # Value-less global option (--no-pager) or inline-value form
            # (--git-dir=/x, -cuser.name=x).
            shift
            ;;
          *)
            # A different subcommand; resume scanning for a later `git` word.
            break
            ;;
        esac
      done
    done
  done < <(printf '%s\n' "$cmd" | tr ';|&()' '\n\n\n\n\n')
  return 1
}

set -f
if is_git_write_command "$CMD"; then
  GIT_WRITE=1
else
  GIT_WRITE=0
fi
set +f
if [ "$GIT_WRITE" -eq 0 ]; then
  if [ -n "${FALLOW_GATE_DEBUG:-}" ]; then
    echo "fallow-gate: not a git commit/push, skipping audit." >&2
  fi
  exit 0
fi

# The hook process can start in a directory that is not the session directory.
# For example, a session in a nested git worktree can get a hook process in the
# main checkout. The audit must run against the tree of the session. The hook
# input gives the session directory in its `cwd` field. The audit root is:
#   1. The nearest directory at or above the session directory that holds this
#      script at the same relative location (.claude/hooks/fallow-gate.sh or
#      .codex/hooks/fallow-gate.sh). The walk stops at the first .git entry,
#      the same rule as the generated handler. The install root can be below
#      the git root (`--root packages/app`), so this step comes first. The walk
#      ignores $HOME, because a script there is the user-scope install.
#   2. Else, when the session directory is in a git work tree: the directory of
#      the hook process when it is in that work tree, as before. A package
#      directory with its own fallow config thus stays the audit root. When the
#      process directory is in another work tree (outside it, or a nested
#      worktree or submodule below it), the audit root is the git top level. A git top level equal to $HOME does
#      not count, so a home directory under git never becomes the audit root.
#   3. Else the session directory.
# All paths are physical (`pwd -P`), so a symlink cannot move the walk into the
# parents of the link. When `cwd` is missing, empty or not a directory, the
# audit runs in the directory of the process, as before.
physical_dir() {
  CDPATH='' cd "$1" 2>/dev/null && pwd -P
}

gate_relative_path() {
  local script_dir harness_dir
  script_dir="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || return 0
  [ "$(basename "$script_dir")" = hooks ] || return 0
  harness_dir="$(basename "$(dirname "$script_dir")")"
  case "$harness_dir" in
    .claude | .codex)
      printf '%s\n' "$harness_dir/hooks/fallow-gate.sh"
      ;;
  esac
}

resolve_audit_root() {
  local session_dir="$1" gate_rel="$2" process_dir="$3" home_dir="$4" dir parent top process_top
  dir="$session_dir"
  if [ -n "$gate_rel" ]; then
    while :; do
      if [ -f "$dir/$gate_rel" ] && [ "$dir" != "$home_dir" ]; then
        printf '%s\n' "$dir"
        return 0
      fi
      [ -e "$dir/.git" ] && break
      parent="$(dirname "$dir")"
      # A path that is its own parent is the file system root.
      [ "$parent" = "$dir" ] && break
      dir="$parent"
    done
  fi
  if top="$(git -C "$session_dir" rev-parse --show-toplevel 2>/dev/null)" && [ -n "$top" ] &&
    top="$(physical_dir "$top")" && [ "$top" != "$home_dir" ]; then
    # A path under the top level is not enough: a nested worktree, submodule
    # or nested repository below it is another work tree. Keep the process
    # directory only when its own git top level is the same work tree.
    process_top="$(git -C "$process_dir" rev-parse --show-toplevel 2>/dev/null || true)"
    if [ -n "$process_top" ] && process_top="$(physical_dir "$process_top")" &&
      [ "$process_top" = "$top" ]; then
      printf '%s\n' "$process_dir"
    else
      printf '%s\n' "$top"
    fi
    return 0
  fi
  printf '%s\n' "$session_dir"
}

SESSION_CWD="$(jq -r '.cwd // empty' <<<"$INPUT" 2>/dev/null || true)"
if [ -n "$SESSION_CWD" ] && [ -d "$SESSION_CWD" ]; then
  # `cd` and `pwd` give an absolute path, also for a Windows path in git-bash.
  if SESSION_DIR="$(physical_dir "$SESSION_CWD")"; then
    HOME_DIR=""
    if [ -n "${HOME:-}" ]; then
      HOME_DIR="$(physical_dir "$HOME" || true)"
    fi
    PROCESS_DIR="$(pwd -P 2>/dev/null || true)"
    AUDIT_ROOT="$(resolve_audit_root "$SESSION_DIR" "$(gate_relative_path)" "$PROCESS_DIR" "$HOME_DIR")"
    if cd "$AUDIT_ROOT" 2>/dev/null; then
      if [ -n "${FALLOW_GATE_DEBUG:-}" ]; then
        echo "fallow-gate: auditing $AUDIT_ROOT (session directory $SESSION_DIR)." >&2
      fi
    else
      echo "fallow-gate: cannot enter $AUDIT_ROOT, auditing $(pwd) instead." >&2
    fi
  fi
fi

# A real installed Git hook keeps its caller's PATH and does not add
# node_modules/.bin, so a project-local install is invisible to `command -v`.
# The arms below cover the layouts a project-local install can take, in the
# order they are cheapest to probe. Keep them in step with the Lefthook job
# `fallow init --hooks` prints: the two must resolve the same installs.
if command -v fallow >/dev/null 2>&1; then
  RUNNER=(fallow)
  BIN_DESC="$(command -v fallow)"
elif [ -x ./node_modules/.bin/fallow ]; then
  RUNNER=(./node_modules/.bin/fallow)
  BIN_DESC="./node_modules/.bin/fallow"
elif command -v yarn >/dev/null 2>&1 && YARN_BIN="$(yarn bin fallow 2>/dev/null)" && [ -n "$YARN_BIN" ]; then
  # Yarn Plug'n'Play has no node_modules/.bin at all, so neither the launcher
  # check above nor npx can see the install.
  RUNNER=(yarn exec fallow --)
  BIN_DESC="yarn exec fallow"
elif command -v npx >/dev/null 2>&1 && VER_PROBE="$(npx --no-install fallow --version 2>/dev/null || true)" && [[ "$VER_PROBE" == fallow* ]]; then
  RUNNER=(npx --no-install fallow)
  BIN_DESC="npx --no-install fallow"
else
  echo "fallow-gate: fallow binary not found (tried PATH, node_modules/.bin, yarn and npx --no-install), skipping audit." >&2
  exit 0
fi

VERSION_RAW="$("${RUNNER[@]}" --version 2>/dev/null || true)"
VERSION="${VERSION_RAW#fallow }"
VERSION="${VERSION%% *}"

MIN_VERSION="${FALLOW_GATE_MIN_VERSION-2.85.0}"
if [ -n "$MIN_VERSION" ] && [ -n "$VERSION" ]; then
  LOWER="$(printf '%s\n%s\n' "$MIN_VERSION" "$VERSION" | sort -V | head -n1)"
  if [ "$LOWER" != "$MIN_VERSION" ]; then
    {
      echo "fallow-gate: blocked: $BIN_DESC is fallow $VERSION, below required $MIN_VERSION."
      echo "fallow-gate: older binaries reject the --gate-marker flag this gate passes"
      echo "fallow-gate: (added in fallow v2.85.0), so the audit cannot run."
      echo "fallow-gate: upgrade the fallow on PATH (e.g. npm install -g fallow@latest or"
      echo "fallow-gate: cargo install fallow-cli), or set FALLOW_GATE_MIN_VERSION= to disable."
    } >&2
    exit 2
  fi
fi

TMP_JSON="$(mktemp)"
TMP_ERR="$(mktemp)"
cleanup() {
  rm -f "$TMP_JSON" "$TMP_ERR"
}
trap cleanup EXIT

if "${RUNNER[@]}" audit --format json --quiet --explain --gate-marker agent >"$TMP_JSON" 2>"$TMP_ERR"; then
  STATUS=0
else
  STATUS=$?
fi

VERDICT="$(jq -r '.verdict // empty' <"$TMP_JSON" 2>/dev/null || true)"
IS_ERROR="$(jq -r '.error // false' <"$TMP_JSON" 2>/dev/null || echo false)"

if [ "$VERDICT" = "fail" ]; then
  echo "fallow-gate: blocked by fallow ${VERSION:-unknown} at $BIN_DESC" >&2
  cat "$TMP_JSON" >&2
  exit 2
fi

if [ "$STATUS" -eq 2 ] || [ "$IS_ERROR" = "true" ]; then
  MSG="$(jq -r '.message // empty' <"$TMP_JSON" 2>/dev/null || true)"
  if [ -n "$MSG" ]; then
    echo "fallow-gate: fallow audit runtime error ($MSG), skipping." >&2
  else
    echo "fallow-gate: fallow audit runtime error, skipping." >&2
  fi
  exit 0
fi

if [ "$STATUS" -ne 0 ]; then
  ERR_LINE="$(sed -n '1p' "$TMP_ERR" 2>/dev/null || true)"
  if [ -n "$ERR_LINE" ]; then
    echo "fallow-gate: fallow audit exited $STATUS ($ERR_LINE), skipping." >&2
  else
    echo "fallow-gate: fallow audit exited $STATUS, skipping." >&2
  fi
  exit 0
fi

exit 0
