Added --push/--no-push flag to release tag command for explicit control over tag pushing behavior. **Implementation**: - Added --push/--no-push flag to CLI tag command (default: --push) - Updated ReleaseManager.create_tag to accept push parameter - Updated GitManager.create_tag to conditionally push based on flag - Maintains backward compatibility (defaults to pushing) **Usage**: ```bash # Default behavior - creates and pushes tag release tag --version 0.11.0 # Explicit push (same as default) release tag --version 0.11.0 --push # Create tag but don't push (manual push later) release tag --version 0.11.0 --no-push ``` **Output when --no-push used**: ``` ✅ Tag v0.11.0 created 💡 Push tag with: git push origin v0.11.0 ``` **Benefits**: - Makes push behavior explicit and controllable - Prevents accidental pushes in some workflows - Defaults to safe behavior (automatic push) - Helpful reminder shown when --no-push used **Files Modified**: - capabilities/release-management/src/release_management/cli/main.py - capabilities/release-management/src/release_management/core/manager.py - capabilities/release-management/src/release_management/git/manager.py Optimizations completed: 2/9 (High Priority)
254 lines
8.5 KiB
Python
254 lines
8.5 KiB
Python
"""
|
|
Git operations for release management.
|
|
|
|
This module handles all Git-related operations needed for releases.
|
|
"""
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, List
|
|
|
|
|
|
class GitManager:
|
|
"""Manages Git operations for releases."""
|
|
|
|
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False):
|
|
"""Initialize Git manager.
|
|
|
|
Args:
|
|
project_root: Root directory of the project
|
|
dry_run: If True, show what would be done without executing
|
|
"""
|
|
self.project_root = project_root or Path.cwd()
|
|
self.dry_run = dry_run
|
|
|
|
def get_repository_status(self) -> Dict[str, Any]:
|
|
"""Get current git repository status.
|
|
|
|
Returns:
|
|
Dictionary with repository status information
|
|
"""
|
|
try:
|
|
# Get current branch
|
|
branch_result = self._run_command(['git', 'branch', '--show-current'])
|
|
current_branch = branch_result.stdout.strip()
|
|
|
|
# Check for uncommitted changes
|
|
status_result = self._run_command(['git', 'status', '--porcelain'])
|
|
has_changes = bool(status_result.stdout.strip())
|
|
|
|
# Get latest commit
|
|
commit_result = self._run_command(['git', 'rev-parse', '--short', 'HEAD'])
|
|
latest_commit = commit_result.stdout.strip()
|
|
|
|
# Get latest tag
|
|
try:
|
|
tag_result = self._run_command(['git', 'describe', '--tags', '--abbrev=0'])
|
|
latest_tag = tag_result.stdout.strip()
|
|
except subprocess.CalledProcessError:
|
|
latest_tag = None
|
|
|
|
# Get unpushed tags
|
|
unpushed_tags = self.get_unpushed_tags()
|
|
|
|
return {
|
|
'is_repo': True,
|
|
'branch': current_branch,
|
|
'has_changes': has_changes,
|
|
'latest_commit': latest_commit,
|
|
'latest_tag': latest_tag,
|
|
'unpushed_tags': unpushed_tags
|
|
}
|
|
except subprocess.CalledProcessError:
|
|
return {'is_repo': False}
|
|
|
|
def create_tag(self, version: str, message: Optional[str] = None, push: bool = True) -> bool:
|
|
"""Create and optionally push git tag.
|
|
|
|
Args:
|
|
version: Version to tag (e.g., "1.0.0")
|
|
message: Optional tag message
|
|
push: Whether to push the tag to origin (default: True)
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
if not version.startswith('v'):
|
|
tag_name = f"v{version}"
|
|
else:
|
|
tag_name = version
|
|
|
|
tag_message = message or f"Release {version.lstrip('v')}"
|
|
print(f"🏷️ Creating git tag {tag_name}")
|
|
|
|
try:
|
|
# Create annotated tag
|
|
self._run_command(['git', 'tag', '-a', tag_name, '-m', tag_message])
|
|
print(f"✅ Tag {tag_name} created")
|
|
|
|
# Push tag to origin if requested
|
|
if push:
|
|
try:
|
|
print(f"📤 Pushing tag to origin...")
|
|
self._run_command(['git', 'push', 'origin', tag_name])
|
|
print(f"✅ Tag pushed to origin")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"⚠️ Could not push tag to origin: {e}")
|
|
print(f"You can push it manually with: git push origin {tag_name}")
|
|
return True # Tag created successfully, push can be done manually
|
|
else:
|
|
return True # Tag created successfully, user chose not to push
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"❌ Failed to create tag: {e}")
|
|
return False
|
|
|
|
def validate_release_state(self, force: bool = False) -> tuple[bool, List[str]]:
|
|
"""Validate that repository is ready for release.
|
|
|
|
Args:
|
|
force: Skip validation checks if True
|
|
|
|
Returns:
|
|
Tuple of (is_valid, list_of_issues)
|
|
"""
|
|
issues = []
|
|
status = self.get_repository_status()
|
|
|
|
if not status['is_repo']:
|
|
issues.append("Not in a git repository")
|
|
else:
|
|
if status['has_changes'] and not force:
|
|
issues.append("Repository has uncommitted changes")
|
|
if status['branch'] != 'main' and not force:
|
|
issues.append(f"Not on main branch (currently on {status['branch']})")
|
|
|
|
return len(issues) == 0, issues
|
|
|
|
def get_commits_since_tag(self, tag_name: Optional[str] = None) -> List[str]:
|
|
"""Get list of commits since specified tag.
|
|
|
|
Args:
|
|
tag_name: Tag to compare against. If None, uses latest tag.
|
|
|
|
Returns:
|
|
List of commit messages since tag
|
|
"""
|
|
try:
|
|
if tag_name is None:
|
|
# Get latest tag
|
|
tag_result = self._run_command(['git', 'describe', '--tags', '--abbrev=0'])
|
|
tag_name = tag_result.stdout.strip()
|
|
|
|
# Get commits since tag
|
|
log_result = self._run_command([
|
|
'git', 'log', f'{tag_name}..HEAD', '--oneline', '--no-merges'
|
|
])
|
|
|
|
commits = []
|
|
for line in log_result.stdout.strip().split('\n'):
|
|
if line:
|
|
commits.append(line)
|
|
|
|
return commits
|
|
|
|
except subprocess.CalledProcessError:
|
|
return []
|
|
|
|
def tag_exists(self, tag_name: str) -> bool:
|
|
"""Check if a git tag exists.
|
|
|
|
Args:
|
|
tag_name: Tag name to check
|
|
|
|
Returns:
|
|
True if tag exists, False otherwise
|
|
"""
|
|
try:
|
|
self._run_command(['git', 'rev-parse', f'refs/tags/{tag_name}'])
|
|
return True
|
|
except subprocess.CalledProcessError:
|
|
return False
|
|
|
|
def get_remote_url(self, remote: str = 'origin') -> Optional[str]:
|
|
"""Get the URL of a git remote.
|
|
|
|
Args:
|
|
remote: Remote name (default: 'origin')
|
|
|
|
Returns:
|
|
Remote URL or None if not found
|
|
"""
|
|
try:
|
|
result = self._run_command(['git', 'remote', 'get-url', remote])
|
|
return result.stdout.strip()
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
|
|
def get_unpushed_tags(self, remote: str = 'origin') -> List[str]:
|
|
"""Get list of tags that exist locally but not on remote.
|
|
|
|
Args:
|
|
remote: Remote name to compare against (default: 'origin')
|
|
|
|
Returns:
|
|
List of unpushed tag names
|
|
"""
|
|
try:
|
|
# Get local tags
|
|
local_result = self._run_command(['git', 'tag', '-l'])
|
|
local_tags = set(tag.strip() for tag in local_result.stdout.strip().split('\n') if tag.strip())
|
|
|
|
# Get remote tags
|
|
try:
|
|
remote_result = self._run_command(['git', 'ls-remote', '--tags', remote])
|
|
remote_lines = remote_result.stdout.strip().split('\n')
|
|
|
|
# Parse remote tags (format: "hash refs/tags/tagname")
|
|
remote_tags = set()
|
|
for line in remote_lines:
|
|
if not line:
|
|
continue
|
|
parts = line.split('refs/tags/')
|
|
if len(parts) > 1:
|
|
# Remove ^{} suffix for annotated tags
|
|
tag_name = parts[1].replace('^{}', '')
|
|
remote_tags.add(tag_name)
|
|
|
|
# Find tags that are local but not remote
|
|
unpushed = sorted(local_tags - remote_tags)
|
|
return unpushed
|
|
|
|
except subprocess.CalledProcessError:
|
|
# Remote not available, assume all tags are unpushed
|
|
return sorted(local_tags)
|
|
|
|
except subprocess.CalledProcessError:
|
|
return []
|
|
|
|
def _run_command(self, cmd: List[str]) -> subprocess.CompletedProcess:
|
|
"""Run a git command.
|
|
|
|
Args:
|
|
cmd: Command to execute
|
|
|
|
Returns:
|
|
CompletedProcess result
|
|
|
|
Raises:
|
|
subprocess.CalledProcessError: If command fails
|
|
"""
|
|
if self.dry_run and not any(read_only in cmd for read_only in
|
|
['status', 'branch', 'rev-parse', 'describe',
|
|
'log', 'remote']):
|
|
print(f"[DRY RUN] Would run: {' '.join(cmd)}")
|
|
return subprocess.CompletedProcess(cmd, 0, "", "")
|
|
|
|
return subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
cwd=self.project_root
|
|
) |