diff.py
python
| 1 | """muse diff — compare working tree against HEAD, or compare two commits.""" |
| 2 | |
| 3 | import json |
| 4 | import logging |
| 5 | import pathlib |
| 6 | |
| 7 | import typer |
| 8 | |
| 9 | from muse.core.errors import ExitCode |
| 10 | from muse.core.repo import require_repo |
| 11 | from muse.core.store import get_commit_snapshot_manifest, get_head_snapshot_manifest, resolve_commit_ref |
| 12 | from muse.domain import DomainOp, SnapshotManifest |
| 13 | from muse.plugins.registry import read_domain, resolve_plugin |
| 14 | |
| 15 | logger = logging.getLogger(__name__) |
| 16 | |
| 17 | app = typer.Typer() |
| 18 | |
| 19 | |
| 20 | def _read_branch(root: pathlib.Path) -> str: |
| 21 | head_ref = (root / ".muse" / "HEAD").read_text().strip() |
| 22 | return head_ref.removeprefix("refs/heads/").strip() |
| 23 | |
| 24 | |
| 25 | def _read_repo_id(root: pathlib.Path) -> str: |
| 26 | return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]) |
| 27 | |
| 28 | |
| 29 | def _print_structured_delta(ops: list[DomainOp]) -> int: |
| 30 | """Print a structured delta op-by-op. Returns the number of ops printed. |
| 31 | |
| 32 | Each branch checks ``op["op"]`` directly so mypy can narrow the |
| 33 | TypedDict union to the specific subtype before accessing its fields. |
| 34 | """ |
| 35 | for op in ops: |
| 36 | if op["op"] == "insert": |
| 37 | typer.echo(f"A {op['address']}") |
| 38 | elif op["op"] == "delete": |
| 39 | typer.echo(f"D {op['address']}") |
| 40 | elif op["op"] == "replace": |
| 41 | typer.echo(f"M {op['address']}") |
| 42 | elif op["op"] == "move": |
| 43 | typer.echo( |
| 44 | f"R {op['address']} ({op['from_position']} → {op['to_position']})" |
| 45 | ) |
| 46 | elif op["op"] == "patch": |
| 47 | typer.echo(f"M {op['address']}") |
| 48 | if op["child_summary"]: |
| 49 | typer.echo(f" └─ {op['child_summary']}") |
| 50 | return len(ops) |
| 51 | |
| 52 | |
| 53 | @app.callback(invoke_without_command=True) |
| 54 | def diff( |
| 55 | ctx: typer.Context, |
| 56 | commit_a: str | None = typer.Argument(None, help="Base commit ID (default: HEAD)."), |
| 57 | commit_b: str | None = typer.Argument(None, help="Target commit ID (default: working tree)."), |
| 58 | stat: bool = typer.Option(False, "--stat", help="Show summary statistics only."), |
| 59 | ) -> None: |
| 60 | """Compare working tree against HEAD, or compare two commits.""" |
| 61 | root = require_repo() |
| 62 | repo_id = _read_repo_id(root) |
| 63 | branch = _read_branch(root) |
| 64 | domain = read_domain(root) |
| 65 | plugin = resolve_plugin(root) |
| 66 | |
| 67 | if commit_a is None: |
| 68 | base_snap = SnapshotManifest( |
| 69 | files=get_head_snapshot_manifest(root, repo_id, branch) or {}, |
| 70 | domain=domain, |
| 71 | ) |
| 72 | target_snap = plugin.snapshot(root / "muse-work") |
| 73 | elif commit_b is None: |
| 74 | base_snap = SnapshotManifest( |
| 75 | files=get_head_snapshot_manifest(root, repo_id, branch) or {}, |
| 76 | domain=domain, |
| 77 | ) |
| 78 | target_snap = SnapshotManifest( |
| 79 | files=get_commit_snapshot_manifest(root, commit_a) or {}, |
| 80 | domain=domain, |
| 81 | ) |
| 82 | else: |
| 83 | base_snap = SnapshotManifest( |
| 84 | files=get_commit_snapshot_manifest(root, commit_a) or {}, |
| 85 | domain=domain, |
| 86 | ) |
| 87 | target_snap = SnapshotManifest( |
| 88 | files=get_commit_snapshot_manifest(root, commit_b) or {}, |
| 89 | domain=domain, |
| 90 | ) |
| 91 | |
| 92 | delta = plugin.diff(base_snap, target_snap, repo_root=root) |
| 93 | |
| 94 | if stat: |
| 95 | typer.echo(delta["summary"] if delta["ops"] else "No differences.") |
| 96 | return |
| 97 | |
| 98 | changed = _print_structured_delta(delta["ops"]) |
| 99 | |
| 100 | if changed == 0: |
| 101 | typer.echo("No differences.") |
| 102 | else: |
| 103 | typer.echo(f"\n{delta['summary']}") |