feat(git): kvb-git-orphans-find for merged and abandoned branches

orphans.py classifies every local branch and detached worktree as merged-clean, merged-dirty, stale-open or active (ancestor or patch-equivalent of origin/<default>, 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 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-04 19:58:10 +01:00
1 parent 03c56f0801
commit b8d0b6cf87
6 files changed
+776 -13

No files matched your search

+7 -2
View File
@@ -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
+9 -8
View File
@@ -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 <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 <peer>)`, 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
+3 -3
View File
@@ -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.
+88
View File
@@ -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 <dir> --default
<branch> --stale-days 7 --top 5 --full /tmp/orphans-<repo>-<N>.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/<default>`, 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 <N> 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 <branch> archive/<branch>` and `git
worktree remove <path>` 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 <repo>`, the command,
the reason, `--check absent:<worktree path>` for a worktree, and for a
branch `absent:<git common dir>/refs/heads/<branch>` 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 <peer>` (`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.
+425
View File
@@ -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/<default>.
--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/<default>,
`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/<default> stays unmerged.
dirty: changed and untracked files of the worktree.
unpushed: commits outside origin/<default> 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/<default> 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"(?<![\w./-]){re.escape(name)}(?![\w-]|\.\w)", text) is not None
def classify(item: dict, scan: Scan, ref: str) -> 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())
+244
View File
@@ -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()