#!/usr/bin/env bash# Render the git hooks from scripts/setup/hooks/ into .git/hooks/, or remove them.# ================================================================================## Installs/uninstalls the project's git hooks by rendering templates from# scripts/setup/hooks/ into .git/hooks/.## Hooks managed:# - pre-commit (.git/hooks/pre-commit)# - pre-push (.git/hooks/pre-push)## Author: Vikas Munshi <vikas.munshi@gmail.com># Copyright (c) 2024. All rights reserved.# Licensed under the MIT License.set-euopipefailREPO_ROOT="$(git-C"$(dirname"${BASH_SOURCE[0]}")"rev-parse--show-toplevel)"HOOKS_DIR="${REPO_ROOT}/.git/hooks"TEMPLATES_DIR="${REPO_ROOT}/scripts/setup/hooks"PRE_COMMIT="${HOOKS_DIR}/pre-commit"PRE_PUSH="${HOOKS_DIR}/pre-push"# The venv whose flake8/mypy the rendered hooks call — identified from the ambient# ``python``, i.e. the shell's answer to ``sys.executable``, rather than guessed from a# path.## That is what makes one render correct everywhere. The operator's terminal answers with# this checkout's ``.venv``; a collaborator's web shell answers with ``/opt/euler/venv``,# which is right — a provisioned clone has no ``.venv`` of its own, and its hooks must# call the deployed venv, exactly as ``user.sh`` § provision_hooks renders them. Both# answers come from ``solver.config._enter_root``, which puts the running interpreter's# bin dir at the head of ``PATH``, so any shell descended from a solver shell names its# own venv. Assuming ``${REPO_ROOT}/.venv`` instead is what gave a clone hooks pointing at# a venv that was never there: every commit and push then died on "No such file or# directory" — the checks did not pass or fail, they could not run at all.## Probed in order:# EULER_VENV — an explicit override, taken strictly: set means use it or fail.# command -v python — the ambient venv, whenever this runs inside one.# REPO_ROOT/.venv — the fresh-install case: `make install-all` builds the venv and# installs the hooks in one breath, from a shell whose PATH predates# that venv and so cannot name it.# A candidate counts only if it is really a venv: a bin/python AND a pyvenv.cfg.is_venv(){localdir="$1"[[-n"${dir}"&&-x"${dir}/bin/python"&&-f"${dir}/pyvenv.cfg"]]}# The venv root of the ambient ``python``, or empty. Deliberately NOT symlink-resolved:# a venv's bin/python points at the system interpreter it was built from, so following it# would report /usr and lose the venv we were asked to find.ambient_venv(){localpypy="$(command-vpython2>/dev/null)"||return0(cd"$(dirname"${py}")/.."2>/dev/null&&pwd)||return0}resolve_venv(){localcandidateforcandidatein"${EULER_VENV:-}""$(ambient_venv)""${REPO_ROOT}/.venv";doifis_venv"${candidate}";thenecho"${candidate}"return0fidonereturn0# nothing usable; the caller reports it (install fatal, status descriptive)}if[[-n"${EULER_VENV:-}"]]&&!is_venv"${EULER_VENV}";thenecho"ERROR: EULER_VENV=${EULER_VENV} is not a venv (needs bin/python and pyvenv.cfg).">&2exit1fiVENV="$(resolve_venv)"FORCE=0usage(){cat<<EOFUsage: $0 [install|uninstall|status|cleanup] [--force] install Write git hooks into .git/hooks/ uninstall Remove the managed hooks from .git/hooks/ status Show installed hooks: present, current vs stale, venv, backups (default) cleanup Remove rotated hook backups (.bak.*) after confirmation --force Overwrite existing hooks without creating a backupEOF}backup_if_exists(){localtarget="$1"if[[-f"${target}"]];thenlocalbackupbackup="${target}.bak.$(date+%Y%m%d%H%M%S)"mv"${target}""${backup}"echo"Backed up existing hook -> ${backup}"fi}render_hook(){localtemplate="$1"dest="$2"if[[!-f"${template}"]];thenecho"ERROR: template not found: ${template}">&2exit1fi# `|` as sed delimiter avoids collisions with `/` in filesystem paths.# The substituted values are paths under the repo root, which never# contain `|` on this project, so this is safe.sed\-e"s|__REPO_ROOT__|${REPO_ROOT}|g"\-e"s|__VENV__|${VENV}|g"\"${template}">"${dest}"chmod+x"${dest}"}install_hooks(){if[[-z"${VENV}"]];thenecho"ERROR: no venv found — '$(command-vpython||echopython)' is not one, and there is">&2echo" no ${REPO_ROOT}/.venv. The hooks run a venv's flake8/mypy, so there is">&2echo" nothing to install against. Run this from a solver shell or an activated venv,">&2echo" build the dev venv ('make install-all'), or set EULER_VENV to one that exists.">&2exit1fiif((FORCE==0));thenbackup_if_exists"${PRE_COMMIT}"backup_if_exists"${PRE_PUSH}"firender_hook"${TEMPLATES_DIR}/pre-commit.template""${PRE_COMMIT}"echo"Installed hook: pre-commit"render_hook"${TEMPLATES_DIR}/pre-push.template""${PRE_PUSH}"echo"Installed hook: pre-push"echo"Git hooks installation completed"}uninstall_hooks(){localremoved=0forhookinpre-commitpre-push;dolocaltarget="${HOOKS_DIR}/${hook}"if[[-f"${target}"]];thenrm-f"${target}"echo"Removed hook: ${hook}"((removed++))||trueelseecho"Hook not installed: ${hook}"fidoneif((removed>0));thenecho"Git hooks uninstallation completed"fi}# One status line per hook: present or not, and — by comparing against a fresh# render of its template with the resolved VENV — whether the installed copy is# current or stale (template changed, or rendered against a different venv).status_hooks(){localhooktargetinstalled_venvfreshforhookinpre-commitpre-push;dotarget="${HOOKS_DIR}/${hook}"if[[!-f"${target}"]];thenecho"${hook}: ✗ not installed (run '$0 install')"continuefiinstalled_venv="$(sed-n's/^VENV="\(.*\)"$/\1/p'"${target}"|head-n1)"# A hook whose venv is gone cannot run its checks at all — a louder problem than# staleness, and the one the reader must act on, so it is reported first.if[[-n"${installed_venv}"&&!-x"${installed_venv}/bin/python"]];thenecho"${hook}: ✗ installed but BROKEN (venv ${installed_venv} is missing — re-run '$0 install')"continuefifresh="$(sed-e"s|__REPO_ROOT__|${REPO_ROOT}|g"-e"s|__VENV__|${VENV}|g"\"${TEMPLATES_DIR}/${hook}.template")"if[["$(cat"${target}")"=="${fresh}"]];thenecho"${hook}: ✓ installed, current (venv ${installed_venv:-?})"elseecho"${hook}: ⚠ installed but STALE (venv ${installed_venv:-?}; template or venv changed — re-run '$0 install')"fidonelocalbackupsbackups=$(find"${HOOKS_DIR}"-maxdepth1-name'*.bak.*'2>/dev/null|sort)if[[-n"${backups}"]];thenecho"backups: $(wc-l<<<"${backups}") rotated hook backup(s) ('$0 cleanup' removes them):"# shellcheck disable=SC2001 # per-line indent of a multi-line string; ${//} can't anchor ^sed's/^/ /'<<<"${backups}"elseecho"backups: none"fi}# Remove the rotated .bak.* copies install leaves behind — with confirmation,# since a backup may hold the only copy of a locally-customised hook.cleanup_backups(){localbackupsreplybackups=$(find"${HOOKS_DIR}"-maxdepth1-name'*.bak.*'2>/dev/null|sort)if[[-z"${backups}"]];thenecho"No hook backups to remove."return0fiecho"Hook backups in ${HOOKS_DIR}:"# shellcheck disable=SC2001 # per-line indent of a multi-line string; ${//} can't anchor ^sed's/^/ /'<<<"${backups}"read-r-p"Remove these $(wc-l<<<"${backups}") backup(s)? [y/N] "replyif[["${reply}"=~^[Yy]$]];thenxargs-d'\n'rm-f--<<<"${backups}"echo"Hook backups removed."elseecho"Keeping the backups."fi}# Argument parsing — supports `install|uninstall` plus `--force` in any order.ACTION="status"ACTION_SET=0forargin"$@";docase"${arg}"ininstall|uninstall|status|cleanup)ACTION="${arg}"ACTION_SET=1;;--force)FORCE=1;;-h|--help|help)usageexit0;;*)echo"Unknown argument: ${arg}">&2usageexit1;;esacdone:"${ACTION_SET}"# silence unused-var lints; default action is status (read-only)case"${ACTION}"ininstall)install_hooks;;uninstall)uninstall_hooks;;status)status_hooks;;cleanup)cleanup_backups;;esac