Skip to content
Merged
140 changes: 140 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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 <<EOF
$flags
EOF
[ -z "$uncompared" ] ||
fail "git doesn't compare these files with the commit (skip-worktree or assume-unchanged), so make quality could check copies the commit doesn't hold. Run git update-index --no-skip-worktree --no-assume-unchanged on them, then commit again:
$uncompared"

untracked=$($names ls-files --others --exclude-standard) ||
fail "git ls-files failed, so the hook can't tell what make quality would check."
[ -z "$untracked" ] ||
fail "make quality checks the working tree, which holds files git doesn't track. Add them, remove them, or list them in .git/info/exclude (Python files and tool settings aside, which the checks read even then), then commit again:
$untracked"

# Python files and tool settings that only .git/info/exclude or a global
# excludes file hides: the checks read them all the same. --directory lists a
# wholly untracked directory once: a virtual environment (with pyvenv.cfg) is
# left out, and another counts if it holds Python files or tool settings.
entries=$($names ls-files --others --directory --exclude-per-directory=.gitignore) ||
fail "git ls-files failed, so the hook can't tell what make quality would check."
hidden=
while IFS= read -r path; do
case $path in
'') continue ;;
*/)
[ -f "${path}pyvenv.cfg" ] && continue
[ -n "$(find "$path" \( -name '*.py' -o -name '*.pyi' -o -name pyproject.toml \
-o -name setup.cfg -o -name mypy.ini -o -name .mypy.ini -o -name ruff.toml \
-o -name .ruff.toml -o -name .importlinter \) -print 2>/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 <<EOF
$entries
EOF
[ -z "$hidden" ] ||
fail "these Python files or tool settings are hidden only by .git/info/exclude or a global excludes file, but ruff, mypy and Import Linter can read them, so the checks wouldn't see what the commit holds. Add them or remove them, then commit again:
$hidden"

# Tracked Python files that .git/info/exclude or a global excludes file
# matches: ruff skips them, but CI, which has neither, checks them.
excluded=$($names ls-files --cached --ignored --exclude-standard -- '*.py' '*.pyi') ||
fail "git ls-files failed, so the hook can't tell what make quality would check."
if [ -n "$excluded" ]; then
ignored_by_repository=$($names ls-files --cached --ignored \
--exclude-per-directory=.gitignore -- '*.py' '*.pyi') ||
fail "git ls-files failed, so the hook can't tell what make quality would check."
skipped=
while IFS= read -r path; do
case "$newline$ignored_by_repository$newline" in
*"$newline$path$newline"*) ;;
*) skipped="$skipped${skipped:+$newline}$path" ;;
esac
done <<EOF
$excluded
EOF
[ -z "$skipped" ] ||
fail "ruff would skip these tracked files, which .git/info/exclude or a global excludes file matches, but CI checks them. Remove the pattern that matches them, then commit again:
$skipped"
fi

make quality ||
fail "make quality failed. Fix what it reported above, then commit again."

fixed=$($names diff --name-only) ||
fail "git diff failed, so the hook can't tell whether make quality changed files."
[ -z "$fixed" ] ||
fail "make quality changed these files. Review the fixes, stage them, then commit again:
$fixed"
5 changes: 5 additions & 0 deletions .githooks/pre-merge-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
# git runs this hook, not pre-commit, before a merge that commits without
# conflicts (api.quality.pre-commit-hook). It runs the same checks. ${0%/*} is
# this hook's directory, without needing dirname.
exec "${0%/*}/pre-commit"
42 changes: 42 additions & 0 deletions .githooks/turn-on.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/bin/sh
# make install runs this to turn on the hooks in .githooks for the clone, with
# git config core.hooksPath .githooks (api.quality.pre-commit-hook). It
# changes nothing, and says why, when this directory isn't the root of a git
# working tree, when core.hooksPath already names another directory, when the
# clone has hooks of its own (files or symbolic links) that core.hooksPath
# would stop git running, or when git can't write the setting. It always
# exits 0, so make install still installs.
set -u

# Outside a git repository, such as a source archive, there's nothing to do.
prefix=$(git rev-parse --show-prefix 2>/dev/null) || exit 0
if [ -n "$prefix" ]; then
echo "make install: $(pwd) isn't the root of a git working tree (it is $prefix inside one), so the pre-commit hook isn't turned on." >&2
exit 0
fi

current=$(git config --get core.hooksPath)
if [ "$current" = ".githooks" ]; then
exit 0
fi
if [ -n "$current" ]; then
echo "make install: core.hooksPath is already $current, so the pre-commit hook isn't turned on. Run git config core.hooksPath .githooks to use it instead, or call .githooks/pre-commit from your own hook." >&2
exit 0
fi

# -L follows symbolic links, both a linked hooks directory and linked hooks.
hooks=$(git rev-parse --git-path hooks)
own=$(find -L "$hooks" -mindepth 1 ! -type d ! -name '*.sample' 2>/dev/null)
if [ -n "$own" ]; then
echo "make install: the pre-commit hook isn't turned on, because git would then stop running these hooks of the clone's own:
$own
Move them into .githooks, or call .githooks/pre-commit from them, then run git config core.hooksPath .githooks." >&2
exit 0
fi

if git config core.hooksPath .githooks; then
echo "make install: turned on the pre-commit hook for every worktree of this clone (core.hooksPath = .githooks)."
else
echo "make install: git couldn't set core.hooksPath (is .git/config locked by another git command?), so the pre-commit hook isn't turned on. Run make install again, or git config core.hooksPath .githooks." >&2
fi
exit 0
17 changes: 12 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,17 @@ named `changelog.d/<name>.<type>.md`, where `<type>` is `breaking`, `added`,
`changed`, `fixed` or `removed` (see "Changelog fragments" in that skill).
Agents must never bypass branch rules (for example with `gh pr merge --admin`).

Before committing, run `make quality`: it applies ruff's fixes, then runs
the checks pr.yml's `quality-guards` job runs, apart from the rules
registry tool's own tests (see "Commit Hygiene" in
`docs/engineering/skills/github-prs.md` for how a local run can differ).
Run `make install` once per clone: it installs the dev environment and turns
on the git hooks in `.githooks`, which run `make quality` before each
`git commit` and each merge commit, in every worktree of the clone. In a new
worktree, run `make install` again to create its `.venv`; the hook stops
commits until it exists. Stage everything the commit needs first: the hook
stops a commit while the working tree holds unstaged changes or untracked
files, because `make quality` would check them instead of what is
committed. `make quality` applies ruff's fixes, then runs the checks
pr.yml's `quality-guards` job runs, apart from the rules registry tool's
own tests (see "Commit Hygiene" in `docs/engineering/skills/github-prs.md`).
Don't skip the hook with `--no-verify`.

Before creating or sharing a migration PR, run the focused migration guards:

Expand Down Expand Up @@ -123,7 +130,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`.

### Code quality and types

- Checked: `api.quality.alembic-revisions-unused-imports`, `api.quality.ci-cd-scripts-ruff-exemptions`, `api.quality.command-line-modules-ruff-exemptions`, `api.quality.command-line-scripts-ruff-exemptions`, `api.quality.coverage-measured`, `api.quality.diff-coverage`, `api.quality.environment-variables-load`, `api.quality.file-length`, `api.quality.make-quality`, `api.quality.mypy-v2`, `api.quality.ruff-format`, `api.quality.ruff-lint`, `api.quality.runtime-evaluated-models-resolve`, `api.quality.suppressions-explained`, `api.quality.tests-assert`, `api.quality.tests-pass`, `api.quality.tests-ruff-exemptions`, `api.quality.unittest-ruff-exemptions`
- Checked: `api.quality.alembic-revisions-unused-imports`, `api.quality.ci-cd-scripts-ruff-exemptions`, `api.quality.command-line-modules-ruff-exemptions`, `api.quality.command-line-scripts-ruff-exemptions`, `api.quality.coverage-measured`, `api.quality.diff-coverage`, `api.quality.environment-variables-load`, `api.quality.file-length`, `api.quality.make-quality`, `api.quality.mypy-v2`, `api.quality.pre-commit-hook`, `api.quality.ruff-format`, `api.quality.ruff-lint`, `api.quality.runtime-evaluated-models-resolve`, `api.quality.suppressions-explained`, `api.quality.tests-assert`, `api.quality.tests-pass`, `api.quality.tests-ruff-exemptions`, `api.quality.unittest-ruff-exemptions`

### Observability

Expand Down
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
# 3.14 for other uv commands; set UV_PYTHON to test another version.
export UV_PYTHON ?= 3.12

# Turns on the hooks in .githooks, which run make quality before each commit
# (api.quality.pre-commit-hook), then installs the locked dev environment.
# core.hooksPath is shared by every worktree of the clone. .githooks/turn-on.sh
# says why it changes nothing outside the root of a git working tree, when
# core.hooksPath is already set, or when the clone has hooks of its own.
install:
sh .githooks/turn-on.sh
uv sync --frozen --extra dev

setup-env:
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ make install
make setup-env
```

