gabriel / muse public
symbol_log.py python
404 lines 13.9 KB
Raw
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 days ago
1 """muse code symbol-log -- track a single semantic symbol through commit history.
2
3 This command is impossible in Git: Git's ``git log -p src/utils.py`` shows
4 every line that changed in a file; it has no concept of a *function*.
5 ``muse code symbol-log`` tracks the full lifecycle of a single named symbol --
6 when it was created, when its implementation changed, when it was renamed,
7 and when it was deleted -- across the entire commit DAG, including all
8 branches that were ever merged in.
9
10 Usage::
11
12 muse code symbol-log "src/utils.py::calculate_total"
13 muse code symbol-log "src/models.py::User.save"
14
15 Output::
16
17 Symbol: src/utils.py::calculate_total
18 ----------------------------------------------------------------------
19
20 * a3f2c9 2026-03-14 "Refactor: extract validation logic"
21 created function calculate_total
22
23 * cb4afa 2026-03-15 "Perf: optimise total calculation"
24 modified implementation changed
25
26 * 1d2e3f 2026-03-16 "Rename: calculate_total -> compute_total"
27 renamed calculate_total -> compute_total
28 (tracking continues as src/utils.py::compute_total)
29
30 * 4a5b6c 2026-03-17 "Move: refactor to helpers module"
31 moved src/utils.py::compute_total -> src/helpers.py::compute_total
32 (tracking continues at src/helpers.py::compute_total)
33
34 4 events (created: 1 modified: 1 renamed: 1 moved: 1)
35
36 Flags::
37
38 --from <ref>
39 Start walking from this commit instead of HEAD.
40 Accepts a full or abbreviated commit SHA or a branch name.
41
42 --max <n>
43 Cap the number of commits inspected (default: 500).
44 When the cap is reached a warning is shown; increase it with
45 --max to see the full history.
46
47 --json
48 Emit a structured JSON object::
49
50 {
51 "address": "<file>::<symbol>",
52 "start_ref": "HEAD",
53 "total_commits_scanned": 282,
54 "truncated": false,
55 "events": [
56 {
57 "event": "created",
58 "commit_id": "<sha256>",
59 "message": "...",
60 "committed_at": "2026-03-14T...",
61 "address": "<file>::<symbol>",
62 "detail": "...",
63 "new_address": null
64 }
65 ]
66 }
67 """
68
69 from __future__ import annotations
70
71 import argparse
72 import json
73 import logging
74 import pathlib
75 import sys
76 from typing import Literal
77
78 from muse.core.errors import ExitCode
79 from muse.core.repo import read_repo_id, require_repo
80 from muse.core.store import (
81 CommitRecord,
82 read_current_branch,
83 resolve_commit_ref,
84 )
85 from muse.domain import DomainOp
86 from muse.plugins.code._query import walk_commits_bfs
87 from muse.core.validation import clamp_int, sanitize_display
88
89
90
91 type _IntMap = dict[str, int]
92 type _SymbolLogDict = dict[str, str | None]
93 logger = logging.getLogger(__name__)
94
95 _EventKind = Literal["created", "modified", "renamed", "moved", "deleted", "signature"]
96
97 # ---------------------------------------------------------------------------
98 # Repository helpers
99 # ---------------------------------------------------------------------------
100
101
102
103 # ---------------------------------------------------------------------------
104 # Event extraction
105 # ---------------------------------------------------------------------------
106
107
108 def _flat_ops(ops: list[DomainOp]) -> list[DomainOp]:
109 """Flatten PatchOp children into a single list for address scanning."""
110 result: list[DomainOp] = []
111 for op in ops:
112 if op["op"] == "patch":
113 result.extend(op["child_ops"])
114 else:
115 result.append(op)
116 return result
117
118
119 class SymbolEvent:
120 """A single event in a symbol's lifecycle."""
121
122 __slots__ = ("kind", "commit", "address", "detail", "new_address")
123
124 def __init__(
125 self,
126 kind: _EventKind,
127 commit: CommitRecord,
128 address: str,
129 detail: str,
130 new_address: str | None = None,
131 ) -> None:
132 self.kind = kind
133 self.commit = commit
134 self.address = address
135 self.detail = detail
136 self.new_address = new_address
137
138 def to_dict(self) -> _SymbolLogDict:
139 return {
140 "event": self.kind,
141 "commit_id": self.commit.commit_id,
142 "message": self.commit.message,
143 "committed_at": self.commit.committed_at.isoformat(),
144 "address": self.address,
145 "detail": self.detail,
146 "new_address": self.new_address,
147 }
148
149
150 def _find_events_in_commit(
151 commit: CommitRecord,
152 address: str,
153 ) -> tuple[list[SymbolEvent], str]:
154 """Scan *commit*'s structured delta for events touching *address*.
155
156 Returns ``(events, next_address)`` where *next_address* is the address to
157 track in older commits (updated on rename events so history is continuous).
158 """
159 events: list[SymbolEvent] = []
160 next_address = address
161
162 if commit.structured_delta is None:
163 return events, next_address
164
165 all_ops = _flat_ops(commit.structured_delta["ops"])
166
167 for op in all_ops:
168 op_address = op["address"]
169
170 if op["op"] == "insert" and op_address == address:
171 events.append(SymbolEvent(
172 kind="created",
173 commit=commit,
174 address=address,
175 detail=op.get("content_summary", "created"),
176 ))
177
178 elif op["op"] == "delete" and op_address == address:
179 detail = op.get("content_summary", "deleted")
180 if "moved to" in detail:
181 events.append(SymbolEvent(
182 kind="moved",
183 commit=commit,
184 address=address,
185 detail=detail,
186 new_address=None,
187 ))
188 else:
189 events.append(SymbolEvent(
190 kind="deleted",
191 commit=commit,
192 address=address,
193 detail=detail,
194 ))
195
196 elif op["op"] == "replace" and op_address == address:
197 new_summary: str = op.get("new_summary", "")
198 if new_summary.startswith("renamed to "):
199 new_name = new_summary.removeprefix("renamed to ").strip()
200 file_prefix = address.rsplit("::", 1)[0]
201 new_addr = f"{file_prefix}::{new_name}"
202 events.append(SymbolEvent(
203 kind="renamed",
204 commit=commit,
205 address=address,
206 detail=f"{address.rsplit('::', 1)[-1]} → {new_name}",
207 new_address=new_addr,
208 ))
209 next_address = new_addr
210 elif new_summary.startswith("moved to "):
211 events.append(SymbolEvent(
212 kind="moved",
213 commit=commit,
214 address=address,
215 detail=new_summary,
216 new_address=None,
217 ))
218 elif "signature" in new_summary:
219 events.append(SymbolEvent(
220 kind="signature",
221 commit=commit,
222 address=address,
223 detail=new_summary,
224 ))
225 else:
226 events.append(SymbolEvent(
227 kind="modified",
228 commit=commit,
229 address=address,
230 detail=new_summary or "modified",
231 ))
232
233 return events, next_address
234
235
236 # ---------------------------------------------------------------------------
237 # Output
238 # ---------------------------------------------------------------------------
239
240
241 def _print_human(
242 address: str,
243 events: list[SymbolEvent],
244 total_commits: int,
245 truncated: bool,
246 ) -> None:
247 print(f"\nSymbol: {sanitize_display(address)}")
248 print("─" * 62)
249
250 if truncated:
251 print(
252 f"\n⚠️ History may be incomplete — scanned {total_commits:,} commits "
253 f"(use --max to increase the limit).",
254 )
255
256 if not events:
257 print(" (no events found — symbol may not exist in this range)")
258 return
259
260 # Collected newest-first; reverse for chronological (oldest-first) display.
261 chrono = list(reversed(events))
262
263 counts: _IntMap = {}
264 for ev in chrono:
265 counts[ev.kind] = counts.get(ev.kind, 0) + 1
266 date_str = ev.commit.committed_at.strftime("%Y-%m-%d")
267 short_id = ev.commit.commit_id[:8]
268 print(f'\n● {short_id} {date_str} "{sanitize_display(ev.commit.message)}"')
269 print(f" {ev.kind:<12} {ev.detail}")
270 if ev.new_address:
271 print(f" (tracking continues as {ev.new_address})")
272
273 total = len(events)
274 summary_parts = [f"{k}: {v}" for k, v in sorted(counts.items())]
275 print(f"\n{total} event(s) ({', '.join(summary_parts)})")
276
277
278 # ---------------------------------------------------------------------------
279 # Argument parser registration
280 # ---------------------------------------------------------------------------
281
282
283 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
284 """Register the symbol-log subcommand."""
285 parser = subparsers.add_parser(
286 "symbol-log",
287 help="Track a single symbol through the entire commit history.",
288 description=__doc__,
289 formatter_class=argparse.RawDescriptionHelpFormatter,
290 )
291 parser.add_argument(
292 "address",
293 metavar="ADDRESS",
294 help=(
295 'Fully-qualified symbol address, e.g. "src/utils.py::calculate_total" '
296 'or "src/models.py::User.save". Must contain "::".'
297 ),
298 )
299 parser.add_argument(
300 "--from",
301 dest="from_ref",
302 default=None,
303 metavar="REF",
304 help="Start walking from this commit / branch (default: HEAD).",
305 )
306 parser.add_argument(
307 "--max",
308 dest="max_commits",
309 type=int,
310 default=500,
311 metavar="N",
312 help="Maximum number of commits to inspect (default: 500).",
313 )
314 parser.add_argument(
315 "--json",
316 dest="as_json",
317 action="store_true",
318 help="Emit the full event list as structured JSON.",
319 )
320 parser.set_defaults(func=run)
321
322
323 # ---------------------------------------------------------------------------
324 # Command entry point
325 # ---------------------------------------------------------------------------
326
327
328 def run(args: argparse.Namespace) -> None:
329 """Track a single symbol through the entire commit history.
330
331 ``muse code symbol-log`` is impossible in Git: Git tracks file lines, not
332 semantic symbols. This command follows a function, class, or method
333 across every commit — detecting creation, implementation changes,
334 renames, cross-file moves, and deletion.
335
336 Unlike a plain ``git log``, Muse's walk crosses merge commit boundaries:
337 events from feature branches that were merged in are included, not hidden
338 behind the merge commit.
339
340 ADDRESS must be a fully-qualified symbol address::
341
342 muse code symbol-log "src/utils.py::calculate_total"
343 muse code symbol-log "src/models.py::User.save"
344 muse code symbol-log "api/handlers.go::Server.HandleRequest"
345 """
346 address: str = args.address
347 from_ref: str | None = args.from_ref
348 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
349 as_json: bool = args.as_json
350
351 # ── Input validation ──────────────────────────────────────────────────────
352
353 if "::" not in address:
354 print(
355 f"❌ '{address}' is not a valid symbol address.\n"
356 " Expected 'file_path::SymbolName', "
357 "e.g. 'src/utils.py::calculate_total'.",
358 file=sys.stderr,
359 )
360 raise SystemExit(ExitCode.USER_ERROR)
361
362 if max_commits < 1:
363 print("❌ --max must be at least 1.", file=sys.stderr)
364 raise SystemExit(ExitCode.USER_ERROR)
365
366 # ── Repo / commit resolution ──────────────────────────────────────────────
367
368 root = require_repo()
369 repo_id = read_repo_id(root)
370 branch = read_current_branch(root)
371
372 start_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
373 if start_commit is None:
374 label = from_ref or "HEAD"
375 print(f"❌ Commit '{label}' not found.", file=sys.stderr)
376 raise SystemExit(ExitCode.USER_ERROR)
377
378 # ── DAG walk ──────────────────────────────────────────────────────────────
379
380 commits, truncated = walk_commits_bfs(root, start_commit.commit_id, max_commits)
381
382 # ── Event scan ────────────────────────────────────────────────────────────
383 # Walk commits newest-first, tracking address changes on rename events.
384
385 current_address = address
386 all_events: list[SymbolEvent] = []
387
388 for commit in commits:
389 evs, current_address = _find_events_in_commit(commit, current_address)
390 all_events.extend(evs)
391
392 # ── Output ────────────────────────────────────────────────────────────────
393
394 if as_json:
395 print(json.dumps({
396 "address": address,
397 "start_ref": from_ref or "HEAD",
398 "total_commits_scanned": len(commits),
399 "truncated": truncated,
400 "events": [e.to_dict() for e in reversed(all_events)],
401 }, indent=2))
402 return
403
404 _print_human(address, all_events, total_commits=len(commits), truncated=truncated)
File History 1 commit
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 days ago