gabriel / muse public
show.py python
145 lines 5.1 KB
bda49bdb feat: redesign .museignore as TOML with domain-scoped sections (#100) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """muse show — inspect a commit: metadata, diff, and files."""
2
3 from __future__ import annotations
4
5 import json
6 import logging
7 import pathlib
8
9 import typer
10
11 from muse.core.errors import ExitCode
12 from muse.core.repo import require_repo
13 from muse.core.store import get_commit_snapshot_manifest, read_commit, resolve_commit_ref
14 from muse.domain import DomainOp
15
16 logger = logging.getLogger(__name__)
17
18 app = typer.Typer()
19
20
21 def _read_branch(root: pathlib.Path) -> str:
22 head_ref = (root / ".muse" / "HEAD").read_text().strip()
23 return head_ref.removeprefix("refs/heads/").strip()
24
25
26 def _read_repo_id(root: pathlib.Path) -> str:
27 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
28
29
30 def _format_op(op: DomainOp) -> list[str]:
31 """Return one or more display lines for a single domain op.
32
33 Each branch checks ``op["op"]`` directly so mypy can narrow the
34 TypedDict union to the specific subtype before accessing its fields.
35 """
36 if op["op"] == "insert":
37 return [f" A {op['address']}"]
38 if op["op"] == "delete":
39 return [f" D {op['address']}"]
40 if op["op"] == "replace":
41 return [f" M {op['address']}"]
42 if op["op"] == "move":
43 return [f" R {op['address']} ({op['from_position']} → {op['to_position']})"]
44 if op["op"] == "mutate":
45 fields = ", ".join(
46 f"{k}: {v['old']}→{v['new']}" for k, v in op.get("fields", {}).items()
47 )
48 return [f" ~ {op['address']} ({fields or op.get('old_summary', '')}→{op.get('new_summary', '')})"]
49 # op["op"] == "patch" — the only remaining variant.
50 lines = [f" M {op['address']}"]
51 if op["child_summary"]:
52 lines.append(f" └─ {op['child_summary']}")
53 return lines
54
55
56 @app.callback(invoke_without_command=True)
57 def show(
58 ctx: typer.Context,
59 ref: str | None = typer.Argument(None, help="Commit ID or branch (default: HEAD)."),
60 stat: bool = typer.Option(True, "--stat/--no-stat", help="Show file change summary."),
61 json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
62 ) -> None:
63 """Inspect a commit: metadata, diff, and files."""
64 root = require_repo()
65 repo_id = _read_repo_id(root)
66 branch = _read_branch(root)
67
68 commit = resolve_commit_ref(root, repo_id, branch, ref)
69 if commit is None:
70 typer.echo(f"❌ Commit '{ref}' not found.")
71 raise typer.Exit(code=ExitCode.USER_ERROR)
72
73 if json_out:
74 import json as json_mod
75 commit_data = commit.to_dict()
76 if stat:
77 cur = get_commit_snapshot_manifest(root, commit.commit_id) or {}
78 par: dict[str, str] = (
79 get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
80 if commit.parent_commit_id else {}
81 )
82 stats = {
83 "files_added": sorted(set(cur) - set(par)),
84 "files_removed": sorted(set(par) - set(cur)),
85 "files_modified": sorted(
86 p for p in set(cur) & set(par) if cur[p] != par[p]
87 ),
88 }
89 typer.echo(json_mod.dumps({**commit_data, **stats}, indent=2, default=str))
90 else:
91 typer.echo(json_mod.dumps(commit_data, indent=2, default=str))
92 return
93
94 typer.echo(f"commit {commit.commit_id}")
95 if commit.parent_commit_id:
96 typer.echo(f"Parent: {commit.parent_commit_id[:8]}")
97 if commit.parent2_commit_id:
98 typer.echo(f"Parent: {commit.parent2_commit_id[:8]} (merge)")
99 if commit.author:
100 typer.echo(f"Author: {commit.author}")
101 typer.echo(f"Date: {commit.committed_at}")
102 if commit.metadata:
103 for k, v in sorted(commit.metadata.items()):
104 typer.echo(f" {k}: {v}")
105 typer.echo(f"\n {commit.message}\n")
106
107 if not stat:
108 return
109
110 # Prefer the structured delta stored on the commit.
111 # It carries rich note-level detail and is faster (no blob reloading).
112 if commit.structured_delta is not None:
113 delta = commit.structured_delta
114 if not delta["ops"]:
115 typer.echo(" (no changes)")
116 return
117 lines: list[str] = []
118 for op in delta["ops"]:
119 lines.extend(_format_op(op))
120 for line in lines:
121 typer.echo(line)
122 typer.echo(f"\n {delta['summary']}")
123 return
124
125 # Fallback for initial commits or pre-Phase-1 commits: compute file-level
126 # diff from snapshot manifests directly.
127 current = get_commit_snapshot_manifest(root, commit.commit_id) or {}
128 parent: dict[str, str] = {}
129 if commit.parent_commit_id:
130 parent = get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
131
132 added = sorted(set(current) - set(parent))
133 removed = sorted(set(parent) - set(current))
134 modified = sorted(p for p in set(current) & set(parent) if current[p] != parent[p])
135
136 for p in added:
137 typer.echo(f" A {p}")
138 for p in removed:
139 typer.echo(f" D {p}")
140 for p in modified:
141 typer.echo(f" M {p}")
142
143 total = len(added) + len(removed) + len(modified)
144 if total:
145 typer.echo(f"\n {total} file(s) changed")