#!/usr/bin/env bash # install-git-hooks.sh: install the push guard (row 211, #426) as pre-push in # a repository's common .git/hooks, which every linked worktree shares. # # Usage: # scripts/install-git-hooks.sh [--repo DIR] [--force-install] # # DIR defaults to this script's own checkout. The hook's bytes are this # checkout's scripts/git-hooks/pre-push. It refuses when core.hooksPath is set # (git would not run the hook), and when a different pre-push is installed, # unless --force-install is given. It never edits an installed hook in place: # a new hook is linked in, a replacement is renamed over the old one. A canary # then runs the installed hook through git and needs a delete refused. # # Exit codes: 0 installed (or already the same bytes); 1 failed; 2 refused; # 4 usage. Installing in the canonical checkout is its own step with the lead. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" SRC="$ROOT/scripts/git-hooks/pre-push" TMPD="" say() { printf 'install-git-hooks: %s\n' "$*" >&2; } die() { local code=$1; shift; say "$*"; exit "$code"; } cleanup() { if [ -n "$TMPD" ]; then rm -rf -- "$TMPD"; fi; } trap cleanup EXIT usage() { sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 exit 4 } REPO=$ROOT FORCE=0 while [ $# -gt 0 ]; do case "$1" in --repo) [ $# -ge 2 ] || usage; REPO=$2; shift 2 ;; --force-install) FORCE=1; shift ;; -h|--help) usage ;; *) say "unknown argument: $1"; usage ;; esac done for v in GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; do if [ -n "${!v+x}" ]; then die 2 "refused: $v is set; run with the normal git environment"; fi done [ -f "$SRC" ] && [ ! -L "$SRC" ] || die 2 "refused: $SRC is missing or not a regular file" g() { git -C "$REPO" "$@"; } top=$(g rev-parse --show-toplevel 2>/dev/null) || die 2 "refused: $REPO is not a git checkout" common=$(g rev-parse --path-format=absolute --git-common-dir) || die 1 "cannot read $REPO's git common directory" HOOKS="$common/hooks" HOOK="$HOOKS/pre-push" out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null) [ -z "$out" ] || die 2 "refused: core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')), so git would not run the push guard; unset it first" if [ -L "$HOOK" ]; then die 2 "refused: $HOOK is a symlink; remove it by hand if it is not wanted"; fi mkdir -p "$HOOKS" || die 1 "cannot create $HOOKS" tmp="$HOOKS/.pre-push.install-git-hooks.$$" if [ -e "$HOOK" ]; then [ -f "$HOOK" ] || die 2 "refused: $HOOK is not a regular file" if cmp -s "$SRC" "$HOOK"; then chmod 0755 "$HOOK" || die 1 "chmod failed" note="the same bytes were already there" else [ "$FORCE" = 1 ] || die 2 "refused: a different pre-push hook is installed at $HOOK; compare it with $SRC, and pass --force-install to replace it" cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; } # rename() swaps the whole file; the old hook's bytes are never edited. mv -f -- "$tmp" "$HOOK" || { rm -f -- "$tmp"; die 1 "cannot replace $HOOK"; } note="replaced a different hook (--force-install)" fi else cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; } # link() never replaces, so a hook that appeared meanwhile is kept. if ! ln -- "$tmp" "$HOOK" 2>/dev/null; then rm -f -- "$tmp"; die 2 "refused: a pre-push hook appeared at $HOOK while installing"; fi rm -f -- "$tmp" note="new" fi cmp -s "$SRC" "$HOOK" || die 1 "$HOOK differs from $SRC after install" [ -x "$HOOK" ] || die 1 "$HOOK is not executable after install" # The canary: git runs the hook it would run for a push. A delete line needs # no objects; the guard must refuse it, and pass an empty push. TMPD=$(mktemp -d "${TMPDIR:-/tmp}/install-git-hooks.XXXXXX") || die 1 "mktemp failed" z=0000000000000000000000000000000000000000 printf '(delete) %s refs/heads/install-canary 1%s\n' "$z" "${z#0}" > "$TMPD/delete" : > "$TMPD/empty" if ! out=$(cd "$top" && git hook run --to-stdin="$TMPD/empty" pre-push -- canary canary 2>&1); then die 2 "refused: the canary's empty push failed, so git is not running the push guard as installed: $out" fi out=$(cd "$top" && git hook run --to-stdin="$TMPD/delete" pre-push -- canary canary 2>&1); rc=$? [ "$rc" -ne 0 ] && [[ "$out" == *"mosaic push guard: refused"* ]] \ || die 2 "refused: the canary's delete was not refused by the push guard (exit $rc)" printf 'ok installed the push guard at %s (%s, sha256 %s, mode 0755); canary passed\n' \ "$HOOK" "$note" "$(sha256sum < "$HOOK" | cut -d' ' -f1)" exit 0