|
#!/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()
|