Files
gh-anthemflynn-ccmp-plugins…/skills/git-commit/scripts/version.py
2025-11-29 17:55:13 +08:00

218 lines
6.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Calculate next semantic version from commits.
Usage:
python version.py # Auto-detect current, suggest next
python version.py --current 1.2.3 # Start from specific version
python version.py --verbose # Show detailed analysis
"""
import subprocess
import sys
import re
from typing import Optional, Tuple
class Version:
"""Semantic version."""
def __init__(self, major: int, minor: int, patch: int):
self.major = major
self.minor = minor
self.patch = patch
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
@classmethod
def parse(cls, version_str: str) -> 'Version':
"""Parse version string."""
version_str = version_str.lstrip('v')
parts = version_str.split('.')
if len(parts) != 3:
raise ValueError(f"Invalid version: {version_str}")
return cls(int(parts[0]), int(parts[1]), int(parts[2]))
def bump_major(self) -> 'Version':
"""Bump major version."""
return Version(self.major + 1, 0, 0)
def bump_minor(self) -> 'Version':
"""Bump minor version."""
return Version(self.major, self.minor + 1, 0)
def bump_patch(self) -> 'Version':
"""Bump patch version."""
return Version(self.major, self.minor, self.patch + 1)
class CommitAnalyzer:
"""Analyze commits for versioning."""
def __init__(self):
self.breaking_commits = []
self.feature_commits = []
self.fix_commits = []
self.other_commits = []
def analyze(self, from_ref: Optional[str] = None):
"""Analyze commits since ref."""
commits = self._get_commits(from_ref)
for commit in commits:
self._classify_commit(commit)
def _get_commits(self, from_ref: Optional[str]) -> list:
"""Get commit messages."""
cmd = ['git', 'log', '--format=%s']
if from_ref:
cmd.insert(2, f'{from_ref}..HEAD')
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return [line.strip() for line in result.stdout.split('\n') if line.strip()]
except subprocess.CalledProcessError:
return []
def _classify_commit(self, message: str):
"""Classify a commit message."""
# Check for breaking change
if '!' in message and ':' in message:
if message.index('!') < message.index(':'):
self.breaking_commits.append(message)
return
# Parse type
pattern = r'^(?P<type>\w+)(?:\([^)]+\))?:\s*'
match = re.match(pattern, message)
if not match:
self.other_commits.append(message)
return
commit_type = match.group('type')
if commit_type == 'feat':
self.feature_commits.append(message)
elif commit_type == 'fix':
self.fix_commits.append(message)
else:
self.other_commits.append(message)
def get_bump_type(self) -> Tuple[str, str]:
"""
Get version bump type and reason.
Returns:
(bump_type, reason) where bump_type is 'major', 'minor', or 'patch'
"""
if self.breaking_commits:
return 'major', f'{len(self.breaking_commits)} breaking change(s)'
if self.feature_commits or self.fix_commits:
feat_count = len(self.feature_commits)
fix_count = len(self.fix_commits)
parts = []
if feat_count:
parts.append(f'{feat_count} feature(s)')
if fix_count:
parts.append(f'{fix_count} fix(es)')
return 'minor', ', '.join(parts)
return 'patch', f'{len(self.other_commits)} other change(s)'
def get_next_version(self, current: Version) -> Tuple[Version, str, str]:
"""
Get next version.
Returns:
(next_version, bump_type, reason)
"""
bump_type, reason = self.get_bump_type()
if bump_type == 'major':
next_version = current.bump_major()
elif bump_type == 'minor':
next_version = current.bump_minor()
else:
next_version = current.bump_patch()
return next_version, bump_type, reason
def get_latest_tag() -> Optional[str]:
"""Get latest git tag."""
try:
result = subprocess.run(
['git', 'describe', '--tags', '--abbrev=0'],
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def main():
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description='Calculate next version')
parser.add_argument('--current', help='Current version (default: latest tag)')
parser.add_argument('--from', dest='from_ref', help='Analyze from this ref')
parser.add_argument('--verbose', action='store_true', help='Show detailed analysis')
args = parser.parse_args()
# Get current version
if args.current:
current = Version.parse(args.current)
from_ref = args.from_ref or f'v{current}'
else:
tag = get_latest_tag()
if tag:
current = Version.parse(tag)
from_ref = args.from_ref or tag
else:
current = Version(0, 0, 0)
from_ref = args.from_ref
# Analyze commits
analyzer = CommitAnalyzer()
analyzer.analyze(from_ref)
next_version, bump_type, reason = analyzer.get_next_version(current)
if args.verbose:
print(f"📊 Version Analysis\n")
print(f"Current version: {current}")
if from_ref:
print(f"Analyzing since: {from_ref}")
print()
print("Commits found:")
print(f"{len(analyzer.breaking_commits)} breaking change(s)")
print(f"{len(analyzer.feature_commits)} feature(s)")
print(f"{len(analyzer.fix_commits)} fix(es)")
print(f"{len(analyzer.other_commits)} other change(s)")
print()
print(f"Bump type: {bump_type.upper()}")
print(f"Reason: {reason}")
print()
print(f"Next version: {next_version}")
if analyzer.breaking_commits:
print()
print("Breaking commits:")
for commit in analyzer.breaking_commits[:5]:
print(f"{commit}")
else:
print(next_version)
if __name__ == '__main__':
main()