#!/usr/bin/env python3
"""
How to have a hapii life:
1. Execute this on a generic place, where you don't delete it:
`wget https://retoor.molodetz.nl/retoor/gists/raw/branch/main/rgithook.py`
2. Navigate to a repository
`python3 [the path of your rgithook.py file]`
3. All done, never write a commit message again, it'll be always accurate."""
API_URL = "https://static.molodetz.nl/rp.cgi/api/v1/chat/completions"
MODEL = "retoor/code"
TEMPERATURE = 0.1
MAX_TOKENS = None
HOOK_PATH_PRECOMMIT = ".git/hooks/pre-commit"
HOOK_PATH_PREPAREMSG = ".git/hooks/prepare-commit-msg"
TEMP_MSG_FILE = ".git/COMMIT_MSG_GENERATED"
import sys
import subprocess
import re
import json
import urllib.request
import urllib.error
import os
from datetime import datetime
from collections import defaultdict
def safe_run(cmd, default=""):
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10
)
return result.stdout.strip() if result.returncode == 0 else default
except Exception:
return default
def install_hook():
try:
if not os.path.exists('.git'):
return False
script_path = os.path.abspath(__file__)
hook_dir = os.path.dirname(HOOK_PATH_PRECOMMIT)
if not os.path.exists(hook_dir):
os.makedirs(hook_dir)
installed = False
for hook_path, mode in [(HOOK_PATH_PRECOMMIT, 'pre-commit'), (HOOK_PATH_PREPAREMSG, 'prepare-commit-msg')]:
needs_install = True
if os.path.exists(hook_path):
with open(hook_path, 'r') as f:
if script_path in f.read():
needs_install = False
if needs_install:
with open(hook_path, 'w') as f:
f.write(f'#!/bin/bash\n{script_path} {mode} "$@"\n')
os.chmod(hook_path, 0o755)
installed = True
return installed
except Exception:
return False
def get_latest_tag():
try:
tags = safe_run(['git', 'tag', '--sort=-v:refname'])
if not tags:
return None
for tag in tags.split('\n'):
tag = tag.strip()
match = re.match(r'v?(\d+\.\d+\.\d+)', tag)
if match:
return match.group(1)
return None
except Exception:
return None
def bump_minor_version(version):
try:
parts = version.split('.')
if len(parts) >= 2:
major = int(parts[0])
minor = int(parts[1])
return f"{major}.{minor + 1}.0"
return version
except Exception:
return "0.1.0"
def get_version():
try:
if os.path.exists('pyproject.toml'):
with open('pyproject.toml', 'r') as f:
content = f.read()
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1), 'pyproject'
tag_version = get_latest_tag()
if tag_version:
return tag_version, 'tag'
return "0.0.0", 'tag'
except Exception:
return "0.0.0", 'tag'
def update_version(current_version, source):
try:
new_version = bump_minor_version(current_version)
if source == 'pyproject':
with open('pyproject.toml', 'r') as f:
content = f.read()
updated = re.sub(
r'(version\s*=\s*["\'])' + re.escape(current_version) + r'(["\'])',
r'\g<1>' + new_version + r'\g<2>',
content
)
with open('pyproject.toml', 'w') as f:
f.write(updated)
safe_run(['git', 'add', 'pyproject.toml'])
return new_version
except Exception:
return current_version
def get_git_diff():
return safe_run(['git', 'diff', '--cached', '--unified=0'], "")
def get_changed_files():
try:
output = safe_run(['git', 'diff', '--cached', '--name-only'], "")
return output.split('\n') if output else []
except Exception:
return []
def analyze_diff_stats(diff):
try:
lines_by_lang = defaultdict(lambda: {'added': 0, 'removed': 0})
current_file = None
for line in diff.split('\n'):
if line.startswith('diff --git'):
match = re.search(r'b/(.+)$', line)
if match:
current_file = match.group(1)
elif line.startswith('+') and not line.startswith('+++'):
if current_file:
ext = os.path.splitext(current_file)[1]
lang = get_language_name(ext)
lines_by_lang[lang]['added'] += 1
elif line.startswith('-') and not line.startswith('---'):
if current_file:
ext = os.path.splitext(current_file)[1]
lang = get_language_name(ext)
lines_by_lang[lang]['removed'] += 1
return lines_by_lang
except Exception:
return {}
def get_language_name(ext):
lang_map = {
'.py': 'Python',
'.js': 'JavaScript',
'.ts': 'TypeScript',
'.css': 'CSS',
'.html': 'HTML',
'.java': 'Java',
'.c': 'C',
'.cpp': 'C++',
'.h': 'C',
'.rs': 'Rust',
'.go': 'Go',
'.rb': 'Ruby',
'.php': 'PHP',
'.sh': 'Shell',
'.sql': 'SQL',
'.md': 'Markdown',
'.json': 'JSON',
'.yaml': 'YAML',
'.yml': 'YAML',
'.toml': 'TOML',
'.xml': 'XML',
'.txt': 'Text',
}
return lang_map.get(ext.lower(), 'Other')
def call_ai(prompt):
try:
api_key = os.environ.get('MOLODETZ_API_KEY',"retoorded")
if not api_key:
return None
data = {
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": TEMPERATURE
}
if MAX_TOKENS is not None:
data["max_tokens"] = MAX_TOKENS
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/commit-hook",
"X-Title": "Git Commit Hook"
}
req = urllib.request.Request(
API_URL,
data=json.dumps(data).encode('utf-8'),
headers=headers
)
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode('utf-8'))
return result['choices'][0]['message']['content'].strip()
except Exception:
return None
def generate_commit_message(diff, files):
try:
files_list = "\n".join([f"- {f}" for f in files[:20]])
prompt = f"""You are a senior software engineer writing precise git commit messages following the Conventional Commits specification.
CONTEXT:
Files modified: {len(files)}
{files_list}
DIFF:
{diff[:12000]}
TASK:
Analyze the changes and write a commit message that accurately describes the modifications.
OUTPUT FORMAT:
<type>: <concise description>
RULES:
1. Use exactly one line per logical change
2. Type must be lowercase
3. Description starts with lowercase verb in imperative mood
4. No trailing punctuation
5. Maximum 72 characters per line
6. Group related changes under a single type when appropriate
7. Order by importance (most significant change first)
TYPES (select the most appropriate):
- feat: new functionality or capability added to the codebase
- fix: correction of a bug or erroneous behavior
- refactor: code restructuring without changing external behavior
- perf: performance optimization or improvement
- docs: documentation additions or modifications
- test: test additions or modifications
- build: build system or dependency changes
- ci: continuous integration configuration changes
- style: formatting changes (whitespace, semicolons, etc.)
- chore: routine maintenance tasks
ANALYSIS GUIDELINES:
- Identify the primary purpose of the change
- Distinguish between new features and modifications to existing ones
- Recognize bug fixes by error handling, null checks, or condition corrections
- Note refactoring by structural changes without behavior modification
- Detect performance work by optimization patterns or caching additions
OUTPUT:
Provide only the commit message lines, no explanations or additional text."""
message = call_ai(prompt)
if not message:
return generate_fallback_message(files)
message = message.strip().strip('"').strip("'")
lines = message.split('\n')
processed_lines = []
prefixes = ['feat:', 'fix:', 'refactor:', 'perf:', 'docs:', 'test:', 'build:', 'ci:', 'style:', 'chore:']
for line in lines:
line = line.strip()
if not line:
continue
if not any(line.startswith(p) for p in prefixes):
line = f"chore: {line}"
processed_lines.append(line)
if not processed_lines:
return generate_fallback_message(files)
return '\n'.join(processed_lines)
except Exception:
return generate_fallback_message(files)
def generate_fallback_message(files):
try:
if not files:
return "chore: update project"
exts = set()
for f in files:
ext = os.path.splitext(f)[1]
if ext:
exts.add(ext[1:])
if exts:
return f"chore: update {', '.join(sorted(exts)[:3])} files"
return "chore: update project files"
except Exception:
return "chore: update project"
def create_git_tag(version):
try:
safe_run(['git', 'tag', '-a', f'v{version}', '-m', f'Release version {version}'])
return True
except Exception:
return False
def read_changelog():
try:
if os.path.exists('CHANGELOG.md'):
with open('CHANGELOG.md', 'r') as f:
return f.read()
return "# Changelog\n\n"
except Exception:
return "# Changelog\n\n"
def write_changelog(content):
try:
with open('CHANGELOG.md', 'w') as f:
f.write(content)
except Exception:
pass
def update_changelog(version, commit_message, stats, files):
try:
changelog = read_changelog()
today = datetime.now().strftime('%Y-%m-%d')
total_changes = len(files)
total_lines = sum(s['added'] + s['removed'] for s in stats.values())
lang_stats = []
for lang in sorted(stats.keys()):
total = stats[lang]['added'] + stats[lang]['removed']
if total > 0:
lang_stats.append(f"{lang} ({total} lines)")
lang_summary = ", ".join(lang_stats) if lang_stats else "No code changes"
functional_desc_prompt = f"""You are a technical writer creating changelog entries for a software release.
COMMIT SUMMARY:
{commit_message}
AFFECTED FILES:
{", ".join(files[:10])}
TASK:
Write a concise changelog entry describing the functional impact of these changes.
REQUIREMENTS:
1. Maximum two sentences
2. Focus on user-visible or developer-relevant changes
3. Use present tense, active voice
4. Be specific about what functionality is added, modified, or fixed
5. Avoid implementation details unless architecturally significant
6. No marketing language or superlatives
OUTPUT:
Provide only the changelog description text, no formatting or prefixes."""
functional_desc = call_ai(functional_desc_prompt)
if not functional_desc:
first_line = commit_message.split('\n')[0]
functional_desc = first_line.split(':', 1)[1].strip() if ':' in first_line else first_line
entry_lines = [
f"## Version {version} - {today}",
"",
functional_desc,
"",
f"**Changes:** {total_changes} files, {total_lines} lines",
f"**Languages:** {lang_summary}",
"",
]
new_entry = "\n".join(entry_lines)
lines = changelog.split('\n')
version_pattern = f"## Version {version} - {today}"
for i, line in enumerate(lines):
if line.startswith(f"## Version {version} -"):
end_idx = i + 1
while end_idx < len(lines) and not lines[end_idx].startswith('## Version'):
end_idx += 1
lines = lines[:i] + new_entry.split('\n') + lines[end_idx:]
write_changelog('\n'.join(lines))
return
insert_idx = 2
while insert_idx < len(lines) and lines[insert_idx].strip() == '':
insert_idx += 1
lines = lines[:insert_idx] + [''] + new_entry.split('\n') + lines[insert_idx:]
write_changelog('\n'.join(lines))
except Exception:
pass
def run_pre_commit():
diff = get_git_diff()
files = get_changed_files()
if not diff and not files:
sys.exit(0)
stats = analyze_diff_stats(diff)
commit_message = generate_commit_message(diff, files)
current_version, source = get_version()
new_version = update_version(current_version, source)
update_changelog(new_version, commit_message, stats, files)
safe_run(['git', 'add', 'CHANGELOG.md'])
with open(TEMP_MSG_FILE, 'w') as f:
f.write(f"{new_version}\n{commit_message}")
sys.exit(0)
def run_prepare_commit_msg(commit_msg_file):
if not os.path.exists(TEMP_MSG_FILE):
sys.exit(0)
with open(TEMP_MSG_FILE, 'r') as f:
content = f.read()
lines = content.split('\n', 1)
new_version = lines[0] if lines else "0.1.0"
commit_message = lines[1] if len(lines) > 1 else "chore: update project"
with open(commit_msg_file, 'w') as f:
f.write(commit_message + '\n')
create_git_tag(new_version)
os.remove(TEMP_MSG_FILE)
sys.exit(0)
def main():
try:
if len(sys.argv) < 2:
if install_hook():
print("Git hook installed successfully")
else:
print("Git hook already installed")
sys.exit(0)
mode = sys.argv[1]
if mode == 'pre-commit':
run_pre_commit()
elif mode == 'prepare-commit-msg':
if len(sys.argv) >= 3:
run_prepare_commit_msg(sys.argv[2])
sys.exit(0)
else:
sys.exit(0)
except Exception:
try:
if os.path.exists(TEMP_MSG_FILE):
os.remove(TEMP_MSG_FILE)
except Exception:
pass
sys.exit(0)
if __name__ == '__main__':
main()