Commit-time gates (pre-commit)
Commit-time gates
Section titled “Commit-time gates”Every drift gate runs at some point in the lifecycle of a change: at commit, at PR/CI, at compile, or at runtime. Earlier is cheaper — a gate that fails on git commit costs seconds; the same failure caught in production costs an incident. The pre-commit subsystem is the earliest tier: hooks that run on every commit, before code ever reaches CI.
This page is the canonical reference for that subsystem. It is the commit-time slice of the broader gate wall described in the glossary.
The timing ladder
Section titled “The timing ladder”A gate’s value is inseparable from when it fires.
| Tier | Fires on | Feedback latency | Example gates |
|---|---|---|---|
| Commit-time | git commit (pre-commit hooks) | seconds, local | secret scan, buf lint, migration discipline |
| CI / PR-time | every pushed PR | minutes, remote | check-vocab, check-mocks, check-sqlc, the full lint umbrella |
| Compile-time | the build | inside the build | var _ ServiceHandler = (*Service)(nil), exhaustive matchState |
| Runtime | a request, a boot | production | DB CHECK constraints, config Validate() at startup |
A gate placed at commit-time gives the tightest loop a human ever gets: the mistake is refused before it is even recorded. The same rule, left to runtime, is an outage.
The hooks
Section titled “The hooks”Seven hooks run on every commit (.pre-commit-config.yaml). They are deliberately the checks that are cheap to run locally and expensive to discover late.
| Hook | Gates | Why it runs at commit-time |
|---|---|---|
scan-secrets | gitleaks + trufflehog — no secret reaches a commit | A leaked secret is unrecoverable once pushed; the only safe place to catch it is before the commit object exists. |
strip-ai-coauthors | removes AI co-author trailers from messages | A message-shape rule with no reason to wait for CI. |
buf-lint | the proto contract stays well-formed | The contract is the BE↔FE seam; a malformed proto should never enter history. |
migration-discipline | migration naming + append-only | An edited applied migration is a data-integrity hazard; refuse it at the source. |
squawk-migrations | migration backwards-compatibility | A backwards-incompatible migration is caught before it can be reviewed as if it were safe. |
check-connect-errors | connect.NewError only inside errorkit/ | Keeps the typed-error boundary intact — one place translates application failures into wire failures. |
check-sql-scope | owner-scoped tables require a user_id filter | A missing tenant filter is a security bug; the cheapest place to refuse it is before commit. |
Install and operate
Section titled “Install and operate”The hooks are wired by the standard dev bootstrap and managed with make:
make hooks-install # wire the git hooks (bootstraps pre-commit into a project-local .venv)make hooks-check # run the hooks against the working tree without committingmake hooks-uninstall # remove the hookspre-commit runs in an isolated project-local .venv — it never touches a global Python. buf and squawk come with the pnpm install; gitleaks/trufflehog run from Docker, and the hook degrades gracefully (skips with a warning) when Docker is unavailable.
Relationship to the CI wall
Section titled “Relationship to the CI wall”Commit-time and CI-time are not redundant; they are the same discipline at two latencies. Some gates appear at both tiers on purpose: buf lint, check-connect-errors, and the migration checks run as pre-commit hooks and in CI, so a contributor who bypasses local hooks (git commit --no-verify) is still caught at the PR. The commit-time copy exists for the feedback loop; the CI copy exists for the guarantee.
The full gate wall — across commit, CI, compile, and runtime — is collected in Are We Drifting? Part 14.