gitgrip 1.5.0__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 (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. gr2_overlay/__init__.py +37 -0
gr2/python_cli/app.py ADDED
@@ -0,0 +1,2516 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import io
5
+ import json
6
+ import os
7
+ import tomllib
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+ from typing import List, Optional
11
+
12
+ import typer
13
+ from gr2.prototypes import lane_workspace_prototype as lane_proto
14
+ from gr2.prototypes import repo_maintenance_prototype as repo_proto
15
+
16
+ from . import add as add_ops
17
+ from . import branch as branch_ops
18
+ from . import commit as commit_ops
19
+ from . import execops, failures, grip, migration, spec_apply, syncops
20
+ from . import pr as pr_ops
21
+ from . import prune as prune_ops
22
+ from . import target as target_ops
23
+ from . import project_review
24
+ from . import push as push_ops
25
+ from .events import EventType, emit_after_outcome
26
+ from .gitops import (
27
+ branch_exists,
28
+ checkout_branch,
29
+ ensure_lane_checkout,
30
+ fetch_ref,
31
+ git,
32
+ is_git_repo,
33
+ refresh_existing_branch,
34
+ remote_origin_url,
35
+ repo_dirty,
36
+ stash_if_dirty,
37
+ )
38
+ from .grip_cli import config_cli_app, grip_app
39
+ from .hooks import (
40
+ HookContext,
41
+ HookRuntimeError,
42
+ apply_file_projections,
43
+ load_repo_hooks,
44
+ run_lifecycle_stage,
45
+ )
46
+ from .merge_verification import MergeVerificationTarget
47
+ from .platform import PRRef, get_platform_adapter
48
+
49
+ app = typer.Typer(
50
+ help="Python-first gr2 CLI. This is the production UX proving layer before Rust."
51
+ )
52
+ repo_app = typer.Typer(help="Repo maintenance and inspection")
53
+ lane_app = typer.Typer(help="Lane creation and navigation")
54
+ lease_app = typer.Typer(help="Lane lease operations")
55
+ review_app = typer.Typer(help="Review and reviewer requirement operations")
56
+ pr_app = typer.Typer(help="Cross-repo PR orchestration")
57
+ workspace_app = typer.Typer(help="Workspace bootstrap and materialization")
58
+ spec_app = typer.Typer(help="Declarative workspace spec operations")
59
+ exec_app = typer.Typer(help="Lane-aware execution planning and execution")
60
+ sync_app = typer.Typer(help="Workspace-wide sync inspection and execution")
61
+ target_app = typer.Typer(help="Stored PR target (settings.target) that prune and future verbs default to")
62
+
63
+ app.add_typer(repo_app, name="repo")
64
+ app.add_typer(lane_app, name="lane")
65
+ lane_app.add_typer(lease_app, name="lease")
66
+ app.add_typer(review_app, name="review")
67
+ app.add_typer(pr_app, name="pr")
68
+ app.add_typer(workspace_app, name="workspace")
69
+ app.add_typer(spec_app, name="spec")
70
+ app.add_typer(exec_app, name="exec")
71
+ app.add_typer(sync_app, name="sync")
72
+ app.add_typer(target_app, name="target")
73
+ app.add_typer(grip_app, name="grip")
74
+ app.add_typer(config_cli_app, name="config")
75
+
76
+
77
+ def _workspace_repo_spec(workspace_root: Path, repo_name: str) -> dict[str, object]:
78
+ spec = lane_proto.load_workspace_spec(workspace_root)
79
+ for repo in spec.get("repos", []):
80
+ if repo.get("name") == repo_name:
81
+ return repo
82
+ raise SystemExit(f"repo not found in workspace spec: {repo_name}")
83
+
84
+
85
+ def _workspace_spec_path(workspace_root: Path) -> Path:
86
+ return workspace_root / ".grip" / "workspace_spec.toml"
87
+
88
+
89
+ def _lane_repo_root(workspace_root: Path, owner_unit: str, lane_name: str, repo_name: str) -> Path:
90
+ return lane_proto.lane_dir(workspace_root, owner_unit, lane_name) / "repos" / repo_name
91
+
92
+
93
+ def _materialize_lane_repos(workspace_root: Path, owner_unit: str, lane_name: str, *, manual_hooks: bool = False) -> None:
94
+ lane_doc = lane_proto.load_lane_doc(workspace_root, owner_unit, lane_name)
95
+ branch_map = dict(lane_doc.get("branch_map", {}))
96
+ lane_root = lane_proto.lane_dir(workspace_root, owner_unit, lane_name)
97
+ fork_base: dict[str, dict[str, str]] = {}
98
+
99
+ # The fork base of every repo whose checkout exists is persisted in the `finally`
100
+ # below, INDEPENDENT of the hooks' outcome. A projection whose source is missing
101
+ # raises HookRuntimeError (exit 1) from `apply_file_projections`; recording only
102
+ # after the loop meant a blocked hook left a half lane -- lane.toml and the
103
+ # checkout present, but no fork_base -- and `review create-project` then refused
104
+ # with "no recorded fork base", the same symptom as a lane that never recorded one
105
+ # from an unrelated cause. Each repo's base is collected
106
+ # BEFORE its own hooks run, so the finally captures every materialized repo,
107
+ # including the one whose hook just blocked, while the exit 1 and its JSON report
108
+ # still propagate.
109
+ try:
110
+ for repo_name in lane_doc.get("repos", []):
111
+ repo_spec = _workspace_repo_spec(workspace_root, repo_name)
112
+ source_repo_root = (workspace_root / str(repo_spec["path"])).resolve()
113
+ if not source_repo_root.exists():
114
+ raise SystemExit(f"source repo path does not exist for lane materialization: {source_repo_root}")
115
+ target_repo_root = _lane_repo_root(workspace_root, owner_unit, lane_name, repo_name)
116
+ first_materialize = ensure_lane_checkout(
117
+ source_repo_root=source_repo_root,
118
+ target_repo_root=target_repo_root,
119
+ branch=branch_map[repo_name],
120
+ workspace_root=workspace_root,
121
+ )
122
+ # Record the fork base = the materialization point (the branch the lane forked
123
+ # from and the sha it started at), so `review create-project` can pin base..head
124
+ # Collected here, before this repo's hooks run below.
125
+ head = git(target_repo_root, "rev-parse", "HEAD")
126
+ head_sha = head.stdout.strip() if head.returncode == 0 else ""
127
+ if len(head_sha) == 40:
128
+ fork_base[repo_name] = {"branch": branch_map[repo_name], "sha": head_sha}
129
+ hooks = load_repo_hooks(target_repo_root)
130
+ if not hooks:
131
+ continue
132
+ ctx = HookContext(
133
+ workspace_root=workspace_root,
134
+ unit_root=lane_proto.lane_state_root(workspace_root) / owner_unit,
135
+ lane_root=lane_root,
136
+ repo_root=target_repo_root,
137
+ repo_name=repo_name,
138
+ lane_owner=owner_unit,
139
+ lane_subject=repo_name,
140
+ lane_name=lane_name,
141
+ )
142
+ apply_file_projections(hooks, ctx)
143
+ run_lifecycle_stage(
144
+ hooks,
145
+ "on_materialize",
146
+ ctx,
147
+ repo_dirty=repo_dirty(target_repo_root),
148
+ first_materialize=first_materialize,
149
+ allow_manual=manual_hooks,
150
+ )
151
+ finally:
152
+ # Persist the fork base for every repo materialized above, even if a hook
153
+ # raised on the way out. Without this a materialized lane can have no
154
+ # fork_base and `review create-project` refuses.
155
+ if fork_base:
156
+ lane_proto.record_fork_base(workspace_root, owner_unit, lane_name, fork_base)
157
+
158
+
159
+ def _run_lane_stage(workspace_root: Path, owner_unit: str, lane_name: str, stage: str, *, manual_hooks: bool = False) -> None:
160
+ lane_doc = lane_proto.load_lane_doc(workspace_root, owner_unit, lane_name)
161
+ lane_root = lane_proto.lane_dir(workspace_root, owner_unit, lane_name)
162
+ for repo_name in lane_doc.get("repos", []):
163
+ repo_root = _lane_repo_root(workspace_root, owner_unit, lane_name, repo_name)
164
+ if not repo_root.exists():
165
+ continue
166
+ branch = dict(lane_doc.get("branch_map", {})).get(repo_name)
167
+ if branch:
168
+ checkout_branch(repo_root, branch)
169
+ hooks = load_repo_hooks(repo_root)
170
+ if not hooks:
171
+ continue
172
+ ctx = HookContext(
173
+ workspace_root=workspace_root,
174
+ unit_root=lane_proto.lane_state_root(workspace_root) / owner_unit,
175
+ lane_root=lane_root,
176
+ repo_root=repo_root,
177
+ repo_name=repo_name,
178
+ lane_owner=owner_unit,
179
+ lane_subject=repo_name,
180
+ lane_name=lane_name,
181
+ )
182
+ run_lifecycle_stage(
183
+ hooks,
184
+ stage,
185
+ ctx,
186
+ repo_dirty=repo_dirty(repo_root),
187
+ first_materialize=False,
188
+ allow_manual=manual_hooks,
189
+ )
190
+
191
+
192
+ def _prepare_review_branch(workspace_root: Path, repo: str, pr_number: int, branch: str | None) -> str:
193
+ repo_spec = _workspace_repo_spec(workspace_root, repo)
194
+ repo_root = (workspace_root / str(repo_spec["path"])).resolve()
195
+ if not repo_root.exists():
196
+ raise SystemExit(f"shared repo missing for review checkout: {repo_root}\nrun `gr2 apply {workspace_root} --yes` first")
197
+
198
+ target_branch = branch or f"pr/{pr_number}"
199
+ source_ref = f"refs/heads/{branch}" if branch else f"refs/pull/{pr_number}/head"
200
+
201
+ if branch_exists(repo_root, target_branch):
202
+ refresh_existing_branch(repo_root, "origin", source_ref, target_branch)
203
+ return target_branch
204
+
205
+ if branch:
206
+ fetch_ref(repo_root, "origin", source_ref, target_branch)
207
+ return target_branch
208
+
209
+ fetch_ref(repo_root, "origin", source_ref, target_branch)
210
+ return target_branch
211
+
212
+
213
+ def _create_review_lane_metadata(
214
+ workspace_root: Path,
215
+ owner_unit: str,
216
+ repo: str,
217
+ pr_number: int,
218
+ *,
219
+ lane_name: str | None = None,
220
+ branch: str | None = None,
221
+ ) -> str:
222
+ review_lane = lane_name or f"review-{pr_number}"
223
+ review_branch = branch or f"pr/{pr_number}"
224
+ ns = SimpleNamespace(
225
+ workspace_root=workspace_root,
226
+ owner_unit=owner_unit,
227
+ repo=repo,
228
+ pr_number=pr_number,
229
+ lane_name=review_lane,
230
+ branch=review_branch,
231
+ )
232
+ with contextlib.redirect_stdout(io.StringIO()):
233
+ _exit(lane_proto.create_review_lane(ns))
234
+ return review_lane
235
+
236
+
237
+ def _repo_hook_context(workspace_root: Path, repo_root: Path) -> HookContext:
238
+ return HookContext(
239
+ workspace_root=workspace_root,
240
+ unit_root=workspace_root,
241
+ lane_root=repo_root,
242
+ repo_root=repo_root,
243
+ repo_name=repo_root.name,
244
+ lane_owner="workspace",
245
+ lane_subject=repo_root.name,
246
+ lane_name="workspace",
247
+ )
248
+
249
+
250
+ def _resolve_lane_name(workspace_root: Path, owner_unit: str, lane_name: Optional[str]) -> str:
251
+ if lane_name:
252
+ return lane_name
253
+ current_doc = lane_proto.require_current_lane(workspace_root, owner_unit)
254
+ return str(current_doc["lane_name"])
255
+
256
+
257
+ def _find_pr_group(workspace_root: Path, owner_unit: str, lane_name: str) -> tuple[Path, dict[str, object]]:
258
+ root = workspace_root / ".grip" / "pr_groups"
259
+ if not root.exists():
260
+ raise SystemExit(f"pr group not found for {owner_unit}/{lane_name}: {root}")
261
+ for path in sorted(root.glob("*.json")):
262
+ doc = json.loads(path.read_text())
263
+ if doc.get("owner_unit") == owner_unit and doc.get("lane_name") == lane_name:
264
+ return path, doc
265
+ raise SystemExit(f"pr group not found for {owner_unit}/{lane_name}: {root}")
266
+
267
+
268
+ def _group_state_from_statuses(statuses: list[dict[str, object]]) -> str:
269
+ states = [str(item.get("state", "")).upper() for item in statuses]
270
+ if not states:
271
+ return "empty"
272
+ if all(state == "MERGED" for state in states):
273
+ return "merged"
274
+ if any(state == "MERGED" for state in states):
275
+ return "partially_merged"
276
+ if all(state in {"OPEN", "MERGEABLE", "CLEAN"} for state in states):
277
+ return "open"
278
+ return "mixed"
279
+
280
+
281
+ def _repo_slug_from_url(url: str, fallback_name: str) -> str:
282
+ cleaned = url.strip()
283
+ if cleaned.startswith("git@github.com:"):
284
+ slug = cleaned.split("git@github.com:", 1)[1]
285
+ return slug.removesuffix(".git")
286
+ if cleaned.startswith("https://github.com/"):
287
+ slug = cleaned.split("https://github.com/", 1)[1]
288
+ return slug.removesuffix(".git")
289
+ return fallback_name
290
+
291
+
292
+ def _merge_verification_targets(
293
+ workspace_root: Path,
294
+ ) -> dict[str, MergeVerificationTarget]:
295
+ """Bind host slugs to explicit local DAGs and source URLs before merging."""
296
+
297
+ workspace_spec = lane_proto.load_workspace_spec(workspace_root)
298
+ targets: dict[str, MergeVerificationTarget] = {}
299
+ for repo_spec_value in workspace_spec.get("repos", []):
300
+ repo_spec = dict(repo_spec_value)
301
+ repo_name = str(repo_spec.get("name", ""))
302
+ remote = str(repo_spec.get("url", "")).strip()
303
+ if not remote:
304
+ raise SystemExit(f"repo has no source URL for merge verification: {repo_name}")
305
+ host_repo = _repo_slug_from_url(remote, repo_name)
306
+ if host_repo in targets:
307
+ raise SystemExit(f"duplicate host repo in merge verification targets: {host_repo}")
308
+ repo_root = (workspace_root / str(repo_spec.get("path", ""))).resolve()
309
+ if not repo_root.is_dir() or not is_git_repo(repo_root):
310
+ raise SystemExit(f"local merge-verification DAG is unavailable: {repo_root}")
311
+ targets[host_repo] = MergeVerificationTarget(repo_root=repo_root, remote=remote)
312
+ return targets
313
+
314
+
315
+ def _configured_merge_method(workspace_root: Path) -> str | None:
316
+ settings = lane_proto.load_workspace_spec(workspace_root).get("settings", {})
317
+ if not isinstance(settings, dict):
318
+ raise SystemExit("workspace spec [settings] must be a table")
319
+ value = settings.get("merge_method")
320
+ if value is None:
321
+ return None
322
+ if not isinstance(value, str):
323
+ raise SystemExit("workspace setting merge_method must be a string")
324
+ return value
325
+
326
+
327
+ def _find_workspace_root(start: Path) -> Path | None:
328
+ """Nearest ancestor of `start` (inclusive) holding a gr2 workspace spec, else None.
329
+
330
+ prune runs single-repo (cwd/--repo-path) but the stored PR target lives in the
331
+ WORKSPACE spec, so we walk up to find it. No spec found -- a bare repo, or gr2
332
+ used outside a gripspace -- returns None, and the stored-target step is skipped
333
+ so prune keeps working standalone.
334
+ """
335
+ for candidate in (start, *start.parents):
336
+ if (candidate / ".grip" / "workspace_spec.toml").is_file():
337
+ return candidate
338
+ return None
339
+
340
+
341
+ def _configured_target(workspace_root: Path) -> str | None:
342
+ """The gripspace's stored PR target (`[settings].target`), read never written."""
343
+ settings = lane_proto.load_workspace_spec(workspace_root).get("settings", {})
344
+ if not isinstance(settings, dict):
345
+ raise SystemExit("workspace spec [settings] must be a table")
346
+ value = settings.get("target")
347
+ if value is None:
348
+ return None
349
+ if not isinstance(value, str):
350
+ raise SystemExit("workspace setting target must be a string")
351
+ return value
352
+
353
+
354
+ def _toml_basic_string(value: str) -> str:
355
+ """Render one string through the TOML basic-string grammar.
356
+
357
+ WorkspaceSpec values are operator-controlled at several call sites. Keeping
358
+ the escaping here makes every value written by ``_write_workspace_spec``
359
+ parseable, rather than relying on each caller to reject a partial set of
360
+ characters.
361
+ """
362
+ if not isinstance(value, str):
363
+ raise TypeError(f"TOML basic string requires str, got {type(value).__name__}")
364
+
365
+ escapes = {
366
+ '"': '\\"',
367
+ "\\": "\\\\",
368
+ "\b": "\\b",
369
+ "\t": "\\t",
370
+ "\n": "\\n",
371
+ "\f": "\\f",
372
+ "\r": "\\r",
373
+ }
374
+ rendered: list[str] = ['"']
375
+ for character in value:
376
+ code_point = ord(character)
377
+ if character in escapes:
378
+ rendered.append(escapes[character])
379
+ elif code_point < 0x20 or code_point == 0x7F:
380
+ rendered.append(f"\\u{code_point:04X}")
381
+ elif 0xD800 <= code_point <= 0xDFFF:
382
+ raise ValueError("TOML basic strings cannot contain surrogate code points")
383
+ else:
384
+ rendered.append(character)
385
+ rendered.append('"')
386
+ return "".join(rendered)
387
+
388
+
389
+ def _write_workspace_spec(
390
+ workspace_root: Path,
391
+ repos: list[dict[str, str]],
392
+ default_unit: str,
393
+ *,
394
+ workspace_name: str | None = None,
395
+ ) -> Path:
396
+ spec_path = _workspace_spec_path(workspace_root)
397
+ emitted_workspace_name = workspace_root.name if workspace_name is None else workspace_name
398
+ lines = [
399
+ f"workspace_name = {_toml_basic_string(emitted_workspace_name)}",
400
+ "",
401
+ ]
402
+ for repo in repos:
403
+ lines.extend(
404
+ [
405
+ "[[repos]]",
406
+ f"name = {_toml_basic_string(repo['name'])}",
407
+ f"path = {_toml_basic_string(repo['path'])}",
408
+ f"url = {_toml_basic_string(repo['url'])}",
409
+ "",
410
+ ]
411
+ )
412
+ lines.extend(
413
+ [
414
+ "[[units]]",
415
+ f"name = {_toml_basic_string(default_unit)}",
416
+ f"path = {_toml_basic_string(f'agents/{default_unit}/home')}",
417
+ "repos = [" + ", ".join(_toml_basic_string(repo["name"]) for repo in repos) + "]",
418
+ "",
419
+ ]
420
+ )
421
+ spec_path.parent.mkdir(parents=True, exist_ok=True)
422
+ spec_path.write_text("\n".join(lines))
423
+ return spec_path
424
+
425
+
426
+ def _scan_existing_repos(workspace_root: Path) -> list[dict[str, str]]:
427
+ repos: list[dict[str, str]] = []
428
+ for child in sorted(workspace_root.iterdir()):
429
+ if child.name.startswith("."):
430
+ continue
431
+ if child.name == "agents":
432
+ continue
433
+ if not child.is_dir():
434
+ continue
435
+ if not is_git_repo(child):
436
+ continue
437
+ url = remote_origin_url(child)
438
+ repos.append(
439
+ {
440
+ "name": child.name,
441
+ "path": child.relative_to(workspace_root).as_posix(),
442
+ "url": url or "",
443
+ }
444
+ )
445
+ return repos
446
+
447
+
448
+ def _declared_workspace_topology(
449
+ workspace_root: Path,
450
+ ) -> tuple[str | None, list[dict[str, str]]]:
451
+ """Lower neutral ``workspace.toml`` repository declarations for gr2.
452
+
453
+ This deliberately reads only the fields the existing WorkspaceSpec writer
454
+ accepts. Declarations can carry fields such as ``default_ref`` as well,
455
+ but those do not belong in the WorkspaceSpec emission path.
456
+ """
457
+ topology_path = workspace_root / "workspace.toml"
458
+ if not topology_path.is_file():
459
+ raise SystemExit(f"workspace topology not found: {topology_path}")
460
+ try:
461
+ with topology_path.open("rb") as topology_file:
462
+ document = tomllib.load(topology_file)
463
+ except tomllib.TOMLDecodeError as exc:
464
+ raise SystemExit(f"workspace.toml at {topology_path} is not valid TOML: {exc}") from exc
465
+
466
+ workspace_name = document.get("workspace_name")
467
+ if workspace_name is not None and not isinstance(workspace_name, str):
468
+ raise SystemExit("workspace.toml workspace_name must be a string when declared")
469
+
470
+ raw_repos = document.get("repos", [])
471
+ if not isinstance(raw_repos, list):
472
+ raise SystemExit("workspace.toml repos must be an array of tables")
473
+ if not raw_repos:
474
+ raise SystemExit("workspace.toml declares no [[repos]] entries")
475
+
476
+ repos: list[dict[str, str]] = []
477
+ for index, raw_repo in enumerate(raw_repos):
478
+ if not isinstance(raw_repo, dict):
479
+ raise SystemExit(f"workspace.toml repos[{index}] must be a table")
480
+ key = str(raw_repo.get("key", "<missing key>"))
481
+ for field in ("key", "path", "url"):
482
+ value = raw_repo.get(field)
483
+ if not value:
484
+ raise SystemExit(
485
+ f"workspace.toml repos[{index}] ({key!r}) is missing {field!r}"
486
+ )
487
+ if not isinstance(value, str):
488
+ raise SystemExit(
489
+ f"workspace.toml repos[{index}] ({key!r}) must declare {field!r} as a string"
490
+ )
491
+ repos.append(
492
+ {
493
+ "name": str(raw_repo["key"]),
494
+ "path": str(raw_repo["path"]),
495
+ "url": str(raw_repo["url"]),
496
+ }
497
+ )
498
+ return workspace_name, repos
499
+
500
+
501
+ def _exit(code: int) -> None:
502
+ if code != 0:
503
+ raise typer.Exit(code=code)
504
+
505
+
506
+ def _consume_lane_transition(outcome: lane_proto.LaneTransitionOutcome | int) -> lane_proto.LaneTransitionOutcome | None:
507
+ """Render the state writer's one outcome instead of inferring one in the CLI."""
508
+ if isinstance(outcome, lane_proto.LaneTransitionOutcome):
509
+ typer.echo(json.dumps(outcome.as_dict(), indent=2))
510
+ _exit(outcome.exit_code)
511
+ return outcome
512
+ _exit(outcome)
513
+ return None
514
+
515
+
516
+ @sync_app.command("status")
517
+ def sync_status(
518
+ workspace_root: Path,
519
+ dirty_mode: str = typer.Option("stash", "--dirty", help="Dirty-state handling: stash, block, or discard"),
520
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
521
+ ) -> None:
522
+ """Inspect workspace-wide sync readiness without mutating any repo state."""
523
+ workspace_root = workspace_root.resolve()
524
+ plan = syncops.build_sync_plan(workspace_root, dirty_mode=dirty_mode)
525
+ if json_output:
526
+ typer.echo(json.dumps(plan.as_dict(), indent=2))
527
+ return
528
+ typer.echo(syncops.render_sync_plan(plan))
529
+
530
+
531
+ @sync_app.command("run")
532
+ def sync_run(
533
+ workspace_root: Path,
534
+ dirty_mode: str = typer.Option("stash", "--dirty", help="Dirty-state handling: stash, block, or discard"),
535
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
536
+ ) -> None:
537
+ """Execute the current sync plan, stopping on the first blocking runtime failure."""
538
+ workspace_root = workspace_root.resolve()
539
+ result = syncops.run_sync(workspace_root, dirty_mode=dirty_mode)
540
+ if json_output:
541
+ typer.echo(json.dumps(result.as_dict(), indent=2))
542
+ else:
543
+ typer.echo(syncops.render_sync_result(result))
544
+ if result.status in {"blocked", "failed", "partial_failure"}:
545
+ raise typer.Exit(code=1)
546
+
547
+
548
+ @workspace_app.command("init")
549
+ def workspace_init(
550
+ workspace_root: Path,
551
+ default_unit: str = typer.Option("default", help="Default owner unit for scanned repos"),
552
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
553
+ ) -> None:
554
+ """Create a bare workspace_spec.toml by scanning an existing directory of repos."""
555
+ workspace_root = workspace_root.resolve()
556
+ repos = _scan_existing_repos(workspace_root)
557
+ if not repos:
558
+ raise SystemExit(f"no git repos found to initialize workspace spec under: {workspace_root}")
559
+ spec_path = _write_workspace_spec(workspace_root, repos, default_unit)
560
+ payload = {
561
+ "workspace_root": str(workspace_root),
562
+ "spec_path": str(spec_path),
563
+ "repo_count": len(repos),
564
+ "repos": repos,
565
+ "default_unit": default_unit,
566
+ }
567
+ if json_output:
568
+ typer.echo(json.dumps(payload, indent=2))
569
+ else:
570
+ lines = [
571
+ "WorkspaceInit",
572
+ f"workspace_root = {workspace_root}",
573
+ f"spec_path = {spec_path}",
574
+ f"default_unit = {default_unit}",
575
+ f"repo_count = {len(repos)}",
576
+ "REPOS",
577
+ ]
578
+ lines.extend(f"- {repo['name']}\t{repo['path']}\t{repo['url'] or '-'}" for repo in repos)
579
+ typer.echo("\n".join(lines))
580
+
581
+
582
+ @workspace_app.command("init-from-topology")
583
+ def workspace_init_from_topology(
584
+ workspace_root: Path,
585
+ default_unit: str = typer.Option("default", help="Default owner unit for declared repos"),
586
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
587
+ ) -> None:
588
+ """Create WorkspaceSpec from neutral ``workspace.toml`` repo declarations."""
589
+ workspace_root = workspace_root.resolve()
590
+ workspace_name, repos = _declared_workspace_topology(workspace_root)
591
+ spec_path = _write_workspace_spec(
592
+ workspace_root,
593
+ repos,
594
+ default_unit,
595
+ workspace_name=workspace_name,
596
+ )
597
+ payload = {
598
+ "workspace_root": str(workspace_root),
599
+ "spec_path": str(spec_path),
600
+ "repo_count": len(repos),
601
+ "repos": repos,
602
+ "default_unit": default_unit,
603
+ "source": "workspace.toml",
604
+ }
605
+ if json_output:
606
+ typer.echo(json.dumps(payload, indent=2))
607
+ else:
608
+ lines = [
609
+ "WorkspaceInitFromTopology",
610
+ f"workspace_root = {workspace_root}",
611
+ f"spec_path = {spec_path}",
612
+ f"default_unit = {default_unit}",
613
+ f"repo_count = {len(repos)}",
614
+ "source = workspace.toml",
615
+ "REPOS",
616
+ ]
617
+ lines.extend(f"- {repo['name']}\t{repo['path']}\t{repo['url']}" for repo in repos)
618
+ typer.echo("\n".join(lines))
619
+
620
+
621
+ @workspace_app.command("materialize")
622
+ def workspace_materialize(
623
+ workspace_root: Path,
624
+ yes: bool = typer.Option(False, "--yes", help="Pre-approve plans with more than 3 operations"),
625
+ manual_hooks: bool = typer.Option(False, "--manual-hooks", help="Also run lifecycle hooks marked when=manual"),
626
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
627
+ ) -> None:
628
+ """Read workspace_spec.toml and apply the current workspace materialization plan."""
629
+ workspace_root = workspace_root.resolve()
630
+ payload = spec_apply.apply_plan(workspace_root, yes=yes, manual_hooks=manual_hooks)
631
+ if json_output:
632
+ typer.echo(json.dumps(payload, indent=2))
633
+ else:
634
+ typer.echo(spec_apply.render_apply_result(payload))
635
+
636
+
637
+ @workspace_app.command("status")
638
+ def workspace_status_cmd(
639
+ workspace_root: Path,
640
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
641
+ ) -> None:
642
+ """Show workspace state: gr1-only, gr2-only, coexistence, or none."""
643
+ workspace_root = workspace_root.resolve()
644
+ payload = migration.workspace_status(workspace_root)
645
+ if json_output:
646
+ typer.echo(json.dumps(payload, indent=2))
647
+ else:
648
+ typer.echo(migration.render_status(payload))
649
+
650
+
651
+ @workspace_app.command("convert-clone")
652
+ def workspace_convert_clone_cmd(
653
+ path: Path,
654
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
655
+ ) -> None:
656
+ """Convert a linked git worktree at PATH into an own clone, in place.
657
+
658
+ Refuses a dirty tree, a symlinked .git, or a path that is not a linked
659
+ worktree at all -- gr does not support worktree-backed repos.
660
+ """
661
+ try:
662
+ receipt = repo_proto.convert_worktree_to_clone(path.resolve())
663
+ except repo_proto.ConvertCloneError as exc:
664
+ typer.echo(f"convert-clone refused: {exc}", err=True)
665
+ raise typer.Exit(code=1) from exc
666
+
667
+ if json_output:
668
+ typer.echo(json.dumps(receipt, indent=2))
669
+ else:
670
+ typer.echo(f"Converted {receipt['path']} from a linked worktree to an own clone.")
671
+ typer.echo(f" branch: {receipt['branch']}")
672
+ typer.echo(f" head: {receipt['head_sha']}")
673
+ typer.echo(f" was linked to: {receipt['old_common_dir']}")
674
+
675
+
676
+ @workspace_app.command("detect-gr1")
677
+ def workspace_detect_gr1(
678
+ workspace_root: Path,
679
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
680
+ ) -> None:
681
+ """Detect whether a workspace is using the gr1 (.gitgrip) layout."""
682
+ workspace_root = workspace_root.resolve()
683
+ payload = migration.detect_gr1_workspace(workspace_root)
684
+ if json_output:
685
+ typer.echo(json.dumps(payload, indent=2))
686
+ else:
687
+ typer.echo(migration.render_detection(payload))
688
+ if not payload["detected"]:
689
+ raise typer.Exit(code=1)
690
+
691
+
692
+ @workspace_app.command("migrate-gr1")
693
+ def workspace_migrate_gr1(
694
+ workspace_root: Path,
695
+ force: bool = typer.Option(False, "--force", help="Allow overwrite of an existing .grip/workspace_spec.toml"),
696
+ apply: bool = typer.Option(False, "--apply", help="After migration, validate and apply the spec in one step"),
697
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
698
+ ) -> None:
699
+ """Convert an existing gr1 (.gitgrip) workspace into parallel gr2 (.grip) layout."""
700
+ workspace_root = workspace_root.resolve()
701
+ payload = migration.migrate_gr1_workspace(workspace_root, force=force)
702
+ if apply:
703
+ issues = spec_apply.validate_spec(workspace_root)
704
+ errors = [i for i in issues if i.level == "error"]
705
+ if errors:
706
+ payload["apply_status"] = "validation_failed"
707
+ payload["validation_errors"] = [i.as_dict() for i in errors]
708
+ else:
709
+ apply_result = spec_apply.apply_plan(workspace_root, yes=True)
710
+ payload["apply_status"] = "applied"
711
+ payload["apply_result"] = apply_result
712
+ if json_output:
713
+ typer.echo(json.dumps(payload, indent=2))
714
+ else:
715
+ typer.echo(migration.render_migration(payload))
716
+ if apply and payload.get("apply_status") == "applied":
717
+ typer.echo("\nWorkspace materialized successfully.")
718
+ elif apply and payload.get("apply_status") == "validation_failed":
719
+ typer.echo(f"\nSpec validation failed: {len(payload.get('validation_errors', []))} error(s).")
720
+
721
+
722
+ @workspace_app.command("migrate-lane-state")
723
+ def workspace_migrate_lane_state(
724
+ workspace_root: Path,
725
+ receipt: Path = typer.Option(..., "--receipt", help="New receipt path naming every lane tree the migration moved"),
726
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
727
+ ) -> None:
728
+ """Move legacy agents/<unit>/lanes/<lane>/ trees to .grip/state/lanes/<unit>/<lane>/ once, with a receipt."""
729
+ workspace_root = workspace_root.resolve()
730
+ payload = migration.migrate_lane_state(workspace_root, receipt_path=receipt)
731
+ if json_output:
732
+ typer.echo(json.dumps(payload, indent=2))
733
+ else:
734
+ count = payload["count"]
735
+ typer.echo(f"Migrated {count} lane tree(s) to .grip/state/lanes/; receipt at {receipt}")
736
+ for row in payload["moved"]:
737
+ typer.echo(f" {row['source']} -> {row['dest']} ({row['files']} file(s))")
738
+
739
+
740
+ @workspace_app.command("bootstrap-gr1")
741
+ def workspace_bootstrap_gr1(
742
+ workspace_root: Path,
743
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
744
+ regenerate: bool = typer.Option(False, "--regenerate", help="Atomically regenerate an existing generated spec"),
745
+ expected_spec_sha256: Optional[str] = typer.Option(None, "--expected-spec-sha256"),
746
+ receipt: Optional[Path] = typer.Option(None, "--receipt"),
747
+ rollback_receipt: Optional[Path] = typer.Option(None, "--rollback-receipt"),
748
+ expected_current_spec_sha256: Optional[str] = typer.Option(None, "--expected-current-spec-sha256"),
749
+ ) -> None:
750
+ """Compile the canonical gr1 manifest and initialize the gr2 grip store."""
751
+ workspace_root = workspace_root.resolve()
752
+ if rollback_receipt is not None:
753
+ if regenerate or expected_spec_sha256 is not None:
754
+ raise typer.BadParameter("--rollback-receipt is mutually exclusive with regeneration inputs")
755
+ if expected_current_spec_sha256 is None or receipt is None:
756
+ raise typer.BadParameter("--rollback-receipt requires --expected-current-spec-sha256 and --receipt")
757
+ payload = migration.rollback_gr1_workspace(
758
+ workspace_root,
759
+ rollback_receipt_path=rollback_receipt,
760
+ expected_current_spec_sha256=expected_current_spec_sha256,
761
+ receipt_path=receipt,
762
+ )
763
+ elif regenerate:
764
+ if expected_spec_sha256 is None or receipt is None:
765
+ raise typer.BadParameter("--regenerate requires --expected-spec-sha256 and --receipt")
766
+ payload = migration.regenerate_gr1_workspace(workspace_root, expected_spec_sha256=expected_spec_sha256, receipt_path=receipt)
767
+ else:
768
+ payload = migration.bootstrap_gr1_workspace(workspace_root)
769
+ if json_output:
770
+ typer.echo(json.dumps(payload, indent=2))
771
+ else:
772
+ # Render each payload by its OWN keys. bootstrap, regenerate and rollback
773
+ # return different schemas (rollback has no manifest_path, regenerate has
774
+ # no repo_count); a fixed key list raised KeyError after a successful
775
+ # mutation, exiting 1 on a completed rollback. Print scalars in order;
776
+ # json-encode nested values so nothing is dropped.
777
+ typer.echo(str(payload.get("schema", "Gr1Bootstrap")))
778
+ for key, value in payload.items():
779
+ if key == "schema":
780
+ continue
781
+ if isinstance(value, (dict, list)):
782
+ value = json.dumps(value)
783
+ typer.echo(f"{key} = {value}")
784
+
785
+
786
+ @spec_app.command("show")
787
+ def spec_show(
788
+ workspace_root: Path,
789
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
790
+ ) -> None:
791
+ """Show the current workspace spec."""
792
+ workspace_root = workspace_root.resolve()
793
+ typer.echo(spec_apply.show_spec(workspace_root, json_output=json_output))
794
+
795
+
796
+ @spec_app.command("validate")
797
+ def spec_validate(
798
+ workspace_root: Path,
799
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
800
+ ) -> None:
801
+ """Validate the current workspace spec."""
802
+ workspace_root = workspace_root.resolve()
803
+ issues = spec_apply.validate_spec(workspace_root)
804
+ payload = {
805
+ "workspace_root": str(workspace_root),
806
+ "valid": not any(issue.level == "error" for issue in issues),
807
+ "issues": [issue.as_dict() for issue in issues],
808
+ }
809
+ if json_output:
810
+ typer.echo(json.dumps(payload, indent=2))
811
+ else:
812
+ typer.echo(spec_apply.render_validation(issues))
813
+ if not payload["valid"]:
814
+ raise typer.Exit(code=1)
815
+
816
+
817
+ @app.command("plan")
818
+ def workspace_plan(
819
+ workspace_root: Path,
820
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
821
+ ) -> None:
822
+ """Build a Python gr2 execution plan from the workspace spec."""
823
+ workspace_root = workspace_root.resolve()
824
+ _, operations = spec_apply.build_plan(workspace_root)
825
+ if json_output:
826
+ typer.echo(json.dumps([item.as_dict() for item in operations], indent=2))
827
+ else:
828
+ typer.echo(spec_apply.render_plan(operations))
829
+
830
+
831
+ @app.command("apply")
832
+ def workspace_apply(
833
+ workspace_root: Path,
834
+ yes: bool = typer.Option(False, "--yes", help="Pre-approve plans with more than 3 operations"),
835
+ manual_hooks: bool = typer.Option(False, "--manual-hooks", help="Also run lifecycle hooks marked when=manual"),
836
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
837
+ ) -> None:
838
+ """Apply the Python gr2 execution plan."""
839
+ workspace_root = workspace_root.resolve()
840
+ payload = spec_apply.apply_plan(workspace_root, yes=yes, manual_hooks=manual_hooks)
841
+ if json_output:
842
+ typer.echo(json.dumps(payload, indent=2))
843
+ else:
844
+ typer.echo(spec_apply.render_apply_result(payload))
845
+
846
+
847
+ @app.command("branch")
848
+ def branch_cmd(
849
+ name: str,
850
+ base: str | None = typer.Option(
851
+ None,
852
+ "--base",
853
+ "--from",
854
+ "--start-point",
855
+ help="Create (or reset) the branch off this ref instead of current HEAD",
856
+ ),
857
+ repo_path: Path | None = typer.Option(
858
+ None,
859
+ "--repo-path",
860
+ help="Repo to operate on (defaults to cwd; gr2 verbs are single-repo, not gripspace-wide)",
861
+ ),
862
+ ) -> None:
863
+ """Create or switch to a branch, natively -- no gr1 dependency."""
864
+ target = (repo_path or Path.cwd()).resolve()
865
+ try:
866
+ branch_ops.create_branch(target, name, base=base)
867
+ except branch_ops.BranchError as exc:
868
+ typer.echo(f"Error: {exc}", err=True)
869
+ raise typer.Exit(code=1) from exc
870
+ typer.echo(f"Switched to branch '{name}'")
871
+
872
+
873
+ @app.command("add")
874
+ def add_cmd(
875
+ paths: list[str] = typer.Argument(..., help="Paths or pathspecs to stage"),
876
+ repo_path: Path | None = typer.Option(
877
+ None,
878
+ "--repo-path",
879
+ help="Repo to operate on (defaults to cwd; gr2 verbs are single-repo)",
880
+ ),
881
+ ) -> None:
882
+ """Stage paths in one repository, including tracked deletions."""
883
+ target = (repo_path or Path.cwd()).resolve()
884
+ try:
885
+ result = add_ops.stage_files(target, paths)
886
+ except add_ops.AddError as exc:
887
+ typer.echo(f"Error: {exc}", err=True)
888
+ raise typer.Exit(code=1) from exc
889
+ if result.staged_files:
890
+ typer.echo(f"Staged {len(result.staged_files)} path(s): {', '.join(result.staged_files)}")
891
+ else:
892
+ typer.echo("No changes staged for the requested paths")
893
+
894
+
895
+ @app.command("commit")
896
+ def commit_cmd(
897
+ message: str = typer.Option(..., "--message", "-m", help="Commit message"),
898
+ amend: bool = typer.Option(False, "--amend", help="Amend the current commit"),
899
+ repo_path: Path | None = typer.Option(
900
+ None,
901
+ "--repo-path",
902
+ help="Repo to operate on (defaults to cwd; single-repo)",
903
+ ),
904
+ workspace_root: Path | None = typer.Option(
905
+ None,
906
+ "--workspace-root",
907
+ help="Lane-aware: commit each repo of a lane in this workspace (requires --owner-unit)",
908
+ ),
909
+ owner_unit: str | None = typer.Option(
910
+ None,
911
+ "--owner-unit",
912
+ help="Lane-aware: the unit whose lane to commit across",
913
+ ),
914
+ lane_name: str | None = typer.Option(
915
+ None,
916
+ "--lane",
917
+ help="Lane-aware: lane name (defaults to the unit's current lane)",
918
+ ),
919
+ ) -> None:
920
+ """Create or amend a commit from the staged index.
921
+
922
+ Single-repo by default (cwd or --repo-path). With --workspace-root and
923
+ --owner-unit, commits each repo of a materialized lane under one message
924
+ (bound lanes stay single-repo).
925
+ """
926
+ lane_mode = workspace_root is not None and owner_unit is not None
927
+ if lane_mode and repo_path is not None:
928
+ typer.echo("Error: --repo-path is single-repo; do not combine it with --workspace-root/--owner-unit", err=True)
929
+ raise typer.Exit(code=1)
930
+ if lane_mode:
931
+ try:
932
+ report = commit_ops.commit_lane(
933
+ workspace_root, owner_unit, message, lane_name=lane_name, amend=amend
934
+ )
935
+ except commit_ops.CommitError as exc:
936
+ typer.echo(f"Error: {exc}", err=True)
937
+ raise typer.Exit(code=1) from exc
938
+ for row in report.results:
939
+ if row.status == "committed":
940
+ typer.echo(f"{row.repo}: committed {row.commit_sha}")
941
+ elif row.status == "skipped_empty":
942
+ typer.echo(f"{row.repo}: skipped (empty index)")
943
+ else:
944
+ typer.echo(f"{row.repo}: FAILED — {row.error}")
945
+ if report.any_failed:
946
+ raise typer.Exit(code=1)
947
+ return
948
+ target = (repo_path or Path.cwd()).resolve()
949
+ try:
950
+ receipt = commit_ops.create_commit(target, message, amend=amend)
951
+ except commit_ops.CommitError as exc:
952
+ typer.echo(f"Error: {exc}", err=True)
953
+ raise typer.Exit(code=1) from exc
954
+ action = "Amended" if receipt.amended else "Committed"
955
+ typer.echo(f"{action} {receipt.commit_sha}")
956
+
957
+
958
+ @app.command("push")
959
+ def push_cmd(
960
+ remote: str | None = typer.Option(None, "--remote", help="Configured remote to push"),
961
+ set_upstream: bool = typer.Option(False, "--set-upstream", "-u"),
962
+ force_with_lease: bool = typer.Option(
963
+ False,
964
+ "--force-with-lease",
965
+ help="Replace the remote ref only if its observed value still matches",
966
+ ),
967
+ repo_path: Path | None = typer.Option(
968
+ None,
969
+ "--repo-path",
970
+ help="Repo to operate on (defaults to cwd; gr2 verbs are single-repo)",
971
+ ),
972
+ ) -> None:
973
+ """Push one branch and verify its immutable remote commit."""
974
+ target = (repo_path or Path.cwd()).resolve()
975
+ try:
976
+ receipt = push_ops.push_current_branch(
977
+ target,
978
+ remote=remote,
979
+ set_upstream=set_upstream,
980
+ force_with_lease=force_with_lease,
981
+ )
982
+ except push_ops.PushError as exc:
983
+ typer.echo(f"Error: {exc}", err=True)
984
+ raise typer.Exit(code=1) from exc
985
+ typer.echo(
986
+ f"Pushed {receipt.branch} to {receipt.remote} at {receipt.remote_sha} "
987
+ "(remote ref verified)"
988
+ )
989
+
990
+
991
+ @app.command("prune")
992
+ def prune_cmd(
993
+ target: str | None = typer.Option(
994
+ None,
995
+ "--target",
996
+ help="Ref to measure merged-ness against (default: the gripspace's stored PR target, then origin/dev, then origin/HEAD, then origin/main)",
997
+ ),
998
+ remote: str = typer.Option(
999
+ "origin",
1000
+ "--remote",
1001
+ help="Remote whose stored-target/dev/HEAD/main resolve the default --target (ignored when --target is given)",
1002
+ ),
1003
+ execute: bool = typer.Option(
1004
+ False,
1005
+ "--execute",
1006
+ help="Actually delete the merged branches (default: dry-run, prints what it would delete)",
1007
+ ),
1008
+ repo_path: Path | None = typer.Option(
1009
+ None,
1010
+ "--repo-path",
1011
+ help="Repo to operate on (defaults to cwd; gr2 verbs are single-repo)",
1012
+ ),
1013
+ ) -> None:
1014
+ r"""List merged local branches and delete them only with --execute.
1015
+
1016
+ Merged is decided by PATCH-ID (git cherry) then a SQUASH tree check, never by
1017
+ containment alone -- so squash- and rebase-merged lane branches, which gr1's
1018
+ containment prune leaves behind, are caught. The SQUASH check matches a
1019
+ branch's whole diff against a single commit already on the target's
1020
+ first-parent line (the shape a squash-merge produces). The per-branch reason
1021
+ is labelled in the output: \[patch-id] means the branch's commits are already
1022
+ present in the target by patch-id; \[squash] means its combined diff matches
1023
+ one target commit.
1024
+
1025
+ Never deletes the current branch, the target, or main/dev, and never touches
1026
+ a remote ref. There is no verbose or debug flag -- the default dry-run already
1027
+ prints every branch it would delete and why. Cost scales as one `git cherry`
1028
+ per local branch, plus (only when a branch is not already patch-id-merged) one
1029
+ bounded first-parent scan of the target that is built once and shared across
1030
+ branches.
1031
+
1032
+ With no --target, the gripspace's stored \[settings].target (the branch this
1033
+ workspace merges into -- an epic branch, say) is preferred over origin/dev.
1034
+ prune only READS it; `gr target set` is the only writer. A stale stored target
1035
+ (its remote ref absent) is skipped and the report's target line names it.
1036
+ """
1037
+ target_repo = (repo_path or Path.cwd()).resolve()
1038
+ stored_target: str | None = None
1039
+ if target is None:
1040
+ workspace_root = _find_workspace_root(target_repo)
1041
+ if workspace_root is not None:
1042
+ stored_target = _configured_target(workspace_root)
1043
+ try:
1044
+ report = prune_ops.prune(
1045
+ target_repo, target=target, remote=remote, stored_target=stored_target, execute=execute
1046
+ )
1047
+ except prune_ops.PruneError as exc:
1048
+ typer.echo(f"Error: {exc}", err=True)
1049
+ raise typer.Exit(code=1) from exc
1050
+ typer.echo(prune_ops.render_report(report))
1051
+ if report.failed:
1052
+ raise typer.Exit(code=1)
1053
+
1054
+
1055
+ def _target_workspace_root(repo_path: Path | None) -> tuple[Path, Path]:
1056
+ """(start, workspace_root) for a target command, or exit 1 if outside a gripspace."""
1057
+ start = (repo_path or Path.cwd()).resolve()
1058
+ workspace_root = _find_workspace_root(start)
1059
+ if workspace_root is None:
1060
+ typer.echo(
1061
+ f"Error: no gr2 workspace spec at or above {start}; a stored target needs a "
1062
+ f"workspace (run inside a gripspace, or pass --target to prune directly).",
1063
+ err=True,
1064
+ )
1065
+ raise typer.Exit(code=1)
1066
+ return start, workspace_root
1067
+
1068
+
1069
+ @target_app.command("set")
1070
+ def target_set(
1071
+ branch: str = typer.Argument(..., help="Branch to store as the PR target, e.g. dev or epic/x"),
1072
+ remote: str = typer.Option(
1073
+ "origin", "--remote", help="Remote to check <branch> against for the absent-ref warning"
1074
+ ),
1075
+ repo_path: Path | None = typer.Option(
1076
+ None, "--repo-path", help="Repo to check the ref against (defaults to cwd)"
1077
+ ),
1078
+ ) -> None:
1079
+ """Store <branch> as the gripspace PR target (settings.target).
1080
+
1081
+ Preserves every other spec field (repos, units, an existing merge_method) via
1082
+ a full tomllib->tomli_w round-trip; the spec is machine-written, so dropping
1083
+ TOML comments in the round-trip is a non-issue. WARNS, never refuses, when
1084
+ <remote>/<branch> is absent in the checked clone -- prune tolerates the same
1085
+ stale case by skipping the stored step -- so a typo is visible at write time.
1086
+ """
1087
+ start, workspace_root = _target_workspace_root(repo_path)
1088
+ try:
1089
+ target_ops.set_target(workspace_root, branch)
1090
+ except target_ops.TargetError as exc:
1091
+ typer.echo(f"Error: {exc}", err=True)
1092
+ raise typer.Exit(code=1) from exc
1093
+ typer.echo(f"stored target: {branch} ({target_ops.spec_path(workspace_root)})")
1094
+ # Absent-ref warning (never fatal): check the clone the caller is standing in.
1095
+ if is_git_repo(start):
1096
+ ref = branch if branch.startswith(f"{remote}/") else f"{remote}/{branch}"
1097
+ if not prune_ops._ref_exists(start, ref):
1098
+ typer.echo(
1099
+ f"warning: {ref} not found in this clone; stored anyway "
1100
+ f"(prune skips the stored target until the ref exists)"
1101
+ )
1102
+
1103
+
1104
+ @target_app.command("show")
1105
+ def target_show(
1106
+ repo_path: Path | None = typer.Option(
1107
+ None, "--repo-path", help="Repo whose workspace spec to read (defaults to cwd)"
1108
+ ),
1109
+ ) -> None:
1110
+ """Print the stored settings.target, or 'unset'."""
1111
+ _start, workspace_root = _target_workspace_root(repo_path)
1112
+ try:
1113
+ value = target_ops.show_target(workspace_root)
1114
+ except target_ops.TargetError as exc:
1115
+ typer.echo(f"Error: {exc}", err=True)
1116
+ raise typer.Exit(code=1) from exc
1117
+ typer.echo(value if value is not None else "unset")
1118
+
1119
+
1120
+ @target_app.command("unset")
1121
+ def target_unset(
1122
+ repo_path: Path | None = typer.Option(
1123
+ None, "--repo-path", help="Repo whose workspace spec to edit (defaults to cwd)"
1124
+ ),
1125
+ ) -> None:
1126
+ """Remove settings.target, preserving every other field."""
1127
+ _start, workspace_root = _target_workspace_root(repo_path)
1128
+ try:
1129
+ removed = target_ops.unset_target(workspace_root)
1130
+ except target_ops.TargetError as exc:
1131
+ typer.echo(f"Error: {exc}", err=True)
1132
+ raise typer.Exit(code=1) from exc
1133
+ typer.echo("target unset" if removed else "target was not set")
1134
+
1135
+
1136
+ @exec_app.command("status")
1137
+ def exec_status(
1138
+ workspace_root: Path,
1139
+ owner_unit: str,
1140
+ lane_name: Optional[str] = typer.Argument(None, help="Lane name. Defaults to the unit's current lane."),
1141
+ repos: Optional[str] = typer.Option(None, help="Optional comma-separated repo subset"),
1142
+ actor: str = typer.Option("agent:exec-status", help="Actor label for lease conflict evaluation"),
1143
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1144
+ ) -> None:
1145
+ """Show lane-aware execution status for a lane."""
1146
+ workspace_root = workspace_root.resolve()
1147
+ payload = execops.exec_status_payload(workspace_root, owner_unit, lane_name, repos=repos, actor=actor)
1148
+ if json_output:
1149
+ typer.echo(json.dumps(payload, indent=2))
1150
+ else:
1151
+ typer.echo(execops.render_exec_status(payload))
1152
+
1153
+
1154
+ @exec_app.command("run", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
1155
+ def exec_run(
1156
+ ctx: typer.Context,
1157
+ workspace_root: Path,
1158
+ owner_unit: str,
1159
+ command: list[str] = typer.Argument(None, help="Command to run inside each selected lane repo"),
1160
+ lane_name: Optional[str] = typer.Option(None, "--lane", help="Lane name. Defaults to the unit's current lane."),
1161
+ repos: Optional[str] = typer.Option(None, help="Optional comma-separated repo subset"),
1162
+ actor: str = typer.Option(..., help="Actor label, e.g. agent:atlas"),
1163
+ ttl_seconds: int = typer.Option(900, "--ttl-seconds", help="TTL for the temporary exec lease"),
1164
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1165
+ ) -> None:
1166
+ """Run a command across the repos in a lane."""
1167
+ workspace_root = workspace_root.resolve()
1168
+ full_command = list(command or []) + list(ctx.args)
1169
+ if not full_command:
1170
+ raise typer.BadParameter("missing command to run")
1171
+ payload = execops.run_exec(
1172
+ workspace_root,
1173
+ owner_unit,
1174
+ lane_name,
1175
+ actor=actor,
1176
+ command=full_command,
1177
+ repos=repos,
1178
+ ttl_seconds=ttl_seconds,
1179
+ )
1180
+ if json_output:
1181
+ typer.echo(json.dumps(payload, indent=2))
1182
+ else:
1183
+ typer.echo(execops.render_exec_run(payload))
1184
+ if payload.get("status") in {"blocked", "failed"}:
1185
+ raise typer.Exit(code=1)
1186
+
1187
+
1188
+ @repo_app.command("status")
1189
+ def repo_status(
1190
+ workspace_root: Path,
1191
+ spec: Optional[Path] = typer.Option(None, help="Path to workspace_spec.toml"),
1192
+ policy: Optional[Path] = typer.Option(None, help="Optional repo maintenance policy TOML"),
1193
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1194
+ ) -> None:
1195
+ """Show repo maintenance status without mutating workspace state."""
1196
+ workspace_root = workspace_root.resolve()
1197
+ spec_path = (spec or workspace_root / ".grip" / "workspace_spec.toml").resolve()
1198
+ spec_doc = repo_proto.read_workspace_spec(spec_path)
1199
+ policy_doc = repo_proto.read_policy(policy.resolve() if policy else None)
1200
+
1201
+ actions = []
1202
+ for target in repo_proto.derive_targets(workspace_root, spec_doc):
1203
+ status = repo_proto.inspect_repo(target.path)
1204
+ repo_policy = repo_proto.policy_for(target, policy_doc)
1205
+ actions.append(repo_proto.classify(target, status, repo_policy))
1206
+
1207
+ if json_output:
1208
+ typer.echo(json.dumps([item.as_dict() for item in actions], indent=2))
1209
+ else:
1210
+ typer.echo(repo_proto.render_table(actions))
1211
+
1212
+
1213
+ @repo_app.command("hooks")
1214
+ def repo_hooks_show(
1215
+ repo_root: Path,
1216
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1217
+ ) -> None:
1218
+ """Inspect parsed .gr2/hooks.toml for a repo."""
1219
+ hooks = load_repo_hooks(repo_root.resolve())
1220
+ if hooks is None:
1221
+ raise typer.Exit(code=1)
1222
+ if json_output:
1223
+ typer.echo(json.dumps(hooks.as_dict(), indent=2))
1224
+ else:
1225
+ typer.echo(json.dumps(hooks.as_dict(), indent=2))
1226
+
1227
+
1228
+ @repo_app.command("hook-run")
1229
+ def repo_hook_run(
1230
+ workspace_root: Path,
1231
+ repo_root: Path,
1232
+ stage: str = typer.Argument(..., help="Lifecycle stage: on_materialize | on_enter | on_exit"),
1233
+ manual: bool = typer.Option(False, "--manual", help="Allow hooks with when=manual to run"),
1234
+ first_materialize: bool = typer.Option(False, "--first-materialize", help="Treat this invocation as first materialization"),
1235
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1236
+ ) -> None:
1237
+ """Run repo hooks explicitly for one lifecycle stage."""
1238
+ workspace_root = workspace_root.resolve()
1239
+ repo_root = repo_root.resolve()
1240
+ if stage not in {"on_materialize", "on_enter", "on_exit"}:
1241
+ raise typer.BadParameter("stage must be one of: on_materialize, on_enter, on_exit")
1242
+ hooks = load_repo_hooks(repo_root)
1243
+ if hooks is None:
1244
+ raise SystemExit(f"no .gr2/hooks.toml found in repo: {repo_root}")
1245
+ ctx = _repo_hook_context(workspace_root, repo_root)
1246
+ results = run_lifecycle_stage(
1247
+ hooks,
1248
+ stage,
1249
+ ctx,
1250
+ repo_dirty=repo_dirty(repo_root),
1251
+ first_materialize=first_materialize,
1252
+ allow_manual=manual,
1253
+ )
1254
+ payload = {
1255
+ "workspace_root": str(workspace_root),
1256
+ "repo_root": str(repo_root),
1257
+ "stage": stage,
1258
+ "results": [item.as_dict() for item in results],
1259
+ }
1260
+ if json_output:
1261
+ typer.echo(json.dumps(payload, indent=2))
1262
+ else:
1263
+ typer.echo(json.dumps(payload, indent=2))
1264
+
1265
+
1266
+ @repo_app.command("projection-run")
1267
+ def repo_projection_run(
1268
+ workspace_root: Path,
1269
+ repo_root: Path,
1270
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1271
+ ) -> None:
1272
+ """Apply file projections explicitly for one repo."""
1273
+ workspace_root = workspace_root.resolve()
1274
+ repo_root = repo_root.resolve()
1275
+ hooks = load_repo_hooks(repo_root)
1276
+ if hooks is None:
1277
+ raise SystemExit(f"no .gr2/hooks.toml found in repo: {repo_root}")
1278
+ ctx = _repo_hook_context(workspace_root, repo_root)
1279
+ results = apply_file_projections(hooks, ctx)
1280
+ payload = {
1281
+ "workspace_root": str(workspace_root),
1282
+ "repo_root": str(repo_root),
1283
+ "results": [item.as_dict() for item in results],
1284
+ }
1285
+ if json_output:
1286
+ typer.echo(json.dumps(payload, indent=2))
1287
+ else:
1288
+ typer.echo(json.dumps(payload, indent=2))
1289
+
1290
+
1291
+ @lane_app.command("create")
1292
+ def lane_create(
1293
+ workspace_root: Path,
1294
+ owner_unit: str,
1295
+ lane_name: str,
1296
+ repos: str = typer.Option(..., help="Comma-separated repo names"),
1297
+ branch: Optional[str] = typer.Option(None, help="Default branch or repo=branch mappings (required unless --bind; ignored with --bind, where the branch is read from the bound worktree)"),
1298
+ lane_type: str = typer.Option("feature", "--type", help="Lane type"),
1299
+ source: str = typer.Option("manual", help="Creation source label"),
1300
+ command: list[str] = typer.Option(None, "--command", help="Default command for the lane"),
1301
+ manual_hooks: bool = typer.Option(False, "--manual-hooks", help="Also run lifecycle hooks marked when=manual during lane materialization"),
1302
+ bind: Optional[Path] = typer.Option(None, "--bind", help="Bind the lane to an EXISTING clean, non-detached single-repo worktree instead of materializing a fresh clone (gr2-lane-author-shape ruling). The receipt is stamped lane_kind=bound."),
1303
+ ) -> None:
1304
+ """Create a lane and materialize its repos.
1305
+
1306
+ A materialized lane clones each repo and records the fork base (the point it
1307
+ forked from its integration branch) — the base a review measures from. Once you
1308
+ have committed work in the lane, `review create-project <workspace> <owner>
1309
+ <lane>` pins each repo at that fork base .. head and prints the gr:<sha> for
1310
+ `review open-project`. A `--bind` lane owns no clone and is single-repo.
1311
+ """
1312
+ workspace_root = workspace_root.resolve()
1313
+ if bind is None and not branch:
1314
+ raise typer.BadParameter("--branch is required unless --bind is given")
1315
+ ns = SimpleNamespace(
1316
+ workspace_root=workspace_root,
1317
+ owner_unit=owner_unit,
1318
+ lane_name=lane_name,
1319
+ repos=repos,
1320
+ branch=branch or "",
1321
+ type=lane_type,
1322
+ source=source,
1323
+ default_commands=command or [],
1324
+ bind=str(bind) if bind is not None else None,
1325
+ )
1326
+ _exit(lane_proto.create_lane(ns))
1327
+ # A bound lane owns no clone: skip materialization. The branch_map for the
1328
+ # event comes from the lane document create_lane just wrote (derived from the
1329
+ # bound worktree), not from the --branch arg, which --bind ignores.
1330
+ if bind is None:
1331
+ _materialize_lane_repos(workspace_root, owner_unit, lane_name, manual_hooks=manual_hooks)
1332
+ repo_list = [r.strip() for r in repos.split(",")]
1333
+ # The event payload carries lane_kind (and bound_worktree for a bound lane)
1334
+ # so an event-stream consumer can tell a bound lane from a materialized one
1335
+ # without a second read of lane.toml.
1336
+ lane_kind = "materialized"
1337
+ bound_worktree_payload: Optional[str] = None
1338
+ if bind is not None:
1339
+ doc = tomllib.loads(lane_proto.lane_file(workspace_root, owner_unit, lane_name).read_text())
1340
+ branch_map = doc.get("branch_map", {})
1341
+ lane_kind = doc.get("lane_kind", "bound")
1342
+ bound_worktree_payload = doc.get("bound_worktree")
1343
+ else:
1344
+ branch_map = {}
1345
+ for part in (branch or "").split(","):
1346
+ if "=" in part:
1347
+ k, v = part.split("=", 1)
1348
+ branch_map[k.strip()] = v.strip()
1349
+ else:
1350
+ for r in repo_list:
1351
+ branch_map[r] = part.strip()
1352
+ payload: dict[str, object] = {
1353
+ "lane_name": lane_name,
1354
+ "lane_type": lane_type,
1355
+ "lane_kind": lane_kind,
1356
+ "repos": repo_list,
1357
+ "branch_map": branch_map,
1358
+ }
1359
+ if bound_worktree_payload is not None:
1360
+ payload["bound_worktree"] = bound_worktree_payload
1361
+ emit_after_outcome(
1362
+ event_type=EventType.LANE_CREATED,
1363
+ workspace_root=workspace_root,
1364
+ actor=source,
1365
+ owner_unit=owner_unit,
1366
+ payload=payload,
1367
+ )
1368
+
1369
+
1370
+ @lane_app.command("enter")
1371
+ def lane_enter(
1372
+ workspace_root: Path,
1373
+ owner_unit: str,
1374
+ lane_name: str,
1375
+ actor: str = typer.Option(..., help="Actor label, e.g. agent:atlas"),
1376
+ notify_channel: bool = typer.Option(False, "--notify-channel"),
1377
+ recall: bool = typer.Option(False, "--recall"),
1378
+ manual_hooks: bool = typer.Option(False, "--manual-hooks", help="Also run lifecycle hooks marked when=manual"),
1379
+ ) -> None:
1380
+ """Enter a lane and optionally emit channel/recall-compatible events."""
1381
+ workspace_root = workspace_root.resolve()
1382
+ unresolved = failures.unresolved_lane_failure(workspace_root, owner_unit, lane_name)
1383
+ if unresolved:
1384
+ typer.echo(
1385
+ json.dumps(
1386
+ {
1387
+ "status": "blocked",
1388
+ "code": "unresolved_failure_marker",
1389
+ "operation_id": unresolved["operation_id"],
1390
+ "lane_name": lane_name,
1391
+ },
1392
+ indent=2,
1393
+ )
1394
+ )
1395
+ raise typer.Exit(code=1)
1396
+ try:
1397
+ _run_lane_stage(workspace_root, owner_unit, lane_name, "on_enter", manual_hooks=manual_hooks)
1398
+ except HookRuntimeError as exc:
1399
+ payload = exc.payload
1400
+ repo_name = Path(str(payload.get("cwd", ""))).name or lane_name
1401
+ event = failures.write_failure_marker(
1402
+ workspace_root,
1403
+ operation="lane.enter",
1404
+ stage=str(payload.get("stage", "on_enter")),
1405
+ hook_name=str(payload.get("hook", payload.get("name", "unknown"))),
1406
+ repo=repo_name,
1407
+ owner_unit=owner_unit,
1408
+ lane_name=lane_name,
1409
+ partial_state={},
1410
+ event_id=None,
1411
+ )
1412
+ typer.echo(json.dumps(event, indent=2))
1413
+ raise typer.Exit(code=1)
1414
+ ns = SimpleNamespace(
1415
+ workspace_root=workspace_root,
1416
+ owner_unit=owner_unit,
1417
+ lane_name=lane_name,
1418
+ actor=actor,
1419
+ notify_channel=notify_channel,
1420
+ recall=recall,
1421
+ )
1422
+ outcome = _consume_lane_transition(lane_proto.enter_lane(ns))
1423
+ lane_doc = lane_proto.load_lane_doc(workspace_root, owner_unit, lane_name)
1424
+ emit_after_outcome(
1425
+ event_type=EventType.LANE_ENTERED,
1426
+ workspace_root=workspace_root,
1427
+ actor=actor,
1428
+ owner_unit=owner_unit,
1429
+ payload={
1430
+ "lane_name": outcome.current_lane if outcome else lane_name,
1431
+ "lane_type": lane_doc.get("type", "feature"),
1432
+ "repos": lane_doc.get("repos", []),
1433
+ },
1434
+ )
1435
+
1436
+
1437
+ @lane_app.command("resolve")
1438
+ def lane_resolve(
1439
+ workspace_root: Path,
1440
+ owner_unit: str,
1441
+ operation_id: str,
1442
+ actor: str = typer.Option(..., help="Actor label, e.g. agent:atlas"),
1443
+ resolution: str = typer.Option(..., help="Resolution note: retry | skip | escalate"),
1444
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1445
+ ) -> None:
1446
+ """Resolve a blocking failure marker for a lane-scoped operation."""
1447
+ workspace_root = workspace_root.resolve()
1448
+ payload = failures.resolve_failure_marker(
1449
+ workspace_root,
1450
+ operation_id=operation_id,
1451
+ resolved_by=actor,
1452
+ resolution=resolution,
1453
+ owner_unit=owner_unit,
1454
+ )
1455
+ if json_output:
1456
+ typer.echo(json.dumps(payload, indent=2))
1457
+ else:
1458
+ typer.echo(json.dumps(payload, indent=2))
1459
+
1460
+
1461
+ @lane_app.command("exit")
1462
+ def lane_exit(
1463
+ workspace_root: Path,
1464
+ owner_unit: str,
1465
+ actor: str = typer.Option(..., help="Actor label, e.g. human:layne"),
1466
+ notify_channel: bool = typer.Option(False, "--notify-channel"),
1467
+ recall: bool = typer.Option(False, "--recall"),
1468
+ manual_hooks: bool = typer.Option(False, "--manual-hooks", help="Also run lifecycle hooks marked when=manual"),
1469
+ ) -> None:
1470
+ """Exit the current lane for a unit."""
1471
+ workspace_root = workspace_root.resolve()
1472
+ current_doc = lane_proto.require_current_lane(workspace_root, owner_unit)
1473
+ lane_name = current_doc["lane_name"]
1474
+ lane_doc = lane_proto.load_lane_doc(workspace_root, owner_unit, lane_name)
1475
+ stashed_repos: list[str] = []
1476
+ for repo_name in lane_doc.get("repos", []):
1477
+ repo_root = _lane_repo_root(workspace_root, owner_unit, lane_name, repo_name)
1478
+ if repo_root.exists():
1479
+ if stash_if_dirty(repo_root, f"gr2 exit {owner_unit}/{lane_name}"):
1480
+ stashed_repos.append(repo_name)
1481
+ _run_lane_stage(workspace_root, owner_unit, lane_name, "on_exit", manual_hooks=manual_hooks)
1482
+ ns = SimpleNamespace(
1483
+ workspace_root=workspace_root,
1484
+ owner_unit=owner_unit,
1485
+ actor=actor,
1486
+ notify_channel=notify_channel,
1487
+ recall=recall,
1488
+ )
1489
+ outcome = _consume_lane_transition(lane_proto.exit_lane(ns))
1490
+ emit_after_outcome(
1491
+ event_type=EventType.LANE_EXITED,
1492
+ workspace_root=workspace_root,
1493
+ actor=actor,
1494
+ owner_unit=owner_unit,
1495
+ payload={
1496
+ "lane_name": outcome.previous_lane if outcome else lane_name,
1497
+ "stashed_repos": stashed_repos,
1498
+ },
1499
+ )
1500
+
1501
+
1502
+ @lane_app.command("current")
1503
+ def lane_current(
1504
+ workspace_root: Path,
1505
+ owner_unit: str,
1506
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1507
+ ) -> None:
1508
+ """Show current lane and recent history for a unit."""
1509
+ ns = SimpleNamespace(
1510
+ workspace_root=workspace_root,
1511
+ owner_unit=owner_unit,
1512
+ json=json_output,
1513
+ )
1514
+ _exit(lane_proto.current_lane(ns))
1515
+
1516
+
1517
+ @lane_app.command("bind")
1518
+ def lane_bind(
1519
+ workspace_root: Path,
1520
+ owner_unit: str,
1521
+ lane_name: str,
1522
+ base: Optional[str] = typer.Option(None, "--base", help="Base SHA the reviewed range is measured from: a full 40-hex commit that is an ancestor of the worktree head. Omit to read the lane's recorded fork base; an explicit --base wins. A lane with no recorded fork base and no --base refuses."),
1523
+ allow_local: bool = typer.Option(False, "--allow-local", help="Allow a non-portable local: identity for a worktree with no GitHub origin (test/local use)"),
1524
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1525
+ ) -> None:
1526
+ """Bind a review receipt for a BOUND lane, sourced live from its worktree.
1527
+
1528
+ Re-checks the bound worktree is a clean, non-detached git checkout whose HEAD
1529
+ has NOT drifted from the head recorded at create, then writes the
1530
+ (repo, base, head, lane_kind=bound) receipt into the worktree's own .git. A
1531
+ moved HEAD, a dirty tree, or a base that is not an ancestor commit of head
1532
+ refuses. When --base is omitted the base is the lane's recorded fork base (the
1533
+ review base is never HEAD^); an explicit --base wins. Only for lanes created
1534
+ with ``lane create --bind``.
1535
+ """
1536
+ base_source = "explicit --base" if base is not None else "lane fork base"
1537
+ try:
1538
+ record = lane_proto.bind_bound_lane(
1539
+ workspace_root.resolve(), owner_unit, lane_name, base=base, allow_local=allow_local
1540
+ )
1541
+ except SystemExit as exc:
1542
+ typer.echo(str(exc), err=True)
1543
+ raise typer.Exit(code=2)
1544
+ if json_output:
1545
+ typer.echo(json.dumps(record.to_dict(), indent=2))
1546
+ else:
1547
+ typer.echo(
1548
+ f"bound review receipt: repo={record.repo} base={record.base} "
1549
+ f"head={record.head} (base from {base_source})"
1550
+ )
1551
+
1552
+
1553
+ @lease_app.command("acquire")
1554
+ def lane_lease_acquire(
1555
+ workspace_root: Path,
1556
+ owner_unit: str,
1557
+ lane_name: str,
1558
+ actor: str = typer.Option(...),
1559
+ mode: str = typer.Option(..., help="edit | exec | review"),
1560
+ ttl_seconds: int = typer.Option(900, "--ttl-seconds"),
1561
+ force: bool = typer.Option(False, "--force"),
1562
+ ) -> None:
1563
+ """Acquire a lease for a lane."""
1564
+ ns = SimpleNamespace(
1565
+ workspace_root=workspace_root,
1566
+ owner_unit=owner_unit,
1567
+ lane_name=lane_name,
1568
+ actor=actor,
1569
+ mode=mode,
1570
+ ttl_seconds=ttl_seconds,
1571
+ force=force,
1572
+ )
1573
+ _exit(lane_proto.acquire_lane_lease(ns))
1574
+ emit_after_outcome(
1575
+ event_type=EventType.LEASE_ACQUIRED,
1576
+ workspace_root=workspace_root,
1577
+ actor=actor,
1578
+ owner_unit=owner_unit,
1579
+ payload={
1580
+ "lane_name": lane_name,
1581
+ "mode": mode,
1582
+ "ttl_seconds": ttl_seconds,
1583
+ "lease_id": f"{owner_unit}:{lane_name}",
1584
+ },
1585
+ )
1586
+
1587
+
1588
+ @lease_app.command("release")
1589
+ def lane_lease_release(
1590
+ workspace_root: Path,
1591
+ owner_unit: str,
1592
+ lane_name: str,
1593
+ actor: str = typer.Option(...),
1594
+ ) -> None:
1595
+ """Release a lease for a lane."""
1596
+ ns = SimpleNamespace(
1597
+ workspace_root=workspace_root,
1598
+ owner_unit=owner_unit,
1599
+ lane_name=lane_name,
1600
+ actor=actor,
1601
+ )
1602
+ _exit(lane_proto.release_lane_lease(ns))
1603
+ emit_after_outcome(
1604
+ event_type=EventType.LEASE_RELEASED,
1605
+ workspace_root=workspace_root,
1606
+ actor=actor,
1607
+ owner_unit=owner_unit,
1608
+ payload={
1609
+ "lane_name": lane_name,
1610
+ "lease_id": f"{owner_unit}:{lane_name}",
1611
+ },
1612
+ )
1613
+
1614
+
1615
+ @lease_app.command("show")
1616
+ def lane_lease_show(
1617
+ workspace_root: Path,
1618
+ owner_unit: str,
1619
+ lane_name: str,
1620
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1621
+ ) -> None:
1622
+ """Show active leases for a lane."""
1623
+ ns = SimpleNamespace(
1624
+ workspace_root=workspace_root,
1625
+ owner_unit=owner_unit,
1626
+ lane_name=lane_name,
1627
+ json=json_output,
1628
+ )
1629
+ _exit(lane_proto.show_lane_leases(ns))
1630
+
1631
+
1632
+ @review_app.command("requirements")
1633
+ def review_requirements(
1634
+ workspace_root: Path,
1635
+ repo: str,
1636
+ pr_number: int,
1637
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1638
+ ) -> None:
1639
+ """Check whether compiled review requirements are satisfied for a repo and PR."""
1640
+ ns = SimpleNamespace(
1641
+ workspace_root=workspace_root,
1642
+ repo=repo,
1643
+ pr_number=pr_number,
1644
+ json=json_output,
1645
+ )
1646
+ _exit(lane_proto.check_review_requirements(ns))
1647
+
1648
+
1649
+ @review_app.command("checkout-pr")
1650
+ def review_checkout_pr(
1651
+ workspace_root: Path,
1652
+ owner_unit: str,
1653
+ repo: str,
1654
+ pr_number: int,
1655
+ lane_name: Optional[str] = typer.Option(None, "--lane", help="Override the review lane name"),
1656
+ branch: Optional[str] = typer.Option(None, "--branch", help="Override the source branch/ref to fetch"),
1657
+ enter: bool = typer.Option(False, "--enter", help="Enter the review lane after materialization"),
1658
+ actor: Optional[str] = typer.Option(None, "--actor", help="Actor label to use when entering the lane"),
1659
+ manual_hooks: bool = typer.Option(False, "--manual-hooks", help="Also run lifecycle hooks marked when=manual during materialization/enter"),
1660
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1661
+ ) -> None:
1662
+ """Create and materialize a review lane for a PR."""
1663
+ workspace_root = workspace_root.resolve()
1664
+ resolved_branch = _prepare_review_branch(workspace_root, repo, pr_number, branch)
1665
+ resolved_lane = _create_review_lane_metadata(
1666
+ workspace_root,
1667
+ owner_unit,
1668
+ repo,
1669
+ pr_number,
1670
+ lane_name=lane_name,
1671
+ branch=resolved_branch,
1672
+ )
1673
+ _materialize_lane_repos(workspace_root, owner_unit, resolved_lane, manual_hooks=manual_hooks)
1674
+
1675
+ entered = False
1676
+ if enter:
1677
+ if not actor:
1678
+ raise typer.BadParameter("--actor is required when using --enter")
1679
+ _run_lane_stage(workspace_root, owner_unit, resolved_lane, "on_enter", manual_hooks=manual_hooks)
1680
+ ns = SimpleNamespace(
1681
+ workspace_root=workspace_root,
1682
+ owner_unit=owner_unit,
1683
+ lane_name=resolved_lane,
1684
+ actor=actor,
1685
+ notify_channel=False,
1686
+ recall=False,
1687
+ )
1688
+ with contextlib.redirect_stdout(io.StringIO()):
1689
+ _exit(lane_proto.enter_lane(ns))
1690
+ entered = True
1691
+
1692
+ payload = {
1693
+ "workspace_root": str(workspace_root),
1694
+ "owner_unit": owner_unit,
1695
+ "repo": repo,
1696
+ "pr_number": pr_number,
1697
+ "lane_name": resolved_lane,
1698
+ "branch": resolved_branch,
1699
+ "entered": entered,
1700
+ "lane_repo_root": str(_lane_repo_root(workspace_root, owner_unit, resolved_lane, repo)),
1701
+ }
1702
+ if json_output:
1703
+ typer.echo(json.dumps(payload, indent=2))
1704
+ else:
1705
+ typer.echo(json.dumps(payload, indent=2))
1706
+
1707
+
1708
+ @review_app.command("open")
1709
+ def review_open(
1710
+ workspace_root: Path,
1711
+ owner_unit: str,
1712
+ repo: str,
1713
+ pr_number: int,
1714
+ lane_name: Optional[str] = typer.Option(None, "--lane", help="Override the review lane name"),
1715
+ platform: str = typer.Option("github", "--platform", help="Platform adapter name"),
1716
+ run: Optional[str] = typer.Option(None, "--run", help="After opening, dispatch this command inside the lane (cwd-contained)"),
1717
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1718
+ ) -> None:
1719
+ """Open an isolated review lane at a PR head over the grip#807 clone seam.
1720
+
1721
+ A wrong head REFUSES (never warns); import resolution is printed so the run
1722
+ cannot silently import a machine-wide install; the review is recorded as the
1723
+ (repo, base pin, review head) triple. ``gr2 review close`` drops the lane.
1724
+ """
1725
+ from . import review as review_mod
1726
+
1727
+ workspace_root = workspace_root.resolve()
1728
+ resolved_lane = lane_name or f"review-{pr_number}"
1729
+ # Portable-component validation before any path is composed from these values
1730
+ # (they build the lane directory that `close` later deletes).
1731
+ lane_proto.validate_lane_path_component(owner_unit, "owner_unit")
1732
+ lane_proto.validate_lane_path_component(repo, "repo")
1733
+ lane_proto.validate_lane_path_component(resolved_lane, "lane_name")
1734
+
1735
+ repo_spec = _workspace_repo_spec(workspace_root, repo)
1736
+ source_repo_root = (workspace_root / str(repo_spec["path"])).resolve()
1737
+ if not source_repo_root.exists():
1738
+ raise SystemExit(
1739
+ f"shared repo missing for review open: {source_repo_root}\n"
1740
+ f"run `gr2 apply {workspace_root} --yes` first"
1741
+ )
1742
+
1743
+ # Bind the expected head from the HOST's own advertisement of the PR head,
1744
+ # BEFORE fetching — so the fetch that brings the bytes down is compared
1745
+ # against an independent authority, not against itself.
1746
+ expected_head = review_mod.host_pr_head_oid(source_repo_root, pr_number)
1747
+
1748
+ # Fetch the PR head into the source as pr/<n>; the core compares the fetched
1749
+ # ref against expected_head and refuses a wrong/tampered fetch before the seam.
1750
+ review_branch = _prepare_review_branch(workspace_root, repo, pr_number, None)
1751
+
1752
+ # Base pin = merge-base(head, base-branch tip). The base branch comes from the
1753
+ # PR itself, so the pin is what the PR is actually measured against.
1754
+ repo_slug = _repo_slug_from_url(remote_origin_url(source_repo_root) or "", repo)
1755
+ base_branch = get_platform_adapter(platform).pr_status(repo_slug, pr_number).ref.base_branch or "main"
1756
+ git(source_repo_root, "fetch", "--quiet", "origin", base_branch)
1757
+ base_tip = git(source_repo_root, "rev-parse", "FETCH_HEAD").stdout.strip()
1758
+ merged = git(source_repo_root, "merge-base", expected_head, base_tip)
1759
+ base_sha = merged.stdout.strip() if merged.returncode == 0 else base_tip
1760
+
1761
+ lane_repo_root = _lane_repo_root(workspace_root, owner_unit, resolved_lane, repo)
1762
+
1763
+ record = review_mod.open_review_lane(
1764
+ source_repo_root=source_repo_root,
1765
+ review_branch=review_branch,
1766
+ expected_head_sha=expected_head,
1767
+ base_sha=base_sha,
1768
+ lane_repo_root=lane_repo_root,
1769
+ workspace_root=workspace_root,
1770
+ echo=typer.echo,
1771
+ )
1772
+
1773
+ if run:
1774
+ import shlex
1775
+
1776
+ review_mod.run_in_review_lane(lane_repo_root, shlex.split(run), echo=typer.echo)
1777
+
1778
+ payload = {
1779
+ "workspace_root": str(workspace_root),
1780
+ "owner_unit": owner_unit,
1781
+ "repo": repo,
1782
+ "pr_number": pr_number,
1783
+ "lane_name": resolved_lane,
1784
+ "lane_repo_root": str(lane_repo_root),
1785
+ "review_record": record.to_dict(),
1786
+ }
1787
+ if json_output:
1788
+ typer.echo(json.dumps(payload, indent=2))
1789
+
1790
+
1791
+ @review_app.command("close")
1792
+ def review_close(
1793
+ workspace_root: Path,
1794
+ owner_unit: str,
1795
+ repo: str,
1796
+ pr_number: int,
1797
+ lane_name: Optional[str] = typer.Option(None, "--lane", help="Override the review lane name"),
1798
+ ) -> None:
1799
+ """Drop a review lane opened by ``gr2 review open``; the base workspace is untouched."""
1800
+ from . import review as review_mod
1801
+
1802
+ workspace_root = workspace_root.resolve()
1803
+ resolved_lane = lane_name or f"review-{pr_number}"
1804
+ lane_proto.validate_lane_path_component(owner_unit, "owner_unit")
1805
+ lane_proto.validate_lane_path_component(repo, "repo")
1806
+ lane_proto.validate_lane_path_component(resolved_lane, "lane_name")
1807
+ review_lane_root = lane_proto.lane_dir(workspace_root, owner_unit, resolved_lane)
1808
+ lane_repo_root = _lane_repo_root(workspace_root, owner_unit, resolved_lane, repo)
1809
+ review_mod.close_review_lane(
1810
+ lane_repo_root=lane_repo_root,
1811
+ review_lane_root=review_lane_root,
1812
+ echo=typer.echo,
1813
+ )
1814
+
1815
+
1816
+ @pr_app.command("create")
1817
+ def pr_create(
1818
+ workspace_root: Path,
1819
+ owner_unit: str,
1820
+ lane_name: Optional[str] = typer.Argument(None, help="Lane name. Defaults to the unit's current lane."),
1821
+ platform: str = typer.Option("github", "--platform", help="Platform adapter name"),
1822
+ base_branch: str = typer.Option("main", "--base", help="Base branch for created PRs"),
1823
+ draft: bool = typer.Option(False, "--draft", help="Create PRs as drafts"),
1824
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1825
+ ) -> None:
1826
+ """Create a grouped set of per-repo PRs for a lane."""
1827
+ workspace_root = workspace_root.resolve()
1828
+ resolved_lane = _resolve_lane_name(workspace_root, owner_unit, lane_name)
1829
+ lane_doc = lane_proto.load_lane_doc(workspace_root, owner_unit, resolved_lane)
1830
+ # A bound lane's PR is opened FROM the author's worktree, not a materialized
1831
+ # clone, so it routes to the push-from-worktree path (verb #4): push the
1832
+ # reviewed head, refusing an empty range. It does not use the group/adapter
1833
+ # flow, which assumes materialized per-repo clones.
1834
+ if lane_doc.get("lane_kind") == "bound":
1835
+ try:
1836
+ receipt = lane_proto.pr_create_bound_lane(workspace_root, owner_unit, resolved_lane)
1837
+ except SystemExit as exc:
1838
+ typer.echo(str(exc), err=True)
1839
+ raise typer.Exit(code=2)
1840
+ pushed = {
1841
+ "lane_kind": "bound", "lane": resolved_lane, "remote": receipt.remote,
1842
+ "branch": receipt.branch, "head": receipt.remote_sha,
1843
+ }
1844
+ typer.echo(json.dumps(pushed, indent=2))
1845
+ return
1846
+ spec = lane_proto.load_workspace_spec(workspace_root)
1847
+ adapter = get_platform_adapter(platform)
1848
+ branch_map = dict(lane_doc.get("branch_map", {}))
1849
+ repos: list[str] = []
1850
+ for repo_name in lane_doc.get("repos", []):
1851
+ repo_spec = next(repo for repo in spec.get("repos", []) if repo.get("name") == repo_name)
1852
+ repos.append(_repo_slug_from_url(str(repo_spec.get("url", "")), repo_name))
1853
+ payload = pr_ops.create_pr_group(
1854
+ workspace_root=workspace_root,
1855
+ owner_unit=owner_unit,
1856
+ lane_name=resolved_lane,
1857
+ title=resolved_lane,
1858
+ base_branch=base_branch,
1859
+ head_branch=str(branch_map.get(next(iter(lane_doc.get("repos", [])), resolved_lane), resolved_lane)),
1860
+ repos=repos,
1861
+ adapter=adapter,
1862
+ actor=f"agent:{owner_unit}",
1863
+ body=f"gr2 PR group for {owner_unit}/{resolved_lane}",
1864
+ draft=draft,
1865
+ )
1866
+ if json_output:
1867
+ typer.echo(json.dumps(payload, indent=2))
1868
+ else:
1869
+ typer.echo(json.dumps(payload, indent=2))
1870
+
1871
+
1872
+ @review_app.command("create-project")
1873
+ def review_create_project(
1874
+ workspace_root: Path,
1875
+ owner_unit: str = typer.Argument(..., help="Owner unit whose materialized lane to pin"),
1876
+ lane_name: str = typer.Argument(..., help="Materialized lane whose repos to pin at base..head"),
1877
+ carry_range: bool = typer.Option(False, "--carry-range", help="Also record each repo's base..head range INSIDE the gr commit, so a pre-push head reconstructs from the commit alone (self-describing). open-project then rebuilds it blobless+sparse without the head on any remote."),
1878
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1879
+ ) -> None:
1880
+ """CREATE a project-review-KIND gr commit from a materialized lane and print the
1881
+ ``gr:<sha>`` that `review open-project` consumes.
1882
+
1883
+ This is the producer half of "one gr commit opens one exact multi-repo review":
1884
+ each repo of the materialized lane (see `lane create`, which records the fork
1885
+ base) is pinned at its RECORDED fork base .. current head (the fork-base ruling,
1886
+ never HEAD^), so the review measures exactly what the lane changed. Then open it:
1887
+
1888
+ gr2 review open-project <workspace> gr:<sha> <owner> <review-lane> --enter
1889
+
1890
+ With ``--carry-range`` the commit ALSO carries each repo's range (format-patch
1891
+ base..head, captured from the lane), so a reviewer with neither the pre-push head
1892
+ nor the lane can reconstruct it from the commit alone; open-project reconstructs
1893
+ the head and asserts its TREE equals the pinned head's tree.
1894
+
1895
+ A lane with no recorded fork base, or a repo not materialized, is refused (never
1896
+ silently pinned against a guessed base).
1897
+ """
1898
+ from . import workspace_snapshot as ws_snap
1899
+ ws = workspace_root.resolve()
1900
+ try:
1901
+ pins = project_review.pins_from_lane(ws, owner_unit, lane_name)
1902
+ ranges: Optional[dict[str, str]] = None
1903
+ committers: Optional[dict[str, str]] = None
1904
+ if carry_range:
1905
+ ranges = {}
1906
+ committers = {}
1907
+ for p in pins:
1908
+ lane_repo = _lane_repo_root(ws, owner_unit, lane_name, p.key)
1909
+ cap = git(lane_repo, "format-patch", f"{p.base}..{p.head}", "--stdout")
1910
+ if cap.returncode != 0 or not cap.stdout.strip():
1911
+ raise ValueError(f"cannot capture range for {p.key} from {lane_repo}: {cap.stderr.strip() or 'empty range'}")
1912
+ ranges[p.key] = cap.stdout
1913
+ # Capture each commit's committer identity+date in apply order (oldest
1914
+ # first, the order format-patch/mailsplit use) so reconstruction can
1915
+ # re-stamp and reproduce the pinned head SHA, not merely its tree. The
1916
+ # lane repo holds the pre-push head; the remote does not.
1917
+ cm = git(lane_repo, "log", "--reverse", "--format=%cn%x09%ce%x09%cI", f"{p.base}..{p.head}")
1918
+ if cm.returncode != 0:
1919
+ raise ValueError(f"cannot capture committer metadata for {p.key} from {lane_repo}: {cm.stderr.strip()}")
1920
+ committers[p.key] = cm.stdout
1921
+ spec = project_review.make_spec(ws, pins, ranges=ranges, committers=committers)
1922
+ except (ValueError, ws_snap.WorkspaceSnapshotError) as exc:
1923
+ typer.echo(f"refused: {exc}", err=True)
1924
+ raise typer.Exit(code=2)
1925
+ if json_output:
1926
+ typer.echo(json.dumps({
1927
+ "gr_commit": f"gr:{spec.grip_commit}",
1928
+ "sha": spec.grip_commit,
1929
+ "pins": [
1930
+ {"key": p.key, "repo": p.repo, "path": p.path, "base": p.base, "head": p.head}
1931
+ for p in spec.pins
1932
+ ],
1933
+ }, indent=2))
1934
+ else:
1935
+ typer.echo(f"gr:{spec.grip_commit}")
1936
+ for p in spec.pins:
1937
+ typer.echo(f" {p.key}: {p.base[:12]}..{p.head[:12]} {p.repo}")
1938
+
1939
+
1940
+ @review_app.command("open-project")
1941
+ def review_open_project(
1942
+ workspace_root: Path,
1943
+ commit: str = typer.Argument(..., help="The project-review-KIND gr commit (gr:<sha> or bare sha); create one with `review create-project`"),
1944
+ owner_unit: str = typer.Argument(..., help="Owner unit whose lane the review enters"),
1945
+ lane_name: str = typer.Argument(..., help="Review lane name to materialize into and enter"),
1946
+ enter: bool = typer.Option(False, "--enter", help="Materialize the pinned heads and enter the review lane (the only open mode)"),
1947
+ sources_json: Optional[Path] = typer.Option(None, "--sources-json", help="Pre-push key -> {source, branch} JSON; clones NORMALLY (full). Prefer --local-source, which keeps the blobless+sparse path"),
1948
+ local_source: list[str] = typer.Option(None, "--local-source", help="key=PATH: a local clone that holds a pre-push head. Tops the shared mirror up from it so the blobless+sparse ephemeral path runs without the head on the remote. Repeatable"),
1949
+ prior_cwd: Optional[Path] = typer.Option(None, "--prior-cwd", help="Directory to restore on `review exit-gr` (defaults to the current directory)"),
1950
+ allow_local: bool = typer.Option(False, "--allow-local", help="Permit filesystem repository identities (fixtures/tests)"),
1951
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
1952
+ ) -> None:
1953
+ """MATERIALIZE a project review from a project-review-KIND gr commit and enter it.
1954
+
1955
+ Resolves each pinned head from its RECORDED REMOTE through the shared bare mirror
1956
+ and clones the review lane blobless + sparse (the ephemeral path). For a PRE-PUSH
1957
+ head — a gated review head is absent on the remote by design — pass `--local-source
1958
+ <key>=<path>` naming a local clone that holds it; the mirror is topped up from that
1959
+ clone and the SAME blobless+sparse path runs (no full clone). `--sources-json` is
1960
+ the older escape hatch that clones normally (full) and is kept for compatibility.
1961
+ Writes a receipt so `review exit-gr` restores the prior lane and cwd and removes the
1962
+ disposable ephemeral tree. Contrast `review open-gr`, which RECONSTRUCTS a
1963
+ review-BIND commit by `git am` over a carried range and asserts tree equality. A
1964
+ commit of the wrong kind is refused, naming the kind it found.
1965
+ """
1966
+ if not enter:
1967
+ raise typer.BadParameter("--enter is required (materialization is the only open mode)")
1968
+ if sources_json is not None and local_source:
1969
+ raise typer.BadParameter("--sources-json (full clone) and --local-source (sparse) are mutually exclusive")
1970
+ from . import open_gr_review
1971
+ sha = _strip_gr_prefix(commit)
1972
+ sources = None
1973
+ if sources_json is not None:
1974
+ source_rows = json.loads(sources_json.read_text())
1975
+ sources = {key: (Path(row["source"]), row["branch"]) for key, row in source_rows.items()}
1976
+ local_sources: Optional[dict[str, Path]] = None
1977
+ if local_source:
1978
+ local_sources = {}
1979
+ for item in local_source:
1980
+ if "=" not in item:
1981
+ raise typer.BadParameter(f"--local-source must be key=PATH, got {item!r}")
1982
+ key, _, path = item.partition("=")
1983
+ local_sources[key.strip()] = Path(path.strip())
1984
+ outcome = _review_call(
1985
+ open_gr_review.open_gr_enter,
1986
+ workspace_root.resolve(), owner_unit, lane_name, sha, sources,
1987
+ prior_cwd=(prior_cwd.resolve() if prior_cwd is not None else Path.cwd()),
1988
+ allow_local=allow_local,
1989
+ local_sources=local_sources,
1990
+ )
1991
+ if json_output:
1992
+ typer.echo(json.dumps(project_review.outcome_payload(outcome), indent=2))
1993
+ else:
1994
+ typer.echo(f"status={outcome.status} lane={lane_name} review_root={outcome.review_root}")
1995
+ # A refusal a stranger cannot read is the worst exit point: print each
1996
+ # failure's reason on the DEFAULT output, not only under --json.
1997
+ for failure in outcome.failures:
1998
+ typer.echo(f" refused[{failure.key}]: {failure.reason}")
1999
+ if outcome.status != "opened":
2000
+ raise typer.Exit(code=1)
2001
+
2002
+
2003
+ @review_app.command("exit-gr")
2004
+ def review_exit_gr(
2005
+ workspace_root: Path,
2006
+ owner_unit: str = typer.Argument(..., help="Owner unit whose review lane to exit"),
2007
+ review_root: Path = typer.Argument(..., help="The review lane root written by `open-project --enter` (holds .grip-open-gr.json)"),
2008
+ actor: str = typer.Option("agent:cli", "--actor", help="Actor recorded for the lane exit"),
2009
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
2010
+ ) -> None:
2011
+ """Exit a MATERIALIZED project review opened by `open-project --enter`: pop the
2012
+ review lane and restore the prior lane, returning the prior cwd from the receipt.
2013
+ This is the project-tier exit; `review close` drops a single-repo `review open` lane.
2014
+ """
2015
+ from . import open_gr_review
2016
+ result = open_gr_review.exit_gr_review(
2017
+ workspace_root.resolve(), owner_unit, review_root.resolve(), actor=actor
2018
+ )
2019
+ if json_output:
2020
+ typer.echo(json.dumps({
2021
+ "restored_lane": result.restored_lane,
2022
+ "restored_cwd": result.restored_cwd,
2023
+ "gr_commit": result.gr_commit,
2024
+ }, indent=2))
2025
+ else:
2026
+ typer.echo(f"restored_lane={result.restored_lane} restored_cwd={result.restored_cwd}")
2027
+
2028
+
2029
+ def _strip_gr_prefix(commit: str) -> str:
2030
+ """A review commit is addressed as ``gr:<sha>``; accept a bare sha too."""
2031
+ return commit[3:] if commit.startswith("gr:") else commit
2032
+
2033
+
2034
+ def _review_call(fn, *args, **kwargs):
2035
+ """Run an engine review function, converting a refusal or corruption into a
2036
+ clean nonzero exit (code 2) with real error text on stderr — never a
2037
+ traceback. The thin CLI is glue over the engine, and glue is exactly where a
2038
+ caught exception can turn into a silent success; this makes the loud engine
2039
+ failure surface loudly, not swallowed and not as a Python stack trace."""
2040
+ try:
2041
+ return fn(*args, **kwargs)
2042
+ except grip.GripReviewRefused as exc:
2043
+ typer.echo(
2044
+ f"refused: {exc.refusal}: expected {exc.expected!r}, observed {exc.observed!r}",
2045
+ err=True,
2046
+ )
2047
+ raise typer.Exit(code=2)
2048
+ except grip.GripCorruptError as exc:
2049
+ typer.echo(f"corrupt: {exc}", err=True)
2050
+ raise typer.Exit(code=2)
2051
+ except grip.GripInitError as exc:
2052
+ # The engine's own message already names what's missing and where
2053
+ # (workspace path included); add the remediation verb rather than
2054
+ # re-deriving the diagnosis, since _validate_grip_repo already did
2055
+ # the naming precisely.
2056
+ typer.echo(f"not_initialized: {exc} Run `gr2 grip init` to create it.", err=True)
2057
+ raise typer.Exit(code=2)
2058
+
2059
+
2060
+ _ROW_REQUIRED = ("key", "remote", "base", "head")
2061
+ # Every field of a rows-json entry is a string; a JSON number/bool/null/object
2062
+ # in any of them must refuse cleanly, not traceback downstream on a string op.
2063
+ _ROW_STRING_FIELDS = ("key", "remote", "base", "head", "path", "ref", "title", "body", "source", "range")
2064
+
2065
+
2066
+ def _normalize_review_row(raw: object) -> dict:
2067
+ """Fill a rows-json entry to the shape create_review_bind_commit expects:
2068
+ key/remote/base/head required; path defaults to key, ref to refs/heads/dev,
2069
+ title/body to empty, source resolved to an absolute path when present."""
2070
+ if not isinstance(raw, dict):
2071
+ raise typer.BadParameter(f"each rows-json entry must be a JSON object, got {type(raw).__name__}")
2072
+ # Type BEFORE presence: a number in a string field is a wrong-type error, not
2073
+ # a missing one, and must be named before the string ops downstream see it.
2074
+ for f in _ROW_STRING_FIELDS:
2075
+ if f in raw and not isinstance(raw[f], str):
2076
+ raise typer.BadParameter(
2077
+ f"rows-json entry {raw.get('key', '?')!r} field {f!r} must be a string, "
2078
+ f"got {type(raw[f]).__name__}"
2079
+ )
2080
+ missing = [f for f in _ROW_REQUIRED if not raw.get(f)]
2081
+ if missing:
2082
+ raise typer.BadParameter(f"rows-json entry {raw.get('key', '?')!r} is missing {', '.join(missing)}")
2083
+ row = {
2084
+ "key": raw["key"], "remote": raw["remote"], "base": raw["base"], "head": raw["head"],
2085
+ "path": raw.get("path") or raw["key"], "ref": raw.get("ref", "refs/heads/dev"),
2086
+ "title": raw.get("title", ""), "body": raw.get("body", ""),
2087
+ }
2088
+ if raw.get("source") and raw.get("range"):
2089
+ raise typer.BadParameter(
2090
+ f"rows-json entry {raw['key']!r}: 'source' and 'range' are mutually exclusive"
2091
+ )
2092
+ if raw.get("source"):
2093
+ row["source"] = str(Path(raw["source"]).resolve())
2094
+ if raw.get("range"):
2095
+ range_path = Path(raw["range"])
2096
+ try:
2097
+ row["range_patch"] = range_path.read_text()
2098
+ except OSError as exc:
2099
+ raise typer.BadParameter(
2100
+ f"rows-json entry {raw['key']!r} range {range_path}: {exc.strerror or exc}"
2101
+ )
2102
+ return row
2103
+
2104
+
2105
+ @review_app.command("bind")
2106
+ def review_bind(
2107
+ workspace_root: Path,
2108
+ key: Optional[str] = typer.Option(None, "--repo", help="Repository key for a single bound row"),
2109
+ remote: Optional[str] = typer.Option(None, "--remote", help="Remote URL or path of the row"),
2110
+ base: Optional[str] = typer.Option(None, "--base", help="Base SHA (must be the live remote head of --ref)"),
2111
+ head: Optional[str] = typer.Option(None, "--head", help="Reviewed head SHA (the pre-push head under review)"),
2112
+ ref: str = typer.Option("refs/heads/dev", "--ref", help="Target ref whose live head must equal --base"),
2113
+ path: Optional[str] = typer.Option(None, "--path", help="Workspace path for the row (defaults to --repo)"),
2114
+ source: Optional[Path] = typer.Option(None, "--source", help="Author clone holding the pre-push head; required to carry the range so open-gr can reconstruct"),
2115
+ from_range: Optional[Path] = typer.Option(None, "--from-range", help="A frozen range.patch (freeze-public-range.sh output). Carries the range so open-gr reconstructs, deriving the head-tree by applying it over --base in a throwaway clone — NO author clone that holds the head is needed. Exclusive with --source."),
2116
+ title: str = typer.Option("", "--title", help="Platform title text (NORM-hashed into the object)"),
2117
+ body: str = typer.Option("", "--body", help="Platform body text (NORM-hashed into the object)"),
2118
+ rows_json: Optional[Path] = typer.Option(None, "--rows-json", help="A JSON file with a list of row objects (key/remote/base/head, optional path/ref/title/body/source); binds ALL rows into ONE gr commit. Exclusive with the single-row flags."),
2119
+ ratified: Optional[str] = typer.Option(None, "--ratified", help="Named ratify receipt id: the sanctioned fix-forward when a --head is already on the remote"),
2120
+ ) -> None:
2121
+ """Bind a review gr commit for one or more repository rows; print ``gr:<commit>``.
2122
+
2123
+ One row from --repo/--remote/--base/--head, or many from --rows-json (all in
2124
+ ONE commit). For every row, reads the live remote head of its ref and refuses
2125
+ before writing if base is not that head (behind-must-be-0) or if head is
2126
+ already on the remote without --ratified. That printed id is the whole artifact.
2127
+ """
2128
+ single = any(v is not None for v in (key, remote, base, head))
2129
+ if rows_json is not None:
2130
+ if single or source is not None or from_range is not None:
2131
+ raise typer.BadParameter("--rows-json is exclusive with --repo/--remote/--base/--head/--source/--from-range")
2132
+ try:
2133
+ text = rows_json.read_text()
2134
+ except OSError as exc:
2135
+ raise typer.BadParameter(f"--rows-json {rows_json}: {exc.strerror or exc}")
2136
+ try:
2137
+ raw = json.loads(text)
2138
+ except json.JSONDecodeError as exc:
2139
+ raise typer.BadParameter(f"--rows-json {rows_json}: invalid JSON ({exc})")
2140
+ if not isinstance(raw, list) or not raw:
2141
+ raise typer.BadParameter("--rows-json must be a non-empty JSON list of row objects")
2142
+ rows = [_normalize_review_row(r) for r in raw]
2143
+ else:
2144
+ missing = [f"--{n}" for n, v in (("repo", key), ("remote", remote), ("base", base), ("head", head)) if not v]
2145
+ if missing:
2146
+ raise typer.BadParameter(f"{', '.join(missing)} required without --rows-json")
2147
+ if source is not None and from_range is not None:
2148
+ raise typer.BadParameter("--source and --from-range are mutually exclusive")
2149
+ row = {
2150
+ "key": key, "remote": remote, "path": path or key,
2151
+ "head": head, "base": base, "ref": ref, "title": title, "body": body,
2152
+ }
2153
+ if source is not None:
2154
+ row["source"] = str(source.resolve())
2155
+ if from_range is not None:
2156
+ try:
2157
+ row["range_patch"] = from_range.read_text()
2158
+ except OSError as exc:
2159
+ raise typer.BadParameter(f"--from-range {from_range}: {exc.strerror or exc}")
2160
+ rows = [row]
2161
+ commit = _review_call(grip.create_review_bind_commit, workspace_root.resolve(), rows, ratified=ratified)
2162
+ typer.echo(f"gr:{commit}")
2163
+
2164
+
2165
+ @review_app.command("open-gr")
2166
+ def review_open_gr(
2167
+ workspace_root: Path,
2168
+ commit: str = typer.Argument(..., help="The review bind commit, as gr:<sha> or a bare sha"),
2169
+ key: Optional[str] = typer.Option(None, "--repo", help="Repository key to materialize; omit to materialize every bound row into <lane-dir>/<key>"),
2170
+ lane_dir: Path = typer.Option(..., "--lane-dir", help="Directory to materialize into (the row's clone for one --repo, or a parent holding one subdir per row)"),
2171
+ enter: bool = typer.Option(False, "--enter", help="Materialize the reconstruction (the only open mode)"),
2172
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
2173
+ ) -> None:
2174
+ """RECONSTRUCT a review lane from a review-BIND commit: clone the recorded remote,
2175
+ check out the bound base, ``git am`` the carried range, and assert the resulting
2176
+ tree equals the bound head-tree. A tree mismatch refuses. With no --repo, every
2177
+ bound row is reconstructed into its own subdirectory of --lane-dir. Contrast
2178
+ `review open-project`, which MATERIALIZES a project-review-KIND commit's pinned
2179
+ heads from their recorded remotes and enters the review lane."""
2180
+ if not enter:
2181
+ raise typer.BadParameter("--enter is required (reconstruction is the only open mode)")
2182
+ sha = _strip_gr_prefix(commit)
2183
+ root = lane_dir.resolve()
2184
+ # close-gr reclaims the WHOLE --lane-dir, so open-gr must OWN it: refuse a
2185
+ # pre-existing non-empty dir rather than write a lane into someone's files and
2186
+ # let teardown remove them. An absent or empty dir is fine (open-gr fills it).
2187
+ if root.exists() and any(root.iterdir()):
2188
+ typer.echo(
2189
+ f"refused: lane_dir_not_empty: --lane-dir {root} exists and is not empty; "
2190
+ "pass a fresh directory (close-gr reclaims the whole lane)",
2191
+ err=True,
2192
+ )
2193
+ raise typer.Exit(code=2)
2194
+ if key is None:
2195
+ keys = _review_call(grip.review_row_keys, workspace_root.resolve(), sha)
2196
+ if not keys:
2197
+ typer.echo("refused: no_rows: the gr commit binds no repository rows", err=True)
2198
+ raise typer.Exit(code=2)
2199
+ if len(keys) == 1:
2200
+ # review-run door 3: a SINGLE bound row with no --repo materializes into
2201
+ # --lane-dir ITSELF (the lane IS the clone), exactly as `--repo <key>` would,
2202
+ # so the marker sits AT the tree root and `review run <lane-dir>` works.
2203
+ # Laying the one row out under <lane-dir>/<key> put the marker one level
2204
+ # ABOVE the only tree, after which neither `review run <lane-dir>` (no git
2205
+ # repo there) nor `review run <lane-dir>/<key>` (no marker there) could run.
2206
+ key = keys[0]
2207
+ else:
2208
+ results = {
2209
+ row_key: _review_call(
2210
+ grip.reconstruct_review_lane, workspace_root.resolve(), sha, row_key, root / row_key
2211
+ )
2212
+ for row_key in keys
2213
+ }
2214
+ from . import open_gr_review
2215
+ open_gr_review.write_open_gr_marker(root, sha, results)
2216
+ if json_output:
2217
+ typer.echo(json.dumps(results, indent=2))
2218
+ else:
2219
+ for row_key, res in results.items():
2220
+ match = res["bound_head_tree"] == res["reconstructed_tree"]
2221
+ typer.echo(f"{row_key}: lane={res['lane']} tree_match={match}")
2222
+ return
2223
+ result = _review_call(
2224
+ grip.reconstruct_review_lane, workspace_root.resolve(), sha, key, root
2225
+ )
2226
+ from . import open_gr_review
2227
+ open_gr_review.write_open_gr_marker(root, sha, {key: result})
2228
+ if json_output:
2229
+ typer.echo(json.dumps(result, indent=2))
2230
+ else:
2231
+ typer.echo(f"lane: {result['lane']}")
2232
+ typer.echo(f"bound_head: {result['bound_head']}")
2233
+ typer.echo(f"reconstructed_head: {result['reconstructed_head']}")
2234
+ typer.echo(f"tree_match: {result['bound_head_tree'] == result['reconstructed_tree']}")
2235
+
2236
+
2237
+ @review_app.command("close-gr")
2238
+ def review_close_gr(
2239
+ lane_dir: Path = typer.Argument(..., help="The open-gr reconstruction lane (the --lane-dir from `review open-gr --enter`) to reclaim"),
2240
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
2241
+ ) -> None:
2242
+ """Reclaim an `open-gr --enter` reconstruction lane: verify the open-gr marker and
2243
+ remove the disposable tree. The teardown counterpart to `open-gr --enter`; unlike
2244
+ `exit-gr` (the open-PROJECT pop) it needs no OWNER_UNIT, because open-gr pushes no
2245
+ lane and changes no cwd. Refuses a directory without an open-gr marker rather than
2246
+ remove an arbitrary path."""
2247
+ from . import open_gr_review
2248
+ try:
2249
+ result = open_gr_review.close_open_gr_lane(lane_dir.resolve())
2250
+ except open_gr_review.OpenGrReviewError as exc:
2251
+ typer.echo(f"refused: {exc}", err=True)
2252
+ raise typer.Exit(code=2)
2253
+ if json_output:
2254
+ typer.echo(json.dumps(result, indent=2))
2255
+ else:
2256
+ typer.echo(f"reclaimed {result['reclaimed']} (gr:{result['gr_commit']})")
2257
+ preserved = result.get("preserved_run")
2258
+ if preserved:
2259
+ typer.echo(f"review-run receipt kept at {preserved['receipt']}")
2260
+ if preserved.get("log"):
2261
+ typer.echo(f"review-run output log kept at {preserved['log']}")
2262
+
2263
+
2264
+ @review_app.command("run")
2265
+ def review_run(
2266
+ lane_dir: Path = typer.Argument(..., help="The open-gr reconstruction lane (the --lane-dir from `review open-gr --enter`)"),
2267
+ package: Optional[str] = typer.Option(None, "--package", help="Importable package name to bind the install to the lane (its __file__ must resolve under the lane). Optional if the lane's .review-install declares `package`."),
2268
+ python: Optional[str] = typer.Option(None, "--python", help="Interpreter to build the lane venv from; defaults to the running interpreter. Recorded in the receipt."),
2269
+ system_site_packages: bool = typer.Option(False, "--system-site-packages", help="Create the lane venv with --system-site-packages (host tools visible)"),
2270
+ install: Optional[str] = typer.Option(None, "--install", help="Install command (shell-split); `{venv}` and `{lane}` are substituted per token, same as the .review-install hint. Defaults to the lane's .review-install hint, else `<venv python> -m pip install -e <lane>`"),
2271
+ json_output: bool = typer.Option(False, "--json", help="Emit the receipt as JSON"),
2272
+ pytest_args: Optional[List[str]] = typer.Argument(None, help="Args passed to pytest after `--` (every -k/-p/path filter is recorded)"),
2273
+ ) -> None:
2274
+ """The review-owned in-lane test run: create `<lane>/.venv`, install the
2275
+ reconstructed tree, and run pytest — but only after the lane's tree is proven to
2276
+ equal the bound head-tree and the import resolves under the lane. Counts come
2277
+ from pytest's summary line, never the exit code; a zero-test or unparseable run
2278
+ is a refusal, not a green."""
2279
+ import shlex
2280
+
2281
+ from . import review_run as rr
2282
+
2283
+ install_cmd = shlex.split(install) if install else None
2284
+ try:
2285
+ receipt = rr.run_review_lane(
2286
+ lane_dir.resolve(),
2287
+ package=package,
2288
+ pytest_args=list(pytest_args or []),
2289
+ python=python,
2290
+ install=install_cmd,
2291
+ system_site_packages=system_site_packages,
2292
+ )
2293
+ except rr.ReviewRunRefused as exc:
2294
+ typer.echo(f"refused: {exc}", err=True)
2295
+ raise typer.Exit(code=2)
2296
+ if json_output:
2297
+ typer.echo(json.dumps(receipt, indent=2))
2298
+ else:
2299
+ typer.echo(
2300
+ f"{receipt['result']}: selected={receipt['selected']} "
2301
+ f"passed={receipt['passed']} failed={receipt['failed']} "
2302
+ f"skipped={receipt['skipped']} xfailed={receipt['xfailed']} "
2303
+ f"errors={receipt['errors']}"
2304
+ )
2305
+ typer.echo(f"bound_head_tree: {receipt['bound_head_tree']}")
2306
+ typer.echo(f"install resolved: {receipt['resolved_install_path']}")
2307
+ if receipt["result"] != "green":
2308
+ raise typer.Exit(code=1)
2309
+
2310
+
2311
+ @review_app.command("verify")
2312
+ def review_verify(
2313
+ workspace_root: Path,
2314
+ commit: str = typer.Argument(..., help="The review bind commit, as gr:<sha> or a bare sha"),
2315
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
2316
+ ) -> None:
2317
+ """Recompute the review gr commit tree from its own objects; a mismatch is
2318
+ corruption, not drift."""
2319
+ result = _review_call(grip.verify_review_commit, workspace_root.resolve(), _strip_gr_prefix(commit))
2320
+ if json_output:
2321
+ typer.echo(json.dumps(result, indent=2, default=str))
2322
+ else:
2323
+ typer.echo(f"tree_matches: {result['tree_matches']}")
2324
+ if not result.get("tree_matches"):
2325
+ raise typer.Exit(code=1)
2326
+
2327
+
2328
+ @pr_app.command("status")
2329
+ def pr_status(
2330
+ workspace_root: Path,
2331
+ owner_unit: str,
2332
+ lane_name: Optional[str] = typer.Argument(None, help="Lane name. Defaults to the unit's current lane."),
2333
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
2334
+ ) -> None:
2335
+ """Show grouped PR status for a lane."""
2336
+ workspace_root = workspace_root.resolve()
2337
+ resolved_lane = _resolve_lane_name(workspace_root, owner_unit, lane_name)
2338
+ group_path, group = _find_pr_group(workspace_root, owner_unit, resolved_lane)
2339
+ adapter = get_platform_adapter(str(group.get("platform", "github")))
2340
+ group = pr_ops.check_pr_group_status(
2341
+ workspace_root=workspace_root,
2342
+ pr_group_id=str(group["pr_group_id"]),
2343
+ adapter=adapter,
2344
+ actor=f"agent:{owner_unit}",
2345
+ )
2346
+ statuses = []
2347
+ for pr_info in group.get("prs", []):
2348
+ repo = str(pr_info["repo"])
2349
+ number = int(pr_info["pr_number"])
2350
+ statuses.append(adapter.pr_status(repo, number).as_dict())
2351
+ payload = {
2352
+ "pr_group_id": group["pr_group_id"],
2353
+ "owner_unit": owner_unit,
2354
+ "lane_name": resolved_lane,
2355
+ "group_state": _group_state_from_statuses(statuses),
2356
+ "statuses": statuses,
2357
+ "state_path": str(group_path),
2358
+ }
2359
+ if json_output:
2360
+ typer.echo(json.dumps(payload, indent=2))
2361
+ else:
2362
+ typer.echo(json.dumps(payload, indent=2))
2363
+
2364
+
2365
+ @pr_app.command("checks")
2366
+ def pr_checks(
2367
+ workspace_root: Path,
2368
+ owner_unit: str,
2369
+ lane_name: Optional[str] = typer.Argument(None, help="Lane name. Defaults to the unit's current lane."),
2370
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
2371
+ ) -> None:
2372
+ """Show grouped PR checks for a lane."""
2373
+ workspace_root = workspace_root.resolve()
2374
+ resolved_lane = _resolve_lane_name(workspace_root, owner_unit, lane_name)
2375
+ group_path, group = _find_pr_group(workspace_root, owner_unit, resolved_lane)
2376
+ adapter = get_platform_adapter(str(group.get("platform", "github")))
2377
+ rows = []
2378
+ for pr_info in group.get("prs", []):
2379
+ ref = PRRef(repo=str(pr_info["repo"]), number=int(pr_info["pr_number"]), url=pr_info.get("url"))
2380
+ rows.append(
2381
+ {
2382
+ "repo": ref.repo,
2383
+ "number": ref.number,
2384
+ "checks": [item.as_dict() for item in adapter.pr_checks(ref.repo, int(ref.number))],
2385
+ }
2386
+ )
2387
+ payload = {
2388
+ "pr_group_id": group["pr_group_id"],
2389
+ "owner_unit": owner_unit,
2390
+ "lane_name": resolved_lane,
2391
+ "checks": rows,
2392
+ "state_path": str(group_path),
2393
+ }
2394
+ if json_output:
2395
+ typer.echo(json.dumps(payload, indent=2))
2396
+ else:
2397
+ typer.echo(json.dumps(payload, indent=2))
2398
+
2399
+
2400
+ @pr_app.command("merge")
2401
+ def pr_merge(
2402
+ workspace_root: Path,
2403
+ owner_unit: str,
2404
+ lane_name: Optional[str] = typer.Argument(None, help="Lane name. Defaults to the unit's current lane."),
2405
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
2406
+ method: Optional[str] = typer.Option(
2407
+ None,
2408
+ "--method",
2409
+ "-m",
2410
+ help="merge/squash/rebase. Defaults to a merge commit.",
2411
+ ),
2412
+ ) -> None:
2413
+ """Merge grouped PRs for a lane."""
2414
+ workspace_root = workspace_root.resolve()
2415
+ resolved_lane = _resolve_lane_name(workspace_root, owner_unit, lane_name)
2416
+ group_path, group = _find_pr_group(workspace_root, owner_unit, resolved_lane)
2417
+ adapter = get_platform_adapter(str(group.get("platform", "github")))
2418
+ merged: list[str] = []
2419
+ failed: list[dict[str, object]] = []
2420
+ if failed:
2421
+ group["group_state"] = "partially_merged" if merged else "merge_failed"
2422
+ group["merged"] = merged
2423
+ group_path.write_text(json.dumps(group, indent=2) + "\n")
2424
+ payload = {
2425
+ "status": "partial_failure" if merged else "failed",
2426
+ "pr_group_id": group["pr_group_id"],
2427
+ "owner_unit": owner_unit,
2428
+ "lane_name": resolved_lane,
2429
+ "merged": merged,
2430
+ "failed": failed,
2431
+ "state_path": str(group_path),
2432
+ }
2433
+ if json_output:
2434
+ typer.echo(json.dumps(payload, indent=2))
2435
+ else:
2436
+ typer.echo(json.dumps(payload, indent=2))
2437
+ raise typer.Exit(code=1)
2438
+ try:
2439
+ result = pr_ops.merge_pr_group(
2440
+ workspace_root=workspace_root,
2441
+ pr_group_id=str(group["pr_group_id"]),
2442
+ adapter=adapter,
2443
+ actor=f"agent:{owner_unit}",
2444
+ method=pr_ops.resolve_merge_method(
2445
+ explicit=method,
2446
+ configured=_configured_merge_method(workspace_root),
2447
+ ),
2448
+ verification_targets=_merge_verification_targets(
2449
+ workspace_root,
2450
+ ),
2451
+ report=lambda message: typer.echo(message, err=True),
2452
+ )
2453
+ completed = list(result.get("completed", []))
2454
+ merged = [str(item["repo"]) for item in completed]
2455
+ payload = {
2456
+ "pr_group_id": group["pr_group_id"],
2457
+ "owner_unit": owner_unit,
2458
+ "lane_name": resolved_lane,
2459
+ "merged": merged,
2460
+ "merged_receipts": completed,
2461
+ "state_path": str(group_path),
2462
+ }
2463
+ except pr_ops.PRMergeError as exc:
2464
+ completed = [item.as_dict() for item in exc.completed]
2465
+ merged = [str(item["repo"]) for item in completed]
2466
+ if exc.outcome_unknown:
2467
+ group["group_state"] = "merge_outcome_unknown"
2468
+ elif exc.operation_acknowledged:
2469
+ group["group_state"] = "merge_postcondition_failed"
2470
+ else:
2471
+ group["group_state"] = "partially_merged" if merged else "merge_failed"
2472
+ group["merged"] = merged
2473
+ group["completed"] = completed
2474
+ group_path.write_text(json.dumps(group, indent=2) + "\n")
2475
+ payload = {
2476
+ "status": (
2477
+ "outcome_unknown"
2478
+ if exc.outcome_unknown
2479
+ else (
2480
+ "postcondition_failed"
2481
+ if exc.operation_acknowledged
2482
+ else ("partial_failure" if merged else "failed")
2483
+ )
2484
+ ),
2485
+ "pr_group_id": group["pr_group_id"],
2486
+ "owner_unit": owner_unit,
2487
+ "lane_name": resolved_lane,
2488
+ "merged": merged,
2489
+ "merged_receipts": completed,
2490
+ "failed": [
2491
+ {
2492
+ "repo": exc.repo,
2493
+ "number": exc.pr_number,
2494
+ "reason": exc.reason,
2495
+ "operation_acknowledged": exc.operation_acknowledged,
2496
+ }
2497
+ ],
2498
+ "state_path": str(group_path),
2499
+ }
2500
+ if json_output:
2501
+ typer.echo(json.dumps(payload, indent=2))
2502
+ else:
2503
+ typer.echo(json.dumps(payload, indent=2))
2504
+ raise typer.Exit(code=1)
2505
+ if json_output:
2506
+ typer.echo(json.dumps(payload, indent=2))
2507
+ else:
2508
+ typer.echo(json.dumps(payload, indent=2))
2509
+
2510
+
2511
+ def main() -> None:
2512
+ app()
2513
+
2514
+
2515
+ if __name__ == "__main__":
2516
+ app()