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