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