query_history.py file-level

at task/mp- · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """muse code query-history β€” temporal symbol search across commit history.
2
3 Searches the commit history for symbols matching a predicate expression,
4 bounded by a commit range. Unlike ``muse code query --all-commits``, this
5 command is focused on *change events* β€” it shows when each symbol first
6 appeared, when it was last seen, how many commits it survived, and how many
7 distinct implementations it had.
8
9 It answers questions that are impossible in Git:
10
11 * "Find all public Python functions introduced after tag v1.0"
12 * "Show me every class whose signature changed in the last 50 commits"
13 * "Which functions were present in tag v1.0 but are gone in tag v2.0?"
14 * "Find the most volatile symbols in the last 100 commits"
15 * "What was deleted between the last two releases?"
16
17 Usage::
18
19 muse code query-history "kind=function" "language=Python"
20 muse code query-history "name~=validate" --from v1.0 --to HEAD
21 muse code query-history "kind=class" --from abc12345
22 muse code query-history "file~=billing" "kind=function" --json
23 muse code query-history "kind=function" --changed-only --sort changes
24 muse code query-history "kind=function" --removed-only --from v1.0 --to v2.0
25 muse code query-history "kind=function" --introduced-only --from v1.0
26
27 Output::
28
29 Symbol history β€” kind=function language=Python (42 commits)
30 ──────────────────────────────────────────────────────────────
31
32 src/billing.py::compute_total function [12 commits] 3 versions 2026-01-01..2026-03-10
33 src/billing.py::compute_tax function [ 8 commits] stable 2026-01-15..2026-03-10
34 └─ introduced: a1b2c3d4 2026-01-15
35 └─ last seen: f7a8b9c0 2026-03-10
36
37 Flags:
38
39 ``--from REF``
40 Start of the commit range (exclusive; default: initial commit).
41
42 ``--to REF``
43 End of the commit range (inclusive; default: HEAD).
44
45 ``--changed-only``
46 Show only symbols that changed at least once (change_count > 1).
47
48 ``--introduced-only``
49 Show only symbols that exist in ``--to`` but not in ``--from`` (net-new).
50
51 ``--removed-only``
52 Show only symbols that exist in ``--from`` but not in ``--to`` (deleted).
53
54 ``--sort FIELD``
55 Sort results by: address (default), commits, changes, first, last.
56
57 ``--min-changes N``
58 Only show symbols with at least N distinct versions.
59
60 ``--count``
61 Emit only the count of matching symbols.
62
63 ``--limit N``
64 Cap the number of results returned.
65
66 ``--max-commits N``
67 Cap the number of commits walked (default: 10000).
68
69 ``--json``
70 Emit results as JSON.
71 """
72
73 from __future__ import annotations
74
75 import argparse
76 import collections.abc
77 import json
78 import logging
79 import pathlib
80 import sys
81
82 from muse._version import __version__
83 from muse.core.errors import ExitCode
84 from muse.core.repo import read_repo_id, require_repo
85 from muse.core.store import (
86 CommitRecord,
87 get_commit_snapshot_manifest,
88 read_current_branch,
89 resolve_commit_ref,
90 walk_commits_between,
91 )
92 from muse.core.symbol_cache import SymbolCache, load_symbol_cache
93 from muse.plugins.code._predicate import Predicate, PredicateError, parse_query
94 from muse.plugins.code._query import language_of, symbols_for_snapshot
95 from muse.plugins.code.ast_parser import SymbolRecord
96 from muse.core.validation import clamp_int, sanitize_display
97
98 type _HistoryDict = dict[str, str | int | None]
99 type _SymbolDict = dict[str, str | int]
100 type _AddrRecordMap = dict[str, tuple[SymbolRecord, str]]
101 type _HistoryIndex = dict[str, "_SymbolHistory"]
102
103 logger = logging.getLogger(__name__)
104
105 _VALID_SORT_FIELDS = frozenset({"address", "commits", "changes", "first", "last"})
106
107
108
109 class _SymbolHistory:
110 """Accumulated history of one symbol across a commit range."""
111
112 def __init__(self, address: str, kind: str, language: str) -> None:
113 self.address = address
114 self.kind = kind
115 self.language = language
116 self.first_commit_id: str = ""
117 self.first_committed_at: str = ""
118 self.last_commit_id: str = ""
119 self.last_committed_at: str = ""
120 self.commit_count: int = 0
121 self.content_ids: set[str] = set()
122
123 @property
124 def change_count(self) -> int:
125 """Number of distinct content_ids seen β€” 1 means body never changed."""
126 return len(self.content_ids)
127
128 def record(self, commit_id: str, committed_at: str, content_id: str) -> None:
129 """Record the symbol's presence in one commit."""
130 if not self.first_commit_id:
131 self.first_commit_id = commit_id
132 self.first_committed_at = committed_at
133 self.last_commit_id = commit_id
134 self.last_committed_at = committed_at
135 self.commit_count += 1
136 self.content_ids.add(content_id)
137
138 def to_dict(self) -> _HistoryDict:
139 return {
140 "address": self.address,
141 "kind": self.kind,
142 "language": self.language,
143 "commit_count": self.commit_count,
144 "change_count": self.change_count,
145 "first_commit_id": self.first_commit_id,
146 "first_commit_id_short": self.first_commit_id[:8],
147 "first_committed_at": self.first_committed_at[:10],
148 "last_commit_id": self.last_commit_id,
149 "last_commit_id_short": self.last_commit_id[:8],
150 "last_committed_at": self.last_committed_at[:10],
151 "stable": self.change_count == 1,
152 }
153
154
155 class _RemovedSymbol:
156 """A symbol present in the --from snapshot but absent in --to."""
157
158 def __init__(self, address: str, rec: SymbolRecord, language: str) -> None:
159 self.address = address
160 self.rec = rec
161 self.language = language
162
163 def to_dict(self) -> _SymbolDict:
164 return {
165 "address": self.address,
166 "kind": self.rec["kind"],
167 "language": self.language,
168 "status": "removed",
169 }
170
171
172 class _IntroducedSymbol:
173 """A symbol present in the --to snapshot but absent in --from."""
174
175 def __init__(self, address: str, rec: SymbolRecord, language: str) -> None:
176 self.address = address
177 self.rec = rec
178 self.language = language
179
180 def to_dict(self) -> _SymbolDict:
181 return {
182 "address": self.address,
183 "kind": self.rec["kind"],
184 "language": self.language,
185 "status": "introduced",
186 }
187
188
189 def _collect_addresses(
190 root: pathlib.Path,
191 commit_id: str,
192 predicate: Predicate,
193 cache: SymbolCache | None = None,
194 ) -> _AddrRecordMap:
195 """Return {address: (rec, language)} for all symbols in *commit_id* matching *predicate*."""
196 manifest = get_commit_snapshot_manifest(root, commit_id) or {}
197 sym_map = symbols_for_snapshot(root, manifest, cache=cache)
198 result: _AddrRecordMap = {}
199 for file_path, tree in sym_map.items():
200 lang = language_of(file_path)
201 for addr, rec in tree.items():
202 if predicate(file_path, rec):
203 result[addr] = (rec, lang)
204 return result
205
206
207 def _sort_key_fn(
208 sort_by: str,
209 ) -> collections.abc.Callable[[_SymbolHistory], tuple[str | int, ...]]:
210 """Return a sort key for ``_SymbolHistory`` objects."""
211 if sort_by == "commits":
212 return lambda h: (-h.commit_count, h.address)
213 if sort_by == "changes":
214 return lambda h: (-h.change_count, h.address)
215 if sort_by == "first":
216 return lambda h: (h.first_committed_at, h.address)
217 if sort_by == "last":
218 return lambda h: (h.last_committed_at, h.address)
219 return lambda h: (h.address,)
220
221
222 def register(
223 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
224 ) -> None:
225 """Register the query-history subcommand."""
226 parser = subparsers.add_parser(
227 "query-history",
228 help="Search commit history for symbols matching a predicate expression.",
229 description=__doc__,
230 formatter_class=argparse.RawDescriptionHelpFormatter,
231 )
232 parser.add_argument(
233 "predicates",
234 nargs="+",
235 metavar="PREDICATE",
236 help='One or more predicates, e.g. "kind=function" "language=Python".',
237 )
238 parser.add_argument(
239 "--from",
240 dest="from_ref",
241 default=None,
242 metavar="REF",
243 help="Start of range (exclusive; default: initial commit).",
244 )
245 parser.add_argument(
246 "--to",
247 dest="to_ref",
248 default=None,
249 metavar="REF",
250 help="End of range (inclusive; default: HEAD).",
251 )
252 parser.add_argument(
253 "--changed-only",
254 action="store_true",
255 help="Show only symbols with more than one distinct implementation (change_count > 1).",
256 )
257 parser.add_argument(
258 "--introduced-only",
259 action="store_true",
260 help=(
261 "Show only symbols present in --to but absent in --from"
262 " (net-new symbols)."
263 ),
264 )
265 parser.add_argument(
266 "--removed-only",
267 action="store_true",
268 help=(
269 "Show only symbols present in --from but absent in --to"
270 " (deleted symbols)."
271 ),
272 )
273 parser.add_argument(
274 "--sort",
275 default="address",
276 metavar="FIELD",
277 choices=sorted(_VALID_SORT_FIELDS),
278 help=(
279 f"Sort results by field:"
280 f" {', '.join(sorted(_VALID_SORT_FIELDS))} (default: address)."
281 ),
282 )
283 parser.add_argument(
284 "--min-changes",
285 type=int,
286 default=1,
287 metavar="N",
288 help="Only show symbols with at least N distinct versions (default: 1).",
289 )
290 parser.add_argument(
291 "--count",
292 action="store_true",
293 help="Emit only the count of matching symbols.",
294 )
295 parser.add_argument(
296 "--limit",
297 type=int,
298 default=0,
299 metavar="N",
300 help="Cap the number of results returned (0 = unlimited).",
301 )
302 parser.add_argument(
303 "--max-commits",
304 type=int,
305 default=10_000,
306 metavar="N",
307 help="Cap the number of commits walked (default: 10000).",
308 )
309 parser.add_argument(
310 "--json",
311 dest="as_json",
312 action="store_true",
313 help="Emit results as JSON.",
314 )
315 parser.set_defaults(func=run)
316
317
318 def run(args: argparse.Namespace) -> None:
319 """Search commit history for symbols matching a predicate expression.
320
321 Walks the commit range ``(--from, --to]`` oldest-first, accumulating
322 presence data for every matching symbol.
323
324 The predicate grammar is identical to ``muse code query`` β€” supports OR,
325 NOT, parentheses, and all field keys including ``size_gt`` / ``size_lt``.
326
327 Modes::
328
329 (default) All symbols seen anywhere in the range.
330 --changed-only Symbols whose body changed at least once.
331 --introduced-only Symbols present in --to but not in --from (born).
332 --removed-only Symbols present in --from but not in --to (deleted).
333
334 Examples::
335
336 muse code query-history "kind=function" "language=Python"
337 muse code query-history "name~=validate" --from v1.0 --to HEAD
338 muse code query-history "kind=class" --json
339 muse code query-history "kind=function" --changed-only --sort changes
340 muse code query-history "kind=function" --removed-only --from v1.0 --to v2.0
341 """
342 predicates: list[str] = args.predicates
343 from_ref: str | None = args.from_ref
344 to_ref: str | None = args.to_ref
345 as_json: bool = args.as_json
346 changed_only: bool = args.changed_only
347 introduced_only: bool = args.introduced_only
348 removed_only: bool = args.removed_only
349 sort_by: str = args.sort
350 min_changes: int = clamp_int(args.min_changes, 0, 100000, 'min_changes')
351 count_only: bool = args.count
352 limit: int = clamp_int(args.limit, 0, 10000, 'limit')
353 max_commits: int = clamp_int(args.max_commits, 1, 100000, 'max_commits')
354
355 # Validate mutually exclusive mode flags.
356 mode_flags = sum([changed_only, introduced_only, removed_only])
357 if mode_flags > 1:
358 print(
359 "❌ --changed-only, --introduced-only, and --removed-only"
360 " are mutually exclusive.",
361 file=sys.stderr,
362 )
363 raise SystemExit(ExitCode.USER_ERROR)
364
365 if limit < 0:
366 print("❌ --limit must be >= 0.", file=sys.stderr)
367 raise SystemExit(ExitCode.USER_ERROR)
368
369 if min_changes < 1:
370 print("❌ --min-changes must be >= 1.", file=sys.stderr)
371 raise SystemExit(ExitCode.USER_ERROR)
372
373 if max_commits < 1:
374 print("❌ --max-commits must be >= 1.", file=sys.stderr)
375 raise SystemExit(ExitCode.USER_ERROR)
376
377 root = require_repo()
378 repo_id = read_repo_id(root)
379 branch = read_current_branch(root)
380
381 if not predicates:
382 print("❌ At least one predicate is required.", file=sys.stderr)
383 raise SystemExit(ExitCode.USER_ERROR)
384
385 try:
386 predicate = parse_query(predicates)
387 except PredicateError as exc:
388 print(f"❌ {exc}", file=sys.stderr)
389 raise SystemExit(ExitCode.USER_ERROR)
390
391 # Resolve range endpoints.
392 to_commit = resolve_commit_ref(root, repo_id, branch, to_ref)
393 if to_commit is None:
394 print(f"❌ --to ref '{to_ref or 'HEAD'}' not found.", file=sys.stderr)
395 raise SystemExit(ExitCode.USER_ERROR)
396
397 from_commit_id: str | None = None
398 if from_ref is not None:
399 from_c = resolve_commit_ref(root, repo_id, branch, from_ref)
400 if from_c is None:
401 print(f"❌ --from ref '{from_ref}' not found.", file=sys.stderr)
402 raise SystemExit(ExitCode.USER_ERROR)
403 from_commit_id = from_c.commit_id
404
405 # Load the symbol cache once β€” shared across all snapshot operations in this
406 # run. On a warm cache this eliminates O(n Γ— 170ms) disk I/O in favour of
407 # a single load + single save, regardless of how many snapshots are walked.
408 shared_cache: SymbolCache = load_symbol_cache(root)
409
410 # ── --introduced-only / --removed-only: snapshot diff mode ───────────────
411 if introduced_only or removed_only:
412 to_addrs = _collect_addresses(
413 root, to_commit.commit_id, predicate, cache=shared_cache
414 )
415
416 # For --from: use from_commit_id if provided, else fall back to the
417 # oldest commit reachable from to_commit.
418 if from_commit_id is not None:
419 from_addrs = _collect_addresses(
420 root, from_commit_id, predicate, cache=shared_cache
421 )
422 else:
423 # No --from: treat the very first commit as the baseline.
424 all_in_range: list[CommitRecord] = sorted(
425 walk_commits_between(root, to_commit.commit_id, None),
426 key=lambda c: c.committed_at,
427 )
428 if all_in_range:
429 from_addrs = _collect_addresses(
430 root, all_in_range[0].commit_id, predicate, cache=shared_cache
431 )
432 else:
433 from_addrs = {}
434
435 # All snapshot loading for diff mode is complete β€” persist any new entries.
436 shared_cache.save()
437
438 if introduced_only:
439 introduced_addrs = set(to_addrs) - set(from_addrs)
440 intro_symbols: list[_IntroducedSymbol] = sorted(
441 [
442 _IntroducedSymbol(addr, rec, lang)
443 for addr, (rec, lang) in to_addrs.items()
444 if addr in introduced_addrs
445 ],
446 key=lambda s: s.address,
447 )
448 if limit > 0:
449 intro_symbols = intro_symbols[:limit]
450 if count_only:
451 print(len(intro_symbols))
452 return
453 if as_json:
454 print(
455 json.dumps(
456 {
457 "schema_version": __version__,
458 "mode": "introduced-only",
459 "to_commit": to_commit.commit_id[:8],
460 "from_commit": from_commit_id[:8]
461 if from_commit_id
462 else None,
463 "symbols_found": len(intro_symbols),
464 "results": [s.to_dict() for s in intro_symbols],
465 },
466 indent=2,
467 )
468 )
469 return
470 pred_display = " AND ".join(predicates)
471 from_label = from_commit_id[:8] if from_commit_id else "initial"
472 print(
473 f"\n Introduced symbols β€” {pred_display}"
474 f" ({from_label}..{to_commit.commit_id[:8]})\n"
475 )
476 if not intro_symbols:
477 print(" (no new symbols found)")
478 return
479 for sym in intro_symbols:
480 print(f" + {sanitize_display(sym.address)} [{sym.rec['kind']}]")
481 print(f"\n {len(intro_symbols)} symbol(s) introduced")
482 return
483
484 # removed_only
485 removed_addrs = set(from_addrs) - set(to_addrs)
486 _removed_list: list[_RemovedSymbol] = [
487 _RemovedSymbol(addr, rec, lang)
488 for addr, (rec, lang) in from_addrs.items()
489 if addr in removed_addrs
490 ]
491 _removed_list.sort(key=lambda s: s.address)
492 removed_symbols: list[_RemovedSymbol] = (
493 _removed_list[:limit] if limit > 0 else _removed_list
494 )
495 if count_only:
496 print(len(removed_symbols))
497 return
498 if as_json:
499 print(
500 json.dumps(
501 {
502 "schema_version": __version__,
503 "mode": "removed-only",
504 "to_commit": to_commit.commit_id[:8],
505 "from_commit": from_commit_id[:8]
506 if from_commit_id
507 else None,
508 "symbols_found": len(removed_symbols),
509 "results": [s.to_dict() for s in removed_symbols],
510 },
511 indent=2,
512 )
513 )
514 return
515 pred_display = " AND ".join(predicates)
516 from_label = from_commit_id[:8] if from_commit_id else "initial"
517 print(
518 f"\n Removed symbols β€” {pred_display}"
519 f" ({from_label}..{to_commit.commit_id[:8]})\n"
520 )
521 if not removed_symbols:
522 print(" (no symbols removed in this range)")
523 return
524 for rem in removed_symbols:
525 print(f" - {sanitize_display(rem.address)} [{rem.rec['kind']}]")
526 print(f"\n {len(removed_symbols)} symbol(s) removed")
527 return
528
529 # ── Default / --changed-only: walk commit range ───────────────────────────
530 raw_commits: list[CommitRecord] = sorted(
531 walk_commits_between(root, to_commit.commit_id, from_commit_id),
532 key=lambda c: c.committed_at,
533 )
534
535 truncated = len(raw_commits) > max_commits
536 commits = raw_commits[:max_commits]
537
538 # Accumulate per-symbol history β€” deduplicate on snapshot_id.
539 history: _HistoryIndex = {}
540 seen_snapshots: set[str] = set()
541
542 for commit in commits:
543 if commit.snapshot_id in seen_snapshots:
544 continue
545 seen_snapshots.add(commit.snapshot_id)
546
547 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
548 sym_map = symbols_for_snapshot(root, manifest, cache=shared_cache)
549 for file_path, tree in sym_map.items():
550 for addr, rec in tree.items():
551 if not predicate(file_path, rec):
552 continue
553 if addr not in history:
554 history[addr] = _SymbolHistory(
555 address=addr,
556 kind=rec["kind"],
557 language=language_of(file_path),
558 )
559 history[addr].record(
560 commit.commit_id,
561 commit.committed_at.isoformat(),
562 rec["content_id"],
563 )
564
565 # All snapshot loading for walk mode is complete β€” persist any new entries.
566 shared_cache.save()
567
568 results: list[_SymbolHistory] = list(history.values())
569
570 # Apply filters.
571 if changed_only:
572 results = [r for r in results if r.change_count > 1]
573 if min_changes > 1:
574 results = [r for r in results if r.change_count >= min_changes]
575
576 # Sort.
577 results.sort(key=_sort_key_fn(sort_by))
578
579 # Apply limit.
580 limited = limit > 0 and len(results) > limit
581 if limited:
582 results = results[:limit]
583
584 # Count-only output.
585 if count_only:
586 print(len(results))
587 return
588
589 # JSON output.
590 if as_json:
591 print(
592 json.dumps(
593 {
594 "schema_version": __version__,
595 "mode": "changed-only" if changed_only else "default",
596 "to_commit": to_commit.commit_id[:8],
597 "from_commit": from_commit_id[:8] if from_commit_id else None,
598 "commits_scanned": len(commits),
599 "truncated": truncated,
600 "symbols_found": len(results),
601 "results": [r.to_dict() for r in results],
602 },
603 indent=2,
604 )
605 )
606 return
607
608 # Human-readable output.
609 pred_display = " AND ".join(predicates)
610 trunc_note = f" ⚠️ capped at {max_commits}" if truncated else ""
611 print(
612 f"\nSymbol history β€” {pred_display}"
613 f" ({len(commits)} commit(s) scanned{trunc_note})"
614 )
615 print("─" * 62)
616
617 if not results:
618 print(" (no matching symbols found in range)")
619 return
620
621 max_addr = max(len(r.address) for r in results)
622 for r in results:
623 versions = (
624 f"{r.change_count} version(s)"
625 if r.change_count > 1
626 else "stable"
627 )
628 span = f"{r.first_committed_at[:10]}..{r.last_committed_at[:10]}"
629 print(
630 f" {r.address:<{max_addr}} {r.kind:<14} "
631 f"[{r.commit_count:>3} commit(s)] {versions:<14} {span}"
632 )
633 if r.first_commit_id:
634 print(f" └─ introduced: {r.first_commit_id[:8]}")
635 if r.first_commit_id != r.last_commit_id:
636 print(f" └─ last seen: {r.last_commit_id[:8]}")
637
638 lim_note = f" (limited to {limit})" if limited else ""
639 print(f"\n {len(results)} symbol(s) found{lim_note}")