#!/usr/bin/env python3
API_URL = "https://static.molodetz.nl/rp.cgi/api/v1/chat/completions"
MODEL = "google/gemma-3-12b-it:free"
TEMPERATURE = 1.0
MAX_TOKENS = None
HOOK_PATH = ".git/hooks/prepare-commit-msg"
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)
if not os.path.exists(hook_dir):
os.makedirs(hook_dir)
if os.path.exists(HOOK_PATH):
with open(HOOK_PATH, 'r') as f:
if script_path in f.read():
return False
with open(HOOK_PATH, 'w') as f:
f.write(f'#!/bin/bash\n{script_path} "$@"\n')
os.chmod(HOOK_PATH, 0o755)
return True
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 write commit messages for code changes.
Changed files:
{files_list}
Code changes:
{diff[:12000]}
Write a commit message with this format:
<prefix>: <description>
Format rules:
- All lowercase for the prefix
- Colon and space after prefix
- Start description with lowercase letter
- No period at the end
- Max 72 characters total
- Use imperative mood (Add not Added)
Choose one prefix:
- fix: for bug fixes
Example: fix: resolve null pointer error in user login
Example: fix: correct date format in export function
- feat: for new features
Example: feat: add dark mode toggle to settings
Example: feat: implement search filter for products
- docs: for documentation changes
Example: docs: update api endpoint descriptions
Example: docs: add setup guide for development
- perf: for performance improvements
Example: perf: reduce database query time by 40%
Example: perf: optimize image loading with lazy load
- refactor: for code restructuring
Example: refactor: simplify user validation logic
Example: refactor: extract common functions to utils
- maintenance: for routine updates and maintenance
Example: maintenance: update dependencies to latest versions
Example: maintenance: clean up unused imports and files
Reply with ONLY the commit message, nothing else."""
message = call_ai(prompt)
if not message:
return generate_fallback_message(files)
message = message.strip().strip('"').strip("'")
prefixes = ['fix:', 'feat:', 'docs:', 'perf:', 'refactor:', 'maintenance:']
if not any(message.startswith(p) for p in prefixes):
message = f"feat: {message}"
return message
except Exception:
return generate_fallback_message(files)
def generate_fallback_message(files):
try:
if not files:
return "feat: update project"
exts = set()
for f in files:
ext = os.path.splitext(f)[1]
if ext:
exts.add(ext[1:])
if exts:
return f"feat: update {', '.join(sorted(exts)[:3])} files"
return "feat: update project files"
except Exception:
return "feat: 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 write changelog entries for software releases.
Commit message: {commit_message}
Changed files: {", ".join(files[:10])}
Write a short functional description of what changed for users or developers.
Use simple clear words, be direct, max 2 sentences.
Focus on what it does, not how.
Reply with ONLY the description text."""
functional_desc = call_ai(functional_desc_prompt)
if not functional_desc:
functional_desc = commit_message.split(':', 1)[1].strip() if ':' in commit_message else commit_message
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 main():
try:
if len(sys.argv) < 2:
if install_hook():
print("Git hook installed successfully")
else:
print("Git hook already installed")
sys.exit(0)
commit_msg_file = sys.argv[1]
current_version, source = get_version()
new_version = update_version(current_version, source)
diff = get_git_diff()
files = get_changed_files()
stats = analyze_diff_stats(diff)
commit_message = generate_commit_message(diff, files)
update_changelog(new_version, commit_message, stats, files)
safe_run(['git', 'add', 'CHANGELOG.md'])
create_git_tag(new_version)
with open(commit_msg_file, 'w') as f:
f.write(commit_message + '\n')
sys.exit(0)
except Exception:
try:
if len(sys.argv) >= 2:
with open(sys.argv[1], 'w') as f:
f.write("feat: update project\n")
except Exception:
pass
sys.exit(0)
if __name__ == '__main__':
main()