#!/usr/bin/env bash
# Template pre-push hook for a vault built on brain-kit.
#
# This is a TEMPLATE: it is copied into an adopting vault's own .githooks/
# directory (or wherever that vault points core.hooksPath at) and is meant
# to be edited there. It names no path or identity specific to any one
# machine or maintainer; everything it needs about THIS vault (its secret
# patterns, whether an automated identity is even allowed near the default
# branch) comes from that vault's own brain-kit.config.json, which travels
# with the vault itself, not from this file.
#
# What it does, in order, and each step's status is read: the first that
# fails refuses the push.
#   1. Runs `brain-kit validate`, refusing the push if the vault does not
#      conform to the Open Knowledge Format or this vault's own house
#      rules.
#   2. Runs `brain-kit lint --base all`, refusing the push if this vault's
#      own health rules (orphans, tables, the `secrets` rule against
#      privacy.secret_patterns and the generic credential shapes, and the
#      rest) find a problem in what the working tree holds. The vault's
#      deliberate escape for a file known to be safe is
#      lint.secrets.exclude_paths in brain-kit.config.json, which a pull
#      request shows.
#   3. Runs `brain-kit push-gate`, the same object scan the brain-kit
#      maintainer's own gate runs, over what the push CARRIES: every commit
#      it sends, each commit's files, their names, its message, its author
#      and committer and its raw headers, every annotated tag, and the
#      destination name of every reference it writes or deletes. The
#      patterns are the generic credential shapes plus privacy.secret_patterns
#      from the working tree's configuration, from the configuration at
#      every pushed tip and at every earlier commit of the push that no
#      remote-tracking reference holds yet, and from the one on the
#      remote's default branch as
#      this repository knows it (refs/remotes/<remote>/HEAD, else main, else
#      master; on a push by url, those of every configured remote), because
#      a pushed branch can delete a pattern from its own configuration and
#      then violate it. The vault's own brain-kit.config.json, when it
#      parses as a JSON object, is read for credential shapes only, since it
#      declares the patterns and would match every one of them. So a
#      literal written into its other fields is not refused: for the
#      checked-out copy that is the trade-off the linter already makes, and
#      for the file's history, or any JSON object stored under its name, it
#      is this gate's own accepted trade-off.
#   4. Refuses a push made under the vault's own automation identity
#      (brain-kit.config.json's git.agent_identity) whose DESTINATION is
#      the vault's default branch, whatever the ref being pushed from is
#      called and whether it adds commits or deletes the branch outright.
#      A vault that curates itself through an agent is designed around that
#      agent proposing changes as a pull request (see
#      brain-kit.config.json's git.branch_prefix and pr_command); an agent
#      pushing directly to the default branch has skipped the review that
#      design depends on, whether by a bug in the automation or by a
#      compromised credential, and either way this is the point that push
#      should stop.
#
# WHAT THIS DOES NOT COVER, rewritten on 22/09/2026 when step 3 arrived.
# Until then this hook read only the working tree, and a credential that
# had ever reached a commit travelled in the history, in a tip hidden by an
# uncommitted edit, or on a branch that was not checked out, all three
# proven against a throwaway remote. Step 3 scans the objects git sends, so
# all three are refused now, whatever the working tree shows. What remains:
#
#   - It is a literal pattern scanner. Content inside a compressed file (a
#     zip archive, an office document, most PDFs) passes, and so does a
#     name split across a line break, or written in decomposed Unicode (a
#     letter followed by a combining accent) where the pattern has the
#     letter precomposed. The maintainer's gate has the same limits.
#   - The default branch is read from remote-tracking references this
#     clone already holds, never asked of the remote. A clone holding none
#     of refs/remotes/<remote>/HEAD, main or master for the remote pushed
#     to (or, on a push by url, for any configured remote) is scanned with
#     the working tree's patterns and those of the configurations the push
#     carries alone: a vault before its first push, or one whose default
#     branch has another name and whose remote HEAD was never recorded (`git pull` does not record
#     it, and neither does `git fetch` before git 2.48). The gate says so
#     on one line and names a command that fixes it for that case.
#   - `git push --no-verify` skips every step of this hook. So does a clone
#     that never ran `git config core.hooksPath .githooks`, and so does a
#     checked-out branch whose own .githooks/pre-push says something else:
#     this file lives in the working tree. Nothing client-side can prevent
#     any of them.
#
# Activate once per clone of the adopting vault:
#   git config core.hooksPath .githooks
# brain-kit is found on PATH and nowhere else. The vault carries no package
# of its own, so there is no copy inside it for this hook to look for, and
# whoever controls PATH for the shell git runs hooks in controls which
# engine judges the push.
set -u

REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$REPO_ROOT" ]; then
  echo "pre-push: could not determine the repository root (git rev-parse --show-toplevel failed); refusing to push." >&2
  exit 1
fi

if ! BRAIN_KIT="$(command -v brain-kit)" || [ -z "$BRAIN_KIT" ]; then
  echo "pre-push: brain-kit was not found on PATH, and this gate runs the brain-kit on PATH and nothing else. Put it on the PATH of the shell git runs hooks in (a global install puts it there), then push again. Refusing to push." >&2
  exit 1
fi

# The reference lines git hands this hook on standard input are read ONCE,
# whole, into a scratch file, because two steps below need them: the object
# scan and the automation guard. Read by the first, a pipe would be empty
# for the second. A read that fails refuses: the lines that did arrive are
# not the push.
REF_LINES="$(mktemp "${TMPDIR:-/tmp}/brain-kit-refs.XXXXXX")" || REF_LINES=""
if [ -z "$REF_LINES" ]; then
  echo "pre-push: could not create a temporary file for the reference lines of this push; refusing to push." >&2
  exit 1
fi
trap 'rm -f "$REF_LINES"' EXIT
if ! cat > "$REF_LINES"; then
  echo "pre-push: could not read the reference lines git sent for this push; refusing to push." >&2
  exit 1
fi

if ! "$BRAIN_KIT" validate < /dev/null; then
  echo "pre-push: brain-kit validate found a problem with this vault; refusing to push." >&2
  exit 1
fi

# --base all, explicitly, never the interactive default.
#
# This hook reads an EXIT CODE and nothing else, so every caveat the lint
# report prints in prose is invisible here. The default base is "auto",
# which resolves against the repository's own state: on the default
# branch it widens to the whole vault only when the tree is clean and
# nothing is untracked, so one unrelated scratch file lying around
# narrows it again. A gate whose reach a stray file can change is not a
# gate. "all" is the one base that cannot be re-derived by anything in
# the working tree, and it is also the right question for a push: what
# is about to exist on the remote, not what this working copy happens to
# have touched since some anchor.
#
# It does not make this hook noisier about prose. The rules that judge
# added lines (style, tables) default to `warn`, and a warning does not
# fail a lint run; only a rule this vault itself escalated to `error`,
# and `secrets`, which defaults to `error`, can refuse a push here.
if ! "$BRAIN_KIT" lint --base all < /dev/null; then
  echo "pre-push: brain-kit lint found a problem with this vault; refusing to push." >&2
  exit 1
fi

# What the push carries, as opposed to what the working tree shows. git's
# own two arguments are handed on unchanged: the remote's name (or the url
# as typed, on a push by url) and the url the push really goes to.
if ! "$BRAIN_KIT" push-gate "${1:-}" "${2:-}" --patterns config < "$REF_LINES"; then
  echo "pre-push: brain-kit push-gate refused what this push carries (see above); refusing to push." >&2
  exit 1
fi

