Skip to content

Install Git hooks and a CI gate to enforce commit‑message rules and staged checks - #313

Merged
jserv merged 5 commits into
mainfrom
git-hooks
Aug 20, 2026
Merged

Install Git hooks and a CI gate to enforce commit‑message rules and staged checks#313
jserv merged 5 commits into
mainfrom
git-hooks

Conversation

@jserv

@jservjserv commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary by cubic

Install Git hooks and a CI gate to enforce commit‑message rules and staged checks. Previously rules were documented but unenforced and hooks were opt‑in; now hooks auto‑install on first build, validate locally, and CI checks every PR commit with a shallow‑safe range.

  • .ci checkers take explicit paths; .ci/check-format.sh returns 1 for style diffs and 2 when the pinned formatter is unavailable; .ci/check-security.sh also accepts named paths and fails on unreadable inputs. The pre-commit hook runs these against the extracted index.
  • New hooks in scripts/: git-commit-msg.sh (rules), git-pre-commit.sh (format, comment reflow, banned APIs, whitespace, shellcheck), git-pre-push.sh (validates new non‑merge commits), check-commit-log.sh (shared), git-prepare-commit-msg.sh (injects rules; respects scissors and core.commentChar), install-git-hooks.sh (link/unlink without overwriting), and test-git-hooks.sh (self‑tests).
  • CI lint workflow: adds a “Commit messages” job that guards shallow fetch depth and runs scripts/check-commit-log.sh; runs hook self‑tests; extends shellcheck to scripts/.

Rollout and developer impact

  • Hooks install on the first make via symlinks; existing hooks are never replaced. Remove with make uninstall-hooks; reinstall with make install-hooks.
  • Missing local tools only warn in pre-commit (unpinned/absent clang-format, missing commentflow, missing shellcheck); CI enforces the same checks.
  • Commit‑message rules exempt merge/fixup/squash and ignore verbose diffs; bodies >72 chars and non‑ASCII/tab characters are rejected. Imperative mood and “what/why, not how” are best‑effort and still reviewer‑judged.
  • CI commit checks run only on pull requests; do not reduce the fetch depth below what the workflow computes. No contributor migration required.

Written for commit 40a1bd1. Summary will update on new commits.

Review in cubic

cubic-dev-ai[bot]

This comment was marked as resolved.

Both checkers selected their own inputs through git ls-files, so a
caller wanting the same patterns applied to different content had to
copy them. A pre-commit hook wants exactly that: it judges staged
content, which is not what the working tree holds during a partial
git add -p.
Named paths now win over the tracked set. With no arguments both
behave as before, which is how CI calls them.
Each also separates a file that is wrong from a file it could not
read, the convention check-commentflow.sh already documents: 1 for the
first, 2 for the second. A path that does not exist otherwise reaches
the diff and comes back as a file differing in its entirety, and a
machine without clang-format-22 comes back the same way, so a caller
acting on either would reject correct code over its own environment.
CONTRIBUTING.md states seven commit-message rules that no gate
enforced. Nothing in the tree read a commit message, so they held by
review or not at all.
The hooks check what can be decided mechanically and leave the rest to
a reviewer. Imperative mood and what-and-why are the two that cannot
be settled by a script, so they look only for the forms that are wrong
beyond argument: a past-tense or gerund opening verb, and a body that
announces it is about to describe the mechanism. Both were calibrated
against 400 commits of this history and reject none of them.
The expensive failure here is a false rejection, not a missed one, and
the exemptions are where that gets decided. A merge subject routinely
runs past 50 characters and rejecting one aborts the merge halfway, so
the six forms git writes are named in full rather than matched as
"Merge ", which is also how an ordinary imperative subject begins. A
verbose commit appends its diff below a scissors line that git strips
only after the hook runs, so the hook cuts there first rather than
linting the diff it was handed, and the message template splices in
above that line rather than below it, where git would discard it.
The pre-commit hook runs the CI checkers against the extracted index
rather than carrying a second copy of their patterns. A checker it
cannot reach, a formatter that is missing or unpinned, and a reflow
tool that could not parse a file all warn rather than block: none of
them is a defect in the change, and blocking on one teaches
contributors to pass --no-verify, which costs every check that does
work. What none of them may do is pass in silence.
pre-push fails closed on a revision walk it cannot complete. A
force-push advertises a remote tip the local repository may never have
fetched, and a failed walk would otherwise land in the same variable
an empty range does and read as "no commits to check".
test-git-hooks.sh covers each rule and the shapes that break a hook
rather than a message: an empty array under the bash 3.2 that a stock
macOS supplies, a push to a remote with no refs yet, an unpinned
formatter on an isolated PATH, and the hooks reached through the
symlinks that install them. Every check asserts a rejection somewhere,
because a clean commit and push succeed whether or not a hook ran.
Left to a make target, hooks reach whoever read the README carefully,
which is not the population that needs them. The first make of a fresh
clone now links them, once per tree, and says so in one line.
A stamp target rather than work done while the makefile is read, so a
dry run and a query stay inert. Both exist to answer a question
without touching the checkout, and writing symlinks is not answering.
The stamp records only a run the installer reported success for, so a
hooks directory that was briefly unavailable is retried rather than
remembered as done, and neither failure stops the build.
Hung off compilation and the help screen, which is to say anything
that builds and the thing a bare make prints, rather than off
MAKECMDGOALS. Naming the goals would give every one of them a rule,
including the misspelled ones: a typo that stops today with "No rule
to make target" would instead find an empty rule, report nothing to be
done, and exit 0. Every binary here reaches the two object rules, host
unit tests included, so one prerequisite there covers every build
entry point without listing any of them.
A hook already in place is never replaced, since it belongs to whoever
put it there. uninstall-hooks enumerates the links rather than the
scripts, so a hook whose script a branch removed is still removable,
and a tarball export with no git dir does nothing at all.
The test for a linked worktree carrying the scripts before the primary
one does lands here rather than with the hooks, because it drives the
install through this makefile and would fail on the commit before it.
The seven rules were documented as enforced by continuous integration
while nothing in the workflows read a commit message. The gate runs
the same script the local hook does, so a contributor with hooks
installed and one without are judged by one implementation rather than
two that drift.
The checkout is shallow, and a shallow range comes back wrong rather
than failing: rev-list exits 0 having listed whatever it could reach.
Both ends can be truncated and the two look nothing alike. Fetched at
depth 1 the base has no known ancestry, so the range excludes the base
commit and nothing else, and every commit below the fork point stays
in it; that is the ordinary case the moment the base branch moves on,
and it comes back longer, not shorter. So both ends are fetched to the
same depth, merge-base has to succeed before the range is trusted, and
the reachable count is still compared against the number the pull
request reports, which is what catches truncation at the head end.
The hook self-test runs here too, because the gate and the hooks are
the same rules and a regression in one is a regression in both.
shellcheck widens to scripts/, where the hooks now live. A hook that
aborts on its own shell is worse than no hook: it teaches contributors
to pass --no-verify.
The contributing guide said continuous integration enforced the
commit-message conventions, which was true of no check in the tree
until the gate landed. Both documents now describe what runs, when it
runs, and which two rules a script can only approximate.
@jservjserv changed the title Git hooksInstall Git hooks and a CI gate to enforce commit‑message rules and staged checksAug 20, 2026
@jserv
jserv merged commit 7cc000b into mainAug 20, 2026
18 checks passed
@jserv
jserv deleted the git-hooks branch August 20, 2026 15:03
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@jserv