Skip to content

fix(hooks): add local commitlint validation hook#868

Merged
carlos-alm merged 3 commits intomainfrom
fix/local-commitlint-hook
Apr 6, 2026
Merged

fix(hooks): add local commitlint validation hook#868
carlos-alm merged 3 commits intomainfrom
fix/local-commitlint-hook

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • Adds a pre-commit hook that validates commit messages against commitlint rules before the commit runs, catching violations locally instead of waiting for CI
  • Checks all @commitlint/config-conventional rules: type-enum, type-case, subject-empty, subject-full-stop, header-max-length, body/footer-max-line-length
  • Skips merge commits (matching the project's commitlint ignores config)
  • Splits logic into commitlint-local.sh (message extraction) and commitlint-check.js (validation) for testability

Motivation

PR #857 had 90+ accumulated commits with body lines exceeding the 100-char limit, causing commitlint CI failures that could only be fixed with a force-push. This hook prevents that class of issue entirely.

Test plan

  • Verified: valid conventional commit messages pass through
  • Verified: bad type (update:) is blocked with clear error
  • Verified: uppercase type (Fix:) is blocked
  • Verified: missing colon/format is blocked
  • Verified: trailing period on subject is blocked
  • Verified: body lines >100 chars are blocked with line number + length
  • Verified: merge commits are skipped (per commitlint ignores config)
  • Verified: non-commit commands (git push) are ignored

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
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Claude finished @carlos-alm's task —— View job


PR Review Complete ✅

Todo List:

  • Read all changed files to understand implementation
  • Phase 1: Root cause analysis
  • Phase 2: Configuration change detection
  • Phase 3: Test integrity check
  • Phase 4: Code quality review
  • Phase 5: Backlog compliance assessment
  • Provide final rating and recommendation

Root Cause Analysis

Problem Being Solved: PR #857 had 90+ commits with body lines exceeding commitlint's 100-char limit, causing CI failures that could only be fixed with force-push. This hook shifts the validation left to catch violations at commit time locally.

Why This Approach: Local pre-commit validation is the right solution - catches issues immediately, prevents accumulation of bad commits, and avoids CI failure cycles. The approach is justified and addresses the root cause rather than symptoms.

Risk Assessment: Low risk - this is purely additive validation that improves developer experience without changing core functionality.

Backlog Compliance

  • Zero-dep: ✓ — No new runtime dependencies, builds on existing Node.js/bash infrastructure
  • Foundation-aligned: ✓ — Fully compliant with all FOUNDATION.md principles
    • Maintains P1 (always-current) - local validation
    • Supports P4 (zero-cost core) - no cloud dependencies
    • Respects P8 (analysis not agency) - validates rather than modifies
  • Problem-fit: 3 — Useful but doesn't directly address the core "lost AI" problem. This is developer tooling that improves commit quality but doesn't help AI agents navigate codebases.
  • Breaking: No — Purely additive hook, no API/CLI changes
  • Tier: 1 (zero-dep ✓ + foundation-aligned ✓)

Critical Concerns

None identified. This is a well-implemented defensive addition to the development workflow.

Code Quality Assessment

Strengths:

  1. Accurate rule mirroring: commitlint-check.js exactly matches commitlint.config.ts type enum and max length rules
  2. Proper merge commit handling: Correctly implements the project's merge commit ignore pattern
  3. Robust message parsing: Handles both single/double quotes and escaped newlines in commit messages
  4. Appropriate error handling: Uses || true to prevent hook failures on parsing errors
  5. Clear separation of concerns: Shell script extracts, Node.js validates

Minor Issues:

  • commitlint-local.sh:50: Only unescapes \\n but not other common escapes like \", \', \t
  • commitlint-local.sh:43: Regex could match -m "..." inside other quoted strings (edge case)

Security/Performance:

  • ✅ No command injection risks
  • ✅ Proper bash safety with set -euo pipefail
  • ✅ Appropriate 10s timeout prevents hanging
  • ✅ Efficient triggering only on git commit commands

Final Recommendation

  • Rating: ⭐⭐⭐⭐☆ (4/5)
  • Action: APPROVE
  • Reasoning: Well-engineered solution to a real problem with accurate rule implementation and appropriate safeguards. The minor parsing edge cases don't affect common usage patterns. This directly prevents the CI failure scenario that motivated it while maintaining full compatibility with existing commitlint configuration.

Recommendation: Approve and merge. Consider adding automated tests for the hook logic in a future iteration if the project adopts hook testing infrastructure.


@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 5, 2026

Greptile Summary

Adds a PreToolUse hook (commitlint-local.sh + commitlint-check.js) that validates conventional commit messages before git commit runs, mirroring the CI commitlint rules locally. Both prior review concerns — heredoc false-positives and the bare-filename terminator — are resolved in this revision.

Confidence Score: 5/5

Safe to merge — both prior P1 issues are resolved and the one remaining finding is a minor edge case with negligible practical impact.

All previous review concerns are addressed. The only remaining finding is a P2 style issue: the double-quote extractor does not skip escaped backslash-quote sequences, so an unusual message with embedded escaped quotes would be silently truncated. In practice, CLAUDE.md instructs agents to use heredoc for multiline commits (already skipped by the hook) or plain single-line -m messages, making this path essentially unreachable. No blocking issues remain.

No files require special attention; commitlint-local.sh has one minor regex edge case in the double-quote extractor at line 48.

Important Files Changed

Filename Overview
.claude/hooks/commitlint-check.js Validates conventional commit header/body rules correctly; minor edge case where escaped quotes in double-quoted -m messages could cause silent truncation upstream in the shell script
.claude/hooks/commitlint-local.sh Pre-commit hook extracting and forwarding commit messages to commitlint-check.js; heredoc and bare-filename edge cases from prior review are addressed
.claude/settings.json Registers commitlint-local.sh as a PreToolUse hook, consistent with the existing guard/lint hook pattern

Sequence Diagram

sequenceDiagram
    participant C as Claude Agent
    participant H as commitlint-local.sh
    participant V as commitlint-check.js
    participant G as git commit

    C->>H: PreToolUse (tool_input.command)
    H->>H: Extract command from JSON
    alt not git commit
        H-->>C: exit 0 (allow)
    else heredoc-style message
        H-->>C: exit 0 (allow, cannot pre-validate)
    else --amend without -m
        H-->>C: exit 0 (allow, reuses existing msg)
    else has -m flag
        H->>H: Extract MSG via regex
        alt MSG is empty
            H-->>C: exit 0 (allow)
        else MSG extracted
            H->>V: node commitlint-check.js "$MSG"
            alt violations found
                V-->>H: error lines (exit 1)
                H-->>C: JSON deny (permissionDecision: deny)
            else clean
                V-->>H: exit 0
                H->>G: allow
                G-->>C: commit created
            end
        end
    end
Loading

Reviews (2): Last reviewed commit: "fix(hooks): skip heredoc commits and fix..." | Re-trigger Greptile

Comment on lines +35 to +53
# 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Heredoc commit messages are incorrectly blocked

When Claude agents use the system-instructed heredoc format for multiline commits, the extraction regex captures the shell code — not the actual message — causing commitlint-check.js to see $(cat <<'EOF' as the header, which fails the header pattern and blocks a valid commit with a misleading error.

For a command like git commit -m "$(cat <<'EOF'\nfix: add feature\n\nBody.\nEOF\n)", dq[1] is $(cat <<'EOF'\nfix: add feature..., not the commit text. The system-level CLAUDE.md instructs Claude to "ALWAYS pass the commit message via a HEREDOC", so this will fire routinely.

Add a guard before the extraction block:

Suggested change
# 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
# 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='';
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7a5a677. Added a heredoc detection guard ($(cat <<) before the extraction block that exits 0 immediately, so heredoc-style commits pass through without false-positive rejection.

Comment on lines +43 to +44
const dq = cmd.match(/-m\s+\"([\\s\\S]*?)\"\s*(?:\)|$|&&|;|\s+-)/);
if (dq) { msg = dq[1]; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Extraction silently skips file-after-message patterns

The double-quote terminator group (?:\)|$|&&|;|\s+-) does not match a bare filename argument that follows the message. A command like git commit -m "fix: msg" src/foo.ts leaves MSG empty (no terminator match after the closing "), silently skipping validation. Low-impact given the project convention places files before -m, but the coverage gap is invisible to the author.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7a5a677. Changed the double-quote terminator regex from (?:\)|$|&&|;|\s+-) to (?:\s|$) which correctly matches a bare filename argument after the closing quote (e.g. git commit -m "fix: msg" src/foo.ts).

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.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 0c97928 into main Apr 6, 2026
13 checks passed
@carlos-alm carlos-alm deleted the fix/local-commitlint-hook branch April 6, 2026 01:52
@github-actions github-actions bot locked and limited conversation to collaborators Apr 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant