#!/usr/bin/env bash # install-git-hooks.sh — Idempotent installer for Red Bear OS optional git hooks. # # Hooks in Red Bear OS are ALWAYS opt-in (per AGENTS.md "absolutely NEVER # DELETE, NEVER IGNORE" rule — operators explicitly choose to install them). # This script is a convenience wrapper around the manual `cp` instructions # documented in local/docs/HOOKS.md. It never silently clobbers an existing # hook: if a hook file already exists and differs from the canonical copy, # it is backed up to `.bak` before the new one is written, and a # warning is printed. # # Usage: # ./local/scripts/install-git-hooks.sh # install post-checkout only (default) # ./local/scripts/install-git-hooks.sh --all # post-checkout + pre-push + commit-msg # ./local/scripts/install-git-hooks.sh --uninstall # remove hooks installed by this script # ./local/scripts/install-git-hooks.sh --uninstall --all # # Exit codes: # 0 success (or already-installed, or nothing to uninstall) # 1 fatal misconfiguration (not a git repo, scripts missing, etc.) # # See: local/docs/HOOKS.md (hook inventory + bypass instructions), # local/docs/RELEASE-BUMP-WORKFLOW.md (release-bump pipeline). set -euo pipefail # --------------------------------------------------------------------------- # Resolve repo root. Must be a Red Bear OS checkout (has local/scripts/). # --------------------------------------------------------------------------- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "$ROOT" ]]; then echo "install-git-hooks: not inside a git work tree" >&2 exit 1 fi SCRIPTS_DIR="$ROOT/local/scripts" HOOKS_DIR="$(git rev-parse --git-path hooks 2>/dev/null)" # `git rev-parse --git-path hooks` resolves correctly for linked worktrees # and custom $GIT_DIR. Fall back to the conventional path if it fails. if [[ -z "$HOOKS_DIR" ]]; then GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || echo .git)" [[ "$GIT_DIR" = /* ]] || GIT_DIR="$ROOT/$GIT_DIR" HOOKS_DIR="$GIT_DIR/hooks" fi mkdir -p "$HOOKS_DIR" # --------------------------------------------------------------------------- # Parse flags. # --------------------------------------------------------------------------- INSTALL_ALL=0 UNINSTALL=0 for arg in "$@"; do case "$arg" in --all) INSTALL_ALL=1 ;; --uninstall) UNINSTALL=1 ;; -h|--help) sed -n '1,30p' "$0" exit 0 ;; *) echo "install-git-hooks: unknown option: $arg" >&2 echo " usage: $0 [--all] [--uninstall]" >&2 exit 1 ;; esac done # --------------------------------------------------------------------------- # Hook manifest. The default install set is intentionally minimal (only # post-checkout) so that a bare `install-git-hooks.sh` does the least # surprising thing. `--all` extends to the pre-push and commit-msg hooks. # --------------------------------------------------------------------------- # Each entry: ; HOOKS_DEFAULT=( "post-checkout;post-checkout-version-sync.sh" ) HOOKS_EXTRA=( "pre-push;pre-push-checks.sh" "commit-msg;commit-msg" ) # Build the active set from the flags. ACTIVE_HOOKS=() if [[ $UNINSTALL -eq 1 ]]; then # Uninstall needs to know the full set that --all would have installed, # so a default-install-then---all-uninstall cleans everything up. for entry in "${HOOKS_DEFAULT[@]}" "${HOOKS_EXTRA[@]}"; do ACTIVE_HOOKS+=("$entry") done if [[ $INSTALL_ALL -eq 0 ]]; then # Without --all, only uninstall the default set (the ones a default # install would have placed). Operators who used --all should pass # --uninstall --all. ACTIVE_HOOKS=("${HOOKS_DEFAULT[@]}") fi else ACTIVE_HOOKS=("${HOOKS_DEFAULT[@]}") [[ $INSTALL_ALL -eq 1 ]] && ACTIVE_HOOKS+=("${HOOKS_EXTRA[@]}") fi # --------------------------------------------------------------------------- # Install / uninstall loop. # --------------------------------------------------------------------------- install_one() { local hook_name="$1" local src_rel="$2" local src="$SCRIPTS_DIR/$src_rel" local dst="$HOOKS_DIR/$hook_name" if [[ ! -f "$src" ]]; then echo "install-git-hooks: WARN source script not found: $src_rel — skipping $hook_name" >&2 return 0 fi if [[ -e "$dst" || -L "$dst" ]]; then # Content-based, not path-based: a copied hook has a different path # than the source, so only byte comparison detects "already current". if cmp -s "$dst" "$src" 2>/dev/null; then echo "install-git-hooks: $hook_name already up to date ($dst)" chmod +x "$dst" 2>/dev/null || true return 0 fi # Existing file differs — back it up before overwriting. local bak="${dst}.bak" # Rotate any pre-existing .bak so we don't destroy the first backup. if [[ -e "$bak" ]]; then local i=1 while [[ -e "${dst}.bak.${i}" && $i -le 100 ]]; do i=$((i+1)); done if [[ $i -gt 100 ]]; then bak="${dst}.bak.$(date +%Y%m%d%H%M%S)-$$" else bak="${dst}.bak.${i}" fi fi echo "install-git-hooks: WARN existing $hook_name differs — backing up to $bak" >&2 mv "$dst" "$bak" fi cp "$src" "$dst" chmod +x "$dst" echo "install-git-hooks: installed $hook_name -> $dst" } uninstall_one() { local hook_name="$1" local dst="$HOOKS_DIR/$hook_name" if [[ ! -e "$dst" && ! -L "$dst" ]]; then echo "install-git-hooks: $hook_name not present ($dst) — nothing to remove" return 0 fi rm -f "$dst" echo "install-git-hooks: removed $hook_name ($dst)" # Restore the most recent backup if one exists, so uninstall is reversible. if [[ -e "${dst}.bak" ]]; then mv "${dst}.bak" "$dst" chmod +x "$dst" 2>/dev/null || true echo "install-git-hooks: restored previous $hook_name from ${dst}.bak" fi } echo "=== Red Bear OS git hook installer ===" echo " repo root: $ROOT" echo " hooks dir: $HOOKS_DIR" echo " mode: $([[ $UNINSTALL -eq 1 ]] && echo 'uninstall' || echo 'install') ($([[ $INSTALL_ALL -eq 1 ]] && echo 'all hooks' || echo 'default: post-checkout only'))" echo "" if [[ $UNINSTALL -eq 1 ]]; then for entry in "${ACTIVE_HOOKS[@]}"; do hook_name="${entry%%;*}" uninstall_one "$hook_name" done echo "" echo "Uninstall complete. Remaining hooks (if any):" ls -1 "$HOOKS_DIR" 2>/dev/null | grep -vE '\.bak(\.[0-9]+)?$' || echo " (none)" exit 0 fi for entry in "${ACTIVE_HOOKS[@]}"; do hook_name="${entry%%;*}" src_rel="${entry##*;}" install_one "$hook_name" "$src_rel" done echo "" echo "=== Install complete ===" echo "" echo "Installed hooks are OPT-IN and never auto-commit, auto-push, or block" echo "checkouts. Bypass / configuration:" echo " post-checkout: REDBEAR_NO_AUTO_SYNC=1 git checkout " echo " pre-push: REDBEAR_SKIP_PRE_PUSH=1 git push (or git push --no-verify)" echo "" echo "Full documentation: local/docs/HOOKS.md" echo "Release-bump runbook: local/docs/RELEASE-BUMP-WORKFLOW.md"