257 lines
8.4 KiB
Python
Executable File
257 lines
8.4 KiB
Python
Executable File
#!/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()
|