"""muse code find-symbol — cross-commit, cross-branch symbol search. Closes two architectural gaps that ``muse code query`` cannot address: 1. **Temporal search**: ``muse code query hash=a3f2c9`` queries *one* snapshot. ``muse code find-symbol --hash a3f2c9`` searches *every commit ever recorded*, finding the exact moment a function body first entered the repository. 2. **Cross-branch presence**: if two branches independently introduced the same function body, ``muse code find-symbol --hash a3f2c9 --all-branches`` finds both. How it works ------------ Every ``CommitRecord`` carries a ``structured_delta`` — the typed ``DomainOp`` tree produced at commit time. ``InsertOp`` entries in that delta record exactly which symbols were *added* in each commit, including their ``content_id``, ``body_hash``, and ``name`` (embedded in the address and ``content_summary``). ``muse code find-symbol`` walks all commits in the object store (or a single branch's linear history with ``--branch``), ordered oldest-first, and scans their ``InsertOp`` entries for symbols matching the given predicates. This gives true cross-branch, temporally-ordered results with no full-snapshot re-parse (except when ``--hash`` is given, where the shared ``SymbolCache`` ensures each blob is parsed at most once regardless of how many commits reference it). With ``--all-branches``, it also checks the current HEAD snapshot of every branch tip to show where the symbol lives right now. Usage:: muse code find-symbol --hash a3f2c9 muse code find-symbol --name validate_amount muse code find-symbol --name "validate*" muse code find-symbol --hash a3f2c9 --all-branches muse code find-symbol --kind function --name compute muse code find-symbol --name process --file src/core/processor.py muse code find-symbol --name "render*" --branch feat/ui --first muse code find-symbol --kind class --since 2025-01-01 --count muse code find-symbol --name checkout --last --json muse code find-symbol --name "parse*" --limit 20 Flags: ``--hash HASH`` Match symbols whose ``content_id`` starts with this prefix (≥ 4 chars). ``--name NAME`` Match symbols whose name exactly equals NAME (case-insensitive). Append ``*`` for prefix matching. ``--kind KIND`` Restrict to a specific symbol kind (function, class, method, …). ``--file PATH`` Restrict to symbols defined in this exact file path. ``--branch BRANCH`` Walk only this branch's linear history instead of all object-store commits. ``--since DATE`` Ignore commits before DATE (YYYY-MM-DD). ``--until DATE`` Ignore commits after DATE (YYYY-MM-DD). ``--limit N`` Stop after N results. ``--first`` Show only the first appearance of each unique symbol address. ``--last`` Show only the most recent appearance of each unique symbol address. ``--count`` Print only the total count of matching appearances. ``--all-branches`` Also report which branch tips currently contain matching symbols. ``--json`` Emit results as JSON. """ from __future__ import annotations import argparse import datetime import json import logging import pathlib import sys from muse.core._types import Manifest, Metadata from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.store import ( CommitRecord, get_all_commits, get_commit_snapshot_manifest, get_head_commit_id, walk_commits_between, ) from muse.core.symbol_cache import SymbolCache, load_symbol_cache from muse.domain import DomainOp from muse.plugins.code._query import symbols_for_snapshot from muse.core.validation import clamp_int, sanitize_display logger = logging.getLogger(__name__) _MIN_HASH_PREFIX = 4 type _AppearanceMap = dict[str, "_Appearance"] # --------------------------------------------------------------------------- # Branch listing # --------------------------------------------------------------------------- def _list_branches(root: pathlib.Path) -> list[str]: """Return all branch names recorded in ``.muse/refs/heads/``.""" heads_dir = root / ".muse" / "refs" / "heads" if not heads_dir.exists(): return [] return sorted(p.name for p in heads_dir.iterdir() if p.is_file()) # --------------------------------------------------------------------------- # Op flattening # --------------------------------------------------------------------------- def _flat_insert_ops(ops: list[DomainOp]) -> list[DomainOp]: """Return all InsertOp leaves, including children of PatchOps.""" result: list[DomainOp] = [] for op in ops: if op["op"] == "patch": for child in op["child_ops"]: if child["op"] == "insert": result.append(child) elif op["op"] == "insert": result.append(op) return result # --------------------------------------------------------------------------- # Name matching # --------------------------------------------------------------------------- def _name_matches(name: str, pattern: str) -> bool: """Case-insensitive exact or prefix (trailing ``*``) match.""" p = pattern.lower() n = name.lower() return n.startswith(p[:-1]) if p.endswith("*") else n == p # --------------------------------------------------------------------------- # Content-id lookup via shared SymbolCache # --------------------------------------------------------------------------- def _content_id_for_address( root: pathlib.Path, manifest: Manifest, address: str, cache: SymbolCache, ) -> str | None: """Return the ``content_id`` for *address* using the shared SymbolCache. Uses ``file_filter`` so only the relevant file blob is parsed/cached, not the entire snapshot. """ if "::" not in address: return None file_path = address.split("::")[0] sym_map = symbols_for_snapshot(root, manifest, file_filter=file_path, cache=cache) tree = sym_map.get(file_path) if tree is None: return None rec = tree.get(address) return rec["content_id"] if rec is not None else None # --------------------------------------------------------------------------- # Result types # --------------------------------------------------------------------------- class _Appearance: """One occurrence of a matching symbol across commit history.""" __slots__ = ("content_id", "address", "commit", "name", "kind") def __init__( self, content_id: str, address: str, commit: CommitRecord, name: str, kind: str, ) -> None: self.content_id = content_id self.address = address self.commit = commit self.name = name self.kind = kind def to_dict(self) -> Metadata: return { "content_id": self.content_id, "address": self.address, "name": self.name, "kind": self.kind, "commit_id": self.commit.commit_id, "commit_message": self.commit.message, "committed_at": self.commit.committed_at.isoformat(), "branch": self.commit.branch, } class _BranchPresence: """Whether a matching symbol currently lives in a branch's HEAD.""" __slots__ = ("branch", "address", "content_id") def __init__(self, branch: str, address: str, content_id: str) -> None: self.branch = branch self.address = address self.content_id = content_id def to_dict(self) -> Metadata: return { "branch": self.branch, "address": self.address, "content_id": self.content_id, } # --------------------------------------------------------------------------- # Core search # --------------------------------------------------------------------------- def _gather_commits( root: pathlib.Path, branch: str | None, ) -> list[CommitRecord]: """Return commits to search, oldest-first. When *branch* is given, walks only that branch's linear history. Otherwise returns every commit in the object store. """ if branch is not None: tip = get_head_commit_id(root, branch) if tip is None: return [] return list(reversed(walk_commits_between(root, tip))) return sorted(get_all_commits(root), key=lambda c: c.committed_at) def _search_all_commits( root: pathlib.Path, hash_prefix: str | None, name_pattern: str | None, kind_filter: str | None, file_filter: str | None, since: datetime.date | None, until: datetime.date | None, first_only: bool, last_only: bool, limit: int | None, branch: str | None, cache: SymbolCache, ) -> list[_Appearance]: """Walk CommitRecords oldest-first, collecting InsertOp matches. The shared ``SymbolCache`` ensures each source blob is parsed at most once across the entire walk — critical for ``--hash`` searches where many commits may reference the same file blob. """ commits = _gather_commits(root, branch) if not commits: return [] appearances: list[_Appearance] = [] seen_addresses: set[str] = set() last_by_address: _AppearanceMap = {} for commit in commits: if since is not None and commit.committed_at.date() < since: continue if until is not None and commit.committed_at.date() > until: continue if commit.structured_delta is None: continue insert_ops = _flat_insert_ops(commit.structured_delta["ops"]) manifest: Manifest | None = None # lazy-load only when hash_prefix set for op in insert_ops: address = op["address"] if "::" not in address: continue # file-level op, not a symbol sym_file = address.split("::")[0] if file_filter and sym_file != file_filter: continue content_summary: str = op["content_summary"] if op["op"] == "insert" else "" parts = content_summary.strip().split(None, 1) sym_kind = parts[0] if parts else "" sym_name = parts[1].split()[0] if len(parts) > 1 else address.split("::")[-1] if name_pattern and not _name_matches(sym_name, name_pattern): continue if kind_filter and sym_kind.lower() != kind_filter.lower(): continue content_id = "" if hash_prefix: if manifest is None: manifest = get_commit_snapshot_manifest(root, commit.commit_id) if manifest is None: continue content_id = _content_id_for_address(root, manifest, address, cache) or "" if not content_id.startswith(hash_prefix.lower()): continue if first_only and address in seen_addresses: continue seen_addresses.add(address) ap = _Appearance( content_id=content_id, address=address, commit=commit, name=sym_name, kind=sym_kind, ) if last_only: last_by_address[address] = ap else: appearances.append(ap) if limit is not None and len(appearances) >= limit: return appearances if last_only: result = list(last_by_address.values()) return result[-limit:] if limit is not None else result return appearances # --------------------------------------------------------------------------- # Branch presence check # --------------------------------------------------------------------------- def _branch_presence( root: pathlib.Path, hash_prefix: str | None, name_pattern: str | None, kind_filter: str | None, file_filter: str | None, cache: SymbolCache, ) -> list[_BranchPresence]: """Check every branch HEAD snapshot for matching symbols.""" results: list[_BranchPresence] = [] for branch in _list_branches(root): commit_id = get_head_commit_id(root, branch) if commit_id is None: continue manifest = get_commit_snapshot_manifest(root, commit_id) if manifest is None: continue sym_map = symbols_for_snapshot( root, manifest, kind_filter=kind_filter, file_filter=file_filter, cache=cache, ) for _file_path, tree in sym_map.items(): for address, rec in tree.items(): if name_pattern and not _name_matches(rec["name"], name_pattern): continue if hash_prefix and not rec["content_id"].startswith(hash_prefix.lower()): continue results.append(_BranchPresence(branch, address, rec["content_id"])) return results # --------------------------------------------------------------------------- # Command registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the find-symbol subcommand.""" parser = subparsers.add_parser( "find-symbol", help="Search across ALL commits (every branch) for a symbol.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--hash", default=None, metavar="HASH", dest="hash_prefix", help=f"Find symbols whose content_id starts with this prefix (≥ {_MIN_HASH_PREFIX} chars).", ) parser.add_argument( "--name", "-n", default=None, metavar="NAME", dest="name_pattern", help="Find symbols with this name (exact, case-insensitive). Append * for prefix.", ) parser.add_argument( "--kind", "-k", default=None, metavar="KIND", dest="kind_filter", help="Restrict to symbols of this kind (function, class, method, …).", ) parser.add_argument( "--file", "-f", default=None, metavar="PATH", dest="file_filter", help="Restrict to symbols defined in this file path.", ) parser.add_argument( "--branch", "-b", default=None, metavar="BRANCH", dest="branch", help="Search only this branch's linear history instead of all object-store commits.", ) parser.add_argument( "--since", default=None, metavar="DATE", dest="since", help="Ignore commits before DATE (YYYY-MM-DD).", ) parser.add_argument( "--until", default=None, metavar="DATE", dest="until", help="Ignore commits after DATE (YYYY-MM-DD).", ) parser.add_argument( "--limit", type=int, default=None, metavar="N", dest="limit", help="Stop after N appearances.", ) parser.add_argument( "--first", action="store_true", dest="first_only", help="Show only the first appearance of each unique symbol address.", ) parser.add_argument( "--last", action="store_true", dest="last_only", help="Show only the most recent appearance of each unique symbol address.", ) parser.add_argument( "--count", action="store_true", dest="count_only", help="Print only the total count of matching appearances.", ) parser.add_argument( "--all-branches", action="store_true", dest="all_branches", help="Also report which branch tips currently contain matching symbols.", ) parser.add_argument( "--json", action="store_true", dest="as_json", help="Emit results as JSON.", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Search across ALL commits (every branch) for a symbol. \\b At least one of ``--hash``, ``--name``, or ``--kind`` is required. ``--first`` and ``--last`` are mutually exclusive. \\b Examples:: muse code find-symbol --hash a3f2c9 muse code find-symbol --name validate_amount --kind function muse code find-symbol --name "compute*" --first muse code find-symbol --hash a3f2c9 --all-branches muse code find-symbol --name process --file src/core/processor.py muse code find-symbol --kind class --since 2025-01-01 --count muse code find-symbol --name checkout --last --json muse code find-symbol --name "render*" --branch feat/ui --limit 5 """ hash_prefix: str | None = args.hash_prefix name_pattern: str | None = args.name_pattern kind_filter: str | None = args.kind_filter file_filter: str | None = args.file_filter branch: str | None = args.branch all_branches: bool = args.all_branches first_only: bool = args.first_only last_only: bool = args.last_only count_only: bool = args.count_only as_json: bool = args.as_json limit: int | None = (clamp_int(args.limit, 1, 100_000, 'limit') if args.limit is not None else None) root = require_repo() read_repo_id(root) if not hash_prefix and not name_pattern and not kind_filter: print("❌ At least one of --hash, --name, or --kind is required.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if first_only and last_only: print("❌ --first and --last are mutually exclusive.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if hash_prefix and len(hash_prefix) < _MIN_HASH_PREFIX: print( f"❌ --hash prefix must be at least {_MIN_HASH_PREFIX} characters " "to avoid matching everything.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) since: datetime.date | None = None until: datetime.date | None = None if args.since: try: since = datetime.date.fromisoformat(args.since) except ValueError: print( f"❌ --since: invalid date '{args.since}' (expected YYYY-MM-DD).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if args.until: try: until = datetime.date.fromisoformat(args.until) except ValueError: print( f"❌ --until: invalid date '{args.until}' (expected YYYY-MM-DD).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) cache = load_symbol_cache(root) appearances = _search_all_commits( root, hash_prefix=hash_prefix, name_pattern=name_pattern, kind_filter=kind_filter, file_filter=file_filter, since=since, until=until, first_only=first_only, last_only=last_only, limit=limit, branch=branch, cache=cache, ) branch_hits: list[_BranchPresence] = [] if all_branches: branch_hits = _branch_presence( root, hash_prefix=hash_prefix, name_pattern=name_pattern, kind_filter=kind_filter, file_filter=file_filter, cache=cache, ) cache.save() if count_only and not as_json: print(len(appearances)) if all_branches: print(f"branch_presence: {len(branch_hits)}") return if as_json: print(json.dumps( { "query": { "hash": hash_prefix, "name": name_pattern, "kind": kind_filter, "file": file_filter, "branch": branch, "since": args.since, "until": args.until, "first_only": first_only, "last_only": last_only, "limit": limit, }, "total": len(appearances), "results": [a.to_dict() for a in appearances], "branch_presence": [b.to_dict() for b in branch_hits] if all_branches else None, }, indent=2, )) return print(f"\nfind-symbol — {len(appearances)} match(es) across history") query_parts: list[str] = [] if hash_prefix: query_parts.append(f"hash prefix={hash_prefix}") if name_pattern: query_parts.append(f"name={name_pattern}") if kind_filter: query_parts.append(f"kind={kind_filter}") if file_filter: query_parts.append(f"file={file_filter}") if branch: query_parts.append(f"branch={branch}") if since: query_parts.append(f"since={since}") if until: query_parts.append(f"until={until}") print(f"Query: {', '.join(query_parts)}") print("─" * 62) if not appearances: print(" (no matching symbols found in commit history)") else: for ap in appearances: date_str = ap.commit.committed_at.strftime("%Y-%m-%d") short_id = ap.commit.commit_id[:8] branch_label = f" [{ap.commit.branch}]" if ap.commit.branch else "" print(f"\n {sanitize_display(ap.address)}") print(f" {short_id} {date_str} \"{sanitize_display(ap.commit.message)}\"{branch_label}") if ap.content_id: print(f" content_id: {ap.content_id[:16]}..") if all_branches: print(f"\nBranch presence ({len(branch_hits)} hit(s)):") print("─" * 62) if not branch_hits: print(" (symbol not found in any branch HEAD)") else: for bh in branch_hits: print(f" [{sanitize_display(bh.branch)}] {sanitize_display(bh.address)} {bh.content_id[:16]}..")