From b67d9f233d27b674594f45b3956ef20d994cd66b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 3 Nov 2026 16:50:32 +0100 Subject: [PATCH] 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 --- README.md | 5 + skills/kvb-code-refactor/SKILL.md | 6 +- skills/kvb-git-update-branches/SKILL.md | 52 ++++++++ .../scripts/branches.py | 121 ++++++++++++++++++ skills/kvb-org-organize/SKILL.md | 7 +- skills/kvb-think-dialectic/SKILL.md | 6 +- tests/test_update_branches.py | 92 +++++++++++++ 7 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 skills/kvb-git-update-branches/SKILL.md create mode 100755 skills/kvb-git-update-branches/scripts/branches.py create mode 100644 tests/test_update_branches.py diff --git a/README.md b/README.md index a3b9dcf..d5fc366 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills/kvb-code-refactor/SKILL.md b/skills/kvb-code-refactor/SKILL.md index 2f9e307..9df3efd 100644 --- a/skills/kvb-code-refactor/SKILL.md +++ b/skills/kvb-code-refactor/SKILL.md @@ -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 diff --git a/skills/kvb-git-update-branches/SKILL.md b/skills/kvb-git-update-branches/SKILL.md new file mode 100644 index 0000000..f2dec73 --- /dev/null +++ b/skills/kvb-git-update-branches/SKILL.md @@ -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 > /prs.json +branches.py --repo --remote origin --base main --email "" --login "" --prs /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 ` 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 | diff --git a/skills/kvb-git-update-branches/scripts/branches.py b/skills/kvb-git-update-branches/scripts/branches.py new file mode 100755 index 0000000..5dccdab --- /dev/null +++ b/skills/kvb-git-update-branches/scripts/branches.py @@ -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()) diff --git a/skills/kvb-org-organize/SKILL.md b/skills/kvb-org-organize/SKILL.md index 0bfce62..f1f2216 100644 --- a/skills/kvb-org-organize/SKILL.md +++ b/skills/kvb-org-organize/SKILL.md @@ -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 diff --git a/skills/kvb-think-dialectic/SKILL.md b/skills/kvb-think-dialectic/SKILL.md index 9ce1227..e0c02e7 100644 --- a/skills/kvb-think-dialectic/SKILL.md +++ b/skills/kvb-think-dialectic/SKILL.md @@ -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 diff --git a/tests/test_update_branches.py b/tests/test_update_branches.py new file mode 100644 index 0000000..45435bf --- /dev/null +++ b/tests/test_update_branches.py @@ -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()