rerere.py python
888 lines 30.7 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """muse rerere — reuse recorded resolutions for merge conflicts.
2
3 Records how you resolve a merge conflict and replays that resolution
4 automatically the next time the same conflict appears. The conflict is
5 identified by a content fingerprint (SHA-256 of the sorted ours/theirs
6 object ID pair) so the same resolution applies regardless of surrounding
7 context changes.
8
9 Subcommands
10 -----------
11
12 ``muse rerere`` Apply cached resolutions to current conflicts.
13 ``muse rerere record`` Record preimages for current conflicts (no replay).
14 ``muse rerere status`` List cached resolutions and match against current conflicts.
15 ``muse rerere forget`` Remove the cached resolution for specific paths or fingerprints.
16 ``muse rerere clear`` Remove all cached resolutions.
17 ``muse rerere gc`` Garbage-collect preimage-only entries older than N days.
18
19 Automatic integration
20 ---------------------
21
22 ``muse merge`` calls rerere automatically:
23
24 - **Before writing conflict markers**: tries to auto-apply any cached
25 resolutions, resolving matching conflicts without user intervention.
26 - **Records preimages** for any remaining conflicts so that the user's
27 resolution can be saved when they run ``muse commit``.
28
29 ``muse commit`` (during a conflicted merge) calls rerere to save the
30 user's chosen resolution for replay on future identical conflicts.
31
32 Security model
33 --------------
34
35 All user-controlled values (paths, fingerprints, domain names) are
36 validated and passed through ``sanitize_display()`` before terminal output
37 to prevent ANSI injection. Fingerprints are validated as 64-char hex
38 strings. Conflict paths are validated not to escape the repository root.
39 Error messages go to **stderr**; **stdout** carries only data.
40
41 Agent UX
42 --------
43
44 Pass ``--json`` to any subcommand for machine-readable output. JSON
45 schemas are defined by TypedDicts below — all fields are always present.
46
47 JSON schemas
48 ~~~~~~~~~~~~
49
50 ``muse rerere --json``::
51
52 {"dry_run": bool, "auto_resolved": [str], "remaining": [str]}
53
54 ``muse rerere record --json``::
55
56 {"recorded": [str], "skipped": [str]}
57
58 ``muse rerere status --json``::
59
60 {
61 "total": int,
62 "records": [
63 {
64 "fingerprint": str, // 64-char hex
65 "path": str,
66 "domain": str,
67 "has_resolution": bool,
68 "resolution_id": str | null,
69 "recorded_at": str, // ISO 8601
70 "matches_current_conflict": bool
71 }
72 ]
73 }
74
75 ``muse rerere forget --json``::
76
77 {"forgotten": [str], "not_found": [str]}
78
79 ``muse rerere clear --json`` / ``muse rerere gc --json``::
80
81 {"removed": int}
82
83 Exit codes
84 ----------
85
86 - 0 — success
87 - 1 — invalid arguments or missing MERGE_STATE
88 - 2 — not inside a Muse repository
89 """
90 from __future__ import annotations
91
92 import argparse
93 import json
94 import logging
95 import pathlib
96 import sys
97 from typing import TYPE_CHECKING, TypedDict
98
99 from muse.core.errors import ExitCode
100 from muse.core.merge_engine import read_merge_state
101 from muse.core.repo import require_repo
102 from muse.core.rerere import (
103 RerereRecord,
104 apply_cached,
105 clear_all,
106 compute_fingerprint,
107 forget_record,
108 gc_stale,
109 list_records,
110 record_preimage,
111 rr_cache_dir,
112 )
113 from muse.core.store import read_commit, read_snapshot
114 from muse.core.validation import clamp_int, sanitize_display
115 from muse.plugins.registry import read_domain, resolve_plugin
116 from muse.core._types import Manifest
117
118 if TYPE_CHECKING:
119 pass
120
121 logger = logging.getLogger(__name__)
122
123 _FORMAT_CHOICES = ("text", "json")
124
125
126 # ---------------------------------------------------------------------------
127 # JSON TypedDicts — stable machine-readable output schemas
128 # ---------------------------------------------------------------------------
129
130
131 class _RerereApplyJson(TypedDict):
132 """JSON output from ``muse rerere`` (default apply action)."""
133
134 dry_run: bool
135 auto_resolved: list[str]
136 remaining: list[str]
137
138
139 class _RerereRecordJson(TypedDict):
140 """JSON output from ``muse rerere record``."""
141
142 recorded: list[str]
143 skipped: list[str]
144
145
146 class _RerereStatusRecordJson(TypedDict):
147 """One record in ``muse rerere status --json`` output."""
148
149 fingerprint: str
150 path: str
151 domain: str
152 has_resolution: bool
153 resolution_id: str | None
154 recorded_at: str
155 matches_current_conflict: bool
156
157
158 class _RerereStatusJson(TypedDict):
159 """JSON output from ``muse rerere status``."""
160
161 total: int
162 records: list[_RerereStatusRecordJson]
163
164
165 class _RerereForgetJson(TypedDict):
166 """JSON output from ``muse rerere forget``."""
167
168 forgotten: list[str]
169 not_found: list[str]
170
171
172 class _RerereScalarJson(TypedDict):
173 """JSON output from ``muse rerere clear`` and ``muse rerere gc``."""
174
175 removed: int
176
177
178 # ---------------------------------------------------------------------------
179 # Helpers
180 # ---------------------------------------------------------------------------
181
182
183 def _load_conflict_manifests(
184 root: pathlib.Path,
185 ) -> tuple[dict[str, str], dict[str, str]] | None:
186 """Return ``(ours_manifest, theirs_manifest)`` from the active MERGE_STATE.
187
188 Returns ``None`` when MERGE_STATE is absent or incomplete.
189 """
190 state = read_merge_state(root)
191 if state is None or not state.ours_commit or not state.theirs_commit:
192 return None
193
194 def _manifest(commit_id: str) -> Manifest:
195 commit = read_commit(root, commit_id)
196 if commit is None:
197 return {}
198 snap = read_snapshot(root, commit.snapshot_id)
199 return snap.manifest if snap else {}
200
201 return _manifest(state.ours_commit), _manifest(state.theirs_commit)
202
203
204 def _fmt_record(rec: RerereRecord) -> str:
205 """Format one :class:`RerereRecord` for human-readable terminal output.
206
207 Applies ``sanitize_display()`` to the path to prevent ANSI injection.
208 """
209 status = "✅ resolved" if rec.has_resolution else "⏳ preimage only"
210 ts = rec.recorded_at.strftime("%Y-%m-%d %H:%M")
211 return f"{rec.fingerprint[:12]} {status} {ts} {sanitize_display(rec.path)}"
212
213
214 def _check_format(fmt: str) -> None:
215 """Exit with USER_ERROR if *fmt* is not a known output format."""
216 if fmt not in _FORMAT_CHOICES:
217 print(
218 f"❌ Unknown format {fmt!r}. Valid choices: {', '.join(_FORMAT_CHOICES)}",
219 file=sys.stderr,
220 )
221 raise SystemExit(ExitCode.USER_ERROR)
222
223
224 # ---------------------------------------------------------------------------
225 # Registration
226 # ---------------------------------------------------------------------------
227
228
229 def register(
230 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
231 ) -> None:
232 """Register the rerere subcommand."""
233 parser = subparsers.add_parser(
234 "rerere",
235 help="Reuse recorded resolutions for merge conflicts.",
236 description=__doc__,
237 formatter_class=argparse.RawDescriptionHelpFormatter,
238 )
239 parser.add_argument(
240 "--dry-run", "-n", action="store_true",
241 help="Show what would be auto-resolved without writing files.",
242 )
243 parser.add_argument(
244 "--format", "-f", default="text", dest="fmt",
245 help="Output format: text or json.",
246 )
247 parser.add_argument(
248 "--json", action="store_const", const="json", dest="fmt",
249 help="Shorthand for --format json.",
250 )
251 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
252
253 # -- clear ----------------------------------------------------------------
254 clear_p = subs.add_parser(
255 "clear",
256 help="Remove all cached rerere resolutions.",
257 description=(
258 "Delete every entry in ``.muse/rr-cache/``. This is irreversible —\n"
259 "all recorded resolutions will be lost. A confirmation prompt is\n"
260 "shown unless ``--yes`` is passed.\n\n"
261 "Agent quickstart\n"
262 "----------------\n"
263 " muse rerere clear --yes --json\n\n"
264 "JSON output schema\n"
265 "------------------\n"
266 ' {"removed": <int>}\n\n'
267 "Exit codes\n"
268 "----------\n"
269 " 0 — clear completed (removed may be 0 if cache was empty)\n"
270 " 1 — invalid --format value\n"
271 " 2 — not inside a Muse repository\n"
272 ),
273 formatter_class=argparse.RawDescriptionHelpFormatter,
274 )
275 clear_p.add_argument("--yes", "-y", action="store_true",
276 help="Skip confirmation prompt.")
277 clear_p.add_argument("--format", "-f", default="text", dest="fmt",
278 help="Output format: text or json.")
279 clear_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
280 help="Shorthand for --format json.")
281 clear_p.set_defaults(func=run_clear)
282
283 # -- forget ---------------------------------------------------------------
284 forget_p = subs.add_parser(
285 "forget",
286 help="Remove the cached rerere resolution for specific paths or fingerprints.",
287 description=(
288 "Remove rr-cache entries so the next identical conflict is resolved\n"
289 "manually instead of auto-applied.\n\n"
290 "Two modes\n"
291 "---------\n"
292 "Path mode muse rerere forget src/foo.py\n"
293 " Requires an active merge state to compute fingerprints.\n\n"
294 "Fingerprint muse rerere forget --fingerprint <hex64>\n"
295 " Works without an active merge. Fingerprints come from\n"
296 " ``muse rerere status --json``.\n\n"
297 "Agent quickstart\n"
298 "----------------\n"
299 " muse rerere forget --fingerprint <hex64> --json\n\n"
300 "JSON output schema\n"
301 "------------------\n"
302 ' {"forgotten": ["<path|hex64>", ...],\n'
303 ' "not_found": ["<path|hex64>", ...]}\n\n'
304 "Exit codes\n"
305 "----------\n"
306 " 0 — forget completed (some items may be in not_found)\n"
307 " 1 — no args given; invalid format; no merge state in path mode\n"
308 " 2 — not inside a Muse repository\n"
309 ),
310 formatter_class=argparse.RawDescriptionHelpFormatter,
311 )
312 forget_p.add_argument(
313 "paths", nargs="*",
314 help="Workspace-relative paths whose rerere resolution should be removed "
315 "(requires an active merge state to compute fingerprints).",
316 )
317 forget_p.add_argument(
318 "--fingerprint", dest="fingerprints", action="append", metavar="HEX",
319 help="Forget by 64-char hex fingerprint directly — does not require an "
320 "active merge state. May be repeated.",
321 )
322 forget_p.add_argument("--format", "-f", default="text", dest="fmt",
323 help="Output format: text or json.")
324 forget_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
325 help="Shorthand for --format json.")
326 forget_p.set_defaults(func=run_forget)
327
328 # -- gc -------------------------------------------------------------------
329 gc_p = subs.add_parser(
330 "gc",
331 help="Remove preimage-only rerere entries older than N days.",
332 description=(
333 "Garbage-collect ``.muse/rr-cache/`` by removing preimage-only\n"
334 "entries older than ``--age`` days. Entries that have a saved\n"
335 "resolution are always kept regardless of age.\n\n"
336 "Agent quickstart\n"
337 "----------------\n"
338 " muse rerere gc --json\n"
339 " muse rerere gc --age 30 --json\n\n"
340 "JSON output schema\n"
341 "------------------\n"
342 ' {"removed": <int>}\n\n'
343 "Exit codes\n"
344 "----------\n"
345 " 0 — gc completed (removed may be 0)\n"
346 " 1 — invalid --age value or --format value\n"
347 " 2 — not inside a Muse repository\n"
348 ),
349 formatter_class=argparse.RawDescriptionHelpFormatter,
350 )
351 gc_p.add_argument(
352 "--age", type=int, default=60, metavar="DAYS",
353 help="Remove preimage-only entries older than DAYS days (default: 60).",
354 )
355 gc_p.add_argument("--format", "-f", default="text", dest="fmt",
356 help="Output format: text or json.")
357 gc_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
358 help="Shorthand for --format json.")
359 gc_p.set_defaults(func=run_gc)
360
361 # -- record ---------------------------------------------------------------
362 record_p = subs.add_parser(
363 "record",
364 help="Record preimages for current conflicts without applying cached resolutions.",
365 description=(
366 "Write a preimage entry to ``.muse/rr-cache/`` for every\n"
367 "conflicting path in MERGE_STATE. The preimage is the fingerprint\n"
368 "of the two conflicting blob IDs; the resolution is saved later\n"
369 "when the user commits. Idempotent: re-recording a known conflict\n"
370 "is a no-op.\n\n"
371 "Agent quickstart\n"
372 "----------------\n"
373 " muse rerere record --json\n\n"
374 "JSON output schema\n"
375 "------------------\n"
376 ' {"recorded": ["<path>", ...], "skipped": ["<path>", ...]}\n\n'
377 "Exit codes\n"
378 "----------\n"
379 " 0 — preimages recorded (or nothing to record)\n"
380 " 1 — MERGE_STATE incomplete\n"
381 " 2 — not inside a Muse repository\n"
382 ),
383 formatter_class=argparse.RawDescriptionHelpFormatter,
384 )
385 record_p.add_argument("--format", "-f", default="text", dest="fmt",
386 help="Output format: text or json.")
387 record_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
388 help="Shorthand for --format json.")
389 record_p.set_defaults(func=run_record)
390
391 # -- status ---------------------------------------------------------------
392 status_p = subs.add_parser(
393 "status",
394 help="Show cached rerere resolutions and their match against current conflicts.",
395 description=(
396 "List every entry in ``.muse/rr-cache/``, showing whether a\n"
397 "resolution has been saved. When a merge is in progress, entries\n"
398 "that match the current conflict set are marked with ◀ current.\n\n"
399 "Agent quickstart\n"
400 "----------------\n"
401 " muse rerere status --json\n\n"
402 "JSON output schema\n"
403 "------------------\n"
404 ' {"total": <int>, "records": [\n'
405 ' {"fingerprint": "<hex64>", "path": "<str>",\n'
406 ' "domain": "<str>", "has_resolution": <bool>,\n'
407 ' "resolution_id": "<hex64>|null",\n'
408 ' "recorded_at": "<iso8601>",\n'
409 ' "matches_current_conflict": <bool>}, ...]}\n\n'
410 "Exit codes\n"
411 "----------\n"
412 " 0 — status listed (may be empty)\n"
413 " 1 — invalid --format value\n"
414 " 2 — not inside a Muse repository\n"
415 ),
416 formatter_class=argparse.RawDescriptionHelpFormatter,
417 )
418 status_p.add_argument("--format", "-f", default="text", dest="fmt",
419 help="Output format: text or json.")
420 status_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
421 help="Shorthand for --format json.")
422 status_p.set_defaults(func=run_status)
423
424 parser.set_defaults(func=run)
425
426
427 # ---------------------------------------------------------------------------
428 # Default action: apply cached resolutions
429 # ---------------------------------------------------------------------------
430
431
432 def run(args: argparse.Namespace) -> None:
433 """Apply cached conflict resolutions to the current working tree.
434
435 Reads ``.muse/MERGE_STATE.json`` to discover which paths are in conflict,
436 looks up each path's fingerprint in ``.muse/rr-cache/``, and restores any
437 cached resolution blobs to the working tree. Paths with no cached
438 resolution are left unchanged and reported for manual attention.
439
440 Run after ``muse merge`` exits with conflicts, or let ``muse merge`` invoke
441 this automatically via the ``--rerere-autoupdate`` flag.
442
443 Each conflicting path is validated not to escape the repository root
444 before any file write occurs.
445 """
446 dry_run: bool = args.dry_run
447 fmt: str = args.fmt
448
449 _check_format(fmt)
450
451 root = require_repo()
452 state = read_merge_state(root)
453 if state is None or not state.conflict_paths:
454 if fmt == "json":
455 print(json.dumps(_RerereApplyJson(dry_run=dry_run, auto_resolved=[], remaining=[]), indent=2))
456 else:
457 print("No merge in progress — nothing for rerere to do.")
458 return
459
460 manifests = _load_conflict_manifests(root)
461 if manifests is None:
462 print("❌ MERGE_STATE.json is incomplete — cannot load conflict manifests.", file=sys.stderr)
463 raise SystemExit(ExitCode.USER_ERROR)
464 ours_manifest, theirs_manifest = manifests
465
466 plugin = resolve_plugin(root)
467
468 auto_resolved: list[str] = []
469 remaining: list[str] = []
470
471 for path in sorted(state.conflict_paths):
472 # Path traversal guard — protect against a tampered MERGE_STATE.json.
473 try:
474 (root / path).relative_to(root)
475 except ValueError:
476 logger.warning("⚠️ rerere: path traversal attempt — skipping %r", path)
477 remaining.append(path)
478 continue
479
480 ours_id = ours_manifest.get(path, "")
481 theirs_id = theirs_manifest.get(path, "")
482 if not ours_id or not theirs_id:
483 remaining.append(path)
484 continue
485
486 fp = compute_fingerprint(path, ours_id, theirs_id, plugin, root)
487
488 if not (root / ".muse" / "rr-cache" / fp / "resolution").exists():
489 remaining.append(path)
490 continue
491
492 if dry_run:
493 auto_resolved.append(path)
494 continue
495
496 dest = root / path
497 if apply_cached(root, fp, dest):
498 auto_resolved.append(path)
499 else:
500 remaining.append(path)
501
502 if fmt == "json":
503 print(
504 json.dumps(
505 _RerereApplyJson(
506 dry_run=dry_run,
507 auto_resolved=auto_resolved,
508 remaining=remaining,
509 ),
510 indent=2,
511 )
512 )
513 return
514
515 prefix = "[dry-run] would resolve" if dry_run else "✅ rerere auto-resolved"
516 for p in auto_resolved:
517 print(f" {prefix}: {sanitize_display(p)}")
518 for p in remaining:
519 print(f" ⏳ needs manual resolution: {sanitize_display(p)}")
520
521 if not auto_resolved and not remaining:
522 print("No conflicting paths found.")
523
524
525 # ---------------------------------------------------------------------------
526 # record — write preimages without replaying
527 # ---------------------------------------------------------------------------
528
529
530 def run_record(args: argparse.Namespace) -> None:
531 """Record preimages for current conflicts without applying cached resolutions.
532
533 Writes a preimage entry to ``.muse/rr-cache/`` for every conflicting path
534 in MERGE_STATE. The preimage is the fingerprint of the two conflicting
535 blob IDs; the resolution is saved later when the user commits.
536
537 Idempotent: re-recording an already-known conflict is a no-op. Paths
538 where one side deleted the file (missing ours_id or theirs_id) are placed
539 in ``skipped`` rather than causing an error.
540
541 JSON schema (``--json``)::
542
543 {"recorded": ["<path>", ...], "skipped": ["<path>", ...]}
544
545 Exit codes:
546 0 — preimages recorded (or no merge in progress)
547 1 — MERGE_STATE is incomplete (missing ours/theirs commit)
548 2 — not inside a Muse repository
549 """
550 fmt: str = args.fmt
551
552 _check_format(fmt)
553
554 root = require_repo()
555 state = read_merge_state(root)
556 if state is None or not state.conflict_paths:
557 if fmt == "json":
558 print(json.dumps(_RerereRecordJson(recorded=[], skipped=[])))
559 else:
560 print("No merge in progress — nothing to record.")
561 return
562
563 manifests = _load_conflict_manifests(root)
564 if manifests is None:
565 print("❌ MERGE_STATE.json is incomplete — cannot load conflict manifests.", file=sys.stderr)
566 raise SystemExit(ExitCode.USER_ERROR)
567 ours_manifest, theirs_manifest = manifests
568
569 domain = read_domain(root)
570 plugin = resolve_plugin(root)
571
572 recorded: list[str] = []
573 skipped: list[str] = []
574
575 for path in sorted(state.conflict_paths):
576 ours_id = ours_manifest.get(path, "")
577 theirs_id = theirs_manifest.get(path, "")
578 if not ours_id or not theirs_id:
579 skipped.append(path)
580 continue
581 record_preimage(root, path, ours_id, theirs_id, domain, plugin)
582 recorded.append(path)
583
584 if fmt == "json":
585 print(json.dumps(_RerereRecordJson(
586 recorded=[sanitize_display(p) for p in recorded],
587 skipped=[sanitize_display(p) for p in skipped],
588 )))
589 return
590
591 for p in recorded:
592 print(f" 📝 preimage recorded: {sanitize_display(p)}")
593 for p in skipped:
594 print(f" ⚠️ skipped (one side deleted): {sanitize_display(p)}")
595
596
597 # ---------------------------------------------------------------------------
598 # status — show cached resolutions vs current conflicts
599 # ---------------------------------------------------------------------------
600
601
602 def run_status(args: argparse.Namespace) -> None:
603 """Show cached rerere resolutions and their match against current conflicts.
604
605 Lists every entry in ``.muse/rr-cache/``, indicating whether a resolution
606 has been saved. When a merge is in progress, entries whose fingerprint
607 matches the current conflict set are marked with ``◀ current``.
608
609 JSON schema::
610
611 {
612 "total": <int>,
613 "records": [
614 {
615 "fingerprint": "<hex64>",
616 "path": "<str>",
617 "domain": "<str>",
618 "has_resolution": <bool>,
619 "resolution_id": "<hex64> | null",
620 "recorded_at": "<iso8601>",
621 "matches_current_conflict": <bool>
622 },
623 ...
624 ]
625 }
626
627 Exit codes:
628 0 — status listed (may be empty)
629 1 — invalid --format value
630 2 — not inside a Muse repository
631 """
632 fmt: str = args.fmt
633
634 _check_format(fmt)
635
636 root = require_repo()
637 records = list_records(root)
638 state = read_merge_state(root)
639
640 # Build the set of fingerprints that match current conflicts.
641 current_fps: set[str] = set()
642 if state and state.conflict_paths:
643 manifests = _load_conflict_manifests(root)
644 if manifests is not None:
645 ours_manifest, theirs_manifest = manifests
646 plugin = resolve_plugin(root)
647 for path in state.conflict_paths:
648 ours_id = ours_manifest.get(path, "")
649 theirs_id = theirs_manifest.get(path, "")
650 if ours_id and theirs_id:
651 fp = compute_fingerprint(path, ours_id, theirs_id, plugin, root)
652 current_fps.add(fp)
653
654 if fmt == "json":
655 status_records: list[_RerereStatusRecordJson] = [
656 _RerereStatusRecordJson(
657 fingerprint=sanitize_display(r.fingerprint),
658 path=sanitize_display(r.path),
659 domain=sanitize_display(r.domain),
660 has_resolution=r.has_resolution,
661 resolution_id=sanitize_display(r.resolution_id) if r.resolution_id else None,
662 recorded_at=r.recorded_at.isoformat(),
663 matches_current_conflict=r.fingerprint in current_fps,
664 )
665 for r in records
666 ]
667 print(json.dumps(_RerereStatusJson(total=len(records), records=status_records)))
668 return
669
670 if not records:
671 print("No rerere records found.")
672 return
673
674 print(f"{'fingerprint':14} {'status':16} {'recorded':16} path")
675 print("-" * 72)
676 for rec in records:
677 marker = " ◀ current" if rec.fingerprint in current_fps else ""
678 print(_fmt_record(rec) + marker)
679
680
681 # ---------------------------------------------------------------------------
682 # forget — remove cached resolution for specific paths or fingerprints
683 # ---------------------------------------------------------------------------
684
685
686 def run_forget(args: argparse.Namespace) -> None:
687 """Remove the cached rerere resolution for specific paths or fingerprints.
688
689 Two modes:
690
691 **Path mode** (requires an active merge): computes the conflict fingerprint
692 from each ``PATH`` against the current MERGE_STATE and removes its
693 rr-cache entry.
694
695 **Fingerprint mode** (``--fingerprint HEX``): removes the rr-cache entry
696 for the given 64-char hex fingerprint directly. Does not require an
697 active merge. Useful for agents that have stored fingerprints from a
698 previous ``muse rerere status --json`` call.
699
700 Run this when a recorded resolution is wrong and you want to force manual
701 resolution on the next identical conflict.
702
703 JSON schema::
704
705 {
706 "forgotten": ["<path | hex64>", ...],
707 "not_found": ["<path | hex64>", ...]
708 }
709
710 Exit codes:
711 0 — forget completed (some items may appear in not_found)
712 1 — no args given; invalid format; no active merge in path mode
713 2 — not inside a Muse repository
714 """
715 paths: list[str] = args.paths
716 fingerprints: list[str] | None = args.fingerprints
717 fmt: str = args.fmt
718
719 _check_format(fmt)
720
721 if not paths and not fingerprints:
722 print(
723 "❌ Provide at least one PATH or --fingerprint HEX.",
724 file=sys.stderr,
725 )
726 raise SystemExit(ExitCode.USER_ERROR)
727
728 root = require_repo()
729
730 forgotten: list[str] = []
731 not_found: list[str] = []
732
733 # -- Fingerprint mode (no active merge required) --------------------------
734 if fingerprints:
735 for fp in fingerprints:
736 if forget_record(root, fp):
737 forgotten.append(fp)
738 else:
739 not_found.append(fp)
740
741 # -- Path mode (requires active merge state) ------------------------------
742 if paths:
743 state = read_merge_state(root)
744 if state is None or not state.conflict_paths:
745 print(
746 "❌ No merge in progress — cannot determine conflict fingerprints from paths. "
747 "Use --fingerprint instead.",
748 file=sys.stderr,
749 )
750 raise SystemExit(ExitCode.USER_ERROR)
751
752 manifests = _load_conflict_manifests(root)
753 if manifests is None:
754 print("❌ MERGE_STATE.json is incomplete.", file=sys.stderr)
755 raise SystemExit(ExitCode.USER_ERROR)
756 ours_manifest, theirs_manifest = manifests
757 plugin = resolve_plugin(root)
758
759 for path in paths:
760 ours_id = ours_manifest.get(path, "")
761 theirs_id = theirs_manifest.get(path, "")
762 if not ours_id or not theirs_id:
763 not_found.append(path)
764 continue
765 fp = compute_fingerprint(path, ours_id, theirs_id, plugin, root)
766 if forget_record(root, fp):
767 forgotten.append(path)
768 else:
769 not_found.append(path)
770
771 if fmt == "json":
772 print(json.dumps(_RerereForgetJson(
773 forgotten=[sanitize_display(x) for x in forgotten],
774 not_found=[sanitize_display(x) for x in not_found],
775 )))
776 return
777
778 for item in forgotten:
779 print(f" 🗑 forgot: {sanitize_display(item)}")
780 for item in not_found:
781 print(f" ⚠️ no record found: {sanitize_display(item)}")
782
783
784 # ---------------------------------------------------------------------------
785 # clear — remove all cached resolutions
786 # ---------------------------------------------------------------------------
787
788
789 def run_clear(args: argparse.Namespace) -> None:
790 """Remove all cached rerere resolutions.
791
792 Deletes the entire ``.muse/rr-cache/`` directory contents. This is
793 irreversible — all recorded resolutions will be lost.
794
795 Pass ``--yes`` to suppress the interactive confirmation prompt (required
796 for non-interactive agents).
797
798 JSON schema::
799
800 {"removed": <int>}
801
802 Exit codes:
803 0 — clear completed (removed may be 0 if cache was empty)
804 1 — invalid --format value
805 2 — not inside a Muse repository
806 """
807 yes: bool = args.yes
808 fmt: str = args.fmt
809
810 _check_format(fmt)
811
812 root = require_repo()
813
814 if not yes:
815 cache = rr_cache_dir(root)
816 # Mirror clear_all's logic: skip symlinks so the count matches what
817 # will actually be removed.
818 count = (
819 sum(1 for e in cache.iterdir() if not e.is_symlink() and e.is_dir())
820 if cache.exists()
821 else 0
822 )
823 if count == 0:
824 if fmt == "json":
825 print(json.dumps(_RerereScalarJson(removed=0)))
826 else:
827 print("rr-cache is already empty.")
828 return
829 confirmed = input(
830 f"This will permanently delete {count} rerere record(s). Continue? [y/N]: "
831 ).strip().lower() in ("y", "yes")
832 if not confirmed:
833 print("Aborted.")
834 return
835
836 removed = clear_all(root)
837
838 if fmt == "json":
839 print(json.dumps(_RerereScalarJson(removed=removed)))
840 return
841
842 print(f"✅ Cleared {removed} rerere record(s).")
843
844
845 # ---------------------------------------------------------------------------
846 # gc — garbage-collect stale preimage-only entries
847 # ---------------------------------------------------------------------------
848
849
850 def run_gc(args: argparse.Namespace) -> None:
851 """Remove preimage-only rerere entries older than ``--age`` days.
852
853 Keeps all entries that have a resolution saved (regardless of age).
854 Removes entries where the user never committed a resolution — these are
855 conflicts that were abandoned or resolved in another way.
856
857 The default age threshold is 60 days. Pass ``--age DAYS`` to customise.
858 Valid range: 1–36 500 days.
859
860 JSON schema::
861
862 {"removed": <int>}
863
864 Exit codes:
865 0 — gc completed (removed may be 0)
866 1 — invalid --age value or --format value
867 2 — not inside a Muse repository
868 """
869 fmt: str = args.fmt
870 try:
871 age_days: int = clamp_int(args.age, 1, 36_500, "age")
872 except ValueError as exc:
873 print(f"❌ {exc}", file=sys.stderr)
874 raise SystemExit(ExitCode.USER_ERROR)
875
876 _check_format(fmt)
877
878 root = require_repo()
879 removed = gc_stale(root, age_days=age_days)
880
881 if fmt == "json":
882 print(json.dumps(_RerereScalarJson(removed=removed)))
883 return
884
885 if removed:
886 print(f"✅ gc: removed {removed} stale preimage-only entry(s) older than {age_days} day(s).")
887 else:
888 print(f"gc: nothing to remove (threshold: {age_days} day(s)).")
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago