From 67721d4b11c52fe89de595c83a7fa426af40f833 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:35:27 -0600 Subject: [PATCH 1/2] 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. --- .claude/hooks/commitlint-check.js | 72 +++++++++++++++++++++++++++++ .claude/hooks/commitlint-local.sh | 76 +++++++++++++++++++++++++++++++ .claude/settings.json | 5 ++ 3 files changed, 153 insertions(+) create mode 100644 .claude/hooks/commitlint-check.js create mode 100644 .claude/hooks/commitlint-local.sh diff --git a/.claude/hooks/commitlint-check.js b/.claude/hooks/commitlint-check.js new file mode 100644 index 00000000..d93e56b9 --- /dev/null +++ b/.claude/hooks/commitlint-check.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// commitlint-check.js — Local commitlint validation +// Mirrors @commitlint/config-conventional + project commitlint.config.ts. +// Called by commitlint-local.sh with the commit message as argv[1]. + +const MAX = 100; +const TYPES = [ + "feat", "fix", "docs", "refactor", "test", "chore", + "ci", "perf", "build", "style", "revert", "release", "merge", +]; + +const msg = process.argv[2]; +if (!msg) process.exit(0); + +// Skip merge commits (matches commitlint ignores config) +if (/^merge[:\s]/i.test(msg)) process.exit(0); + +const lines = msg.split("\n"); +const header = lines[0] || ""; +const errors = []; + +// --- Header checks --- + +// type-empty + subject-empty: header must match type(scope)?: subject +const headerMatch = header.match(/^(\w+)(\(.+\))?(!)?:\s*(.*)$/); +if (!headerMatch) { + errors.push("header must match format: type(scope)?: subject"); +} else { + const [, type, , , subject] = headerMatch; + + // type-case: must be lowercase + if (type !== type.toLowerCase()) { + errors.push(`type must be lowercase: "${type}"`); + } + + // type-enum: must be in allowed list + if (!TYPES.includes(type.toLowerCase())) { + errors.push(`type "${type}" not in allowed types: ${TYPES.join(", ")}`); + } + + // subject-empty + if (!subject || !subject.trim()) { + errors.push("subject must not be empty"); + } + + // subject-full-stop + if (subject && subject.trimEnd().endsWith(".")) { + errors.push("subject must not end with a period"); + } +} + +// header-max-length +if (header.length > MAX) { + errors.push( + `header is ${header.length} chars (max ${MAX}): ${header.substring(0, 60)}...` + ); +} + +// --- Body/footer line length checks --- +for (let i = 2; i < lines.length; i++) { + if (lines[i].length > MAX) { + errors.push( + `line ${i + 1} is ${lines[i].length} chars (max ${MAX}): ${lines[i].substring(0, 60)}...` + ); + } +} + +if (errors.length > 0) { + // Output one error per line to stdout + process.stdout.write(errors.join("\n")); + process.exit(1); +} diff --git a/.claude/hooks/commitlint-local.sh b/.claude/hooks/commitlint-local.sh new file mode 100644 index 00000000..17339756 --- /dev/null +++ b/.claude/hooks/commitlint-local.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# commitlint-local.sh — PreToolUse hook for Bash (git commit) +# Validates commit message format locally before the commit runs, +# catching violations that would fail CI commitlint. +# Delegates validation to commitlint-check.js. + +set -euo pipefail + +INPUT=$(cat) + +# Extract the command from tool_input JSON +COMMAND=$(echo "$INPUT" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const p=JSON.parse(d).tool_input?.command||''; + if(p)process.stdout.write(p); + }); +" 2>/dev/null) || true + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# Only trigger on git commit commands +if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit\b'; then + exit 0 +fi + +# Skip --amend without -m (reuses existing message) +if echo "$COMMAND" | grep -qE '\-\-amend' && ! echo "$COMMAND" | grep -qE '\s-m\s'; then + exit 0 +fi + +# Extract the commit message from -m flag using node for robust parsing +MSG=$(echo "$COMMAND" | node -e " + let d=''; + process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + const cmd = d; + let msg = ''; + // Match -m \"...\" or -m '...' + const dq = cmd.match(/-m\s+\"([\\s\\S]*?)\"\s*(?:\)|$|&&|;|\s+-)/); + if (dq) { msg = dq[1]; } + else { + const sq = cmd.match(/-m\s+'([^']*)'/); + if (sq) { msg = sq[1]; } + } + // Unescape \\n to real newlines + msg = msg.replace(/\\\\n/g, '\\n'); + process.stdout.write(msg); + }); +" 2>/dev/null) || true + +if [ -z "$MSG" ]; then + exit 0 +fi + +# Run commitlint checks +HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" +VIOLATIONS=$(node "$HOOK_DIR/commitlint-check.js" "$MSG" 2>/dev/null) || true + +if [ -n "$VIOLATIONS" ]; then + REASON="Commit message fails commitlint rules:"$'\n'"${VIOLATIONS}"$'\n'"Fix the message to match conventional commit format." + node -e " + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: process.argv[1] + } + })); + " "$REASON" +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 2f9b092f..9d39db73 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -19,6 +19,11 @@ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-pr-body.sh\"", "timeout": 10 }, + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/commitlint-local.sh\"", + "timeout": 10 + }, { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit.sh\"", From 7a5a6772df73a5b7e9106d146c195437018be878 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:36:17 -0600 Subject: [PATCH 2/2] 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. --- .claude/hooks/commitlint-local.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/commitlint-local.sh b/.claude/hooks/commitlint-local.sh index 17339756..7d1fc03e 100644 --- a/.claude/hooks/commitlint-local.sh +++ b/.claude/hooks/commitlint-local.sh @@ -32,6 +32,11 @@ if echo "$COMMAND" | grep -qE '\-\-amend' && ! echo "$COMMAND" | grep -qE '\s-m\ exit 0 fi +# Skip heredoc-style messages (shell code only; can't validate pre-execution) +if echo "$COMMAND" | grep -qE '\$\(cat <<'; then + exit 0 +fi + # Extract the commit message from -m flag using node for robust parsing MSG=$(echo "$COMMAND" | node -e " let d=''; @@ -40,7 +45,7 @@ MSG=$(echo "$COMMAND" | node -e " const cmd = d; let msg = ''; // Match -m \"...\" or -m '...' - const dq = cmd.match(/-m\s+\"([\\s\\S]*?)\"\s*(?:\)|$|&&|;|\s+-)/); + const dq = cmd.match(/-m\s+\"([\\s\\S]*?)\"(?:\s|$)/); if (dq) { msg = dq[1]; } else { const sq = cmd.match(/-m\s+'([^']*)'/);