$ cat node-template.py

B

Branch cleanup

// Delete the branches earlier plans left behind in a GitHub repository — never work that exists nowhere else. Run it with no branches to look: every branch under a prefix (vibe/ by default), what deleting it would lose, and a ready question card offering only the safe ones (merged, or kept on a closed pull request). Run it again with the picked branches to delete them; each is re-checked first. Never touches the default branch, protected branches, the working branch or anything kept.

Process
Integration
#github#branch#cleanup#repo-land#vibe
template.py
1"""Branch cleanup — delete the branches a plan left behind, never unsaved work.23Every attempt at a plan leaves branches: a working branch per plan, one per4Lane, and the pull requests they opened. After a few runs a repository holds a5dozen `vibe/*` branches and stale pull requests, and the next plan reuses a6name and finds someone else's work on it. This node lists them and deletes the7ones the user picks — Claude Code's `/clean_gone`, for a remote repository.89## Two runs: look, then delete1011With `branches` empty it only LOOKS: every branch under `prefix`, each with12what would be lost by deleting it, and a ready question card listing the ones13that are safe. The agent asks the card; the picks go back into `branches` and14the node runs again to delete them — and re-checks each one at that moment,15so a branch that gained a commit in between is skipped, not deleted.1617## What "safe" means1819A branch is safe to delete when every commit on it is kept somewhere else:20  - its pull request was MERGED and the branch has nothing after it (a squash21    merge leaves the branch's own commits unmerged in git terms — the pull22    request is what proves they landed);23  - its pull request was CLOSED and the branch has nothing after it — GitHub24    keeps a closed pull request's commits, and the branch can be restored25    from its page;26  - it has no commit its base does not have.27Anything else holds work that exists nowhere else, and is deleted only with28`allow_unmerged` — never by default. A branch with an OPEN pull request is29deleted only with `close_prs`, which closes the pull request first, with a30comment saying why (deleting the branch would close it anyway, silently).3132A branch that open pull requests merge INTO — an earlier plan's working33branch, with its Lanes' pull requests still open — is never deleted: GitHub34closes every pull request whose base branch disappears. Clean up those Lane35branches first (with `close_prs`); on the next look it is offered.3637Never touched, whatever is asked: branches outside `prefix`, the repository's38default branch, protected branches, the branch named by `repo` (the working39branch) and anything in `keep`.40"""4142from gais import github_repo as gh43from gais._errors import RemoteError4445MAX_BRANCHES = 4046CLOSE_NOTE = ("<!-- emblema:branch-cleanup -->\nClosed by Branch cleanup: its branch `{branch}` is being "47              "deleted. The commits stay on this pull request; restore the branch from here if needed.")484950def as_list(value) -> list[str]:51    if isinstance(value, str):52        value = [part for chunk in value.splitlines() for part in chunk.split(",")]53    return [str(v).strip() for v in (value or []) if str(v).strip()]545556def latest_pr(client: gh.Client, owner: str, repo: str, branch: str) -> dict | None:57    """The most recent pull request from `branch`, in any state."""58    try:59        rows = client.get(f"/repos/{owner}/{repo}/pulls?state=all&head={gh.enc(owner + ':' + branch)}"60                          "&sort=created&direction=desc&per_page=5") or []61    except RemoteError as e:62        raise gh.explain(e, f"to list the pull requests of '{branch}'", gh.NEED["pulls_read"]) from None63    return rows[0] if rows else None646566def open_prs_by_base(client: gh.Client, owner: str, repo: str) -> dict[str, list[int]]:67    """{base branch: [open pull request numbers]} — what deleting a base would close."""68    try:69        rows = client.paginate(f"/repos/{owner}/{repo}/pulls?state=open", limit=500)70    except RemoteError as e:71        raise gh.explain(e, f"to list the open pull requests of {owner}/{repo}", gh.NEED["pulls_read"]) from None72    out: dict[str, list[int]] = {}73    for pr in rows:74        out.setdefault((pr.get("base") or {}).get("ref") or "", []).append(int(pr.get("number") or 0))75    return out767778def assess(client: gh.Client, owner: str, repo: str, branch: str, sha: str, default: str,79           into: dict[str, list[int]]) -> dict:80    """What deleting `branch` would lose."""81    row = {"branch": branch, "sha": sha, "pr": None, "safe": False, "open_pr": False, "base_of": [],82           "reason": ""}83    if into.get(branch):84        row["base_of"] = sorted(into[branch])85        numbers = ", ".join(f"#{n}" for n in row["base_of"])86        many = len(row["base_of"]) != 187        row["reason"] = f"open pull request{'s' if many else ''} {numbers} merge{'' if many else 's'} into it"88        return row89    found = latest_pr(client, owner, repo, branch)90    if found:91        brief = gh.pr_brief(found)92        row["pr"] = {"number": brief["number"], "state": brief["state"], "url": brief["url"], "base": brief["base"]}93        if brief["state"] == "open":94            row["open_pr"] = True95            row["reason"] = f"pull request #{brief['number']} is open"96            return row97        if brief["head_sha"] == sha:98            row["safe"] = True99            row["reason"] = (f"merged in #{brief['number']}" if brief["state"] == "merged"100                             else f"its commits are kept on closed #{brief['number']}")101            return row102    try:103        cmp = client.get(f"/repos/{owner}/{repo}/compare/{gh.enc(default)}...{gh.enc(branch)}") or {}104    except RemoteError as e:105        raise gh.explain(e, f"to compare '{branch}' with '{default}'", gh.NEED["contents_read"]) from None106    ahead = int(cmp.get("ahead_by") or 0)107    if ahead == 0:108        row["safe"] = True109        row["reason"] = f"nothing that '{default}' does not have"110    elif found:111        row["reason"] = f"{ahead} commit{'s' * (ahead != 1)} after #{row['pr']['number']} that nothing else holds"112    else:113        row["reason"] = f"{ahead} commit{'s' * (ahead != 1)} in no pull request and not on '{default}'"114    return row115116117def delete_recommendation(count: int) -> str:118    """Every offered branch is safe by construction, so the card recommends them all."""119    if count == 0:120        return ""121    if count == 1:122        return "Delete it — its work is merged or kept on a pull request."123    return f"Delete all {count} — each one's work is merged or kept on a pull request."124125126def run(inputs: dict, client: gh.Client) -> dict:127    owner, repo, working = gh.parse_repo(inputs.get("repo"))128    prefix = str(inputs.get("prefix") if inputs.get("prefix") is not None else "vibe/").strip()129    if not prefix:130        raise gh.RepoError("Set a `prefix` (e.g. vibe/): Branch cleanup only ever looks under one.")131    keep = set(as_list(inputs.get("keep"))) | ({working} if working else set())132    requested = as_list(inputs.get("branches"))133    close_prs = gh.flag(inputs.get("close_prs"), False)134    allow_unmerged = gh.flag(inputs.get("allow_unmerged"), False)135136    info = gh.repository(client, owner, repo)137    default = info.get("default_branch") or ""138    try:139        all_branches = client.paginate(f"/repos/{owner}/{repo}/branches", limit=1000)140    except RemoteError as e:141        raise gh.explain(e, f"to list the branches of {owner}/{repo}", gh.NEED["contents_read"]) from None142143    def off_limits(b: dict) -> str | None:144        name = b.get("name") or ""145        if not name.startswith(prefix):146            return f"outside '{prefix}'"147        if name == default:148            return "the default branch"149        if b.get("protected"):150            return "protected"151        if name in keep:152            return "kept" + (" (the working branch)" if name == working else "")153        return None154155    by_name = {b.get("name"): b for b in all_branches}156    in_scope = [b for b in all_branches if off_limits(b) is None][:MAX_BRANCHES]157    into = open_prs_by_base(client, owner, repo)158159    if not requested:160        candidates = [assess(client, owner, repo, b["name"], (b.get("commit") or {}).get("sha") or "", default, into)161                      for b in in_scope]162        safe = [c for c in candidates if c["safe"]]163        held = [c for c in candidates if not c["safe"]]164        question = gh.question_card(165            "cleanup", "Cleanup", f"Which of these branches in {owner}/{repo} should be deleted?",166            (f"Each one's work is kept elsewhere (merged, or on a closed pull request)."167             + (f" {len(held)} more hold work or an open pull request and are not offered." if held else "")),168            [{"id": c["branch"], "label": c["branch"], "description": c["reason"], "recommended": True}169             for c in safe],170            allow_free_text=False,171            recommendation=delete_recommendation(len(safe)),172        )173        lines = [f"**{len(candidates)} branch{'es' * (len(candidates) != 1)} under `{prefix}`** "174                 f"({len(safe)} safe to delete)."]175        lines += [f"- {'✓' if c['safe'] else '•'} `{c['branch']}` — {c['reason']}" for c in candidates]176        return {177            "applied": False, "candidates": candidates, "deleted": [], "closed_prs": [], "skipped": [],178            "question": question, "summary": "\n".join(lines),179            "summary_line": f"{owner}/{repo} {len(candidates)} under {prefix}, {len(safe)} safe",180        }181182    deleted, closed, skipped = [], [], []183    for name in requested:184        b = by_name.get(name)185        if not b:186            skipped.append({"branch": name, "reason": "does not exist (already deleted?)"})187            continue188        limit = off_limits(b)189        if limit:190            skipped.append({"branch": name, "reason": limit})191            continue192        row = assess(client, owner, repo, name, (b.get("commit") or {}).get("sha") or "", default, into)193        if row["base_of"]:194            skipped.append({"branch": name, "reason": row["reason"] + " — deleting it would close them"})195            continue196        if row["open_pr"] and not close_prs:197            skipped.append({"branch": name, "reason": row["reason"] + " — set close_prs to close it"})198            continue199        if not row["safe"] and not row["open_pr"] and not allow_unmerged:200            skipped.append({"branch": name, "reason": row["reason"] + " — set allow_unmerged to delete it anyway"})201            continue202        if row["open_pr"]:203            number = row["pr"]["number"]204            try:205                client.request("POST", f"/repos/{owner}/{repo}/issues/{number}/comments",206                               {"body": CLOSE_NOTE.format(branch=name)})207                client.request("PATCH", f"/repos/{owner}/{repo}/pulls/{number}", {"state": "closed"})208            except RemoteError as e:209                skipped.append({"branch": name, "reason": str(gh.explain(e, f"to close #{number}",210                                                                          gh.NEED["pulls_write"]))})211                continue212            closed.append(number)213        try:214            client.request("DELETE", f"/repos/{owner}/{repo}/git/refs/heads/{gh.enc(name)}")215        except RemoteError as e:216            if e.http_status in (404, 422):217                skipped.append({"branch": name, "reason": "already deleted"})218                continue219            skipped.append({"branch": name, "reason": str(gh.explain(e, f"to delete '{name}'",220                                                                      gh.NEED["contents_write"]))})221            continue222        deleted.append(name)223224    lines = [f"Deleted {len(deleted)} branch{'es' * (len(deleted) != 1)}"225             + (f", closed {len(closed)} pull request{'s' * (len(closed) != 1)}" if closed else "") + "."]226    lines += [f"- ✗ `{d}`" for d in deleted]227    lines += [f"- skipped `{s['branch']}` — {s['reason']}" for s in skipped]228    return {229        "applied": True, "candidates": [], "deleted": deleted, "closed_prs": closed, "skipped": skipped,230        "question": {}, "summary": "\n".join(lines),231        "summary_line": f"{owner}/{repo} deleted {len(deleted)}, closed {len(closed)}, skipped {len(skipped)}",232    }233234235def main() -> None:236    gh.run_node("branch-cleanup", run)237238239if __name__ == "__main__":240    main()241

$ git log --oneline

v1.0.0
HEAD
2026-10-14

First marketplace publish: vibe-coding lanes and the gstack plan flow (PR #1313).