Add branch-cleanup mise task for merged branch housekeeping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-02-22 15:21:57 -08:00
commit add0d4ef6f
2 changed files with 243 additions and 0 deletions

View file

@ -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).

242
mise-tasks/branch-cleanup Executable file
View file

@ -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 <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()