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