Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:57:39 +08:00
commit 02e22b6e13
13 changed files with 1024 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Auto Changelog Updater Hook
This hook automatically updates the changelog after git commits are made.
It runs the update-changelog.py script in automatic mode to analyze recent
commits and update the CHANGELOG.md file accordingly.
Hook Type: post_tool_use
Triggers On: git commit commands
"""
import json
import subprocess
import sys
from pathlib import Path
def main():
# Read the tool use data from stdin
tool_data = json.load(sys.stdin)
# Check if this is a git commit command
tool_name = tool_data.get("tool", "")
# We're looking for Bash tool with git commit commands
if tool_name != "Bash":
# Not a bash command, skip
return 0
# Check if the command contains git commit
command = tool_data.get("arguments", {}).get("command", "")
if not command:
return 0
# Check for various forms of git commit commands
git_commit_patterns = [
"git commit",
"git commit -m",
"git commit --message",
"git commit -am",
"git commit --amend",
]
is_git_commit = any(pattern in command for pattern in git_commit_patterns)
if not is_git_commit:
# Not a git commit command, skip
return 0
# Check if the command was successful
result = tool_data.get("result", {})
if isinstance(result, dict):
exit_code = result.get("exitCode", 0)
if exit_code != 0:
# Git commit failed, don't update changelog
return 0
# Find the update-changelog.py script
script_path = (
Path(__file__).parent.parent.parent
/ "scripts"
/ "changelog"
/ "update-changelog.py"
)
if not script_path.exists():
print(
f"Warning: Changelog update script not found at {script_path}",
file=sys.stderr,
)
return 0
# Run the changelog update script in auto mode
try:
print(
"\n🔄 Automatically updating changelog after git commit...", file=sys.stderr
)
# Run the script with --auto flag
result = subprocess.run(
["python", str(script_path), "--auto"],
capture_output=True,
text=True,
cwd=Path(__file__).parent.parent.parent, # Run from project root
)
if result.returncode == 0:
print("✅ Changelog updated successfully!", file=sys.stderr)
if result.stdout:
print(result.stdout, file=sys.stderr)
else:
print(
f"⚠️ Changelog update completed with warnings (exit code: {result.returncode})",
file=sys.stderr,
)
if result.stderr:
print(f"Error output: {result.stderr}", file=sys.stderr)
except Exception as e:
print(f"❌ Error updating changelog: {e}", file=sys.stderr)
# Don't fail the hook even if changelog update fails
return 0
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.8"
# dependencies = []
# ///
import subprocess
import sys
from datetime import datetime
from pathlib import Path
def run_git_command(command: list[str]) -> subprocess.CompletedProcess:
"""Run a git command and return the completed process."""
try:
return subprocess.run(
command, capture_output=True, text=True, check=True, cwd=Path.cwd()
)
except subprocess.CalledProcessError as e:
print(f"Git command failed: {' '.join(command)}")
print(f"Error: {e.stderr}")
sys.exit(1)
def count_changed_files(max_count: int = 6) -> int:
"""
Count all changed files (staged, unstaged, and untracked) with early exit.
Ignores files in .gitignore. Returns count up to max_count.
"""
changed_files = set()
try:
# 1. Get unstaged changes (working tree vs index)
result = subprocess.run(
["git", "diff-files", "--name-only"],
capture_output=True,
text=True,
check=True,
)
if result.stdout.strip():
changed_files.update(result.stdout.strip().split("\n"))
if len(changed_files) >= max_count:
return max_count
# 2. Get staged changes (index vs HEAD)
result = subprocess.run(
["git", "diff-index", "--cached", "--name-only", "HEAD"],
capture_output=True,
text=True,
check=True,
)
if result.stdout.strip():
changed_files.update(result.stdout.strip().split("\n"))
if len(changed_files) >= max_count:
return max_count
# 3. Get untracked files (respects .gitignore)
result = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard"],
capture_output=True,
text=True,
check=True,
)
if result.stdout.strip():
changed_files.update(result.stdout.strip().split("\n"))
return min(len(changed_files), max_count)
except subprocess.CalledProcessError:
# If git command fails, assume no changes
return 0
def check_git_repository() -> bool:
"""Check if we're in a git repository."""
try:
subprocess.run(
["git", "rev-parse", "--git-dir"], capture_output=True, check=True
)
return True
except subprocess.CalledProcessError:
return False
def request_claude_commit():
"""Request Claude Code to make a commit by echoing the appropriate message."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
commit_message = f"Auto-commit: 5+ file changes detected at {timestamp}"
# Echo a message that Claude Code can interpret as a commit request
print(f"CLAUDE_COMMIT_REQUEST: {commit_message}")
print("🔄 Requesting Claude Code to stage and commit changes...")
def main():
"""Main execution function."""
print("🔍 Checking for file changes...")
# Verify we're in a git repository
if not check_git_repository():
print("❌ Not in a git repository. Exiting.")
sys.exit(1)
# Count changed files with early exit at 6
changed_count = count_changed_files(max_count=6)
print(f"📊 Found {changed_count} changed file(s)")
# Check if we hit the threshold
if changed_count >= 5:
print("🚨 Threshold reached: 5+ files changed")
request_claude_commit()
else:
print(f"✅ Below threshold: {changed_count}/5 files changed")
if __name__ == "__main__":
main()

View 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()

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
import json
import sys
import subprocess
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
command = tool_input.get("command", "")
# Only validate git push commands
if tool_name != "Bash" or "git push" not in command:
sys.exit(0)
# Get current branch
try:
current_branch = subprocess.check_output(
["git", "branch", "--show-current"],
stderr=subprocess.DEVNULL,
text=True
).strip()
except:
current_branch = ""
# Check if pushing to main or develop
push_cmd = command
is_force_push = "--force" in push_cmd or "-f" in push_cmd
# Check if command or current branch targets protected branches
targets_protected = (
"origin main" in push_cmd or
"origin develop" in push_cmd or
current_branch in ["main", "develop"]
)
# Block direct push to main/develop (unless force push which is already dangerous)
if targets_protected and not is_force_push:
if current_branch in ["main", "develop"] or "origin main" in push_cmd or "origin develop" in push_cmd:
reason = f"""❌ Direct push to main/develop is not allowed!
Protected branches:
- main (production)
- develop (integration)
Git Flow workflow:
1. Create a feature branch:
/feature <name>
2. Make your changes and commit
3. Push feature branch:
git push origin feature/<name>
4. Create pull request:
gh pr create
5. After approval, merge with:
/finish
For releases:
/release <version> → PR → /finish
For hotfixes:
/hotfix <name> → PR → /finish
Current branch: {current_branch}
💡 Use feature/release/hotfix branches instead of pushing directly to main/develop."""
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
}
print(json.dumps(output))
sys.exit(0)
# Allow the command
sys.exit(0)

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
import json
import sys
import re
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
command = tool_input.get("command", "")
# Only validate git checkout -b commands
if tool_name != "Bash" or "git checkout -b" not in command:
sys.exit(0)
# Extract branch name
match = re.search(r'git checkout -b\s+([^\s]+)', command)
if not match:
sys.exit(0)
branch_name = match.group(1)
# Allow main and develop branches
if branch_name in ["main", "develop"]:
sys.exit(0)
# Validate Git Flow naming convention
if not re.match(r'^(feature|release|hotfix)/', branch_name):
reason = f"""❌ Invalid Git Flow branch name: {branch_name}
Git Flow branches must follow these patterns:
• feature/<descriptive-name>
• release/v<MAJOR>.<MINOR>.<PATCH>
• hotfix/<descriptive-name>
Examples:
✅ feature/user-authentication
✅ release/v1.2.0
✅ hotfix/critical-security-fix
Invalid:
{branch_name} (missing Git Flow prefix)
❌ feat/something (use 'feature/' not 'feat/')
❌ fix/bug (use 'hotfix/' not 'fix/')
💡 Use Git Flow commands instead:
/feature <name> - Create feature branch
/release <version> - Create release branch
/hotfix <name> - Create hotfix branch"""
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
}
print(json.dumps(output))
sys.exit(0)
# Validate release version format
if branch_name.startswith("release/"):
if not re.match(r'^release/v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$', branch_name):
reason = f"""❌ Invalid release version: {branch_name}
Release branches must follow semantic versioning:
release/vMAJOR.MINOR.PATCH[-prerelease]
Valid examples:
✅ release/v1.0.0
✅ release/v2.1.3
✅ release/v1.0.0-beta.1
Invalid:
❌ release/1.0.0 (missing 'v' prefix)
❌ release/v1.0 (incomplete version)
{branch_name}
💡 Use: /release v1.2.0"""
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
}
print(json.dumps(output))
sys.exit(0)
# Allow the command
sys.exit(0)