gabriel / muse public
show.py python
139 lines 4.8 KB
d7054e63 feat(phase-1): typed delta algebra — replace DeltaManifest with Structu… Gabriel Cardona <gabriel@tellurstori.com> 6d ago
1 """muse show — inspect a commit: metadata, diff, and files."""
2 from __future__ import annotations
3
4 import json
5 import logging
6 import pathlib
7
8 import typer
9
10 from muse.core.errors import ExitCode
11 from muse.core.repo import require_repo
12 from muse.core.store import get_commit_snapshot_manifest, read_commit, resolve_commit_ref
13 from muse.domain import DomainOp
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 _format_op(op: DomainOp) -> list[str]:
30 """Return one or more display lines for a single domain op.
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 if op["op"] == "insert":
36 return [f" A {op['address']}"]
37 if op["op"] == "delete":
38 return [f" D {op['address']}"]
39 if op["op"] == "replace":
40 return [f" M {op['address']}"]
41 if op["op"] == "move":
42 return [f" R {op['address']} ({op['from_position']} → {op['to_position']})"]
43 # op["op"] == "patch" — the only remaining variant after the four branches above.
44 lines = [f" M {op['address']}"]
45 if op["child_summary"]:
46 lines.append(f" └─ {op['child_summary']}")
47 return lines
48
49
50 @app.callback(invoke_without_command=True)
51 def show(
52 ctx: typer.Context,
53 ref: str | None = typer.Argument(None, help="Commit ID or branch (default: HEAD)."),
54 stat: bool = typer.Option(True, "--stat/--no-stat", help="Show file change summary."),
55 json_out: bool = typer.Option(False, "--json", help="Output as JSON."),
56 ) -> None:
57 """Inspect a commit: metadata, diff, and files."""
58 root = require_repo()
59 repo_id = _read_repo_id(root)
60 branch = _read_branch(root)
61
62 commit = resolve_commit_ref(root, repo_id, branch, ref)
63 if commit is None:
64 typer.echo(f"❌ Commit '{ref}' not found.")
65 raise typer.Exit(code=ExitCode.USER_ERROR)
66
67 if json_out:
68 import json as json_mod
69 commit_data = commit.to_dict()
70 if stat:
71 cur = get_commit_snapshot_manifest(root, commit.commit_id) or {}
72 par: dict[str, str] = (
73 get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
74 if commit.parent_commit_id else {}
75 )
76 stats = {
77 "files_added": sorted(set(cur) - set(par)),
78 "files_removed": sorted(set(par) - set(cur)),
79 "files_modified": sorted(
80 p for p in set(cur) & set(par) if cur[p] != par[p]
81 ),
82 }
83 typer.echo(json_mod.dumps({**commit_data, **stats}, indent=2, default=str))
84 else:
85 typer.echo(json_mod.dumps(commit_data, indent=2, default=str))
86 return
87
88 typer.echo(f"commit {commit.commit_id}")
89 if commit.parent_commit_id:
90 typer.echo(f"Parent: {commit.parent_commit_id[:8]}")
91 if commit.parent2_commit_id:
92 typer.echo(f"Parent: {commit.parent2_commit_id[:8]} (merge)")
93 if commit.author:
94 typer.echo(f"Author: {commit.author}")
95 typer.echo(f"Date: {commit.committed_at}")
96 if commit.metadata:
97 for k, v in sorted(commit.metadata.items()):
98 typer.echo(f" {k}: {v}")
99 typer.echo(f"\n {commit.message}\n")
100
101 if not stat:
102 return
103
104 # Prefer the structured delta stored on the commit (Phase 1+).
105 # It carries rich note-level detail and is faster (no blob reloading).
106 if commit.structured_delta is not None:
107 delta = commit.structured_delta
108 if not delta["ops"]:
109 typer.echo(" (no changes)")
110 return
111 lines: list[str] = []
112 for op in delta["ops"]:
113 lines.extend(_format_op(op))
114 for line in lines:
115 typer.echo(line)
116 typer.echo(f"\n {delta['summary']}")
117 return
118
119 # Fallback for initial commits or pre-Phase-1 commits: compute file-level
120 # diff from snapshot manifests directly.
121 current = get_commit_snapshot_manifest(root, commit.commit_id) or {}
122 parent: dict[str, str] = {}
123 if commit.parent_commit_id:
124 parent = get_commit_snapshot_manifest(root, commit.parent_commit_id) or {}
125
126 added = sorted(set(current) - set(parent))
127 removed = sorted(set(parent) - set(current))
128 modified = sorted(p for p in set(current) & set(parent) if current[p] != parent[p])
129
130 for p in added:
131 typer.echo(f" A {p}")
132 for p in removed:
133 typer.echo(f" D {p}")
134 for p in modified:
135 typer.echo(f" M {p}")
136
137 total = len(added) + len(removed) + len(modified)
138 if total:
139 typer.echo(f"\n {total} file(s) changed")