testsAndMisc/linux_configuration/.githooks/lib/common.sh
Krzysztof Rudnicki 2b3c1a69f7 fix(hooks): make git hooks and their toolchain mandatory and fail-closed
A completely absent `prettier` binary went unnoticed long enough for
unformatted markdown to reach main. Two independent causes, both fixed here.

No pre-push hook existed. The three hooks declaring `stages: [pre-push]` in
.pre-commit-config.yaml — prettier, ci-mirror, pytest-coverage — therefore
never ran locally at all. Adds one.

The pre-commit hook failed open. shfmt and shellcheck were wrapped in
`if command -v`, and the entire pre-commit framework was skipped with a
warning when its binary was missing. A silently skipped gate is
indistinguishable from a passing one, so a missing tool now aborts.

- lib/common.sh: require_tool tries `sudo -n pacman -S` and aborts with the
  exact command when a tool cannot be installed non-interactively;
  verify_toolchain applies it to all eight binaries the hooks invoke.
- The pre-push hook resolves git's stdin refs into --from-ref/--to-ref.
  Without a range, `pre-commit run --hook-stage pre-push` checks *staged*
  files, and nothing is staged at push time, so prettier would have received
  zero files and passed vacuously.
- ensure_hooks_path repairs core.hooksPath drift on every hook run.
- meta/scripts/install_hooks.sh covers the one case the hooks cannot
  self-heal — a fresh clone where the config was never set, so nothing runs.
  `--check` reports state and exits non-zero when incomplete.

Note `pre-commit install` must never be used here: it writes into
.git/hooks/, which git ignores entirely while core.hooksPath is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:09:11 +02:00

102 lines
3.2 KiB
Bash

# shellcheck shell=bash
# ============================================================================
# Shared helpers for the tracked git hooks in linux_configuration/.githooks/.
#
# Sourced by the pre-commit and pre-push hooks and by
# meta/scripts/install_hooks.sh. Not executable on its own.
#
# Design note: these hooks FAIL CLOSED. A missing tool means "stop", never
# "skip the check" — a silently skipped gate is indistinguishable from a
# passing one, which is how a missing prettier went unnoticed long enough to
# let unformatted markdown reach main.
# ============================================================================
# Where git must look for hooks. Relative to the repo root so it survives the
# repo being cloned to a different path.
readonly HOOKS_PATH_REL="linux_configuration/.githooks"
# Every external binary the hooks shell out to, as "command:pacman-package".
# Derived from the `language: system` hooks in .pre-commit-config.yaml plus
# the tools this hook directory invokes directly (shfmt, jscpd via npm).
# Keep in sync when a new system-language hook is added.
readonly REQUIRED_TOOLS=(
"pre-commit:python-pre-commit"
"prettier:prettier"
"shellcheck:shellcheck"
"shfmt:shfmt"
"zsh:zsh"
"node:nodejs"
"npm:npm"
"python3:python"
)
# Emit a hook progress line.
hook_log() {
printf ' %s\n' "$1"
}
# Abort the current hook with a message on stderr.
hook_abort() {
printf '\n%s\n' "$1" >&2
exit 1
}
# Ensure git actually routes hooks through this tracked directory.
#
# Self-heal: if core.hooksPath drifted (unset by a tool, pointed elsewhere, or
# never set on a fresh clone that then ran a hook some other way), put it back.
# Note the inherent limit — if hooksPath is unset AND .git/hooks is empty, no
# hook runs at all and nothing here executes. That gap is what
# meta/scripts/install_hooks.sh exists to close on a new machine.
ensure_hooks_path() {
local current
current="$(git config --get core.hooksPath || true)"
if [[ $current == "$HOOKS_PATH_REL" ]]; then
return 0
fi
git config core.hooksPath "$HOOKS_PATH_REL"
hook_log "↻ repaired core.hooksPath: '${current:-<unset>}' → ${HOOKS_PATH_REL}"
}
# Ensure a single command is available, installing it via pacman when possible.
#
# $1 — command name to look for on PATH
# $2 — pacman package that provides it
#
# Tries a non-interactive install first; a package needing a TTY (password
# prompt, AUR) aborts with the exact command to run by hand rather than
# hanging on a prompt inside a git hook.
require_tool() {
local cmd="$1" pkg="$2"
if command -v "$cmd" >/dev/null 2>&1; then
return 0
fi
hook_log "→ ${cmd} missing; installing ${pkg}..."
if sudo -n pacman -S --noconfirm --needed "$pkg" >/dev/null 2>&1 &&
command -v "$cmd" >/dev/null 2>&1; then
hook_log "✓ ${cmd} installed"
return 0
fi
hook_abort "Aborted: required tool '${cmd}' is missing and could not be installed automatically.
Run this yourself, then retry:
! sudo pacman -S ${pkg}"
}
# Ensure every tool in REQUIRED_TOOLS is present. Fails closed on the first
# one that cannot be provided.
verify_toolchain() {
local entry cmd pkg
for entry in "${REQUIRED_TOOLS[@]}"; do
cmd="${entry%%:*}"
pkg="${entry##*:}"
require_tool "$cmd" "$pkg"
done
}