Initial commit
This commit is contained in:
217
skills/git-commit/scripts/version.py
Executable file
217
skills/git-commit/scripts/version.py
Executable file
@@ -0,0 +1,217 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user