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_merge_fix[kvb-git-merge-fix]
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_changelog[kvb-git-changelog]
check --> check_legal[kvb-check-legal]
@@ -128,6 +129,7 @@ flowchart LR
commit_no_verify[kvb-git-commit-no-verify]
merge_fix[kvb-git-merge-fix]
close_issues[kvb-git-close-issues]
update_branches[kvb-git-update-branches]
branch_gc[kvb-git-branch-gc]
changelog[kvb-git-changelog]
end
@@ -185,6 +187,9 @@ flowchart LR
commit_next --> explain
commit_next --> dialectic
close_issues --> confidence
update_branches --> active_listening
update_branches -. conflicts .-> merge_fix
update_branches -. pushes .-> operator
commit_no_verify --> comments_clean
commit_no_verify --> advocate
advocate --> dialectic
+3 -3
View File
@@ -1,9 +1,9 @@
---
name: kvb-code-refactor
description: >
Refactor every uncommitted file (staged, unstaged, untracked) up to the
project's standards without changing behaviour. Trigger on /kvb-code-refactor or when
the operator asks to clean up, tidy or polish the current changes.
Refactor every uncommitted file up to the project's standards without
changing behaviour. Trigger on /kvb-code-refactor or asked to clean up,
tidy or polish the current changes.
---
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
description: >
Keep parallel agents from duplicating work: the first claim owns it; a
resource chair allocates. Run AUTOMATICALLY
before work another agent might also do (failure, cluster, feature, gate,
deploy, cleanup), and on /kvb-org-organize.
Keep parallel agents from duplicating work: the first claim owns it, a
chair allocates. Run AUTOMATICALLY before work another agent might also
do, and on /kvb-org-organize.
---
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
description: >
Reach a defensible conclusion by thesis, independent skeptic antithesis and
synthesis, iterated to ~99% confidence. Trigger for any consequential root
cause, design or decision that is uncertain and expensive to get wrong.
Thesis, independent skeptic antithesis and synthesis, iterated to ~99%
confidence. Trigger for any root cause, design or decision that is
uncertain and expensive to get wrong.
---
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()