find_symbol.py
python
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