arctx-cli 0.2.0b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. arctx_cli/__init__.py +1 -0
  2. arctx_cli/alias.py +238 -0
  3. arctx_cli/append_batch.py +90 -0
  4. arctx_cli/commands/__init__.py +85 -0
  5. arctx_cli/commands/alias_cmd.py +174 -0
  6. arctx_cli/commands/anchor.py +82 -0
  7. arctx_cli/commands/current.py +69 -0
  8. arctx_cli/commands/cut.py +89 -0
  9. arctx_cli/commands/dump.py +72 -0
  10. arctx_cli/commands/ext.py +236 -0
  11. arctx_cli/commands/git.py +216 -0
  12. arctx_cli/commands/graph.py +73 -0
  13. arctx_cli/commands/guide.py +360 -0
  14. arctx_cli/commands/init.py +223 -0
  15. arctx_cli/commands/list.py +45 -0
  16. arctx_cli/commands/migrate.py +135 -0
  17. arctx_cli/commands/node.py +55 -0
  18. arctx_cli/commands/outcomes.py +58 -0
  19. arctx_cli/commands/payload.py +192 -0
  20. arctx_cli/commands/reachable.py +75 -0
  21. arctx_cli/commands/show.py +113 -0
  22. arctx_cli/commands/sync.py +244 -0
  23. arctx_cli/commands/trace.py +46 -0
  24. arctx_cli/commands/transition.py +212 -0
  25. arctx_cli/commands/use.py +67 -0
  26. arctx_cli/commands/view.py +82 -0
  27. arctx_cli/commands/work_session.py +330 -0
  28. arctx_cli/context.py +38 -0
  29. arctx_cli/ext/__init__.py +1 -0
  30. arctx_cli/ext/command/__init__.py +110 -0
  31. arctx_cli/ext/git/__init__.py +1 -0
  32. arctx_cli/ext/git/branch.py +140 -0
  33. arctx_cli/ext/git/cherry_pick.py +144 -0
  34. arctx_cli/ext/git/commit.py +205 -0
  35. arctx_cli/ext/git/hook.py +758 -0
  36. arctx_cli/ext/git/merge.py +204 -0
  37. arctx_cli/ext/git/reset.py +138 -0
  38. arctx_cli/ext/git/revert.py +157 -0
  39. arctx_cli/ext/git/verify.py +140 -0
  40. arctx_cli/ext/git/worktree.py +173 -0
  41. arctx_cli/ext_registry.py +34 -0
  42. arctx_cli/main.py +133 -0
  43. arctx_cli/paths.py +27 -0
  44. arctx_cli/payload_builder.py +23 -0
  45. arctx_cli/workspace.py +64 -0
  46. arctx_cli-0.2.0b2.dist-info/METADATA +48 -0
  47. arctx_cli-0.2.0b2.dist-info/RECORD +49 -0
  48. arctx_cli-0.2.0b2.dist-info/WHEEL +4 -0
  49. arctx_cli-0.2.0b2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,330 @@
1
+ """arctx CLI work-session command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ from os import environ
11
+
12
+ from arctx_cli.context import (
13
+ resolve_run_id_from_args,
14
+ resolve_store,
15
+ resolve_user_id_from_args,
16
+ )
17
+ from arctx.core.append import AppendBatch
18
+ from arctx.core.ids import opaque_id
19
+
20
+
21
+ def add_parser(subparsers) -> argparse.ArgumentParser:
22
+ parser = subparsers.add_parser(
23
+ "work-session",
24
+ help="Create, inspect, and pin work sessions for parallel work",
25
+ description=(
26
+ "Create and use work sessions. Use explicit mode with --work-session on "
27
+ "mutating commands, or fixed mode via `eval \"$(arctx work-session env "
28
+ "--run RUN --new)\"`. Fixed mode writes only shell environment variables, "
29
+ "so parallel terminals and child processes do not share the <gitdir>/arctx-id pointer."
30
+ ),
31
+ )
32
+ work_sub = parser.add_subparsers(dest="work_session_command", required=True)
33
+
34
+ start = work_sub.add_parser(
35
+ "start",
36
+ help="Create a work session and print its id",
37
+ description=(
38
+ "Create a work session in a run. This does not switch global state; pass "
39
+ "the id with --work-session or use `work-session env` for shell-local "
40
+ "fixed mode."
41
+ ),
42
+ )
43
+ start.add_argument("--run", default=None)
44
+ start.add_argument("--work-session", default=None)
45
+ start.add_argument("--user", default=None)
46
+ start.add_argument("--store-dir", default=None)
47
+ start.add_argument(
48
+ "--worktree",
49
+ default=None,
50
+ metavar="PATH",
51
+ help=(
52
+ "Attach this work session to a git worktree at PATH. The path "
53
+ "is recorded in WorkSession.metadata and exported as "
54
+ "ARCTX_GIT_WORKTREE for child processes."
55
+ ),
56
+ )
57
+ start.add_argument("--json", action="store_true", dest="as_json")
58
+
59
+ env = work_sub.add_parser(
60
+ "env",
61
+ help="Print shell exports for shell-local fixed mode",
62
+ description=(
63
+ "Print exports for ARCTX_RUN_ID, ARCTX_WORK_SESSION_ID, and ARCTX_USER_ID. "
64
+ "Use eval with this output to pin only the current shell or subprocess "
65
+ "environment, which is safe for parallel terminals."
66
+ ),
67
+ )
68
+ env.add_argument("work_session_id", nargs="?")
69
+ env.add_argument("--run", default=None)
70
+ env.add_argument("--new", action="store_true", dest="create_new")
71
+ env.add_argument("--user", default=None)
72
+ env.add_argument("--store-dir", default=None)
73
+ env.add_argument(
74
+ "--worktree",
75
+ default=None,
76
+ metavar="PATH",
77
+ help=(
78
+ "Attach the session to a git worktree at PATH. With --new the "
79
+ "path is stored on the new WorkSession; the exports include "
80
+ "ARCTX_GIT_WORKTREE=PATH so subsequent git verbs run there."
81
+ ),
82
+ )
83
+ env.add_argument("--json", action="store_true", dest="as_json")
84
+
85
+ spawn = work_sub.add_parser(
86
+ "spawn",
87
+ help="Run a command with a child-only fixed work session",
88
+ description=(
89
+ "Run a child command with ARCTX_RUN_ID, ARCTX_WORK_SESSION_ID, and "
90
+ "ARCTX_USER_ID set only in that child process. Use this for Codex, "
91
+ "Claude Code, scripts, or other parallel workers."
92
+ ),
93
+ )
94
+ spawn.add_argument("--run", default=None)
95
+ spawn.add_argument("--work-session", default=None)
96
+ spawn.add_argument("--user", default=None)
97
+ spawn.add_argument("--store-dir", default=None)
98
+ spawn.add_argument(
99
+ "--worktree",
100
+ default=None,
101
+ metavar="PATH",
102
+ help=(
103
+ "Attach the child session to a git worktree at PATH and export "
104
+ "ARCTX_GIT_WORKTREE=PATH for the child command."
105
+ ),
106
+ )
107
+ spawn.add_argument("command", nargs=argparse.REMAINDER)
108
+
109
+ list_cmd = work_sub.add_parser("list", help="List work sessions in a run")
110
+ list_cmd.add_argument("--run", default=None)
111
+ list_cmd.add_argument("--store-dir", default=None)
112
+
113
+ show = work_sub.add_parser("show", help="Show one work session")
114
+ show.add_argument("work_session_id")
115
+ show.add_argument("--run", default=None)
116
+ show.add_argument("--store-dir", default=None)
117
+
118
+ return parser
119
+
120
+
121
+ def run_work_session_start_command(
122
+ *,
123
+ run_id: str,
124
+ work_session_id: str | None,
125
+ user_id: str,
126
+ store_dir: str,
127
+ worktree: str | None = None,
128
+ ) -> dict:
129
+ store = resolve_store(store_dir)
130
+ if not store.run_path(run_id).exists():
131
+ raise KeyError(f"unknown run_id: {run_id}")
132
+ handle = store.load_run(run_id)
133
+ ws_id = work_session_id or opaque_id("ws")
134
+ metadata = _build_worktree_metadata(worktree)
135
+ session = handle.ensure_work_session(
136
+ user_id=user_id,
137
+ work_session_id=ws_id,
138
+ metadata=metadata or None,
139
+ )
140
+ if session is None:
141
+ raise RuntimeError("failed to create work session")
142
+ if hasattr(store, "append_batch"):
143
+ store.append_batch(
144
+ AppendBatch(
145
+ run_id=run_id,
146
+ user_id=user_id,
147
+ work_session_id=ws_id,
148
+ work_session=session,
149
+ records=(),
150
+ events=(),
151
+ )
152
+ )
153
+ else:
154
+ store.save_run(handle)
155
+ result = {"run_id": run_id, "work_session_id": ws_id, "user_id": user_id}
156
+ if worktree:
157
+ result["worktree"] = str(metadata["worktree"]["path"])
158
+ return result
159
+
160
+
161
+ def _build_worktree_metadata(worktree: str | None) -> dict:
162
+ """Resolve worktree path to absolute path and capture branch / repo info.
163
+
164
+ Returns the metadata dict to store on the WorkSession, or an empty
165
+ dict when no worktree was requested. The path is normalised so that
166
+ it does not depend on the caller's cwd; the branch lookup is best
167
+ effort and silently degrades when git is unavailable or the path is
168
+ not a real worktree.
169
+ """
170
+ if not worktree:
171
+ return {}
172
+ from pathlib import Path # noqa: PLC0415
173
+
174
+ path = Path(worktree).expanduser().resolve()
175
+ info: dict = {"path": str(path)}
176
+ try:
177
+ from arctx.ext.git.helpers import repo as git_repo # noqa: PLC0415
178
+
179
+ branch = git_repo.current_branch(path)
180
+ if branch is not None:
181
+ info["branch"] = branch
182
+ info["repo_common_dir"] = str(git_repo.common_dir(path))
183
+ except Exception: # noqa: BLE001
184
+ # Recording the path is still useful; git lookups are best-effort.
185
+ pass
186
+ return {"worktree": info}
187
+
188
+
189
+ def run_work_session_env_command(
190
+ *,
191
+ run_id: str,
192
+ work_session_id: str | None,
193
+ create_new: bool,
194
+ user_id: str,
195
+ store_dir: str,
196
+ worktree: str | None = None,
197
+ ) -> dict:
198
+ if create_new:
199
+ return run_work_session_start_command(
200
+ run_id=run_id,
201
+ work_session_id=work_session_id,
202
+ user_id=user_id,
203
+ store_dir=store_dir,
204
+ worktree=worktree,
205
+ )
206
+ if not work_session_id:
207
+ raise ValueError("work_session_id is required unless --new is used")
208
+ result = {"run_id": run_id, "work_session_id": work_session_id, "user_id": user_id}
209
+ if worktree:
210
+ from pathlib import Path # noqa: PLC0415
211
+
212
+ result["worktree"] = str(Path(worktree).expanduser().resolve())
213
+ return result
214
+
215
+
216
+ def run_work_session_list_command(*, run_id: str, store_dir: str) -> dict:
217
+ store = resolve_store(store_dir)
218
+ if not store.run_path(run_id).exists():
219
+ raise KeyError(f"unknown run_id: {run_id}")
220
+ handle = store.load_run(run_id)
221
+ return {
222
+ "run_id": run_id,
223
+ "work_sessions": [
224
+ session.to_dict()
225
+ for session in sorted(
226
+ handle.run_graph.work_sessions.values(),
227
+ key=lambda s: (s.started_at or "", s.work_session_id),
228
+ )
229
+ ],
230
+ }
231
+
232
+
233
+ def run_work_session_show_command(
234
+ *,
235
+ run_id: str,
236
+ work_session_id: str,
237
+ store_dir: str,
238
+ ) -> dict:
239
+ listed = run_work_session_list_command(run_id=run_id, store_dir=store_dir)
240
+ for session in listed["work_sessions"]:
241
+ if session["work_session_id"] == work_session_id:
242
+ return {"run_id": run_id, "work_session": session}
243
+ raise KeyError(f"unknown work_session_id: {work_session_id}")
244
+
245
+
246
+ def cli_work_session(args) -> int:
247
+ try:
248
+ if args.work_session_command == "start":
249
+ result = run_work_session_start_command(
250
+ run_id=resolve_run_id_from_args(args),
251
+ work_session_id=args.work_session,
252
+ user_id=resolve_user_id_from_args(args),
253
+ store_dir=args.store_dir,
254
+ worktree=getattr(args, "worktree", None),
255
+ )
256
+ _print_result(result, as_json=args.as_json)
257
+ return 0
258
+
259
+ if args.work_session_command == "env":
260
+ result = run_work_session_env_command(
261
+ run_id=resolve_run_id_from_args(args),
262
+ work_session_id=args.work_session_id,
263
+ create_new=args.create_new,
264
+ user_id=resolve_user_id_from_args(args),
265
+ store_dir=args.store_dir,
266
+ worktree=getattr(args, "worktree", None),
267
+ )
268
+ if args.as_json:
269
+ print(json.dumps(result, ensure_ascii=False, indent=2))
270
+ else:
271
+ print(_env_exports(result))
272
+ return 0
273
+
274
+ if args.work_session_command == "spawn":
275
+ if not args.command:
276
+ raise ValueError("spawn requires a command after --")
277
+ result = run_work_session_start_command(
278
+ run_id=resolve_run_id_from_args(args),
279
+ work_session_id=args.work_session,
280
+ user_id=resolve_user_id_from_args(args),
281
+ store_dir=args.store_dir,
282
+ worktree=getattr(args, "worktree", None),
283
+ )
284
+ child_env = dict(environ)
285
+ child_env["ARCTX_RUN_ID"] = result["run_id"]
286
+ child_env["ARCTX_WORK_SESSION_ID"] = result["work_session_id"]
287
+ child_env["ARCTX_USER_ID"] = result["user_id"]
288
+ if result.get("worktree"):
289
+ child_env["ARCTX_GIT_WORKTREE"] = result["worktree"]
290
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
291
+ return subprocess.call(command, env=child_env)
292
+
293
+ if args.work_session_command == "list":
294
+ result = run_work_session_list_command(
295
+ run_id=resolve_run_id_from_args(args),
296
+ store_dir=args.store_dir,
297
+ )
298
+ print(json.dumps(result, ensure_ascii=False, indent=2))
299
+ return 0
300
+
301
+ if args.work_session_command == "show":
302
+ result = run_work_session_show_command(
303
+ run_id=resolve_run_id_from_args(args),
304
+ work_session_id=args.work_session_id,
305
+ store_dir=args.store_dir,
306
+ )
307
+ print(json.dumps(result, ensure_ascii=False, indent=2))
308
+ return 0
309
+ except (KeyError, RuntimeError, ValueError) as exc:
310
+ print(f"error: {exc}", file=sys.stderr)
311
+ return 2
312
+ return 1
313
+
314
+
315
+ def _print_result(result: dict, *, as_json: bool) -> None:
316
+ if as_json:
317
+ print(json.dumps(result, ensure_ascii=False, indent=2))
318
+ else:
319
+ print(result["work_session_id"])
320
+
321
+
322
+ def _env_exports(result: dict) -> str:
323
+ lines = [
324
+ f"export ARCTX_RUN_ID={shlex.quote(result['run_id'])}",
325
+ f"export ARCTX_WORK_SESSION_ID={shlex.quote(result['work_session_id'])}",
326
+ f"export ARCTX_USER_ID={shlex.quote(result['user_id'])}",
327
+ ]
328
+ if result.get("worktree"):
329
+ lines.append(f"export ARCTX_GIT_WORKTREE={shlex.quote(result['worktree'])}")
330
+ return "\n".join(lines)
arctx_cli/context.py ADDED
@@ -0,0 +1,38 @@
1
+ """CLI current-run context persistence.
2
+
3
+ Session-level resolution logic (resolve_store, resolve_run_id, resolve_user_id,
4
+ resolve_work_session_id, _config_path, RunHandleProxy, ExtensionAwareStore) now
5
+ lives in arctx.session. This module re-exports those names so existing CLI
6
+ code keeps working, and adds the argparse-Namespace helpers that belong here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from arctx.session import ( # noqa: F401
12
+ ExtensionAwareStore,
13
+ RunHandleProxy,
14
+ _config_path,
15
+ resolve_run_id,
16
+ resolve_store,
17
+ resolve_user_id,
18
+ resolve_work_session_id,
19
+ )
20
+
21
+
22
+ def resolve_run_id_from_args(args) -> str:
23
+ """Resolve a run_id from a parsed argparse namespace.
24
+
25
+ Reads the ``--run`` flag and falls back to the env var and the
26
+ ``<gitdir>/arctx-id`` pointer.
27
+ """
28
+ return resolve_run_id(getattr(args, "run", None))
29
+
30
+
31
+ def resolve_user_id_from_args(args) -> str:
32
+ """Resolve user attribution from parsed CLI args."""
33
+ return resolve_user_id(getattr(args, "user", None))
34
+
35
+
36
+ def resolve_work_session_id_from_args(args) -> str:
37
+ """Resolve work-session attribution from parsed CLI args."""
38
+ return resolve_work_session_id(getattr(args, "work_session", None))
@@ -0,0 +1 @@
1
+ """CLI-side extension subpackages."""
@@ -0,0 +1,110 @@
1
+ """CLI commands for the command extension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from arctx_cli.append_batch import graph_counts, maybe_append_or_save
10
+ from arctx_cli.context import (
11
+ resolve_run_id_from_args,
12
+ resolve_store,
13
+ resolve_user_id_from_args,
14
+ resolve_work_session_id_from_args,
15
+ )
16
+ from arctx.ext.command import CommandNamespace
17
+
18
+
19
+ def add_parser(subparsers) -> argparse.ArgumentParser:
20
+ parser = subparsers.add_parser("command", help="Run external commands")
21
+ command_sub = parser.add_subparsers(dest="command_command", required=True)
22
+
23
+ sp_run = command_sub.add_parser(
24
+ "run",
25
+ help="Run an external command and record its result",
26
+ )
27
+ sp_run.add_argument("--run", default=None)
28
+ sp_run.add_argument("--store-dir", default=None)
29
+ sp_run.add_argument("--user", default=None)
30
+ sp_run.add_argument("--work-session", default=None)
31
+ sp_run.add_argument("--cwd", default=None)
32
+ sp_run.add_argument("--max-output-chars", type=int, default=20000)
33
+ sp_run.add_argument(
34
+ "argv",
35
+ nargs=argparse.REMAINDER,
36
+ help="Command to execute. Prefix with -- when needed.",
37
+ )
38
+ return parser
39
+
40
+
41
+ def run_command_run_command(
42
+ *,
43
+ run_id: str,
44
+ store_dir: str,
45
+ argv: list[str],
46
+ cwd: str | None = None,
47
+ user_id: str | None = None,
48
+ work_session_id: str | None = None,
49
+ max_output_chars: int = 20000,
50
+ ) -> dict[str, object]:
51
+ store = resolve_store(store_dir)
52
+ if not store.run_path(run_id).exists():
53
+ raise KeyError(f"unknown run_id: {run_id}")
54
+ handle = store.load_run(run_id)
55
+ command_ns = getattr(handle, "command", None)
56
+ if not isinstance(command_ns, CommandNamespace):
57
+ raise RuntimeError("command extension is not enabled for this run")
58
+
59
+ before = graph_counts(handle)
60
+ result = command_ns.run(
61
+ command=argv,
62
+ cwd=cwd,
63
+ user_id=user_id,
64
+ work_session_id=work_session_id,
65
+ max_output_chars=max_output_chars,
66
+ )
67
+ maybe_append_or_save(
68
+ store=store,
69
+ handle=handle,
70
+ user_id=user_id,
71
+ work_session_id=work_session_id,
72
+ before=before,
73
+ )
74
+ return {
75
+ "transition": result["transition"].to_dict(),
76
+ "output_node": result["output_node"].to_dict(),
77
+ "payload": result["payload"].to_dict(),
78
+ "exit_code": result["exit_code"],
79
+ }
80
+
81
+
82
+ def cli_command(args) -> int:
83
+ try:
84
+ if args.command_command == "run":
85
+ argv = _strip_separator(list(args.argv))
86
+ if not argv:
87
+ print("error: command run requires a command after --", file=sys.stderr)
88
+ return 2
89
+ result = run_command_run_command(
90
+ run_id=resolve_run_id_from_args(args),
91
+ store_dir=args.store_dir,
92
+ argv=argv,
93
+ cwd=args.cwd,
94
+ user_id=resolve_user_id_from_args(args),
95
+ work_session_id=resolve_work_session_id_from_args(args),
96
+ max_output_chars=args.max_output_chars,
97
+ )
98
+ print(json.dumps(result, ensure_ascii=False, indent=2))
99
+ return int(result["exit_code"])
100
+ print(f"unknown command subcommand: {args.command_command}", file=sys.stderr)
101
+ return 2
102
+ except Exception as exc: # noqa: BLE001
103
+ print(f"error: {exc}", file=sys.stderr)
104
+ return 1
105
+
106
+
107
+ def _strip_separator(argv: list[str]) -> list[str]:
108
+ if argv and argv[0] == "--":
109
+ return argv[1:]
110
+ return argv
@@ -0,0 +1 @@
1
+ """Git CLI commands."""
@@ -0,0 +1,140 @@
1
+ """arctx CLI branch command.
2
+
3
+ Provides:
4
+ arctx branch list — list all known branches and their tip node IDs
5
+ arctx branch show NAME — show tip, members count, and BranchPayload transitions
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+
14
+ from arctx_cli.context import resolve_run_id_from_args, resolve_store
15
+ from arctx.core.schema.work_helpers import BRANCH_TIP_EVENT
16
+ from arctx.ext.git.queries import branch_members
17
+
18
+
19
+ def add_parser(subparsers) -> argparse.ArgumentParser:
20
+ """Register the ``branch`` subcommand parser."""
21
+ p = subparsers.add_parser("branch", help="Inspect git branches recorded in arctx")
22
+ branch_sub = p.add_subparsers(dest="branch_command", required=True)
23
+
24
+ sp_list = branch_sub.add_parser("list", help="List all known branches")
25
+ sp_list.add_argument("--run", default=None)
26
+ sp_list.add_argument("--store-dir", default=None)
27
+
28
+ sp_show = branch_sub.add_parser("show", help="Show branch tip and members")
29
+ sp_show.add_argument("name", help="Branch name")
30
+ sp_show.add_argument("--run", default=None)
31
+ sp_show.add_argument("--store-dir", default=None)
32
+
33
+ return p
34
+
35
+
36
+ def _all_branches(graph) -> dict[str, str]:
37
+ """Return a mapping of branch_name → tip_node_id from BranchTipEvents.
38
+
39
+ The latest event per branch wins (events are in append order).
40
+ """
41
+ tips: dict[str, str] = {}
42
+ for event in graph.work_events:
43
+ if event.event_type == BRANCH_TIP_EVENT:
44
+ branch = str(event.data.get("branch", ""))
45
+ tip = str(event.data.get("tip_node_id", ""))
46
+ if branch:
47
+ tips[branch] = tip
48
+ return tips
49
+
50
+
51
+ def run_branch_list_command(*, run_id: str | None, store_dir: str | None) -> list[dict]:
52
+ """Return a list of all branches with their current tip node IDs.
53
+
54
+ Returns
55
+ -------
56
+ List of dicts with keys ``branch`` and ``tip_node_id``.
57
+ """
58
+ store = resolve_store(store_dir)
59
+ handle = store.load_run(run_id)
60
+ branches = _all_branches(handle.run_graph)
61
+ return [{"branch": b, "tip_node_id": t} for b, t in sorted(branches.items())]
62
+
63
+
64
+ def run_branch_show_command(
65
+ *, name: str, run_id: str | None, store_dir: str | None
66
+ ) -> dict:
67
+ """Return detailed info for a single branch.
68
+
69
+ Returns
70
+ -------
71
+ dict with keys:
72
+ - branch: str
73
+ - tip_node_id: str | None
74
+ - members_count: int
75
+ - members_sample: list[str] (up to 10 node IDs)
76
+ - transitions: list[dict] for transitions that carry a BranchPayload
77
+ targeting this branch
78
+ """
79
+ store = resolve_store(store_dir)
80
+ handle = store.load_run(run_id)
81
+ graph = handle.run_graph
82
+
83
+ branches = _all_branches(graph)
84
+ tip_node_id: str | None = branches.get(name)
85
+
86
+ members: set[str] = set()
87
+ if tip_node_id:
88
+ members = branch_members(graph, name)
89
+
90
+ # Find transitions with BranchPayload(branch=name).
91
+ branch_transitions = []
92
+ for t_id, transition in graph.transitions.items():
93
+ for p in graph.payloads_for_transition(t_id, payload_type="branch"):
94
+ if getattr(p, "branch", None) == name:
95
+ # Collect associated GitChangePayload info.
96
+ git_payloads = graph.payloads_for_transition(t_id, payload_type="git_change")
97
+ head_commit = git_payloads[-1].head_commit if git_payloads else None
98
+ branch_transitions.append(
99
+ {
100
+ "transition_id": t_id,
101
+ "output_node_id": transition.output_node_id,
102
+ "head_commit": head_commit,
103
+ "branch_payload_id": p.payload_id,
104
+ }
105
+ )
106
+
107
+ return {
108
+ "branch": name,
109
+ "tip_node_id": tip_node_id,
110
+ "members_count": len(members),
111
+ "members_sample": sorted(members)[:10],
112
+ "transitions": branch_transitions,
113
+ }
114
+
115
+
116
+ def cli_branch(args) -> int:
117
+ """Entry point for ``arctx branch`` subcommand."""
118
+ run_id = resolve_run_id_from_args(args)
119
+
120
+ if args.branch_command == "list":
121
+ try:
122
+ result = run_branch_list_command(run_id=run_id, store_dir=args.store_dir)
123
+ except Exception as exc: # noqa: BLE001
124
+ print(f"error: {exc}", file=sys.stderr)
125
+ return 1
126
+ print(json.dumps(result, indent=2))
127
+ return 0
128
+
129
+ if args.branch_command == "show":
130
+ try:
131
+ result = run_branch_show_command(
132
+ name=args.name, run_id=run_id, store_dir=args.store_dir
133
+ )
134
+ except Exception as exc: # noqa: BLE001
135
+ print(f"error: {exc}", file=sys.stderr)
136
+ return 1
137
+ print(json.dumps(result, indent=2))
138
+ return 0
139
+
140
+ return 1