Skip to content

Commit 0c97928

Browse files
authored
fix(hooks): add local commitlint validation hook (#868)
* fix(hooks): add local commitlint validation hook Validates commit messages before execution, catching violations that would otherwise only fail in CI. Checks type-enum, type-case, subject-empty, subject-full-stop, header-max-length, and body/footer-max-line-length rules from config-conventional. * fix(hooks): skip heredoc commits and fix file-after-message regex Add a guard to skip heredoc-style commit messages ($(cat <<'EOF'...)) that cannot be validated pre-execution. Also fix the double-quote terminator regex to match trailing whitespace or end-of-string, which correctly handles `git commit -m "msg" src/foo.ts` patterns. Addresses Greptile P1 and P2 review feedback on #868.
1 parent 69c27b0 commit 0c97928

File tree

3 files changed

+158
-0
lines changed

3 files changed

+158
-0
lines changed

.claude/hooks/commitlint-check.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env node
2+
// commitlint-check.js — Local commitlint validation
3+
// Mirrors @commitlint/config-conventional + project commitlint.config.ts.
4+
// Called by commitlint-local.sh with the commit message as argv[1].
5+
6+
const MAX = 100;
7+
const TYPES = [
8+
"feat", "fix", "docs", "refactor", "test", "chore",
9+
"ci", "perf", "build", "style", "revert", "release", "merge",
10+
];
11+
12+
const msg = process.argv[2];
13+
if (!msg) process.exit(0);
14+
15+
// Skip merge commits (matches commitlint ignores config)
16+
if (/^merge[:\s]/i.test(msg)) process.exit(0);
17+
18+
const lines = msg.split("\n");
19+
const header = lines[0] || "";
20+
const errors = [];
21+
22+
// --- Header checks ---
23+
24+
// type-empty + subject-empty: header must match type(scope)?: subject
25+
const headerMatch = header.match(/^(\w+)(\(.+\))?(!)?:\s*(.*)$/);
26+
if (!headerMatch) {
27+
errors.push("header must match format: type(scope)?: subject");
28+
} else {
29+
const [, type, , , subject] = headerMatch;
30+
31+
// type-case: must be lowercase
32+
if (type !== type.toLowerCase()) {
33+
errors.push(`type must be lowercase: "${type}"`);
34+
}
35+
36+
// type-enum: must be in allowed list
37+
if (!TYPES.includes(type.toLowerCase())) {
38+
errors.push(`type "${type}" not in allowed types: ${TYPES.join(", ")}`);
39+
}
40+
41+
// subject-empty
42+
if (!subject || !subject.trim()) {
43+
errors.push("subject must not be empty");
44+
}
45+
46+
// subject-full-stop
47+
if (subject && subject.trimEnd().endsWith(".")) {
48+
errors.push("subject must not end with a period");
49+
}
50+
}
51+
52+
// header-max-length
53+
if (header.length > MAX) {
54+
errors.push(
55+
`header is ${header.length} chars (max ${MAX}): ${header.substring(0, 60)}...`
56+
);
57+
}
58+
59+
// --- Body/footer line length checks ---
60+
for (let i = 2; i < lines.length; i++) {
61+
if (lines[i].length > MAX) {
62+
errors.push(
63+
`line ${i + 1} is ${lines[i].length} chars (max ${MAX}): ${lines[i].substring(0, 60)}...`
64+
);
65+
}
66+
}
67+
68+
if (errors.length > 0) {
69+
// Output one error per line to stdout
70+
process.stdout.write(errors.join("\n"));
71+
process.exit(1);
72+
}

.claude/hooks/commitlint-local.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
# commitlint-local.sh — PreToolUse hook for Bash (git commit)
3+
# Validates commit message format locally before the commit runs,
4+
# catching violations that would fail CI commitlint.
5+
# Delegates validation to commitlint-check.js.
6+
7+
set -euo pipefail
8+
9+
INPUT=$(cat)
10+
11+
# Extract the command from tool_input JSON
12+
COMMAND=$(echo "$INPUT" | node -e "
13+
let d='';
14+
process.stdin.on('data',c=>d+=c);
15+
process.stdin.on('end',()=>{
16+
const p=JSON.parse(d).tool_input?.command||'';
17+
if(p)process.stdout.write(p);
18+
});
19+
" 2>/dev/null) || true
20+
21+
if [ -z "$COMMAND" ]; then
22+
exit 0
23+
fi
24+
25+
# Only trigger on git commit commands
26+
if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then
27+
exit 0
28+
fi
29+
30+
# Skip --amend without -m (reuses existing message)
31+
if echo "$COMMAND" | grep -qE '\-\-amend' && ! echo "$COMMAND" | grep -qE '\s-m\s'; then
32+
exit 0
33+
fi
34+
35+
# Skip heredoc-style messages (shell code only; can't validate pre-execution)
36+
if echo "$COMMAND" | grep -qE '\$\(cat <<'; then
37+
exit 0
38+
fi
39+
40+
# Extract the commit message from -m flag using node for robust parsing
41+
MSG=$(echo "$COMMAND" | node -e "
42+
let d='';
43+
process.stdin.on('data',c=>d+=c);
44+
process.stdin.on('end',()=>{
45+
const cmd = d;
46+
let msg = '';
47+
// Match -m \"...\" or -m '...'
48+
const dq = cmd.match(/-m\s+\"([\\s\\S]*?)\"(?:\s|$)/);
49+
if (dq) { msg = dq[1]; }
50+
else {
51+
const sq = cmd.match(/-m\s+'([^']*)'/);
52+
if (sq) { msg = sq[1]; }
53+
}
54+
// Unescape \\n to real newlines
55+
msg = msg.replace(/\\\\n/g, '\\n');
56+
process.stdout.write(msg);
57+
});
58+
" 2>/dev/null) || true
59+
60+
if [ -z "$MSG" ]; then
61+
exit 0
62+
fi
63+
64+
# Run commitlint checks
65+
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
66+
VIOLATIONS=$(node "$HOOK_DIR/commitlint-check.js" "$MSG" 2>/dev/null) || true
67+
68+
if [ -n "$VIOLATIONS" ]; then
69+
REASON="Commit message fails commitlint rules:"$'\n'"${VIOLATIONS}"$'\n'"Fix the message to match conventional commit format."
70+
node -e "
71+
console.log(JSON.stringify({
72+
hookSpecificOutput: {
73+
hookEventName: 'PreToolUse',
74+
permissionDecision: 'deny',
75+
permissionDecisionReason: process.argv[1]
76+
}
77+
}));
78+
" "$REASON"
79+
fi
80+
81+
exit 0

.claude/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-pr-body.sh\"",
2020
"timeout": 10
2121
},
22+
{
23+
"type": "command",
24+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/commitlint-local.sh\"",
25+
"timeout": 10
26+
},
2227
{
2328
"type": "command",
2429
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit.sh\"",

0 commit comments

Comments
 (0)