Files
gh-emillindfors-claude-mark…/hooks/check-changelog-before-commit.py
2025-11-29 18:25:43 +08:00

196 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""
Changelog enforcement hook for Claude Code.
This hook checks if a git commit attempt includes a changelog update.
Blocks commits that don't have corresponding changelog entries.
"""
import json
import sys
import subprocess
import re
from pathlib import Path
def run_git_command(cmd, cwd):
"""Run a git command and return the output."""
try:
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=5)
return result.stdout.strip(), result.returncode
except subprocess.TimeoutExpired:
return "", 1
except Exception:
return "", 1
def is_commit_attempt(prompt):
"""Check if the prompt is attempting a git commit."""
commit_patterns = [
r"\bgit\s+commit\b",
r"\bcommit\s+(the\s+)?changes?\b",
r"\bcreate\s+a\s+commit\b",
r"\bmake\s+a\s+commit\b",
r"\bcommit\s+.*\s+to\s+git\b",
]
prompt_lower = prompt.lower()
return any(re.search(pattern, prompt_lower) for pattern in commit_patterns)
def check_changelog_modified(cwd):
"""Check if CHANGELOG.md has been modified in the current branch."""
# Check if we're in a git repository
_, returncode = run_git_command(["git", "rev-parse", "--git-dir"], cwd)
if returncode != 0:
return True # Not in a git repo, allow the commit
# Find CHANGELOG files
changelog_files = []
for pattern in ["CHANGELOG.md", "CHANGELOG.MD", "changelog.md"]:
changelog_path = Path(cwd) / pattern
if changelog_path.exists():
changelog_files.append(pattern)
if not changelog_files:
return False # No changelog file found, require user to address this
# Check if any changelog file is in staged changes
staged_output, _ = run_git_command(["git", "diff", "--cached", "--name-only"], cwd)
for changelog_file in changelog_files:
if changelog_file in staged_output:
return True
# Check if any changelog file has unstaged changes
unstaged_output, _ = run_git_command(["git", "diff", "--name-only"], cwd)
for changelog_file in changelog_files:
if changelog_file in unstaged_output:
return True
return False
def check_unused_sections(cwd):
"""Check if changelog has empty/unused sections that should be cleaned up."""
# Find the changelog file
changelog_path = None
for pattern in ["CHANGELOG.md", "CHANGELOG.MD", "changelog.md"]:
path = Path(cwd) / pattern
if path.exists():
changelog_path = path
break
if not changelog_path:
return None # No changelog found
try:
content = changelog_path.read_text(encoding='utf-8')
except Exception:
return None # Can't read file
# Find the [Unreleased] section
unreleased_match = re.search(r'## \[Unreleased\](.*?)(?=## \[|\Z)', content, re.DOTALL)
if not unreleased_match:
return None # No unreleased section
unreleased_section = unreleased_match.group(1)
# Check for empty category sections
categories = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
empty_sections = []
for category in categories:
# Look for the category heading
category_pattern = rf'### {category}\s*\n'
if re.search(category_pattern, unreleased_section):
# Check if there's content after the heading (before next heading or end)
content_pattern = rf'### {category}\s*\n(.*?)(?=###|\Z)'
match = re.search(content_pattern, unreleased_section, re.DOTALL)
if match:
section_content = match.group(1).strip()
# Filter out HTML comments
section_content = re.sub(r'<!--.*?-->', '', section_content, flags=re.DOTALL).strip()
if not section_content:
empty_sections.append(category)
return empty_sections if empty_sections else None
def main():
"""Main hook execution."""
try:
# Read input from stdin
input_data = json.load(sys.stdin)
prompt = input_data.get("prompt", "")
cwd = input_data.get("cwd", ".")
# Check if this is a commit attempt
if not is_commit_attempt(prompt):
# Not a commit attempt, allow it
sys.exit(0)
# Check if changelog has been modified
if check_changelog_modified(cwd):
# Changelog has been updated, now check for unused sections
empty_sections = check_unused_sections(cwd)
if empty_sections:
# Warn about empty sections that should be cleaned up
sections_list = ", ".join(empty_sections)
output = {
"decision": "block",
"reason": f"""⚠️ Changelog cleanup required!
Your CHANGELOG.md has empty sections that should be removed before committing:
{sections_list}
Please remove these empty category headings from the [Unreleased] section to keep the changelog clean.
Only include category headings that have actual entries under them.
💡 Tip: Edit CHANGELOG.md to remove the empty ### headings, then stage the file again.""",
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Empty changelog sections should be removed before committing.",
},
}
print(json.dumps(output))
sys.exit(2)
# Changelog has been updated and is clean, allow the commit
sys.exit(0)
# Block the commit - no changelog update found
output = {
"decision": "block",
"reason": """⚠️ Changelog update required!
You're attempting to commit changes without updating the CHANGELOG.md file.
Please:
1. Add an entry to CHANGELOG.md describing your changes
2. Follow the Keep a Changelog format (https://keepachangelog.com/)
3. Add the changes under the [Unreleased] section
4. Stage the changelog file: git add CHANGELOG.md
5. Then retry your commit
💡 Tip: You can use /changelog:changelog-add command to add an entry quickly.""",
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "The changelog must be updated before committing code changes.",
},
}
print(json.dumps(output))
sys.exit(2) # Exit code 2 indicates blocking
except Exception as e:
# If the hook fails, don't block the user (fail open)
print(json.dumps({"error": str(e)}), file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()