Skip to content

chore: add a commit message standard with normalizer and linter - #212

Open
gregggreg wants to merge 2 commits into
marcus:mainfrom
gregggreg:chore/commit-message-normalizer
Open

chore: add a commit message standard with normalizer and linter#212
gregggreg wants to merge 2 commits into
marcus:mainfrom
gregggreg:chore/commit-message-normalizer

Conversation

@gregggreg

Copy link
Copy Markdown

What

Adds an opt-in, documented commit message standard plus tooling that normalizes and validates it.

File Purpose
docs/commit-messages.md the standard: grammar, types, rules, examples
internal/commitmsg/ the rules — Normalize and Lint, table-driven tests
cmd/commitmsg/ CLI: normalize <file>, lint [file], lint --range <r> [--report-only]
scripts/commit-msg.sh the hook: normalize, then lint
.github/workflows/commit-lint.yml lints the commits this PR adds
CONTRIBUTING.md new — links to the standard and the hook install command
Makefile, README.md make install-hooks also installs commit-msg; new make commit-lint

The standard

Conventional Commits: type(scope)!: subject, types feat fix docs style refactor perf test build ci chore revert. Imperative lowercase subject, no trailing period, ≤72 chars. Blank line before the body. Trailers (Nightshift-Task, Nightshift-Ref, Co-Authored-By, …) are never rewritten or width-checked.

The normalizer fixes only mechanical problems — casing, the missing space after the colon, trailing periods, stray whitespace, blank-line placement, git comment/scissors stripping. It never rewraps or rewrites prose, which is what makes it idempotent and safe. Anything it cannot fix (too-long subject, unwrapped body) is reported by the linter for a human to fix.

Assumptions

  1. Conventional Commits is the target — most widely tooled, and already what most of this repo's recent history looks like.
  2. History is not rewritten and existing commits are not retroactively enforced. CI checks only the commits a PR adds.
  3. The hook is opt-in via make install-hooks.

Deviations from the original plan, and why

  • Hook installed by symlink into .git/hooks/, not core.hooksPath. The repo already installs scripts/pre-commit.sh that way. Switching to core.hooksPath would silently disable the pre-commit hook for everyone who already ran make install-hooks. Matching the existing convention was the safer call.
  • Body width: recommend 72, enforce 80. The plan specified 72. Running the linter over the last 78 commits in report-only mode showed a hard 72 rejects 40 commits on body width alone — mostly good Conventional Commits with a 73–80 char line. At 80 that drops to 9. The subject limit stays at 72. Both constants sit next to each other in internal/commitmsg/commitmsg.go if you want them tightened.
  • Tooling is Go rather than shell, matching the repo, so the tests run under the existing go test ./... CI job.

Width checks skip fenced code, indented blocks, trailers, and lines with an unwrappable token (a long URL or path). Messages git writes itself — Merge …, Revert "…", fixup!/squash! — are exempt entirely.

Verification

  • go build ./..., go vet ./..., go test ./... all pass.
  • 30+ table-driven cases covering normalization of messy input, idempotency (every case is normalized twice and compared), trailer preservation, and one case per valid/invalid rule; plus CLI-level tests for the file round-trip and exit codes.
  • Hook run end-to-end: a messy message is rewritten in place and accepted; Fixed all the things is rejected with the allowed-type list and a --no-verify hint.
  • This PR's own commit lints clean: commitmsg: checked 1 commit(s) in origin/main..HEAD, 0 with violations.

Note: the historical-violation numbers above are informational — nothing in this PR touches existing history.

🤖 Generated with Claude Code

Adopt Conventional Commits as the documented standard and add tooling
that both normalizes and validates it.

- docs/commit-messages.md defines the format, types, rules and examples
- internal/commitmsg holds the rules, with table-driven tests
- cmd/commitmsg normalizes a message file in place or lints a message
  or a rev-range
- scripts/commit-msg.sh runs both, installed opt-in by make install-hooks
- .github/workflows/commit-lint.yml checks the commits a PR adds

The normalizer only fixes mechanical problems and never rewrites prose,
so it is idempotent and leaves trailers untouched.

Nightshift-Task: commit-normalize
Nightshift-Ref: https://github.com/marcus/nightshift
Normalize walked backwards from the end of the message over any line
shaped like "Word: text", so a body paragraph ending in "Before: 2.1s."
or "Note: it is opt-in" was mistaken for a trailer block. That inserted
a blank line into the middle of the paragraph, contradicting the
standard's guarantee that the normalizer never rewrites prose, and it
silently exempted those lines from the body width check.

A trailing block now only counts as trailers when at least one of its
keys is one we recognize (Co-Authored-By, Signed-off-by, Refs, Fixes,
Nightshift-*, and friends). Hyphenated keys such as Reviewed-by are
still kept with a block anchored by a known key, since prose never has
that shape.

Nightshift-Task: commit-normalize
Nightshift-Ref: https://github.com/marcus/nightshift
@gregggreg

Copy link
Copy Markdown
Author

Review follow-up (iteration 2): fixed the confirmed defect where Normalize misread prose as a trailer block.

trailerRe matched any Word: text line, so walking backwards from the end of the message swallowed the last lines of an ordinary paragraph — fix: speed up parsing with a body ending in Before: 2.1s. / After: 0.3s. got a blank line inserted mid-paragraph, and those lines were silently exempted from the body-width check.

A trailing block now only counts as trailers when at least one of its keys is recognized (Co-Authored-By, Signed-off-by, Reviewed-by, Refs, Fixes, Closes, BREAKING CHANGE, Nightshift-*, …). Hyphenated keys are still kept with a block anchored by a known key, so Reviewed-by: above Co-Authored-By: is not split off. Prose is left exactly where the author put it and is width-checked normally.

Three new normalizer table cases (paragraph ending in Word:, closing Note: line, unknown-key trailer beside a known one) plus a lint case pinning that a long Note: … line is still reported as body-line-too-long. Docs updated to state the detection rule. go build, go vet, gofmt -l, go test ./... all pass; internal/commitmsg coverage 93.5%. Verified end to end through the installed hook: the repro message now round-trips byte-identical, while a messy Fix: Speed Up Parsing. with real trailers still normalizes and gets its blank line in the right place.

@gregggreg

Copy link
Copy Markdown
Author

Review note (iteration 3)

The trailer-misdetection defect from the first review is fixed in e529f13 (fix: stop treating prose as a commit message trailer).

A trailer block is now only recognized when at least one line uses a known trailer key (Co-Authored-By, Signed-off-by, Refs, Fixes, BREAKING CHANGE, Nightshift-*, …); hyphenated keys are accepted only inside a block already anchored by a known key. A paragraph ending in Before:, Note:, or TODO: is therefore left alone by normalize and is still subject to the body-width check in lint.

Verified on the exact repro from the review:

fix: speed up parsing

The parser rescanned the buffer on every token.
Before: 2.1s on the fixture corpus.
After: 0.3s.

normalize is now a no-op on it, and a long wrappable Note: … body line correctly reports body-line-too-long. Regression cases were added for both (normalizer table cases plus a lint test).

go build ./..., go vet ./..., gofmt -l, and go test ./... all pass (23 packages); internal/commitmsg is at 93.5% statement coverage.

For reviewers: this branch is chore/commit-message-normalizer in the marcus/nightshift checkout — the files only exist on disk when that branch is checked out, so please review from the branch or the PR diff rather than by searching the filesystem.

Sign up for free to 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