$ 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-14First marketplace publish: vibe-coding lanes and the gstack plan flow (PR #1313).