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
@@ -0,0 +1,985 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import hashlib
5
+ import os
6
+ import re
7
+ import shutil
8
+ import stat
9
+ import tempfile
10
+ import tomllib
11
+ import contextlib
12
+ from pathlib import Path, PurePosixPath, PureWindowsPath
13
+
14
+ import yaml
15
+
16
+ from . import grip
17
+ from .gitops import git
18
+ from gr2.prototypes import lane_workspace_prototype as lane_proto
19
+ from gr2.prototypes import repo_maintenance_prototype as repo_proto
20
+
21
+
22
+ def _transaction_phase_hook(_phase: str) -> None:
23
+ """Inert production seam; scratch interruption tests replace this callable."""
24
+ return None
25
+
26
+
27
+ def gr1_manifest_path(workspace_root: Path) -> Path:
28
+ return workspace_root / ".gitgrip" / "spaces" / "main" / "gripspace.yml"
29
+
30
+
31
+ def gr1_agents_path(workspace_root: Path) -> Path:
32
+ return workspace_root / ".gitgrip" / "agents.toml"
33
+
34
+
35
+ def gr1_state_paths(workspace_root: Path) -> dict[str, Path]:
36
+ gitgrip = workspace_root / ".gitgrip"
37
+ return {
38
+ "state_json": gitgrip / "state.json",
39
+ "sync_state_json": gitgrip / "sync-state.json",
40
+ "griptrees_json": gitgrip / "griptrees.json",
41
+ "manifest_yaml": gr1_manifest_path(workspace_root),
42
+ }
43
+
44
+
45
+ def detect_gr1_workspace(workspace_root: Path) -> dict[str, object]:
46
+ manifest_path = gr1_manifest_path(workspace_root)
47
+ if not manifest_path.exists():
48
+ return {
49
+ "detected": False,
50
+ "workspace_root": str(workspace_root),
51
+ "reason": f"missing {manifest_path}",
52
+ }
53
+
54
+ manifest = yaml.safe_load(manifest_path.read_text()) or {}
55
+ repos = manifest.get("repos", {}) or {}
56
+ agents_doc = _load_agents_doc(workspace_root)
57
+ agent_names = sorted(((agents_doc.get("agents") or {}) or {}).keys())
58
+ reference_repos = sorted(name for name, repo in repos.items() if bool((repo or {}).get("reference", False)))
59
+ writable_repos = sorted(name for name in repos.keys() if name not in reference_repos)
60
+ state_paths = gr1_state_paths(workspace_root)
61
+
62
+ return {
63
+ "detected": True,
64
+ "workspace_root": str(workspace_root),
65
+ "manifest_path": str(manifest_path),
66
+ "repo_count": len(repos),
67
+ "reference_repo_count": len(reference_repos),
68
+ "agent_count": len(agent_names),
69
+ "repos": sorted(repos.keys()),
70
+ "reference_repos": reference_repos,
71
+ "writable_repos": writable_repos,
72
+ "agents": agent_names,
73
+ "state_files": {key: str(path) for key, path in state_paths.items() if path.exists()},
74
+ }
75
+
76
+
77
+ def migrate_gr1_workspace(workspace_root: Path, *, force: bool = False) -> dict[str, object]:
78
+ detection = detect_gr1_workspace(workspace_root)
79
+ if not detection["detected"]:
80
+ raise SystemExit(detection["reason"])
81
+
82
+ grip_dir = workspace_root / ".grip"
83
+ spec_path = grip_dir / "workspace_spec.toml"
84
+ if spec_path.exists() and not force:
85
+ raise SystemExit(f"refusing to overwrite existing gr2 workspace spec: {spec_path}")
86
+
87
+ manifest = yaml.safe_load(Path(str(detection["manifest_path"])).read_text()) or {}
88
+ agents_doc = _load_agents_doc(workspace_root)
89
+ compiled = compile_gr1_to_workspace_spec(workspace_root, manifest, agents_doc)
90
+
91
+ grip_dir.mkdir(parents=True, exist_ok=True)
92
+ spec_path.write_text(render_workspace_spec(compiled))
93
+
94
+ migration_dir = grip_dir / "migrations" / "gr1"
95
+ migration_dir.mkdir(parents=True, exist_ok=True)
96
+ snapshots = preserve_gr1_state(workspace_root, migration_dir)
97
+ summary_path = migration_dir / "migration-summary.json"
98
+ summary = {
99
+ "source": "gr1",
100
+ "workspace_root": str(workspace_root),
101
+ "workspace_spec_path": str(spec_path),
102
+ "repo_count": len(compiled["repos"]),
103
+ "unit_count": len(compiled["units"]),
104
+ "snapshots": snapshots,
105
+ }
106
+ summary_path.write_text(json.dumps(summary, indent=2) + "\n")
107
+
108
+ return {
109
+ **summary,
110
+ "units": [unit["name"] for unit in compiled["units"]],
111
+ "repos": [repo["name"] for repo in compiled["repos"]],
112
+ }
113
+
114
+
115
+ def _compiled_gr1_spec(workspace_root: Path) -> tuple[Path, bytes, bytes, dict[str, object]]:
116
+ """Compile and parse the generated bytes before a caller mutates state."""
117
+ manifest_path = gr1_manifest_path(workspace_root)
118
+ if not manifest_path.is_file():
119
+ raise SystemExit(f"missing canonical gripspace manifest: {manifest_path}")
120
+ manifest_bytes = manifest_path.read_bytes()
121
+ try:
122
+ manifest = yaml.safe_load(manifest_bytes) or {}
123
+ if not isinstance(manifest, dict) or not isinstance(manifest.get("repos", {}), dict):
124
+ raise ValueError("canonical gripspace manifest repos must be a mapping")
125
+ agents_doc = _load_agents_doc(workspace_root)
126
+ if not isinstance(agents_doc, dict):
127
+ raise ValueError("gr1 agents manifest must be a mapping")
128
+ compiled = compile_gr1_to_workspace_spec(workspace_root, manifest, agents_doc)
129
+ spec_bytes = render_workspace_spec(compiled).encode()
130
+ tomllib.loads(spec_bytes.decode())
131
+ except (yaml.YAMLError, OSError, TypeError, ValueError, KeyError, tomllib.TOMLDecodeError) as exc:
132
+ raise SystemExit(f"cannot compile canonical gripspace manifest: {exc}") from exc
133
+ return manifest_path, manifest_bytes, spec_bytes, compiled
134
+
135
+
136
+ def bootstrap_gr1_workspace(workspace_root: Path) -> dict[str, object]:
137
+ """Compile the authoritative gr1 manifest and make its gr2 object store usable.
138
+
139
+ This is intentionally narrower than migration: it does not snapshot mutable
140
+ gr1 state or materialize repositories. It first builds and parses the
141
+ exact WorkspaceSpec bytes, so malformed source cannot create ``.grip``.
142
+ The only persistent result is the git-native grip store plus that generated
143
+ spec. Repeating the operation accepts only the identical compiled bytes.
144
+ """
145
+ manifest_path, manifest_bytes, spec_bytes, compiled = _compiled_gr1_spec(workspace_root)
146
+
147
+ grip_dir = workspace_root / ".grip"
148
+ spec_path = grip_dir / "workspace_spec.toml"
149
+ git_dir = grip_dir / ".git"
150
+ for path, label in ((grip_dir, ".grip"), (git_dir, ".grip/.git"), (spec_path, ".grip/workspace_spec.toml")):
151
+ _refuse_symlink(path, label)
152
+
153
+ grip_dir_existed = grip_dir.exists()
154
+ git_dir_existed = git_dir.exists()
155
+ spec_existed = spec_path.exists()
156
+ if grip_dir.exists() and not grip_dir.is_dir():
157
+ raise SystemExit(f"refusing bootstrap: {grip_dir} is not a directory")
158
+ if git_dir.is_file():
159
+ raise SystemExit(f"refusing bootstrap: {git_dir} is not a directory")
160
+ if git_dir.is_dir():
161
+ probe = git(grip_dir, "rev-parse", "--is-inside-work-tree")
162
+ if probe.returncode != 0 or probe.stdout.strip() != "true":
163
+ raise SystemExit(f"refusing bootstrap: {grip_dir} is not a valid git object store")
164
+
165
+ if spec_path.exists() and spec_path.read_bytes() != spec_bytes:
166
+ raise SystemExit(f"refusing bootstrap: existing generated spec differs: {spec_path}")
167
+
168
+ already_initialized = git_dir.is_dir() and spec_path.exists()
169
+ if not already_initialized:
170
+ try:
171
+ grip.grip_init(workspace_root)
172
+ if not spec_path.exists():
173
+ _atomic_write(spec_path, spec_bytes)
174
+ except grip.GripInitError as exc:
175
+ _rollback_bootstrap(grip_dir, git_dir, spec_path, grip_dir_existed, git_dir_existed, spec_existed)
176
+ raise SystemExit(str(exc)) from exc
177
+ except BaseException:
178
+ _rollback_bootstrap(grip_dir, git_dir, spec_path, grip_dir_existed, git_dir_existed, spec_existed)
179
+ raise
180
+
181
+ return {
182
+ "status": "already_initialized" if already_initialized else "initialized",
183
+ "workspace_root": str(workspace_root),
184
+ "manifest_path": str(manifest_path),
185
+ "workspace_spec_path": str(spec_path),
186
+ "grip_repo_path": str(grip_dir),
187
+ "repo_count": len(compiled["repos"]),
188
+ "unit_count": len(compiled["units"]),
189
+ "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
190
+ "workspace_spec_sha256": hashlib.sha256(spec_bytes).hexdigest(),
191
+ }
192
+
193
+
194
+ def regenerate_gr1_workspace(workspace_root: Path, *, expected_spec_sha256: str, receipt_path: Path) -> dict[str, object]:
195
+ """Atomically replace only an already-generated spec after strict binding."""
196
+ workspace_root = workspace_root.resolve()
197
+ if not isinstance(expected_spec_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_spec_sha256):
198
+ raise SystemExit("--expected-spec-sha256 must be a lowercase 64-hex SHA-256")
199
+ manifest_path, manifest_bytes, candidate, compiled = _compiled_gr1_spec(workspace_root)
200
+ agents_path = gr1_agents_path(workspace_root)
201
+ _refuse_authority_symlinks(workspace_root, manifest_path, "canonical gripspace manifest")
202
+ _refuse_authority_symlinks(workspace_root, agents_path, "gr1 agents manifest")
203
+ agents_bytes = agents_path.read_bytes()
204
+ grip_dir = workspace_root / ".grip"
205
+ spec_path = grip_dir / "workspace_spec.toml"
206
+ git_dir = grip_dir / ".git"
207
+ for path, label in ((grip_dir, ".grip"), (git_dir, ".grip/.git"), (spec_path, ".grip/workspace_spec.toml"), (grip_dir / "state", ".grip/state")):
208
+ _refuse_symlink(path, label)
209
+ if not git_dir.is_dir() or not spec_path.is_file():
210
+ raise SystemExit("regeneration requires an initialized object store and regular generated spec")
211
+ _refuse_active_lane_state(workspace_root)
212
+ _require_clean_regeneration_store(grip_dir)
213
+ _recover_or_refuse_prepared_marker(receipt_path, workspace_root, spec_path)
214
+ _require_new_receipt_target(receipt_path)
215
+
216
+ lock_path = grip_dir / "state" / "workspace_spec_regeneration.lock"
217
+ with _regeneration_lock(lock_path):
218
+ # The compare occurs inside the critical section, so two regenerators
219
+ # cannot both accept the same old bytes and silently overwrite each other.
220
+ # A lane can become active while a regenerator waits for the lock, so the
221
+ # active-lane guard is re-checked HERE, after acquisition, not only before
222
+ # (the seam lets a test activate a lane at exactly this moment).
223
+ _transaction_phase_hook("lock_acquired")
224
+ _refuse_active_lane_state(workspace_root)
225
+ locked_manifest, locked_manifest_bytes, locked_candidate, locked_compiled = _compiled_gr1_spec(workspace_root)
226
+ locked_agents = gr1_agents_path(workspace_root)
227
+ _refuse_authority_symlinks(workspace_root, locked_manifest, "canonical gripspace manifest")
228
+ _refuse_authority_symlinks(workspace_root, locked_agents, "gr1 agents manifest")
229
+ locked_agents_bytes = locked_agents.read_bytes()
230
+ if locked_manifest != manifest_path or locked_manifest_bytes != manifest_bytes or locked_agents_bytes != agents_bytes or locked_candidate != candidate:
231
+ raise SystemExit("regeneration source authority changed while awaiting lock")
232
+ old = spec_path.read_bytes()
233
+ old_hash = hashlib.sha256(old).hexdigest()
234
+ if old_hash != expected_spec_sha256:
235
+ raise SystemExit(f"expected old spec hash {expected_spec_sha256}, observed {old_hash}")
236
+ if old == candidate:
237
+ raise SystemExit("regeneration candidate is identical to existing generated spec")
238
+ head = git(grip_dir, "rev-parse", "HEAD").stdout.strip()
239
+ lane_snapshot = _lane_snapshot(workspace_root)
240
+ status_snapshot = _store_status(grip_dir)
241
+ sidecar_path = _sidecar_path(receipt_path, old_hash)
242
+ _require_new_receipt_target(sidecar_path)
243
+ _atomic_write(sidecar_path, old)
244
+ payload = {
245
+ "schema": "gr2-workspace-regeneration/v2",
246
+ "status": "regenerated",
247
+ "workspace_root": str(workspace_root),
248
+ "manifest_path": str(manifest_path),
249
+ "workspace_spec_path": str(spec_path),
250
+ "grip_repo_path": str(grip_dir),
251
+ "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
252
+ "agents_sha256": hashlib.sha256(agents_bytes).hexdigest(),
253
+ "expected_old_spec_sha256": expected_spec_sha256,
254
+ "observed_old_spec_sha256": old_hash,
255
+ "new_spec_sha256": hashlib.sha256(candidate).hexdigest(),
256
+ "sidecar_relative_path": sidecar_path.name,
257
+ "sidecar_sha256": old_hash,
258
+ "object_store_head": head,
259
+ "object_store_status": status_snapshot,
260
+ "lane_snapshot": lane_snapshot,
261
+ "operations": ["atomic_workspace_spec_replace"],
262
+ "materialization": False,
263
+ "repo_count": len(compiled["repos"]),
264
+ "unit_count": len(compiled["units"]),
265
+ }
266
+ marker_path = _prepared_marker_path(receipt_path)
267
+ _require_new_receipt_target(marker_path)
268
+ marker = {"schema": "gr2-workspace-regeneration-prepared/v1", "phase": "prepared", "forward_receipt": receipt_path.name, "payload": payload}
269
+ _atomic_write(marker_path, (json.dumps(marker, indent=2, sort_keys=True) + "\n").encode())
270
+ _transaction_phase_hook("marker_durable")
271
+ try:
272
+ _atomic_write(spec_path, candidate)
273
+ _transaction_phase_hook("spec_replaced")
274
+ if hashlib.sha256(spec_path.read_bytes()).hexdigest() != hashlib.sha256(candidate).hexdigest():
275
+ raise SystemExit("atomic generated spec replacement did not bind candidate bytes")
276
+ if git(grip_dir, "rev-parse", "HEAD").stdout.strip() != head:
277
+ raise SystemExit("regeneration changed object-store HEAD")
278
+ _write_new_receipt(receipt_path, payload)
279
+ _transaction_phase_hook("receipt_durable")
280
+ marker_path.unlink()
281
+ _transaction_phase_hook("marker_cleared")
282
+ except BaseException:
283
+ try:
284
+ _atomic_write(spec_path, old)
285
+ sidecar_path.unlink(missing_ok=True)
286
+ marker_path.unlink(missing_ok=True)
287
+ receipt_path.unlink(missing_ok=True)
288
+ except BaseException as rollback_error:
289
+ raise SystemExit(f"regeneration publication failed and restore failed: {rollback_error}") from rollback_error
290
+ raise
291
+ return payload
292
+
293
+
294
+ def _iter_legacy_lane_dirs(workspace_root: Path) -> list[tuple[str, str, Path]]:
295
+ """Legacy lane trees: ``workspace_root/agents/<unit>/lanes/<lane>/``.
296
+
297
+ The unit HOME (``agents/<unit>/home/``) is deliberately NOT enumerated --
298
+ it is unit identity, not lane state, and stays where the workspace spec
299
+ places it. Only the ``lanes/`` subtree moves.
300
+ """
301
+ legacy_root = workspace_root / "agents"
302
+ out: list[tuple[str, str, Path]] = []
303
+ if not legacy_root.is_dir():
304
+ return out
305
+ for unit_dir in sorted(legacy_root.iterdir()):
306
+ lanes_parent = unit_dir / "lanes"
307
+ if not lanes_parent.is_dir():
308
+ continue
309
+ for lane in sorted(lanes_parent.iterdir()):
310
+ if lane.is_dir():
311
+ out.append((unit_dir.name, lane.name, lane))
312
+ return out
313
+
314
+
315
+ def _refuse_active_legacy_lane_state(workspace_root: Path) -> None:
316
+ """Refuse migration while a lane is entered or holds a live lease.
317
+
318
+ ``_refuse_active_lane_state`` reads the NEW ``.grip/state/lanes`` path;
319
+ migration runs BEFORE the move, so the live state is still at the OLD path
320
+ and must be judged there. current_lane pointers already live at their final
321
+ ``.grip/state/current_lane`` home (never moved), so that half is shared.
322
+ """
323
+ current_root = workspace_root / ".grip" / "state" / "current_lane"
324
+ if current_root.is_dir():
325
+ for current_path in current_root.glob("*.json"):
326
+ _refuse_symlink(current_path, f"{current_path.relative_to(workspace_root)}")
327
+ try:
328
+ current = json.loads(current_path.read_text())
329
+ except (OSError, json.JSONDecodeError) as exc:
330
+ raise SystemExit(f"lane-state migration cannot read lane state: {current_path}: {exc}") from exc
331
+ if current.get("current"):
332
+ raise SystemExit("lane-state migration refuses active lanes")
333
+ for leases_path in (workspace_root / "agents").glob("*/lanes/*/leases.json"):
334
+ _refuse_symlink(leases_path, f"{leases_path.relative_to(workspace_root)}")
335
+ try:
336
+ leases = json.loads(leases_path.read_text())
337
+ except (OSError, json.JSONDecodeError) as exc:
338
+ raise SystemExit(f"lane-state migration cannot read lane leases: {leases_path}: {exc}") from exc
339
+ if leases:
340
+ raise SystemExit("lane-state migration refuses active lane leases")
341
+
342
+
343
+ def migrate_lane_state(workspace_root: Path, *, receipt_path: Path) -> dict[str, object]:
344
+ """Carry legacy ``agents/<unit>/lanes/<lane>/`` trees to
345
+ ``.grip/state/lanes/<unit>/<lane>/`` ONCE, recording a receipt that names
346
+ every tree moved (unit, lane, file count) so what the move relocates is
347
+ explicit -- the receipt is the record of what would be lost if the new home
348
+ were ever cleared (the ``.grip`` overlay-reset durability follow-on).
349
+
350
+ Two-pass and no-clobber: every destination is checked BEFORE anything moves,
351
+ so a lane already present at the new path refuses the whole migration rather
352
+ than leaving a half-moved workspace. Idempotent across runs: once the legacy
353
+ trees are gone a fresh-receipt re-run is a zero-move no-op.
354
+ """
355
+ workspace_root = workspace_root.resolve()
356
+ _require_new_receipt_target(receipt_path)
357
+ _refuse_active_legacy_lane_state(workspace_root)
358
+
359
+ dest_root = lane_proto.lane_state_root(workspace_root)
360
+ legacy = _iter_legacy_lane_dirs(workspace_root)
361
+
362
+ plan: list[tuple[str, str, Path, Path]] = []
363
+ conflicts: list[str] = []
364
+ for unit, lane, src in legacy:
365
+ _refuse_symlink(src, f"agents/{unit}/lanes/{lane}")
366
+ dest = dest_root / unit / lane
367
+ if dest.exists():
368
+ conflicts.append(str(dest.relative_to(workspace_root)))
369
+ else:
370
+ plan.append((unit, lane, src, dest))
371
+ if conflicts:
372
+ raise SystemExit(
373
+ "lane-state migration refuses to overwrite existing destinations "
374
+ f"(both layouts hold these lanes, reconcile by hand): {conflicts}"
375
+ )
376
+
377
+ moved: list[dict[str, object]] = []
378
+ for unit, lane, src, dest in plan:
379
+ file_count = sum(1 for p in src.rglob("*") if p.is_file())
380
+ dest.parent.mkdir(parents=True, exist_ok=True)
381
+ shutil.move(str(src), str(dest))
382
+ moved.append(
383
+ {
384
+ "unit": unit,
385
+ "lane": lane,
386
+ "source": f"agents/{unit}/lanes/{lane}",
387
+ "dest": str(dest.relative_to(workspace_root)),
388
+ "files": file_count,
389
+ }
390
+ )
391
+
392
+ payload: dict[str, object] = {
393
+ "schema": "gr2-lane-state-migration/v1",
394
+ "status": "migrated",
395
+ "workspace_root": str(workspace_root),
396
+ "from": "agents/<unit>/lanes/<lane>",
397
+ "to": ".grip/state/lanes/<unit>/<lane>",
398
+ "moved": moved,
399
+ "count": len(moved),
400
+ }
401
+ _write_new_receipt(receipt_path, payload)
402
+ return payload
403
+
404
+
405
+ def _prepared_marker_path(receipt_path: Path) -> Path:
406
+ return receipt_path.parent / f"{receipt_path.name}.prepared.json"
407
+
408
+
409
+ def _recover_or_refuse_prepared_marker(receipt_path: Path, workspace_root: Path, spec_path: Path) -> None:
410
+ marker_path = _prepared_marker_path(receipt_path)
411
+ _refuse_symlink(marker_path, "prepared regeneration marker")
412
+ if not marker_path.exists():
413
+ return
414
+ try:
415
+ marker = json.loads(marker_path.read_text())
416
+ payload = marker["payload"]
417
+ if marker.get("schema") != "gr2-workspace-regeneration-prepared/v1" or marker.get("phase") != "prepared" or marker.get("forward_receipt") != receipt_path.name or payload.get("workspace_root") != str(workspace_root):
418
+ raise ValueError("marker binding is invalid")
419
+ if receipt_path.exists():
420
+ raise ValueError("receipt already exists")
421
+ if hashlib.sha256(spec_path.read_bytes()).hexdigest() != payload.get("new_spec_sha256"):
422
+ raise ValueError("generated spec does not match prepared new hash")
423
+ _write_new_receipt(receipt_path, payload)
424
+ marker_path.unlink()
425
+ except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
426
+ raise SystemExit(f"pending regeneration marker requires manual resolution: {marker_path}: {exc}") from exc
427
+
428
+
429
+ def rollback_gr1_workspace(
430
+ workspace_root: Path,
431
+ *,
432
+ rollback_receipt_path: Path,
433
+ expected_current_spec_sha256: str,
434
+ receipt_path: Path,
435
+ ) -> dict[str, object]:
436
+ """Restore one receipt-bound generated spec without materializing repos."""
437
+ if not isinstance(expected_current_spec_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_current_spec_sha256):
438
+ raise SystemExit("--expected-current-spec-sha256 must be a lowercase 64-hex SHA-256")
439
+ workspace_root = workspace_root.resolve()
440
+ grip_dir, spec_path, git_dir = workspace_root / ".grip", workspace_root / ".grip" / "workspace_spec.toml", workspace_root / ".grip" / ".git"
441
+ for path, label in ((grip_dir, ".grip"), (git_dir, ".grip/.git"), (spec_path, ".grip/workspace_spec.toml"), (rollback_receipt_path, "rollback receipt")):
442
+ _refuse_symlink(path, label)
443
+ if not git_dir.is_dir() or not spec_path.is_file() or not rollback_receipt_path.is_file():
444
+ raise SystemExit("rollback requires an initialized object store, regular generated spec, and regular receipt")
445
+ _refuse_active_lane_state(workspace_root)
446
+ _require_clean_regeneration_store(grip_dir)
447
+ _require_new_receipt_target(receipt_path)
448
+ receipt = _load_forward_receipt(rollback_receipt_path, workspace_root, grip_dir, spec_path)
449
+ manifest_path = gr1_manifest_path(workspace_root)
450
+ agents_path = gr1_agents_path(workspace_root)
451
+ _refuse_authority_symlinks(workspace_root, manifest_path, "canonical gripspace manifest")
452
+ _refuse_authority_symlinks(workspace_root, agents_path, "gr1 agents manifest")
453
+ if not manifest_path.is_file() or hashlib.sha256(manifest_path.read_bytes()).hexdigest() != receipt["manifest_sha256"]:
454
+ raise SystemExit("rollback manifest binding differs from receipt")
455
+ if not agents_path.is_file() or hashlib.sha256(agents_path.read_bytes()).hexdigest() != receipt.get("agents_sha256"):
456
+ raise SystemExit("rollback agents binding differs from receipt")
457
+ sidecar_path = _bound_sidecar_path(rollback_receipt_path, receipt)
458
+ _refuse_symlink(sidecar_path, "rollback sidecar")
459
+ sidecar = sidecar_path.read_bytes()
460
+ if hashlib.sha256(sidecar).hexdigest() != receipt["sidecar_sha256"] or hashlib.sha256(sidecar).hexdigest() != receipt["observed_old_spec_sha256"]:
461
+ raise SystemExit("rollback sidecar hash does not match receipt")
462
+
463
+ lock_path = grip_dir / "state" / "workspace_spec_regeneration.lock"
464
+ with _regeneration_lock(lock_path):
465
+ _refuse_active_lane_state(workspace_root)
466
+ _refuse_authority_symlinks(workspace_root, manifest_path, "canonical gripspace manifest")
467
+ _refuse_authority_symlinks(workspace_root, agents_path, "gr1 agents manifest")
468
+ if hashlib.sha256(manifest_path.read_bytes()).hexdigest() != receipt["manifest_sha256"] or hashlib.sha256(agents_path.read_bytes()).hexdigest() != receipt.get("agents_sha256"):
469
+ raise SystemExit("rollback source authority changed while awaiting lock")
470
+ current = spec_path.read_bytes()
471
+ current_hash = hashlib.sha256(current).hexdigest()
472
+ if current_hash != expected_current_spec_sha256 or current_hash != receipt["new_spec_sha256"]:
473
+ raise SystemExit("rollback current spec hash does not match caller and receipt binding")
474
+ if _lane_snapshot(workspace_root) != receipt["lane_snapshot"]:
475
+ raise SystemExit("rollback lane or lease state differs from receipt binding")
476
+ head = git(grip_dir, "rev-parse", "HEAD").stdout.strip()
477
+ if head != receipt["object_store_head"] or _store_status(grip_dir) != receipt["object_store_status"]:
478
+ raise SystemExit("rollback object-store binding differs from receipt")
479
+ try:
480
+ _atomic_write(spec_path, sidecar)
481
+ if hashlib.sha256(spec_path.read_bytes()).hexdigest() != receipt["observed_old_spec_sha256"]:
482
+ raise SystemExit("rollback replacement did not restore receipt sidecar bytes")
483
+ if git(grip_dir, "rev-parse", "HEAD").stdout.strip() != head:
484
+ raise SystemExit("rollback changed object-store HEAD")
485
+ except BaseException:
486
+ _atomic_write(spec_path, current)
487
+ raise
488
+ payload = {
489
+ "schema": "gr2-workspace-regeneration-rollback/v1",
490
+ "status": "rolled_back",
491
+ "workspace_root": str(workspace_root),
492
+ "rollback_of_receipt_sha256": hashlib.sha256(rollback_receipt_path.read_bytes()).hexdigest(),
493
+ "rollback_of_receipt": rollback_receipt_path.name,
494
+ "expected_current_spec_sha256": expected_current_spec_sha256,
495
+ "restored_spec_sha256": receipt["observed_old_spec_sha256"],
496
+ "object_store_head": head,
497
+ "lane_snapshot": receipt["lane_snapshot"],
498
+ "operations": ["atomic_workspace_spec_restore"],
499
+ "materialization": False,
500
+ }
501
+ try:
502
+ _write_new_receipt(receipt_path, payload)
503
+ except BaseException:
504
+ try:
505
+ _atomic_write(spec_path, current)
506
+ receipt_path.unlink(missing_ok=True)
507
+ except BaseException as compensation_error:
508
+ raise SystemExit(f"rollback receipt publication failed and compensation failed: {compensation_error}") from compensation_error
509
+ raise
510
+ return payload
511
+
512
+
513
+ @contextlib.contextmanager
514
+ def _regeneration_lock(lock_path: Path):
515
+ """Hold the shared state lock. The lock FILE is KEPT across releases: flock
516
+ binds an inode, and a path that is unlinked and later re-created is a NEW
517
+ inode, so a concurrent waiter holding the old inode is not excluded at all
518
+ (measured: a third party acquired while a second still held). Keeping the
519
+ file -- no unlink, no rmdir -- is the mutual exclusion; a stale lock file is
520
+ not dirt (it lives under state/, excluded from the clean-store judgment)."""
521
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
522
+ with lane_proto.exclusive_lock(lock_path):
523
+ yield
524
+
525
+
526
+ def _require_clean_regeneration_store(grip_dir: Path) -> None:
527
+ probe = git(grip_dir, "rev-parse", "--is-inside-work-tree")
528
+ # The .grip store is PLUMBING-ONLY: grip.py writes objects via
529
+ # hash-object/mktree/commit-tree/update-ref and never checks out HEAD's
530
+ # tree. So once HEAD is a real commit (e.g. a review commit), every path in
531
+ # HEAD's tree is absent from the (unused) worktree and `git status
532
+ # --porcelain` reports it as a deletion. That is the store's NORMAL state,
533
+ # not dirt -- judging HEAD-vs-worktree here refuses every store that has
534
+ # ever held a commit. Regeneration only needs to refuse UNTRACKED files it
535
+ # would clobber, so judge those alone.
536
+ status = git(grip_dir, "status", "--porcelain", "--untracked-files=all")
537
+ if probe.returncode != 0 or probe.stdout.strip() != "true" or status.returncode != 0:
538
+ raise SystemExit("regeneration requires a valid readable object store")
539
+ # state/ is the lane control plane, not object-store dirt: inactive lane
540
+ # files and the regeneration lock file legitimately live there. Active lanes
541
+ # and leases are refused separately by _refuse_active_lane_state; counting an
542
+ # inactive lane file (or the held lock) as "dirty" here made a valid
543
+ # regeneration refuse, and made a second regenerator report "dirty" instead
544
+ # of waiting on the lock. Exclude the subtree; judge the object store only.
545
+ dirty = [
546
+ line for line in status.stdout.splitlines()
547
+ if line.startswith("??")
548
+ and line != "?? workspace_spec.toml"
549
+ and not line.startswith("?? state/")
550
+ ]
551
+ if dirty:
552
+ raise SystemExit("regeneration refuses dirty object store outside generated workspace_spec.toml")
553
+
554
+
555
+ def _store_status(grip_dir: Path) -> list[str]:
556
+ status = git(grip_dir, "status", "--porcelain")
557
+ if status.returncode != 0:
558
+ raise SystemExit("regeneration requires a readable object-store status")
559
+ return status.stdout.splitlines()
560
+
561
+
562
+ def _lane_snapshot(workspace_root: Path) -> dict[str, object]:
563
+ """Bind empty/inactive lane state, rather than merely assuming it remains so."""
564
+ rows: list[dict[str, str]] = []
565
+ for root, pattern in ((workspace_root / ".grip" / "state" / "current_lane", "*.json"), (lane_proto.lane_state_root(workspace_root), "*/*/leases.json")):
566
+ if not root.exists():
567
+ continue
568
+ for path in sorted(root.glob(pattern)):
569
+ _refuse_symlink(path, f"{path.relative_to(workspace_root)}")
570
+ rows.append({"path": str(path.relative_to(workspace_root)), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()})
571
+ return {"files": rows}
572
+
573
+
574
+ def _refuse_active_lane_state(workspace_root: Path) -> None:
575
+ """Do not change the control plane while a lane or lease can consume it."""
576
+ state_root = workspace_root / ".grip" / "state"
577
+ current_root = state_root / "current_lane"
578
+ if current_root.exists():
579
+ for current_path in current_root.glob("*.json"):
580
+ _refuse_symlink(current_path, f"{current_path.relative_to(workspace_root)}")
581
+ try:
582
+ current = json.loads(current_path.read_text())
583
+ except (OSError, json.JSONDecodeError) as exc:
584
+ raise SystemExit(f"regeneration cannot read lane state: {current_path}: {exc}") from exc
585
+ if current.get("current"):
586
+ raise SystemExit("regeneration refuses active lanes")
587
+ for leases_path in lane_proto.lane_state_root(workspace_root).glob("*/*/leases.json"):
588
+ _refuse_symlink(leases_path, f"{leases_path.relative_to(workspace_root)}")
589
+ try:
590
+ leases = json.loads(leases_path.read_text())
591
+ except (OSError, json.JSONDecodeError) as exc:
592
+ raise SystemExit(f"regeneration cannot read lane leases: {leases_path}: {exc}") from exc
593
+ if leases:
594
+ raise SystemExit("regeneration refuses active lane leases")
595
+
596
+
597
+ def _require_new_receipt_target(path: Path) -> None:
598
+ """Receipt artifacts are append-only authority, never overwriteable state."""
599
+ _refuse_symlink(path.parent, "receipt directory")
600
+ _refuse_symlink(path, "regeneration receipt")
601
+ if not path.parent.is_dir():
602
+ raise SystemExit(f"receipt parent must already exist: {path.parent}")
603
+ if path.exists():
604
+ raise SystemExit(f"refusing to overwrite existing receipt artifact: {path}")
605
+
606
+
607
+ def _sidecar_path(receipt_path: Path, old_hash: str) -> Path:
608
+ relative = f"{receipt_path.name}.old-spec-{old_hash}.toml"
609
+ if Path(relative).is_absolute() or ".." in Path(relative).parts:
610
+ raise SystemExit("generated receipt sidecar path is unsafe")
611
+ return receipt_path.parent / relative
612
+
613
+
614
+ def _bound_sidecar_path(receipt_path: Path, receipt: dict[str, object]) -> Path:
615
+ relative = receipt.get("sidecar_relative_path")
616
+ if not isinstance(relative, str):
617
+ raise SystemExit("rollback receipt lacks a relative sidecar path")
618
+ candidate = Path(relative)
619
+ if candidate.is_absolute() or ".." in candidate.parts or len(candidate.parts) != 1:
620
+ raise SystemExit("rollback receipt sidecar path is unsafe")
621
+ return receipt_path.parent / candidate
622
+
623
+
624
+ def _write_new_receipt(receipt_path: Path, payload: dict[str, object]) -> None:
625
+ _require_new_receipt_target(receipt_path)
626
+ _atomic_write(receipt_path, (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode())
627
+
628
+
629
+ def _load_forward_receipt(receipt_path: Path, workspace_root: Path, grip_dir: Path, spec_path: Path) -> dict[str, object]:
630
+ try:
631
+ receipt = json.loads(receipt_path.read_text())
632
+ except (OSError, json.JSONDecodeError) as exc:
633
+ raise SystemExit(f"rollback cannot parse receipt: {exc}") from exc
634
+ if not isinstance(receipt, dict) or receipt.get("schema") != "gr2-workspace-regeneration/v2":
635
+ raise SystemExit("rollback receipt is not a successful regeneration receipt")
636
+ required = ("workspace_root", "workspace_spec_path", "grip_repo_path", "manifest_sha256", "object_store_head", "object_store_status", "lane_snapshot", "observed_old_spec_sha256", "new_spec_sha256", "sidecar_sha256", "materialization")
637
+ if any(key not in receipt for key in required) or receipt["materialization"] is not False:
638
+ raise SystemExit("rollback receipt is missing required immutable bindings")
639
+ if receipt["workspace_root"] != str(workspace_root) or receipt["workspace_spec_path"] != str(spec_path) or receipt["grip_repo_path"] != str(grip_dir):
640
+ raise SystemExit("rollback receipt workspace binding differs from requested workspace")
641
+ if not all(isinstance(receipt[key], str) and re.fullmatch(r"[0-9a-f]{64}", receipt[key]) for key in ("manifest_sha256", "observed_old_spec_sha256", "new_spec_sha256", "sidecar_sha256")):
642
+ raise SystemExit("rollback receipt has invalid SHA-256 bindings")
643
+ return receipt
644
+
645
+
646
+ def _atomic_write(path: Path, content: bytes) -> None:
647
+ """Replace one generated control-plane file without exposing partial TOML."""
648
+ path.parent.mkdir(parents=True, exist_ok=True)
649
+ fd, raw_temp_path = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
650
+ temp_path = Path(raw_temp_path)
651
+ try:
652
+ with os.fdopen(fd, "wb") as fh:
653
+ fh.write(content)
654
+ fh.flush()
655
+ os.fsync(fh.fileno())
656
+ os.replace(temp_path, path)
657
+ directory_fd = os.open(path.parent, os.O_RDONLY)
658
+ try:
659
+ os.fsync(directory_fd)
660
+ finally:
661
+ os.close(directory_fd)
662
+ except BaseException:
663
+ temp_path.unlink(missing_ok=True)
664
+ raise
665
+
666
+
667
+ def _refuse_symlink(path: Path, label: str) -> None:
668
+ """Inspect the entry itself, never the target an attacker could redirect."""
669
+ try:
670
+ mode = path.lstat().st_mode
671
+ except FileNotFoundError:
672
+ return
673
+ if stat.S_ISLNK(mode):
674
+ raise SystemExit(f"refusing bootstrap: {label} must not be a symlink")
675
+
676
+
677
+ def _refuse_authority_symlinks(workspace_root: Path, path: Path, label: str) -> None:
678
+ """Authority must stay physically below the canonical workspace root."""
679
+ root = workspace_root.resolve()
680
+ try:
681
+ relative = path.relative_to(root)
682
+ except ValueError as exc:
683
+ raise SystemExit(f"refusing regeneration: {label} escapes workspace") from exc
684
+ current = root
685
+ _refuse_symlink(current, "workspace root")
686
+ for part in relative.parts:
687
+ current = current / part
688
+ _refuse_symlink(current, label)
689
+
690
+
691
+ def _rollback_bootstrap(
692
+ grip_dir: Path,
693
+ git_dir: Path,
694
+ spec_path: Path,
695
+ grip_dir_existed: bool,
696
+ git_dir_existed: bool,
697
+ spec_existed: bool,
698
+ ) -> None:
699
+ """Remove only control-plane entries created by this bootstrap attempt."""
700
+ for path, label in ((grip_dir, ".grip"), (git_dir, ".grip/.git"), (spec_path, ".grip/workspace_spec.toml")):
701
+ _refuse_symlink(path, label)
702
+ if not spec_existed and spec_path.exists():
703
+ spec_path.unlink()
704
+ if not git_dir_existed and git_dir.exists():
705
+ shutil.rmtree(git_dir)
706
+ if not grip_dir_existed and grip_dir.exists():
707
+ shutil.rmtree(grip_dir)
708
+
709
+
710
+ def compile_gr1_to_workspace_spec(
711
+ workspace_root: Path,
712
+ manifest: dict[str, object],
713
+ agents_doc: dict[str, object],
714
+ ) -> dict[str, object]:
715
+ repos_doc = manifest.get("repos", {}) or {}
716
+ if not isinstance(repos_doc, dict):
717
+ raise ValueError("canonical gripspace manifest repos must be a mapping")
718
+ repos: list[dict[str, object]] = []
719
+ writable_repo_names: list[str] = []
720
+
721
+ for repo_name, repo_doc in repos_doc.items():
722
+ safe_repo_name = _safe_workspace_component(repo_name, "repo name")
723
+ if not isinstance(repo_doc, dict):
724
+ raise ValueError(f"repository {safe_repo_name!r} must be a mapping")
725
+ path = repo_doc.get("path", "")
726
+ normalized_path = _safe_workspace_relative_path(path, f"repository {safe_repo_name!r} path")
727
+ repo_item = {
728
+ "name": safe_repo_name,
729
+ "path": normalized_path,
730
+ "url": str(repo_doc.get("url", "")).strip(),
731
+ }
732
+ if "revision" in repo_doc:
733
+ repo_item["default_branch"] = str(repo_doc.get("revision") or "").strip()
734
+ elif "default_branch" in repo_doc:
735
+ repo_item["default_branch"] = str(repo_doc.get("default_branch") or "").strip()
736
+ if repo_doc.get("reference", False):
737
+ repo_item["reference"] = True
738
+ repos.append(repo_item)
739
+ if not repo_doc.get("reference", False):
740
+ writable_repo_names.append(safe_repo_name)
741
+
742
+ agents = (agents_doc.get("agents") or {}) or {}
743
+ if not isinstance(agents, dict):
744
+ raise ValueError("gr1 agents manifest agents must be a mapping")
745
+ unit_items = (
746
+ sorted((_safe_workspace_component(unit_name, "agent unit name"), unit_doc) for unit_name, unit_doc in agents.items())
747
+ if agents
748
+ else [("default", {})]
749
+ )
750
+ units: list[dict[str, object]] = []
751
+ for safe_unit_name, unit_doc in unit_items:
752
+ unit_doc = unit_doc or {}
753
+ if not isinstance(unit_doc, dict):
754
+ raise ValueError(f"agent unit {safe_unit_name!r} must be a mapping")
755
+ units.append(
756
+ {
757
+ "name": safe_unit_name,
758
+ "path": f"agents/{safe_unit_name}/home",
759
+ "repos": writable_repo_names,
760
+ "migration_source": {
761
+ "worktree": unit_doc.get("worktree"),
762
+ "channel": unit_doc.get("channel"),
763
+ },
764
+ }
765
+ )
766
+
767
+ return {
768
+ "workspace_name": workspace_root.name,
769
+ "repos": repos,
770
+ "units": units,
771
+ "workspace_constraints": {
772
+ "migration_source": "gr1",
773
+ },
774
+ }
775
+
776
+
777
+ def _safe_workspace_component(value: object, field: str) -> str:
778
+ """Accept one logical name, never a path fragment or invisible control data."""
779
+ if not isinstance(value, str) or not value or value in {".", ".."}:
780
+ raise ValueError(f"{field} must be a non-empty logical name")
781
+ if "/" in value or "\\" in value or ":" in value or any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
782
+ raise ValueError(f"{field} must not contain separators or control characters: {value!r}")
783
+ return value
784
+
785
+
786
+ def _safe_workspace_relative_path(value: object, field: str) -> str:
787
+ """Normalize only a safe, portable workspace-relative manifest path."""
788
+ if not isinstance(value, str) or not value or any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
789
+ raise ValueError(f"{field} must be a non-empty relative path")
790
+ if "\\" in value:
791
+ raise ValueError(f"{field} must use portable '/' separators: {value!r}")
792
+ posix_path = PurePosixPath(value)
793
+ windows_path = PureWindowsPath(value)
794
+ if (
795
+ posix_path.is_absolute()
796
+ or windows_path.is_absolute()
797
+ or windows_path.drive
798
+ or windows_path.root
799
+ or ".." in posix_path.parts
800
+ or str(posix_path) in {".", ""}
801
+ ):
802
+ raise ValueError(f"{field} escapes the workspace: {value!r}")
803
+ return posix_path.as_posix()
804
+
805
+
806
+ def preserve_gr1_state(workspace_root: Path, migration_dir: Path) -> dict[str, str]:
807
+ snapshots: dict[str, str] = {}
808
+ for name, src in gr1_state_paths(workspace_root).items():
809
+ if not src.exists():
810
+ continue
811
+ dest = migration_dir / src.name
812
+ shutil.copy2(src, dest)
813
+ snapshots[name] = str(dest)
814
+ return snapshots
815
+
816
+
817
+ def render_workspace_spec(compiled: dict[str, object]) -> str:
818
+ lines = [
819
+ f'workspace_name = "{compiled["workspace_name"]}"',
820
+ "",
821
+ ]
822
+ constraints = compiled.get("workspace_constraints") or {}
823
+ if constraints:
824
+ lines.append("[workspace_constraints]")
825
+ for key, value in constraints.items():
826
+ lines.append(f'{key} = "{value}"')
827
+ lines.append("")
828
+
829
+ for repo in compiled["repos"]:
830
+ lines.extend(
831
+ [
832
+ "[[repos]]",
833
+ f'name = "{repo["name"]}"',
834
+ f'path = "{repo["path"]}"',
835
+ f'url = "{repo["url"]}"',
836
+ ]
837
+ )
838
+ default_branch = str(repo.get("default_branch", "")).strip()
839
+ if default_branch:
840
+ lines.append(f'default_branch = "{default_branch}"')
841
+ if repo.get("reference", False):
842
+ lines.append("reference = true")
843
+ lines.append("")
844
+
845
+ for unit in compiled["units"]:
846
+ lines.extend(
847
+ [
848
+ "[[units]]",
849
+ f'name = "{unit["name"]}"',
850
+ f'path = "{unit["path"]}"',
851
+ "repos = [" + ", ".join(f'"{repo}"' for repo in unit["repos"]) + "]",
852
+ ]
853
+ )
854
+ lines.append("")
855
+
856
+ return "\n".join(lines)
857
+
858
+
859
+ def render_detection(payload: dict[str, object]) -> str:
860
+ if not payload["detected"]:
861
+ return "\n".join(["Gr1Detection", "detected = false", f"reason = {payload['reason']}"])
862
+ lines = [
863
+ "Gr1Detection",
864
+ "detected = true",
865
+ f"workspace_root = {payload['workspace_root']}",
866
+ f"manifest_path = {payload['manifest_path']}",
867
+ f"repo_count = {payload['repo_count']}",
868
+ f"reference_repo_count = {payload['reference_repo_count']}",
869
+ f"agent_count = {payload['agent_count']}",
870
+ "REPOS",
871
+ ]
872
+ lines.extend(f"- {repo}" for repo in payload["repos"])
873
+ lines.append("AGENTS")
874
+ lines.extend(f"- {agent}" for agent in payload["agents"])
875
+ return "\n".join(lines)
876
+
877
+
878
+ def render_migration(payload: dict[str, object]) -> str:
879
+ lines = [
880
+ "Gr1Migration",
881
+ f"workspace_root = {payload['workspace_root']}",
882
+ f"workspace_spec_path = {payload['workspace_spec_path']}",
883
+ f"repo_count = {payload['repo_count']}",
884
+ f"unit_count = {payload['unit_count']}",
885
+ "UNITS",
886
+ ]
887
+ lines.extend(f"- {unit}" for unit in payload["units"])
888
+ lines.append("SNAPSHOTS")
889
+ lines.extend(f"- {name}\t{path}" for name, path in payload["snapshots"].items())
890
+ return "\n".join(lines)
891
+
892
+
893
+ def workspace_status(workspace_root: Path) -> dict[str, object]:
894
+ """Report workspace state: gr1-only, gr2-only, coexistence, or none."""
895
+ has_gr1 = gr1_manifest_path(workspace_root).exists()
896
+ gr2_spec_path = workspace_root / ".grip" / "workspace_spec.toml"
897
+ has_gr2 = gr2_spec_path.exists()
898
+ migration_dir = workspace_root / ".grip" / "migrations" / "gr1"
899
+ has_migration = migration_dir.exists() and (migration_dir / "migration-summary.json").exists()
900
+
901
+ if has_gr1 and has_gr2:
902
+ phase = "coexistence"
903
+ elif has_gr1:
904
+ phase = "gr1-only"
905
+ elif has_gr2:
906
+ phase = "gr2-only"
907
+ else:
908
+ phase = "none"
909
+
910
+ result: dict[str, object] = {
911
+ "workspace_root": str(workspace_root),
912
+ "gr1": has_gr1,
913
+ "gr2": has_gr2,
914
+ "coexistence": has_gr1 and has_gr2,
915
+ "migration_snapshot": has_migration,
916
+ "phase": phase,
917
+ }
918
+
919
+ if has_gr1:
920
+ detection = detect_gr1_workspace(workspace_root)
921
+ result["gr1_repo_count"] = detection.get("repo_count", 0)
922
+ result["gr1_agents"] = detection.get("agents", [])
923
+
924
+ if has_gr2:
925
+ import tomllib
926
+ with gr2_spec_path.open("rb") as fh:
927
+ spec = tomllib.load(fh)
928
+ repos = spec.get("repos", [])
929
+ units = spec.get("units", [])
930
+ result["gr2_repo_count"] = len(repos)
931
+ result["gr2_unit_count"] = len(units)
932
+ result["gr2_spec_path"] = str(gr2_spec_path)
933
+
934
+ # gr does not support worktree-backed repos (design/worktree-refactor
935
+ # ruling). `repo status` already flags a hand-made linked worktree
936
+ # per-repo; this reuses the SAME check (repo_maintenance_prototype's
937
+ # is_linked_worktree, via derive_targets so shared repos and per-unit
938
+ # repos are both covered) so `workspace status` reports it too --
939
+ # the playbook has always claimed both commands do this, and until
940
+ # now only one actually did.
941
+ parsed_spec = repo_proto.read_workspace_spec(gr2_spec_path)
942
+ linked_worktrees = [
943
+ str(target.path)
944
+ for target in repo_proto.derive_targets(workspace_root, parsed_spec)
945
+ if target.path.exists() and repo_proto.is_linked_worktree(target.path)
946
+ ]
947
+ result["linked_worktrees"] = linked_worktrees
948
+
949
+ return result
950
+
951
+
952
+ def render_status(payload: dict[str, object]) -> str:
953
+ lines = [
954
+ "WorkspaceStatus",
955
+ f"phase = {payload['phase']}",
956
+ f"workspace_root = {payload['workspace_root']}",
957
+ ]
958
+ if payload["gr1"]:
959
+ lines.append(f"gr1 = true (repos: {payload.get('gr1_repo_count', '?')})")
960
+ if payload["gr2"]:
961
+ lines.append(f"gr2 = true (repos: {payload.get('gr2_repo_count', '?')}, units: {payload.get('gr2_unit_count', '?')})")
962
+ if payload["coexistence"]:
963
+ lines.append("coexistence = true (both .gitgrip and .grip present)")
964
+ if payload.get("migration_snapshot"):
965
+ lines.append("migration_snapshot = true (.grip/migrations/gr1/ present)")
966
+ linked = payload.get("linked_worktrees") or []
967
+ if linked:
968
+ lines.append(
969
+ "linked_worktrees = " + ", ".join(linked)
970
+ + " -- gr does not support worktree-backed repos; "
971
+ "run `gr2 workspace convert-clone <path>` on each"
972
+ )
973
+ if not payload["gr1"] and not payload["gr2"]:
974
+ lines.append("No workspace detected. Run `gr2 workspace init` or `gr2 workspace migrate-gr1`.")
975
+ return "\n".join(lines)
976
+
977
+
978
+ def _load_agents_doc(workspace_root: Path) -> dict[str, object]:
979
+ path = gr1_agents_path(workspace_root)
980
+ if not path.exists():
981
+ return {}
982
+ import tomllib
983
+
984
+ with path.open("rb") as fh:
985
+ return tomllib.load(fh)