gabriel / muse public
find_symbol.py python
759 lines 26.7 KB
Raw
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 7 days ago
1 """muse code find-symbol — cross-commit, cross-branch symbol search.
2
3 Closes two architectural gaps that ``muse code query`` cannot address:
4
5 1. **Temporal search**: ``muse code query hash=a3f2c9`` queries *one* snapshot.
6 ``muse code find-symbol --hash a3f2c9`` searches *every commit ever recorded*,
7 finding the exact moment a function body first entered the repository.
8
9 2. **Cross-branch presence**: if two branches independently introduced the
10 same function body, ``muse code find-symbol --hash a3f2c9 --all-branches``
11 finds both.
12
13 How it works
14 ------------
15 Every ``CommitRecord`` carries a ``structured_delta`` — the typed ``DomainOp``
16 tree produced at commit time. ``InsertOp`` entries in that delta record
17 exactly which symbols were *added* in each commit, including their
18 ``content_id``, ``body_hash``, and ``name`` (embedded in the address and
19 ``content_summary``).
20
21 ``muse code find-symbol`` walks all commits in the object store (or a single
22 branch's linear history with ``--branch``), ordered oldest-first, and scans
23 their ``InsertOp`` entries for symbols matching the given predicates. This
24 gives true cross-branch, temporally-ordered results with no full-snapshot
25 re-parse (except when ``--hash`` is given, where the shared ``SymbolCache``
26 ensures each blob is parsed at most once regardless of how many commits
27 reference it).
28
29 With ``--all-branches``, it also checks the current HEAD snapshot of every
30 branch tip to show where the symbol lives right now.
31
32 Usage::
33
34 muse code find-symbol --hash a3f2c9
35 muse code find-symbol --name validate_amount
36 muse code find-symbol --name "validate*"
37 muse code find-symbol --hash a3f2c9 --all-branches
38 muse code find-symbol --kind function --name compute
39 muse code find-symbol --name process --file src/core/processor.py
40 muse code find-symbol --name "render*" --branch feat/ui --first
41 muse code find-symbol --kind class --since 2025-01-01 --count
42 muse code find-symbol --name checkout --last --json
43 muse code find-symbol --name "parse*" --limit 20
44
45 Flags:
46
47 ``--hash HASH``
48 Match symbols whose ``content_id`` starts with this prefix (≥ 4 chars).
49
50 ``--name NAME``
51 Match symbols whose name exactly equals NAME (case-insensitive).
52 Append ``*`` for prefix matching.
53
54 ``--kind KIND``
55 Restrict to a specific symbol kind (function, class, method, …).
56
57 ``--file PATH``
58 Restrict to symbols defined in this exact file path.
59
60 ``--branch BRANCH``
61 Walk only this branch's linear history instead of all object-store commits.
62
63 ``--since DATE``
64 Ignore commits before DATE (YYYY-MM-DD).
65
66 ``--until DATE``
67 Ignore commits after DATE (YYYY-MM-DD).
68
69 ``--limit N``
70 Stop after N results.
71
72 ``--first``
73 Show only the first appearance of each unique symbol address.
74
75 ``--last``
76 Show only the most recent appearance of each unique symbol address.
77
78 ``--count``
79 Print only the total count of matching appearances.
80
81 ``--all-branches``
82 Also report which branch tips currently contain matching symbols.
83
84 ``--json``
85 Emit results as JSON.
86 """
87
88 import argparse
89 import datetime
90 import json
91 import logging
92 import pathlib
93 import sys
94 from typing import TypedDict
95
96 from muse.core.types import Manifest, Metadata
97 from muse.core.paths import heads_dir as _heads_dir
98 from muse.core.envelope import EnvelopeJson, make_envelope
99 from muse.core.errors import ExitCode
100 from muse.core.indices import HashOccurrenceIndex, load_hash_occurrence
101 from muse.core.repo import require_repo
102 from muse.core.timing import start_timer
103 from muse.core.refs import (
104 get_head_commit_id,
105 read_current_branch,
106 )
107 from muse.core.commits import (
108 CommitRecord,
109 get_all_commits,
110 walk_commits_between,
111 )
112 from muse.core.snapshots import get_commit_snapshot_manifest
113 from muse.core.symbol_cache import SymbolCache, load_symbol_cache
114 from muse.domain import DomainOp
115 from muse.plugins.code._query import symbols_for_snapshot
116 from muse.core.validation import clamp_int, sanitize_display
117
118 logger = logging.getLogger(__name__)
119
120 _MIN_HASH_PREFIX = 4
121
122 type _AppearanceMap = dict[str, "_Appearance"]
123
124 # ---------------------------------------------------------------------------
125 # Typed output shape
126 # ---------------------------------------------------------------------------
127
128 class _QueryParams(TypedDict, total=False):
129 hash: str | None
130 name: str | None
131 kind: str | None
132 file: str | None
133 branch: str | None
134 since: str | None
135 until: str | None
136 first_only: bool
137 last_only: bool
138 limit: int
139
140 class _FindSymbolOutputJson(EnvelopeJson):
141 """JSON output for ``muse code find-symbol --json``.
142
143 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
144
145 Fields
146 ------
147 query Dict of active search predicates (hash, name, kind, file, …)
148 reflecting the flags passed by the caller.
149 total Total number of matching symbol appearances found across
150 all searched branches.
151 results List of appearance dicts; each contains address, commit_id,
152 branch, kind, file, and content hash.
153 branch_presence List of branch-presence dicts (branch → commit_id → found),
154 or ``None`` when ``--all-branches`` was not passed.
155 """
156
157 query: _QueryParams
158 total: int
159 results: list[Metadata]
160 branch_presence: list[Metadata] | None
161
162 # ---------------------------------------------------------------------------
163 # Branch listing
164 # ---------------------------------------------------------------------------
165
166 def _list_branches(root: pathlib.Path) -> list[str]:
167 """Return all branch names recorded in ``.muse/refs/heads/``."""
168 heads_dir = _heads_dir(root)
169 if not heads_dir.exists():
170 return []
171 return sorted(p.name for p in heads_dir.iterdir() if p.is_file())
172
173 # ---------------------------------------------------------------------------
174 # Op flattening
175 # ---------------------------------------------------------------------------
176
177 def _flat_insert_ops(ops: list[DomainOp]) -> list[DomainOp]:
178 """Return all InsertOp leaves, including children of PatchOps."""
179 result: list[DomainOp] = []
180 for op in ops:
181 if op["op"] == "patch":
182 for child in op["child_ops"]:
183 if child["op"] == "insert":
184 result.append(child)
185 elif op["op"] == "insert":
186 result.append(op)
187 return result
188
189 # ---------------------------------------------------------------------------
190 # Name matching
191 # ---------------------------------------------------------------------------
192
193 def _name_matches(name: str, pattern: str) -> bool:
194 """Case-insensitive exact or prefix (trailing ``*``) match."""
195 p = pattern.lower()
196 n = name.lower()
197 return n.startswith(p[:-1]) if p.endswith("*") else n == p
198
199 # ---------------------------------------------------------------------------
200 # Content-id lookup via shared SymbolCache
201 # ---------------------------------------------------------------------------
202
203 def _content_id_for_address(
204 root: pathlib.Path,
205 manifest: Manifest,
206 address: str,
207 cache: SymbolCache,
208 ) -> str | None:
209 """Return the ``content_id`` for *address* using the shared SymbolCache.
210
211 Uses ``file_filter`` so only the relevant file blob is parsed/cached,
212 not the entire snapshot.
213 """
214 if "::" not in address:
215 return None
216 file_path = address.split("::")[0]
217 sym_map = symbols_for_snapshot(root, manifest, file_filter=file_path, cache=cache)
218 tree = sym_map.get(file_path)
219 if tree is None:
220 return None
221 rec = tree.get(address)
222 return rec["content_id"] if rec is not None else None
223
224 # ---------------------------------------------------------------------------
225 # Result types
226 # ---------------------------------------------------------------------------
227
228 class _Appearance:
229 """One occurrence of a matching symbol across commit history."""
230
231 __slots__ = ("content_id", "address", "commit", "name", "kind")
232
233 def __init__(
234 self,
235 content_id: str,
236 address: str,
237 commit: CommitRecord,
238 name: str,
239 kind: str,
240 ) -> None:
241 self.content_id = content_id
242 self.address = address
243 self.commit = commit
244 self.name = name
245 self.kind = kind
246
247 def to_dict(self) -> Metadata:
248 return {
249 "content_id": self.content_id,
250 "address": self.address,
251 "name": self.name,
252 "kind": self.kind,
253 "commit_id": self.commit.commit_id,
254 "commit_message": self.commit.message,
255 "committed_at": self.commit.committed_at.isoformat(),
256 "branch": self.commit.branch,
257 }
258
259 class _BranchPresence:
260 """Whether a matching symbol currently lives in a branch's HEAD."""
261
262 __slots__ = ("branch", "address", "content_id")
263
264 def __init__(self, branch: str, address: str, content_id: str) -> None:
265 self.branch = branch
266 self.address = address
267 self.content_id = content_id
268
269 def to_dict(self) -> Metadata:
270 return {
271 "branch": self.branch,
272 "address": self.address,
273 "content_id": self.content_id,
274 }
275
276 # ---------------------------------------------------------------------------
277 # Body-hash search (index-backed or snapshot fallback)
278 # ---------------------------------------------------------------------------
279
280 def _search_by_body_hash_from_index(
281 index: HashOccurrenceIndex,
282 prefix: str,
283 ) -> list[Metadata]:
284 """O(1) body-hash lookup using the prebuilt hash_occurrence index."""
285 results: list[Metadata] = []
286 for body_hash, addresses in index.items():
287 if body_hash.startswith(prefix):
288 for address in addresses:
289 results.append({"address": address, "body_hash": body_hash})
290 return results
291
292 def _search_by_body_hash_from_snapshot(
293 root: pathlib.Path,
294 prefix: str,
295 cache: SymbolCache,
296 ) -> list[Metadata]:
297 """Fallback: walk HEAD snapshot and match symbols by body_hash prefix."""
298 branch = read_current_branch(root)
299 commit_id = get_head_commit_id(root, branch)
300 if not commit_id:
301 return []
302 manifest = get_commit_snapshot_manifest(root, commit_id) or {}
303 sym_map = symbols_for_snapshot(root, manifest, cache=cache)
304 results: list[Metadata] = []
305 for _fp, tree in sorted(sym_map.items()):
306 for address, rec in sorted(tree.items()):
307 if rec["kind"] == "import":
308 continue
309 bh = rec["body_hash"]
310 if bh.startswith(prefix):
311 results.append({"address": address, "body_hash": bh, "kind": rec["kind"]})
312 return results
313
314 # ---------------------------------------------------------------------------
315 # Core search
316 # ---------------------------------------------------------------------------
317
318 def _gather_commits(
319 root: pathlib.Path,
320 branch: str | None,
321 ) -> list[CommitRecord]:
322 """Return commits to search, oldest-first.
323
324 When *branch* is given, walks only that branch's linear history.
325 Otherwise returns every commit in the object store.
326 """
327 if branch is not None:
328 tip = get_head_commit_id(root, branch)
329 if tip is None:
330 return []
331 return list(reversed(walk_commits_between(root, tip)))
332 return sorted(get_all_commits(root), key=lambda c: c.committed_at)
333
334 def _search_all_commits(
335 root: pathlib.Path,
336 hash_prefix: str | None,
337 name_pattern: str | None,
338 kind_filter: str | None,
339 file_filter: str | None,
340 since: datetime.date | None,
341 until: datetime.date | None,
342 first_only: bool,
343 last_only: bool,
344 limit: int | None,
345 branch: str | None,
346 cache: SymbolCache,
347 ) -> list[_Appearance]:
348 """Walk CommitRecords oldest-first, collecting InsertOp matches.
349
350 The shared ``SymbolCache`` ensures each source blob is parsed at most
351 once across the entire walk — critical for ``--hash`` searches where
352 many commits may reference the same file blob.
353 """
354 commits = _gather_commits(root, branch)
355 if not commits:
356 return []
357
358 appearances: list[_Appearance] = []
359 seen_addresses: set[str] = set()
360 last_by_address: _AppearanceMap = {}
361
362 for commit in commits:
363 if since is not None and commit.committed_at.date() < since:
364 continue
365 if until is not None and commit.committed_at.date() > until:
366 continue
367 if commit.structured_delta is None:
368 continue
369
370 insert_ops = _flat_insert_ops(commit.structured_delta["ops"])
371 manifest: Manifest | None = None # lazy-load only when hash_prefix set
372
373 for op in insert_ops:
374 address = op["address"]
375 if "::" not in address:
376 continue # file-level op, not a symbol
377
378 sym_file = address.split("::")[0]
379 if file_filter and sym_file != file_filter:
380 continue
381
382 content_summary: str = op["content_summary"] if op["op"] == "insert" else ""
383 parts = content_summary.strip().split(None, 1)
384 sym_kind = parts[0] if parts else ""
385 sym_name = parts[1].split()[0] if len(parts) > 1 else address.split("::")[-1]
386
387 if name_pattern and not _name_matches(sym_name, name_pattern):
388 continue
389 if kind_filter and sym_kind.lower() != kind_filter.lower():
390 continue
391
392 content_id = ""
393 if hash_prefix:
394 if manifest is None:
395 manifest = get_commit_snapshot_manifest(root, commit.commit_id)
396 if manifest is None:
397 continue
398 content_id = _content_id_for_address(root, manifest, address, cache) or ""
399 if not content_id.startswith(hash_prefix.lower()):
400 continue
401
402 if first_only and address in seen_addresses:
403 continue
404 seen_addresses.add(address)
405
406 ap = _Appearance(
407 content_id=content_id,
408 address=address,
409 commit=commit,
410 name=sym_name,
411 kind=sym_kind,
412 )
413
414 if last_only:
415 last_by_address[address] = ap
416 else:
417 appearances.append(ap)
418 if limit is not None and len(appearances) >= limit:
419 return appearances
420
421 if last_only:
422 result = list(last_by_address.values())
423 return result[-limit:] if limit is not None else result
424
425 return appearances
426
427 # ---------------------------------------------------------------------------
428 # Branch presence check
429 # ---------------------------------------------------------------------------
430
431 def _branch_presence(
432 root: pathlib.Path,
433 hash_prefix: str | None,
434 name_pattern: str | None,
435 kind_filter: str | None,
436 file_filter: str | None,
437 cache: SymbolCache,
438 ) -> list[_BranchPresence]:
439 """Check every branch HEAD snapshot for matching symbols."""
440 results: list[_BranchPresence] = []
441 for branch in _list_branches(root):
442 commit_id = get_head_commit_id(root, branch)
443 if commit_id is None:
444 continue
445 manifest = get_commit_snapshot_manifest(root, commit_id)
446 if manifest is None:
447 continue
448
449 sym_map = symbols_for_snapshot(
450 root,
451 manifest,
452 kind_filter=kind_filter,
453 file_filter=file_filter,
454 cache=cache,
455 )
456 for _file_path, tree in sym_map.items():
457 for address, rec in tree.items():
458 if name_pattern and not _name_matches(rec["name"], name_pattern):
459 continue
460 if hash_prefix and not rec["content_id"].startswith(hash_prefix.lower()):
461 continue
462 results.append(_BranchPresence(branch, address, rec["content_id"]))
463 return results
464
465 # ---------------------------------------------------------------------------
466 # Command registration
467 # ---------------------------------------------------------------------------
468
469 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
470 """Register the find-symbol subcommand."""
471 parser = subparsers.add_parser(
472 "find-symbol",
473 help="Search across ALL commits (every branch) for a symbol.",
474 description=__doc__,
475 formatter_class=argparse.RawDescriptionHelpFormatter,
476 )
477 parser.add_argument(
478 "--hash", default=None, metavar="HASH", dest="hash_prefix",
479 help=f"Find symbols whose content_id starts with this prefix (≥ {_MIN_HASH_PREFIX} chars).",
480 )
481 parser.add_argument(
482 "--body-hash", default=None, metavar="HASH", dest="body_hash_prefix",
483 help=(
484 f"Find all symbols in HEAD snapshot whose body_hash starts with this prefix "
485 f"(≥ {_MIN_HASH_PREFIX} chars). Uses the hash_occurrence index when available; "
486 "falls back to a full snapshot scan. Mutually exclusive with --hash and --name."
487 ),
488 )
489 parser.add_argument(
490 "--name", "-n", default=None, metavar="NAME", dest="name_pattern",
491 help="Find symbols with this name (exact, case-insensitive). Append * for prefix.",
492 )
493 parser.add_argument(
494 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
495 help="Restrict to symbols of this kind (function, class, method, …).",
496 )
497 parser.add_argument(
498 "--file", "-f", default=None, metavar="PATH", dest="file_filter",
499 help="Restrict to symbols defined in this file path.",
500 )
501 parser.add_argument(
502 "--branch", "-b", default=None, metavar="BRANCH", dest="branch",
503 help="Search only this branch's linear history instead of all object-store commits.",
504 )
505 parser.add_argument(
506 "--since", default=None, metavar="DATE", dest="since",
507 help="Ignore commits before DATE (YYYY-MM-DD).",
508 )
509 parser.add_argument(
510 "--until", default=None, metavar="DATE", dest="until",
511 help="Ignore commits after DATE (YYYY-MM-DD).",
512 )
513 parser.add_argument(
514 "--limit", type=int, default=None, metavar="N", dest="limit",
515 help="Stop after N appearances.",
516 )
517 parser.add_argument(
518 "--first", action="store_true", dest="first_only",
519 help="Show only the first appearance of each unique symbol address.",
520 )
521 parser.add_argument(
522 "--last", action="store_true", dest="last_only",
523 help="Show only the most recent appearance of each unique symbol address.",
524 )
525 parser.add_argument(
526 "--count", action="store_true", dest="count_only",
527 help="Print only the total count of matching appearances.",
528 )
529 parser.add_argument(
530 "--all-branches", action="store_true", dest="all_branches",
531 help="Also report which branch tips currently contain matching symbols.",
532 )
533 parser.add_argument(
534 "--json", "-j", action="store_true", dest="json_out",
535 help="Emit results as JSON.",
536 )
537 parser.set_defaults(func=run)
538
539 def run(args: argparse.Namespace) -> None:
540 """Search across ALL commits (every branch) for a symbol.
541
542 Scans the full commit history for symbol appearances matching a name
543 pattern, body hash prefix, or kind filter. Unlike ``muse code grep``
544 (working tree only), this command walks every branch and every commit so
545 no symbol appearance is hidden behind a merge or deleted branch.
546
547 Agent quickstart
548 ----------------
549 ::
550
551 muse code find-symbol --name validate_amount --json
552 muse code find-symbol --name "compute*" --kind function --json
553 muse code find-symbol --hash a3f2c9 --all-branches --json
554 muse code find-symbol --name checkout --last --json
555
556 JSON fields
557 -----------
558 query Echo of the search parameters used.
559 total Total number of matching symbol appearances found.
560 results List of appearance objects: ``address``, ``commit_id``,
561 ``branch``, ``committed_at``, ``kind``.
562
563 Exit codes
564 ----------
565 0 Search complete (zero results is still success).
566 1 Missing required filter, conflicting flags, or invalid arguments.
567 2 Not inside a Muse repository.
568 """
569 elapsed = start_timer()
570 hash_prefix: str | None = args.hash_prefix
571 body_hash_prefix: str | None = args.body_hash_prefix
572 name_pattern: str | None = args.name_pattern
573 kind_filter: str | None = args.kind_filter
574 file_filter: str | None = args.file_filter
575 branch: str | None = args.branch
576 all_branches: bool = args.all_branches
577 first_only: bool = args.first_only
578 last_only: bool = args.last_only
579 count_only: bool = args.count_only
580 json_out: bool = args.json_out
581 limit: int | None = (clamp_int(args.limit, 1, 100_000, 'limit') if args.limit is not None else None)
582
583 root = require_repo()
584
585 # --body-hash mode: standalone snapshot lookup by body hash.
586 if body_hash_prefix is not None:
587 if hash_prefix:
588 print("❌ --body-hash and --hash are mutually exclusive.", file=sys.stderr)
589 raise SystemExit(ExitCode.USER_ERROR)
590 if name_pattern:
591 print("❌ --body-hash and --name are mutually exclusive.", file=sys.stderr)
592 raise SystemExit(ExitCode.USER_ERROR)
593 if len(body_hash_prefix) < _MIN_HASH_PREFIX:
594 print(
595 f"❌ --body-hash prefix must be at least {_MIN_HASH_PREFIX} characters.",
596 file=sys.stderr,
597 )
598 raise SystemExit(ExitCode.USER_ERROR)
599
600 cache = load_symbol_cache(root)
601 ho_index = load_hash_occurrence(root)
602 if ho_index:
603 bh_results = _search_by_body_hash_from_index(ho_index, body_hash_prefix)
604 else:
605 bh_results = _search_by_body_hash_from_snapshot(root, body_hash_prefix, cache)
606 cache.save()
607
608 if json_out:
609 print(json.dumps(_FindSymbolOutputJson(
610 **make_envelope(elapsed),
611 query={"body_hash": body_hash_prefix},
612 total=len(bh_results),
613 results=bh_results,
614 branch_presence=None,
615 )))
616 return
617
618 print(f"\nfind-symbol (body-hash) — {len(bh_results)} address(es) with prefix {body_hash_prefix}")
619 print("─" * 62)
620 if not bh_results:
621 print(" (no matching symbols found in HEAD snapshot)")
622 else:
623 for r in bh_results:
624 print(f" {sanitize_display(r['address'])}")
625 return
626
627 if not hash_prefix and not name_pattern and not kind_filter:
628 print("❌ At least one of --hash, --name, or --kind is required.", file=sys.stderr)
629 raise SystemExit(ExitCode.USER_ERROR)
630
631 if first_only and last_only:
632 print("❌ --first and --last are mutually exclusive.", file=sys.stderr)
633 raise SystemExit(ExitCode.USER_ERROR)
634
635 if hash_prefix and len(hash_prefix) < _MIN_HASH_PREFIX:
636 print(
637 f"❌ --hash prefix must be at least {_MIN_HASH_PREFIX} characters "
638 "to avoid matching everything.",
639 file=sys.stderr,
640 )
641 raise SystemExit(ExitCode.USER_ERROR)
642
643 since: datetime.date | None = None
644 until: datetime.date | None = None
645 if args.since:
646 try:
647 since = datetime.date.fromisoformat(args.since)
648 except ValueError:
649 print(
650 f"❌ --since: invalid date '{args.since}' (expected YYYY-MM-DD).",
651 file=sys.stderr,
652 )
653 raise SystemExit(ExitCode.USER_ERROR)
654 if args.until:
655 try:
656 until = datetime.date.fromisoformat(args.until)
657 except ValueError:
658 print(
659 f"❌ --until: invalid date '{args.until}' (expected YYYY-MM-DD).",
660 file=sys.stderr,
661 )
662 raise SystemExit(ExitCode.USER_ERROR)
663
664 cache = load_symbol_cache(root)
665
666 appearances = _search_all_commits(
667 root,
668 hash_prefix=hash_prefix,
669 name_pattern=name_pattern,
670 kind_filter=kind_filter,
671 file_filter=file_filter,
672 since=since,
673 until=until,
674 first_only=first_only,
675 last_only=last_only,
676 limit=limit,
677 branch=branch,
678 cache=cache,
679 )
680
681 branch_hits: list[_BranchPresence] = []
682 if all_branches:
683 branch_hits = _branch_presence(
684 root,
685 hash_prefix=hash_prefix,
686 name_pattern=name_pattern,
687 kind_filter=kind_filter,
688 file_filter=file_filter,
689 cache=cache,
690 )
691
692 cache.save()
693
694 if count_only and not json_out:
695 print(len(appearances))
696 if all_branches:
697 print(f"branch_presence: {len(branch_hits)}")
698 return
699
700 if json_out:
701 print(json.dumps(_FindSymbolOutputJson(
702 **make_envelope(elapsed),
703 query={
704 "hash": hash_prefix,
705 "name": name_pattern,
706 "kind": kind_filter,
707 "file": file_filter,
708 "branch": branch,
709 "since": args.since,
710 "until": args.until,
711 "first_only": first_only,
712 "last_only": last_only,
713 "limit": limit,
714 },
715 total=len(appearances),
716 results=[a.to_dict() for a in appearances],
717 branch_presence=[b.to_dict() for b in branch_hits] if all_branches else None,
718 )))
719 return
720
721 print(f"\nfind-symbol — {len(appearances)} match(es) across history")
722
723 query_parts: list[str] = []
724 if hash_prefix:
725 query_parts.append(f"hash prefix={hash_prefix}")
726 if name_pattern:
727 query_parts.append(f"name={name_pattern}")
728 if kind_filter:
729 query_parts.append(f"kind={kind_filter}")
730 if file_filter:
731 query_parts.append(f"file={file_filter}")
732 if branch:
733 query_parts.append(f"branch={branch}")
734 if since:
735 query_parts.append(f"since={since}")
736 if until:
737 query_parts.append(f"until={until}")
738 print(f"Query: {', '.join(query_parts)}")
739 print("─" * 62)
740
741 if not appearances:
742 print(" (no matching symbols found in commit history)")
743 else:
744 for ap in appearances:
745 date_str = ap.commit.committed_at.strftime("%Y-%m-%d")
746 branch_label = f" [{ap.commit.branch}]" if ap.commit.branch else ""
747 print(f"\n {sanitize_display(ap.address)}")
748 print(f" {ap.commit.commit_id} {date_str} \"{sanitize_display(ap.commit.message)}\"{branch_label}")
749 if ap.content_id:
750 print(f" content_id: {ap.content_id}")
751
752 if all_branches:
753 print(f"\nBranch presence ({len(branch_hits)} hit(s)):")
754 print("─" * 62)
755 if not branch_hits:
756 print(" (symbol not found in any branch HEAD)")
757 else:
758 for bh in branch_hits:
759 print(f" [{sanitize_display(bh.branch)}] {sanitize_display(bh.address)} {bh.content_id}")
File History 1 commit
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f fix: show full cryptographic IDs in all human-readable CLI output Sonnet 4.6 patch 7 days ago