SLOPSHOPPER

commit-style

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

newguardprompt
★ 16v0.1.0MITupdated 2026-10-09rr-/dotfiles/cfg/claude/mods/commit-style
A shopper browsing a rack in a slop shop
README

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.

Repository structure
  • bin/: custom tools
  • cfg/: configuration and installers for third party programs
  • libdotfiles/: code that powers the installers
  • opt/: projects too big to fit in bin/

Installing a module

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.

Source 2 files
hooks/register.ts 27 lines
1import 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}
27
hooks/message.ts 74 lines
1const 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