#!/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()