gabriel / muse public
index_rebuild.py python
732 lines 28.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """muse code index — manage and rebuild the optional local index layer.
2
3 Indexes live under ``.muse/indices/`` and are fully derived from the commit
4 history. They are optional — all commands work without them, but indexes
5 dramatically accelerate repeated queries on large repositories.
6
7 Available indexes
8 -----------------
9
10 ``symbol_history``
11 Maps every symbol address to its full event timeline across all commits.
12 Reduces ``muse code symbol-log``, ``muse code lineage``, and
13 ``muse code query-history`` from O(commits × files) to O(1) lookups.
14
15 ``hash_occurrence``
16 Maps every ``body_hash`` to the list of addresses that share it.
17 Reduces ``muse code clones`` and ``muse code find-symbol hash=`` to O(1).
18
19 Sub-commands
20 ------------
21
22 ``muse code index status``
23 Show the status, entry count, and last-updated time of each index.
24
25 ``muse code index rebuild [--index NAME] [--dry-run]``
26 Rebuild one or all indexes by walking the entire commit history.
27 Safe to run multiple times. Pass ``--dry-run`` to see what would be
28 built without writing anything.
29
30 ``muse code index purge [--index NAME]``
31 Delete one or all local index files. The next rebuild recreates them.
32
33 Usage::
34
35 muse code index status
36 muse code index status --json
37 muse code index rebuild
38 muse code index rebuild --json
39 muse code index rebuild --index symbol_history
40 muse code index rebuild --index hash_occurrence
41 muse code index rebuild --dry-run
42 muse code index purge
43 muse code index purge --index symbol_history
44
45 JSON output — ``muse code index status --json``::
46
47 {"indexes": [
48 {"name": "symbol_history", "status": "present", "entries": 1024,
49 "updated_at": "2026-03-21T12:00:00+00:00"},
50 {"name": "hash_occurrence", "status": "absent", "entries": 0,
51 "updated_at": null}
52 ],
53 "exit_code": 0,
54 "duration_ms": 12.5}
55
56 JSON output — ``muse code index rebuild --json``::
57
58 {"schema_version": "0.1.5",
59 "rebuilt": ["symbol_history", "hash_occurrence"],
60 "symbol_history_addresses": 512, "symbol_history_events": 2048,
61 "hash_occurrence_clusters": 31, "hash_occurrence_addresses": 87,
62 "exit_code": 0, "duration_ms": 8432.1}
63 """
64
65 from __future__ import annotations
66
67 import argparse
68 import json
69 import logging
70 import pathlib
71 from typing import TypedDict
72
73 from muse import __version__
74 from muse.core._types import short_id
75 from muse.core.errors import ExitCode
76 from muse.core.timing import start_timer
77 from muse.core.indices import (
78 KNOWN_INDEX_NAMES,
79 HashOccurrenceIndex,
80 IndexInfoEntry,
81 SymbolHistoryEntry,
82 SymbolHistoryIndex,
83 index_info,
84 purge_index,
85 save_hash_occurrence,
86 save_symbol_history,
87 )
88 from muse.core.object_store import read_object
89 from muse.core.refs import read_ref
90 from muse.core.repo import require_repo
91 from muse.core.store import get_all_commits, get_commit_snapshot_manifest, read_current_branch
92 from muse.core.symbol_cache import SymbolCache, load_symbol_cache
93 from muse.plugins.code._query import is_semantic
94 from muse.plugins.code.ast_parser import parse_symbols
95 from muse.core.validation import sanitize_display
96
97
98
99 type _BlobCache = dict[str, bytes]
100 type _ManifestCache = dict[str, dict[str, str]]
101 logger = logging.getLogger(__name__)
102
103
104 # ---------------------------------------------------------------------------
105 # TypedDicts for JSON output envelopes
106 # ---------------------------------------------------------------------------
107
108
109 class _RebuildResult(TypedDict, total=False):
110 """JSON envelope for ``muse code index rebuild --json``.
111
112 Fields
113 ------
114 schema_version Muse version string at build time.
115 dry_run True when --dry-run was passed; no files written.
116 rebuilt Index names that were (or would be) rebuilt.
117 symbol_history_addresses Distinct symbol addresses in the new index.
118 Present only when symbol_history was rebuilt.
119 symbol_history_events Total insert/delete/replace events across all addresses.
120 Present only when symbol_history was rebuilt.
121 hash_occurrence_clusters Hash clusters with more than one address.
122 Present only when hash_occurrence was rebuilt.
123 hash_occurrence_addresses Total address count across all clone clusters.
124 Present only when hash_occurrence was rebuilt.
125 exit_code Always 0 — all error paths raise SystemExit before JSON.
126 duration_ms Wall-clock time for the full rebuild in milliseconds.
127 """
128
129 schema_version: str
130 dry_run: bool
131 rebuilt: list[str]
132 symbol_history_addresses: int
133 symbol_history_events: int
134 hash_occurrence_clusters: int
135 hash_occurrence_addresses: int
136 exit_code: int
137 duration_ms: float
138
139
140 class _StatusIndexEntry(TypedDict):
141 """One index entry in the status JSON output."""
142
143 name: str
144 status: str
145 entries: int
146 updated_at: str | None
147
148
149 class _StatusResult(TypedDict):
150 """JSON envelope for ``muse code index status --json``.
151
152 Fields
153 ------
154 indexes List of index status entries (name, status, entries, updated_at).
155 exit_code Always 0 — absent or corrupt indexes do not cause non-zero exit.
156 duration_ms Wall-clock time for the status check in milliseconds.
157 """
158
159 indexes: list[_StatusIndexEntry]
160 exit_code: int
161 duration_ms: float
162
163
164 class _PurgeResult(TypedDict):
165 """JSON envelope for ``muse code index purge --json``.
166
167 Fields
168 ------
169 schema_version Muse version string.
170 purged Index names whose files were found and deleted.
171 skipped Index names that were not present (nothing to delete).
172 exit_code Always 0 — skipped indexes do not cause non-zero exit.
173 duration_ms Wall-clock time for the purge operation in milliseconds.
174 """
175
176 schema_version: str
177 purged: list[str]
178 skipped: list[str]
179 exit_code: int
180 duration_ms: float
181
182
183 # ---------------------------------------------------------------------------
184 # Index build logic
185 # ---------------------------------------------------------------------------
186
187
188 def _build_symbol_history(
189 root: pathlib.Path,
190 symbol_cache: SymbolCache | None = None,
191 ) -> SymbolHistoryIndex:
192 """Walk all commits oldest-first and build the symbol history index.
193
194 Performance notes
195 -----------------
196 * A ``manifest_cache`` (keyed by commit_id) ensures that each snapshot
197 manifest is fetched at most once per rebuild, regardless of how many
198 symbol ops share the same commit.
199
200 * A ``blob_cache`` (keyed by obj_id) ensures that each source blob is
201 ``read_object``'d at most once per rebuild.
202
203 * ``symbol_cache`` is the **persistent** cross-run parse cache
204 (:class:`~muse.core.symbol_cache.SymbolCache`). Because obj_id is the
205 SHA-256 of file bytes (content-addressed), a file that did not change
206 between two commits always produces a cache hit — its AST is never
207 re-parsed. On a warm cache a 300-commit rebuild drops from minutes to
208 seconds.
209
210 Together these three layers reduce I/O from
211 O(commits × ops × files) on the first run to
212 O(1) per unique (obj_id, address) pair on subsequent runs.
213 """
214 all_commits = sorted(
215 get_all_commits(root),
216 key=lambda c: c.committed_at,
217 )
218 index: SymbolHistoryIndex = {}
219
220 # manifest_cache: commit_id → {file_path: obj_id}
221 manifest_cache: _ManifestCache = {}
222 # blob_cache: obj_id → raw bytes (within this run; SymbolCache handles cross-run)
223 blob_cache: _BlobCache = {}
224
225 for commit in all_commits:
226 if commit.structured_delta is None:
227 continue
228 committed_at = commit.committed_at.isoformat()
229 ops = commit.structured_delta.get("ops", [])
230
231 # Fetch the manifest once per commit, not once per child op.
232 if commit.commit_id not in manifest_cache:
233 raw_manifest = get_commit_snapshot_manifest(root, commit.commit_id)
234 if raw_manifest is None:
235 logger.debug(
236 "Missing snapshot manifest for commit %s — skipping",
237 short_id(commit.commit_id),
238 )
239 continue
240 manifest_cache[commit.commit_id] = raw_manifest
241 manifest = manifest_cache[commit.commit_id]
242
243 for op in ops:
244 if op["op"] != "patch":
245 continue
246 for child in op.get("child_ops", []):
247 addr = child["address"]
248 if "::" not in addr:
249 continue
250 file_path = addr.split("::")[0]
251 if not is_semantic(file_path):
252 continue
253 child_op = child["op"]
254 if child_op not in ("insert", "delete", "replace"):
255 continue
256
257 # Extract hash fields from the snapshot blob.
258 # Resolution order:
259 # 1. symbol_cache (persistent msgpack cache, keyed by obj_id)
260 # 2. blob_cache (in-run bytes cache, avoids duplicate reads)
261 # 3. read_object + parse_symbols (cache miss — first encounter)
262 obj_id = manifest.get(file_path)
263 body_hash = ""
264 signature_id = ""
265 content_id = ""
266 if obj_id:
267 # Try the persistent SymbolCache first.
268 tree = symbol_cache.get(obj_id) if symbol_cache else None
269 if tree is None:
270 # Fetch bytes (in-run blob_cache avoids duplicate reads).
271 if obj_id not in blob_cache:
272 raw = read_object(root, obj_id)
273 if raw is not None:
274 blob_cache[obj_id] = raw
275 blob = blob_cache.get(obj_id)
276 if blob is not None:
277 tree = parse_symbols(blob, file_path)
278 if symbol_cache is not None:
279 symbol_cache.put(obj_id, tree)
280 if tree is not None:
281 rec = tree.get(addr)
282 if rec:
283 body_hash = rec["body_hash"]
284 signature_id = rec["signature_id"]
285 content_id = rec["content_id"]
286
287 if not content_id:
288 # Fall back to the content_id stored in the delta itself.
289 if child_op == "insert":
290 content_id = str(child.get("content_id") or "")
291 elif child_op == "delete":
292 content_id = str(child.get("content_id") or "")
293 elif child_op == "replace":
294 content_id = str(child.get("new_content_id") or "")
295
296 entry = SymbolHistoryEntry(
297 commit_id=commit.commit_id,
298 committed_at=committed_at,
299 op=child_op,
300 content_id=content_id,
301 body_hash=body_hash,
302 signature_id=signature_id,
303 )
304 index.setdefault(addr, []).append(entry)
305
306 return index
307
308
309 def _build_hash_occurrence(root: pathlib.Path) -> HashOccurrenceIndex:
310 """Walk the HEAD snapshot and build the hash occurrence index."""
311 try:
312 muse_dir = root / ".muse"
313 branch = read_current_branch(root)
314 head_commit_id = read_ref(muse_dir / "refs" / "heads" / branch) or ""
315 except OSError as exc:
316 logger.debug("Could not determine HEAD commit for hash_occurrence build: %s", exc)
317 return {}
318
319 raw_manifest = get_commit_snapshot_manifest(root, head_commit_id)
320 if raw_manifest is None:
321 logger.debug(
322 "Missing snapshot manifest for HEAD %s — hash_occurrence will be empty",
323 short_id(head_commit_id),
324 )
325 return {}
326
327 index: HashOccurrenceIndex = {}
328 for file_path, obj_id in sorted(raw_manifest.items()):
329 if not is_semantic(file_path):
330 continue
331 raw = read_object(root, obj_id)
332 if raw is None:
333 continue
334 tree = parse_symbols(raw, file_path)
335 for addr, rec in tree.items():
336 if rec["kind"] == "import":
337 continue
338 bh = rec["body_hash"]
339 index.setdefault(bh, []).append(addr)
340
341 # Remove trivial (size-1) entries — they are not clones.
342 return {h: addrs for h, addrs in index.items() if len(addrs) > 1}
343
344
345 # ---------------------------------------------------------------------------
346 # Sub-commands
347 # ---------------------------------------------------------------------------
348
349
350 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
351 """Register the index subcommand."""
352 parser = subparsers.add_parser(
353 "index",
354 help="Manage the optional local index layer.",
355 description=__doc__,
356 formatter_class=argparse.RawDescriptionHelpFormatter,
357 )
358 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
359 subs.required = True
360
361 # --- purge ---
362 purge_p = subs.add_parser(
363 "purge",
364 help="Delete one or all local index files.",
365 description=(
366 "Delete index files under .muse/indices/. The canonical commit\n"
367 "history and object store are never touched — indexes are fully\n"
368 "rebuildable at any time with 'muse code index rebuild'.\n"
369 "Absent indexes are silently skipped (exit 0).\n\n"
370 "Agent quickstart\n"
371 "----------------\n"
372 " muse code index purge --json\n"
373 " muse code index purge -j\n"
374 " muse code index purge -j | jq .purged\n"
375 " muse code index purge --index symbol_history -j\n\n"
376 "JSON output schema\n"
377 "------------------\n"
378 ' {"schema_version": "<str>",\n'
379 ' "purged": ["symbol_history", ...],\n'
380 ' "skipped": ["hash_occurrence", ...]}\n\n'
381 "Exit codes\n"
382 "----------\n"
383 " 0 — operation completed (skipped indexes do not cause failure)\n"
384 " 2 — not inside a Muse repository\n"
385 ),
386 formatter_class=argparse.RawDescriptionHelpFormatter,
387 )
388 purge_p.add_argument(
389 "--index", "-i",
390 dest="index_name",
391 default=None,
392 metavar="NAME",
393 choices=list(KNOWN_INDEX_NAMES),
394 help="Purge a specific index. Default: purge all.",
395 )
396 purge_p.add_argument("--json", "-j", dest="as_json", action="store_true",
397 help="Emit purge summary as JSON.")
398 purge_p.set_defaults(func=run_purge)
399
400 # --- rebuild ---
401 rebuild_p = subs.add_parser(
402 "rebuild",
403 help="Rebuild local indexes from the full commit history.",
404 description=(
405 "Rebuild symbol_history and/or hash_occurrence under .muse/indices/.\n"
406 "Safe to run any number of times — atomic writes prevent corruption.\n"
407 "A warm SymbolCache means unchanged files are never re-parsed.\n\n"
408 "Use --dry-run to compute statistics without writing to disk.\n\n"
409 "Agent quickstart\n"
410 "----------------\n"
411 " muse code index rebuild --json\n"
412 " muse code index rebuild -j\n"
413 " muse code index rebuild -j | jq .rebuilt\n"
414 " muse code index rebuild --index symbol_history -j\n"
415 " muse code index rebuild --dry-run -j\n\n"
416 "JSON output schema\n"
417 "------------------\n"
418 ' {"schema_version": "<str>", "dry_run": <bool>,\n'
419 ' "rebuilt": ["symbol_history", "hash_occurrence"],\n'
420 ' "symbol_history_addresses": <int>, "symbol_history_events": <int>,\n'
421 ' "hash_occurrence_clusters": <int>, "hash_occurrence_addresses": <int>}\n\n'
422 " Note: *_addresses / *_events / *_clusters keys are absent when the\n"
423 " corresponding index was not rebuilt.\n\n"
424 "Exit codes\n"
425 "----------\n"
426 " 0 — rebuild (or dry run) completed successfully\n"
427 " 2 — not inside a Muse repository\n"
428 ),
429 formatter_class=argparse.RawDescriptionHelpFormatter,
430 )
431 rebuild_p.add_argument(
432 "--index", "-i",
433 dest="index_name",
434 default=None,
435 metavar="NAME",
436 choices=list(KNOWN_INDEX_NAMES),
437 help="Rebuild a specific index. Default: rebuild all.",
438 )
439 rebuild_p.add_argument("--dry-run", action="store_true",
440 help="Compute what would be built without writing anything.")
441 rebuild_p.add_argument("--verbose", "-v", action="store_true", help="Show progress.")
442 rebuild_p.add_argument("--json", "-j", dest="as_json", action="store_true",
443 help="Emit rebuild summary as JSON.")
444 rebuild_p.set_defaults(func=run_rebuild)
445
446 # --- status ---
447 status_p = subs.add_parser(
448 "status",
449 help="Show the status and entry count of each local index.",
450 description=(
451 "Report the on-disk state of every index under .muse/indices/.\n"
452 "Each index is either present (valid), absent (not yet built),\n"
453 "or corrupt (exists but failed to parse).\n\n"
454 "Agent quickstart\n"
455 "----------------\n"
456 " muse code index status --json\n"
457 " muse code index status -j\n"
458 " muse code index status -j | jq '.[].status'\n"
459 " muse code index status -j | jq '.[] | select(.status != \"present\")'\n\n"
460 "JSON output schema\n"
461 "------------------\n"
462 " [{\"name\": \"symbol_history\", \"status\": \"present|absent|corrupt\",\n"
463 ' "entries": <int>, "updated_at": "<iso8601>|null"}, ...]\n\n'
464 "Exit codes\n"
465 "----------\n"
466 " 0 — status emitted (absent/corrupt indexes do NOT cause non-zero exit)\n"
467 " 2 — not inside a Muse repository\n"
468 ),
469 formatter_class=argparse.RawDescriptionHelpFormatter,
470 )
471 status_p.add_argument("--json", "-j", dest="as_json", action="store_true", help="Emit index status as JSON.")
472 status_p.set_defaults(func=run_status)
473
474
475 def run_status(args: argparse.Namespace) -> None:
476 """Show the status and entry count of each local Muse index.
477
478 Reports the on-disk state of every index under ``.muse/indices/``.
479 Each index is reported as ``present`` (readable and valid), ``absent``
480 (file not found — not yet built), or ``corrupt`` (file exists but
481 failed to parse).
482
483 Indexes are derived entirely from commit history and can be rebuilt at
484 any time without modifying the canonical store.
485
486 Security: all text-mode output passes through ``sanitize_display()`` so
487 index names cannot inject ANSI or control characters into the terminal.
488 Index names in JSON output come exclusively from ``KNOWN_INDEX_NAMES``
489 (a static compile-time tuple), not from user input. The ``updated_at``
490 and ``entries`` values are read from trusted local msgpack files in
491 ``.muse/indices/``; they are not derived from user-supplied data.
492
493 JSON envelope (``--json`` / ``-j``)
494 ------------------------------------
495 ``indexes``
496 List of index status objects, one per known index:
497
498 ``name`` Registry key (``"symbol_history"`` or ``"hash_occurrence"``).
499 ``status`` ``"present"``, ``"absent"``, or ``"corrupt"``.
500 ``entries`` Top-level entry count (``0`` when absent or corrupt).
501 ``updated_at`` ISO-8601 timestamp of last rebuild, or ``null``.
502
503 ``exit_code``
504 Always 0 — all error paths raise SystemExit before JSON is emitted.
505 Absent or corrupt indexes do not cause non-zero exit.
506 ``duration_ms``
507 Wall-clock time for the status check in milliseconds (non-negative float).
508
509 Exit codes
510 ----------
511 0 — status emitted (even if indexes are absent or corrupt)
512 2 — not inside a Muse repository
513 """
514 elapsed = start_timer()
515 as_json: bool = args.as_json
516
517 root = require_repo()
518 infos: list[IndexInfoEntry] = index_info(root)
519
520 if as_json:
521 indexes: list[_StatusIndexEntry] = [
522 _StatusIndexEntry(
523 name=info["name"],
524 status=info["status"],
525 entries=info["entries"],
526 updated_at=info["updated_at"],
527 )
528 for info in infos
529 ]
530 print(json.dumps(_StatusResult(
531 indexes=indexes,
532 exit_code=0,
533 duration_ms=elapsed(),
534 )))
535 return
536
537 print("\nLocal index status:")
538 print("─" * 50)
539 for info in infos:
540 status = info["status"]
541 name = info["name"]
542 updated = (info["updated_at"] or "")[:19]
543 entries = info["entries"]
544 if status == "present":
545 print(f" ✅ {sanitize_display(name):<20} {entries:>8} entries (updated {updated})")
546 elif status == "absent":
547 print(f" ⬜ {sanitize_display(name):<20} (not built — run: muse code index rebuild)")
548 else:
549 print(f" ❌ {sanitize_display(name):<20} corrupt — run: muse code index rebuild")
550 print()
551
552
553 def run_rebuild(args: argparse.Namespace) -> None:
554 """Rebuild local indexes from the full commit history.
555
556 Walks all commits oldest-first to build ``symbol_history`` and/or
557 ``hash_occurrence`` under ``.muse/indices/``. Safe to run any number of
558 times — existing index files are atomically overwritten.
559
560 A shared ``SymbolCache`` (keyed by content-addressed object ID) means AST
561 parses are never repeated for unchanged files. On a warm cache a
562 300-commit rebuild drops from minutes to seconds.
563
564 With ``--dry-run`` the full build is performed in memory and statistics
565 are reported, but nothing is written to disk and the symbol cache is not
566 saved.
567
568 Security: the ``--index`` argument is validated by argparse against
569 ``KNOWN_INDEX_NAMES`` before ``run_rebuild`` is called — no
570 caller-supplied name reaches the file system unvalidated. Writes use
571 atomic msgpack saves (``save_symbol_history`` / ``save_hash_occurrence``)
572 so a crash cannot corrupt existing index data.
573
574 JSON output fields (``--json`` / ``-j``)
575 -----------------------------------------
576 ``schema_version``
577 Muse version string at build time.
578 ``dry_run``
579 ``true`` when ``--dry-run`` was passed; no files were written.
580 ``rebuilt``
581 List of index names that were (or would be) rebuilt.
582 ``symbol_history_addresses``
583 Number of distinct symbol addresses in the new index.
584 Present only when ``symbol_history`` was rebuilt.
585 ``symbol_history_events``
586 Total number of insert/delete/replace events across all addresses.
587 Present only when ``symbol_history`` was rebuilt.
588 ``hash_occurrence_clusters``
589 Number of hash clusters with more than one address (clone groups).
590 Present only when ``hash_occurrence`` was rebuilt.
591 ``hash_occurrence_addresses``
592 Total address count across all clone clusters.
593 Present only when ``hash_occurrence`` was rebuilt.
594
595 ``exit_code``
596 Always 0 — all error paths raise SystemExit before JSON is emitted.
597 ``duration_ms``
598 Wall-clock time for the full rebuild in milliseconds (non-negative float).
599
600 Exit codes
601 ----------
602 0 — rebuild completed (or dry run computed) successfully
603 2 — not inside a Muse repository
604 """
605 elapsed = start_timer()
606 index_name: str | None = args.index_name
607 dry_run: bool = args.dry_run
608 verbose: bool = args.verbose
609 as_json: bool = args.as_json
610
611 root = require_repo()
612
613 build_all = index_name is None
614 built: list[str] = []
615 result: _RebuildResult = {
616 "schema_version": __version__,
617 "dry_run": dry_run,
618 }
619
620 # Load the persistent SymbolCache once — it is shared across both index
621 # builds and saved at the end. This gives cross-run caching of AST parses
622 # so that unchanged files are never re-parsed.
623 sym_cache = load_symbol_cache(root)
624
625 if build_all or index_name == "symbol_history":
626 if verbose and not as_json:
627 print("Building symbol_history index…")
628 idx = _build_symbol_history(root, symbol_cache=sym_cache)
629 if not dry_run:
630 save_symbol_history(root, idx)
631 n_events = sum(len(evts) for evts in idx.values())
632 result["symbol_history_addresses"] = len(idx)
633 result["symbol_history_events"] = n_events
634 if not as_json:
635 tag = " (dry run)" if dry_run else ""
636 print(f" ✅ symbol_history — {len(idx)} addresses, {n_events} events{tag}")
637 built.append("symbol_history")
638
639 if build_all or index_name == "hash_occurrence":
640 if verbose and not as_json:
641 print("Building hash_occurrence index…")
642 idx2 = _build_hash_occurrence(root)
643 if not dry_run:
644 save_hash_occurrence(root, idx2)
645 n_clones = sum(len(addrs) for addrs in idx2.values())
646 result["hash_occurrence_clusters"] = len(idx2)
647 result["hash_occurrence_addresses"] = n_clones
648 if not as_json:
649 tag = " (dry run)" if dry_run else ""
650 print(f" ✅ hash_occurrence — {len(idx2)} clone clusters, {n_clones} addresses{tag}")
651 built.append("hash_occurrence")
652
653 result["rebuilt"] = built
654
655 # Persist any newly cached parse results so the next rebuild is faster.
656 if not dry_run:
657 sym_cache.save()
658
659 if as_json:
660 result["exit_code"] = 0
661 result["duration_ms"] = elapsed()
662 print(json.dumps(result))
663 return
664
665 action = "Computed" if dry_run else "Rebuilt"
666 print(f"\n{action} {len(built)} index(es)" + (" — no files written (dry run)" if dry_run else " under .muse/indices/"))
667 if not dry_run:
668 print("Run 'muse code index status' to verify.")
669
670
671 def run_purge(args: argparse.Namespace) -> None:
672 """Delete one or all local Muse index files under ``.muse/indices/``.
673
674 Indexes are derived data — the canonical commit history and object store
675 are never modified. Any purged index can be fully recreated at any time
676 with ``muse code index rebuild``.
677
678 Indexes that are not present are silently skipped and reported under
679 ``skipped`` rather than causing an error.
680
681 Security: the ``--index`` argument is validated by argparse against
682 ``KNOWN_INDEX_NAMES`` before ``run_purge`` is called, and ``purge_index``
683 performs a second validation before any ``unlink`` — no caller-supplied
684 path fragment can reach the file system. Only files under
685 ``.muse/indices/`` are ever deleted; the rest of the object store is
686 untouched.
687
688 JSON envelope (``--json`` / ``-j``)
689 ------------------------------------
690 ``schema_version`` Muse version string.
691 ``purged`` Index names whose files were found and deleted.
692 ``skipped`` Index names that were not present (nothing to delete).
693 ``exit_code`` Always 0 — skipped indexes do not cause non-zero exit.
694 ``duration_ms`` Wall-clock time for the purge in milliseconds (non-negative float).
695
696 Exit codes
697 ----------
698 0 — operation completed (skipped indexes do NOT cause non-zero exit)
699 2 — not inside a Muse repository
700 """
701 elapsed = start_timer()
702 index_name: str | None = args.index_name
703 as_json: bool = args.as_json
704
705 root = require_repo()
706 muse_dir = root / ".muse"
707 names = list(KNOWN_INDEX_NAMES) if index_name is None else [index_name]
708
709 purged: list[str] = []
710 skipped: list[str] = []
711 for name in names:
712 if purge_index(root, name):
713 purged.append(name)
714 else:
715 skipped.append(name)
716
717 if as_json:
718 print(json.dumps(_PurgeResult(
719 schema_version=__version__,
720 purged=purged,
721 skipped=skipped,
722 exit_code=0,
723 duration_ms=elapsed(),
724 )))
725 return
726
727 for name in purged:
728 print(f" 🗑️ {name} — deleted")
729 for name in skipped:
730 print(f" ⬜ {name} — not present, nothing to delete")
731 if purged:
732 print(f"\nPurged {len(purged)} index(es). Run 'muse code index rebuild' to recreate.")
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago