Initial commit
This commit is contained in:
195
hooks/check-changelog-before-commit.py
Normal file
195
hooks/check-changelog-before-commit.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user