chore: port the existing pre-push hook into .githooks before activating hooksPath

This checkout already had a pre-push in .git/hooks. Setting core.hooksPath
redirects every hook to .githooks/, so activating the template's hooks would
have silently stopped it running — which is the exact failure the versioned
hooks directory exists to prevent, applied to a hook.

Kept its two working-tree checks: a push that leaves edits behind is how docs
end up one commit adrift of the code they describe.

Dropped its third. It refused whenever the branch was ahead of its remote —
the precondition for pushing at all — so it fired on every real push and its
only advice was to re-run with --no-verify. A guard that can never pass teaches
people to bypass the two beside it that can. It did exactly that on the push of
the adoption commits.

Both remaining checks proven to refuse a dirty tree and pass a clean one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ripley 2026-08-18 01:22:33 -05:00
parent aa1d805e04
commit 8e186fdf3f
3 changed files with 75 additions and 2 deletions

62
.githooks/pre-push Executable file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# Nothing uncommitted goes out with a push.
#
# ## Where this came from
#
# There was already a `pre-push` in `.git/hooks` on this checkout when the
# template was adopted on 2026-08-18. Setting `core.hooksPath` to this directory
# would have silently stopped it running — the whole class of failure these
# hooks exist to prevent, applied to a hook — so its useful half was moved here,
# where it is versioned and every clone gets it.
#
# ## What was kept, and what was dropped
#
# **Kept:** the two working-tree checks. A push that leaves edits behind is how
# documentation ends up one commit adrift of the code it describes, and
# `docs/WORK_CYCLE.md` is built on those travelling together.
#
# **Dropped:** its third check, which refused the push when the branch was ahead
# of its remote. That is the precondition for pushing at all, so it fired on
# every real push and its only instruction was to re-run with `--no-verify` — a
# guard that can never pass teaches people to bypass the two beside it that can.
#
# ## Escape hatch
#
# git push --no-verify
#
# Deliberately not silent about it: the checks below print what they found
# before refusing, so an intentional bypass is a decision with the evidence in
# front of it.
#
# Exit 0 nothing outstanding, 1 something is.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 1
say() { printf '\033[1mpre-push:\033[0m %s\n' "$*" >&2; }
fail=0
if ! git diff --quiet --exit-code; then
say "uncommitted working-tree changes:"
git diff --name-only | sed 's/^/ /' >&2
fail=1
fi
if ! git diff --cached --quiet --exit-code; then
say "staged but uncommitted changes:"
git diff --cached --name-only | sed 's/^/ /' >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
say ""
say "push refused. Commit or stash the above first — a push that leaves edits"
say "behind is how the documents end up describing a commit nobody is running."
say "To push anyway: git push --no-verify"
exit 1
fi
exit 0

View File

@ -329,7 +329,7 @@ npm run dev # Vite on 5173, Express on 3001
### The git hooks ### The git hooks
Three hooks live in `.githooks/` because `.git/hooks` is not versioned. **The Four hooks live in `.githooks/` because `.git/hooks` is not versioned. **The
`core.hooksPath` line above is per clone**, so every checkout runs it once; an `core.hooksPath` line above is per clone**, so every checkout runs it once; an
uninstalled hook fails silently. uninstalled hook fails silently.
@ -338,6 +338,7 @@ uninstalled hook fails silently.
| `pre-commit` | scans the staged diff for credentials, then runs `npm run build` if source is staged | | `pre-commit` | scans the staged diff for credentials, then runs `npm run build` if source is staged |
| `commit-msg` | refuses a message with no conventional type (`feat`, `fix`, `ui`, `docs`, `test`, `refactor`, `security`, `perf`, `chore`) | | `commit-msg` | refuses a message with no conventional type (`feat`, `fix`, `ui`, `docs`, `test`, `refactor`, `security`, `perf`, `chore`) |
| `post-commit` | **pushes to `origin`** | | `post-commit` | **pushes to `origin`** |
| `pre-push` | refuses a push that leaves uncommitted or staged edits behind |
**`post-commit` pushes.** That is deliberate — work that exists on one laptop is **`post-commit` pushes.** That is deliberate — work that exists on one laptop is
one disk away from gone — but it has a consequence: whatever documentation was one disk away from gone — but it has a consequence: whatever documentation was

View File

@ -69,7 +69,7 @@ of your own — how to write one that can actually fail.
## The hooks ## The hooks
Three, in `.githooks/`, because `.git/hooks` is not versioned and a hook living Four, in `.githooks/`, because `.git/hooks` is not versioned and a hook living
there protects exactly one clone. there protects exactly one clone.
| Hook | What it runs here | | Hook | What it runs here |
@ -77,6 +77,16 @@ there protects exactly one clone.
| `pre-commit` | `scripts/secrets.sh` on the staged diff, then `npm run build` when source is staged | | `pre-commit` | `scripts/secrets.sh` on the staged diff, then `npm run build` when source is staged |
| `commit-msg` | refuses a message with no conventional type | | `commit-msg` | refuses a message with no conventional type |
| `post-commit` | **pushes to `origin`** | | `post-commit` | **pushes to `origin`** |
| `pre-push` | refuses a push that leaves uncommitted or staged edits behind |
`pre-push` is not the template's — it is the useful half of a hook that was
already sitting in this checkout's `.git/hooks` before adoption. Setting
`core.hooksPath` would have silently stopped that one running, which is exactly
the failure this directory exists to prevent, so it was moved here instead. Its
third check went: it refused whenever the branch was ahead of its remote, which
is the precondition for pushing at all, so it fired on every real push and told
you to re-run with `--no-verify`. A guard that can never pass teaches people to
bypass the ones beside it.
`git config core.hooksPath .githooks` is per clone, so every checkout runs it `git config core.hooksPath .githooks` is per clone, so every checkout runs it
once. An uninstalled hook fails silently, which is the same class of problem the once. An uninstalled hook fails silently, which is the same class of problem the