Files
Dash ada8d5b6ed build: switch hooks to prek, check 50/72
prek replaces pre-commit as the hook runner and reads the same
config. A new commit-msg hook in .prek/ rejects subjects over 50
characters, subjects without a module prefix, and body lines over
72 characters. Install the hooks with `uv run prek install`, and run
them with `just lint`.
2026-10-17 15:55:55 +02:00

96 lines
3.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail the commit if its message breaks the 50/72 rule.
Subject line <= 50 chars, a blank line, then body wrapped at <= 72 chars.
The subject also has to open with a module prefix. Wired in as a
prek/pre-commit commit-msg hook so it is enforced mechanically instead of by
memory.
"""
import re
import sys
from collections.abc import Sequence
from pathlib import Path
MAX_SUBJECT = 50
MAX_BODY = 72
# `module-prefix: description`, the prefix at the very start of the subject.
# A prefix names a file, so it spells the name as the file does: underscores
# within a name, `/` between folders, `+` between two modules.
SUBJECT_RE = re.compile(r"^[a-z0-9][a-z0-9_+./]*: \S")
# Subjects git or a rebase writes itself, which the rule is not about.
EXEMPT_PREFIXES = ("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ")
def main(argv: Sequence[str]) -> int:
if len(argv) != 2:
sys.stderr.write(f"Usage: {argv[0]} COMMIT_MSG_FILE\n")
return 2
message_path = Path(argv[1])
with message_path.open(encoding="utf-8") as handle:
raw_lines = handle.read().splitlines()
# `git commit -v` appends a diff below a scissors line; ignore it.
cut = next(
(
i
for i, line in enumerate(raw_lines)
if ">8" in line and line[:1] == "#"
),
None,
)
if cut is not None:
raw_lines = raw_lines[:cut]
lines = [line for line in raw_lines if not line.startswith("#")]
while lines and not lines[-1].strip():
lines.pop()
if not lines:
return 0
errors: list[str] = []
subject = lines[0]
if not subject.startswith(EXEMPT_PREFIXES):
if len(subject) > MAX_SUBJECT:
errors.append(
f"subject is {len(subject)} chars (max {MAX_SUBJECT})"
)
if not SUBJECT_RE.match(subject):
head = subject.split(":")[0]
if "," in head:
errors.append(
f"prefix `{head}` separates modules with a comma; join"
" them with `+`"
)
elif "-" in head:
errors.append(
f"prefix `{head}` uses a dash; spell the module as its"
f" file does (`{head.replace('-', '_')}`)"
)
else:
errors.append(
"subject must open with a lowercase module prefix, as in"
" `mechanic: shorten quests`"
)
if len(lines) > 1 and lines[1].strip():
errors.append("second line must be blank (subject, blank, body)")
for number, line in enumerate(lines[2:], start=3):
if len(line) > MAX_BODY:
errors.append(
f"body line {number} is {len(line)} chars (max {MAX_BODY})"
)
if errors:
sys.stderr.write("Commit message breaks the 50/72 rule:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))