snapshot_diff.py python
370 lines 11.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 34 days ago
1 """muse plumbing snapshot-diff — diff two snapshot manifests.
2
3 Compares two Muse snapshots and categorises every path change into one of three
4 buckets: added, modified, or deleted. Accepts snapshot IDs directly or commit
5 IDs / branch names (in which case the commit's snapshot is resolved first).
6
7 Single-pair mode (default)
8 --------------------------
9
10 Output (JSON, default)::
11
12 {
13 "snapshot_a": "<sha256>",
14 "snapshot_b": "<sha256>",
15 "added": [{"path": "src/foo.py", "object_id": "<sha256>"}],
16 "modified": [{"path": "src/bar.py",
17 "object_id_a": "<sha256>", "object_id_b": "<sha256>"}],
18 "deleted": [{"path": "src/old.py", "object_id": "<sha256>"}],
19 "total_changes": 3
20 }
21
22 Text output (``--format text``)::
23
24 A src/foo.py
25 M src/bar.py
26 D src/old.py
27
28 Text output with ``--raw`` (includes object IDs)::
29
30 A <oid_b> src/foo.py
31 M <oid_a> <oid_b> src/bar.py
32 D <oid_a> src/old.py
33
34 Batch mode (``--stdin``)
35 ------------------------
36
37 Reads ref pairs from stdin (one ``<ref_a> <ref_b>`` per line) and processes
38 each pair. Analogous to ``git diff-tree --stdin``.
39
40 JSON mode emits one JSON object per pair (newline-delimited)::
41
42 {"snapshot_a": "...", "snapshot_b": "...", "added": [...], ...}
43 {"snapshot_a": "...", "snapshot_b": "...", "added": [...], ...}
44
45 Text mode emits the text diff for each pair separated by a blank line.
46
47 Invalid or unresolvable pairs emit an error object/line and continue.
48
49 Plumbing contract
50 -----------------
51
52 - Exit 0: diff computed (even when zero changes).
53 - Exit 1: snapshot or commit ID cannot be resolved; bad ``--format`` value.
54 - Exit 3: I/O error reading snapshot records.
55 - Batch mode (``--stdin``) always exits 0; individual errors are reported inline.
56 """
57
58 from __future__ import annotations
59
60 import argparse
61 import json
62 import logging
63 import pathlib
64 import sys
65 from typing import TypedDict
66
67 from muse.core.errors import ExitCode
68 from muse.core.repo import require_repo
69 from muse.core.store import (
70 get_head_commit_id,
71 read_commit,
72 read_current_branch,
73 read_snapshot,
74 )
75 from muse.core.validation import sanitize_display, validate_object_id
76
77 logger = logging.getLogger(__name__)
78
79 _FORMAT_CHOICES = ("json", "text")
80
81 # Column widths for --raw text output alignment.
82 _OID_WIDTH = 64
83
84
85 class _AddedEntry(TypedDict):
86 path: str
87 object_id: str
88
89
90 class _ModifiedEntry(TypedDict):
91 path: str
92 object_id_a: str
93 object_id_b: str
94
95
96 class _DeletedEntry(TypedDict):
97 path: str
98 object_id: str
99
100
101 class _DiffResult(TypedDict):
102 snapshot_a: str
103 snapshot_b: str
104 added: list[_AddedEntry]
105 modified: list[_ModifiedEntry]
106 deleted: list[_DeletedEntry]
107 total_changes: int
108
109
110 def _resolve_to_snapshot_id(root: pathlib.Path, ref: str) -> str | None:
111 """Return the snapshot ID for *ref*.
112
113 *ref* may be a 64-char snapshot ID, a 64-char commit ID, a branch name, or
114 ``HEAD``. Returns ``None`` when the ref cannot be resolved.
115 """
116 if ref.upper() == "HEAD":
117 branch = read_current_branch(root)
118 commit_id = get_head_commit_id(root, branch)
119 if commit_id is None:
120 return None
121 commit = read_commit(root, commit_id)
122 return commit.snapshot_id if commit else None
123
124 commit_id = get_head_commit_id(root, ref)
125 if commit_id is not None:
126 commit = read_commit(root, commit_id)
127 return commit.snapshot_id if commit else None
128
129 try:
130 validate_object_id(ref)
131 except ValueError:
132 return None
133
134 snap = read_snapshot(root, ref)
135 if snap is not None:
136 return snap.snapshot_id
137
138 commit = read_commit(root, ref)
139 if commit is not None:
140 return commit.snapshot_id
141
142 return None
143
144
145 def _compute_diff(root: pathlib.Path, ref_a: str, ref_b: str) -> "_DiffResult | dict[str, str]":
146 """Compute the diff between *ref_a* and *ref_b*.
147
148 Returns a ``_DiffResult`` on success or a ``{"error": "..."}`` dict when
149 either ref cannot be resolved or a snapshot cannot be read.
150 """
151 snap_id_a = _resolve_to_snapshot_id(root, ref_a)
152 if snap_id_a is None:
153 return {"error": f"Cannot resolve ref: {ref_a!r}"}
154
155 snap_id_b = _resolve_to_snapshot_id(root, ref_b)
156 if snap_id_b is None:
157 return {"error": f"Cannot resolve ref: {ref_b!r}"}
158
159 try:
160 snap_a = read_snapshot(root, snap_id_a)
161 snap_b = read_snapshot(root, snap_id_b)
162 except Exception as exc:
163 return {"error": str(exc)}
164
165 if snap_a is None:
166 return {"error": f"Snapshot not found: {snap_id_a}"}
167 if snap_b is None:
168 return {"error": f"Snapshot not found: {snap_id_b}"}
169
170 manifest_a = snap_a.manifest
171 manifest_b = snap_b.manifest
172 keys_a = set(manifest_a)
173 keys_b = set(manifest_b)
174
175 added: list[_AddedEntry] = sorted(
176 [{"path": p, "object_id": manifest_b[p]} for p in (keys_b - keys_a)],
177 key=lambda e: e["path"],
178 )
179 deleted: list[_DeletedEntry] = sorted(
180 [{"path": p, "object_id": manifest_a[p]} for p in (keys_a - keys_b)],
181 key=lambda e: e["path"],
182 )
183 modified: list[_ModifiedEntry] = sorted(
184 [
185 {"path": p, "object_id_a": manifest_a[p], "object_id_b": manifest_b[p]}
186 for p in (keys_a & keys_b)
187 if manifest_a[p] != manifest_b[p]
188 ],
189 key=lambda e: e["path"],
190 )
191
192 return _DiffResult(
193 snapshot_a=snap_id_a,
194 snapshot_b=snap_id_b,
195 added=added,
196 modified=modified,
197 deleted=deleted,
198 total_changes=len(added) + len(modified) + len(deleted),
199 )
200
201
202 def _emit_text(result: _DiffResult, raw: bool, stat: bool) -> None:
203 """Print text-format diff output for *result*."""
204 if raw:
205 for entry in result["added"]:
206 oid = entry["object_id"]
207 print(f"A {oid} {sanitize_display(entry['path'])}")
208 for entry in result["modified"]:
209 oid_a = entry["object_id_a"]
210 oid_b = entry["object_id_b"]
211 print(f"M {oid_a} {oid_b} {sanitize_display(entry['path'])}")
212 for entry in result["deleted"]:
213 oid = entry["object_id"]
214 print(f"D {oid} {sanitize_display(entry['path'])}")
215 else:
216 for entry in result["added"]:
217 print(f"A {sanitize_display(entry['path'])}")
218 for entry in result["modified"]:
219 print(f"M {sanitize_display(entry['path'])}")
220 for entry in result["deleted"]:
221 print(f"D {sanitize_display(entry['path'])}")
222
223 if stat:
224 na = len(result["added"])
225 nm = len(result["modified"])
226 nd = len(result["deleted"])
227 print(f"\n{na} added, {nm} modified, {nd} deleted")
228
229
230 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
231 """Register the snapshot-diff subcommand."""
232 parser = subparsers.add_parser(
233 "snapshot-diff",
234 help="Diff two snapshot manifests: added, modified, deleted paths.",
235 description=__doc__,
236 formatter_class=argparse.RawDescriptionHelpFormatter,
237 )
238 parser.add_argument(
239 "ref_a",
240 nargs="?",
241 default=None,
242 help=(
243 "First snapshot ID, commit ID, branch name, or HEAD. "
244 "Required in single-pair mode; omit when using --stdin."
245 ),
246 )
247 parser.add_argument(
248 "ref_b",
249 nargs="?",
250 default=None,
251 help=(
252 "Second snapshot ID, commit ID, branch name, or HEAD. "
253 "Required in single-pair mode; omit when using --stdin."
254 ),
255 )
256 parser.add_argument(
257 "--stdin",
258 action="store_true",
259 dest="from_stdin",
260 help=(
261 "Batch mode: read '<ref_a> <ref_b>' pairs from stdin (one per line) "
262 "and process each. JSON mode emits one object per line; "
263 "text mode separates pairs with a blank line."
264 ),
265 )
266 parser.add_argument(
267 "--raw",
268 action="store_true",
269 dest="raw",
270 help=(
271 "Include object IDs in text-format output. "
272 "Has no effect on JSON output (which always includes OIDs). "
273 "Format: 'A <oid> <path>', 'M <oid_a> <oid_b> <path>', 'D <oid> <path>'."
274 ),
275 )
276 parser.add_argument(
277 "--format", "-f",
278 dest="fmt",
279 default="json",
280 metavar="FORMAT",
281 help="Output format: json or text. (default: json)",
282 )
283 parser.add_argument(
284 "--json", action="store_const", const="json", dest="fmt",
285 help="Shorthand for --format json.",
286 )
287 parser.add_argument(
288 "--stat", "-s",
289 action="store_true",
290 help="Append a summary line: N added, M modified, D deleted (text mode only).",
291 )
292 parser.set_defaults(func=run)
293
294
295 def run(args: argparse.Namespace) -> None:
296 """Diff two snapshots and report added, modified, and deleted paths."""
297 fmt: str = args.fmt
298 ref_a: str | None = args.ref_a
299 ref_b: str | None = args.ref_b
300 from_stdin: bool = args.from_stdin
301 raw: bool = args.raw
302 stat: bool = args.stat
303
304 if fmt not in _FORMAT_CHOICES:
305 print(
306 json.dumps({"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"}),
307 file=sys.stderr,
308 )
309 raise SystemExit(ExitCode.USER_ERROR)
310
311 root = require_repo()
312
313 # ── Batch (--stdin) mode ──────────────────────────────────────────────────
314 if from_stdin:
315 first = True
316 for raw_line in sys.stdin:
317 line = raw_line.strip()
318 if not line or line.startswith("#"):
319 continue
320 parts = line.split()
321 if len(parts) < 2:
322 if fmt == "json":
323 print(json.dumps({"error": f"Expected '<ref_a> <ref_b>', got: {line!r}"}))
324 else:
325 print(f"error: expected '<ref_a> <ref_b>', got: {sanitize_display(line)}")
326 if not first:
327 print()
328 first = False
329 continue
330
331 pair_a, pair_b = parts[0], parts[1]
332 result = _compute_diff(root, pair_a, pair_b)
333
334 if "error" in result:
335 if fmt == "json":
336 print(json.dumps(result))
337 else:
338 if not first:
339 print()
340 print(f"error: {sanitize_display(result['error'])}") # type: ignore[index]
341 elif fmt == "json":
342 print(json.dumps(result))
343 else:
344 if not first:
345 print()
346 _emit_text(result, raw=raw, stat=stat) # type: ignore[arg-type]
347
348 first = False
349 return
350
351 # ── Single-pair mode ──────────────────────────────────────────────────────
352 if ref_a is None or ref_b is None:
353 print(
354 json.dumps({"error": "ref_a and ref_b are required in single-pair mode "
355 "(or use --stdin for batch processing)"}),
356 file=sys.stderr,
357 )
358 raise SystemExit(ExitCode.USER_ERROR)
359
360 result = _compute_diff(root, ref_a, ref_b)
361
362 if "error" in result:
363 print(json.dumps(result), file=sys.stderr)
364 raise SystemExit(ExitCode.USER_ERROR)
365
366 if fmt == "text":
367 _emit_text(result, raw=raw, stat=stat) # type: ignore[arg-type]
368 return
369
370 print(json.dumps(result))
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 34 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 34 days ago