# The automation identity this vault configured, and whether this guard is
# even wanted, both come from the vault's own configuration
# (brain-kit.config.json, validated already by the `validate` call above,
# so a missing or malformed file has already refused the push by this
# point). git.agent_identity.email is a required field of that schema;
# git.forbid_agent_push_to_default is optional and defaults to "on",
# because a vault that never mentions it is not thereby declaring it wants
# its automation pushing straight to the default branch. It is skipped
# only when the vault sets that flag to `false` explicitly.
CONFIG_FILE="$REPO_ROOT/brain-kit.config.json"
READ_AGENT_GUARD_SCRIPT='
var fs = require("fs");
try {
  var config = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
  var git = (config && typeof config === "object") ? config.git : undefined;
  var identity = (git && typeof git === "object") ? git.agent_identity : undefined;
  var email = (identity && typeof identity === "object") ? identity.email : undefined;
  var forbidden = !(git && typeof git === "object" && git.forbid_agent_push_to_default === false);
  if (typeof email === "string" && email.length > 0 && forbidden) {
    process.stdout.write(email);
  }
} catch (err) {
  // No config, an unreadable config, or malformed JSON: validate above
  // would already have refused the push over this, so there is nothing
  // further to enforce here.
}
'
AGENT_EMAIL="$(node -e "$READ_AGENT_GUARD_SCRIPT" "$CONFIG_FILE" 2>/dev/null)"

# The default branch, the same three-step fallback src/git.mjs's own
# findDefaultBranch uses: the remote's own notion of it, a local branch
# named main or master, or a remote-tracking branch by either of those
# names, each reduced to a bare branch name so it compares directly against
# the branch a pushed ref lands on below.
#
# The remote's notion of it is read as the FULL reference and reduced by
# removing exactly "refs/remotes/<this remote>/", never with --short
# (final fix round 2). --short prints the shortest name that is not
# ambiguous, and a local branch that happens to be called "origin/main" (an
# easy thing to create by mistake) makes that "remotes/origin/main", which
# no strip of "origin/" undoes: the guard then compared every push against
# a branch name nothing can be pushed to, and an automated push straight to
# the default branch went through with exit 0 and no line from this hook.
# A symbolic reference that points anywhere else is not something git
# writes; it yields nothing here, and the warning below says the guard is
# not running rather than guessing.
remote_name="${1:-origin}"

# On a push by url, git hands this hook the url as typed in place of a
# remote name, and a url can carry a token (https://<user>:<token>@<host>).
# The warning below prints the name, so it prints it without its userinfo:
# the same rule as `without_userinfo` in brain-kit's src/push/records.sh.
without_userinfo() {
  local url="$1" scheme rest authority prefix
  case "$url" in
    *://*)
      scheme="${url%%://*}://"
      rest="${url#*://}"
      authority="${rest%%/*}"
      case "$authority" in
        *@*) printf '%s' "$scheme${authority##*@}${rest#"$authority"}" ; return 0 ;;
      esac
      ;;
    *)
      prefix="${url%%/*}"
      case "$prefix" in
        *@*:*)
          case "${prefix##*@}" in
            *:*) printf '%s' "${url#"${prefix%@*}@"}" ; return 0 ;;
          esac
          ;;
      esac
      ;;
  esac
  printf '%s' "$url"
}
remote_shown="$(without_userinfo "$remote_name")"

