SLOPSHOPPER

commit-lint

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

newguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commit-lint
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /commit-lint ⎿ commit-lint: commit-lint is ON. Header max: 100 characters. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

commit-lint

Refuses git commit commands whose message subject is not a Conventional Commit.

What it does

  • Checks each 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.
  • Reads the message from -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).
  • Looks through rtk, env assignments, git -C/-c, and chains such as git add . && git commit -m "...".
  • Allows commands without a message (--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.

Options

OptionDefaultEffect
maxHeader100Longest allowed first line.

Commands

CommandEffect
/commit-lint statusShow whether the check is on.
/commit-lint offStop checking for this session; status line shows commit-lint: OFF.
/commit-lint onTurn it back on.

Works with no-coauthor

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.

Install

claude --plugin-dir /path/to/ModsTools/mods/commit-lint

Limits

  • Reads the command line word by word: messages built by variables, scripts, bash -c, aliases or git commit -F - with a heredoc on stdin are not seen (and are allowed).
  • Only the commit command is checked, not hooks or commits made by other tools.

Develop

claude plugin validate mods/commit-lint
claude plugin test mods/commit-lint   # 19 tests
Source 3 files
hooks/register.ts 63 lines
1import { 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}
63
hooks/rules.ts 146 lines
1export 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}
146
types/index.d.ts 8 lines
1export type CommitLintSwitch = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'commit-lint': { isOff: CommitLintSwitch }
6  }
7}
8