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

+28 -1
View File
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
import yaml
from pkgmgr.actions.worktree import flow
from pkgmgr.core.credentials.store_keys import build_keyring_key
from pkgmgr.core.git.errors import GitBaseError
@@ -193,6 +194,27 @@ def _missing(root: str, known: bool, scanned_at: str) -> dict:
}
def _git_dir(
root: str, registrations: gc.Registrations, worktree: str | None
) -> str | None:
if worktree is None:
return None
if worktree == root:
return os.path.dirname(registrations[0])
return gc._linked_meta(registrations, worktree)
def _flow(git_dir: str | None, item: dict, warnings: list[str]) -> dict | None:
found = flow.read_flow(git_dir) if git_dir else None
if found is None or found.source == "none":
return None
warnings += [
f"flow of {item['worktree']}: {warning}"
for warning in found.warnings + flow.mismatch(found, item["branch"])
]
return {"pull": found.pull, "push": found.push, "source": found.source}
def _scan(
root: str,
known: bool,
@@ -227,16 +249,21 @@ def _scan(
fetch=False,
forge_source=source,
)
registrations = gc._registrations(root)
except GitBaseError as exc:
entry["warnings"] = [f"scan failed: {str(exc).strip().splitlines()[0]}"]
return entry
owner = next((r for r in repo.remotes if repo.default.startswith(f"{r}/")), None)
items = [asdict(item) for item in repo.items]
for item in items:
git_dir = _git_dir(root, registrations, item["worktree"])
item["flow"] = _flow(git_dir, item, repo.warnings)
entry.update(
remote=repo.urls[repo.remotes[0]] if repo.remotes else None,
default=repo.default.removeprefix(f"{owner}/") if owner else repo.default,
forge=sources[0],
warnings=repo.warnings,
items=[asdict(item) for item in repo.items],
items=items,
)
return entry
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
import os
import secrets
import stat
from contextlib import suppress
from dataclasses import dataclass, field
import yaml
from pkgmgr.actions.branch.nofollow import DIR_FLAGS
from pkgmgr.core.git.errors import GitBaseError
from pkgmgr.core.git.run import run
FLOW_DIR = "pkgmgr"
FLOW_FILE = "flow.yml"
RELATIVE = f"{FLOW_DIR}/{FLOW_FILE}"
VERSION = 1
MAX_BYTES = 64 * 1024
FOLDER_MODE = 0o700
FILE_MODE = 0o600
KEYS = ("version", "branch", "pull", "push")
REQUIRED = ("version", "pull", "push")
# O_NONBLOCK: opening a planted FIFO for reading would block until a writer comes
READ_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
DETACHED = "a detached HEAD"
NONE_LISTED = "(none)"
class FlowError(ValueError):
pass
@dataclass
class Flow:
pull: list[str] = field(default_factory=list)
push: list[str] = field(default_factory=list)
branch: str | None = None
source: str = "none"
warnings: list[str] = field(default_factory=list)
def describe(exc: BaseException) -> str:
return (str(exc).strip().splitlines() or [type(exc).__name__])[0]
def _ref_format(name: str) -> bool:
# check-ref-format needs no repository, so / keeps any repository config
# out; --branch would expand @{-N} into another name inside one
try:
return (
run(["check-ref-format", "--branch", name], cwd=os.sep, scan=True) == name
)
except (GitBaseError, OSError, ValueError):
return False
def check_name(kind: str, name: object) -> str:
"""
Args:
kind: pull, push or branch, for the message.
name: an entry from the file or the command line.
Returns:
name, once it is a string git accepts as a branch name that does not
start with `-`; a remote-tracking name such as origin/main passes.
Raises:
FlowError: name is anything else.
"""
if not isinstance(name, str) or name.startswith("-") or not _ref_format(name):
raise FlowError(f"{kind} {name!r} is not a valid branch name")
return name
def check_names(kind: str, names: object) -> list[str]:
"""
Args:
kind: pull or push, for the message.
names: the list from the file or the command line.
Returns:
The names in order, each passed through check_name.
Raises:
FlowError: names is not a list or an entry is invalid.
"""
if not isinstance(names, list):
raise FlowError(f"{kind} must be a list of branch names")
return [check_name(kind, name) for name in names]
def _folder(git_dir: str, create: bool) -> int:
parent = os.open(git_dir, os.O_RDONLY | os.O_DIRECTORY)
try:
if create:
with suppress(FileExistsError):
os.mkdir(FLOW_DIR, FOLDER_MODE, dir_fd=parent)
return os.open(FLOW_DIR, DIR_FLAGS, dir_fd=parent)
finally:
os.close(parent)
def _read_text(git_dir: str) -> str | None:
try:
folder = _folder(git_dir, create=False)
except FileNotFoundError:
return None
try:
fd = os.open(FLOW_FILE, READ_FLAGS, dir_fd=folder)
except FileNotFoundError:
return None
finally:
os.close(folder)
# os.fdopen refuses a directory but leaves its fd open, so check first
if not stat.S_ISREG(os.fstat(fd).st_mode):
os.close(fd)
raise FlowError("not a regular file")
with os.fdopen(fd, "rb") as fh:
data = fh.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise FlowError(f"larger than {MAX_BYTES} bytes")
return data.decode("utf-8")
def _parse(text: str) -> tuple[str | None, list[str], list[str]]:
data = yaml.safe_load(text)
if not isinstance(data, dict):
raise FlowError("not a mapping")
unknown = [repr(key) for key in data if key not in KEYS]
if unknown:
raise FlowError(f"unknown keys {', '.join(unknown)}")
missing = [key for key in REQUIRED if key not in data]
if missing:
raise FlowError(f"missing keys {', '.join(missing)}")
# bool is an int, and True == 1
if type(data["version"]) is not int or data["version"] != VERSION:
raise FlowError(f"version must be {VERSION}")
branch = data.get("branch")
if branch is not None:
check_name("branch", branch)
return branch, check_names("pull", data["pull"]), check_names("push", data["push"])
def read_flow(git_dir: str) -> Flow:
"""
Read `<git_dir>/pkgmgr/flow.yml`, which an agent may have written.
Args:
git_dir: the worktree's own git directory.
Returns:
The flow with source file; source none when there is no file; source
invalid, empty lists and a warning when the file is a symbolic link,
no regular file, larger than MAX_BYTES or malformed. Never raises.
"""
try:
text = _read_text(git_dir)
if text is None:
return Flow()
branch, pull, push = _parse(text)
# RecursionError: safe_load recurses once per nesting level
except (OSError, ValueError, yaml.YAMLError, RecursionError) as exc:
return Flow(source="invalid", warnings=[f"{RELATIVE} ignored: {describe(exc)}"])
return Flow(pull=pull, push=push, branch=branch, source="file")
def write_flow(
git_dir: str, branch: str | None, pull: list[str], push: list[str]
) -> None:
"""
Replace the flow atomically, never writing through a symbolic link.
Args:
git_dir: the worktree's own git directory.
branch: the worktree's branch now; None when detached.
pull: refs merged into the branch, in order, already through check_names.
push: branches the branch is merged into, in order, likewise checked.
Raises:
OSError: `pkgmgr` is a symbolic link or no directory, or a write failed.
"""
data = {"version": VERSION, "branch": branch, "pull": pull, "push": push}
text = yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
folder = _folder(git_dir, create=True)
tmp = f".{FLOW_FILE}.{secrets.token_hex(8)}"
try:
fd = os.open(tmp, WRITE_FLAGS, FILE_MODE, dir_fd=folder)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, FLOW_FILE, src_dir_fd=folder, dst_dir_fd=folder)
finally:
with suppress(FileNotFoundError):
os.unlink(tmp, dir_fd=folder)
os.close(folder)
def clear_flow(git_dir: str) -> None:
"""
Remove the flow file; a symbolic link in its place goes, never its target.
Args:
git_dir: the worktree's own git directory.
Raises:
OSError: `pkgmgr` is a symbolic link or no directory, or the unlink failed.
"""
try:
folder = _folder(git_dir, create=False)
except FileNotFoundError:
return
try:
with suppress(FileNotFoundError):
os.unlink(FLOW_FILE, dir_fd=folder)
finally:
os.close(folder)
def mismatch(flow: Flow, branch: str | None) -> list[str]:
"""
Args:
flow: a flow from read_flow.
branch: the worktree's current branch; None when detached.
Returns:
One warning when the file was written on another branch, else none.
"""
if flow.source != "file" or flow.branch == branch:
return []
warning = (
f"flow was set on {flow.branch or DETACHED}, "
f"the worktree is on {branch or DETACHED}"
)
return [warning]
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import json
import sys
from pkgmgr.core.git.errors import GitBaseError
from pkgmgr.core.git.run import run
from .flow import (
NONE_LISTED,
Flow,
FlowError,
check_names,
clear_flow,
describe,
mismatch,
read_flow,
write_flow,
)
Located = tuple[str, str, str | None]
def locate(path: str) -> Located:
"""
Args:
path: any directory inside the worktree.
Returns:
The worktree root, its own git directory (`.git` of the main
worktree, `<common>/worktrees/<id>` of a linked one) and its branch,
None when detached.
Raises:
GitBaseError: path is not inside a worktree, e.g. a bare repository.
OSError: path is no directory.
ValueError: a path git printed spans lines.
"""
out = run(
["rev-parse", "--absolute-git-dir", "--show-toplevel"], cwd=path, scan=True
)
git_dir, top = out.split("\n")
try:
ref = run(["symbolic-ref", "--quiet", "HEAD"], cwd=top, scan=True)
except GitBaseError:
ref = ""
branch = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else None
return top, git_dir, branch
def _located(path: str) -> Located | None:
try:
return locate(path)
except (GitBaseError, OSError, ValueError) as exc:
print(
f"Refusing: {path} is not a git worktree: {describe(exc)}",
file=sys.stderr,
)
return None
def _report(located: Located, flow: Flow, as_json: bool) -> None:
top, git_dir, branch = located
result = {
"worktree": top,
"git_dir": git_dir,
"branch": branch,
"flow_branch": flow.branch,
"pull": flow.pull,
"push": flow.push,
"source": flow.source,
"warnings": flow.warnings + mismatch(flow, branch),
}
if as_json:
print(json.dumps(result, indent=2))
return
for warning in result["warnings"]:
print(f"WARN {warning}")
for key in ("pull", "push"):
print(f"{key}: {', '.join(result[key]) or NONE_LISTED}")
def worktree_flow_show(path: str, *, as_json: bool) -> int:
"""
Print the flow of the worktree at path.
Args:
path: any directory inside the worktree.
as_json: print the result as JSON instead of text.
Returns:
0, also without a flow or with an invalid file; 2 when path is not a
git worktree.
"""
located = _located(path)
if located is None:
return 2
_report(located, read_flow(located[1]), as_json)
return 0
def worktree_flow_set(
path: str, *, pull: list[str], push: list[str], as_json: bool
) -> int:
"""
Replace both lists of the worktree's flow and print the result.
Args:
path: any directory inside the worktree.
pull: refs merged into the branch before a gate, deploy or merge, in
order; may be empty.
push: branches the branch is merged into when finished, in order;
may be empty.
as_json: print the result as JSON instead of text.
Returns:
0 when written, 1 when the write failed, 2 on an invalid name or when
path is not a git worktree; nothing is written unless 0.
"""
try:
pull, push = check_names("pull", pull), check_names("push", push)
except FlowError as exc:
print(f"Refusing: {exc}", file=sys.stderr)
return 2
located = _located(path)
if located is None:
return 2
try:
write_flow(located[1], located[2], pull, push)
except OSError as exc:
print(f"Cannot write the flow of {located[0]}: {exc}", file=sys.stderr)
return 1
_report(located, read_flow(located[1]), as_json)
return 0
def worktree_flow_clear(path: str, *, as_json: bool) -> int:
"""
Remove the worktree's flow and print the empty result.
Args:
path: any directory inside the worktree.
as_json: print the result as JSON instead of text.
Returns:
0 when no flow is left, 1 when the removal failed, 2 when path is not
a git worktree.
"""
located = _located(path)
if located is None:
return 2
try:
clear_flow(located[1])
except OSError as exc:
print(f"Cannot clear the flow of {located[0]}: {exc}", file=sys.stderr)
return 1
_report(located, read_flow(located[1]), as_json)
return 0
+18 -5
View File
@@ -24,6 +24,21 @@ def next_slot(used: set[int]) -> int:
return slot
def new_worktree_meta(path: str) -> str:
"""
Args:
path: a worktree this process just added, whose `.git` file names
its metadata.
Returns:
The worktree's own git directory, `<common>/worktrees/<id>`.
"""
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
return os.path.normpath(
os.path.join(path, fh.read().strip().removeprefix("gitdir: "))
)
def write_slot(path: str, slot: int) -> None:
"""
Record a slot in the metadata of a worktree this process just added.
@@ -32,9 +47,7 @@ def write_slot(path: str, slot: int) -> None:
path: the new worktree, whose `.git` file names its metadata.
slot: the slot to record.
"""
with open(os.path.join(path, ".git"), encoding="utf-8") as fh:
meta = os.path.normpath(
os.path.join(path, fh.read().strip().removeprefix("gitdir: "))
)
with open(os.path.join(meta, SLOT_FILE), "w", encoding="utf-8") as fh:
with open(
os.path.join(new_worktree_meta(path), SLOT_FILE), "w", encoding="utf-8"
) as fh:
fh.write(f"{slot}\n")
+61 -1
View File
@@ -9,8 +9,9 @@ from pkgmgr.core.git.errors import GitRunError
from pkgmgr.core.git.queries import FILTER_CONFIG, local_config
from pkgmgr.core.git.run import END_OF_OPTIONS, run
from .flow import NONE_LISTED, FlowError, check_name, check_names, write_flow
from .hooks import INIT_HOOK, defined_in, hook_command, run_hook
from .slots import next_slot, used_slots, write_slot
from .slots import new_worktree_meta, next_slot, used_slots, write_slot
PORT_PREFIX = re.compile(r"^\d+/")
HOST_AND_PATH = re.compile(r"([^:/]+)[:/](.*)")
@@ -81,6 +82,51 @@ def _default_base(repo: str) -> str:
return "origin/main" if _ref_exists(repo, "origin/main") else "origin/master"
def _local_branch(repo: str, ref: str) -> str:
try:
remotes = run(["remote"], cwd=repo, scan=True).split()
except GitRunError:
return ref
owner = next((r for r in remotes if ref.startswith(f"{r}/")), None)
return ref.removeprefix(f"{owner}/") if owner else ref
def _invalid_flow(pull: list[str] | None, push: list[str] | None) -> str | None:
try:
for kind, names in (("pull", pull), ("push", push)):
if names is not None:
check_names(kind, names)
except FlowError as exc:
return str(exc)
return None
def _record_flow(
root: str,
path: str,
branch: str,
lists: tuple[list[str] | None, list[str] | None],
base: str | None,
warnings: list[str],
) -> dict[str, list[str]] | None:
pull, push = lists
if pull is None or push is None:
default = base or _default_base(root)
try:
check_name("base", default)
except FlowError as exc:
warnings.append(f"flow not set: {exc}")
return None
pull = [default] if pull is None else pull
push = [_local_branch(root, default)] if push is None else push
try:
write_flow(new_worktree_meta(path), branch, pull, push)
except OSError as exc:
warnings.append(f"flow not written: {exc}")
return None
return {"pull": pull, "push": push}
def worktree_up(
repo_dir: str,
branch: str,
@@ -90,6 +136,8 @@ def worktree_up(
path: str | None,
hooks: dict[str, dict[str, list[str]]],
as_json: bool,
pull: list[str] | None,
push: list[str] | None,
) -> int:
"""
Check a branch out into its own worktree under the standard path.
@@ -106,6 +154,10 @@ def worktree_up(
CLI; the worktree-init hook runs only when the repository root is
pinned there, never from the Makefile or by origin's URL.
as_json: print the result as JSON instead of text.
pull: refs merged into the branch before a gate, deploy or merge, in
order, for the worktree's flow; None takes the base.
push: branches the branch is merged into when finished, in order;
None takes the base.
Returns:
0 when the worktree is ready, 1 when the worktree-init hook failed
@@ -127,6 +179,10 @@ def worktree_up(
file=sys.stderr,
)
return 2
invalid = _invalid_flow(pull, push)
if invalid:
print(f"Refusing: {invalid}", file=sys.stderr)
return 2
location = worktree_location(url)
if path:
@@ -192,6 +248,7 @@ def worktree_up(
return 2
write_slot(path, slot)
flow = _record_flow(root, path, branch, (pull, push), start or base, warnings)
hook = "none"
code = 0
@@ -214,6 +271,7 @@ def worktree_up(
"origin": origin,
"slot": slot,
"hook": hook,
"flow": flow,
"warnings": warnings,
}
if as_json:
@@ -223,4 +281,6 @@ def worktree_up(
print(f"WARN {warning}")
for key in ("path", "branch", "origin", "slot", "hook"):
print(f"{key:<8} {result[key]}")
for key, names in (flow or {}).items():
print(f"{key:<8} {', '.join(names) or NONE_LISTED}")
return code
+59 -1
View File
@@ -6,6 +6,11 @@ import sys
from pkgmgr.actions.branch.gc import main_repo_root
from pkgmgr.actions.gc.report import disk_report, render_text
from pkgmgr.actions.worktree.flow_actions import (
worktree_flow_clear,
worktree_flow_set,
worktree_flow_show,
)
from pkgmgr.actions.worktree.orphans import worktree_orphans
from pkgmgr.actions.worktree.up import worktree_up
from pkgmgr.cli.context import CLIContext
@@ -19,13 +24,61 @@ from .branch import (
)
MEBIBYTE = 1 << 20
FLOW_ACTIONS = ("set", "clear")
def _flow_usage(action: str, args) -> str | None:
if action == "show" and args.path is not None:
return f"unknown flow action {args.target!r}; use set or clear"
if action != "set":
flags = {
"--pull": args.pull,
"--push": args.push,
"--no-pull": args.no_pull,
"--no-push": args.no_push,
}
used = [flag for flag, value in flags.items() if value]
return f"{', '.join(used)} only with `flow set`" if used else None
for kind in ("pull", "push"):
if bool(getattr(args, kind)) == getattr(args, f"no_{kind}"):
return f"flow set takes either --{kind} or --no-{kind}"
return None
def _flow(args) -> int:
action, path = (
(args.target, args.path)
if args.target in FLOW_ACTIONS
else ("show", args.target)
)
problem = _flow_usage(action, args)
if problem:
print(f"Refusing: {problem}", file=sys.stderr)
return 2
target = path or "."
if action == "show":
return worktree_flow_show(target, as_json=args.json)
if action == "set":
code = worktree_flow_set(
target, pull=args.pull or [], push=args.push or [], as_json=args.json
)
else:
code = worktree_flow_clear(target, as_json=args.json)
if code == 0:
refresh_tree([main_repo_root(target)])
return code
def handle_worktree(args, ctx: CLIContext) -> None:
"""
Handle `pkgmgr worktree` subcommands.
- pkgmgr worktree up <branch> [--base REF] [--path DIR] [--json]
- pkgmgr worktree up <branch> [--base REF] [--path DIR] [--pull REF ...]
[--push BRANCH ...] [--json]
- pkgmgr worktree flow [PATH] [--json]
- pkgmgr worktree flow set [PATH] (--pull REF ... | --no-pull)
(--push BRANCH ... | --no-push) [--json]
- pkgmgr worktree flow clear [PATH] [--json]
- pkgmgr worktree cleanup [--apply] [--json] [scan options] [--min-age HOURS]
- pkgmgr worktree orphans --stale-days N --top N [--json] [scan options]
"""
@@ -38,10 +91,15 @@ def handle_worktree(args, ctx: CLIContext) -> None:
path=args.path,
hooks=pinned_hooks(ctx),
as_json=args.json,
pull=args.pull,
push=args.push,
)
refresh_tree([main_repo_root(".")])
sys.exit(code)
if args.subcommand == "flow":
sys.exit(_flow(args))
if args.subcommand == "cleanup":
run_cleanup(args, ctx, "worktree")
+59 -1
View File
@@ -5,10 +5,65 @@ import argparse
from .branch_cmd import add_apply_options, add_min_age_option, add_scan_options
def _add_flow_options(parser: argparse.ArgumentParser, default: str) -> None:
parser.add_argument(
"--pull",
action="append",
metavar="REF",
help=(
"Ref merged into the branch before a gate, deploy or merge; "
f"repeatable, kept in order ({default})"
),
)
parser.add_argument(
"--push",
action="append",
metavar="BRANCH",
help=(
"Branch the branch is merged into when finished, then pushed by "
f"the operator; repeatable, kept in order ({default})"
),
)
def _add_flow_parser(worktree_subparsers: argparse._SubParsersAction) -> None:
flow = worktree_subparsers.add_parser(
"flow",
help=(
"Show, set or clear the worktree's merge flow in "
"<git-dir>/pkgmgr/flow.yml: pull refs merged in, push branches "
"merged into"
),
)
flow.add_argument(
"target",
nargs="?",
metavar="set|clear|PATH",
help="`set` or `clear`, else the worktree to show (default: .)",
)
flow.add_argument(
"path",
nargs="?",
metavar="PATH",
help="Worktree of set or clear (default: .)",
)
_add_flow_options(flow, "set only, replaces the list")
flow.add_argument(
"--no-pull", action="store_true", help="set only: an empty pull list"
)
flow.add_argument(
"--no-push", action="store_true", help="set only: an empty push list"
)
flow.add_argument("--json", action="store_true", help="Print the flow as JSON")
def add_worktree_subparsers(subparsers: argparse._SubParsersAction) -> None:
worktree_parser = subparsers.add_parser(
"worktree",
help="Worktree utilities: create, clean up and find abandoned worktrees",
help=(
"Worktree utilities: create, keep a merge flow, clean up and find "
"abandoned worktrees"
),
)
worktree_subparsers = worktree_parser.add_subparsers(
dest="subcommand",
@@ -38,8 +93,11 @@ def add_worktree_subparsers(subparsers: argparse._SubParsersAction) -> None:
"an agent sandbox releases"
),
)
_add_flow_options(up, "default: the base, for push its local branch")
up.add_argument("--json", action="store_true", help="Print the result as JSON")
_add_flow_parser(worktree_subparsers)
cleanup = worktree_subparsers.add_parser(
"cleanup",
help=(