diff --git a/docs/changelog.d/feature-branch-cleanup-task.feature.md b/docs/changelog.d/feature-branch-cleanup-task.feature.md new file mode 100644 index 0000000..1174431 --- /dev/null +++ b/docs/changelog.d/feature-branch-cleanup-task.feature.md @@ -0,0 +1 @@ +Add `branch-cleanup` mise task to delete merged branches locally and on the Forgejo remote, with a configurable age cutoff (default 30 days). diff --git a/mise-tasks/branch-cleanup b/mise-tasks/branch-cleanup new file mode 100755 index 0000000..0729be9 --- /dev/null +++ b/mise-tasks/branch-cleanup @@ -0,0 +1,242 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["rich>=13.0.0", "typer>=0.15.0"] +# /// +#MISE description="Delete branches that have been merged into main (local and remote)" +#USAGE flag "--dry-run" help="Show what would be deleted without deleting" +#USAGE flag "--local-only" help="Only clean up local branches" +#USAGE flag "--remote-only" help="Only clean up remote branches" +#USAGE flag "--cutoff " default="30" help="Only delete branches whose HEAD commit is older than N days (default 30)" +"""Clean up merged branches locally and on the Forgejo remote. + +Finds branches that have been fully merged into main whose HEAD commit +is older than a cutoff (default 30 days), then offers to delete them. +Uses git operations directly (SSH for remote), no API token needed. + +Protected branches (main) are never deleted. + +Usage: + mise run branch-cleanup # interactive cleanup (30-day cutoff) + mise run branch-cleanup -- --cutoff 7 # only branches older than 7 days + mise run branch-cleanup -- --cutoff 0 # all merged branches regardless of age + mise run branch-cleanup -- --dry-run # preview only +""" + +import subprocess +from datetime import datetime, timezone +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +PROTECTED_BRANCHES = {"main", "master"} + + +def run_git(*args: str) -> str: + """Run a git command and return stdout.""" + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def branch_head_age_days(ref: str) -> int | None: + """Get the age in days of the HEAD commit on a branch ref.""" + try: + date_str = run_git("log", "-1", "--format=%aI", ref) + if not date_str: + return None + commit_date = datetime.fromisoformat(date_str) + return (datetime.now(timezone.utc) - commit_date).days + except subprocess.CalledProcessError: + return None + + +def get_merged_local_branches() -> list[str]: + """Get local branches that are fully merged into main.""" + output = run_git("branch", "--merged", "main") + branches = [] + for line in output.splitlines(): + name = line.strip().lstrip("* ") + if name and name not in PROTECTED_BRANCHES: + branches.append(name) + return sorted(branches) + + +def get_merged_remote_branches() -> list[str]: + """Get remote branches that are fully merged into origin/main.""" + output = run_git("branch", "-r", "--merged", "origin/main") + branches = [] + for line in output.splitlines(): + name = line.strip() + if " -> " in name: + continue # skip HEAD pointer + if not name.startswith("origin/"): + continue + short = name.removeprefix("origin/") + if short not in PROTECTED_BRANCHES: + branches.append(short) + return sorted(branches) + + +def delete_local_branch(branch: str) -> tuple[bool, str]: + """Delete a local branch. Returns (success, message).""" + try: + run_git("branch", "-d", branch) + return True, "deleted" + except subprocess.CalledProcessError as e: + return False, e.stderr.strip() + + +def delete_remote_branch(branch: str) -> tuple[bool, str]: + """Delete a remote branch via git push. Returns (success, message).""" + try: + run_git("push", "origin", "--delete", branch) + return True, "deleted" + except subprocess.CalledProcessError as e: + return False, e.stderr.strip() + + +app = typer.Typer(add_completion=False) + + +@app.command() +def main( + cutoff: Annotated[ + int, + typer.Option(help="Only delete branches whose HEAD commit is older than N days"), + ] = 30, + dry_run: Annotated[ + bool, + typer.Option("--dry-run", help="Show what would be deleted without deleting"), + ] = False, + local_only: Annotated[ + bool, + typer.Option("--local-only", help="Only clean up local branches"), + ] = False, + remote_only: Annotated[ + bool, + typer.Option("--remote-only", help="Only clean up remote branches"), + ] = False, +) -> None: + """Delete branches that have been merged into main.""" + console = Console() + + # Fetch latest remote state + console.print("[dim]Fetching remote branches...[/dim]") + try: + run_git("fetch", "--prune", "origin") + except subprocess.CalledProcessError as e: + console.print(f"[red]Failed to fetch:[/red] {e.stderr}") + raise typer.Exit(1) + + do_local = not remote_only + do_remote = not local_only + + local_merged = get_merged_local_branches() if do_local else [] + remote_merged = get_merged_remote_branches() if do_remote else [] + + # Compute ages and filter by cutoff + all_names = sorted(set(local_merged) | set(remote_merged)) + branch_ages: dict[str, int | None] = {} + + for name in all_names: + # Prefer remote ref for age (more authoritative), fall back to local + age = None + if name in remote_merged: + age = branch_head_age_days(f"origin/{name}") + if age is None and name in local_merged: + age = branch_head_age_days(name) + branch_ages[name] = age + + # Apply cutoff filter + local_branches = [ + b for b in local_merged + if (branch_ages.get(b) is not None and branch_ages[b] >= cutoff) # type: ignore[operator] + ] + remote_branches = [ + b for b in remote_merged + if (branch_ages.get(b) is not None and branch_ages[b] >= cutoff) # type: ignore[operator] + ] + + skipped = [ + b for b in all_names + if b not in local_branches and b not in remote_branches + ] + + if not local_branches and not remote_branches: + console.print("[green]No merged branches older than " + f"{cutoff} days to clean up.[/green]") + if skipped: + console.print(f"[dim]({len(skipped)} merged branches skipped — " + f"newer than {cutoff} days)[/dim]") + raise typer.Exit(0) + + # Build summary table + table = Table(title=f"Merged branches to delete (older than {cutoff} days)") + table.add_column("Branch") + table.add_column("Age (days)", justify="right") + table.add_column("Local") + table.add_column("Remote") + + candidates = sorted(set(local_branches) | set(remote_branches)) + for branch in candidates: + has_local = branch in local_branches + has_remote = branch in remote_branches + age = branch_ages.get(branch) + age_str = str(age) if age is not None else "?" + table.add_row( + branch, + age_str, + "[yellow]delete[/yellow]" if has_local else "[dim]-[/dim]", + "[yellow]delete[/yellow]" if has_remote else "[dim]-[/dim]", + ) + + console.print(table) + console.print( + f"\n[bold]{len(local_branches)}[/bold] local, " + f"[bold]{len(remote_branches)}[/bold] remote branches to delete" + ) + if skipped: + console.print(f"[dim]({len(skipped)} merged branches skipped — " + f"newer than {cutoff} days)[/dim]") + + if dry_run: + console.print("\n[dim]Dry run — no branches were deleted.[/dim]") + raise typer.Exit(0) + + # Confirm + if not typer.confirm("\nProceed with deletion?"): + console.print("[dim]Aborted.[/dim]") + raise typer.Exit(0) + + # Delete local branches + if local_branches: + console.print("\n[bold]Deleting local branches...[/bold]") + for branch in local_branches: + ok, msg = delete_local_branch(branch) + if ok: + console.print(f" [green]✓[/green] {branch}") + else: + console.print(f" [red]✗[/red] {branch}: {msg}") + + # Delete remote branches + if remote_branches: + console.print("\n[bold]Deleting remote branches...[/bold]") + for branch in remote_branches: + ok, msg = delete_remote_branch(branch) + if ok: + console.print(f" [green]✓[/green] origin/{branch}") + else: + console.print(f" [red]✗[/red] origin/{branch}: {msg}") + + console.print("\n[green]Done.[/green]") + + +if __name__ == "__main__": + app()