Files
gh-dhofheinz-open-plugins-p…/commands/commit-analysis/.scripts/atomicity-checker.py
2025-11-29 18:20:25 +08:00

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()