#!/usr/bin/env python3 """Branch staleness report across local and configured remotes. Usage: python3 scripts/stale_branches.py [--repo PATH] [--base BRANCH] [--age-days N] [--remote NAME ...] Examples: python3 scripts/stale_branches.py python3 scripts/stale_branches.py --repo ~/ecosystem/muse --age-days 14 python3 scripts/stale_branches.py --remote local staging """ from __future__ import annotations import argparse import json import subprocess import sys from datetime import datetime, timezone from pathlib import Path # ── muse wrappers ───────────────────────────────────────────────────────────── def _muse(repo: str, *args: str) -> dict: cmd = ["muse", "-C", repo, *args, "--json"] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"muse {' '.join(args)} failed:\n{result.stderr.strip()}") return json.loads(result.stdout) def _rev_count(repo: str, range_expr: str) -> int: try: data = _muse(repo, "rev-list", "--count", range_expr) return int(data.get("count", 0)) except Exception: return -1 # ── staleness logic ──────────────────────────────────────────────────────────── def _age_days(committed_at: str | None) -> int | None: if not committed_at: return None try: ts = datetime.fromisoformat(committed_at.replace("Z", "+00:00")) return (datetime.now(tz=timezone.utc) - ts).days except ValueError: return None def _age_label(days: int | None) -> str: if days is None: return "never" if days == 0: return "today" if days == 1: return "1 day ago" return f"{days}d ago" # ── remote branch discovery ──────────────────────────────────────────────────── def _remote_branches(repo: str, remotes: list[str]) -> dict[str, dict[str, str]]: """Return {remote_name: {branch_name: commit_id}} for each reachable remote.""" result: dict[str, dict[str, str]] = {} for remote in remotes: try: data = _muse(repo, "ls-remote", remote) result[remote] = data.get("branches", {}) except Exception as exc: print(f" [warn] could not reach remote '{remote}': {exc}", file=sys.stderr) return result def _remote_only_branches( local_names: set[str], remote_data: dict[str, dict[str, str]], ) -> list[tuple[str, str, str]]: """Branches that exist on a remote but not locally.""" seen: set[tuple[str, str]] = set() out: list[tuple[str, str, str]] = [] for remote, branches in remote_data.items(): for name, commit_id in branches.items(): if name not in local_names and (remote, name) not in seen: seen.add((remote, name)) out.append((remote, name, commit_id)) return sorted(out) # ── report ───────────────────────────────────────────────────────────────────── def _col(text: str, width: int) -> str: return text[:width].ljust(width) def run(repo: str, base: str, age_days_threshold: int, remotes: list[str]) -> None: repo = str(Path(repo).expanduser().resolve()) print(f"\nBranch staleness report") print(f" repo : {repo}") print(f" base : {base}") print(f" stale: last commit > {age_days_threshold} days ago\n") # Local branches branches: list[dict] = _muse(repo, "branch") if isinstance(branches, dict): # some versions wrap in a dict — normalise branches = branches.get("branches", []) local_names = {b["name"] for b in branches} skip = {base, "main"} merged: list[tuple] = [] active: list[tuple] = [] stale: list[tuple] = [] for b in branches: name = b["name"] if name in skip: continue commit_id = b.get("commit_id") committed = b.get("committed_at") resumable = b.get("resumable", False) intent = b.get("intent") or "" days = _age_days(committed) age = _age_label(days) if not commit_id: ahead, behind = 0, 0 else: ahead = _rev_count(repo, f"{base}..{name}") behind = _rev_count(repo, f"{name}..{base}") row = (name, age, days, ahead, behind, resumable, intent) if ahead == 0: merged.append(row) elif days is not None and days > age_days_threshold: stale.append(row) else: active.append(row) # Remote-only branches remote_data = _remote_branches(repo, remotes) remote_only = _remote_only_branches(local_names, remote_data) # ── print sections ──────────────────────────────────────────────────────── hdr = f" {'BRANCH':<40} {'LAST':<12} {'AHEAD':>5} {'BEHIND':>6} FLAGS" if merged: print(f"MERGED INTO {base} — safe to delete ({len(merged)})") print(hdr) for name, age, _, ahead, behind, resumable, intent in sorted(merged, key=lambda r: r[1]): flags = "resumable" if resumable else "" print(f" {_col(name,40)} {_col(age,12)} {ahead:>5} {behind:>6} {flags}") print() if active: print(f"ACTIVE ({len(active)})") print(hdr) for name, age, _, ahead, behind, resumable, intent in sorted(active, key=lambda r: r[1]): flags = "resumable" if resumable else "" hint = f" # {intent}" if intent else "" print(f" {_col(name,40)} {_col(age,12)} {ahead:>5} {behind:>6} {flags}{hint}") print() if stale: print(f"STALE — not merged, last commit > {age_days_threshold}d ago ({len(stale)})") print(hdr) for name, age, _, ahead, behind, resumable, intent in sorted(stale, key=lambda r: r[1]): flags = "resumable" if resumable else "" print(f" {_col(name,40)} {_col(age,12)} {ahead:>5} {behind:>6} {flags}") print() if remote_only: print(f"REMOTE-ONLY branches ({len(remote_only)})") print(f" {'REMOTE':<10} {'BRANCH':<40} COMMIT") for remote, name, commit_id in remote_only: print(f" {_col(remote,10)} {_col(name,40)} {commit_id[:20]}…") print() if not (merged or active or stale or remote_only): print("No non-base branches found.") # ── entry point ──────────────────────────────────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--repo", default=".", help="Path to muse repo (default: cwd)") parser.add_argument("--base", default="dev", help="Integration branch to compare against (default: dev)") parser.add_argument("--age-days", type=int, default=30, metavar="N", help="Days threshold for 'stale' (default: 30)") parser.add_argument("--remote", nargs="*", default=["local", "staging"], metavar="NAME", help="Remotes to inspect for remote-only branches (default: local staging)") args = parser.parse_args() try: run( repo=args.repo, base=args.base, age_days_threshold=args.age_days, remotes=args.remote, ) except KeyboardInterrupt: pass except Exception as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()