gabriel / muse public
diff.py python
1,036 lines 43.7 KB
Raw
sha256:a8de2a41b39b56b68a4f5024e8475ff463681093380c43b72ed39e256cf6a46c feat(shelf): --path scoped shelving, the native replacement… Sonnet 5 minor ⚠ breaking 1 day ago
1 """``muse diff`` — show what has changed since the last commit.
2
3 ``muse diff`` always answers: **what has changed since my last commit?**
4 That means HEAD vs the actual working tree, regardless of what is staged.
5 The stage is a commit-preparation tool; it does not change the meaning of diff.
6
7 Usage
8 -----
9
10 Everything changed since last commit (default)::
11
12 muse diff
13
14 What *will* be committed (staged changes vs HEAD)::
15
16 muse diff --staged
17
18 What is *not yet* staged (working tree vs stage)::
19
20 muse diff --unstaged
21
22 Two commits::
23
24 muse diff <commit_a> <commit_b>
25
26 Limit output to specific files or directories::
27
28 muse diff -p muse/cli/commands/status.py
29 muse diff -p muse/cli/ -p muse/plugins/
30 muse diff --staged -p muse/cli/commands/status.py
31
32 Show shelved changes vs HEAD::
33
34 muse diff --shelf # most recent shelf entry
35 muse diff --shelf 1 # shelf entry at index 1
36
37 CI / agent pipeline usage::
38
39 muse diff --exit-code # exits 1 when changes exist, 0 when clean
40 muse diff --json --exit-code # structured output + exit code for scripting
41 """
42
43 import argparse
44 import difflib
45 import json
46 import logging
47 import os
48 import pathlib
49 import sys
50 from collections.abc import Callable
51 from typing import TypedDict
52
53 from muse.core.types import Manifest
54 from muse.core.envelope import EnvelopeJson, make_envelope
55 from muse.core.errors import ExitCode
56 from muse.core.merge_engine import read_merge_state
57 from muse.core.object_store import read_object
58 from muse.core.repo import require_repo
59 from muse.core.refs import read_current_branch
60 from muse.core.commits import resolve_commit_ref
61 from muse.core.snapshots import (
62 get_commit_snapshot_manifest,
63 get_head_snapshot_manifest,
64 read_snapshot,
65 )
66 from muse.core.commits import get_head_snapshot_id
67 from muse.core.validation import sanitize_display
68 from muse.core.snapshot import directories_from_manifest, filter_manifest_by_paths
69 from muse.core.cohen_transform import (
70 CONFLICT_SEPARATOR,
71 annotate_hunk_action,
72 format_conflict_diff,
73 )
74 from muse.core.semver_classifier import classify_delta
75 from muse.core.timing import start_timer
76 from muse.domain import DomainOp, PatchOp, SnapshotManifest, StagePlugin
77 from muse.plugins.code._query import flat_directory_ops
78 from muse.plugins.code.stage import read_stage_dir_renames
79 from muse.plugins.code.symbol_diff import delta_summary
80 from muse.plugins.registry import read_domain, resolve_plugin
81 from muse.cli.commands.shelf import _load_shelf, _resolve_entry
82
83 logger = logging.getLogger(__name__)
84
85 class _DiffConflictJson(EnvelopeJson):
86 """JSON output for ``muse diff --conflict --json``.
87
88 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
89
90 Fields
91 ------
92 status "conflict" when a merge is in progress, "no_conflict" otherwise.
93 base_commit Common ancestor commit ID, or None if no merge is in progress.
94 ours_commit The commit ID of the local (ours) side of the merge.
95 theirs_commit The commit ID of the incoming (theirs) side of the merge.
96 ours_label Human-readable label for the ours side (usually the branch name).
97 theirs_label Human-readable label for the theirs side.
98 conflicts List of per-path conflict detail dicts (path, ours_id, theirs_id, …).
99 """
100
101 status: str
102 base_commit: str | None
103 ours_commit: str | None
104 theirs_commit: str | None
105 ours_label: str
106 theirs_label: str
107 conflicts: list[dict]
108
109 class _DiffSymbolsJson(TypedDict):
110 """Per-file symbol change summary nested inside :class:`_DiffJson`.
111
112 Fields
113 ------
114 added Symbol names added in this file.
115 deleted Symbol names deleted from this file.
116 modified Symbol names whose bodies changed in this file.
117 """
118
119 added: list[str]
120 deleted: list[str]
121 modified: list[str]
122
123 class _DiffJson(EnvelopeJson):
124 """JSON output for ``muse diff --json`` (normal diff mode).
125
126 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
127
128 Fields
129 ------
130 from_ref Start ref of the diff (branch name, commit SHA, or "workdir").
131 to_ref End ref of the diff (branch name, commit SHA, or "workdir").
132 from_commit_id Full commit ID for the from side, or None for the working tree.
133 to_commit_id Full commit ID for the to side, or None for the working tree.
134 has_changes True when at least one file differs between the two sides.
135 summary Human-readable one-line summary of the change set.
136 added Paths of files added relative to from_ref.
137 deleted Paths of files deleted relative to from_ref.
138 modified Paths of files modified relative to from_ref.
139 renamed Map of old_path → new_path for renamed files.
140 total_changes Total count of added + deleted + modified + renamed files.
141 symbols Per-file symbol diff (path → _DiffSymbolsJson).
142 sem_ver_bump Semantic-version bump classification ("major", "minor",
143 "patch", or None when not determinable).
144 breaking_changes List of symbol addresses with breaking API changes.
145 """
146
147 from_ref: str
148 to_ref: str
149 from_commit_id: str | None
150 to_commit_id: str | None
151 has_changes: bool
152 summary: str
153 added: list[str]
154 deleted: list[str]
155 modified: list[str]
156 renamed: dict[str, str]
157 total_changes: int
158 symbols: dict[str, _DiffSymbolsJson]
159 sem_ver_bump: str
160 breaking_changes: list[str]
161
162 _MAX_INLINE_CHILDREN = 12
163
164 # Sentinel: the two-space + "L" prefix used by the domain plugin to annotate
165 # symbol locations inside op summaries (e.g. "added function foo L4–8").
166 _LOC_SEP = " L"
167
168 # ── Colour helpers ────────────────────────────────────────────────────────────
169 # Colours are applied only when stdout is a real TTY. When output is piped or
170 # redirected (e.g. into an agent tool, a file, or `less`) the raw text is
171 # emitted without escape sequences. Pass NO_COLOR=1 or TERM=dumb to force
172 # plain output even on a TTY.
173
174 def _use_color() -> bool:
175 """Return True when ANSI colours should be emitted to stdout."""
176 if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
177 return False
178 return sys.stdout.isatty()
179
180 def _green(text: str) -> str:
181 return f"\033[32m{text}\033[0m" if _use_color() else text
182
183 def _red(text: str) -> str:
184 return f"\033[31m{text}\033[0m" if _use_color() else text
185
186 def _yellow(text: str) -> str:
187 return f"\033[33m{text}\033[0m" if _use_color() else text
188
189 def _cyan(text: str) -> str:
190 return f"\033[36m{text}\033[0m" if _use_color() else text
191
192 def _bold(text: str) -> str:
193 return f"\033[1m{text}\033[0m" if _use_color() else text
194
195 # ── Op categorization ─────────────────────────────────────────────────────────
196
197 # ── Display helpers ───────────────────────────────────────────────────────────
198
199 def _split_loc(summary: str) -> tuple[str, str]:
200 """Split ``'added function foo L4–8'`` into ``('added function foo', 'L4–8')``.
201
202 Returns the original string and an empty loc when no location suffix is
203 present (e.g. cross-file move annotations that carry no line data).
204 """
205 if _LOC_SEP in summary:
206 label, _, loc = summary.rpartition(_LOC_SEP)
207 return label, f"L{loc}"
208 return summary, ""
209
210 def _print_child_ops(child_ops: list[DomainOp]) -> None:
211 """Render symbol-level child ops with aligned columns and colours.
212
213 Labels are left-padded to a uniform width within the group so the
214 line-range column (``L{start}–{end}``) lines up vertically. Shows up
215 to ``_MAX_INLINE_CHILDREN`` entries inline; summarises the rest on a
216 single trailing line.
217 """
218 visible = child_ops[:_MAX_INLINE_CHILDREN]
219 overflow = len(child_ops) - len(visible)
220
221 rows: list[tuple[str, str, str]] = []
222 for cop in visible:
223 if cop["op"] == "insert":
224 label, loc = _split_loc(cop["content_summary"])
225 rows.append(("insert", label, loc))
226 elif cop["op"] == "delete":
227 label, loc = _split_loc(cop["content_summary"])
228 rows.append(("delete", label, loc))
229 elif cop["op"] == "replace":
230 label, loc = _split_loc(cop["new_summary"])
231 rows.append(("replace", label, loc))
232 elif cop["op"] == "move":
233 label = f"{cop['address']} ({cop['from_position']} → {cop['to_position']})"
234 rows.append(("move", label, ""))
235 else:
236 rows.append(("unknown", "", ""))
237
238 for i, (op_type, label, loc) in enumerate(rows):
239 is_last = (i == len(rows) - 1) and overflow == 0
240 connector = "└─" if is_last else "├─"
241 if op_type == "insert":
242 styled = _green(label)
243 elif op_type == "delete":
244 styled = _red(label)
245 elif op_type == "replace":
246 styled = _yellow(label)
247 elif op_type == "move":
248 styled = _cyan(label)
249 else:
250 styled = label
251 suffix = f" {loc}" if loc else ""
252 print(f" {connector} {styled}{suffix}")
253
254 if overflow > 0:
255 print(f" └─ … and {overflow} more")
256
257 def _print_structured_delta(ops: list[DomainOp]) -> int:
258 """Print a colour-coded delta op-by-op. Returns the number of ops printed.
259
260 Colour scheme mirrors standard diff conventions:
261
262 - Green → added (A)
263 - Red → deleted (D)
264 - Yellow → modified (M)
265 - Cyan → moved / renamed (R)
266
267 Each branch checks ``op["op"]`` directly so mypy can narrow the
268 TypedDict union to the specific subtype before accessing its fields.
269 """
270 for op in ops:
271 if op["op"] == "insert":
272 print(_green(f"A {op['address']}"))
273 elif op["op"] == "delete":
274 print(_red(f"D {op['address']}"))
275 elif op["op"] == "replace":
276 print(_yellow(f"M {op['address']}"))
277 elif op["op"] == "move":
278 print(
279 _cyan(f"R {op['address']} ({op['from_position']} → {op['to_position']})")
280 )
281 elif op["op"] == "rename":
282 print(_cyan(f"R {op['from_address']} → {op['address']}"))
283 elif op["op"] == "patch":
284 child_ops = op["child_ops"]
285 # Use the authoritative file_change field set by build_diff_ops.
286 # Default to "modified" for PatchOps from older callers that
287 # predate this field.
288 fc = op.get("file_change", "modified")
289 if fc == "added":
290 print(_green(f"A {op['address']}"))
291 elif fc == "deleted":
292 print(_red(f"D {op['address']}"))
293 else:
294 print(_yellow(f"M {op['address']}"))
295 _print_child_ops(child_ops)
296 return len(ops)
297
298 def _print_text_diff(
299 base_files: Manifest,
300 target_files: Manifest,
301 root: pathlib.Path,
302 workdir: pathlib.Path | None,
303 ) -> int:
304 """Print a coloured unified diff for every changed file. Returns change count."""
305 base_paths = set(base_files)
306 target_paths = set(target_files)
307 changed = (
308 sorted(target_paths - base_paths) # added
309 + sorted(base_paths - target_paths) # removed
310 + sorted( # modified
311 p for p in base_paths & target_paths
312 if base_files[p] != target_files[p]
313 )
314 )
315
316 for path in changed:
317 # Sanitize the path before using it in diff headers so that file
318 # names containing ANSI escape sequences cannot spoof terminal output.
319 safe_path = sanitize_display(path)
320
321 # Read base content.
322 if path in base_files:
323 raw_base = read_object(root, base_files[path])
324 base_lines = (
325 raw_base.decode("utf-8", errors="replace").splitlines()
326 if raw_base
327 else []
328 )
329 base_label = f"a/{safe_path}"
330 else:
331 base_lines = []
332 base_label = "/dev/null"
333
334 # Read target content (object store first, then disk for working tree).
335 if path in target_files:
336 raw_target = read_object(root, target_files[path])
337 if raw_target is None and workdir is not None:
338 disk = workdir / path
339 if disk.is_file():
340 raw_target = disk.read_bytes()
341 target_lines = (
342 raw_target.decode("utf-8", errors="replace").splitlines()
343 if raw_target
344 else []
345 )
346 target_label = f"b/{safe_path}"
347 else:
348 target_lines = []
349 target_label = "/dev/null"
350
351 hunks = list(difflib.unified_diff(
352 base_lines, target_lines,
353 fromfile=base_label, tofile=target_label,
354 lineterm="",
355 ))
356 if not hunks:
357 continue
358
359 for line in hunks:
360 if line.startswith("---") or line.startswith("+++"):
361 print(_bold(line))
362 elif line.startswith("@@"):
363 print(_cyan(line))
364 elif line.startswith("+"):
365 print(_green(line))
366 elif line.startswith("-"):
367 print(_red(line))
368 else:
369 print(line)
370
371 return len(changed)
372
373 # ── Registration ──────────────────────────────────────────────────────────────
374
375 def _print_conflict_diff(
376 root: pathlib.Path,
377 base_commit_id: str | None,
378 ours_commit_id: str | None,
379 theirs_commit_id: str | None,
380 conflict_paths: list[str],
381 path_filter: list[str],
382 *,
383 ours_label: str,
384 theirs_label: str,
385 json_out: bool,
386 elapsed: Callable[[], float],
387 ) -> int:
388 """Render a Cohen-transform labeled diff for every conflicting file.
389
390 For each path in *conflict_paths*, computes ``base→ours`` and
391 ``base→theirs`` unified diffs and renders them with per-hunk action
392 annotations (``[ours: deleted]``, ``[theirs: inserted]``, …) so the
393 user can immediately see *what each side did* rather than staring at two
394 opaque blobs.
395
396 This is the direct implementation of the conflict-presentation insight
397 from Bram Cohen's Manyana project. Credit: Bram Cohen,
398 https://github.com/bramcohen/manyana.
399
400 Args:
401 root: Repository root.
402 base_commit_id: Merge-base commit ID (``None`` if unavailable).
403 ours_commit_id: Our branch commit ID at merge time.
404 theirs_commit_id: Their branch commit ID.
405 conflict_paths: Paths with unresolved conflicts (from MERGE_STATE).
406 path_filter: If non-empty, only render paths matching this list.
407 ours_label: Human-readable name for the ours side (branch name).
408 theirs_label: Human-readable name for the theirs side.
409 fmt: ``'text'`` or ``'json'``.
410
411 Returns:
412 Number of conflicting paths rendered.
413 """
414 base_manifest = get_commit_snapshot_manifest(root, base_commit_id) or {} if base_commit_id else {}
415 ours_manifest = get_commit_snapshot_manifest(root, ours_commit_id) or {} if ours_commit_id else {}
416 theirs_manifest = get_commit_snapshot_manifest(root, theirs_commit_id) or {} if theirs_commit_id else {}
417
418 paths = [
419 p for p in sorted(conflict_paths)
420 if not path_filter or any(p == pf or p.startswith(f"{pf}/") for pf in path_filter)
421 ]
422
423 if json_out:
424 conflicts_out = []
425 for path in paths:
426 def _lines(manifest: Manifest, disk_fallback: bool = False) -> list[str]:
427 oid = manifest.get(path)
428 if oid:
429 raw = read_object(root, oid)
430 if raw is not None:
431 return raw.decode("utf-8", errors="replace").splitlines(keepends=True)
432 if disk_fallback:
433 disk = root / path
434 if disk.is_file():
435 return disk.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
436 return []
437
438 safe = sanitize_display(path)
439 base_lines = _lines(base_manifest)
440 ours_lines = _lines(ours_manifest, disk_fallback=True)
441 theirs_lines = _lines(theirs_manifest)
442
443 ours_diff = "".join(difflib.unified_diff(
444 base_lines, ours_lines,
445 fromfile=f"base/{safe}", tofile=f"{ours_label}/{safe}", lineterm="",
446 ))
447 theirs_diff = "".join(difflib.unified_diff(
448 base_lines, theirs_lines,
449 fromfile=f"base/{safe}", tofile=f"{theirs_label}/{safe}", lineterm="",
450 ))
451 conflicts_out.append({
452 "path": safe,
453 "ours_diff": ours_diff,
454 "theirs_diff": theirs_diff,
455 })
456
457 print(json.dumps(_DiffConflictJson(
458 **make_envelope(elapsed),
459 status="conflict",
460 base_commit=base_commit_id,
461 ours_commit=ours_commit_id,
462 theirs_commit=theirs_commit_id,
463 ours_label=ours_label,
464 theirs_label=theirs_label,
465 conflicts=conflicts_out,
466 )))
467 return len(paths)
468
469 # Text mode — render with color.
470 use_color = _use_color()
471 for path in paths:
472 lines = format_conflict_diff(
473 path, root,
474 base_manifest, ours_manifest, theirs_manifest,
475 read_object,
476 use_color=use_color,
477 ours_label=ours_label,
478 theirs_label=theirs_label,
479 )
480 for line in lines:
481 print(line)
482
483 if paths:
484 print(f"\n{len(paths)} conflicting file(s). "
485 f"Run 'muse checkout --ours/--theirs <file>' to resolve.")
486
487 return len(paths)
488
489 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
490 """Register the ``muse diff`` subcommand and its flags."""
491 parser = subparsers.add_parser(
492 "diff",
493 help="Compare working tree against HEAD, or compare two commits.",
494 description=__doc__,
495 formatter_class=argparse.RawDescriptionHelpFormatter,
496 )
497 parser.add_argument(
498 "commit_a", nargs="?", default=None,
499 help=(
500 "Base commit ID (default: HEAD). If given alone and it does not "
501 "resolve to a commit/branch/tag but DOES match a tracked file or "
502 "directory, it is treated as a path filter instead (muse#36, "
503 "git-idiomatic 'muse diff <path>' / 'muse diff -- <path>'). When "
504 "a token is both a valid ref and a tracked path, the ref always "
505 "wins — use --path/-p to force path interpretation unambiguously."
506 ),
507 )
508 parser.add_argument(
509 "commit_b", nargs="?", default=None,
510 help="Target commit ID (default: working tree).",
511 )
512 parser.add_argument(
513 "--path", "-p", dest="paths", action="append", default=[],
514 metavar="path",
515 help="Limit diff to this file or directory. Repeat for multiple paths.",
516 )
517 parser.add_argument(
518 "--staged", action="store_true",
519 help="Show staged changes vs HEAD (what will be committed).",
520 )
521 parser.add_argument(
522 "--unstaged", action="store_true",
523 help="Show working-tree changes not yet staged (working tree vs stage).",
524 )
525 parser.add_argument(
526 "--stat", action="store_true",
527 help="Show summary statistics only.",
528 )
529 parser.add_argument(
530 "--text", action="store_true",
531 help="Show line-level unified diff instead of semantic symbols.",
532 )
533 parser.add_argument(
534 "--exit-code", "-z", action="store_true", dest="exit_code",
535 help=(
536 "Exit with code 1 when changes are present, 0 when the working "
537 "tree is clean. Useful in CI pipelines and agent preflight checks."
538 ),
539 )
540 parser.add_argument(
541 "--json", "-j", action="store_true", dest="json_out",
542 help="Emit machine-readable JSON instead of human text.",
543 )
544 parser.add_argument(
545 "--shelf", action="store_true", dest="shelf",
546 help=(
547 "Show the shelved changes vs HEAD. "
548 "Pass a positional name or index (default 0) to select an entry: "
549 "muse diff --shelf 1"
550 ),
551 )
552 parser.add_argument(
553 "--conflict", action="store_true", dest="conflict",
554 help=(
555 "Show a Cohen-transform labeled diff for every conflicting file "
556 "in the current in-progress merge. For each conflict, renders "
557 "base→ours and base→theirs diffs side-by-side, with each hunk "
558 "annotated by its action ([inserted], [deleted], [modified]). "
559 "Exits 1 when no merge is in progress and --conflict is forced."
560 ),
561 )
562 parser.set_defaults(func=run, conflict=False)
563
564 # ── Manifest filter ───────────────────────────────────────────────────────────
565
566 def _filter_manifest(manifest: Manifest, paths: list[str]) -> Manifest:
567 """Return a copy of *manifest* restricted to entries matching *paths*.
568
569 Thin wrapper over the shared :func:`muse.core.snapshot.filter_manifest_by_paths`
570 so every ``--path``/``-p`` filter in the CLI shares one matching rule.
571 """
572 return filter_manifest_by_paths(manifest, paths)
573
574 # ── Command entry point ───────────────────────────────────────────────────────
575
576 def run(args: argparse.Namespace) -> None:
577 """Show what has changed since the last commit.
578
579 Default: HEAD vs working tree (everything changed, staged or not).
580 Use ``--staged`` to see only what will be committed; ``--unstaged`` for
581 un-staged edits only. ``--exit-code`` exits 1 when changes exist (useful
582 in CI preflight scripts).
583
584 Agent quickstart
585 ----------------
586 ::
587
588 muse diff --json
589 muse diff --staged --json
590 muse diff HEAD~3 HEAD --json
591 muse diff --exit-code --json
592
593 JSON fields
594 -----------
595 from_ref Start ref label (``"HEAD"`` or commit SHA).
596 to_ref End ref label (``"working tree"`` or commit SHA).
597 from_commit_id Full start commit ID, or ``null``.
598 to_commit_id Full end commit ID, or ``null``.
599 has_changes ``true`` when any diff exists.
600 summary Human-readable summary string.
601 added List of added file paths.
602 deleted List of deleted file paths.
603 modified List of modified file paths.
604 renamed Map of old path → new path.
605 total_changes Total number of changed files.
606 symbols Per-file symbol diff: ``added``, ``deleted``, ``modified``.
607 sem_ver_bump Suggested semver bump level or ``null``.
608 breaking_changes List of breaking symbol addresses.
609
610 Exit codes
611 ----------
612 0 No changes (or changes exist but ``--exit-code`` not set).
613 1 Changes exist and ``--exit-code`` was passed; or invalid arguments.
614 2 Not inside a Muse repository.
615 """
616 elapsed = start_timer()
617 commit_a: str | None = args.commit_a
618 commit_b: str | None = args.commit_b
619 # Support a..b range syntax as sugar for two positional args.
620 if commit_a is not None and commit_b is None and ".." in commit_a:
621 commit_a, commit_b = commit_a.split("..", 1)
622 path_filter: list[str] = args.paths
623 staged: bool = args.staged
624 unstaged: bool = args.unstaged
625 shelf: bool = args.shelf
626 stat: bool = args.stat
627 text: bool = args.text
628 exit_code: bool = args.exit_code
629 json_out: bool = args.json_out
630 conflict: bool = getattr(args, "conflict", False)
631
632 if shelf and staged:
633 if json_out:
634 print(json.dumps({"error": "mutually_exclusive", "flags": ["--shelf", "--staged"], "message": "--shelf and --staged are mutually exclusive"}))
635 print("❌ --shelf and --staged are mutually exclusive.", file=sys.stderr)
636 raise SystemExit(ExitCode.USER_ERROR)
637 if shelf and unstaged:
638 if json_out:
639 print(json.dumps({"error": "mutually_exclusive", "flags": ["--shelf", "--unstaged"], "message": "--shelf and --unstaged are mutually exclusive"}))
640 print("❌ --shelf and --unstaged are mutually exclusive.", file=sys.stderr)
641 raise SystemExit(ExitCode.USER_ERROR)
642 if staged and unstaged:
643 if json_out:
644 print(json.dumps({"error": "mutually_exclusive", "flags": ["--staged", "--unstaged"], "message": "--staged and --unstaged are mutually exclusive"}))
645 print("❌ --staged and --unstaged are mutually exclusive.", file=sys.stderr)
646 raise SystemExit(ExitCode.USER_ERROR)
647
648 root = require_repo()
649
650 # ── Cohen-transform conflict diff mode ────────────────────────────────────
651 # Activated by --conflict, or automatically when a merge is in progress and
652 # no positional refs are given. Renders a labeled two-sided diff for each
653 # conflicting file (base→ours and base→theirs, hunk-annotated).
654 if conflict or (not commit_a and not commit_b and not shelf):
655 merge_state = read_merge_state(root)
656 if conflict and merge_state is None:
657 if json_out:
658 print(json.dumps({"error": "no_merge_in_progress", "message": "--conflict requires an in-progress merge — no MERGE_STATE.json found"}))
659 print(
660 "❌ --conflict requires an in-progress merge. "
661 "No MERGE_STATE.json found.",
662 file=sys.stderr,
663 )
664 raise SystemExit(ExitCode.USER_ERROR)
665 if merge_state is not None and conflict:
666 branch = read_current_branch(root)
667 ours_label = branch or "ours"
668 theirs_label = merge_state.other_branch or "theirs"
669 count = _print_conflict_diff(
670 root,
671 merge_state.base_commit,
672 merge_state.ours_commit,
673 merge_state.theirs_commit,
674 merge_state.conflict_paths,
675 path_filter,
676 ours_label=ours_label,
677 theirs_label=theirs_label,
678 json_out=json_out,
679 elapsed=elapsed,
680 )
681 raise SystemExit(1 if count > 0 else 0)
682 # No merge in progress — fall through to normal diff logic.
683 # ── End conflict diff mode ────────────────────────────────────────────────
684 branch = read_current_branch(root)
685 domain = read_domain(root)
686 plugin = resolve_plugin(root)
687
688 # ── muse#36: ref-vs-path disambiguation for a single bare positional ──────
689 # `commit_a` with no `commit_b` is ambiguous: it could be a commit ref
690 # (existing behavior) or a bare path, git-idiomatic `git diff <path>`.
691 # Prefer the ref interpretation when it resolves (100% unchanged
692 # behavior for every existing script); fall back to treating it as a
693 # path filter — folded into --path's existing _filter_manifest — only
694 # when it does NOT resolve as a ref but DOES match a tracked path.
695 #
696 # This transparently covers `muse diff -- <path>` too: argparse consumes
697 # a literal `--` before run() ever sees args, so by this point an
698 # unresolvable commit_a from `-- <path>` is indistinguishable from the
699 # bare `<path>` case, and the same fallback handles both.
700 #
701 # Documented divergence from git for the ambiguous case (a token that is
702 # BOTH a valid ref AND a tracked path): git requires `--` to force path
703 # interpretation; muse cannot observe whether `--` was present once
704 # argparse has stripped it, so muse always prefers the ref — the
705 # alternative the ticket (muse#36) explicitly sanctioned when `--`
706 # can't be detected. See the Gitism Glossary in docs/agent-guide.md.
707 if commit_a is not None and commit_b is None and not shelf:
708 if resolve_commit_ref(root, branch, commit_a) is None:
709 head_files_probe = get_head_snapshot_manifest(root, branch) or {}
710 workdir_files_probe: Manifest = {}
711 if isinstance(plugin, StagePlugin):
712 workdir_files_probe = plugin.workdir_snapshot(root)["files"]
713 candidate = commit_a.rstrip("/")
714 tracked_paths = set(head_files_probe) | set(workdir_files_probe)
715 is_tracked_path = any(
716 p == candidate or p.startswith(f"{candidate}/") for p in tracked_paths
717 )
718 if is_tracked_path:
719 path_filter = [commit_a] + path_filter
720 commit_a = None
721
722 # Cached commit ID for each resolved ref — populated alongside manifests so
723 # agents can track exactly which commits were compared.
724 from_commit_id: str | None = None
725 to_commit_id: str | None = None
726
727 def _resolve_manifest(ref: str) -> tuple[Manifest, str | None]:
728 """Resolve a ref to (manifest, commit_id). Exits on unknown ref."""
729 resolved = resolve_commit_ref(root, branch, ref)
730 if resolved is None:
731 print(f"⚠️ Commit '{sanitize_display(ref)}' not found.", file=sys.stderr)
732 raise SystemExit(ExitCode.USER_ERROR)
733 manifest = get_commit_snapshot_manifest(root, resolved.commit_id) or {}
734 return manifest, resolved.commit_id
735
736 # Track human-readable ref labels for JSON output so agents know exactly
737 # what was compared without having to re-parse positional arguments.
738 from_ref: str
739 to_ref: str
740
741 if shelf:
742 # --shelf: diff HEAD vs the shelved snapshot at name/index N.
743 # commit_a is reused as the optional name/index argument (default 0).
744 shelf_selector: str | None = commit_a
745 entries = _load_shelf(root)
746 if not entries:
747 msg = "no shelf entries — run `muse shelf save` to save changes first"
748 if json_out:
749 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
750 else:
751 print(f"❌ {msg}", file=sys.stderr)
752 raise SystemExit(ExitCode.USER_ERROR)
753 try:
754 idx, entry = _resolve_entry(entries, shelf_selector)
755 except ValueError as exc:
756 if json_out:
757 print(json.dumps({"error": str(exc), "exit_code": ExitCode.USER_ERROR}))
758 else:
759 print(f"❌ {exc}", file=sys.stderr)
760 raise SystemExit(ExitCode.USER_ERROR)
761 label = f"shelf/{idx} {entry['name']}"
762 if entry.get("intent"):
763 label += f": {entry['intent']}"
764 head_files = get_head_snapshot_manifest(root, branch) or {}
765 head_ref = resolve_commit_ref(root, branch, None)
766 from_commit_id = head_ref.commit_id if head_ref else None
767 # Shelf stores the full snapshot directly — no delta reconstruction needed.
768 shelf_files: Manifest = dict(entry["snapshot"])
769 for deleted_path in entry["deleted"]:
770 shelf_files.pop(deleted_path, None)
771 base_snap = SnapshotManifest(
772 files=head_files,
773 domain=domain,
774 directories=directories_from_manifest(head_files),
775 )
776 target_snap = SnapshotManifest(
777 files=shelf_files,
778 domain=domain,
779 directories=directories_from_manifest(shelf_files),
780 )
781 from_ref, to_ref = "HEAD", label
782
783 elif commit_a is None:
784 head_files = get_head_snapshot_manifest(root, branch) or {}
785 # Read the full HEAD snapshot so we pick up explicitly tracked empty
786 # directories (snapshot.directories) — not just those derivable from file paths.
787 _head_snap_id = get_head_snapshot_id(root, branch)
788 _head_snap = read_snapshot(root, _head_snap_id) if _head_snap_id else None
789 head_dirs: list[str] = _head_snap.directories if _head_snap is not None else []
790 head_ref = resolve_commit_ref(root, branch, None)
791 from_commit_id = head_ref.commit_id if head_ref else None
792
793 if staged and isinstance(plugin, StagePlugin):
794 # --staged: what will be committed (stage vs HEAD).
795 #
796 # Build the staged manifest as HEAD + explicit stage entries.
797 # We do NOT delegate to plugin.snapshot() here because when the
798 # stage index is empty that method falls through to a full
799 # working-tree walk — correct for `muse commit` (no stage = commit
800 # everything) but wrong for `muse diff --staged` (no stage =
801 # nothing staged = view equals HEAD, so has_changes=False).
802 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=sorted(set(directories_from_manifest(head_files)) | set(head_dirs)))
803 staged_entries = plugin.read_stage(root)
804 staged_files: dict[str, str] = dict(head_files)
805 staged_dirs: list[str] = []
806 for _path, _entry in staged_entries.items():
807 if _path.startswith(".muse/"):
808 continue
809 if _entry.get("kind") == "dir":
810 # Sentinel entry: directory placeholder — keep for SnapshotManifest.directories
811 # but never put in files (has no content object to read).
812 if _entry["mode"] == "A":
813 staged_dirs.append(_path)
814 continue
815 if _entry["mode"] == "D":
816 staged_files.pop(_path, None)
817 else:
818 staged_files[_path] = _entry["object_id"]
819 target_snap = SnapshotManifest(
820 files=staged_files,
821 domain=domain,
822 directories=sorted(set(directories_from_manifest(staged_files)) | set(staged_dirs)),
823 )
824 from_ref, to_ref = "HEAD", "staged"
825 elif unstaged and isinstance(plugin, StagePlugin):
826 # --unstaged: working-tree changes not yet added to the stage.
827 base_snap = plugin.snapshot(root) # staged manifest
828 target_snap = plugin.workdir_snapshot(root)
829 from_ref, to_ref = "staged", "working tree"
830 elif isinstance(plugin, StagePlugin):
831 # Default with staging: HEAD vs full working tree.
832 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=sorted(set(directories_from_manifest(head_files)) | set(head_dirs)))
833 target_snap = plugin.workdir_snapshot(root)
834 from_ref, to_ref = "HEAD", "working tree"
835 else:
836 # No staging support: HEAD vs working tree (original behaviour).
837 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=sorted(set(directories_from_manifest(head_files)) | set(head_dirs)))
838 target_snap = plugin.snapshot(root)
839 from_ref, to_ref = "HEAD", "working tree"
840 elif commit_b is None:
841 # Single ref: diff HEAD vs that commit's snapshot.
842 head_files = get_head_snapshot_manifest(root, branch) or {}
843 head_ref = resolve_commit_ref(root, branch, None)
844 from_commit_id = head_ref.commit_id if head_ref else None
845 target_manifest, to_commit_id = _resolve_manifest(commit_a)
846 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files))
847 target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest))
848 from_ref, to_ref = "HEAD", commit_a
849 else:
850 base_manifest, from_commit_id = _resolve_manifest(commit_a)
851 target_manifest, to_commit_id = _resolve_manifest(commit_b)
852 base_snap = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest))
853 target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest))
854 from_ref, to_ref = commit_a, commit_b
855
856 if path_filter:
857 filtered_base_files = _filter_manifest(base_snap["files"], path_filter)
858 filtered_target_files = _filter_manifest(target_snap["files"], path_filter)
859 base_snap = SnapshotManifest(
860 files=filtered_base_files,
861 domain=domain,
862 directories=directories_from_manifest(filtered_base_files),
863 )
864 target_snap = SnapshotManifest(
865 files=filtered_target_files,
866 domain=domain,
867 directories=directories_from_manifest(filtered_target_files),
868 )
869
870 if text and not json_out:
871 workdir = root if commit_a is None else None
872 changed = _print_text_diff(
873 base_snap["files"], target_snap["files"], root, workdir
874 )
875 if changed == 0:
876 print("No differences.")
877 if exit_code:
878 raise SystemExit(1 if changed > 0 else 0)
879 return
880
881 delta = plugin.diff(base_snap, target_snap, repo_root=root)
882
883 # For live diffs (HEAD vs workdir or HEAD vs staged), inject staged directory
884 # renames that the plugin cannot detect via content-hash matching (empty dirs
885 # have no content). Replace matching delete+insert op pairs with a single
886 # rename op and recompute the summary.
887 if commit_a is None and isinstance(plugin, StagePlugin):
888 _staged_dir_renames = read_stage_dir_renames(root)
889 if _staged_dir_renames:
890 _del_addrs = {
891 op["address"]: op for op in delta["ops"]
892 if op["op"] == "delete" and op["address"].endswith("/")
893 }
894 _ins_addrs = {
895 op["address"]: op for op in delta["ops"]
896 if op["op"] == "insert" and op["address"].endswith("/")
897 }
898 _drop: set[str] = set()
899 _new_rename_ops: list[DomainOp] = []
900 for _old, _new in sorted(_staged_dir_renames.items()):
901 _old_addr, _new_addr = _old + "/", _new + "/"
902 if _old_addr in _del_addrs and _new_addr in _ins_addrs:
903 _drop.add(_old_addr)
904 _drop.add(_new_addr)
905 _new_rename_ops.append({
906 "op": "rename",
907 "address": _new_addr,
908 "from_address": _old_addr,
909 }) # type: ignore[arg-type]
910 if _new_rename_ops:
911 _new_ops = [op for op in delta["ops"] if op["address"] not in _drop]
912 _new_ops.extend(_new_rename_ops)
913 delta = dict(delta) # type: ignore[assignment]
914 delta["ops"] = _new_ops
915 delta["summary"] = delta_summary(_new_ops)
916
917 if json_out:
918 # Categorise ops into file-level buckets and extract symbol-level detail.
919 #
920 # Muse's delta ops are file-level at the top; each file's symbol changes
921 # live in PatchOp.child_ops. The JSON schema exposes both layers so
922 # agents can ask "which files changed?" (added/modified/deleted/renamed)
923 # AND "which symbols changed in each file?" (symbols dict).
924 #
925 # Op type → file bucket:
926 # insert → added
927 # delete → deleted
928 # rename → renamed {from_address: address}
929 # patch (file_change="added") → added (whole-file add via patch)
930 # patch (file_change="deleted") → deleted (whole-file del via patch)
931 # patch (otherwise) → modified
932 # anything else → modified
933 #
934 # Symbol extraction (from PatchOp.child_ops):
935 # child op "insert" → symbols[file].added
936 # child op "delete" → symbols[file].deleted
937 # child op anything else → symbols[file].modified
938 added: list[str] = []
939 deleted: list[str] = []
940 modified: list[str] = []
941 renamed: dict[str, str] = {} # {old_path: new_path}
942 symbols = {} # {file: {added,deleted,modified}}
943
944 def _sym_name(address: str) -> str:
945 """Extract the symbol name from an address like 'file.py::func_name'."""
946 return address.split("::")[-1] if "::" in address else address
947
948 def _collect_child_symbols(file_path: str, child_ops: list[PatchOp]) -> None:
949 """Populate symbols[file_path] from a PatchOp's child_ops list."""
950 if not child_ops:
951 return
952 buckets = symbols.setdefault(file_path, {"added": [], "deleted": [], "modified": []})
953 for child in child_ops:
954 name = _sym_name(child.get("address", ""))
955 if not name:
956 continue
957 cop = child.get("op", "")
958 if cop == "insert":
959 buckets["added"].append(name)
960 elif cop == "delete":
961 buckets["deleted"].append(name)
962 else:
963 buckets["modified"].append(name)
964
965 for op in delta["ops"]:
966 op_type = op["op"]
967 address = op["address"]
968 if op_type == "rename":
969 renamed[op["from_address"]] = address
970 elif op_type == "insert":
971 summary = op.get("content_summary", "")
972 if address.endswith("/") or (isinstance(summary, str) and summary.startswith("directory:")):
973 added.append(address.rstrip("/") + "/")
974 else:
975 added.append(address)
976 elif op_type == "delete":
977 summary = op.get("content_summary", "")
978 if address.endswith("/") or (isinstance(summary, str) and summary.startswith("directory:")):
979 deleted.append(address.rstrip("/") + "/")
980 else:
981 deleted.append(address)
982 elif op_type == "patch":
983 fc = op.get("file_change", "modified")
984 if fc == "added":
985 added.append(address)
986 elif fc == "deleted":
987 deleted.append(address)
988 else:
989 modified.append(address)
990 # Symbol-level detail from child_ops — always collected.
991 _collect_child_symbols(address, op.get("child_ops", []))
992 else:
993 modified.append(address)
994
995 has_changes = bool(delta["ops"])
996 classification = classify_delta(delta, repo_root=root)
997 bump = classification.bump
998 _sorted_added = sorted(added)
999 _sorted_deleted = sorted(deleted)
1000 _sorted_modified = sorted(modified)
1001 print(json.dumps(_DiffJson(
1002 **make_envelope(elapsed),
1003 from_ref=from_ref,
1004 to_ref=to_ref,
1005 from_commit_id=from_commit_id,
1006 to_commit_id=to_commit_id,
1007 has_changes=has_changes,
1008 summary=delta["summary"] if has_changes else "",
1009 added=_sorted_added,
1010 deleted=_sorted_deleted,
1011 modified=_sorted_modified,
1012 renamed=renamed,
1013 total_changes=len(_sorted_added) + len(_sorted_modified) + len(_sorted_deleted) + len(renamed),
1014 symbols=symbols,
1015 sem_ver_bump=bump,
1016 breaking_changes=classification.breaking_addresses,
1017 )))
1018 if exit_code:
1019 raise SystemExit(1 if has_changes else 0)
1020 return
1021
1022 if stat:
1023 print(delta["summary"] if delta["ops"] else "No differences.")
1024 if exit_code:
1025 raise SystemExit(1 if delta["ops"] else 0)
1026 return
1027
1028 changed = _print_structured_delta(delta["ops"])
1029
1030 if changed == 0:
1031 print("No differences.")
1032 else:
1033 print(f"\n{delta['summary']}")
1034
1035 if exit_code:
1036 raise SystemExit(1 if changed > 0 else 0)
File History 1 commit
sha256:a8de2a41b39b56b68a4f5024e8475ff463681093380c43b72ed39e256cf6a46c feat(shelf): --path scoped shelving, the native replacement… Sonnet 5 minor ⚠ 1 day ago