Commit messages without the Co-Authored-By trailer, following the 50/72 rule and no conventional commits

dotfiles
This repository contains configuration files and scripts tailored to my needs or preferences. Some of these might prove useful to other people. The most custom goodies are located in the bin/ directory.
bin/: custom toolscfg/: configuration and installers for third party programslibdotfiles/: code that powers the installersopt/: projects too big to fit in bin/Every module can be installed with ./install like this:
./install zsh
./install nvim
./install wezterm
Most things are installed using symbolic links.
The installation scripts also try to install relevant packages using various package managers, e.g. ./install zsh will try downloading zsh with a package manager relevant to the current Linux distribution.
hooks/register.ts 27 lines1import type { Register } from 'claude-code'
2import { RULES, extractMessage, findProblems } from './message'
3
4export const register: Register = on => {
5 on('attribution.text', { kind: 'commit' }, () => ({ text: '' }))
6
7 on('prompt.compose', async ($, e, next) => {
8 const { sections } = await next(e)
9 return {
10 sections: [
11 ...sections,
12 { id: `${$.plugin.name}:rules`, text: RULES, scope: 'session' },
13 ],
14 }
15 })
16
17 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
18 const message = extractMessage(e.command)
19 const problems = message === null ? [] : findProblems(message)
20 return problems.length > 0
21 ? {
22 deny: `${$.plugin.name}: fix the commit message:\n- ${problems.join('\n- ')}`,
23 }
24 : next(e)
25 }).catch(($, e, next) => next(e))
26}
27hooks/message.ts 74 lines1const SUBJECT_MAX = 50
2const BODY_MAX = 72
3
4const CONVENTIONAL =
5 /^(feat|fix|docs|style|refactor|perf|tests?|build|ci|chore|revert)(\([^)]*\))?!?:/i
6const TRAILER = /^co-authored-by:/im
7const GIT_COMMIT = /\bgit\b(\s+-\S+(\s+[^-\s]\S*)?)*\s+commit\b/
8const HEREDOC = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n\s*\2\b/
9const MESSAGE_FLAG =
10 /(?:^|\s)(?:-m|--message)(?:=|\s+)("((?:[^"\\]|\\.)*)"|'([^']*)')/g
11
12export const RULES = `\
13When writing a git commit message:
14- The subject is "scope: summary in imperative mood", e.g. \
15"cfg/tmux: give panes an explicit foreground". Use a scope when the \
16repository's history uses them; never a conventional commits type such as \
17"feat:", "fix:" or "chore(x):".
18- The subject is at most ${SUBJECT_MAX} characters, with no trailing period.
19- If a body is needed, leave a blank line after the subject, then write the \
20body in third person ("Moves the search box so it no longer..."), wrapped \
21at ${BODY_MAX} characters.
22- Never add a Co-Authored-By trailer or any other attribution lines.`
23
24const unescapeDouble = (text: string): string =>
25 text.replace(/\\([\\"$`])/g, '$1')
26
27/** The message a `git commit` command passes inline, or null if none. */
28export const extractMessage = (command: string): string | null => {
29 if (!GIT_COMMIT.test(command)) {
30 return null
31 }
32 const heredoc = HEREDOC.exec(command)
33 if (heredoc) {
34 return heredoc[3]
35 }
36 const parts = [...command.matchAll(MESSAGE_FLAG)].map(m =>
37 m[2] !== undefined ? unescapeDouble(m[2]) : m[3],
38 )
39 return parts.length > 0 ? parts.join('\n\n') : null
40}
41
42/** Why the message breaks the house style, one line per problem. */
43export const findProblems = (message: string): string[] => {
44 const lines = message.trim().split('\n')
45 const subject = lines[0].trim()
46 const problems: string[] = []
47
48 if (TRAILER.test(message)) {
49 problems.push('remove the Co-Authored-By trailer')
50 }
51 if (CONVENTIONAL.test(subject)) {
52 problems.push(
53 'do not use conventional commits; write "scope: imperative summary"',
54 )
55 }
56 if (subject.length > SUBJECT_MAX) {
57 problems.push(
58 `shorten the subject to ${SUBJECT_MAX} characters (it has ${subject.length})`,
59 )
60 }
61 if (lines.length > 1 && lines[1].trim() !== '') {
62 problems.push('leave a blank line between the subject and the body')
63 }
64 const longLines = lines
65 .slice(1)
66 .filter(line => line.length > BODY_MAX && /\s/.test(line.trim()))
67 if (longLines.length > 0) {
68 problems.push(
69 `wrap the body at ${BODY_MAX} characters (${longLines.length} line(s) are longer)`,
70 )
71 }
72 return problems
73}
74