gabriel / muse public
blame.py python
200 lines 7.0 KB
bda49bdb feat: redesign .museignore as TOML with domain-scoped sections (#100) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """muse blame — symbol-level attribution.
2
3 ``git blame`` attributes every *line* to a commit — a 300-line class gives
4 you 300 attribution entries. ``muse blame`` attributes the *symbol* as a
5 semantic unit: one answer per function, class, or method, regardless of how
6 many lines it occupies.
7
8 Usage::
9
10 muse blame "src/billing.py::compute_invoice_total"
11 muse blame "api/server.go::Server.HandleRequest"
12 muse blame "src/models.py::User.save" --json
13
14 Output::
15
16 src/billing.py::compute_invoice_total
17 ──────────────────────────────────────────────────────────────
18 last touched: cb4afaed 2026-03-16
19 author: alice
20 message: "Perf: optimise compute_invoice_total"
21 change: implementation changed
22
23 previous: 1d2e3faa 2026-03-15 (renamed from calculate_total)
24 before that: a3f2c9e1 2026-03-14 (created)
25 """
26
27 from __future__ import annotations
28
29 import json
30 import logging
31 import pathlib
32 from dataclasses import dataclass
33 from typing import Literal
34
35 import typer
36
37 from muse.core.errors import ExitCode
38 from muse.core.repo import require_repo
39 from muse.core.store import CommitRecord, resolve_commit_ref
40 from muse.domain import DomainOp
41 from muse.plugins.code._query import walk_commits
42
43 logger = logging.getLogger(__name__)
44
45 app = typer.Typer()
46
47 _EventKind = Literal["created", "modified", "renamed", "moved", "deleted", "signature"]
48
49
50 @dataclass
51 class _BlameEvent:
52 kind: str
53 commit: CommitRecord
54 address: str
55 detail: str
56 new_address: str | None = None
57
58 def to_dict(self) -> dict[str, str | None]:
59 return {
60 "event": self.kind,
61 "commit_id": self.commit.commit_id,
62 "author": self.commit.author,
63 "message": self.commit.message,
64 "committed_at": self.commit.committed_at.isoformat(),
65 "address": self.address,
66 "detail": self.detail,
67 "new_address": self.new_address,
68 }
69
70
71 def _flat_ops(ops: list[DomainOp]) -> list[DomainOp]:
72 result: list[DomainOp] = []
73 for op in ops:
74 if op["op"] == "patch":
75 result.extend(op["child_ops"])
76 else:
77 result.append(op)
78 return result
79
80
81 def _events_in_commit(
82 commit: CommitRecord,
83 address: str,
84 ) -> tuple[list[_BlameEvent], str]:
85 """Scan *commit* for events touching *address*; return ``(events, next_address)``."""
86 events: list[_BlameEvent] = []
87 next_address = address
88 if commit.structured_delta is None:
89 return events, next_address
90 for op in _flat_ops(commit.structured_delta["ops"]):
91 if op["address"] != address:
92 continue
93 if op["op"] == "insert":
94 events.append(_BlameEvent("created", commit, address, op.get("content_summary", "created")))
95 elif op["op"] == "delete":
96 detail = op.get("content_summary", "deleted")
97 kind = "moved" if "moved to" in detail else "deleted"
98 events.append(_BlameEvent(kind, commit, address, detail))
99 elif op["op"] == "replace":
100 ns: str = op.get("new_summary", "")
101 if ns.startswith("renamed to "):
102 new_name = ns.removeprefix("renamed to ").strip()
103 file_prefix = address.rsplit("::", 1)[0]
104 new_addr = f"{file_prefix}::{new_name}"
105 events.append(_BlameEvent("renamed", commit, address, f"renamed to {new_name}", new_addr))
106 next_address = new_addr
107 elif ns.startswith("moved to "):
108 events.append(_BlameEvent("moved", commit, address, ns))
109 elif "signature" in ns:
110 events.append(_BlameEvent("signature", commit, address, ns or "signature changed"))
111 else:
112 events.append(_BlameEvent("modified", commit, address, ns or "modified"))
113 return events, next_address
114
115
116 def _read_repo_id(root: pathlib.Path) -> str:
117 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
118
119
120 def _read_branch(root: pathlib.Path) -> str:
121 head_ref = (root / ".muse" / "HEAD").read_text().strip()
122 return head_ref.removeprefix("refs/heads/").strip()
123
124
125 @app.callback(invoke_without_command=True)
126 def blame(
127 ctx: typer.Context,
128 address: str = typer.Argument(
129 ..., metavar="ADDRESS",
130 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
131 ),
132 from_ref: str | None = typer.Option(
133 None, "--from", metavar="REF",
134 help="Start walking from this commit / branch (default: HEAD).",
135 ),
136 show_all: bool = typer.Option(
137 False, "--all", "-a",
138 help="Show the full change history, not just the three most recent events.",
139 ),
140 as_json: bool = typer.Option(
141 False, "--json", help="Emit attribution as JSON.",
142 ),
143 ) -> None:
144 """Show which commit last touched a specific symbol.
145
146 ``muse blame`` attributes the symbol as a semantic unit — one answer
147 per function, class, or method, regardless of line count. The full
148 chain of prior events (renames, signature changes, etc.) is available
149 via ``--all``.
150
151 Unlike ``git blame``, which gives per-line attribution across an entire
152 file, ``muse blame`` gives a single clear answer: *this commit last
153 changed this symbol, and this is what changed*.
154 """
155 root = require_repo()
156 repo_id = _read_repo_id(root)
157 branch = _read_branch(root)
158
159 start_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
160 if start_commit is None:
161 typer.echo(f"❌ Commit '{from_ref or 'HEAD'}' not found.", err=True)
162 raise typer.Exit(code=ExitCode.USER_ERROR)
163
164 commits = walk_commits(root, start_commit.commit_id)
165
166 current_address = address
167 all_events: list[_BlameEvent] = []
168 for commit in commits:
169 evs, current_address = _events_in_commit(commit, current_address)
170 all_events.extend(evs)
171
172 if as_json:
173 typer.echo(json.dumps(
174 {"address": address, "events": [e.to_dict() for e in reversed(all_events)]},
175 indent=2,
176 ))
177 return
178
179 typer.echo(f"\n{address}")
180 typer.echo("─" * 62)
181
182 if not all_events:
183 typer.echo(" (no events found — symbol may not exist in this repository)")
184 return
185
186 events_to_show = all_events if show_all else all_events[:3]
187 labels = ["last touched:", "previous: ", "before that: "]
188
189 for idx, ev in enumerate(events_to_show):
190 label = labels[idx] if idx < len(labels) else " :"
191 date_str = ev.commit.committed_at.strftime("%Y-%m-%d")
192 short_id = ev.commit.commit_id[:8]
193 typer.echo(f"{label} {short_id} {date_str}")
194 if idx == 0:
195 typer.echo(f"author: {ev.commit.author or 'unknown'}")
196 typer.echo(f'message: "{ev.commit.message}"')
197 typer.echo(f"change: {ev.detail}")
198 if ev.new_address:
199 typer.echo(f" (tracking continues as {ev.new_address})")
200 typer.echo("")