gabriel / muse public
range_diff.py python
613 lines 20.4 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """``muse range-diff <base>..<old> <base>..<new>`` — compare two versions of a commit series.
2
3 Shows which commits changed, which are new, and which were dropped between
4 two versions of a patch series — typically before and after a rebase.
5
6 Pairing algorithm
7 -----------------
8 1. Compute a patch-id (SHA-256 of diff content lines) for every commit in both
9 series using the same algorithm as ``muse patch-id``.
10 2. Exact patch-id matches are always paired as ``equivalent`` — the commits
11 make identical logical changes regardless of commit ID or timestamp.
12 3. Remaining (unmatched) commits are paired positionally in series order when
13 ``--creation-factor > 0.0``. The creation factor controls aggressiveness:
14 at ``1.0`` all remaining are paired; at ``0.0`` only exact matches are paired
15 and all remaining are reported as ``dropped`` or ``added``.
16 4. Leftover old commits (no new partner) → ``dropped``.
17 Leftover new commits (no old partner) → ``added``.
18
19 Output (text, default)::
20
21 = <old_short> <subject> (equivalent)
22 ! <old_short> → <new_short> (changed)
23 < <old_short> <subject> (dropped from new)
24 > <new_short> <subject> (added in new)
25
26 JSON (``--json``)::
27
28 {
29 "old_range": "base..old",
30 "new_range": "base..new",
31 "trivially_equivalent": true,
32 "old_count": 3,
33 "new_count": 3,
34 "stable": false,
35 "creation_factor": 0.6,
36 "pairs": [
37 {
38 "old": {
39 "commit_id": "sha256:...",
40 "patch_id": "sha256:...",
41 "subject": "feat: add foo",
42 "files_changed": 2
43 },
44 "new": {
45 "commit_id": "sha256:...",
46 "patch_id": "sha256:...",
47 "subject": "feat: add foo",
48 "files_changed": 2
49 },
50 "status": "equivalent"
51 }
52 ],
53 "duration_ms": 12.3,
54 "exit_code": 0
55 }
56
57 ``old`` or ``new`` is ``null`` for ``dropped`` and ``added`` entries respectively.
58
59 Flags
60 -----
61 ``--creation-factor N``
62 Float 0.0–1.0. At ``0.6`` (default) remaining (non-exact-match) commits are
63 paired positionally as ``changed``. At ``0.0`` no fuzzy pairing is performed —
64 unpaired commits are always ``dropped`` or ``added``.
65
66 ``--stable``
67 Strip trailing whitespace from diff lines before computing patch-ids
68 (cosmetic whitespace changes are ignored).
69
70 ``--json``
71 Emit a single JSON object on stdout.
72
73 Exit codes::
74
75 0 — series are trivially equivalent (all patch-ids match)
76 1 — at least one commit differs, was dropped, or was added; or usage error
77 2 — not a Muse repository
78 """
79
80 from __future__ import annotations
81
82 import argparse
83 import json as _json
84 import logging
85 import pathlib
86 import re
87 import sys
88 from concurrent.futures import ThreadPoolExecutor, as_completed
89 from typing import Any
90
91 from muse.core.errors import ExitCode
92 from muse.core.graph import ancestor_ids, iter_ancestors
93 from muse.core.object_store import read_object
94 from muse.core.repo import require_repo
95 from muse.core.refs import read_ref
96 from muse.core.store import (
97 get_head_commit_id,
98 read_commit,
99 read_current_branch,
100 read_snapshot,
101 )
102 from muse.core._types import Manifest, blob_id, long_id, short_id
103 from muse.core.validation import sanitize_display
104 from muse.core.timing import start_timer
105
106 logger = logging.getLogger(__name__)
107
108 # Safe ref characters: alphanumeric, underscore, slash, dot, hyphen, colon.
109 # The colon is required for ``sha256:``-prefixed commit IDs.
110 _SAFE_REF_RE = re.compile(r"^[a-zA-Z0-9_/.\-:]+$")
111 # Range pattern: allows '..' as separator in addition to safe ref chars.
112 _SAFE_RANGE_RE = re.compile(r"^[a-zA-Z0-9_/.\-:]+(\.\.)[a-zA-Z0-9_/.\-:]+$|^[a-zA-Z0-9_/.\-:]+$")
113
114
115 # ---------------------------------------------------------------------------
116 # Range parsing
117 # ---------------------------------------------------------------------------
118
119
120 def _parse_range(ref: str) -> tuple[str | None, str]:
121 """Parse ``"base..tip"`` into ``(base, tip)``.
122
123 Returns ``(None, ref)`` for plain refs without ``".."``.
124 Leading/trailing whitespace around both parts is stripped.
125 """
126 if ".." in ref:
127 parts = ref.split("..", 1)
128 return parts[0].strip(), parts[1].strip()
129 return None, ref.strip()
130
131
132 # ---------------------------------------------------------------------------
133 # Ref resolution
134 # ---------------------------------------------------------------------------
135
136
137 def _resolve_ref(root: pathlib.Path, treeish: str) -> str | None:
138 """Resolve HEAD, a branch name, or a commit ID to a canonical commit ID.
139
140 Accepts both bare 64-char hex and ``sha256:<64hex>`` commit IDs.
141 Always returns the ``sha256:``-prefixed form or ``None`` if not found.
142 """
143 if treeish.upper() == "HEAD":
144 try:
145 branch = read_current_branch(root)
146 return get_head_commit_id(root, branch)
147 except Exception:
148 return None
149
150 # Accept both bare hex and sha256:-prefixed commit IDs.
151 if re.fullmatch(r"sha256:[0-9a-f]{64}", treeish):
152 full_id = treeish
153 elif re.fullmatch(r"[0-9a-f]{64}", treeish):
154 full_id = long_id(treeish)
155 else:
156 full_id = None
157 if full_id is not None:
158 if read_commit(root, full_id) is not None:
159 return full_id
160 return None
161
162 ref_file = root / ".muse" / "refs" / "heads" / treeish
163 return read_ref(ref_file)
164
165
166 # ---------------------------------------------------------------------------
167 # Range walking
168 # ---------------------------------------------------------------------------
169
170
171 def _exclude_set(root: pathlib.Path, start_id: str | None) -> set[str]:
172 """Return all commit IDs reachable from *start_id* (for range exclusion)."""
173 if start_id is None:
174 return set()
175 return ancestor_ids(root, start_id)
176
177
178 def _walk_range(root: pathlib.Path, base_id: str | None, tip_id: str) -> list[str]:
179 """Return commit IDs in ``base..tip``, oldest-first.
180
181 Commits reachable from *base_id* are excluded. When *base_id* equals
182 *tip_id* the result is empty (empty range).
183 """
184 if base_id is not None and base_id == tip_id:
185 return []
186
187 exclude = _exclude_set(root, base_id)
188 result = [
189 c.commit_id
190 for c in iter_ancestors(root, tip_id, first_parent_only=True, exclude=exclude)
191 ]
192 result.reverse() # oldest-first
193 return result
194
195
196 # ---------------------------------------------------------------------------
197 # Patch-id computation
198 # ---------------------------------------------------------------------------
199
200
201 def _compute_patch_id(
202 root: pathlib.Path,
203 base_manifest: Manifest,
204 target_manifest: Manifest,
205 *,
206 stable: bool = False,
207 ) -> tuple[str, int]:
208 """Compute a patch-id and files_changed count from the diff between two manifests.
209
210 Args:
211 root: Absolute repo root.
212 base_manifest: Parent commit manifest (path → object_id).
213 target_manifest: This commit manifest (path → object_id).
214 stable: When True, strip trailing whitespace before hashing.
215
216 Returns:
217 Tuple of (patch_id, files_changed) where patch_id is a
218 ``sha256:``-prefixed 64-char hex string and files_changed is the
219 count of added + removed + modified files.
220 """
221 import difflib
222
223 h = hashlib.sha256()
224 base_paths = set(base_manifest)
225 target_paths = set(target_manifest)
226 changed = sorted(
227 (target_paths - base_paths)
228 | (base_paths - target_paths)
229 | {p for p in base_paths & target_paths if base_manifest[p] != target_manifest[p]}
230 )
231
232 for path in changed:
233 if path in base_manifest:
234 raw = read_object(root, base_manifest[path])
235 base_lines = raw.decode("utf-8", errors="replace").splitlines() if raw else []
236 else:
237 base_lines = []
238
239 if path in target_manifest:
240 raw = read_object(root, target_manifest[path])
241 target_lines = raw.decode("utf-8", errors="replace").splitlines() if raw else []
242 else:
243 target_lines = []
244
245 for line in difflib.unified_diff(
246 base_lines, target_lines,
247 fromfile=f"a/{path}", tofile=f"b/{path}",
248 lineterm="",
249 ):
250 if line.startswith("+") or line.startswith("-"):
251 if stable:
252 line = line.rstrip()
253 h.update(line.encode("utf-8", errors="replace"))
254 h.update(b"\n")
255
256 return long_id(h.hexdigest()), len(changed)
257
258
259 def _patch_id_for_commit(
260 root: pathlib.Path,
261 commit_id: str,
262 *,
263 stable: bool,
264 ) -> tuple[str, int]:
265 """Compute the patch-id and files_changed for a single commit vs its first parent.
266
267 Args:
268 root: Absolute repo root.
269 commit_id: ``sha256:``-prefixed commit ID.
270 stable: Strip trailing whitespace before hashing when True.
271
272 Returns:
273 Tuple of (patch_id, files_changed). ``patch_id`` is a
274 ``sha256:``-prefixed hex string. ``files_changed`` counts added +
275 removed + modified files in this commit's diff vs its parent.
276 """
277 commit = read_commit(root, commit_id)
278 if commit is None:
279 return blob_id(commit_id.encode()), 0
280
281 base_manifest: Manifest = {}
282 if commit.parent_commit_id:
283 parent = read_commit(root, commit.parent_commit_id)
284 if parent:
285 snap = read_snapshot(root, parent.snapshot_id)
286 if snap:
287 base_manifest = dict(snap.manifest)
288
289 snap = read_snapshot(root, commit.snapshot_id)
290 target_manifest = dict(snap.manifest) if snap else {}
291
292 return _compute_patch_id(root, base_manifest, target_manifest, stable=stable)
293
294
295 def _commit_info(
296 root: pathlib.Path,
297 commit_id: str,
298 patch_id: str,
299 files_changed: int,
300 ) -> dict[str, Any]:
301 """Build a commit info dict for JSON output.
302
303 Args:
304 root: Absolute repo root.
305 commit_id: ``sha256:``-prefixed commit ID.
306 patch_id: ``sha256:``-prefixed patch-id.
307 files_changed: Number of files added/removed/modified in this commit.
308
309 Returns:
310 Dict with ``commit_id``, ``patch_id``, ``subject``, ``files_changed``.
311 """
312 commit = read_commit(root, commit_id)
313 subject = ""
314 if commit and commit.message:
315 subject = commit.message.splitlines()[0]
316 return {
317 "commit_id": commit_id,
318 "patch_id": patch_id,
319 "subject": subject,
320 "files_changed": files_changed,
321 }
322
323
324 # ---------------------------------------------------------------------------
325 # Pairing
326 # ---------------------------------------------------------------------------
327
328
329 def _pair_series(
330 root: pathlib.Path,
331 old_ids: list[str],
332 new_ids: list[str],
333 old_pids: dict[str, str],
334 new_pids: dict[str, str],
335 old_fcs: dict[str, int],
336 new_fcs: dict[str, int],
337 creation_factor: float,
338 ) -> list[dict[str, Any]]:
339 """Pair old and new commit series into a list of pair dicts.
340
341 Each pair has:
342 old: commit info dict or None (for "added")
343 new: commit info dict or None (for "dropped")
344 status: "equivalent" | "changed" | "dropped" | "added"
345 """
346 # Build reverse maps: patch_id → commit_id
347 old_by_pid: dict[str, str] = {v: k for k, v in old_pids.items()}
348 new_by_pid: dict[str, str] = {v: k for k, v in new_pids.items()}
349
350 used_old: set[str] = set()
351 used_new: set[str] = set()
352 pairs: list[dict[str, Any]] = []
353
354 # Pass 1: exact patch-id matches.
355 for cid in old_ids:
356 pid = old_pids[cid]
357 if pid in new_by_pid:
358 new_cid = new_by_pid[pid]
359 if new_cid not in used_new:
360 pairs.append({
361 "old": _commit_info(root, cid, pid, old_fcs.get(cid, 0)),
362 "new": _commit_info(root, new_cid, new_pids[new_cid], new_fcs.get(new_cid, 0)),
363 "status": "equivalent",
364 "_old_idx": old_ids.index(cid),
365 "_new_idx": new_ids.index(new_cid),
366 })
367 used_old.add(cid)
368 used_new.add(new_cid)
369
370 # Pass 2: positional pairing for unmatched commits (if creation_factor > 0).
371 remaining_old = [c for c in old_ids if c not in used_old]
372 remaining_new = [c for c in new_ids if c not in used_new]
373
374 if creation_factor > 0.0:
375 n_pairs = min(len(remaining_old), len(remaining_new))
376 for i in range(n_pairs):
377 old_cid = remaining_old[i]
378 new_cid = remaining_new[i]
379 pairs.append({
380 "old": _commit_info(root, old_cid, old_pids[old_cid], old_fcs.get(old_cid, 0)),
381 "new": _commit_info(root, new_cid, new_pids[new_cid], new_fcs.get(new_cid, 0)),
382 "status": "changed",
383 "_old_idx": old_ids.index(old_cid),
384 "_new_idx": new_ids.index(new_cid),
385 })
386 used_old.add(old_cid)
387 used_new.add(new_cid)
388
389 # Dropped: old commits with no partner.
390 for cid in old_ids:
391 if cid not in used_old:
392 pairs.append({
393 "old": _commit_info(root, cid, old_pids[cid], old_fcs.get(cid, 0)),
394 "new": None,
395 "status": "dropped",
396 "_old_idx": old_ids.index(cid),
397 "_new_idx": len(new_ids),
398 })
399
400 # Added: new commits with no partner.
401 for cid in new_ids:
402 if cid not in used_new:
403 pairs.append({
404 "old": None,
405 "new": _commit_info(root, cid, new_pids[cid], new_fcs.get(cid, 0)),
406 "status": "added",
407 "_old_idx": len(old_ids),
408 "_new_idx": new_ids.index(cid),
409 })
410
411 # Sort by new series order (then old order for dropped).
412 pairs.sort(key=lambda p: (p["_new_idx"], p["_old_idx"]))
413
414 # Strip internal sort keys and populate commit info with root.
415 for p in pairs:
416 del p["_old_idx"]
417 del p["_new_idx"]
418
419 return pairs
420
421
422 # ---------------------------------------------------------------------------
423 # Registration
424 # ---------------------------------------------------------------------------
425
426
427 def register(
428 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
429 ) -> None:
430 """Register the ``muse range-diff`` subcommand."""
431 parser = subparsers.add_parser(
432 "range-diff",
433 help="Compare two versions of a commit series.",
434 description=__doc__,
435 formatter_class=argparse.RawDescriptionHelpFormatter,
436 )
437 parser.add_argument(
438 "old_range",
439 metavar="OLD_RANGE",
440 help="Old commit range (e.g. base..old-branch).",
441 )
442 parser.add_argument(
443 "new_range",
444 metavar="NEW_RANGE",
445 help="New commit range (e.g. base..new-branch).",
446 )
447 parser.add_argument(
448 "--creation-factor",
449 type=float,
450 default=0.6,
451 dest="creation_factor",
452 metavar="N",
453 help=(
454 "Float 0.0–1.0. How aggressively to pair unmatched commits positionally. "
455 "1.0 = pair all remaining; 0.0 = exact patch-id matches only. (default: 0.6)"
456 ),
457 )
458 parser.add_argument(
459 "--stable",
460 action="store_true",
461 help="Ignore trailing whitespace when computing patch-ids.",
462 )
463 parser.add_argument(
464 "--json",
465 action="store_true",
466 dest="output_json",
467 help="Emit a single JSON object on stdout.",
468 )
469 parser.set_defaults(func=run)
470
471
472 # ---------------------------------------------------------------------------
473 # Run
474 # ---------------------------------------------------------------------------
475
476
477 def run(args: argparse.Namespace) -> None:
478 """Compare two commit series and report differences.
479
480 Exit codes::
481
482 0 — trivially equivalent (all pairs are equivalent)
483 1 — at least one commit differs, dropped, or added
484 2 — usage error
485 """
486 elapsed = start_timer()
487 old_range_str: str = args.old_range
488 new_range_str: str = args.new_range
489 creation_factor: float = max(0.0, min(1.0, args.creation_factor))
490 stable: bool = args.stable
491 output_json: bool = args.output_json
492
493 # Validate — reject ANSI/control characters.
494 for raw in (old_range_str, new_range_str):
495 if any(ord(c) < 32 for c in raw):
496 print(f"❌ Invalid ref: {sanitize_display(raw)}", file=sys.stderr)
497 raise SystemExit(ExitCode.USER_ERROR)
498 # Validate each part of the range individually.
499 for part in raw.split(".."):
500 part = part.strip()
501 if part and not _SAFE_REF_RE.match(part):
502 print(f"❌ Invalid ref: {sanitize_display(raw)}", file=sys.stderr)
503 raise SystemExit(ExitCode.USER_ERROR)
504
505 root = require_repo()
506
507 # Parse ranges.
508 old_base_str, old_tip_str = _parse_range(old_range_str)
509 new_base_str, new_tip_str = _parse_range(new_range_str)
510
511 # Resolve refs.
512 def _resolve(ref: str, label: str) -> str | None:
513 resolved = _resolve_ref(root, ref)
514 if resolved is None:
515 print(f"❌ Cannot resolve ref: {sanitize_display(ref)} ({label})", file=sys.stderr)
516 return resolved
517
518 old_base_id = _resolve(old_base_str, "old base") if old_base_str else None
519 old_tip_id = _resolve(old_tip_str, "old tip")
520 new_base_id = _resolve(new_base_str, "new base") if new_base_str else None
521 new_tip_id = _resolve(new_tip_str, "new tip")
522
523 if old_tip_id is None or new_tip_id is None:
524 raise SystemExit(ExitCode.USER_ERROR)
525 if old_base_str and old_base_id is None:
526 raise SystemExit(ExitCode.USER_ERROR)
527 if new_base_str and new_base_id is None:
528 raise SystemExit(ExitCode.USER_ERROR)
529
530 # Collect commit series.
531 old_ids = _walk_range(root, old_base_id, old_tip_id)
532 new_ids = _walk_range(root, new_base_id, new_tip_id)
533
534 # Compute patch-ids (and files_changed counts) in parallel.
535 old_pids: dict[str, str] = {}
536 new_pids: dict[str, str] = {}
537 old_fcs: dict[str, int] = {}
538 new_fcs: dict[str, int] = {}
539
540 all_ids = [(cid, "old") for cid in old_ids] + [(cid, "new") for cid in new_ids]
541
542 def _compute(item: tuple[str, str]) -> tuple[str, str, str, int]:
543 cid, side = item
544 pid, fc = _patch_id_for_commit(root, cid, stable=stable)
545 return cid, side, pid, fc
546
547 with ThreadPoolExecutor(max_workers=min(8, max(1, len(all_ids)))) as pool:
548 for cid, side, pid, fc in pool.map(_compute, all_ids):
549 if side == "old":
550 old_pids[cid] = pid
551 old_fcs[cid] = fc
552 else:
553 new_pids[cid] = pid
554 new_fcs[cid] = fc
555
556 # Pair the series.
557 pairs = _pair_series(
558 root, old_ids, new_ids,
559 old_pids, new_pids,
560 old_fcs, new_fcs,
561 creation_factor,
562 )
563
564 trivially_equivalent = all(p["status"] == "equivalent" for p in pairs)
565 exit_code = 0 if trivially_equivalent else int(ExitCode.USER_ERROR)
566
567 result: dict[str, Any] = {
568 "old_range": old_range_str,
569 "new_range": new_range_str,
570 "trivially_equivalent": trivially_equivalent,
571 "old_count": len(old_ids),
572 "new_count": len(new_ids),
573 "stable": stable,
574 "creation_factor": creation_factor,
575 "pairs": pairs,
576 "duration_ms": elapsed(),
577 "exit_code": exit_code,
578 }
579
580 if output_json:
581 print(_json.dumps(result))
582 else:
583 _print_text(result)
584
585 if not trivially_equivalent:
586 raise SystemExit(ExitCode.USER_ERROR)
587
588
589
590 def _print_text(result: dict[str, Any]) -> None:
591 """Print a human-readable range-diff summary."""
592 print(f"# range-diff {sanitize_display(result['old_range'])} → {sanitize_display(result['new_range'])}")
593 print()
594
595 if not result["pairs"]:
596 print("(empty — both series are empty)")
597 return
598
599 for p in result["pairs"]:
600 status = p["status"]
601 if status == "equivalent":
602 old = p["old"]
603 print(f"= {short_id(old['commit_id'])} {sanitize_display(old['subject'])}")
604 elif status == "changed":
605 old = p["old"]
606 new = p["new"]
607 print(f"! {short_id(old['commit_id'])} → {short_id(new['commit_id'])} {sanitize_display(new['subject'])}")
608 elif status == "dropped":
609 old = p["old"]
610 print(f"< {short_id(old['commit_id'])} {sanitize_display(old['subject'])}")
611 elif status == "added":
612 new = p["new"]
613 print(f"> {short_id(new['commit_id'])} {sanitize_display(new['subject'])}")
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago