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:
1 parent
608da13c0f
commit
b67d9f233d
7 files changed
+279
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user