473 lines
15 KiB
Python
Executable File
473 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
git.cleanupbranches - Clean up merged and stale git branches
|
|
|
|
Analyzes branch status, identifies branches that are safe to delete (merged or stale),
|
|
and provides interactive cleanup with safety checks.
|
|
|
|
Generated by meta.skill with Betty Framework certification
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import yaml
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Dict, List, Any, Optional
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
from betty.config import BASE_DIR
|
|
from betty.logging_utils import setup_logger
|
|
from betty.certification import certified_skill
|
|
|
|
logger = setup_logger(__name__)
|
|
|
|
|
|
class GitCleanupbranches:
|
|
"""
|
|
Clean up merged and stale git branches both locally and remotely.
|
|
"""
|
|
|
|
def __init__(self, base_dir: str = "."):
|
|
"""Initialize skill"""
|
|
self.base_dir = Path(base_dir)
|
|
self.protected_branches = ["main", "master", "develop", "development"]
|
|
|
|
def run_git_command(self, command: List[str]) -> tuple[bool, str]:
|
|
"""
|
|
Run a git command and return success status and output
|
|
|
|
Args:
|
|
command: Git command as list of arguments
|
|
|
|
Returns:
|
|
Tuple of (success, output)
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
cwd=self.base_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
return True, result.stdout.strip()
|
|
except subprocess.CalledProcessError as e:
|
|
return False, e.stderr.strip()
|
|
|
|
def is_git_repository(self) -> bool:
|
|
"""Check if current directory is a git repository"""
|
|
success, _ = self.run_git_command(["git", "rev-parse", "--is-inside-work-tree"])
|
|
return success
|
|
|
|
def get_current_branch(self) -> Optional[str]:
|
|
"""Get the current branch name"""
|
|
success, output = self.run_git_command(["git", "branch", "--show-current"])
|
|
return output if success else None
|
|
|
|
def get_all_local_branches(self) -> List[str]:
|
|
"""Get list of all local branches"""
|
|
success, output = self.run_git_command(["git", "branch", "--format=%(refname:short)"])
|
|
if not success:
|
|
return []
|
|
return [b.strip() for b in output.split('\n') if b.strip()]
|
|
|
|
def is_branch_merged(self, branch: str, into_branch: str = "main") -> bool:
|
|
"""
|
|
Check if a branch is merged into another branch
|
|
|
|
Args:
|
|
branch: Branch to check
|
|
into_branch: Branch to check against
|
|
|
|
Returns:
|
|
True if merged, False otherwise
|
|
"""
|
|
# Try multiple base branches
|
|
for base in ["main", "master", "develop"]:
|
|
success, output = self.run_git_command(
|
|
["git", "branch", "--merged", base, "--format=%(refname:short)"]
|
|
)
|
|
if success and branch in output.split('\n'):
|
|
return True
|
|
return False
|
|
|
|
def get_branch_last_commit_date(self, branch: str) -> Optional[datetime]:
|
|
"""
|
|
Get the date of the last commit on a branch
|
|
|
|
Args:
|
|
branch: Branch name
|
|
|
|
Returns:
|
|
Datetime of last commit or None
|
|
"""
|
|
success, output = self.run_git_command(
|
|
["git", "log", "-1", "--format=%ct", branch]
|
|
)
|
|
if success and output:
|
|
try:
|
|
timestamp = int(output)
|
|
return datetime.fromtimestamp(timestamp)
|
|
except (ValueError, OSError):
|
|
return None
|
|
return None
|
|
|
|
def is_branch_stale(self, branch: str, days: int = 30) -> bool:
|
|
"""
|
|
Check if a branch is stale (no commits for N days)
|
|
|
|
Args:
|
|
branch: Branch name
|
|
days: Number of days to consider stale
|
|
|
|
Returns:
|
|
True if stale, False otherwise
|
|
"""
|
|
last_commit = self.get_branch_last_commit_date(branch)
|
|
if last_commit is None:
|
|
return False
|
|
|
|
cutoff_date = datetime.now() - timedelta(days=days)
|
|
return last_commit < cutoff_date
|
|
|
|
def delete_local_branch(self, branch: str, force: bool = False) -> bool:
|
|
"""
|
|
Delete a local branch
|
|
|
|
Args:
|
|
branch: Branch name
|
|
force: Use -D instead of -d
|
|
|
|
Returns:
|
|
True if deleted successfully
|
|
"""
|
|
flag = "-D" if force else "-d"
|
|
success, output = self.run_git_command(["git", "branch", flag, branch])
|
|
if success:
|
|
logger.info(f"Deleted local branch: {branch}")
|
|
else:
|
|
logger.warning(f"Failed to delete branch {branch}: {output}")
|
|
return success
|
|
|
|
def delete_remote_branch(self, branch: str) -> bool:
|
|
"""
|
|
Delete a remote branch
|
|
|
|
Args:
|
|
branch: Branch name
|
|
|
|
Returns:
|
|
True if deleted successfully
|
|
"""
|
|
success, output = self.run_git_command(["git", "push", "origin", "--delete", branch])
|
|
if success:
|
|
logger.info(f"Deleted remote branch: {branch}")
|
|
else:
|
|
logger.warning(f"Failed to delete remote branch {branch}: {output}")
|
|
return success
|
|
|
|
@certified_skill("git.cleanupbranches")
|
|
def execute(
|
|
self,
|
|
dry_run: bool = True,
|
|
include_remote: bool = False,
|
|
stale_days: int = 30,
|
|
protected_branches: Optional[List[str]] = None,
|
|
interactive: bool = True,
|
|
merged_only: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Execute the skill
|
|
|
|
Args:
|
|
dry_run: Show what would be deleted without deleting
|
|
include_remote: Also clean up remote branches
|
|
stale_days: Consider branches stale after N days
|
|
protected_branches: Branches to never delete
|
|
interactive: Ask for confirmation before deleting
|
|
merged_only: Only delete merged branches
|
|
|
|
Returns:
|
|
Dict with execution results
|
|
"""
|
|
try:
|
|
logger.info("Executing git.cleanupbranches...")
|
|
|
|
# Validate we're in a git repository
|
|
if not self.is_git_repository():
|
|
return {
|
|
"ok": False,
|
|
"status": "failed",
|
|
"error": "Not in a git repository"
|
|
}
|
|
|
|
# Use provided protected branches or defaults
|
|
if protected_branches:
|
|
self.protected_branches = protected_branches
|
|
|
|
# Get current branch (never delete)
|
|
current_branch = self.get_current_branch()
|
|
if not current_branch:
|
|
return {
|
|
"ok": False,
|
|
"status": "failed",
|
|
"error": "Could not determine current branch"
|
|
}
|
|
|
|
# Get all local branches
|
|
all_branches = self.get_all_local_branches()
|
|
logger.info(f"Found {len(all_branches)} local branches")
|
|
|
|
# Analyze branches
|
|
branches_to_delete = []
|
|
branches_kept = []
|
|
analysis = []
|
|
|
|
for branch in all_branches:
|
|
# Skip current branch
|
|
if branch == current_branch:
|
|
branches_kept.append(branch)
|
|
analysis.append({
|
|
"branch": branch,
|
|
"action": "keep",
|
|
"reason": "current branch"
|
|
})
|
|
continue
|
|
|
|
# Skip protected branches
|
|
if branch in self.protected_branches:
|
|
branches_kept.append(branch)
|
|
analysis.append({
|
|
"branch": branch,
|
|
"action": "keep",
|
|
"reason": "protected"
|
|
})
|
|
continue
|
|
|
|
# Check if merged
|
|
is_merged = self.is_branch_merged(branch)
|
|
|
|
# Check if stale
|
|
is_stale = False if merged_only else self.is_branch_stale(branch, stale_days)
|
|
|
|
# Determine if should delete
|
|
should_delete = is_merged or (is_stale and not merged_only)
|
|
|
|
if should_delete:
|
|
reason = "merged" if is_merged else f"stale ({stale_days}+ days)"
|
|
branches_to_delete.append(branch)
|
|
analysis.append({
|
|
"branch": branch,
|
|
"action": "delete",
|
|
"reason": reason,
|
|
"is_merged": is_merged,
|
|
"is_stale": is_stale
|
|
})
|
|
else:
|
|
branches_kept.append(branch)
|
|
analysis.append({
|
|
"branch": branch,
|
|
"action": "keep",
|
|
"reason": "active"
|
|
})
|
|
|
|
# Display analysis
|
|
logger.info(f"Analysis complete:")
|
|
logger.info(f" Total branches: {len(all_branches)}")
|
|
logger.info(f" To delete: {len(branches_to_delete)}")
|
|
logger.info(f" To keep: {len(branches_kept)}")
|
|
|
|
if dry_run:
|
|
logger.info("DRY RUN - No branches will be deleted")
|
|
logger.info("Branches that would be deleted:")
|
|
for item in analysis:
|
|
if item["action"] == "delete":
|
|
logger.info(f" - {item['branch']} ({item['reason']})")
|
|
else:
|
|
# Interactive confirmation
|
|
if interactive and branches_to_delete:
|
|
logger.info("Branches to delete:")
|
|
for item in analysis:
|
|
if item["action"] == "delete":
|
|
logger.info(f" - {item['branch']} ({item['reason']})")
|
|
|
|
response = input("\nProceed with deletion? (yes/no): ").strip().lower()
|
|
if response not in ["yes", "y"]:
|
|
logger.info("Aborted by user")
|
|
return {
|
|
"ok": True,
|
|
"status": "aborted",
|
|
"message": "Aborted by user",
|
|
"analyzed": len(all_branches),
|
|
"would_delete": len(branches_to_delete)
|
|
}
|
|
|
|
# Delete branches
|
|
deleted = []
|
|
errors = []
|
|
|
|
for branch in branches_to_delete:
|
|
# Delete local
|
|
if self.delete_local_branch(branch):
|
|
deleted.append(branch)
|
|
|
|
# Delete remote if requested
|
|
if include_remote:
|
|
self.delete_remote_branch(branch)
|
|
else:
|
|
errors.append(f"Failed to delete {branch}")
|
|
|
|
logger.info(f"Deleted {len(deleted)} branches")
|
|
if errors:
|
|
logger.warning(f"{len(errors)} errors occurred")
|
|
|
|
# Build result
|
|
result = {
|
|
"ok": True,
|
|
"status": "success",
|
|
"analyzed": len(all_branches),
|
|
"deleted": len(branches_to_delete) if not dry_run else 0,
|
|
"kept": len(branches_kept),
|
|
"branches_to_delete": branches_to_delete,
|
|
"branches_kept": branches_kept,
|
|
"protected": len([b for b in all_branches if b in self.protected_branches]),
|
|
"dry_run": dry_run,
|
|
"analysis": analysis
|
|
}
|
|
|
|
if not dry_run and branches_to_delete:
|
|
result["branches_deleted"] = deleted if not dry_run else []
|
|
result["errors"] = errors if not dry_run else []
|
|
|
|
logger.info("Skill completed successfully")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error executing skill: {e}")
|
|
return {
|
|
"ok": False,
|
|
"status": "failed",
|
|
"error": str(e)
|
|
}
|
|
|
|
|
|
def main():
|
|
"""CLI entry point"""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Clean up merged and stale git branches"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
default=True,
|
|
help="Show what would be deleted without deleting (default: true)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--no-dry-run",
|
|
dest="dry_run",
|
|
action="store_false",
|
|
help="Actually delete branches"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--include-remote",
|
|
action="store_true",
|
|
default=False,
|
|
help="Also delete remote branches"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--stale-days",
|
|
type=int,
|
|
default=30,
|
|
help="Consider branches stale after N days (default: 30)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--protected-branches",
|
|
nargs="+",
|
|
default=["main", "master", "develop", "development"],
|
|
help="Branches to never delete"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--interactive",
|
|
action="store_true",
|
|
default=True,
|
|
help="Ask for confirmation before deleting (default: true)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--no-interactive",
|
|
dest="interactive",
|
|
action="store_false",
|
|
help="Don't ask for confirmation"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--merged-only",
|
|
action="store_true",
|
|
default=False,
|
|
help="Only delete merged branches, ignore stale"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--output-format",
|
|
choices=["json", "yaml", "human"],
|
|
default="human",
|
|
help="Output format"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Create skill instance
|
|
skill = GitCleanupbranches()
|
|
|
|
# Execute skill
|
|
result = skill.execute(
|
|
dry_run=args.dry_run,
|
|
include_remote=args.include_remote,
|
|
stale_days=args.stale_days,
|
|
protected_branches=args.protected_branches,
|
|
interactive=args.interactive,
|
|
merged_only=args.merged_only
|
|
)
|
|
|
|
# Output result
|
|
if args.output_format == "json":
|
|
print(json.dumps(result, indent=2))
|
|
elif args.output_format == "yaml":
|
|
print(yaml.dump(result, default_flow_style=False))
|
|
else:
|
|
# Human-readable output
|
|
if result.get("ok"):
|
|
print(f"\n✓ Branch Cleanup {'(DRY RUN)' if result.get('dry_run') else ''}")
|
|
print(f" Analyzed: {result.get('analyzed', 0)} branches")
|
|
if result.get('dry_run'):
|
|
print(f" Would delete: {len(result.get('branches_to_delete', []))} branches")
|
|
else:
|
|
print(f" Deleted: {result.get('deleted', 0)} branches")
|
|
print(f" Kept: {result.get('kept', 0)} branches")
|
|
print(f" Protected: {result.get('protected', 0)} branches")
|
|
|
|
if result.get('branches_to_delete'):
|
|
print(f"\nBranches to delete:")
|
|
for branch in result.get('branches_to_delete', []):
|
|
print(f" - {branch}")
|
|
else:
|
|
print(f"\n✗ Error: {result.get('error', 'Unknown error')}")
|
|
|
|
# Exit with appropriate code
|
|
sys.exit(0 if result.get("ok") else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|