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,493 @@
1
+ """CLI commands for grip object model and config overlay.
2
+
3
+ Separate module so tests can import without pulling in all of app.py's
4
+ dependencies (gr2.prototypes, lane_workspace_prototype, etc.).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ import typer
14
+
15
+ from . import config as config_mod
16
+ from . import grip as grip_mod
17
+ from .gitops import git, repo_dirty
18
+
19
+ grip_app = typer.Typer(help="Grip object model: workspace snapshots and history")
20
+ config_cli_app = typer.Typer(help="Config base+overlay management")
21
+
22
+
23
+ def _resolve_repos(workspace: Path, repos_csv: str) -> dict[str, Path]:
24
+ result: dict[str, Path] = {}
25
+ for name in repos_csv.split(","):
26
+ name = name.strip()
27
+ if not name:
28
+ continue
29
+ result[name] = workspace / name
30
+ return result
31
+
32
+
33
+ def _discover_repos(workspace: Path) -> dict[str, Path]:
34
+ """Auto-discover repos from workspace_spec.toml."""
35
+ spec_path = workspace / ".grip" / "workspace_spec.toml"
36
+ if not spec_path.exists():
37
+ typer.echo(
38
+ f"No .grip/workspace_spec.toml at {workspace}. Use --repos or create a workspace spec."
39
+ )
40
+ raise typer.Exit(code=1)
41
+ with spec_path.open("rb") as fh:
42
+ spec = tomllib.load(fh)
43
+ result: dict[str, Path] = {}
44
+ for repo in spec.get("repos", []):
45
+ name = repo.get("name", "")
46
+ path = repo.get("path", name)
47
+ if name:
48
+ result[name] = workspace / path
49
+ return result
50
+
51
+
52
+ def _validate_grip_dir(workspace: Path) -> None:
53
+ """Check .grip/ directory exists."""
54
+ grip_dir = workspace / ".grip"
55
+ if not grip_dir.exists():
56
+ typer.echo(f"No .grip/ directory at {workspace}. Run workspace init first.")
57
+ raise typer.Exit(code=1)
58
+
59
+
60
+ def _check_dirty_repos(repos: dict[str, Path]) -> list[str]:
61
+ """Return list of dirty repo names."""
62
+ dirty = []
63
+ for name, path in sorted(repos.items()):
64
+ if path.is_dir() and repo_dirty(path):
65
+ dirty.append(name)
66
+ return dirty
67
+
68
+
69
+ def _repo_head_state(repo_path: Path) -> dict[str, object]:
70
+ """Get head state for a single repo."""
71
+ head_proc = git(repo_path, "rev-parse", "HEAD")
72
+ if head_proc.returncode != 0:
73
+ return {"head": None, "is_empty": True, "head_state": "empty"}
74
+
75
+ head_sha = head_proc.stdout.strip()
76
+ branch = git(repo_path, "branch", "--show-current")
77
+ branch_name = branch.stdout.strip() if branch.returncode == 0 else ""
78
+
79
+ if branch_name:
80
+ return {
81
+ "head": head_sha,
82
+ "is_empty": False,
83
+ "head_state": "attached",
84
+ "branch": branch_name,
85
+ }
86
+ return {"head": head_sha, "is_empty": False, "head_state": "detached"}
87
+
88
+
89
+ def _read_snapshot_index(workspace: Path) -> list[dict[str, object]]:
90
+ index_path = workspace / ".grip" / "snapshots" / "index.json"
91
+ if not index_path.exists():
92
+ return []
93
+ return json.loads(index_path.read_text())
94
+
95
+
96
+ def _write_snapshot_index(workspace: Path, index: list[dict[str, object]]) -> None:
97
+ snapshots_dir = workspace / ".grip" / "snapshots"
98
+ snapshots_dir.mkdir(parents=True, exist_ok=True)
99
+ index_path = snapshots_dir / "index.json"
100
+ index_path.write_text(json.dumps(index, indent=2) + "\n")
101
+
102
+
103
+ class AmbiguousSnapshotId(Exception):
104
+ """A prefix matching more than one snapshot. Never resolved by guessing."""
105
+
106
+ def __init__(self, prefix: str, matches: list[str]) -> None:
107
+ self.prefix = prefix
108
+ self.matches = matches
109
+ super().__init__(prefix)
110
+
111
+
112
+ def _find_snapshot_by_id(
113
+ index: list[dict[str, object]],
114
+ snapshot_id: str,
115
+ ) -> dict[str, object] | None:
116
+ """Resolve a snapshot by full id or by unique prefix.
117
+
118
+ The store holds 40-character ids and ``grip log`` prints the first 12, so
119
+ an exact-match-only lookup made the ONLY id a user can obtain from the CLI
120
+ the one that does not work: ``grip checkout`` and ``grip diff`` answered
121
+ ``Snapshot not found`` for an id ``grip log`` had just printed, while the
122
+ working id existed solely inside ``.grip/snapshots/index.json``.
123
+
124
+ Prefix resolution follows git's rule, including the part that matters most:
125
+ an ambiguous prefix RAISES rather than picking the first match. Silently
126
+ resolving to one of several would make ``checkout`` land on an arbitrary
127
+ snapshot, which is worse than the refusal this replaces.
128
+ """
129
+ if not snapshot_id:
130
+ return None
131
+ for entry in index:
132
+ if entry.get("id") == snapshot_id:
133
+ return entry
134
+ matches = [e for e in index if str(e.get("id", "")).startswith(snapshot_id)]
135
+ if len(matches) > 1:
136
+ raise AmbiguousSnapshotId(snapshot_id, [str(e.get("id", "")) for e in matches])
137
+ return matches[0] if matches else None
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # gr grip
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def _resolve_snapshot_or_exit(
146
+ index: list[dict[str, object]],
147
+ snapshot_id: str,
148
+ ) -> dict[str, object] | None:
149
+ """Resolve for a CLI verb, turning ambiguity into a refusal.
150
+
151
+ ``_find_snapshot_by_id`` RAISES on an ambiguous prefix, which is right for
152
+ a library: picking one of several arbitrarily is the thing we are trying
153
+ not to do. But every verb called it bare, so the raise reached the user as
154
+ a traceback -- the exact class this whole range exists to remove,
155
+ reintroduced by the fix for it.
156
+
157
+ The verbs go through here rather than each carrying its own ``except``,
158
+ for the same reason the current-lane fix added a required accessor: there
159
+ were three call sites, and a per-site handler makes the fourth one's
160
+ omission silent. ``None`` (not found) still returns, because each verb
161
+ words that message differently.
162
+ """
163
+ try:
164
+ return _find_snapshot_by_id(index, snapshot_id)
165
+ except AmbiguousSnapshotId as exc:
166
+ typer.echo(
167
+ f"Ambiguous snapshot id: {exc.prefix!r} matches {len(exc.matches)} snapshots:"
168
+ )
169
+ for match in exc.matches:
170
+ typer.echo(f" {match}")
171
+ typer.echo("Use more characters to disambiguate.")
172
+ raise typer.Exit(code=1)
173
+
174
+
175
+ @grip_app.command("init")
176
+ def grip_init_cmd(
177
+ workspace_root: Path,
178
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
179
+ ) -> None:
180
+ """Initialize the .grip/ git repo at workspace root."""
181
+ workspace_root = workspace_root.resolve()
182
+ try:
183
+ grip_mod.grip_init(workspace_root)
184
+ except grip_mod.GripInitError as exc:
185
+ typer.echo(str(exc), err=True)
186
+ raise typer.Exit(code=1)
187
+ if json_output:
188
+ typer.echo(json.dumps({"status": "initialized", "path": str(workspace_root / ".grip")}))
189
+ else:
190
+ typer.echo(f"Initialized .grip/ at {workspace_root}")
191
+
192
+
193
+ @grip_app.command("snapshot")
194
+ def grip_snapshot_cmd(
195
+ workspace_root: Path,
196
+ repos: str = typer.Option(
197
+ "",
198
+ "--repos",
199
+ help="Comma-separated repo names (auto from spec if omitted)",
200
+ ),
201
+ message: str = typer.Option(
202
+ "",
203
+ "--message",
204
+ "-m",
205
+ help="Snapshot message",
206
+ ),
207
+ changeset_type: str = typer.Option(
208
+ "",
209
+ "--type",
210
+ help="Changeset type (e.g. ceremony, feature)",
211
+ ),
212
+ sprint: str = typer.Option("", "--sprint", help="Sprint number"),
213
+ overlay_dir: str = typer.Option(
214
+ "",
215
+ "--overlay-dir",
216
+ help="Config overlay directory",
217
+ ),
218
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
219
+ ) -> None:
220
+ """Snapshot current workspace into a grip commit."""
221
+ workspace_root = workspace_root.resolve()
222
+
223
+ _validate_grip_dir(workspace_root)
224
+
225
+ if repos:
226
+ repo_map = _resolve_repos(workspace_root, repos)
227
+ else:
228
+ repo_map = _discover_repos(workspace_root)
229
+
230
+ if not repo_map:
231
+ typer.echo("No repos found in workspace spec.")
232
+ raise typer.Exit(code=1)
233
+
234
+ dirty = _check_dirty_repos(repo_map)
235
+ if dirty:
236
+ typer.echo(f"Dirty repos detected: {', '.join(dirty)}. Commit or stash changes first.")
237
+ raise typer.Exit(code=1)
238
+
239
+ repo_states: dict[str, dict[str, object]] = {}
240
+ for name, path in sorted(repo_map.items()):
241
+ repo_states[name] = _repo_head_state(path)
242
+
243
+ grip_mod.grip_init(workspace_root)
244
+
245
+ overlay = Path(overlay_dir).resolve() if overlay_dir else None
246
+ try:
247
+ sha = grip_mod.grip_snapshot(
248
+ workspace_root,
249
+ repo_map,
250
+ changeset_type=changeset_type,
251
+ sprint=sprint,
252
+ message=message,
253
+ overlay_dir=overlay,
254
+ )
255
+ except grip_mod.GripInitError as exc:
256
+ typer.echo(str(exc), err=True)
257
+ raise typer.Exit(code=1)
258
+
259
+ snapshot_id = sha
260
+
261
+ index = _read_snapshot_index(workspace_root)
262
+ entry: dict[str, object] = {
263
+ "id": snapshot_id,
264
+ "sha": sha,
265
+ "message": message or "grip snapshot",
266
+ "repos": sorted(repo_map.keys()),
267
+ "repo_states": repo_states,
268
+ }
269
+ if changeset_type:
270
+ entry["type"] = changeset_type
271
+ if sprint:
272
+ entry["sprint"] = sprint
273
+ index.append(entry)
274
+ _write_snapshot_index(workspace_root, index)
275
+
276
+ if json_output:
277
+ typer.echo(json.dumps({"sha": sha, "id": snapshot_id, "repos": sorted(repo_map.keys())}))
278
+ else:
279
+ typer.echo(f"grip snapshot {sha[:12]} ({len(repo_map)} repos)")
280
+
281
+
282
+ @grip_app.command("log")
283
+ def grip_log_cmd(
284
+ workspace_root: Path,
285
+ max_count: int = typer.Option(10, "--max-count", "-n", help="Max entries to show"),
286
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
287
+ ) -> None:
288
+ """Show grip commit history."""
289
+ workspace_root = workspace_root.resolve()
290
+
291
+ _validate_grip_dir(workspace_root)
292
+
293
+ index = _read_snapshot_index(workspace_root)
294
+
295
+ if json_output:
296
+ display = list(reversed(index[-max_count:])) if index else []
297
+ typer.echo(json.dumps({"entries": display}))
298
+ return
299
+
300
+ if not index:
301
+ typer.echo("No grip snapshots yet.")
302
+ return
303
+
304
+ display = list(reversed(index[-max_count:]))
305
+ for entry in display:
306
+ sid = entry.get("id", "?")
307
+ msg = entry.get("message", "")
308
+ repos = entry.get("repos", [])
309
+ typer.echo(f"{msg} [{', '.join(repos)}] ({sid[:12]})")
310
+
311
+
312
+ @grip_app.command("diff")
313
+ def grip_diff_cmd(
314
+ workspace_root: Path,
315
+ ref_a: str = typer.Argument(..., help="First snapshot id"),
316
+ ref_b: str = typer.Argument(..., help="Second snapshot id"),
317
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
318
+ ) -> None:
319
+ """Show changes between two grip snapshots."""
320
+ workspace_root = workspace_root.resolve()
321
+
322
+ _validate_grip_dir(workspace_root)
323
+
324
+ index = _read_snapshot_index(workspace_root)
325
+ snap_a = _resolve_snapshot_or_exit(index, ref_a)
326
+ snap_b = _resolve_snapshot_or_exit(index, ref_b)
327
+
328
+ if snap_a is None:
329
+ typer.echo(f"Snapshot not found: missing id '{ref_a}'")
330
+ raise typer.Exit(code=1)
331
+ if snap_b is None:
332
+ typer.echo(f"Snapshot not found: missing id '{ref_b}'")
333
+ raise typer.Exit(code=1)
334
+
335
+ states_a = snap_a.get("repo_states", {})
336
+ states_b = snap_b.get("repo_states", {})
337
+ all_repos = set(states_a.keys()) | set(states_b.keys())
338
+
339
+ changed: dict[str, dict[str, str]] = {}
340
+ added: list[str] = []
341
+ removed: list[str] = []
342
+
343
+ for name in sorted(all_repos):
344
+ if name in states_a and name not in states_b:
345
+ removed.append(name)
346
+ elif name not in states_a and name in states_b:
347
+ added.append(name)
348
+ else:
349
+ head_a = states_a[name].get("head")
350
+ head_b = states_b[name].get("head")
351
+ if head_a != head_b:
352
+ changed[name] = {"old": str(head_a), "new": str(head_b)}
353
+
354
+ if json_output:
355
+ typer.echo(json.dumps({"changed": changed, "added": added, "removed": removed}))
356
+ else:
357
+ if not changed and not added and not removed:
358
+ typer.echo("No changes.")
359
+ return
360
+ for name, info in changed.items():
361
+ old = info["old"][:12] if info["old"] else "None"
362
+ new = info["new"][:12] if info["new"] else "None"
363
+ typer.echo(f" changed {name}: {old} -> {new}")
364
+ for name in added:
365
+ typer.echo(f" + {name}")
366
+ for name in removed:
367
+ typer.echo(f" - {name}")
368
+
369
+
370
+ @grip_app.command("checkout")
371
+ def grip_checkout_cmd(
372
+ workspace_root: Path,
373
+ ref: str = typer.Argument(..., help="Snapshot id to restore"),
374
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
375
+ ) -> None:
376
+ """Restore workspace repo HEADs from a grip snapshot."""
377
+ workspace_root = workspace_root.resolve()
378
+
379
+ _validate_grip_dir(workspace_root)
380
+
381
+ index = _read_snapshot_index(workspace_root)
382
+ snapshot = _resolve_snapshot_or_exit(index, ref)
383
+ if snapshot is None:
384
+ typer.echo(f"Snapshot not found: {ref}")
385
+ raise typer.Exit(code=1)
386
+
387
+ repo_states = snapshot.get("repo_states", {})
388
+
389
+ existing_repos: dict[str, Path] = {}
390
+ for name in repo_states:
391
+ repo_path = workspace_root / name
392
+ if repo_path.is_dir():
393
+ existing_repos[name] = repo_path
394
+
395
+ dirty = _check_dirty_repos(existing_repos)
396
+ if dirty:
397
+ typer.echo(f"Dirty repos detected: {', '.join(dirty)}. Commit or stash changes first.")
398
+ raise typer.Exit(code=1)
399
+
400
+ result: dict[str, str] = {}
401
+ for name, state in sorted(repo_states.items()):
402
+ head_sha = state.get("head")
403
+ if not head_sha:
404
+ continue
405
+ repo_path = workspace_root / name
406
+ if not repo_path.is_dir():
407
+ continue
408
+ git(repo_path, "checkout", head_sha)
409
+ result[name] = head_sha
410
+
411
+ if json_output:
412
+ typer.echo(json.dumps({"repos": result}))
413
+ else:
414
+ for name, sha in result.items():
415
+ typer.echo(f" {name} -> {sha[:12]}")
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # gr config
420
+ # ---------------------------------------------------------------------------
421
+
422
+
423
+ @config_cli_app.command("apply")
424
+ def config_apply_cmd(
425
+ base_path: Path = typer.Argument(..., help="Path to base TOML config file"),
426
+ overlay_dir: str = typer.Option(
427
+ "",
428
+ "--overlay-dir",
429
+ help="Overlay directory (default: sibling overlay/)",
430
+ ),
431
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
432
+ ) -> None:
433
+ """Materialize TOML base into JSON runtime overlay."""
434
+ base_path = base_path.resolve()
435
+ if not base_path.is_file():
436
+ typer.echo(f"base config file not found: {base_path}", err=True)
437
+ raise typer.Exit(code=1)
438
+ overlay = Path(overlay_dir).resolve() if overlay_dir else base_path.parent / "overlay"
439
+ result = config_mod.config_apply(base_path, overlay)
440
+ if json_output:
441
+ typer.echo(json.dumps(result))
442
+ else:
443
+ typer.echo(f"Applied {base_path.name} -> {overlay / (base_path.stem + '.json')}")
444
+
445
+
446
+ @config_cli_app.command("show")
447
+ def config_show_cmd(
448
+ base_path: Path = typer.Argument(..., help="Path to base TOML config file"),
449
+ overlay_dir: str = typer.Option("", "--overlay-dir", help="Overlay directory"),
450
+ key: str = typer.Option("", "--key", "-k", help="Dotted key path (e.g. agents.opus.model)"),
451
+ strict: bool = typer.Option(False, "--strict", help="Fail if overlay is stale"),
452
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
453
+ ) -> None:
454
+ """Show merged config (overlay-first, base-fallback)."""
455
+ base_path = base_path.resolve()
456
+ if not base_path.is_file():
457
+ typer.echo(f"base config file not found: {base_path}", err=True)
458
+ raise typer.Exit(code=1)
459
+ overlay = Path(overlay_dir).resolve() if overlay_dir else base_path.parent / "overlay"
460
+ try:
461
+ result = config_mod.config_show(
462
+ base_path,
463
+ overlay,
464
+ key=key or None,
465
+ strict=strict,
466
+ )
467
+ except config_mod.BaseStaleError as exc:
468
+ typer.echo(str(exc), err=True)
469
+ raise typer.Exit(code=1)
470
+ if json_output:
471
+ if key:
472
+ typer.echo(json.dumps({"key": key, "value": result}))
473
+ else:
474
+ typer.echo(json.dumps(result))
475
+ else:
476
+ typer.echo(json.dumps(result, indent=2))
477
+
478
+
479
+ @config_cli_app.command("restore")
480
+ def config_restore_cmd(
481
+ workspace_root: Path,
482
+ ref: str = typer.Argument(..., help="Grip commit ref to restore config from"),
483
+ overlay_dir: str = typer.Option("", "--overlay-dir", help="Overlay directory to restore into"),
484
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
485
+ ) -> None:
486
+ """Restore config overlay from a grip commit snapshot."""
487
+ workspace_root = workspace_root.resolve()
488
+ overlay = Path(overlay_dir).resolve() if overlay_dir else workspace_root / "config" / "overlay"
489
+ result = config_mod.config_restore(workspace_root, ref, overlay)
490
+ if json_output:
491
+ typer.echo(json.dumps({"restored": result}))
492
+ else:
493
+ typer.echo(f"Restored {len(result)} file(s) from {ref[:12]}")