feat(kvb-git-update-branches): merge main into open PRs and branches

branches.py lists every open branch of the remote with its lag behind the base and whether it belongs to the operator: a pull request by the operator's login, or a branch whose own commits all carry the operator's email; merged branches drop out and fork pull requests are marked. The skill merges the base into the operator's own branches directly and into others only after consent through kvb-think-listen, never rebases, aborts on a conflict for kvb-git-merge-fix, and hands every push and gh pr update-branch to kvb-org-operator. tests/test_update_branches.py builds a bare origin with own, foreign, merged, PR and fork cases. Operator order 2026-11-03; three descriptions trimmed for the listing budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-03 16:51:46 +01:00
1 parent 608da13c0f
commit b67d9f233d
7 files changed
+279 -10

No files matched your search

+5
View File
@@ -50,6 +50,7 @@ flowchart LR
git --> git_commit_no_verify[kvb-git-commit-no-verify] git --> git_commit_no_verify[kvb-git-commit-no-verify]
git --> git_merge_fix[kvb-git-merge-fix] git --> git_merge_fix[kvb-git-merge-fix]
git --> git_close_issues[kvb-git-close-issues] git --> git_close_issues[kvb-git-close-issues]
git --> git_update_branches[kvb-git-update-branches]
git --> git_branch_gc[kvb-git-branch-gc] git --> git_branch_gc[kvb-git-branch-gc]
git --> git_changelog[kvb-git-changelog] git --> git_changelog[kvb-git-changelog]
check --> check_legal[kvb-check-legal] check --> check_legal[kvb-check-legal]
@@ -128,6 +129,7 @@ flowchart LR
commit_no_verify[kvb-git-commit-no-verify] commit_no_verify[kvb-git-commit-no-verify]
merge_fix[kvb-git-merge-fix] merge_fix[kvb-git-merge-fix]
close_issues[kvb-git-close-issues] close_issues[kvb-git-close-issues]
update_branches[kvb-git-update-branches]
branch_gc[kvb-git-branch-gc] branch_gc[kvb-git-branch-gc]
changelog[kvb-git-changelog] changelog[kvb-git-changelog]
end end
@@ -185,6 +187,9 @@ flowchart LR
commit_next --> explain commit_next --> explain
commit_next --> dialectic commit_next --> dialectic
close_issues --> confidence close_issues --> confidence
update_branches --> active_listening
update_branches -. conflicts .-> merge_fix
update_branches -. pushes .-> operator
commit_no_verify --> comments_clean commit_no_verify --> comments_clean
commit_no_verify --> advocate commit_no_verify --> advocate
advocate --> dialectic advocate --> dialectic
+3 -3
View File
@@ -1,9 +1,9 @@
--- ---
name: kvb-code-refactor name: kvb-code-refactor
description: > description: >
Refactor every uncommitted file (staged, unstaged, untracked) up to the Refactor every uncommitted file up to the project's standards without
project's standards without changing behaviour. Trigger on /kvb-code-refactor or when changing behaviour. Trigger on /kvb-code-refactor or asked to clean up,
the operator asks to clean up, tidy or polish the current changes. tidy or polish the current changes.
--- ---
The unit of work is the uncommitted change set, not the repository. Behaviour The unit of work is the uncommitted change set, not the repository. Behaviour
+52
View File
@@ -0,0 +1,52 @@
---
name: kvb-git-update-branches
description: >
Merge main into open PRs or branches: the operator's own directly, others
after consent. Trigger on /kvb-git-update-branches.
---
Invoking this skill authorizes the merge commits it makes; pushing stays the
operator's. Branches are merged, never rebased.
## Step 1: scope and candidates
Pull requests, branches or both, as the operator said; ask when it is open.
`git fetch --all --prune`, then list the open branches with their lag and
owner:
```bash
gh pr list --state open --json number,headRefName,author,isCrossRepository > <tmp>/prs.json
branches.py --repo <repo> --remote origin --base main --email "<git user.email>" --login "<gh login>" --prs <tmp>/prs.json
```
`${CLAUDE_SKILL_DIR}/scripts/branches.py` drops branches already merged into
the base and marks `mine`: a pull request by the operator's login, or a
branch whose own commits all carry the operator's email. A row with
`behind 0` is current and skipped.
## Step 2: consent
The operator's own rows go on. For every other row run the `kvb-think-listen`
skill: one `AskUserQuestion` with `multiSelect: true` listing branch, pull
request, owner and lag, nothing preselected. Only what the operator picks is
updated.
## Step 3: update
- **Fork pull request**: no local merge; its `gh pr update-branch <n>` goes
to the operator.
- **Any other branch**: in the worktree that already has it checked out,
else a temporary one under `/tmp` (the repository's own worktree targets
where it has them), `git merge --no-verify --no-edit origin/main`. On a
conflict, `git merge --abort` and report the files; the `kvb-git-merge-fix`
skill or the operator resolves it later.
- Remove the temporary worktrees afterwards.
## Step 4: hand over the pushes
Every updated branch's push and every `gh pr update-branch` goes to the
`kvb-org-operator` skill as an operator command.
## Output
| Branch | PR | Owner | Behind | Result | Push command |
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""List the open branches of a remote with their lag behind the base and their owner.
Args:
--repo: path of the local clone.
--remote: remote whose branches are listed, e.g. origin.
--base: base branch on that remote, e.g. main.
--email: the operator's commit email; a branch whose own commits all carry
it belongs to the operator.
--login: the operator's forge login, matched against pull request authors.
--prs: file with `gh pr list --state open --json
number,headRefName,author,isCrossRepository`; omit it for branches only.
--json: print the rows as JSON instead of tab-separated lines.
Exit codes:
0 done, 2 usage error.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
def fail(message: str) -> None:
print(f"branches: {message}", file=sys.stderr)
sys.exit(2)
def git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args], cwd=repo, capture_output=True, text=True, check=False
)
if result.returncode != 0:
fail(result.stderr.strip() or f"git {' '.join(args)} failed")
return result.stdout
def load_prs(path: str | None) -> list[dict]:
if path is None:
return []
try:
prs = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
fail(f"{path} is not the JSON of gh pr list")
if not isinstance(prs, list):
fail(f"{path} is not a list of pull requests")
return prs
def rows(repo: Path, remote: str, base: str, email: str, login: str, prs: list[dict]) -> list[dict]:
target = f"{remote}/{base}"
git(repo, "rev-parse", "--verify", target)
by_branch = {pr["headRefName"]: pr for pr in prs if not pr.get("isCrossRepository")}
found = []
refs = git(repo, "for-each-ref", "--format=%(refname:short)", f"refs/remotes/{remote}/")
for ref in refs.split():
branch = ref.removeprefix(f"{remote}/")
if branch in (base, "HEAD") or ref == remote:
continue
ahead = int(git(repo, "rev-list", "--count", f"{target}..{ref}"))
if ahead == 0:
continue
behind = int(git(repo, "rev-list", "--count", f"{ref}..{target}"))
authors = sorted(set(git(repo, "log", "--format=%ae", f"{target}..{ref}").split()))
pr = by_branch.get(branch)
mine = (pr["author"]["login"] == login) if pr else authors == [email]
found.append(
{
"branch": branch,
"pr": pr["number"] if pr else None,
"ahead": ahead,
"behind": behind,
"authors": authors,
"mine": mine,
}
)
for pr in prs:
if pr.get("isCrossRepository"):
found.append(
{
"branch": pr["headRefName"],
"pr": pr["number"],
"ahead": None,
"behind": None,
"authors": [pr["author"]["login"]],
"mine": pr["author"]["login"] == login,
"fork": True,
}
)
return found
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
for name in ("--repo", "--remote", "--base", "--email", "--login"):
parser.add_argument(name, required=True)
parser.add_argument("--prs")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
repo = Path(args.repo)
if not repo.is_dir():
fail(f"{repo} is not a directory")
found = rows(repo, args.remote, args.base, args.email, args.login, load_prs(args.prs))
if args.json:
print(json.dumps(found, indent=1))
return 0
for row in found:
print(
f"{row['branch']}\t#{row['pr'] or '-'}\tahead {row['ahead']}\tbehind {row['behind']}"
f"\t{'mine' if row['mine'] else ', '.join(row['authors'])}"
)
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -4
View File
@@ -1,10 +1,9 @@
--- ---
name: kvb-org-organize name: kvb-org-organize
description: > description: >
Keep parallel agents from duplicating work: the first claim owns it; a Keep parallel agents from duplicating work: the first claim owns it, a
resource chair allocates. Run AUTOMATICALLY chair allocates. Run AUTOMATICALLY before work another agent might also
before work another agent might also do (failure, cluster, feature, gate, do, and on /kvb-org-organize.
deploy, cleanup), and on /kvb-org-organize.
--- ---
Two agents doing the same work waste the second run and then collide in the Two agents doing the same work waste the second run and then collide in the
+3 -3
View File
@@ -1,9 +1,9 @@
--- ---
name: kvb-think-dialectic name: kvb-think-dialectic
description: > description: >
Reach a defensible conclusion by thesis, independent skeptic antithesis and Thesis, independent skeptic antithesis and synthesis, iterated to ~99%
synthesis, iterated to ~99% confidence. Trigger for any consequential root confidence. Trigger for any root cause, design or decision that is
cause, design or decision that is uncertain and expensive to get wrong. uncertain and expensive to get wrong.
--- ---
Reach the truth by dialectic, not by first guess. Do not stop at a plausible Reach the truth by dialectic, not by first guess. Do not stop at a plausible
+92
View File
@@ -0,0 +1,92 @@
"""Exercise branches.py: lag behind the base, ownership by commits and by PR author, forks."""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "kvb-git-update-branches" / "scripts" / "branches.py"
ME = "me@example.org"
@unittest.skipUnless(shutil.which("git"), "git not installed")
class TestBranches(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
base = Path(self.tmp.name)
self.env = {"PATH": "/usr/bin:/bin", "HOME": str(base)}
self.origin = base / "origin.git"
self.clone = base / "clone"
self._git(base, "init", "-q", "--bare", "-b", "main", str(self.origin))
self._git(base, "clone", "-q", str(self.origin), str(self.clone))
self._commit(ME, "base")
self._git(self.clone, "branch", "-M", "main")
self._git(self.clone, "push", "-q", "origin", "main")
for branch, email in (("feat/mine", ME), ("feat/theirs", "other@example.org"), ("feat/pr-mine", "other@example.org")):
self._git(self.clone, "switch", "-q", "-c", branch, "main")
self._commit(email, branch)
self._git(self.clone, "push", "-q", "origin", branch)
self._git(self.clone, "switch", "-q", "-c", "feat/merged", "main")
self._git(self.clone, "push", "-q", "origin", "feat/merged")
self._git(self.clone, "switch", "-q", "main")
self._commit(ME, "main moves on")
self._commit(ME, "and again")
self._git(self.clone, "push", "-q", "origin", "main")
self._git(self.clone, "fetch", "-q", "--prune")
self.prs = base / "prs.json"
self.prs.write_text(
json.dumps(
[
{"number": 5, "headRefName": "feat/pr-mine", "author": {"login": "me"}, "isCrossRepository": False},
{"number": 6, "headRefName": "fix", "author": {"login": "stranger"}, "isCrossRepository": True},
]
),
encoding="utf-8",
)
def _git(self, cwd: Path, *args: str) -> None:
subprocess.run(["git", *args], check=True, capture_output=True, env=self.env, cwd=cwd)
def _commit(self, email: str, message: str) -> None:
env = {**self.env, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": email, "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": email}
subprocess.run(
["git", "commit", "-q", "--allow-empty", "-m", message],
check=True, capture_output=True, env=env, cwd=self.clone,
)
def _run(self, *extra: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), "--repo", str(self.clone), "--remote", "origin", "--base", "main",
"--email", ME, "--login", "me", *extra],
check=False, capture_output=True, text=True, env=self.env,
)
def test_open_branches_carry_lag_and_owner(self):
result = self._run("--prs", str(self.prs), "--json")
self.assertEqual(result.returncode, 0, result.stderr)
rows = {row["branch"]: row for row in json.loads(result.stdout)}
self.assertEqual(set(rows), {"feat/mine", "feat/theirs", "feat/pr-mine", "fix"})
self.assertEqual((rows["feat/mine"]["behind"], rows["feat/mine"]["mine"]), (2, True))
self.assertEqual((rows["feat/theirs"]["mine"], rows["feat/theirs"]["pr"]), (False, None))
self.assertEqual((rows["feat/pr-mine"]["pr"], rows["feat/pr-mine"]["mine"]), (5, True))
self.assertEqual((rows["fix"]["mine"], rows["fix"].get("fork")), (False, True))
def test_invalid_input_exits_2(self):
self.assertEqual(self._run("--prs", str(self.tmp.name) + "/missing.json").returncode, 2)
broken = subprocess.run(
[sys.executable, str(SCRIPT), "--repo", str(self.clone), "--remote", "origin", "--base", "nope",
"--email", ME, "--login", "me"],
check=False, capture_output=True, text=True, env=self.env,
)
self.assertEqual(broken.returncode, 2)
if __name__ == "__main__":
unittest.main()