Files
stack/scripts/install-git-hooks.sh
T

114 lines
5.3 KiB
Bash
Executable File

#!/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), when the hooks directory is a symlink or
# resolves anywhere but the common directory's hooks, 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"
# The hooks directory must be the common directory's own. Through a symlink
# to a shared directory, the install would set the hook of every repository
# that shares it. Checked before and after mkdir, before any file is written.
own_hooks() {
local real want
[ ! -L "$HOOKS" ] || die 2 "refused: $HOOKS is a symlink; install only into the repository's own hooks directory"
[ -e "$HOOKS" ] || return 0
real=$(cd -P -- "$HOOKS" && pwd -P) && want="$(cd -P -- "$common" && pwd -P)/hooks" || die 1 "cannot resolve $HOOKS"
[ "$real" = "$want" ] || die 2 "refused: $HOOKS resolves to $real, not $want"
}
own_hooks
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"
own_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