diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..d46ef16 --- /dev/null +++ b/.githooks/pre-push @@ -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 diff --git a/README.md b/README.md index 389463e..13cf99a 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ npm run dev # Vite on 5173, Express on 3001 ### 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 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 | | `commit-msg` | refuses a message with no conventional type (`feat`, `fix`, `ui`, `docs`, `test`, `refactor`, `security`, `perf`, `chore`) | | `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 one disk away from gone — but it has a consequence: whatever documentation was diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 02defcd..e0ec9b7 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -69,7 +69,7 @@ of your own — how to write one that can actually fail. ## 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. | 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 | | `commit-msg` | refuses a message with no conventional type | | `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 once. An uninstalled hook fails silently, which is the same class of problem the