`make install` runs `uv sync --frozen --extra dev`, which creates `.venv` with
Run `make install` once per clone, and in each new worktree to create its
`.venv`. It turns on the git hooks in `.githooks/`,
which run `make quality` before each `git commit` and each merge commit in
every worktree of the clone (`git config core.hooksPath .githooks`), and runs
`uv sync --frozen --extra dev`, which creates `.venv` with
exactly the versions in `uv.lock` on Python 3.12, the version the Cloud Run
image runs. `make test`, `make test-env-vars`, `make debug-test`, `make debug`,
`make debug-asgi`, `make typecheck-v2` and the towncrier step of
Expand Down
1 change: 1 addition & 0 deletions changelog.d/4206.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added git hooks, turned on by `make install`, that run `make quality` before each `git commit` and each merge commit in every worktree of the clone, and stop the commit when the working tree holds changes the commit doesn't, when a check fails, when ruff's fixes still need staging, or when `uv` or the dev environment is missing.
47 changes: 45 additions & 2 deletions docs/engineering/skills/github-prs.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,55 @@ the reason a code change or ruff setting couldn't remove the finding
## Commit Hygiene

AI agents must run the quality checks before committing their own code
changes:
changes. Run `make install` once per clone: it turns on the git hooks in
`.githooks` (through `git config core.hooksPath .githooks`), which run them
before each `git commit` and each merge commit
(`api.quality.pre-commit-hook`):

```bash
make quality
make install # once per clone, and once in each new worktree for its .venv
make quality # what the hook runs; run it yourself to check sooner
```

`.githooks/pre-commit` runs `make quality` from the root of the working
tree, and `.githooks/pre-merge-commit` runs it for a merge that commits
without conflicts. `make quality` checks the working tree, not the commit,
so the hook first requires the two to match. It stops the commit, naming
the files and saying what to do:

- when tracked files have unstaged changes: stage exactly what the commit
needs and run `git commit` without paths, or set the rest aside with
`git stash push --keep-index`. This covers fixes `make quality` made in an
earlier run, `git commit <paths>` while other files are changed, and a
rename that changes only a name's case;
- when there are untracked files outside the ignore files: add them, remove
them, or list them in `.git/info/exclude`;
- when Python files or tool settings (`pyproject.toml`, `setup.cfg`,
`mypy.ini`, `ruff.toml`, `.importlinter`) are hidden only by
`.git/info/exclude` or a global excludes file: ruff, mypy and Import
Linter can read them anyway, so add them or remove them. A virtual
environment (a directory with `pyvenv.cfg`) doesn't count;
- when those excludes match tracked Python files, which ruff then skips
but CI checks: remove the pattern;
- when tracked files are marked skip-worktree or assume-unchanged, which
`git diff` doesn't compare: clear the marks with
`git update-index --no-skip-worktree --no-assume-unchanged`;
- when a check fails;
- when ruff's fixes changed a file: review the fixes, stage them and
commit again;
- when `make`, `uv` or the worktree's dev environment (`.venv`, or
`UV_PROJECT_ENVIRONMENT`) is missing.

`core.hooksPath` is a setting of the clone, so every worktree runs the
hooks, each from its own checkout; a worktree on a branch from before the
hooks runs none. `make install` leaves `core.hooksPath` alone, and says
why, when it would change an enclosing repository, replace another
`core.hooksPath`, or stop the clone's own hooks in `.git/hooks`, and when
git can't write the setting; it never stops `make install`. git's
cherry-pick, revert, rebase and am can make commits without hooks. Don't
skip them with `git commit --no-verify`; CI's `Quality checks` job runs the
same checks and blocks the merge anyway.

`make quality` applies ruff's fixes (`ruff check --fix`, then `ruff format`),
then runs `make quality-check`: `uv lock --check`, `ruff check`,
`ruff format --check`, Import Linter, mypy and the quality guards
Expand Down
3 changes: 2 additions & 1 deletion docs/engineering/skills/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Use this skill whenever adding, moving, or reviewing tests.
Tests run in the environment `uv.lock` defines, on Python 3.12, the same
versions the Cloud Run image installs (`gcp/cloud_run/Dockerfile`):

- `make install` runs `uv sync --frozen --extra dev`, and `make test` runs
- `make install` turns on the git hooks in `.githooks` and runs
`uv sync --frozen --extra dev`, and `make test` runs
pytest and coverage through `uv run --frozen --extra dev`. The Makefile sets
`UV_PYTHON=3.12` unless it is already set, because `.python-version` selects
3.14.
Expand Down
Loading
Loading