"""``muse diff`` — show what has changed since the last commit. ``muse diff`` always answers: **what has changed since my last commit?** That means HEAD vs the actual working tree, regardless of what is staged. The stage is a commit-preparation tool; it does not change the meaning of diff. Usage ----- Everything changed since last commit (default):: muse diff What *will* be committed (staged changes vs HEAD):: muse diff --staged What is *not yet* staged (working tree vs stage):: muse diff --unstaged Two commits:: muse diff Limit output to specific files or directories:: muse diff -p muse/cli/commands/status.py muse diff -p muse/cli/ -p muse/plugins/ muse diff --staged -p muse/cli/commands/status.py Show stashed changes vs HEAD:: muse diff --stash # most recent stash entry muse diff --stash 1 # stash entry at index 1 CI / agent pipeline usage:: muse diff --exit-code # exits 1 when changes exist, 0 when clean muse diff --json --exit-code # structured output + exit code for scripting """ from __future__ import annotations import argparse import difflib import json import logging import os import pathlib import sys from muse.core._types import Manifest from muse.core.errors import ExitCode from muse.core.merge_engine import read_merge_state from muse.core.object_store import read_object from muse.core.repo import read_repo_id, require_repo from muse.core.store import ( get_commit_snapshot_manifest, get_head_snapshot_manifest, read_current_branch, resolve_commit_ref, ) from muse.core.validation import sanitize_display from muse.core.snapshot import directories_from_manifest from muse.core.cohen_transform import ( CONFLICT_SEPARATOR, annotate_hunk_action, format_conflict_diff, ) from muse.domain import DomainOp, PatchOp, SnapshotManifest, StagePlugin from muse.plugins.registry import read_domain, resolve_plugin from muse.cli.commands.stash import _load_stash, _resolve_index logger = logging.getLogger(__name__) _MAX_INLINE_CHILDREN = 12 # Sentinel: the two-space + "L" prefix used by the domain plugin to annotate # symbol locations inside op summaries (e.g. "added function foo L4–8"). _LOC_SEP = " L" # ── Colour helpers ──────────────────────────────────────────────────────────── # Colours are applied only when stdout is a real TTY. When output is piped or # redirected (e.g. into an agent tool, a file, or `less`) the raw text is # emitted without escape sequences. Pass NO_COLOR=1 or TERM=dumb to force # plain output even on a TTY. def _use_color() -> bool: """Return True when ANSI colours should be emitted to stdout.""" if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb": return False return sys.stdout.isatty() def _green(text: str) -> str: return f"\033[32m{text}\033[0m" if _use_color() else text def _red(text: str) -> str: return f"\033[31m{text}\033[0m" if _use_color() else text def _yellow(text: str) -> str: return f"\033[33m{text}\033[0m" if _use_color() else text def _cyan(text: str) -> str: return f"\033[36m{text}\033[0m" if _use_color() else text def _bold(text: str) -> str: return f"\033[1m{text}\033[0m" if _use_color() else text # ── Op categorization ───────────────────────────────────────────────────────── def _classify_patch_op(op: PatchOp) -> str: """Classify a ``patch`` op as ``'insert'``, ``'delete'``, or ``'replace'``. The domain plugin emits ``patch`` for file-level changes with child ops describing symbol-level mutations. A patch whose children are exclusively ``insert`` ops represents a newly added file; one whose children are all ``delete`` ops represents a removed file; anything else is a modification. This mirrors the heuristic already used in ``_print_structured_delta`` for the text output path, and makes the JSON ``added``/``deleted`` lists correct for agent consumers. """ child_ops: list[DomainOp] = op["child_ops"] if not child_ops: return "replace" if all(c["op"] == "insert" for c in child_ops): return "insert" if all(c["op"] == "delete" for c in child_ops): return "delete" return "replace" def _op_category(op: DomainOp) -> str: """Return the logical category (``'insert'`` / ``'delete'`` / ``'replace'``) of any op.""" if op["op"] == "patch": return _classify_patch_op(op) if op["op"] in ("replace", "mutate", "move"): return "replace" return op["op"] # "insert" or "delete" # ── Display helpers ─────────────────────────────────────────────────────────── def _split_loc(summary: str) -> tuple[str, str]: """Split ``'added function foo L4–8'`` into ``('added function foo', 'L4–8')``. Returns the original string and an empty loc when no location suffix is present (e.g. cross-file move annotations that carry no line data). """ if _LOC_SEP in summary: label, _, loc = summary.rpartition(_LOC_SEP) return label, f"L{loc}" return summary, "" def _print_child_ops(child_ops: list[DomainOp]) -> None: """Render symbol-level child ops with aligned columns and colours. Labels are left-padded to a uniform width within the group so the line-range column (``L{start}–{end}``) lines up vertically. Shows up to ``_MAX_INLINE_CHILDREN`` entries inline; summarises the rest on a single trailing line. """ visible = child_ops[:_MAX_INLINE_CHILDREN] overflow = len(child_ops) - len(visible) rows: list[tuple[str, str, str]] = [] for cop in visible: if cop["op"] == "insert": label, loc = _split_loc(cop["content_summary"]) rows.append(("insert", label, loc)) elif cop["op"] == "delete": label, loc = _split_loc(cop["content_summary"]) rows.append(("delete", label, loc)) elif cop["op"] == "replace": label, loc = _split_loc(cop["new_summary"]) rows.append(("replace", label, loc)) elif cop["op"] == "move": label = f"{cop['address']} ({cop['from_position']} → {cop['to_position']})" rows.append(("move", label, "")) else: rows.append(("unknown", "", "")) for i, (op_type, label, loc) in enumerate(rows): is_last = (i == len(rows) - 1) and overflow == 0 connector = "└─" if is_last else "├─" if op_type == "insert": styled = _green(label) elif op_type == "delete": styled = _red(label) elif op_type == "replace": styled = _yellow(label) elif op_type == "move": styled = _cyan(label) else: styled = label suffix = f" {loc}" if loc else "" print(f" {connector} {styled}{suffix}") if overflow > 0: print(f" └─ … and {overflow} more") def _print_structured_delta(ops: list[DomainOp]) -> int: """Print a colour-coded delta op-by-op. Returns the number of ops printed. Colour scheme mirrors standard diff conventions: - Green → added (A) - Red → deleted (D) - Yellow → modified (M) - Cyan → moved / renamed (R) Each branch checks ``op["op"]`` directly so mypy can narrow the TypedDict union to the specific subtype before accessing its fields. """ for op in ops: if op["op"] == "insert": print(_green(f"A {op['address']}")) elif op["op"] == "delete": print(_red(f"D {op['address']}")) elif op["op"] == "replace": print(_yellow(f"M {op['address']}")) elif op["op"] == "move": print( _cyan(f"R {op['address']} ({op['from_position']} → {op['to_position']})") ) elif op["op"] == "patch": child_ops = op["child_ops"] from_address = op.get("from_address") if from_address: # File was renamed AND edited simultaneously. print(_cyan(f"R {from_address} → {op['address']}")) else: # Classify the patch: all-inserts = new file, all-deletes = # removed file, mixed = modification. category = _classify_patch_op(op) if category == "insert": print(_green(f"A {op['address']}")) elif category == "delete": print(_red(f"D {op['address']}")) else: print(_yellow(f"M {op['address']}")) _print_child_ops(child_ops) return len(ops) def _print_text_diff( base_files: Manifest, target_files: Manifest, root: pathlib.Path, workdir: pathlib.Path | None, ) -> int: """Print a coloured unified diff for every changed file. Returns change count.""" base_paths = set(base_files) target_paths = set(target_files) changed = ( sorted(target_paths - base_paths) # added + sorted(base_paths - target_paths) # removed + sorted( # modified p for p in base_paths & target_paths if base_files[p] != target_files[p] ) ) for path in changed: # Sanitize the path before using it in diff headers so that file # names containing ANSI escape sequences cannot spoof terminal output. safe_path = sanitize_display(path) # Read base content. if path in base_files: raw_base = read_object(root, base_files[path]) base_lines = ( raw_base.decode("utf-8", errors="replace").splitlines() if raw_base else [] ) base_label = f"a/{safe_path}" else: base_lines = [] base_label = "/dev/null" # Read target content (object store first, then disk for working tree). if path in target_files: raw_target = read_object(root, target_files[path]) if raw_target is None and workdir is not None: disk = workdir / path if disk.is_file(): raw_target = disk.read_bytes() target_lines = ( raw_target.decode("utf-8", errors="replace").splitlines() if raw_target else [] ) target_label = f"b/{safe_path}" else: target_lines = [] target_label = "/dev/null" hunks = list(difflib.unified_diff( base_lines, target_lines, fromfile=base_label, tofile=target_label, lineterm="", )) if not hunks: continue for line in hunks: if line.startswith("---") or line.startswith("+++"): print(_bold(line)) elif line.startswith("@@"): print(_cyan(line)) elif line.startswith("+"): print(_green(line)) elif line.startswith("-"): print(_red(line)) else: print(line) return len(changed) # ── Registration ────────────────────────────────────────────────────────────── def _print_conflict_diff( root: pathlib.Path, base_commit_id: str | None, ours_commit_id: str | None, theirs_commit_id: str | None, conflict_paths: list[str], path_filter: list[str], *, ours_label: str, theirs_label: str, fmt: str, ) -> int: """Render a Cohen-transform labeled diff for every conflicting file. For each path in *conflict_paths*, computes ``base→ours`` and ``base→theirs`` unified diffs and renders them with per-hunk action annotations (``[ours: deleted]``, ``[theirs: inserted]``, …) so the user can immediately see *what each side did* rather than staring at two opaque blobs. This is the direct implementation of the conflict-presentation insight from Bram Cohen's Manyana project. Credit: Bram Cohen, https://github.com/bramcohen/manyana. Args: root: Repository root. base_commit_id: Merge-base commit ID (``None`` if unavailable). ours_commit_id: Our branch commit ID at merge time. theirs_commit_id: Their branch commit ID. conflict_paths: Paths with unresolved conflicts (from MERGE_STATE). path_filter: If non-empty, only render paths matching this list. ours_label: Human-readable name for the ours side (branch name). theirs_label: Human-readable name for the theirs side. fmt: ``'text'`` or ``'json'``. Returns: Number of conflicting paths rendered. """ base_manifest = get_commit_snapshot_manifest(root, base_commit_id) or {} if base_commit_id else {} ours_manifest = get_commit_snapshot_manifest(root, ours_commit_id) or {} if ours_commit_id else {} theirs_manifest = get_commit_snapshot_manifest(root, theirs_commit_id) or {} if theirs_commit_id else {} paths = [ p for p in sorted(conflict_paths) if not path_filter or any(p == pf or p.startswith(pf + "/") for pf in path_filter) ] if fmt == "json": conflicts_out = [] for path in paths: def _lines(manifest: dict[str, str], disk_fallback: bool = False) -> list[str]: oid = manifest.get(path) if oid: raw = read_object(root, oid) if raw is not None: return raw.decode("utf-8", errors="replace").splitlines(keepends=True) if disk_fallback: disk = root / path if disk.is_file(): return disk.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True) return [] safe = sanitize_display(path) base_lines = _lines(base_manifest) ours_lines = _lines(ours_manifest, disk_fallback=True) theirs_lines = _lines(theirs_manifest) ours_diff = "".join(difflib.unified_diff( base_lines, ours_lines, fromfile=f"base/{safe}", tofile=f"{ours_label}/{safe}", lineterm="", )) theirs_diff = "".join(difflib.unified_diff( base_lines, theirs_lines, fromfile=f"base/{safe}", tofile=f"{theirs_label}/{safe}", lineterm="", )) conflicts_out.append({ "path": safe, "ours_diff": ours_diff, "theirs_diff": theirs_diff, }) print(json.dumps({ "status": "conflict", "base_commit": base_commit_id, "ours_commit": ours_commit_id, "theirs_commit": theirs_commit_id, "ours_label": ours_label, "theirs_label": theirs_label, "conflicts": conflicts_out, })) return len(paths) # Text mode — render with color. use_color = _use_color() for path in paths: lines = format_conflict_diff( path, root, base_manifest, ours_manifest, theirs_manifest, read_object, use_color=use_color, ours_label=ours_label, theirs_label=theirs_label, ) for line in lines: print(line) if paths: print(f"\n{len(paths)} conflicting file(s). " f"Run 'muse checkout --ours/--theirs ' to resolve.") return len(paths) def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``muse diff`` subcommand and its flags.""" parser = subparsers.add_parser( "diff", help="Compare working tree against HEAD, or compare two commits.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "commit_a", nargs="?", default=None, help="Base commit ID (default: HEAD).", ) parser.add_argument( "commit_b", nargs="?", default=None, help="Target commit ID (default: working tree).", ) parser.add_argument( "--path", "-p", dest="paths", action="append", default=[], metavar="path", help="Limit diff to this file or directory. Repeat for multiple paths.", ) parser.add_argument( "--staged", action="store_true", help="Show staged changes vs HEAD (what will be committed).", ) parser.add_argument( "--unstaged", action="store_true", help="Show working-tree changes not yet staged (working tree vs stage).", ) parser.add_argument( "--stat", action="store_true", help="Show summary statistics only.", ) parser.add_argument( "--text", action="store_true", help="Show line-level unified diff instead of semantic symbols.", ) parser.add_argument( "--exit-code", "-z", action="store_true", dest="exit_code", help=( "Exit with code 1 when changes are present, 0 when the working " "tree is clean. Useful in CI pipelines and agent preflight checks." ), ) parser.add_argument( "--format", "-f", default="text", dest="fmt", help="Output format: text (default) or json.", ) parser.add_argument( "--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) parser.add_argument( "--stash", action="store_true", dest="stash", help=( "Show the stashed changes vs HEAD. " "Pass a positional index (default 0) to select an entry: " "muse diff --stash 1" ), ) parser.add_argument( "--conflict", action="store_true", dest="conflict", help=( "Show a Cohen-transform labeled diff for every conflicting file " "in the current in-progress merge. For each conflict, renders " "base→ours and base→theirs diffs side-by-side, with each hunk " "annotated by its action ([inserted], [deleted], [modified]). " "Exits 1 when no merge is in progress and --conflict is forced." ), ) parser.set_defaults(func=run, conflict=False) # ── Manifest filter ─────────────────────────────────────────────────────────── def _filter_manifest(manifest: Manifest, paths: list[str]) -> Manifest: """Return a copy of *manifest* restricted to entries matching *paths*. Each entry in *paths* is treated as a prefix — it matches both exact file paths (``muse/cli/commands/status.py``) and directory prefixes (``muse/cli/``). An empty *paths* list returns the manifest unchanged. """ if not paths: return manifest normalised = [p.rstrip("/") for p in paths] return { rel: oid for rel, oid in manifest.items() if any(rel == p or rel.startswith(p + "/") for p in normalised) } # ── Command entry point ─────────────────────────────────────────────────────── def run(args: argparse.Namespace) -> None: """Show what has changed since the last commit. Default: HEAD vs working tree (everything changed, staged or not). Use ``--staged`` to see only what will be committed. Use ``--unstaged`` to see only what is not yet staged. Use ``--exit-code`` for scripting: exits 1 when changes exist, 0 when clean. Agents should pass ``--json`` to receive a structured result:: { "from_ref": "HEAD", "to_ref": "working tree", "from_commit_id": " | null", "to_commit_id": " | null", "has_changes": true, "summary": "3 changes", "added": ["path/to/new_file.py"], "deleted": ["path/to/removed.py"], "modified": ["path/to/changed.py"], "total_changes": 3 } ``--exit-code`` exits with 1 when changes exist and 0 when the working tree is clean, regardless of ``--format``. Combine with ``--json`` for fully machine-readable CI preflight output. """ commit_a: str | None = args.commit_a commit_b: str | None = args.commit_b path_filter: list[str] = args.paths staged: bool = args.staged unstaged: bool = args.unstaged stash: bool = args.stash stat: bool = args.stat text: bool = args.text exit_code: bool = args.exit_code fmt: str = args.fmt conflict: bool = getattr(args, "conflict", False) as_json = fmt == "json" if stash and staged: print("❌ --stash and --staged are mutually exclusive.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if stash and unstaged: print("❌ --stash and --unstaged are mutually exclusive.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if staged and unstaged: print("❌ --staged and --unstaged are mutually exclusive.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if fmt not in ("text", "json"): print( f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() # ── Cohen-transform conflict diff mode ──────────────────────────────────── # Activated by --conflict, or automatically when a merge is in progress and # no positional refs are given. Renders a labeled two-sided diff for each # conflicting file (base→ours and base→theirs, hunk-annotated). if conflict or (not commit_a and not commit_b and not stash): merge_state = read_merge_state(root) if conflict and merge_state is None: print( "❌ --conflict requires an in-progress merge. " "No MERGE_STATE.json found.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if merge_state is not None and conflict: branch = read_current_branch(root) ours_label = branch or "ours" theirs_label = merge_state.other_branch or "theirs" count = _print_conflict_diff( root, merge_state.base_commit, merge_state.ours_commit, merge_state.theirs_commit, merge_state.conflict_paths, path_filter, ours_label=ours_label, theirs_label=theirs_label, fmt=fmt, ) raise SystemExit(1 if count > 0 else 0) # No merge in progress — fall through to normal diff logic. # ── End conflict diff mode ──────────────────────────────────────────────── repo_id = read_repo_id(root) branch = read_current_branch(root) domain = read_domain(root) plugin = resolve_plugin(root) # Cached commit ID for each resolved ref — populated alongside manifests so # agents can track exactly which commits were compared. from_commit_id: str | None = None to_commit_id: str | None = None def _resolve_manifest(ref: str) -> tuple[dict[str, str], str | None]: """Resolve a ref to (manifest, commit_id). Exits on unknown ref.""" resolved = resolve_commit_ref(root, repo_id, branch, ref) if resolved is None: print(f"⚠️ Commit '{sanitize_display(ref)}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) manifest = get_commit_snapshot_manifest(root, resolved.commit_id) or {} return manifest, resolved.commit_id # Track human-readable ref labels for JSON output so agents know exactly # what was compared without having to re-parse positional arguments. from_ref: str to_ref: str if stash: # --stash: diff HEAD vs the stashed snapshot at index N. # commit_a is reused as the optional index argument (default "0"). stash_index_raw: str | None = commit_a entries = _load_stash(root) if not entries: msg = "no stash entries — run `muse stash` to save changes first" if as_json: print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR})) else: print(f"❌ {msg}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) idx = _resolve_index(entries, stash_index_raw) entry = entries[idx] label = f"stash@{{{idx}}}" if entry.get("message"): label += f": {entry['message']}" head_files = get_head_snapshot_manifest(root, repo_id, branch) or {} head_ref = resolve_commit_ref(root, repo_id, branch, None) from_commit_id = head_ref.commit_id if head_ref else None # Build the stash manifest: HEAD + stash delta + stash deletions. stash_files: Manifest = {**head_files} stash_files.update(entry["delta"]) for deleted_path in entry["deleted"]: stash_files.pop(deleted_path, None) base_snap = SnapshotManifest( files=head_files, domain=domain, directories=directories_from_manifest(head_files), ) target_snap = SnapshotManifest( files=stash_files, domain=domain, directories=directories_from_manifest(stash_files), ) from_ref, to_ref = "HEAD", label elif commit_a is None: head_files = get_head_snapshot_manifest(root, repo_id, branch) or {} head_ref = resolve_commit_ref(root, repo_id, branch, None) from_commit_id = head_ref.commit_id if head_ref else None if staged and isinstance(plugin, StagePlugin): # --staged: what will be committed (stage vs HEAD). base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files)) target_snap = plugin.snapshot(root) from_ref, to_ref = "HEAD", "staged" elif unstaged and isinstance(plugin, StagePlugin): # --unstaged: working-tree changes not yet added to the stage. base_snap = plugin.snapshot(root) # staged manifest target_snap = plugin.workdir_snapshot(root) from_ref, to_ref = "staged", "working tree" elif isinstance(plugin, StagePlugin): # Default with staging: HEAD vs full working tree. base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files)) target_snap = plugin.workdir_snapshot(root) from_ref, to_ref = "HEAD", "working tree" else: # No staging support: HEAD vs working tree (original behaviour). base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files)) target_snap = plugin.snapshot(root) from_ref, to_ref = "HEAD", "working tree" elif commit_b is None: # Single ref: diff HEAD vs that commit's snapshot. head_files = get_head_snapshot_manifest(root, repo_id, branch) or {} head_ref = resolve_commit_ref(root, repo_id, branch, None) from_commit_id = head_ref.commit_id if head_ref else None target_manifest, to_commit_id = _resolve_manifest(commit_a) base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files)) target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest)) from_ref, to_ref = "HEAD", commit_a else: base_manifest, from_commit_id = _resolve_manifest(commit_a) target_manifest, to_commit_id = _resolve_manifest(commit_b) base_snap = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest)) target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest)) from_ref, to_ref = commit_a, commit_b if path_filter: filtered_base_files = _filter_manifest(base_snap["files"], path_filter) filtered_target_files = _filter_manifest(target_snap["files"], path_filter) base_snap = SnapshotManifest( files=filtered_base_files, domain=domain, directories=directories_from_manifest(filtered_base_files), ) target_snap = SnapshotManifest( files=filtered_target_files, domain=domain, directories=directories_from_manifest(filtered_target_files), ) if text and fmt != "json": workdir = root if commit_a is None else None changed = _print_text_diff( base_snap["files"], target_snap["files"], root, workdir ) if changed == 0: print("No differences.") if exit_code: raise SystemExit(1 if changed > 0 else 0) return delta = plugin.diff(base_snap, target_snap, repo_root=root) if fmt == "json": # Categorize ops into added/deleted/modified, correctly handling the # ``patch`` op that the code plugin emits for file-level changes. # A ``patch`` with all-insert children = file added. # A ``patch`` with all-delete children = file deleted. # Anything else = file modified. added: list[str] = [] deleted: list[str] = [] modified: list[str] = [] for op in delta["ops"]: category = _op_category(op) if category == "insert": added.append(op["address"]) elif category == "delete": deleted.append(op["address"]) else: modified.append(op["address"]) has_changes = bool(delta["ops"]) print(json.dumps({ "from_ref": from_ref, "to_ref": to_ref, "from_commit_id": from_commit_id, "to_commit_id": to_commit_id, "has_changes": has_changes, "summary": delta["summary"], "added": sorted(added), "deleted": sorted(deleted), "modified": sorted(modified), "total_changes": len(delta["ops"]), })) if exit_code: raise SystemExit(1 if has_changes else 0) return if stat: print(delta["summary"] if delta["ops"] else "No differences.") if exit_code: raise SystemExit(1 if delta["ops"] else 0) return changed = _print_structured_delta(delta["ops"]) if changed == 0: print("No differences.") else: print(f"\n{delta['summary']}") if exit_code: raise SystemExit(1 if changed > 0 else 0)