gabriel / muse public
diff.py python
1,003 lines 41.5 KB
Raw
sha256:fb06ee1c470e3428858ce11acda629ecf2863cba8d5a55430671d54c9381c8bf fix(diff): restore .. range syntax lost in --allow-empty merge Sonnet 4.6 patch 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
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 EMPTY_DIR_OID, 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="Base commit ID (default: HEAD).",
500 )
501 parser.add_argument(
502 "commit_b", nargs="?", default=None,
503 help="Target commit ID (default: working tree).",
504 )
505 parser.add_argument(
506 "--path", "-p", dest="paths", action="append", default=[],
507 metavar="path",
508 help="Limit diff to this file or directory. Repeat for multiple paths.",
509 )
510 parser.add_argument(
511 "--staged", action="store_true",
512 help="Show staged changes vs HEAD (what will be committed).",
513 )
514 parser.add_argument(
515 "--unstaged", action="store_true",
516 help="Show working-tree changes not yet staged (working tree vs stage).",
517 )
518 parser.add_argument(
519 "--stat", action="store_true",
520 help="Show summary statistics only.",
521 )
522 parser.add_argument(
523 "--text", action="store_true",
524 help="Show line-level unified diff instead of semantic symbols.",
525 )
526 parser.add_argument(
527 "--exit-code", "-z", action="store_true", dest="exit_code",
528 help=(
529 "Exit with code 1 when changes are present, 0 when the working "
530 "tree is clean. Useful in CI pipelines and agent preflight checks."
531 ),
532 )
533 parser.add_argument(
534 "--json", "-j", action="store_true", dest="json_out",
535 help="Emit machine-readable JSON instead of human text.",
536 )
537 parser.add_argument(
538 "--shelf", action="store_true", dest="shelf",
539 help=(
540 "Show the shelved changes vs HEAD. "
541 "Pass a positional name or index (default 0) to select an entry: "
542 "muse diff --shelf 1"
543 ),
544 )
545 parser.add_argument(
546 "--conflict", action="store_true", dest="conflict",
547 help=(
548 "Show a Cohen-transform labeled diff for every conflicting file "
549 "in the current in-progress merge. For each conflict, renders "
550 "base→ours and base→theirs diffs side-by-side, with each hunk "
551 "annotated by its action ([inserted], [deleted], [modified]). "
552 "Exits 1 when no merge is in progress and --conflict is forced."
553 ),
554 )
555 parser.set_defaults(func=run, conflict=False)
556
557 # ── Manifest filter ───────────────────────────────────────────────────────────
558
559 def _filter_manifest(manifest: Manifest, paths: list[str]) -> Manifest:
560 """Return a copy of *manifest* restricted to entries matching *paths*.
561
562 Each entry in *paths* is treated as a prefix — it matches both exact file
563 paths (``muse/cli/commands/status.py``) and directory prefixes
564 (``muse/cli/``). An empty *paths* list returns the manifest unchanged.
565 """
566 if not paths:
567 return manifest
568 normalised = [p.rstrip("/") for p in paths]
569 return {
570 rel: oid
571 for rel, oid in manifest.items()
572 if any(rel == p or rel.startswith(f"{p}/") for p in normalised)
573 }
574
575 # ── Command entry point ───────────────────────────────────────────────────────
576
577 def run(args: argparse.Namespace) -> None:
578 """Show what has changed since the last commit.
579
580 Default: HEAD vs working tree (everything changed, staged or not).
581 Use ``--staged`` to see only what will be committed; ``--unstaged`` for
582 un-staged edits only. ``--exit-code`` exits 1 when changes exist (useful
583 in CI preflight scripts).
584
585 Agent quickstart
586 ----------------
587 ::
588
589 muse diff --json
590 muse diff --staged --json
591 muse diff HEAD~3 HEAD --json
592 muse diff --exit-code --json
593
594 JSON fields
595 -----------
596 from_ref Start ref label (``"HEAD"`` or commit SHA).
597 to_ref End ref label (``"working tree"`` or commit SHA).
598 from_commit_id Full start commit ID, or ``null``.
599 to_commit_id Full end commit ID, or ``null``.
600 has_changes ``true`` when any diff exists.
601 summary Human-readable summary string.
602 added List of added file paths.
603 deleted List of deleted file paths.
604 modified List of modified file paths.
605 renamed Map of old path → new path.
606 total_changes Total number of changed files.
607 symbols Per-file symbol diff: ``added``, ``deleted``, ``modified``.
608 sem_ver_bump Suggested semver bump level or ``null``.
609 breaking_changes List of breaking symbol addresses.
610
611 Exit codes
612 ----------
613 0 No changes (or changes exist but ``--exit-code`` not set).
614 1 Changes exist and ``--exit-code`` was passed; or invalid arguments.
615 2 Not inside a Muse repository.
616 """
617 elapsed = start_timer()
618 commit_a: str | None = args.commit_a
619 commit_b: str | None = args.commit_b
620 # Support a..b range syntax as sugar for two positional args.
621 if commit_a is not None and commit_b is None and ".." in commit_a:
622 commit_a, commit_b = commit_a.split("..", 1)
623 path_filter: list[str] = args.paths
624 staged: bool = args.staged
625 unstaged: bool = args.unstaged
626 shelf: bool = args.shelf
627 stat: bool = args.stat
628 text: bool = args.text
629 exit_code: bool = args.exit_code
630 json_out: bool = args.json_out
631 conflict: bool = getattr(args, "conflict", False)
632
633 if shelf and staged:
634 if json_out:
635 print(json.dumps({"error": "mutually_exclusive", "flags": ["--shelf", "--staged"], "message": "--shelf and --staged are mutually exclusive"}))
636 print("❌ --shelf and --staged are mutually exclusive.", file=sys.stderr)
637 raise SystemExit(ExitCode.USER_ERROR)
638 if shelf and unstaged:
639 if json_out:
640 print(json.dumps({"error": "mutually_exclusive", "flags": ["--shelf", "--unstaged"], "message": "--shelf and --unstaged are mutually exclusive"}))
641 print("❌ --shelf and --unstaged are mutually exclusive.", file=sys.stderr)
642 raise SystemExit(ExitCode.USER_ERROR)
643 if staged and unstaged:
644 if json_out:
645 print(json.dumps({"error": "mutually_exclusive", "flags": ["--staged", "--unstaged"], "message": "--staged and --unstaged are mutually exclusive"}))
646 print("❌ --staged and --unstaged are mutually exclusive.", file=sys.stderr)
647 raise SystemExit(ExitCode.USER_ERROR)
648
649 root = require_repo()
650
651 # ── Cohen-transform conflict diff mode ────────────────────────────────────
652 # Activated by --conflict, or automatically when a merge is in progress and
653 # no positional refs are given. Renders a labeled two-sided diff for each
654 # conflicting file (base→ours and base→theirs, hunk-annotated).
655 if conflict or (not commit_a and not commit_b and not shelf):
656 merge_state = read_merge_state(root)
657 if conflict and merge_state is None:
658 if json_out:
659 print(json.dumps({"error": "no_merge_in_progress", "message": "--conflict requires an in-progress merge — no MERGE_STATE.json found"}))
660 print(
661 "❌ --conflict requires an in-progress merge. "
662 "No MERGE_STATE.json found.",
663 file=sys.stderr,
664 )
665 raise SystemExit(ExitCode.USER_ERROR)
666 if merge_state is not None and conflict:
667 branch = read_current_branch(root)
668 ours_label = branch or "ours"
669 theirs_label = merge_state.other_branch or "theirs"
670 count = _print_conflict_diff(
671 root,
672 merge_state.base_commit,
673 merge_state.ours_commit,
674 merge_state.theirs_commit,
675 merge_state.conflict_paths,
676 path_filter,
677 ours_label=ours_label,
678 theirs_label=theirs_label,
679 json_out=json_out,
680 elapsed=elapsed,
681 )
682 raise SystemExit(1 if count > 0 else 0)
683 # No merge in progress — fall through to normal diff logic.
684 # ── End conflict diff mode ────────────────────────────────────────────────
685 branch = read_current_branch(root)
686 domain = read_domain(root)
687 plugin = resolve_plugin(root)
688
689 # Cached commit ID for each resolved ref — populated alongside manifests so
690 # agents can track exactly which commits were compared.
691 from_commit_id: str | None = None
692 to_commit_id: str | None = None
693
694 def _resolve_manifest(ref: str) -> tuple[Manifest, str | None]:
695 """Resolve a ref to (manifest, commit_id). Exits on unknown ref."""
696 resolved = resolve_commit_ref(root, branch, ref)
697 if resolved is None:
698 print(f"⚠️ Commit '{sanitize_display(ref)}' not found.", file=sys.stderr)
699 raise SystemExit(ExitCode.USER_ERROR)
700 manifest = get_commit_snapshot_manifest(root, resolved.commit_id) or {}
701 return manifest, resolved.commit_id
702
703 # Track human-readable ref labels for JSON output so agents know exactly
704 # what was compared without having to re-parse positional arguments.
705 from_ref: str
706 to_ref: str
707
708 if shelf:
709 # --shelf: diff HEAD vs the shelved snapshot at name/index N.
710 # commit_a is reused as the optional name/index argument (default 0).
711 shelf_selector: str | None = commit_a
712 entries = _load_shelf(root)
713 if not entries:
714 msg = "no shelf entries — run `muse shelf save` to save changes first"
715 if json_out:
716 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
717 else:
718 print(f"❌ {msg}", file=sys.stderr)
719 raise SystemExit(ExitCode.USER_ERROR)
720 try:
721 idx, entry = _resolve_entry(entries, shelf_selector)
722 except ValueError as exc:
723 if json_out:
724 print(json.dumps({"error": str(exc), "exit_code": ExitCode.USER_ERROR}))
725 else:
726 print(f"❌ {exc}", file=sys.stderr)
727 raise SystemExit(ExitCode.USER_ERROR)
728 label = f"shelf/{idx} {entry['name']}"
729 if entry.get("intent"):
730 label += f": {entry['intent']}"
731 head_files = get_head_snapshot_manifest(root, branch) or {}
732 head_ref = resolve_commit_ref(root, branch, None)
733 from_commit_id = head_ref.commit_id if head_ref else None
734 # Shelf stores the full snapshot directly — no delta reconstruction needed.
735 shelf_files: Manifest = dict(entry["snapshot"])
736 for deleted_path in entry["deleted"]:
737 shelf_files.pop(deleted_path, None)
738 base_snap = SnapshotManifest(
739 files=head_files,
740 domain=domain,
741 directories=directories_from_manifest(head_files),
742 )
743 target_snap = SnapshotManifest(
744 files=shelf_files,
745 domain=domain,
746 directories=directories_from_manifest(shelf_files),
747 )
748 from_ref, to_ref = "HEAD", label
749
750 elif commit_a is None:
751 head_files = get_head_snapshot_manifest(root, branch) or {}
752 # Read the full HEAD snapshot so we pick up explicitly tracked empty
753 # directories (snapshot.directories) — not just those derivable from file paths.
754 _head_snap_id = get_head_snapshot_id(root, branch)
755 _head_snap = read_snapshot(root, _head_snap_id) if _head_snap_id else None
756 head_dirs: list[str] = _head_snap.directories if _head_snap is not None else []
757 head_ref = resolve_commit_ref(root, branch, None)
758 from_commit_id = head_ref.commit_id if head_ref else None
759
760 if staged and isinstance(plugin, StagePlugin):
761 # --staged: what will be committed (stage vs HEAD).
762 #
763 # Build the staged manifest as HEAD + explicit stage entries.
764 # We do NOT delegate to plugin.snapshot() here because when the
765 # stage index is empty that method falls through to a full
766 # working-tree walk — correct for `muse commit` (no stage = commit
767 # everything) but wrong for `muse diff --staged` (no stage =
768 # nothing staged = view equals HEAD, so has_changes=False).
769 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=sorted(set(directories_from_manifest(head_files)) | set(head_dirs)))
770 staged_entries = plugin.read_stage(root)
771 staged_files: dict[str, str] = dict(head_files)
772 staged_dirs: list[str] = []
773 for _path, _entry in staged_entries.items():
774 if _path.startswith(".muse/"):
775 continue
776 if _entry["object_id"] == EMPTY_DIR_OID:
777 # Sentinel entry: directory placeholder — keep for SnapshotManifest.directories
778 # but never put in files (has no content object to read).
779 if _entry["mode"] == "A":
780 staged_dirs.append(_path)
781 continue
782 if _entry["mode"] == "D":
783 staged_files.pop(_path, None)
784 else:
785 staged_files[_path] = _entry["object_id"]
786 target_snap = SnapshotManifest(
787 files=staged_files,
788 domain=domain,
789 directories=sorted(set(directories_from_manifest(staged_files)) | set(staged_dirs)),
790 )
791 from_ref, to_ref = "HEAD", "staged"
792 elif unstaged and isinstance(plugin, StagePlugin):
793 # --unstaged: working-tree changes not yet added to the stage.
794 base_snap = plugin.snapshot(root) # staged manifest
795 target_snap = plugin.workdir_snapshot(root)
796 from_ref, to_ref = "staged", "working tree"
797 elif isinstance(plugin, StagePlugin):
798 # Default with staging: HEAD vs full working tree.
799 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=sorted(set(directories_from_manifest(head_files)) | set(head_dirs)))
800 target_snap = plugin.workdir_snapshot(root)
801 from_ref, to_ref = "HEAD", "working tree"
802 else:
803 # No staging support: HEAD vs working tree (original behaviour).
804 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=sorted(set(directories_from_manifest(head_files)) | set(head_dirs)))
805 target_snap = plugin.snapshot(root)
806 from_ref, to_ref = "HEAD", "working tree"
807 elif commit_b is None:
808 # Single ref: diff HEAD vs that commit's snapshot.
809 head_files = get_head_snapshot_manifest(root, branch) or {}
810 head_ref = resolve_commit_ref(root, branch, None)
811 from_commit_id = head_ref.commit_id if head_ref else None
812 target_manifest, to_commit_id = _resolve_manifest(commit_a)
813 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files))
814 target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest))
815 from_ref, to_ref = "HEAD", commit_a
816 else:
817 base_manifest, from_commit_id = _resolve_manifest(commit_a)
818 target_manifest, to_commit_id = _resolve_manifest(commit_b)
819 base_snap = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest))
820 target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest))
821 from_ref, to_ref = commit_a, commit_b
822
823 if path_filter:
824 filtered_base_files = _filter_manifest(base_snap["files"], path_filter)
825 filtered_target_files = _filter_manifest(target_snap["files"], path_filter)
826 base_snap = SnapshotManifest(
827 files=filtered_base_files,
828 domain=domain,
829 directories=directories_from_manifest(filtered_base_files),
830 )
831 target_snap = SnapshotManifest(
832 files=filtered_target_files,
833 domain=domain,
834 directories=directories_from_manifest(filtered_target_files),
835 )
836
837 if text and not json_out:
838 workdir = root if commit_a is None else None
839 changed = _print_text_diff(
840 base_snap["files"], target_snap["files"], root, workdir
841 )
842 if changed == 0:
843 print("No differences.")
844 if exit_code:
845 raise SystemExit(1 if changed > 0 else 0)
846 return
847
848 delta = plugin.diff(base_snap, target_snap, repo_root=root)
849
850 # For live diffs (HEAD vs workdir or HEAD vs staged), inject staged directory
851 # renames that the plugin cannot detect via content-hash matching (empty dirs
852 # have no content). Replace matching delete+insert op pairs with a single
853 # rename op and recompute the summary.
854 if commit_a is None and isinstance(plugin, StagePlugin):
855 _staged_dir_renames = read_stage_dir_renames(root)
856 if _staged_dir_renames:
857 _del_addrs = {
858 op["address"]: op for op in delta["ops"]
859 if op["op"] == "delete" and op["address"].endswith("/")
860 }
861 _ins_addrs = {
862 op["address"]: op for op in delta["ops"]
863 if op["op"] == "insert" and op["address"].endswith("/")
864 }
865 _drop: set[str] = set()
866 _new_rename_ops: list[DomainOp] = []
867 for _old, _new in sorted(_staged_dir_renames.items()):
868 _old_addr, _new_addr = _old + "/", _new + "/"
869 if _old_addr in _del_addrs and _new_addr in _ins_addrs:
870 _drop.add(_old_addr)
871 _drop.add(_new_addr)
872 _new_rename_ops.append({
873 "op": "rename",
874 "address": _new_addr,
875 "from_address": _old_addr,
876 }) # type: ignore[arg-type]
877 if _new_rename_ops:
878 _new_ops = [op for op in delta["ops"] if op["address"] not in _drop]
879 _new_ops.extend(_new_rename_ops)
880 delta = dict(delta) # type: ignore[assignment]
881 delta["ops"] = _new_ops
882 delta["summary"] = delta_summary(_new_ops)
883
884 if json_out:
885 # Categorise ops into file-level buckets and extract symbol-level detail.
886 #
887 # Muse's delta ops are file-level at the top; each file's symbol changes
888 # live in PatchOp.child_ops. The JSON schema exposes both layers so
889 # agents can ask "which files changed?" (added/modified/deleted/renamed)
890 # AND "which symbols changed in each file?" (symbols dict).
891 #
892 # Op type → file bucket:
893 # insert → added
894 # delete → deleted
895 # rename → renamed {from_address: address}
896 # patch (file_change="added") → added (whole-file add via patch)
897 # patch (file_change="deleted") → deleted (whole-file del via patch)
898 # patch (otherwise) → modified
899 # anything else → modified
900 #
901 # Symbol extraction (from PatchOp.child_ops):
902 # child op "insert" → symbols[file].added
903 # child op "delete" → symbols[file].deleted
904 # child op anything else → symbols[file].modified
905 added: list[str] = []
906 deleted: list[str] = []
907 modified: list[str] = []
908 renamed: dict[str, str] = {} # {old_path: new_path}
909 symbols = {} # {file: {added,deleted,modified}}
910
911 def _sym_name(address: str) -> str:
912 """Extract the symbol name from an address like 'file.py::func_name'."""
913 return address.split("::")[-1] if "::" in address else address
914
915 def _collect_child_symbols(file_path: str, child_ops: list[PatchOp]) -> None:
916 """Populate symbols[file_path] from a PatchOp's child_ops list."""
917 if not child_ops:
918 return
919 buckets = symbols.setdefault(file_path, {"added": [], "deleted": [], "modified": []})
920 for child in child_ops:
921 name = _sym_name(child.get("address", ""))
922 if not name:
923 continue
924 cop = child.get("op", "")
925 if cop == "insert":
926 buckets["added"].append(name)
927 elif cop == "delete":
928 buckets["deleted"].append(name)
929 else:
930 buckets["modified"].append(name)
931
932 for op in delta["ops"]:
933 op_type = op["op"]
934 address = op["address"]
935 if op_type == "rename":
936 renamed[op["from_address"]] = address
937 elif op_type == "insert":
938 summary = op.get("content_summary", "")
939 if address.endswith("/") or (isinstance(summary, str) and summary.startswith("directory:")):
940 added.append(address.rstrip("/") + "/")
941 else:
942 added.append(address)
943 elif op_type == "delete":
944 summary = op.get("content_summary", "")
945 if address.endswith("/") or (isinstance(summary, str) and summary.startswith("directory:")):
946 deleted.append(address.rstrip("/") + "/")
947 else:
948 deleted.append(address)
949 elif op_type == "patch":
950 fc = op.get("file_change", "modified")
951 if fc == "added":
952 added.append(address)
953 elif fc == "deleted":
954 deleted.append(address)
955 else:
956 modified.append(address)
957 # Symbol-level detail from child_ops — always collected.
958 _collect_child_symbols(address, op.get("child_ops", []))
959 else:
960 modified.append(address)
961
962 has_changes = bool(delta["ops"])
963 classification = classify_delta(delta, repo_root=root)
964 bump = classification.bump
965 _sorted_added = sorted(added)
966 _sorted_deleted = sorted(deleted)
967 _sorted_modified = sorted(modified)
968 print(json.dumps(_DiffJson(
969 **make_envelope(elapsed),
970 from_ref=from_ref,
971 to_ref=to_ref,
972 from_commit_id=from_commit_id,
973 to_commit_id=to_commit_id,
974 has_changes=has_changes,
975 summary=delta["summary"] if has_changes else "",
976 added=_sorted_added,
977 deleted=_sorted_deleted,
978 modified=_sorted_modified,
979 renamed=renamed,
980 total_changes=len(_sorted_added) + len(_sorted_modified) + len(_sorted_deleted) + len(renamed),
981 symbols=symbols,
982 sem_ver_bump=bump,
983 breaking_changes=classification.breaking_addresses,
984 )))
985 if exit_code:
986 raise SystemExit(1 if has_changes else 0)
987 return
988
989 if stat:
990 print(delta["summary"] if delta["ops"] else "No differences.")
991 if exit_code:
992 raise SystemExit(1 if delta["ops"] else 0)
993 return
994
995 changed = _print_structured_delta(delta["ops"])
996
997 if changed == 0:
998 print("No differences.")
999 else:
1000 print(f"\n{delta['summary']}")
1001
1002 if exit_code:
1003 raise SystemExit(1 if changed > 0 else 0)
File History 4 commits
sha256:fb06ee1c470e3428858ce11acda629ecf2863cba8d5a55430671d54c9381c8bf fix(diff): restore .. range syntax lost in --allow-empty merge Sonnet 4.6 patch 1 day ago
sha256:f30216fc8207df0308c0813b6eed7fd0a05d57dd383344800aa596393849c278 feat(diff): support a..b range syntax as sugar for two posi… Sonnet 4.6 patch 1 day ago
sha256:3f46367650ccd121654f3bbe06ed3471a9007c3229fe9556d1069d64b6a2550a refactor: directories are proper content-addressed objects … Sonnet 4.6 patch 14 days ago
sha256:9ef531ff8cf89d5fa4f90b6558383ffe3c7f5edfb02feaf77071cbca71f33fcb feat: muse diff shows staged dir renames as R not A+D Sonnet 4.6 patch 14 days ago