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.
- arctx_cli/__init__.py +1 -0
- arctx_cli/alias.py +238 -0
- arctx_cli/append_batch.py +90 -0
- arctx_cli/commands/__init__.py +85 -0
- arctx_cli/commands/alias_cmd.py +174 -0
- arctx_cli/commands/anchor.py +82 -0
- arctx_cli/commands/current.py +69 -0
- arctx_cli/commands/cut.py +89 -0
- arctx_cli/commands/dump.py +72 -0
- arctx_cli/commands/ext.py +236 -0
- arctx_cli/commands/git.py +216 -0
- arctx_cli/commands/graph.py +73 -0
- arctx_cli/commands/guide.py +360 -0
- arctx_cli/commands/init.py +223 -0
- arctx_cli/commands/list.py +45 -0
- arctx_cli/commands/migrate.py +135 -0
- arctx_cli/commands/node.py +55 -0
- arctx_cli/commands/outcomes.py +58 -0
- arctx_cli/commands/payload.py +192 -0
- arctx_cli/commands/reachable.py +75 -0
- arctx_cli/commands/show.py +113 -0
- arctx_cli/commands/sync.py +244 -0
- arctx_cli/commands/trace.py +46 -0
- arctx_cli/commands/transition.py +212 -0
- arctx_cli/commands/use.py +67 -0
- arctx_cli/commands/view.py +82 -0
- arctx_cli/commands/work_session.py +330 -0
- arctx_cli/context.py +38 -0
- arctx_cli/ext/__init__.py +1 -0
- arctx_cli/ext/command/__init__.py +110 -0
- arctx_cli/ext/git/__init__.py +1 -0
- arctx_cli/ext/git/branch.py +140 -0
- arctx_cli/ext/git/cherry_pick.py +144 -0
- arctx_cli/ext/git/commit.py +205 -0
- arctx_cli/ext/git/hook.py +758 -0
- arctx_cli/ext/git/merge.py +204 -0
- arctx_cli/ext/git/reset.py +138 -0
- arctx_cli/ext/git/revert.py +157 -0
- arctx_cli/ext/git/verify.py +140 -0
- arctx_cli/ext/git/worktree.py +173 -0
- arctx_cli/ext_registry.py +34 -0
- arctx_cli/main.py +133 -0
- arctx_cli/paths.py +27 -0
- arctx_cli/payload_builder.py +23 -0
- arctx_cli/workspace.py +64 -0
- arctx_cli-0.2.0b2.dist-info/METADATA +48 -0
- arctx_cli-0.2.0b2.dist-info/RECORD +49 -0
- arctx_cli-0.2.0b2.dist-info/WHEEL +4 -0
- 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
|