Refuses git commit commands whose message subject is not a Conventional Commit (type(scope)!: subject, header within a length limit)

Refuses git commit commands whose message subject is not a Conventional Commit.
Bash call containing git commit and denies it with the expected format when the subject does not match type(scope)?!?: subject, with type one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, a non-empty subject, and a header of at most maxHeader characters.-m/--message (the first -m is the subject, also in -am), from -m "$(cat <<'EOF' ... EOF)" heredocs (first line), and from -F file/--file (read relative to the session folder, or to the last cd/git -C folder; unreadable or - stdin: allowed).rtk, env assignments, git -C/-c, and chains such as git add . && git commit -m "...".--amend --no-edit, -C, --reuse-message, plain git commit opening an editor), merge commits (Merge ...), fixup!/squash! subjects and Revert "..." subjects, and subjects that are an unresolved $(...)/$VAR.| Option | Default | Effect |
|---|---|---|
maxHeader | 100 | Longest allowed first line. |
| Command | Effect |
|---|---|
/commit-lint status | Show whether the check is on. |
/commit-lint off | Stop checking for this session; status line shows commit-lint: OFF. |
/commit-lint on | Turn it back on. |
no-coauthor rewrites the same commands to drop Claude trailers. commit-lint only reads the subject (the first line), which no-coauthor never changes, so the order of the two does not matter.
claude --plugin-dir /path/to/ModsTools/mods/commit-lint
bash -c, aliases or git commit -F - with a heredoc on stdin are not seen (and are allowed).claude plugin validate mods/commit-lint
claude plugin test mods/commit-lint # 19 testshooks/register.ts 63 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DEFAULT_MAX_HEADER, TYPES, commitMessages, firstLine, lint, resolveFile } from './rules'
5import type { CommitInfo } from './rules'
6
7const isOff = atom({ plugin: 'commit-lint', key: 'isOff' } as const, false)
8
9async function subjectOf($: EngineInterface, info: CommitInfo): Promise<string | null> {
10 if (info.subject !== null) return info.subject
11 if (info.file === null || info.file === '-') return null
12 const text = await $.fs.read(resolveFile(info.file, info.dir, await $.session.cwd())).catch(() => null)
13
14 return typeof text === 'string' ? firstLine(text) : null
15}
16
17async function problem($: EngineInterface, command: string, maxHeader: number): Promise<string | null> {
18 for (const info of commitMessages(command)) {
19 const subject = await subjectOf($, info)
20 const reason = subject === null ? null : lint(subject, maxHeader)
21 if (reason !== null) return reason
22 }
23
24 return null
25}
26
27export const register: Register = (on, options) => {
28 const maxHeader = typeof options?.maxHeader === 'number' && options.maxHeader > 0 ? options.maxHeader : DEFAULT_MAX_HEADER
29
30 on('session.start', async ($, e, next) => {
31 await $.command.register({
32 name: 'commit-lint',
33 description: 'commit-lint on|off|status: refuse commit messages that are not Conventional Commits',
34 })
35
36 return next(e)
37 })
38
39 on('command.run', { command: 'commit-lint' }, async ($, e) => {
40 const arg = e.args.trim()
41 if (arg === 'off' || arg === 'on') {
42 await update($, isOff, () => arg === 'off')
43 $.ui.status(arg === 'off' ? 'commit-lint: OFF' : undefined)
44 }
45 const off = await read($, isOff)
46
47 return { text: `commit-lint is ${off ? 'OFF for this session' : 'ON'}. Header max: ${maxHeader} characters.` }
48 })
49
50 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
51 if (!/\bcommit\b/.test(e.command) || (await read($, isOff))) return next(e)
52 const reason = await problem($, e.command, maxHeader)
53 if (reason === null) return next(e)
54 $.ui.toast(`commit-lint blocked: ${reason}`)
55
56 return {
57 deny:
58 `commit-lint: refused, ${reason}. Expected "type(scope)?!?: subject" with type one of ${TYPES.join(', ')}, ` +
59 `header up to ${maxHeader} characters, e.g. "feat(api): add retry". Fix the message and run the commit again, or stop the checks with /commit-lint off.`,
60 }
61 })
62}
63hooks/rules.ts 146 lines1export const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']
2export const DEFAULT_MAX_HEADER = 100
3
4const HEADER = new RegExp(`^(?:${TYPES.join('|')})(?:\\([^()\\s][^()]*\\))?!?: \\S`)
5// Subjects git or its tools write themselves.
6const EXEMPT = /^(?:Merge |(?:fixup|squash|amend)! |Revert ")/
7// `$(cat <<'EOF' ... EOF)`: the heredoc form of a -m argument.
8const HEREDOC = /^\$\(\s*cat\s*<<-?\s*(["']?)(\w+)\1[ \t]*\n([\s\S]*?)\n[ \t]*\2[ \t]*\n?\s*\)$/
9
10export type CommitInfo = {
11 subject: string | null // first line of the first -m, or null
12 file: string | null // -F / --file argument when there is no -m
13 dir: string | undefined // last `cd` or `git -C` folder before the commit
14}
15
16// The words of each simple command of a shell line, quotes removed.
17// A quoted argument stays one word, newlines and heredocs included.
18export function shellWords(command: string): string[][] {
19 const statements: string[][] = []
20 let words: string[] = []
21 let word = ''
22 let inWord = false
23 const endWord = () => {
24 if (inWord) words.push(word)
25 word = ''
26 inWord = false
27 }
28 const endStatement = () => {
29 endWord()
30 if (words.length > 0) statements.push(words)
31 words = []
32 }
33
34 for (let i = 0; i < command.length; i++) {
35 const c = command[i]!
36 if (c === "'") {
37 const end = command.indexOf("'", i + 1)
38 word += command.slice(i + 1, end === -1 ? undefined : end)
39 inWord = true
40 i = end === -1 ? command.length : end
41 } else if (c === '"') {
42 inWord = true
43 for (i++; i < command.length && command[i] !== '"'; i++) {
44 if (command[i] === '\\' && '"\\$`\n'.includes(command[i + 1] ?? 'x')) i++
45 word += command[i] ?? ''
46 }
47 } else if (c === '\\') {
48 if (command[i + 1] !== '\n') {
49 word += command[i + 1] ?? ''
50 inWord = true
51 }
52 i++
53 } else if (c === ';' || c === '&' || c === '|' || c === '\n') endStatement()
54 else if (c === ' ' || c === '\t') endWord()
55 else {
56 word += c
57 inWord = true
58 }
59 }
60 endStatement()
61
62 return statements
63}
64
65// The words after `git` and its global options (`-C dir` kept apart), or null when not git.
66function gitCommand(words: string[]): { args: string[]; dir: string | undefined } | null {
67 let i = 0
68 while (words[i] !== undefined && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) i++
69 if (words[i] === 'rtk') i++
70 if (words[i] !== 'git') return null
71 i++
72 let dir: string | undefined
73 while (words[i]?.startsWith('-')) {
74 if (words[i] === '-C') dir = words[i + 1]
75 i += words[i] === '-C' || words[i] === '-c' ? 2 : 1
76 }
77
78 return { args: words.slice(i), dir }
79}
80
81// First non-blank line of a message.
82export const firstLine = (text: string) => text.split('\n').find(l => l.trim() !== '') ?? ''
83
84function messageText(value: string): string {
85 const heredoc = HEREDOC.exec(value)
86
87 return heredoc === null ? value : heredoc[3]!
88}
89
90function parseCommit(args: string[]): Pick<CommitInfo, 'subject' | 'file'> {
91 let subject: string | null = null
92 let file: string | null = null
93 for (let i = 1; i < args.length; i++) {
94 const a = args[i]!
95 if (a === '-C' || a === '-c' || a === '--reuse-message' || a === '--reedit-message' || a.startsWith('--reuse-message=') || a.startsWith('--reedit-message=')) {
96 return { subject: null, file: null }
97 }
98 const message = a === '--message' ? args[++i] : a.startsWith('--message=') ? a.slice(10) : undefined
99 const short = /^-[asnveioqpu]*([mF])(.*)$/.exec(a)
100 if (a.startsWith('--')) {
101 const path = a === '--file' ? args[++i] : a.startsWith('--file=') ? a.slice(7) : undefined
102 if (path !== undefined && file === null) file = path
103 if (message !== undefined && subject === null) subject = firstLine(messageText(message))
104 } else if (short !== null) {
105 const value = short[2] !== '' ? short[2]! : args[++i]
106 if (value === undefined) continue
107 if (short[1] === 'm' && subject === null) subject = firstLine(messageText(value))
108 if (short[1] === 'F' && file === null) file = value
109 }
110 }
111
112 return { subject, file: subject === null ? file : null }
113}
114
115/** One entry per `git commit` of the line. */
116export function commitMessages(command: string): CommitInfo[] {
117 const found: CommitInfo[] = []
118 let cd: string | undefined
119 for (const words of shellWords(command)) {
120 if (words[0] === 'cd' && words[1] !== undefined) cd = words[1]
121 const git = gitCommand(words)
122 if (git === null || git.args[0] !== 'commit') continue
123 found.push({ ...parseCommit(git.args), dir: git.dir ?? cd })
124 }
125
126 return found
127}
128
129export function resolveFile(file: string, dir: string | undefined, cwd: string): string {
130 if (file.startsWith('/')) return file
131 const base = dir === undefined ? cwd : dir.startsWith('/') ? dir : `${cwd}/${dir}`
132
133 return `${base}/${file}`
134}
135
136/** Why the subject is not acceptable, or null when it is (or cannot be judged). */
137export function lint(subject: string, maxHeader: number): string | null {
138 const header = subject.trimEnd()
139 if (header === '') return 'the commit subject is empty'
140 if (header.startsWith('$') || EXEMPT.test(header)) return null
141 if (!HEADER.test(header)) return `"${header.length > 60 ? `${header.slice(0, 57)}...` : header}" is not a Conventional Commit header`
142 if (header.length > maxHeader) return `the header is ${header.length} characters (max ${maxHeader})`
143
144 return null
145}
146types/index.d.ts 8 lines1export type CommitLintSwitch = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'commit-lint': { isOff: CommitLintSwitch }
6 }
7}
8