feat(worktree): keep a per-worktree merge flow that scripts read

Every worktree can carry a deterministic merge flow in <git-dir>/pkgmgr/flow.yml (never committed, gone with the worktree): pull lists the refs merged into the branch, in order, before a gate, deploy or merge; push lists the branches the finished branch is merged into, in order, each then pushed by the operator.

- pkgmgr worktree flow [PATH] [--json] shows it; flow set (--pull REF.. | --no-pull) (--push BRANCH.. | --no-push) replaces both lists atomically (0600 temp file, os.replace, pkgmgr/ of mode 0700); flow clear removes it. JSON: worktree, git_dir, branch, flow_branch, pull, push, source (file, none, invalid), warnings.
- pkgmgr worktree up gains repeatable --pull/--push; without them pull is the base and push its local branch (origin/main gives main).
- pkgmgr branch tree carries "flow": {pull, push, source} or null on every item with a worktree, read from the linked worktree's metadata dir without running git inside the worktree; the Agent Orchestrator shows it.

The file and the arguments may come from an agent: every entry must equal its git check-ref-format --branch output (scan mode, cwd /) and must not start with -; the reader opens pkgmgr/ and flow.yml without following links, non-blocking, refuses anything but a regular file, caps at 64 KiB, uses yaml.safe_load, rejects unknown keys, wrong types and bool versions, and never raises into callers: a bad file reads as source invalid with empty lists and a warning.

