diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..4cc2a51fb --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,140 @@ +#!/bin/sh +# The repository's pre-commit hook (api.quality.pre-commit-hook). It runs +# make quality before git commit, and pre-merge-commit runs it before a merge +# commits without conflicts, so the checks pull-request CI runs fail here +# first. make install turns both on with git config core.hooksPath .githooks; +# every worktree of the clone shares that setting and runs its own checkout's +# copy. git commit --no-verify skips them, git's cherry-pick, revert, rebase +# and am can make commits without them, and CI's Quality checks job still +# runs the same checks. +# +# make quality checks the working tree, so the hook first requires it to hold +# exactly what is being committed: no unstaged changes to tracked files +# (including case-only renames), no tracked files git doesn't compare +# (skip-worktree or assume-unchanged), no untracked files outside the ignore +# files, no Python files or tool settings that only .git/info/exclude or a +# global excludes file hides (virtual environments aside), and no tracked +# Python files those excludes match, which ruff would skip. The commit stops, +# saying why, when that isn't so, when make quality fails, when ruff's fixes +# changed a file, or when make, uv or the worktree's dev environment is +# missing. +set -u + +fail() { + printf 'pre-commit: %s\n' "$1" >&2 + exit 1 +} + +root=$(git rev-parse --show-toplevel) || + fail "can't find the root of this working tree." +cd "$root" || fail "can't enter $root." + +# make's own variables from the environment could make it ignore a failing +# check, read another Makefile, or replace $(MAKE) and so skip quality-check. +unset MAKEFLAGS MFLAGS GNUMAKEFLAGS MAKEFILES MAKELEVEL MAKE MAKE_COMMAND MAKEOVERRIDES + +command -v make >/dev/null 2>&1 || + fail "make isn't installed, so make quality can't run. Install make, then commit again." +command -v uv >/dev/null 2>&1 || + fail "uv isn't installed, so make quality can't run. Install uv (https://docs.astral.sh/uv/), run make install, then commit again." +environment=${UV_PROJECT_ENVIRONMENT:-.venv} +[ -d "$environment" ] || + fail "this worktree has no dev environment ($environment). Run make install here, then commit again." + +# git -c core.quotePath=false shows names outside ASCII as they are, and +# core.ignorecase=false shows a case-only rename on a file system that ignores +# case as an untracked file. +names='git -c core.quotePath=false -c core.ignorecase=false' +newline=' +' + +unstaged=$($names diff --name-only) || + fail "git diff failed, so the hook can't tell what make quality would check." +[ -z "$unstaged" ] || + fail "make quality checks the working tree, which holds changes this commit doesn't. Stage exactly what this commit needs and run git commit without paths, or set the rest aside with git stash push --keep-index, then commit again: +$unstaged" + +# git ls-files -v marks skip-worktree files S and assume-unchanged ones with a +# lowercase letter. [[:lower:]], not [a-z]: bash 3.2, macOS's /bin/sh, matches +# uppercase letters with [a-z] in UTF-8 locales. (A loop, not a command +# substitution: bash 3.2 misreads a case pattern's parenthesis inside one.) +flags=$($names ls-files -v) || + fail "git ls-files failed, so the hook can't tell what make quality would check." +uncompared= +while IFS= read -r entry; do + case $entry in + [[:lower:]S]\ *) uncompared="$uncompared${uncompared:+$newline}${entry#??}" ;; + esac +done </dev/null)" ] || + continue + ;; + *.py | *.pyi | pyproject.toml | */pyproject.toml | setup.cfg | */setup.cfg | \ + mypy.ini | */mypy.ini | .mypy.ini | */.mypy.ini | ruff.toml | */ruff.toml | \ + .ruff.toml | */.ruff.toml | .importlinter | */.importlinter) ;; + *) continue ;; + esac + hidden="$hidden${hidden:+$newline}$path" +done <