From b8d0b6cf87abd951558c4e1791dc8647fa595506 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 4 Nov 2026 19:58:10 +0100 Subject: [PATCH] feat(git): kvb-git-orphans-find for merged and abandoned branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orphans.py classifies every local branch and detached worktree as merged-clean, merged-dirty, stale-open or active (ancestor or patch-equivalent of origin/, dirty and unpushed counts, last activity, live council claims, busy processes) and proposes kvb-git-branch-gc, ask or keep; it reads only. The skill asks one question before any per-row round. kvb-dream step 8 runs it per repository in scope, kvb-cleanup at watch stage warn or with large worktrees. 🎯 Fix: 92 % 💥 Break: 3 % 💸 Cost: 148 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 60 🚦 Gate: ⚠️ cost 148 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- skills/kvb-cleanup/SKILL.md | 17 +- skills/kvb-dream/SKILL.md | 6 +- skills/kvb-git-orphans-find/SKILL.md | 88 ++++ .../kvb-git-orphans-find/scripts/orphans.py | 425 ++++++++++++++++++ tests/test_orphans.py | 244 ++++++++++ 6 files changed, 776 insertions(+), 13 deletions(-) create mode 100644 skills/kvb-git-orphans-find/SKILL.md create mode 100755 skills/kvb-git-orphans-find/scripts/orphans.py create mode 100644 tests/test_orphans.py diff --git a/README.md b/README.md index b3112cb..c04a971 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ flowchart LR git --> git_pr_label[kvb-git-pr-label] git --> git_update_branches[kvb-git-update-branches] git --> git_branch_gc[kvb-git-branch-gc] + git --> git_orphans_find[kvb-git-orphans-find] git --> git_changelog[kvb-git-changelog] git --> git_hotfix[kvb-git-hotfix] check --> check_legal[kvb-check-legal] @@ -156,6 +157,7 @@ flowchart LR pr_label[kvb-git-pr-label] update_branches[kvb-git-update-branches] branch_gc[kvb-git-branch-gc] + orphans_find[kvb-git-orphans-find] changelog[kvb-git-changelog] hotfix[kvb-git-hotfix] end @@ -261,8 +263,11 @@ flowchart LR coordinate --> democracy cleanup --> democracy - cleanup --> branch_gc + cleanup --> orphans_find branch_gc --> democracy + orphans_find --> branch_gc + orphans_find --> choice + orphans_find --> operator funnel --> democracy funnel --> dialectic funnel --> subagent_model @@ -312,7 +317,7 @@ flowchart LR dream --> dialectic dream --> optimize dream --> cleanup - dream --> branch_gc + dream --> orphans_find optimize --> skill_cleanup optimize --> transcend optimize --> autoskill diff --git a/skills/kvb-cleanup/SKILL.md b/skills/kvb-cleanup/SKILL.md index 0c1e1a9..61da6b7 100644 --- a/skills/kvb-cleanup/SKILL.md +++ b/skills/kvb-cleanup/SKILL.md @@ -21,20 +21,21 @@ request and stop, or, when the operator ordered the cleanup here, first ## Step 1: inventory Read-only, in parallel, with sizes, only in the agent write scope (Claude -Code: `sandbox.filesystem.allowWrite`) and session scratch dirs, even where -more is readable; never another user's files. Prefer the project's cleanup -and teardown targets; obey rules forbidding raw commands. +Code: `sandbox.filesystem.allowWrite`) and session scratch dirs; never +another user's files. Prefer the project's cleanup and teardown targets; obey +rules forbidding raw commands. - **/tmp**: `du -sh` per own top-level entry; system entries (`.X11-unix`, `.ICE-unix`, `systemd-private-*`, `ssh-*`, `tmux-*`, sockets) are never candidates. - **Docker**, **worktrees**: read `docker.md` (its `⚠ Volumes` checklist - joins Step 4) and `worktrees.md` next to this file when present. + joins Step 4) and `worktrees.md` next to this file when present. Only at + watch stage `warn` or worse, or with worktrees among the biggest consumers: + `kvb-git-orphans-find --sizes`, its largest orphaned worktrees as candidates. - **Agent dirs**: pip, npm, pre-commit, act, gh, buildx caches; Ansible and build temp dirs. - **Register**: `council.py list --long`; `rasci.py artifacts` (`kvb-org-artefacts`, - `--ended` for candidates): workdir, branch, artifacts, status and worker per - `kvb-org-rasci` task. + `--ended` for candidates). - **Live use**: `fuser`, `lsof`, `ps -o pid,etime,comm`, never full command lines or environments (credentials). Sandboxed, an empty result proves nothing. @@ -54,8 +55,8 @@ First match wins: claimed by a peer, `keep`; in a register task not `done`, `stopped` or `stale`, `keep (task )` (an ended task's items are its worker's candidates); a running deploy, gate, test, swarm run or build, `keep (running)`; a silent peer's, `keep (no answer from )`, silence is not -consent; sandboxed, anything touched within 24 hours (`find -mtime +0` lists -the older), `keep (recent, possibly in use)`; archive duty, +consent; sandboxed, anything touched within 24 hours, `keep (recent, +possibly in use)`; archive duty, `archive first`, never a plain delete. Else a candidate: - **low**, regenerated cheaply: stopped containers without volumes, dangling diff --git a/skills/kvb-dream/SKILL.md b/skills/kvb-dream/SKILL.md index d03e9e0..0f6303d 100644 --- a/skills/kvb-dream/SKILL.md +++ b/skills/kvb-dream/SKILL.md @@ -75,9 +75,9 @@ in-scope session's scripts, tests and skills into their repositories. The `kvb-cleanup` skill for the disk, `/tmp`, Docker and agent directories the window filled, with the finished tasks' artifacts (`rasci.py artifacts ---ended`) as candidates; then the `kvb-git-branch-gc` skill for every -in-scope repository's merged branches and worktrees. Each keeps its own -confirmations; the dream never writes, deletes or commits for them. Close +--ended`) as candidates; then `kvb-git-orphans-find` per in-scope +repository, which hands merged-clean rows to `kvb-git-branch-gc`. Each keeps +its own confirmations; the dream never writes, deletes or commits for them. Close with `date +%s > ~/.claude/.dream-stamps/"$CLAUDE_CODE_SESSION_ID"`, so the next hint is twelve hours out. diff --git a/skills/kvb-git-orphans-find/SKILL.md b/skills/kvb-git-orphans-find/SKILL.md new file mode 100644 index 0000000..b91ad45 --- /dev/null +++ b/skills/kvb-git-orphans-find/SKILL.md @@ -0,0 +1,88 @@ +--- +name: kvb-git-orphans-find +description: > + Find merged and abandoned branches and worktrees, propose deleting them. +--- + +Finds merged branches and worktrees and those with open changes nobody works +on any more, then proposes a step for each. The script only reads; nothing +goes without the operator's pick in this run. + +## When + +On "verwaiste branches", "alte worktrees", "was kann weg"; from step 8 of +`kvb-dream`; from `kvb-cleanup` with `--sizes` when disk runs short. + +## Step 1: find + +Per repository in scope: + +1. Fetch first: `git fetch --no-auto-maintenance origin`, never a plain + fetch (the `kvb-git-branch-gc` reason) and no `--prune`, which deletes + refs. +2. `python3 ${CLAUDE_SKILL_DIR}/scripts/orphans.py --repo --default + --stale-days 7 --top 5 --full /tmp/orphans--.md`: + `--default` the branch `origin/HEAD` names, another limit only on the + operator's word. `--sizes` adds and orders by each orphan's worktree size; + it walks every file, so only when disk matters. `--json` lists every row. + +The summary counts each class and shows its top rows, oldest or largest +first; the `--full` file holds all. + +Merged means the tip is on `origin/`, or `git cherry` finds every +commit's patch there. That catches squash and rebase merges only partly: a +squash of several commits, a rebase that changed a patch or a merge commit +on the branch stays unmerged. Say so above the summaries. + +| Class | Means | Proposal | +| --- | --- | --- | +| `merged-clean` | merged, nothing dirty | `kvb-git-branch-gc` | +| `merged-dirty` | merged, dirty files | `ask` | +| `stale-open` | unmerged, dirty or unpushed (no upstream counts), idle beyond the limit, no live claim, not busy | `ask` | +| `active` | anything else; always the main worktree, the default branch and `archive/` branches | `keep` | + +A merged row with a live claim or a busy process proposes `keep (claimed)` +or `keep (busy)`: never offer it. Busy is unknown without the agents-council +config, and a sandbox hides other PID namespaces: say so. + +## Step 2: propose + +Print each repository's summary, then: + +- **merged-clean**: the rows go to the `kvb-git-branch-gc` skill; its dry + run, claims step and pick decide. A row its dry run does not list stays. +- **merged-dirty**, **stale-open** (the `ask` rows): first one question + through the `kvb-think-choice` skill: "review abandoned items now?". + No ends here. Yes: rounds of at most four rows in summary order, taken + from the `--full` file, one question per row naming what a delete loses + (dirty files, unpushed commits): + - `archive` (recommended, loses nothing): the owner commits or stashes + the changes, then `git branch -m archive/` and `git + worktree remove ` once clean. Owner gone: an `operator.py` + `decision` row for the changes. + - `keep`: nothing changes. + - `delete`: the worktree and the branch go. + +## Step 3: act + +Only on the operator's pick for that row, never widened: + +- An agent never runs `git branch -D`, `git worktree remove --force` or + anything outside the sandbox. Each such command becomes its own + `operator.py add` row (`kvb-org-operator`): `--dir `, the command, + the reason, `--check absent:` for a worktree, and for a + branch `absent:/refs/heads/` while that loose ref + file exists, else `manual`. The worktree row comes first: git refuses to + delete a checked-out branch. +- Never delete a peer's worktree without the operator's pick; afterwards + `radio.py post --to ` (`kvb-org-radio`) names what went. + +## Step 4: report + +Per repository: rows handed to gc, archived, kept and deleted, each with its +reason, then the `operator.py run` block. + +## Rules + +- A pick from an earlier run does not carry over. +- Never route around a refusal through another command or a peer. diff --git a/skills/kvb-git-orphans-find/scripts/orphans.py b/skills/kvb-git-orphans-find/scripts/orphans.py new file mode 100755 index 0000000..c1609c4 --- /dev/null +++ b/skills/kvb-git-orphans-find/scripts/orphans.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Find merged and abandoned branches and worktrees of a repository and propose a step for each. + +Reads only: no fetch, delete or edit; the caller fetches first. Every git call +runs with GIT_OPTIONAL_LOCKS=0, because a plain `git status` refreshes the +index and so moves the index mtime this script reads as activity. + +Args: + --repo: any worktree of the repository. + --default: the default branch; merged means merged into origin/. + --stale-days: an unmerged row idle for longer may be stale-open. + --top: rows per class the summary shows, 0 for the counts only. + --full: file that receives the complete table of every row but the active + ones; the summary names it. + --sizes: measure the worktree of every row but the active ones (bytes of + its regular files, .git skipped, no symlink followed), show it and + order by it, largest first; slow, as it walks every file. + --json: print every row, active ones included, as JSON instead of the + summary; --top does not shorten it. + +Environment: + AGENTS_COUNCIL_ROOT: council directory of the kvb-org-organize skill, read + through its council.py for claims. + AGENTS_COUNCIL_CONFIG: config of its watch.py, whose busy_processes name + the busy processes; without the file busy is unknown (null). + +Rows, one per local branch with the worktree holding it, and one per worktree +with a detached HEAD: + merged: `ancestor` when the tip is an ancestor of origin/, + `patch` when `git cherry` finds a patch-equivalent there for every + commit outside it. That catches squash and rebase merges only partly: + a squash of several commits, a rebase that changed a patch or a merge + commit outside origin/ stays unmerged. + dirty: changed and untracked files of the worktree. + unpushed: commits outside origin/ without a patch-equivalent + there that the upstream lacks; without an upstream on a remote, those + no remote-tracking ref holds. Always 0 when merged. + upstream: the remote-tracking upstream, null when none resolves; null + counts as unpushed for an unmerged row. + last activity: newest of the tip's commit date, the worktree's index mtime + and the mtimes of its changed files. + claims: council claims whose work names the worktree path or the branch; + only a live one protects. + busy: a busy_processes process works inside the worktree; processes of + another PID namespace stay unseen. + size: bytes with --sizes, else null; null for active rows and rows + without a worktree. + +Classes, first match wins: + active: the main worktree, the default branch and archive/ branches. + merged-clean: merged, nothing dirty; proposal kvb-git-branch-gc. + merged-dirty: merged with dirty files; proposal ask. + stale-open: unmerged, dirty or unpushed, last activity older than + --stale-days, no live claim, not busy; proposal ask. + active: anything else; proposal keep. + A merged row with a live claim or a busy process proposes keep (claimed) + or keep (busy) instead. + +Output: + A summary: one line per class with its count and proposal, the --full + file, claims or busy that could not be checked; then the table `| Class | + Branch | Worktree | Dirty | Unpushed | Last activity | Claim | Proposal |` + of the top rows per class but active, oldest last activity first, with + --sizes largest first and a Size column after Worktree. The --full file + holds the same table with every row but the active ones. + +Exit codes: + 0 done, 2 usage error, a failed git call or origin/ missing. +""" + +from __future__ import annotations + +import argparse +import contextlib +import dataclasses +import importlib.util +import json +import os +import re +import stat +import subprocess +import sys +import time +from collections.abc import Callable +from pathlib import Path +from types import ModuleType +from typing import NoReturn + +ORGANIZE = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts" +DAY = 86400 +ORDER = ("merged-clean", "merged-dirty", "stale-open", "active") +HEADER = ("Class", "Branch", "Worktree", "Dirty", "Unpushed", "Last activity", "Claim", "Proposal") +GC = "kvb-git-branch-gc" +PROPOSALS = {"merged-clean": GC, "merged-dirty": "ask", "stale-open": "ask", "active": "keep, listed by --json"} +UNITS = ("KiB", "MiB", "GiB", "TiB") +DETACHED = "(detached)" +ARCHIVE = "refs/heads/archive/" + + +@dataclasses.dataclass(frozen=True) +class Scan: + repo: str + base: str + default: str + claims: list[dict] + busy: Callable[[str], bool] | None + now: float + limit: float + + +def fail(message: str) -> NoReturn: + print(f"orphans: {message}", file=sys.stderr) + sys.exit(2) + + +def module(name: str) -> ModuleType | None: + path = ORGANIZE / f"{name}.py" + if not path.is_file(): + return None + spec = importlib.util.spec_from_file_location(name, path) + loaded = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loaded) + return loaded + + +def git(where: str, *args: str) -> subprocess.CompletedProcess: + try: + return subprocess.run( + ["git", *args], cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True, + env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, check=False, + ) + except OSError as error: + return subprocess.CompletedProcess(["git", *args], 128, "", str(error)) + + +def git_out(where: str, *args: str) -> str: + result = git(where, *args) + if result.returncode: + fail(f"git {' '.join(args)} in {where}: {(result.stderr or result.stdout).strip()}") + return result.stdout + + +def resolves(where: str, ref: str) -> bool: + return git(where, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").returncode == 0 + + +def worktrees(repo: str) -> list[dict]: + trees: list[dict] = [] + for line in git_out(repo, "worktree", "list", "--porcelain").splitlines(): + key, _, value = line.partition(" ") + if key == "worktree": + trees.append({"path": value, "head": None, "branch": None, "main": not trees, "bare": False}) + elif trees and key == "HEAD": + trees[-1]["head"] = value + elif trees and key == "branch": + trees[-1]["branch"] = value + elif trees and key == "bare": + trees[-1]["bare"] = True + return trees + + +def branches(repo: str) -> list[list[str]]: + out = git_out(repo, "branch", "--list", "--format=%(refname)\t%(upstream)\t%(committerdate:unix)") + return [line.split("\t") for line in out.splitlines() if line] + + +def committed(repo: str, rev: str) -> int: + return int(git_out(repo, "log", "-1", "--format=%ct", rev).strip()) + + +def measure(scan: Scan, ref: str, upstream: str) -> tuple[str | None, int, str | None]: + tracked = upstream if upstream.startswith("refs/remotes/") and resolves(scan.repo, upstream) else None + outside = set(git_out(scan.repo, "rev-list", ref, f"^{scan.base}").split()) + if not outside: + return "ancestor", 0, tracked + cherry = git_out(scan.repo, "cherry", scan.base, ref).splitlines() + equivalent = {line[2:] for line in cherry if line.startswith("- ")} + if equivalent == outside: + return "patch", 0, tracked + held = [f"^{tracked}"] if tracked else ["--not", "--remotes"] + local = set(git_out(scan.repo, "rev-list", ref, *held).split()) + return None, len(local & (outside - equivalent)), tracked + + +def index_mtime(tree: str) -> float: + path = git_out(tree, "rev-parse", "--path-format=absolute", "--git-path", "index").strip() + try: + return os.stat(path).st_mtime + except OSError: + return 0.0 + + +def changes(tree: str) -> tuple[int, float]: + fields = git_out(tree, "status", "--porcelain=v1", "-z", "--untracked-files=all").split("\0") + count, newest, at = 0, 0.0, 0 + while at < len(fields): + entry = fields[at] + at += 2 if "R" in entry[:2] or "C" in entry[:2] else 1 + if not entry: + continue + count += 1 + with contextlib.suppress(OSError): + newest = max(newest, os.lstat(os.path.join(tree, entry[3:])).st_mtime) + return count, newest + + +def mentions(text: str, name: str) -> bool: + return re.search(rf"(? str: + if item["main"] or item["default"] or ref.startswith(ARCHIVE): + return "active" + if item["merged"]: + return "merged-dirty" if item["dirty"] else "merged-clean" + pending = bool(item["dirty"] or item["unpushed"] or not item["upstream"]) + idle = scan.now - item["last_activity"] > scan.limit + guarded = any(claim["live"] for claim in item["claims"]) or item["busy"] + return "stale-open" if pending and idle and not guarded else "active" + + +def proposal(item: dict) -> str: + if item["class"] == "active": + return "keep" + if any(claim["live"] for claim in item["claims"]): + return "keep (claimed)" + if item["busy"]: + return "keep (busy)" + return GC if item["class"] == "merged-clean" else "ask" + + +def inspect(scan: Scan, ref: str, upstream: str, date: int, tree: dict | None) -> dict: + name = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else DETACHED + merged, unpushed, tracked = measure(scan, ref, upstream) + path = tree["path"] if tree else None + present = path is not None and os.path.isdir(path) + activity, dirty, busy = float(date), None, False + if present: + activity = max(activity, index_mtime(path)) + dirty, changed = changes(path) + activity = max(activity, changed) + busy = scan.busy(path) if scan.busy else None + names = {path, os.path.realpath(path)} if path else set() + if ref.startswith("refs/heads/"): + names.add(name) + item = { + "branch": name, + "worktree": path, + "missing": path is not None and not present, + "main": bool(tree and tree["main"]), + "default": ref == f"refs/heads/{scan.default}", + "merged": merged, + "dirty": dirty, + "unpushed": unpushed, + "upstream": tracked, + "last_activity": int(activity), + "claims": [ + {key: claim[key] for key in ("office", "holder", "live")} + for claim in scan.claims + if any(mentions(claim["work"], each) for each in names) + ], + "busy": busy, + } + item["class"] = classify(item, scan, ref) + item["proposal"] = proposal(item) + return item + + +def tree_size(top: str) -> int: + total = 0 + for base, dirs, files in os.walk(top): + dirs[:] = [name for name in dirs if name != ".git"] + for name in files: + if name == ".git": + continue + with contextlib.suppress(OSError): + found = os.lstat(os.path.join(base, name)) + if stat.S_ISREG(found.st_mode): + total += found.st_size + return total + + +def human(size: int | None) -> str: + if size is None: + return "-" + if size < 1024: + return f"{size} B" + value = float(size) + for unit in UNITS: + value /= 1024 + if value < 1024 or unit == UNITS[-1]: + break + return f"{value:.1f} {unit}" + + +def cells(item: dict, now: float, sizes: bool) -> list[str]: + worktree = (item["worktree"] or "-") + (" (missing)" if item["missing"] else "") + unpushed = str(item["unpushed"]) + (" (no upstream)" if not item["upstream"] and not item["merged"] else "") + day = time.strftime("%Y-%m-%d", time.localtime(item["last_activity"])) + age = int((now - item["last_activity"]) // DAY) + claims = [f"{c['office']} ({c['holder']}{'' if c['live'] else ', ended'})" for c in item["claims"]] + if item["busy"]: + claims.append("busy") + values = [ + item["class"], item["branch"], worktree, *([human(item["size"])] if sizes else []), + "-" if item["dirty"] is None else str(item["dirty"]), unpushed, f"{day} ({age} d)", + ", ".join(claims) or "-", item["proposal"], + ] + return [value.replace("|", "\\|") for value in values] + + +def table(items: list[dict], now: float, sizes: bool) -> list[str]: + header = [*HEADER[:3], *(["Size"] if sizes else []), *HEADER[3:]] + lines = ["| " + " | ".join(header) + " |", "| " + " | ".join("---" for _ in header) + " |"] + return lines + ["| " + " | ".join(cells(item, now, sizes)) + " |" for item in items] + + +def rank(item: dict, sizes: bool) -> tuple: + largest = -(item["size"] or 0) if sizes else 0 + return ORDER.index(item["class"]), largest, item["last_activity"], item["branch"], item["worktree"] or "" + + +def parse() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--repo", required=True) + parser.add_argument("--default", required=True) + parser.add_argument("--stale-days", type=int, required=True) + parser.add_argument("--top", type=int, required=True) + parser.add_argument("--full") + parser.add_argument("--sizes", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + if args.stale_days < 0: + fail("--stale-days must be 0 or more") + if args.top < 0: + fail("--top must be 0 or more") + return args + + +def target(args: argparse.Namespace) -> tuple[str, str]: + repo = os.path.realpath(args.repo) + if not os.path.isdir(repo): + fail(f"--repo {args.repo} is no directory") + if args.default.startswith("-") or git(repo, "check-ref-format", f"refs/heads/{args.default}").returncode: + fail(f"--default {args.default!r} is no valid branch name") + base = f"refs/remotes/origin/{args.default}" + if not resolves(repo, base): + fail(f"origin/{args.default} does not resolve in {repo}; fetch first") + return repo, base + + +def council_claims(council: ModuleType | None) -> list[dict]: + if council is None: + return [] + live = council.live_sessions() + return [ + { + "office": office, + "holder": claim.get("name") or claim.get("session"), + "live": not council.is_stale(claim, live), + "work": str(claim.get("work", "")), + } + for office, claim in council.claims() + ] + + +def rows(scan: Scan, sizes: bool) -> list[dict]: + trees = worktrees(scan.repo) + held = {tree["branch"]: tree for tree in trees if tree["branch"]} + items = [inspect(scan, ref, upstream, int(date), held.get(ref)) for ref, upstream, date in branches(scan.repo)] + items += [ + inspect(scan, tree["head"], "", committed(scan.repo, tree["head"]), tree) + for tree in trees + if not tree["bare"] and tree["branch"] is None and tree["head"] + ] + for item in items: + measured = sizes and item["class"] != "active" and item["worktree"] and not item["missing"] + item["size"] = tree_size(item["worktree"]) if measured else None + items.sort(key=lambda item: rank(item, sizes)) + return items + + +def main() -> int: + args = parse() + repo, base = target(args) + council, watch = module("council"), module("watch") + config = watch.load_config() if watch else None + names = config["busy_processes"] if config else None + scan = Scan( + repo=repo, base=base, default=args.default, claims=council_claims(council), + busy=(lambda tree: watch.busy(tree, names)) if names is not None else None, + now=time.time(), limit=args.stale_days * DAY, + ) + items = rows(scan, args.sizes) + orphans = [item for item in items if item["class"] != "active"] + full = os.path.abspath(args.full) if args.full is not None else None + if full is not None: + try: + Path(full).write_text("\n".join(table(orphans, scan.now, args.sizes)) + "\n", encoding="utf-8") + except OSError as error: + fail(f"--full {args.full}: {error.strerror or error}") + if args.json: + print(json.dumps({ + "repo": repo, "default": args.default, "stale_days": args.stale_days, + "claims_checked": council is not None, "busy_checked": names is not None, "rows": items, + }, indent=1)) + return 0 + print(f"orphans in {repo}, merged into origin/{args.default}, stale after {args.stale_days} days:") + for name in ORDER: + print(f"- {name}: {sum(item['class'] == name for item in items)} ({PROPOSALS[name]})") + if full is not None: + print(f"- full table: {full}") + if council is None: + print("- claims unknown: council.py of kvb-org-organize is missing") + if names is None: + print("- busy unknown: no agents-council config") + top = [item for name in ORDER[:-1] for item in [o for o in orphans if o["class"] == name][: args.top]] + if top: + print(f"\nTop {args.top} per class, {'largest' if args.sizes else 'oldest'} first:\n") + print("\n".join(table(top, scan.now, args.sizes))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_orphans.py b/tests/test_orphans.py new file mode 100644 index 0000000..6bae9a1 --- /dev/null +++ b/tests/test_orphans.py @@ -0,0 +1,244 @@ +"""Exercise kvb-git-orphans-find: merged, stale and active branches and worktrees, read only.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +ORPHANS = REPO_ROOT / "skills" / "kvb-git-orphans-find" / "scripts" / "orphans.py" +DAY = 86400 +OLD = time.time() - 30 * DAY +AGES = {"stale": OLD, "claimed": OLD, "ended": OLD - 5 * DAY, "busy": OLD, "recent": OLD} +BIG = 50_000 +IDENTITY = { + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.org", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.org", + "GIT_CONFIG_NOSYSTEM": "1", +} +TABLE = { + "merged": ("merged-clean", "kvb-git-branch-gc"), + "squashed": ("merged-clean", "kvb-git-branch-gc"), + "mdirty": ("merged-dirty", "ask"), + "ended": ("stale-open", "ask"), + "stale": ("stale-open", "ask"), +} +ACTIVE = ("archive/old", "busy", "claimed", "main", "recent") +COUNTS = [ + "- merged-clean: 2 (kvb-git-branch-gc)", + "- merged-dirty: 1 (ask)", + "- stale-open: 2 (ask)", + "- active: 5 (keep, listed by --json)", +] + + +def rows_of(text: str) -> list[dict[str, str]]: + lines = [line for line in text.splitlines() if line.startswith("| ")] + if not lines: + return [] + header = lines[0].strip("| ").split(" | ") + return [dict(zip(header, line.strip("| ").split(" | "), strict=True)) for line in lines[2:]] + + +class TestOrphans(unittest.TestCase): + @classmethod + def setUpClass(cls): + tmp = tempfile.TemporaryDirectory() + cls.addClassCleanup(tmp.cleanup) + cls.base = Path(tmp.name).resolve() + home = cls.base / "home" + (home / ".claude" / "sessions").mkdir(parents=True) + cls.root = cls.base / "council" + config = cls.base / "config.json" + config.write_text(json.dumps({"busy_processes": ["sleep"]}), encoding="utf-8") + cls.env = { + "PATH": "/usr/bin:/bin", + "HOME": str(home), + "AGENTS_COUNCIL_ROOT": str(cls.root), + "AGENTS_COUNCIL_CONFIG": str(config), + "GIT_CEILING_DIRECTORIES": str(cls.base), + **IDENTITY, + } + origin = cls.base / "origin.git" + cls.git(cls.base, "init", "-q", "--bare", "-b", "main", str(origin)) + cls.main = cls.base / "main" + cls.main.mkdir() + cls.git(cls.main, "init", "-q", "-b", "main") + cls.commit(cls.main, "app.txt", "one\n", "init", None) + cls.git(cls.main, "remote", "add", "origin", str(origin)) + cls.git(cls.main, "push", "-q", "-u", "origin", "main") + + cls.trees = {name: cls.base / f"wt-{name}" for name in ("merged", "mdirty", *AGES)} + cls.add_tree("merged") + cls.commit(cls.trees["merged"], "m.txt", "m\n", "merged work", None) + cls.git(cls.main, "merge", "-q", "--ff-only", "merged") + + cls.git(cls.main, "switch", "-q", "-c", "squashed") + cls.commit(cls.main, "sq.txt", "sq\n", "squashed work", OLD) + cls.git(cls.main, "switch", "-q", "main") + cls.git(cls.main, "merge", "-q", "--squash", "squashed") + cls.git(cls.main, "commit", "-q", "-m", "squashed work, squashed") + cls.git(cls.main, "push", "-q", "origin", "main") + + cls.add_tree("mdirty") + (cls.trees["mdirty"] / "scratch.txt").write_text("left over\n", encoding="utf-8") + for name, when in AGES.items(): + cls.add_tree(name) + cls.commit(cls.trees[name], f"{name}.txt", "one\n", f"{name} work", when) + (cls.trees[name] / f"{name}.txt").write_text("two\n", encoding="utf-8") + if name != "recent": + os.utime(cls.trees[name] / f"{name}.txt", (when, when)) + os.utime(cls.index(cls.trees[name]), (when, when)) + huge = cls.base / "huge.bin" + huge.write_bytes(b"x" * 4 * BIG) + big, link = cls.trees["stale"] / "big.bin", cls.trees["stale"] / "outside" + big.write_bytes(b"x" * BIG) + link.symlink_to(huge) + os.utime(big, (OLD, OLD)) + os.utime(link, (OLD, OLD), follow_symlinks=False) + cls.git(cls.main, "branch", "archive/old", "stale") + notes = cls.main / "notes.txt" + notes.write_text("main worktree scratch\n", encoding="utf-8") + os.utime(notes, (OLD, OLD)) + + cls.claim("work/claimed-job", {"session": "peer", "runtime": "other", "name": "Peer", "beat": time.time(), + "work": f"finish {cls.trees['claimed']}"}) + cls.claim("work/ended-job", {"session": "gone", "runtime": "claude-code", "name": None, "beat": OLD, + "work": "rebase ended onto main"}) + cls.sleeper = subprocess.Popen(["sleep", "120"], cwd=cls.trees["busy"]) + cls.addClassCleanup(cls.sleeper.wait) + cls.addClassCleanup(cls.sleeper.kill) + + @classmethod + def run_git(cls, cwd: Path, env: dict[str, str], *args: str) -> str: + return subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True, check=True).stdout + + @classmethod + def git(cls, cwd: Path, *args: str) -> str: + return cls.run_git(cwd, cls.env, *args) + + @classmethod + def commit(cls, repo: Path, name: str, text: str, message: str, when: float | None) -> None: + """Commit one file. + + Args: + when: author and committer date as epoch seconds; None means now. + """ + (repo / name).write_text(text, encoding="utf-8") + cls.git(repo, "add", name) + dated = {} if when is None else {"GIT_AUTHOR_DATE": f"@{int(when)} +0000", "GIT_COMMITTER_DATE": f"@{int(when)} +0000"} + cls.run_git(repo, {**cls.env, **dated}, "commit", "-q", "-m", message) + + @classmethod + def add_tree(cls, name: str) -> None: + cls.git(cls.main, "worktree", "add", "-q", "-b", name, str(cls.trees[name]), "main") + + @classmethod + def index(cls, tree: Path) -> Path: + return Path(cls.git(tree, "rev-parse", "--path-format=absolute", "--git-path", "index").strip()) + + @classmethod + def claim(cls, office: str, fields: dict) -> None: + path = cls.root / office + path.mkdir(parents=True) + claim = {"office": office, "since": "2026-11-01T00:00:00+00:00", **fields} + (path / "claim.json").write_text(json.dumps(claim), encoding="utf-8") + + def orphans(self, *extra: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(ORPHANS), "--repo", str(self.main), "--default", "main", "--stale-days", "7", *extra], + capture_output=True, text=True, env=self.env, cwd=self.base, check=False, + ) + + def out(self, *extra: str) -> str: + result = self.orphans(*extra) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + def rows(self, *extra: str) -> dict[str, dict]: + return {row["branch"]: row for row in json.loads(self.out("--json", "--top", "1", *extra))["rows"]} + + def test_the_summary_counts_every_class_then_shows_the_oldest_rows_per_class(self): + lines = self.out("--top", "1").splitlines() + self.assertEqual(lines[1:5], COUNTS) + self.assertNotIn("busy unknown", "\n".join(lines)) + self.assertIn("Top 1 per class, oldest first:", lines) + self.assertEqual([(row["Branch"], row["Class"], row["Proposal"]) for row in rows_of("\n".join(lines))], + [("squashed", *TABLE["squashed"]), ("mdirty", *TABLE["mdirty"]), + ("ended", *TABLE["ended"])]) + self.assertEqual([row["Branch"] for row in rows_of(self.out("--top", "2"))], + ["squashed", "merged", "mdirty", "ended", "stale"]) + counts_only = self.out("--top", "0") + self.assertEqual((counts_only.splitlines()[1:5], rows_of(counts_only)), (COUNTS, [])) + + def test_the_full_file_holds_every_row_but_the_active_ones(self): + full = self.base / "full.md" + lines = self.out("--top", "1", "--full", str(full)).splitlines() + self.assertIn(f"- full table: {full}", lines) + rows = {row["Branch"]: row for row in rows_of(full.read_text(encoding="utf-8"))} + self.assertEqual({branch: (row["Class"], row["Proposal"]) for branch, row in rows.items()}, TABLE) + stale = rows["stale"] + self.assertEqual((stale["Worktree"], stale["Dirty"], stale["Unpushed"]), (str(self.trees["stale"]), "3", + "1 (no upstream)")) + self.assertTrue(stale["Last activity"].endswith("(30 d)"), stale["Last activity"]) + self.assertEqual(rows["ended"]["Claim"], "work/ended-job (gone, ended)") + self.assertEqual((rows["squashed"]["Worktree"], rows["squashed"]["Dirty"], rows["squashed"]["Unpushed"]), + ("-", "-", "0")) + self.assertNotIn("Size", rows["stale"]) + + def test_sizes_order_the_largest_first_and_never_follow_a_symlink(self): + shown = rows_of(self.out("--top", "1", "--sizes")) + self.assertEqual([(row["Branch"], row["Size"]) for row in shown], + [("merged", "6 B"), ("mdirty", "19 B"), ("stale", "48.8 KiB")]) + rows = self.rows("--sizes") + self.assertEqual({branch: rows[branch]["size"] for branch in ("stale", "ended", "merged", "mdirty")}, + {"stale": BIG + 13, "ended": 13, "merged": 6, "mdirty": 19}) + for branch in ("squashed", *ACTIVE): + with self.subTest(branch=branch): + self.assertIsNone(rows[branch]["size"]) + self.assertIsNone(self.rows()["stale"]["size"]) + + def test_json_keeps_the_main_worktree_claimed_busy_recent_and_archived_rows(self): + rows = self.rows() + self.assertEqual(sorted(rows), sorted([*TABLE, *ACTIVE])) + for branch in ACTIVE: + with self.subTest(branch=branch): + self.assertEqual((rows[branch]["class"], rows[branch]["proposal"]), ("active", "keep")) + self.assertEqual((rows["merged"]["merged"], rows["squashed"]["merged"]), ("ancestor", "patch")) + self.assertTrue(rows["main"]["main"]) + self.assertEqual(rows["main"]["dirty"], 1) + self.assertEqual(rows["claimed"]["claims"], [{"office": "work/claimed-job", "holder": "Peer", "live": True}]) + self.assertTrue(rows["busy"]["busy"]) + self.assertFalse(rows["stale"]["busy"]) + self.assertGreater(rows["recent"]["last_activity"], time.time() - DAY) + self.assertIsNone(rows["stale"]["merged"]) + self.assertIsNone(rows["merged"]["upstream"]) + + def test_the_scan_changes_nothing_in_the_repository(self): + indexes = [self.index(tree) for tree in (self.main, *self.trees.values())] + before = [os.stat(index).st_mtime_ns for index in indexes] + refs = self.git(self.main, "for-each-ref") + self.out("--top", "5", "--sizes") + self.out("--json", "--top", "5") + self.assertEqual([os.stat(index).st_mtime_ns for index in indexes], before) + self.assertEqual(self.git(self.main, "for-each-ref"), refs) + + def test_a_missing_origin_default_or_bad_input_exits_2(self): + for args in (("--default", "nope"), ("--stale-days", "-1"), ("--default", "a..b"), ("--top", "-1"), + ("--full", str(self.base / "absent" / "full.md"))): + with self.subTest(args=args): + result = self.orphans("--top", "1", *args) + self.assertEqual((result.returncode, result.stdout), (2, ""), result.stderr) + self.assertEqual(self.orphans().returncode, 2) + + +if __name__ == "__main__": + unittest.main()