Tests: 30 new (unit and integration on real repositories), unit suite 500 OK, integration 254 with the two known sandbox signing errors of test_publish_integration only.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 97 %
⏱️ Time: 93 %
🛡️ Security: 100 %
🏆 Score: 98
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 15:11:43 +00:00
1 parent 4c8b137449
commit 7fb229688b
12 files changed
+1196 -10

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import io
import json
import os
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
from pkgmgr.actions.branch import gc, tree
from pkgmgr.actions.worktree import flow
from tests.integration.test_branch_gc import GcFixture
class TestBranchTreeFlow(GcFixture, unittest.TestCase):
def setUp(self) -> None:
super().setUp()
self.shared = os.path.join(self.root, "shared-worktrees")
os.makedirs(self.shared)
self.metas = os.path.join(self.repo, ".git", "worktrees")
for patcher in (
patch.object(gc, "host_pid_namespace", return_value=True),
patch.object(gc, "list_compose_containers", return_value=[]),
patch.object(gc, "_process_cwds", return_value={}),
patch.object(tree, "secret_service_token", return_value=(None, "locked")),
):
patcher.start()
self.addCleanup(patcher.stop)
def _entry(self) -> dict:
out = io.StringIO()
with redirect_stdout(out):
code = tree.branch_tree(
[self.repo], fmt="json", output=None, worktrees_root=self.shared
)
self.assertEqual(code, 0)
return json.loads(out.getvalue())["repos"][0]
def _flows(self, entry: dict) -> dict[str, dict | None]:
return {
item["branch"]: item["flow"] for item in entry["items"] if item["branch"]
}
def _flow_warnings(self, entry: dict) -> list[str]:
return [w for w in entry["warnings"] if w.startswith("flow of ")]
def test_items_carry_their_flow_or_null(self) -> None:
flow.write_flow(
os.path.join(self.metas, "wt-merged"), "merged", ["origin/main"], ["prod"]
)
flow.write_flow(os.path.join(self.repo, ".git"), "main", [], ["origin/main"])
entry = self._entry()
flows = self._flows(entry)
self.assertEqual(
flows["merged"],
{"pull": ["origin/main"], "push": ["prod"], "source": "file"},
)
self.assertEqual(
flows["main"], {"pull": [], "push": ["origin/main"], "source": "file"}
)
self.assertIsNone(flows["dirty"])
self.assertIsNone(flows["wip"])
self.assertTrue(all("flow" in item for item in entry["items"]))
self.assertEqual(self._flow_warnings(entry), [])
def test_invalid_and_mismatched_flows_warn_without_failing(self) -> None:
folder = os.path.join(self.metas, "wt-dirty", flow.FLOW_DIR)
os.makedirs(folder)
with open(os.path.join(folder, flow.FLOW_FILE), "w", encoding="utf-8") as fh:
fh.write("version: 1\npull: [a..b]\npush: []\n")
flow.write_flow(os.path.join(self.metas, "wt-merged"), "gone", ["main"], [])
entry = self._entry()
flows = self._flows(entry)
self.assertEqual(flows["dirty"], {"pull": [], "push": [], "source": "invalid"})
self.assertEqual(flows["merged"]["source"], "file")
warnings = self._flow_warnings(entry)
self.assertEqual(len(warnings), 2)
self.assertTrue(
any(
w.startswith(f"flow of {self.dirty_wt}: pkgmgr/flow.yml")
for w in warnings
)
)
self.assertIn(
f"flow of {self.merged_wt}: flow was set on gone, the worktree is on merged",
warnings,
)
def test_a_forged_registration_reads_no_flow(self) -> None:
flow.write_flow(os.path.join(self.metas, "wt-merged"), "merged", ["main"], [])
with open(os.path.join(self.metas, "wt-locked", "gitdir"), "w") as fh:
fh.write(os.path.join(self.merged_wt, ".git") + "\n")
items = {item["branch"]: item for item in self._entry()["items"]}
for branch in ("merged", "locked"):
with self.subTest(branch=branch):
self.assertEqual(items[branch]["worktree"], self.merged_wt)
self.assertIsNone(items[branch]["flow"])
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -314,6 +314,8 @@ class TestWorktreeUp(WorktreeFixture, unittest.TestCase):
path=path,
hooks=hooks or {},
as_json=True,
pull=None,
push=None,
)
return code, json.loads(out.getvalue()) if code != 2 else {}
@@ -729,6 +731,8 @@ class TestWorktreeCli(unittest.TestCase):
path="/srv/wt/x",
hooks={os.path.realpath("/srv/host/o/r"): pins},
as_json=True,
pull=None,
push=None,
)
refresh.assert_called_once()
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
import io
import json
import os
import unittest
from contextlib import redirect_stderr, redirect_stdout
from types import SimpleNamespace
from unittest.mock import patch
import yaml
from pkgmgr.actions.worktree import up
from pkgmgr.cli.commands.worktree import handle_worktree
from pkgmgr.cli.parser import create_parser
from tests.integration.test_branch_gc import GcFixture, git
PULL = ["origin/main", "store-i18n"]
PUSH = ["feature/iam-profile-rbac", "production"]
class FlowFixture(GcFixture):
def setUp(self) -> None:
super().setUp()
self.linked_git_dir = os.path.join(self.repo, ".git", "worktrees", "wt-merged")
self.linked_file = os.path.join(self.linked_git_dir, "pkgmgr", "flow.yml")
self.addCleanup(os.chdir, os.getcwd())
def _cli(self, *argv: str) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
args = create_parser("test").parse_args(["worktree", *argv])
with (
patch("pkgmgr.cli.commands.worktree.refresh_tree") as self.refresh,
redirect_stdout(out),
redirect_stderr(err),
self.assertRaises(SystemExit) as done,
):
handle_worktree(args, SimpleNamespace())
return done.exception.code, out.getvalue(), err.getvalue()
def _json(self, *argv: str) -> dict:
code, out, err = self._cli(*argv, "--json")
self.assertEqual(code, 0, err)
return json.loads(out)
class TestFlowCli(FlowFixture, unittest.TestCase):
def test_set_show_clear_json_on_a_linked_worktree(self) -> None:
argv = [f"--pull={ref}" for ref in PULL] + [f"--push={b}" for b in PUSH]
expected = {
"worktree": self.merged_wt,
"git_dir": self.linked_git_dir,
"branch": "merged",
"flow_branch": "merged",
"pull": PULL,
"push": PUSH,
"source": "file",
"warnings": [],
}
self.assertEqual(self._json("flow", "set", self.merged_wt, *argv), expected)
self.refresh.assert_called_once_with([self.repo])
self.assertEqual(self._json("flow", self.merged_wt), expected)
self.assertTrue(os.path.isfile(self.linked_file))
self.assertEqual(git(self.merged_wt, "status", "--porcelain"), "")
cleared = self._json("flow", "clear", self.merged_wt)
self.assertEqual(
{**expected, "flow_branch": None, "pull": [], "push": [], "source": "none"},
cleared,
)
self.assertFalse(os.path.exists(self.linked_file))
def test_set_show_clear_text_on_the_main_worktree(self) -> None:
os.chdir(self.repo)
code, out, _ = self._cli("flow", "set", "--pull", "origin/main", "--no-push")
self.assertEqual((code, out), (0, "pull: origin/main\npush: (none)\n"))
self.assertEqual(
self._cli("flow")[:2], (0, "pull: origin/main\npush: (none)\n")
)
shown = self._json("flow")
self.assertEqual(
(shown["worktree"], shown["git_dir"], shown["branch"]),
(self.repo, os.path.join(self.repo, ".git"), "main"),
)
self.assertTrue(
os.path.isfile(os.path.join(self.repo, ".git", "pkgmgr", "flow.yml"))
)
self.assertEqual(
self._cli("flow", "clear")[:2], (0, "pull: (none)\npush: (none)\n")
)
def test_show_without_a_flow_succeeds_empty(self) -> None:
shown = self._json("flow", self.merged_wt)
self.assertEqual(
(shown["source"], shown["flow_branch"], shown["pull"], shown["push"]),
("none", None, [], []),
)
self.assertEqual(shown["warnings"], [])
def test_invalid_names_exit_2_and_write_nothing(self) -> None:
escape = os.path.join(self.root, "escape")
for bad in ("-x", "a..b", f"--output={escape}"):
for flag, other in (("--pull", "--push"), ("--push", "--pull")):
with self.subTest(bad=bad, flag=flag):
code, out, err = self._cli(
"flow", "set", self.merged_wt, f"{flag}={bad}", f"{other}=main"
)
self.assertEqual((code, out), (2, ""))
self.assertIn("is not a valid branch name", err)
self.assertFalse(os.path.exists(os.path.join(self.linked_git_dir, "pkgmgr")))
self.assertFalse(os.path.exists(escape))
def test_usage_errors_exit_2_and_write_nothing(self) -> None:
cases = (
("flow", "set", self.merged_wt, "--pull", "main"),
("flow", "set", self.merged_wt, "--pull", "main", "--no-pull", "--no-push"),
("flow", self.merged_wt, "--push", "main"),
("flow", "clear", self.merged_wt, "--no-pull"),
("flow", "show", self.merged_wt),
)
for argv in cases:
with self.subTest(argv=argv[1:]):
self.assertEqual(self._cli(*argv)[:2], (2, ""))
self.assertFalse(os.path.exists(os.path.join(self.linked_git_dir, "pkgmgr")))
def test_a_path_that_is_no_worktree_exits_2(self) -> None:
absent = os.path.join(self.root, "absent")
for path in (self.root, self.origin, absent):
for argv in (
("flow", path),
("flow", "set", path, "--no-pull", "--no-push"),
):
with self.subTest(argv=argv):
code, out, err = self._cli(*argv)
self.assertEqual((code, out), (2, ""))
self.assertIn("is not a git worktree", err)
self.assertFalse(os.path.exists(os.path.join(self.origin, "pkgmgr")))
def test_another_branch_warns_of_the_mismatch(self) -> None:
self._json("flow", "set", self.merged_wt, "--pull", "main", "--push", "main")
git(self.merged_wt, "checkout", "--quiet", "-b", "renamed")
shown = self._json("flow", self.merged_wt)
self.assertEqual(
(shown["branch"], shown["flow_branch"], shown["source"], shown["pull"]),
("renamed", "merged", "file", ["main"]),
)
warning = "flow was set on merged, the worktree is on renamed"
self.assertEqual(shown["warnings"], [warning])
self.assertEqual(
self._cli("flow", self.merged_wt)[1],
f"WARN {warning}\npull: main\npush: main\n",
)
def test_a_planted_symlink_reads_invalid_and_set_replaces_it(self) -> None:
target = os.path.join(self.root, "planted.yml")
with open(target, "w", encoding="utf-8") as fh:
yaml.safe_dump({"version": 1, "pull": ["planted"], "push": []}, fh)
os.makedirs(os.path.dirname(self.linked_file))
os.symlink(target, self.linked_file)
shown = self._json("flow", self.merged_wt)
self.assertEqual(
(shown["source"], shown["pull"], shown["push"]), ("invalid", [], [])
)
self.assertTrue(shown["warnings"][0].startswith("pkgmgr/flow.yml ignored: "))
self._json("flow", "set", self.merged_wt, "--pull", "main", "--no-push")
self.assertFalse(os.path.islink(self.linked_file))
with open(target, encoding="utf-8") as fh:
self.assertEqual(yaml.safe_load(fh)["pull"], ["planted"])
class TestUpFlow(FlowFixture, unittest.TestCase):
def _up(
self,
branch: str,
pull: list[str] | None,
push: list[str] | None,
base: str | None = None,
) -> tuple[int, dict]:
out = io.StringIO()
with redirect_stdout(out), redirect_stderr(io.StringIO()):
code = up.worktree_up(
self.repo,
branch,
base=base,
worktrees_dir=os.path.join(self.root, "wts"),
path=None,
hooks={},
as_json=True,
pull=pull,
push=push,
)
return code, json.loads(out.getvalue()) if code != 2 else {}
def _shown(self, path: str) -> tuple:
shown = self._json("flow", path)
return shown["source"], shown["flow_branch"], shown["pull"], shown["push"]
def test_without_lists_the_flow_is_the_base(self) -> None:
code, result = self._up("feature/d", None, None)
self.assertEqual(code, 0)
self.assertEqual(result["flow"], {"pull": ["origin/main"], "push": ["main"]})
self.assertEqual(
self._shown(result["path"]),
("file", "feature/d", ["origin/main"], ["main"]),
)
def test_given_lists_replace_the_base_each_on_its_own(self) -> None:
_, both = self._up("feature/b", PULL, PUSH, base="origin/main")
_, one = self._up("feature/o", None, ["production"])
self.assertEqual(self._shown(both["path"]), ("file", "feature/b", PULL, PUSH))
self.assertEqual(one["flow"], {"pull": ["origin/main"], "push": ["production"]})
def test_a_base_that_is_no_branch_name_sets_no_flow(self) -> None:
code, result = self._up("feature/c", None, None, base="main~0")
self.assertEqual((code, result["flow"]), (0, None))
self.assertIn("flow not set: base 'main~0'", " ".join(result["warnings"]))
self.assertEqual(self._shown(result["path"])[0], "none")
def test_invalid_names_refuse_before_the_checkout(self) -> None:
escape = os.path.join(self.root, "escape")
for pull, push in (
(["-x"], None),
(None, ["a..b"]),
([f"--output={escape}"], None),
):
with self.subTest(pull=pull, push=push):
self.assertEqual(self._up("feature/i", pull, push)[0], 2)
self.assertFalse(os.path.exists(os.path.join(self.root, "wts")))
self.assertFalse(os.path.exists(escape))
self.assertEqual(git(self.repo, "branch", "--list", "feature/i"), "")
def test_up_parses_repeatable_lists(self) -> None:
args = create_parser("test").parse_args(
["worktree", "up", "feature/x", "--pull", "a", "--pull", "b", "--push", "c"]
)
self.assertEqual((args.pull, args.push), (["a", "b"], ["c"]))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,176 @@
from __future__ import annotations
import os
import stat
import tempfile
import unittest
from unittest.mock import patch
import yaml
from pkgmgr.actions.worktree import flow
PULL = ["origin/main", "store-i18n"]
PUSH = ["feature/iam-profile-rbac", "production"]
PLANTED = {"version": 1, "branch": "x", "pull": ["planted"], "push": ["planted"]}
class TestFlowFile(unittest.TestCase):
def setUp(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.git_dir = os.path.realpath(tmp.name)
self.folder = os.path.join(self.git_dir, flow.FLOW_DIR)
self.file = os.path.join(self.folder, flow.FLOW_FILE)
def _plant(self, data: bytes) -> None:
os.makedirs(self.folder, exist_ok=True)
with open(self.file, "wb") as fh:
fh.write(data)
def _outside(self, name: str) -> str:
target = os.path.join(self.git_dir, name)
with open(target, "w", encoding="utf-8") as fh:
yaml.safe_dump(PLANTED, fh)
return target
def _invalid(self) -> str:
found = flow.read_flow(self.git_dir)
self.assertEqual(
(found.source, found.branch, found.pull, found.push),
("invalid", None, [], []),
)
self.assertEqual(len(found.warnings), 1)
self.assertTrue(found.warnings[0].startswith("pkgmgr/flow.yml ignored: "))
return found.warnings[0]
def test_round_trip_keeps_order_modes_and_schema(self) -> None:
flow.write_flow(self.git_dir, "feature/x", PULL, PUSH)
found = flow.read_flow(self.git_dir)
self.assertEqual(
(found.source, found.branch, found.pull, found.push, found.warnings),
("file", "feature/x", PULL, PUSH, []),
)
self.assertEqual(stat.S_IMODE(os.stat(self.folder).st_mode), 0o700)
self.assertEqual(stat.S_IMODE(os.stat(self.file).st_mode), 0o600)
self.assertEqual(os.listdir(self.folder), [flow.FLOW_FILE])
with open(self.file, encoding="utf-8") as fh:
self.assertEqual(
yaml.safe_load(fh),
{"version": 1, "branch": "feature/x", "pull": PULL, "push": PUSH},
)
def test_missing_file_reads_as_none(self) -> None:
self.assertEqual(flow.read_flow(self.git_dir), flow.Flow())
os.makedirs(self.folder)
self.assertEqual(flow.read_flow(self.git_dir).source, "none")
def test_symlinked_file_is_never_followed(self) -> None:
os.makedirs(self.folder)
os.symlink(self._outside("planted.yml"), self.file)
self._invalid()
def test_symlinked_folder_is_never_followed_or_written(self) -> None:
real = os.path.join(self.git_dir, "real")
os.makedirs(real)
os.rename(self._outside("planted.yml"), os.path.join(real, flow.FLOW_FILE))
os.symlink(real, self.folder)
self._invalid()
with self.assertRaises(OSError):
flow.write_flow(self.git_dir, None, [], [])
with self.assertRaises(OSError):
flow.clear_flow(self.git_dir)
self.assertEqual(os.listdir(real), [flow.FLOW_FILE])
with open(os.path.join(real, flow.FLOW_FILE), encoding="utf-8") as fh:
self.assertEqual(yaml.safe_load(fh), PLANTED)
def test_write_and_clear_replace_a_symlink_never_its_target(self) -> None:
target = self._outside("victim.yml")
os.makedirs(self.folder)
os.symlink(target, self.file)
flow.write_flow(self.git_dir, "x", ["main"], [])
self.assertFalse(os.path.islink(self.file))
self.assertEqual(flow.read_flow(self.git_dir).pull, ["main"])
os.unlink(self.file)
os.symlink(target, self.file)
flow.clear_flow(self.git_dir)
self.assertFalse(os.path.lexists(self.file))
with open(target, encoding="utf-8") as fh:
self.assertEqual(yaml.safe_load(fh), PLANTED)
def test_clear_without_a_file_is_a_no_op(self) -> None:
flow.clear_flow(self.git_dir)
flow.write_flow(self.git_dir, "x", PULL, PUSH)
flow.clear_flow(self.git_dir)
flow.clear_flow(self.git_dir)
self.assertEqual(flow.read_flow(self.git_dir), flow.Flow())
def test_oversized_file_reads_as_invalid(self) -> None:
self._plant(b"version: 1\npull: []\npush: []\n" + b"#" * flow.MAX_BYTES)
self.assertIn("larger than", self._invalid())
def test_fifo_reads_as_invalid_without_blocking(self) -> None:
os.makedirs(self.folder)
os.mkfifo(self.file)
self.assertIn("not a regular file", self._invalid())
def test_directory_reads_as_invalid(self) -> None:
os.makedirs(self.file)
self.assertIn("not a regular file", self._invalid())
def test_malformed_files_read_as_invalid(self) -> None:
lists = "pull: []\npush: []\n"
cases = {
"yaml": b"pull: [\n",
"not a mapping": b"- main\n",
"unknown key": f"version: 1\n{lists}extra: 1\n".encode(),
"missing key": b"version: 1\npull: []\n",
"bool version": f"version: true\n{lists}".encode(),
"other version": f"version: 2\n{lists}".encode(),
"string list": b"version: 1\npull: main\npush: []\n",
"number entry": b"version: 1\npull: [1]\npush: []\n",
"option entry": b"version: 1\npull: ['--output=/tmp/x']\npush: []\n",
"range entry": b"version: 1\npull: []\npush: [a..b]\n",
"bad branch": f"version: 1\nbranch: -x\n{lists}".encode(),
"nesting": b"[" * 30000,
"binary": b"\xff\xfe",
}
for name, data in cases.items():
with self.subTest(case=name):
self._plant(data)
self._invalid()
class TestNames(unittest.TestCase):
def test_branch_and_remote_tracking_names_pass_in_order(self) -> None:
self.assertEqual(flow.check_names("pull", PULL + ["@"]), PULL + ["@"])
def test_invalid_names_are_refused(self) -> None:
for name in ("-x", "a..b", "--output=/tmp/x", "", "HEAD", "a b", "x\0y", 1):
with self.subTest(name=name), self.assertRaises(flow.FlowError):
flow.check_name("pull", name)
with self.assertRaises(flow.FlowError):
flow.check_names("push", "main")
def test_option_like_names_never_reach_git(self) -> None:
with patch.object(flow, "run") as run, self.assertRaises(flow.FlowError):
flow.check_name("pull", "--output=/tmp/x")
run.assert_not_called()
def test_mismatch_warns_only_for_a_file_of_another_branch(self) -> None:
found = flow.Flow(branch="a", source="file")
self.assertEqual(flow.mismatch(found, "a"), [])
self.assertEqual(
flow.mismatch(found, None),
["flow was set on a, the worktree is on a detached HEAD"],
)
self.assertEqual(flow.mismatch(flow.Flow(source="invalid"), "a"), [])
if __name__ == "__main__":
unittest.main()