default_branch() {
  local symref candidate
  symref="$(git symbolic-ref "refs/remotes/${remote_name}/HEAD" 2>/dev/null)"
  if [ -n "$symref" ]; then
    case "$symref" in
      "refs/remotes/${remote_name}/"*)
        printf '%s' "${symref#"refs/remotes/${remote_name}/"}"
        return 0
        ;;
      refs/heads/*)
        printf '%s' "${symref#refs/heads/}"
        return 0
        ;;
    esac
    return 1
  fi
  for candidate in main master; do
    if git show-ref --verify --quiet "refs/heads/$candidate"; then
      printf '%s' "$candidate"
      return 0
    fi
  done
  for candidate in main master; do
    if git show-ref --verify --quiet "refs/remotes/${remote_name}/$candidate"; then
      printf '%s' "$candidate"
      return 0
    fi
  done
  return 1
}

DEFAULT_BRANCH="$(default_branch)"
CURRENT_EMAIL="$(git config user.email 2>/dev/null || true)"

# Whether this guard has anything to say about this push at all, decided
# once rather than per ref: a vault with no automation identity configured,
# or one being pushed by a human, is not what this guard is for.
guard_active=0
if [ -n "$AGENT_EMAIL" ] && [ "$CURRENT_EMAIL" = "$AGENT_EMAIL" ]; then
  guard_active=1
fi

# The ladder above has three steps and can still come up empty (a vault
# whose default branch is called neither main nor master, with no
# remote-tracking information for it). That used to be a bare `continue`,
# which switched the guard off in silence: a guard that cannot find its
# default branch is a guard that is not running, and the vault it is
# supposed to protect has no way of knowing. It says so now. It does not
# refuse: refusing every push an automation makes, including the ones to
# its own branches, is how a vault learns to pass --no-verify, which would
# turn this guard off permanently instead of for one push.
#
# The remedy it names has to work for the push at hand: `git remote
# set-head` takes a remote's NAME, and on a push by url git hands this hook
# the url, for which that command only fails. So a url gets the remedy
# that does work: push by the remote's name.
if [ "$guard_active" -eq 1 ] && [ -z "$DEFAULT_BRANCH" ]; then
  if git remote | grep -qxF -- "$remote_name"; then
    remedy="Set it with: git remote set-head $remote_shown --auto"
  else
    remedy="'$remote_shown' is not the name of a remote: push by the remote's name (git remote -v lists them), then set it with: git remote set-head <that name> --auto"
  fi
  echo "pre-push: WARNING: could not determine this vault's default branch (no $remote_shown/HEAD, no local main or master, no remote-tracking main or master), so the guard against an automated push to it is NOT running for this push. $remedy" >&2
fi

# The ref negotiation, from the scratch file it was read into above.
#
# WHICH FIELD DECIDES: remote_ref, the DESTINATION of this push, and never
# local_ref, its source. They are equal for the ordinary `git push origin
# main`, which is why reading the wrong one survived a whole review round,
# but only the destination says where the change lands. Reading local_ref
# let `git push origin agent/some-branch:main` and `git push origin
# HEAD:refs/heads/main` both through, under the automation identity, and
# the remote's default branch really did advance; a detached-head push
# slipped for a second reason, because stripping refs/heads/ off `HEAD`
# leaves `HEAD`, which matches no branch name. A deletion of the default
# branch is a push to that destination too (its local_ref is `(delete)`
# and its local_sha is zero), and is refused for the same reason: it is a
# change to the default branch that skipped the pull request.
#
# A destination outside refs/heads/ (a tag, a note, a replace ref) is not
# a branch and cannot be the default branch, so it is skipped rather than
# compared.
guard_failed=0
while read -r local_ref local_sha remote_ref remote_sha; do
  [ -z "${remote_ref:-}" ] && continue
  [ "$guard_active" -eq 1 ] || continue
  [ -z "$DEFAULT_BRANCH" ] && continue
  case "$remote_ref" in
    refs/heads/*) ;;
    *) continue ;;
  esac
  branch="${remote_ref#refs/heads/}"
  if [ "$branch" = "$DEFAULT_BRANCH" ]; then
    echo "pre-push: refusing a push to the default branch ('$DEFAULT_BRANCH') under this vault's automation identity ($AGENT_EMAIL). Automated changes go through a pull request, never a direct push to $DEFAULT_BRANCH." >&2
    guard_failed=1
  fi
done < "$REF_LINES"

if [ "$guard_failed" -ne 0 ]; then
  exit 1
fi
exit 0
