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.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- 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]}")
|