196 lines
6.6 KiB
Python
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()
|