Skip to content

Instantly share code, notes, and snippets.

@Prosen-Ghosh
Created November 4, 2025 19:16
Show Gist options
  • Select an option

  • Save Prosen-Ghosh/af5ac5ea65c9c64996cb4f75826fe442 to your computer and use it in GitHub Desktop.

Select an option

Save Prosen-Ghosh/af5ac5ea65c9c64996cb4f75826fe442 to your computer and use it in GitHub Desktop.
AI Commit Message Generator Generate conventional commit messages using local LLMs (Ollama). Quick Start Install Ollama: curl -fsSL https://ollama.com/install.sh | sh ollama pull mistral:7b-instruct Install dependencies: pip install -r requirements.txt Stage changes and generate: git add . python3 src/commit_generator.py
#!/usr/bin/env python3
import subprocess
import json
import re
import sys
from typing import Optional, Dict, List
import argparse
try:
import requests
except ImportError:
print("Installing requests library...")
subprocess.check_call([
sys.executable,
"-m",
"pip",
"install",
"requests"
])
class OllamaClient:
def __init__(self, base_url: str = "http://localhost:11434", model: str = "mistral:7b-instruct"):
self.base_url = base_url
self.model = model
self.available = self._checkAvailability()
def _checkAvailability(self) -> bool:
try:
response = requests.get(f"{self.base_url}/api/tags", timeout=2)
if response.status_code == 200:
models = response.json().get('models', [])
return any(self.model in m.get('name', '') for m in models)
return False
except requests.exceptions.RequestException:
return False
def generate(self, prompt: str, max_tokens: int = 150) -> Optional[str]:
if not self.available:
return None
try:
response = requests.post(
f"{self.base_url}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"stream": False,
"think": False,
"options": {
"temperature": 0.3, # Lower temperature for more consistent output
"num_predict": max_tokens
}
},
timeout=30
)
if response.status_code == 200:
return response.json().get('response', '').strip()
return None
except requests.exceptions.RequestException as e:
print(f"Error calling Ollama: {e}", file=sys.stderr)
return None
class DiffAnalyzer:
@staticmethod
def get_staged_diff() -> Optional[str]:
try:
result = subprocess.run(
['git', 'diff', '--staged'],
capture_output=True,
text=True,
check=True
)
return result.stdout
except subprocess.CalledProcessError:
print("Error: Not in a Git repository or no staged changes", file=sys.stderr)
return None
@staticmethod
def analyze_diff(diff: str) -> Dict[str, any]:
lines = diff.split('\n')
files_changed = []
additions = 0
deletions = 0
for line in lines:
if line.startswith('+++') or line.startswith('---'):
match = re.search(r'[ab]/(.*)', line)
if match:
filename = match.group(1)
if filename != '/dev/null' and filename not in files_changed:
files_changed.append(filename)
elif line.startswith('+') and not line.startswith('+++'):
additions += 1
elif line.startswith('-') and not line.startswith('---'):
deletions += 1
file_types = set()
for f in files_changed:
ext = f.split('.')[-1] if '.' in f else 'unknown'
file_types.add(ext)
return {
'files_changed': files_changed,
'file_types': list(file_types),
'additions': additions,
'deletions': deletions,
'diff_size': len(diff)
}
class CommitMessageGenerator:
COMMIT_TYPES = [
'feat', # New feature
'fix', # Bug fix
'docs', # Documentation changes
'style', # Code style changes (formatting, etc)
'refactor', # Code refactoring
'test', # Adding or updating tests
'chore', # Maintenance tasks
'perf', # Performance improvements
'ci', # CI/CD changes
'build', # Build system changes
]
def __init__(self, ollama_client: OllamaClient):
self.ollama = ollama_client
def generate(self, diff: str, metadata: Dict) -> str:
if self.ollama.available:
return self._generate_with_llm(diff, metadata)
else:
print("⚠️ Ollama not available, using mock generator", file=sys.stderr)
return self._generate_mock(diff, metadata)
def _generate_with_llm(self, diff: str, metadata: Dict) -> str:
max_diff_length = 2000
diff_preview = diff[:max_diff_length]
if len(diff) > max_diff_length:
diff_preview += "\n... (truncated)"
prompt = f"""You are a Git commit message generator. Analyze the following git diff and generate a concise conventional commit message.
RULES:
1. Use conventional commit format: <type>(<scope>): <description>
2. Types: {', '.join(self.COMMIT_TYPES)}
3. Keep description under 72 characters
4. Be specific but concise
5. Use imperative mood ("add" not "added")
6. Do NOT include explanations, only output the commit message
FILES CHANGED: {', '.join(metadata['files_changed'][:3])}
STATS: +{metadata['additions']} -{metadata['deletions']}
GIT DIFF:
{diff_preview}
COMMIT MESSAGE:"""
response = self.ollama.generate(prompt, max_tokens=100)
if response:
message = response.split('\n')[0].strip()
message = message.strip('"\'')
if self._validate_format(message):
return message
return self._generate_mock(diff, metadata)
def _generate_mock(self, diff: str, metadata: Dict) -> str:
"""Generate basic message without LLM"""
files = metadata['files_changed']
file_types = metadata['file_types']
# Determine type based on heuristics
commit_type = 'chore'
scope = ''
# Check for common patterns in diff
diff_lower = diff.lower()
if any(word in diff_lower for word in ['test', 'spec', 'jest', 'pytest']):
commit_type = 'test'
elif any(word in diff_lower for word in ['readme', 'doc', 'comment', '/**']):
commit_type = 'docs'
elif any(word in diff_lower for word in ['fix', 'bug', 'issue', 'error']):
commit_type = 'fix'
elif any(word in diff_lower for word in ['add', 'new', 'create', 'implement']):
commit_type = 'feat'
elif metadata['additions'] < metadata['deletions']:
commit_type = 'refactor'
# Determine scope from file types or directories
if file_types:
if 'py' in file_types:
scope = 'python'
elif 'js' in file_types or 'ts' in file_types:
scope = 'javascript'
elif 'md' in file_types:
scope = 'docs'
elif 'yml' in file_types or 'yaml' in file_types:
scope = 'config'
# Generate description
if len(files) == 1:
description = f"update {files[0].split('/')[-1]}"
else:
description = f"update {len(files)} files"
if scope:
return f"{commit_type}({scope}): {description}"
return f"{commit_type}: {description}"
def _validate_format(self, message: str) -> bool:
pattern = r'^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([a-z\-]+\))?: .{1,72}$'
return bool(re.match(pattern, message, re.IGNORECASE))
def main():
parser = argparse.ArgumentParser(
description='Generate conventional commit messages using local LLM'
)
parser.add_argument(
'--model',
default='mistral:7b-instruct',
help='Ollama model to use (default: mistral:7b-instruct)'
)
parser.add_argument(
'--ollama-url',
default='http://localhost:11434',
help='Ollama API URL (default: http://localhost:11434)'
)
parser.add_argument(
'--auto-commit',
action='store_true',
help='Automatically commit without confirmation'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Generate message but do not commit'
)
args = parser.parse_args()
diff_analyzer = DiffAnalyzer()
diff = diff_analyzer.get_staged_diff()
if not diff or not diff.strip():
print("❌ No staged changes found. Use 'git add' first.", file=sys.stderr)
sys.exit(1)
print("πŸ“Š Analyzing staged changes...")
metadata = diff_analyzer.analyze_diff(diff)
print(f" Files: {len(metadata['files_changed'])}")
print(f" Stats: +{metadata['additions']} -{metadata['deletions']}")
print(f"\nπŸ€– Initializing LLM ({args.model})...")
ollama = OllamaClient(base_url=args.ollama_url, model=args.model)
if ollama.available:
print(" βœ“ Ollama connected")
else:
print(" ⚠️ Ollama not available, using fallback generator")
print("\n✨ Generating commit message...")
generator = CommitMessageGenerator(ollama)
commit_message = generator.generate(diff, metadata)
print(f"\nπŸ“ Generated commit message:")
print(f" {commit_message}")
if args.dry_run:
print("\n🏁 Dry run complete (no commit created)")
sys.exit(0)
if not args.auto_commit:
response = input("\n❓ Use this message? [Y/n/e(dit)]: ").strip().lower()
if response == 'e':
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(commit_message)
temp_file = f.name
editor = subprocess.os.environ.get('Editor', 'nano')
subprocess.call([editor, temp_file])
with open(temp_file, 'r') as f:
commit_message = f.read().strip()
subprocess.os.unlink(temp_file)
elif response == 'n':
print("❌ Commit cancelled")
sys.exit(0)
# Create commit
try:
subprocess.run(
['git', 'commit', '-m', commit_message],
check=True
)
print(f"\nβœ… Committed: {commit_message}")
except subprocess.CalledProcessError as e:
print(f"❌ Commit failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment