187 lines
6.1 KiB
Python
Executable File
187 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Script: atomicity-checker.py
|
|
# Purpose: Assess if changes form an atomic commit or should be split
|
|
# Author: Git Commit Assistant Plugin
|
|
# Version: 1.0.0
|
|
#
|
|
# Usage:
|
|
# git diff HEAD | ./atomicity-checker.py
|
|
#
|
|
# Returns:
|
|
# JSON: {"atomic": true/false, "reasoning": "...", "recommendations": [...]}
|
|
#
|
|
# Exit Codes:
|
|
# 0 - Success
|
|
# 1 - No input
|
|
# 2 - Analysis error
|
|
|
|
import sys
|
|
import re
|
|
import json
|
|
from collections import defaultdict
|
|
|
|
def analyze_atomicity(diff_content):
|
|
"""
|
|
Analyze if changes are atomic (single logical unit).
|
|
|
|
Criteria for atomic:
|
|
- Single type (all feat, or all fix, etc.)
|
|
- Single scope (all in one module)
|
|
- Logically cohesive
|
|
- Reasonable file count (<= 10)
|
|
"""
|
|
|
|
lines = diff_content.split('\n')
|
|
|
|
# Track changes
|
|
files = []
|
|
types_detected = set()
|
|
scopes_detected = set()
|
|
file_changes = defaultdict(lambda: {'additions': 0, 'deletions': 0})
|
|
|
|
current_file = None
|
|
|
|
for line in lines:
|
|
# Track files
|
|
if line.startswith('+++ '):
|
|
file_path = line[4:].strip()
|
|
if file_path != '/dev/null' and file_path.startswith('b/'):
|
|
file_path = file_path[2:]
|
|
files.append(file_path)
|
|
current_file = file_path
|
|
|
|
# Detect type from file
|
|
if '.test.' in file_path or '.spec.' in file_path:
|
|
types_detected.add('test')
|
|
elif file_path.endswith('.md'):
|
|
types_detected.add('docs')
|
|
elif 'package.json' in file_path or 'pom.xml' in file_path:
|
|
types_detected.add('build')
|
|
elif '.github/workflows' in file_path or '.gitlab-ci' in file_path:
|
|
types_detected.add('ci')
|
|
|
|
# Detect scope from path
|
|
match = re.match(r'src/([^/]+)/', file_path)
|
|
if match:
|
|
scopes_detected.add(match.group(1))
|
|
|
|
# Count line changes
|
|
if current_file:
|
|
if line.startswith('+') and not line.startswith('+++'):
|
|
file_changes[current_file]['additions'] += 1
|
|
elif line.startswith('-') and not line.startswith('---'):
|
|
file_changes[current_file]['deletions'] += 1
|
|
|
|
# Detect types from content
|
|
if line.startswith('+'):
|
|
if 'export function' in line or 'export class' in line:
|
|
types_detected.add('feat')
|
|
elif 'fix' in line.lower() or 'error' in line.lower():
|
|
types_detected.add('fix')
|
|
elif 'refactor' in line.lower() or 'rename' in line.lower():
|
|
types_detected.add('refactor')
|
|
|
|
# Calculate metrics
|
|
total_files = len(files)
|
|
total_additions = sum(f['additions'] for f in file_changes.values())
|
|
total_deletions = sum(f['deletions'] for f in file_changes.values())
|
|
total_changes = total_additions + total_deletions
|
|
|
|
num_types = len(types_detected)
|
|
num_scopes = len(scopes_detected)
|
|
|
|
# Atomicity checks
|
|
checks = {
|
|
'single_type': num_types <= 1,
|
|
'single_scope': num_scopes <= 1,
|
|
'reasonable_file_count': total_files <= 10,
|
|
'reasonable_change_size': total_changes <= 500,
|
|
'cohesive': num_types <= 1 and num_scopes <= 1
|
|
}
|
|
|
|
# Determine atomicity
|
|
is_atomic = all([
|
|
checks['single_type'] or num_types == 0,
|
|
checks['single_scope'] or num_scopes == 0,
|
|
checks['reasonable_file_count']
|
|
])
|
|
|
|
# Build reasoning
|
|
if is_atomic:
|
|
reasoning = f"Changes are atomic: {total_files} files, "
|
|
if num_types <= 1:
|
|
reasoning += f"single type ({list(types_detected)[0] if types_detected else 'unknown'}), "
|
|
if num_scopes <= 1:
|
|
reasoning += f"single scope ({list(scopes_detected)[0] if scopes_detected else 'root'}). "
|
|
reasoning += "Forms a cohesive logical unit."
|
|
else:
|
|
issues = []
|
|
if num_types > 1:
|
|
issues.append(f"multiple types ({', '.join(types_detected)})")
|
|
if num_scopes > 1:
|
|
issues.append(f"multiple scopes ({', '.join(list(scopes_detected)[:3])})")
|
|
if total_files > 10:
|
|
issues.append(f"many files ({total_files})")
|
|
|
|
reasoning = f"Changes are NOT atomic: {', '.join(issues)}. Should be split into focused commits."
|
|
|
|
# Generate recommendations if not atomic
|
|
recommendations = []
|
|
if not is_atomic:
|
|
if num_types > 1:
|
|
recommendations.append({
|
|
'strategy': 'Split by type',
|
|
'description': f"Create separate commits for each type: {', '.join(types_detected)}"
|
|
})
|
|
if num_scopes > 1:
|
|
recommendations.append({
|
|
'strategy': 'Split by scope',
|
|
'description': f"Create separate commits for each module: {', '.join(list(scopes_detected)[:3])}"
|
|
})
|
|
if total_files > 15:
|
|
recommendations.append({
|
|
'strategy': 'Split by feature',
|
|
'description': 'Break into smaller logical units (5-10 files per commit)'
|
|
})
|
|
|
|
return {
|
|
'atomic': is_atomic,
|
|
'reasoning': reasoning,
|
|
'checks': checks,
|
|
'metrics': {
|
|
'total_files': total_files,
|
|
'total_additions': total_additions,
|
|
'total_deletions': total_deletions,
|
|
'total_changes': total_changes,
|
|
'types_detected': list(types_detected),
|
|
'scopes_detected': list(scopes_detected),
|
|
'num_types': num_types,
|
|
'num_scopes': num_scopes
|
|
},
|
|
'recommendations': recommendations if not is_atomic else []
|
|
}
|
|
|
|
def main():
|
|
diff_content = sys.stdin.read()
|
|
|
|
if not diff_content or not diff_content.strip():
|
|
print(json.dumps({
|
|
'error': 'No diff content provided',
|
|
'atomic': None
|
|
}))
|
|
sys.exit(1)
|
|
|
|
try:
|
|
result = analyze_atomicity(diff_content)
|
|
print(json.dumps(result, indent=2))
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
print(json.dumps({
|
|
'error': str(e),
|
|
'atomic': None
|
|
}))
|
|
sys.exit(2)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|