Initial commit
This commit is contained in:
256
hooks/scripts/commit-message-validator.py
Executable file
256
hooks/scripts/commit-message-validator.py
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CommitMessageValidator:
|
||||
def __init__(self, input_data: dict[str, Any]):
|
||||
self.input = input_data
|
||||
self.valid_types = ["feat", "fix", "docs", "style", "refactor", "test", "chore"]
|
||||
|
||||
def validate(self) -> dict[str, Any]:
|
||||
"""Main validation entry point"""
|
||||
tool_name = self.input.get("tool_name")
|
||||
tool_input = self.input.get("tool_input", {})
|
||||
command = tool_input.get("command")
|
||||
|
||||
# Security: Basic input validation
|
||||
if command and not isinstance(command, str):
|
||||
return self.approve("Invalid command format")
|
||||
|
||||
# Only validate git commit commands
|
||||
if tool_name != "Bash" or not self.is_commit_command(command):
|
||||
return self.approve()
|
||||
|
||||
# Extract commit message from command
|
||||
message = self.extract_commit_message(command)
|
||||
if not message:
|
||||
return self.approve() # Can't validate without message
|
||||
|
||||
# Validate the commit message format
|
||||
validation = self.validate_message(message)
|
||||
|
||||
if validation["valid"]:
|
||||
return self.approve(validation["details"])
|
||||
else:
|
||||
return self.block(validation["errors"], validation["suggestions"])
|
||||
|
||||
def is_commit_command(self, command: str | None) -> bool:
|
||||
"""Check if command is a git commit"""
|
||||
return command and (
|
||||
"git commit" in command
|
||||
or "git cm" in command # common alias
|
||||
or "gc -m" in command # common alias
|
||||
)
|
||||
|
||||
def extract_commit_message(self, command: str) -> str:
|
||||
"""Extract commit message from command"""
|
||||
message = ""
|
||||
|
||||
# Format: git commit -m "message"
|
||||
single_quote_match = re.search(r"-m\s+'([^']+)'", command)
|
||||
double_quote_match = re.search(r'-m\s+"([^"]+)"', command)
|
||||
|
||||
# Format: git commit -m "$(cat <<'EOF'...EOF)"
|
||||
heredoc_match = re.search(
|
||||
r"cat\s*<<\s*['\"]?EOF['\"]?\s*([\s\S]*?)\s*EOF", command
|
||||
)
|
||||
|
||||
if single_quote_match:
|
||||
message = single_quote_match.group(1)
|
||||
elif double_quote_match:
|
||||
message = double_quote_match.group(1)
|
||||
elif heredoc_match:
|
||||
message = heredoc_match.group(1).strip()
|
||||
|
||||
# Get just the first line for conventional commit validation
|
||||
return message.split("\n")[0].strip()
|
||||
|
||||
def validate_message(self, message: str) -> dict[str, Any]:
|
||||
"""Validate commit message format"""
|
||||
errors = []
|
||||
suggestions = []
|
||||
details = []
|
||||
|
||||
# Check for empty message
|
||||
if not message:
|
||||
errors.append("Commit message cannot be empty")
|
||||
return {"valid": False, "errors": errors, "suggestions": suggestions}
|
||||
|
||||
# Check basic format: type(scope): subject or type: subject
|
||||
conventional_format = re.compile(r"^(\w+)(?:\(([^)]+)\))?:\s*(.+)$")
|
||||
match = conventional_format.match(message)
|
||||
|
||||
if not match:
|
||||
errors.append(
|
||||
"Commit message must follow conventional format: type(scope): subject"
|
||||
)
|
||||
suggestions.extend(
|
||||
[
|
||||
"Examples:",
|
||||
" feat(auth): add login functionality",
|
||||
" fix: resolve memory leak in provider list",
|
||||
" docs(api): update REST endpoint documentation",
|
||||
]
|
||||
)
|
||||
return {"valid": False, "errors": errors, "suggestions": suggestions}
|
||||
|
||||
type_, scope, subject = match.groups()
|
||||
|
||||
# Validate type
|
||||
if type_ not in self.valid_types:
|
||||
errors.append(f"Invalid commit type '{type_}'")
|
||||
suggestions.append(f"Valid types: {', '.join(self.valid_types)}")
|
||||
else:
|
||||
details.append(f"Type: {type_} ✓")
|
||||
|
||||
# Validate scope (optional but recommended for features)
|
||||
if scope:
|
||||
if len(scope) > 20:
|
||||
errors.append("Scope should be concise (max 20 characters)")
|
||||
else:
|
||||
details.append(f"Scope: {scope} ✓")
|
||||
elif type_ in ["feat", "fix"]:
|
||||
suggestions.append("Consider adding a scope for better context")
|
||||
|
||||
# Validate subject
|
||||
if subject:
|
||||
# Check first character is lowercase
|
||||
if re.match(r"^[A-Z]", subject):
|
||||
errors.append("Subject should start with lowercase letter")
|
||||
|
||||
# Check for ending punctuation
|
||||
if re.search(r"[.!?]$", subject):
|
||||
errors.append("Subject should not end with punctuation")
|
||||
|
||||
# Check length
|
||||
if len(subject) > 50:
|
||||
suggestions.append(
|
||||
f"Subject is {len(subject)} characters (recommended: max 50)"
|
||||
)
|
||||
|
||||
# Check for imperative mood (basic check)
|
||||
first_word = subject.split()[0]
|
||||
past_tense_words = [
|
||||
"added",
|
||||
"updated",
|
||||
"fixed",
|
||||
"removed",
|
||||
"implemented",
|
||||
"created",
|
||||
"deleted",
|
||||
"improved",
|
||||
"refactored",
|
||||
"changed",
|
||||
"moved",
|
||||
"renamed",
|
||||
]
|
||||
|
||||
if first_word.lower() in past_tense_words:
|
||||
errors.append(
|
||||
'Use imperative mood in subject (e.g., "add" not "added")'
|
||||
)
|
||||
|
||||
if not errors:
|
||||
details.append(f'Subject: "{subject}" ✓')
|
||||
else:
|
||||
errors.append("Subject cannot be empty")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"suggestions": suggestions,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
def approve(self, details: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Approve the operation"""
|
||||
message = "✅ Commit message validation passed"
|
||||
if details:
|
||||
message += "\n" + "\n".join(details)
|
||||
|
||||
return {"approve": True, "message": message}
|
||||
|
||||
def block(self, errors: list[str], suggestions: list[str]) -> dict[str, Any]:
|
||||
"""Block the operation due to invalid format"""
|
||||
message_parts = [
|
||||
"❌ Invalid commit message format:",
|
||||
*[f" - {e}" for e in errors],
|
||||
"",
|
||||
*[f" {s}" for s in suggestions],
|
||||
"",
|
||||
"Commit format: type(scope): subject",
|
||||
"",
|
||||
"Types:",
|
||||
" feat - New feature",
|
||||
" fix - Bug fix",
|
||||
" docs - Documentation only",
|
||||
" style - Code style changes",
|
||||
" refactor - Code refactoring",
|
||||
" test - Add/update tests",
|
||||
" chore - Maintenance tasks",
|
||||
"",
|
||||
"Example: feat(providers): add location filter to provider list",
|
||||
]
|
||||
|
||||
return {"approve": False, "message": "\n".join(message_parts)}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main execution"""
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
|
||||
# Comprehensive logging functionality
|
||||
# Ensure log directory exists
|
||||
log_dir = Path.cwd() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / "commit_message_validator.json"
|
||||
|
||||
# Read existing log data or initialize empty list
|
||||
if log_path.exists():
|
||||
with open(log_path) as f:
|
||||
try:
|
||||
log_data = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
log_data = []
|
||||
else:
|
||||
log_data = []
|
||||
|
||||
# Add timestamp to the log entry
|
||||
timestamp = datetime.now().strftime("%b %d, %I:%M%p").lower()
|
||||
input_data["timestamp"] = timestamp
|
||||
|
||||
# Process validation and get results
|
||||
validator = CommitMessageValidator(input_data)
|
||||
result = validator.validate()
|
||||
|
||||
# Add validation result to log entry
|
||||
input_data["validation_result"] = result
|
||||
|
||||
# Append new data to log
|
||||
log_data.append(input_data)
|
||||
|
||||
# Write back to file with formatting
|
||||
with open(log_path, "w") as f:
|
||||
json.dump(log_data, f, indent=2)
|
||||
|
||||
print(json.dumps(result))
|
||||
except Exception as error:
|
||||
print(
|
||||
json.dumps({"approve": True, "message": f"Commit validator error: {error}"})
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user