checkout.py python
1,097 lines 44.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
1 """``muse checkout`` — switch branches or restore working tree from a commit.
2
3 Usage::
4
5 muse checkout <branch> — switch to existing branch
6 muse checkout -b <branch> — create and switch to new branch
7 muse checkout <commit-id> — detach HEAD at a specific commit
8 muse checkout --ours <file> — resolve conflict by accepting our version
9 muse checkout --theirs <file> — resolve conflict by accepting their version
10 muse checkout --dry-run <branch> — show what would happen without switching
11
12 Conflict resolution
13 -------------------
14 After a ``muse merge`` that leaves conflicts, use ``--ours`` or ``--theirs``
15 to accept one side of each conflicted file. The file is restored from the
16 appropriate commit in MERGE_STATE.json and removed from the unresolved conflict
17 list. Once all conflicts are cleared, run ``muse commit`` to complete the merge.
18
19 # Accept our version of AGENTS.md (feature branch wins)
20 muse checkout --ours AGENTS.md
21
22 # Accept their version of pyproject.toml (main branch wins)
23 muse checkout --theirs pyproject.toml
24
25 # Bulk-resolve every conflict to one side
26 muse checkout --ours --all
27 muse checkout --theirs --all
28
29 # Check remaining conflicts
30 muse status
31
32 # Complete the merge
33 muse commit -m "merge: resolve conflicts"
34
35 JSON output (``--format json`` or ``--json``)::
36
37 {
38 "action": "created|switched|detached|already_on",
39 "branch": "<name> | null",
40 "commit_id": "<sha256>",
41 "from_branch": "<previous-branch>"
42 }
43
44 Exit codes::
45
46 0 — success
47 1 — invalid branch name, branch not found, dirty working tree (without --force)
48 3 — internal error (missing snapshot or object)
49 """
50
51 from __future__ import annotations
52
53 import argparse
54 import json
55 import logging
56 import pathlib
57 import sys
58
59 from muse.core.errors import ExitCode
60 from muse.core.merge_engine import read_merge_state, write_merge_state
61 from muse.core.object_store import has_object, read_object, restore_object
62 from muse.core.repo import read_repo_id, require_repo
63 from muse.core.snapshot import diff_workdir_vs_snapshot
64 from muse.core.store import (
65 get_head_commit_id,
66 get_head_snapshot_id,
67 get_head_snapshot_manifest,
68 read_commit,
69 read_current_branch,
70 read_snapshot,
71 resolve_commit_ref,
72 write_branch_ref,
73 write_head_branch,
74 write_head_commit,
75 write_text_atomic,
76 )
77 from muse.core.reflog import append_reflog
78 from muse.core.validation import contain_path, sanitize_display, validate_branch_name
79 from muse.cli.guard import require_clean_workdir
80 from muse.cli.commands.stash import _stash_push_programmatic, _stash_pop_programmatic
81 from muse.core.snapshot import directories_from_manifest
82 from muse.core.cohen_transform import three_way_merge_lines
83 from muse.domain import SnapshotManifest
84 from muse.plugins.registry import read_domain, resolve_plugin
85 from muse.core._types import Manifest
86
87 logger = logging.getLogger(__name__)
88
89
90 _CHECKOUT_HEAD_FILENAME = "CHECKOUT_HEAD"
91
92
93 def _apply_autostash(root: pathlib.Path, fmt: str) -> None:
94 """Pop the autostash and emit a git-idiomatic status line.
95
96 Called unconditionally from the ``finally`` block in :func:`run` when an
97 autostash was created. On conflict the stash is left intact at index 0 so
98 the user can resolve and ``muse stash pop`` manually.
99
100 Args:
101 root: Repository root containing ``.muse/``.
102 fmt: Output format (``"text"`` or ``"json"``). In JSON mode all
103 autostash messages go to **stderr** so stdout stays machine-
104 readable.
105 """
106 try:
107 entry = _stash_pop_programmatic(root)
108 files_n = len(entry["delta"]) + len(entry["deleted"])
109 if fmt == "text":
110 # Mirrors git: "Applied autostash."
111 print(f"Applied autostash ({files_n} file(s) restored).")
112 else:
113 # Keep stdout clean; emit to stderr so scripts can detect it.
114 print(f"autostash: applied ({files_n} file(s) restored)", file=sys.stderr)
115 except ValueError as exc:
116 # Empty stash or bad index — shouldn't happen since we just pushed,
117 # but handle gracefully.
118 msg = f"⚠️ autostash pop failed — stash may be empty: {exc}"
119 print(msg, file=sys.stderr)
120 except RuntimeError as exc:
121 # Missing objects in store. Stash is still there at index 0.
122 print(
123 f"⚠️ autostash: could not restore changes — object store error: {exc}\n"
124 f" Your changes are safe in stash@{{0}} — run 'muse stash pop' to restore them.",
125 file=sys.stderr,
126 )
127
128
129 def checkout_head_path(root: pathlib.Path) -> pathlib.Path:
130 """Return the path to ``.muse/CHECKOUT_HEAD``.
131
132 This file exists only while a checkout is in progress. Its presence
133 after a crash means the working tree may be partially mutated and
134 ``muse status`` should warn the user.
135 """
136 return root / ".muse" / _CHECKOUT_HEAD_FILENAME
137
138
139 def read_checkout_head(root: pathlib.Path) -> str | None:
140 """Return the target branch recorded in ``.muse/CHECKOUT_HEAD``, or ``None``.
141
142 ``None`` means no checkout is in progress (normal state).
143 A non-``None`` value means a previous checkout was interrupted and the
144 working tree may be partially mutated.
145 """
146 p = checkout_head_path(root)
147 try:
148 return p.read_text(encoding="utf-8").strip() or None
149 except FileNotFoundError:
150 return None
151
152
153 def _checkout_snapshot(
154 root: pathlib.Path,
155 target_snapshot_id: str,
156 current_snapshot_id: str | None,
157 *,
158 target_branch: str | None = None,
159 ) -> None:
160 """Incrementally update the working tree from the current to the target snapshot.
161
162 Uses the domain plugin to compute the delta between the two snapshots and
163 only touches files that actually changed — removing deleted paths and
164 restoring added/modified ones from the object store. Calls
165 ``plugin.apply()`` as the domain-level post-checkout hook.
166
167 Pre-flight integrity guarantee
168 Before mutating any file on disk, every object that must be restored
169 is verified to exist in the local store via :func:`has_object`. If
170 any object is missing the function prints a diagnostic and raises
171 ``SystemExit`` **without touching the working tree**.
172
173 CHECKOUT_HEAD marker
174 ``.muse/CHECKOUT_HEAD`` is written with *target_branch* (or the
175 snapshot ID when no branch name is available) before the first file
176 mutation. It is deleted atomically on success. If the process is
177 killed mid-checkout ``muse status`` detects the marker and warns the
178 user, preventing silent data loss from being misread as uncommitted
179 deletions.
180
181 Raises ``SystemExit(ExitCode.INTERNAL_ERROR)`` if the target snapshot or
182 any required object is missing from the local store.
183 """
184 plugin = resolve_plugin(root)
185 domain = read_domain(root)
186
187 target_snap_rec = read_snapshot(root, target_snapshot_id)
188 if target_snap_rec is None:
189 print(
190 f"❌ Snapshot {target_snapshot_id[:8]} not found in object store.",
191 file=sys.stderr,
192 )
193 raise SystemExit(ExitCode.INTERNAL_ERROR)
194
195 target_snap = SnapshotManifest(
196 files=target_snap_rec.manifest,
197 domain=domain,
198 directories=list(target_snap_rec.directories),
199 )
200
201 cur_rec = None
202 if current_snapshot_id is not None:
203 cur_rec = read_snapshot(root, current_snapshot_id)
204 current_snap = (
205 SnapshotManifest(
206 files=cur_rec.manifest,
207 domain=domain,
208 directories=list(cur_rec.directories),
209 )
210 if cur_rec
211 else SnapshotManifest(files={}, domain=domain, directories=[])
212 )
213 else:
214 current_snap = SnapshotManifest(files={}, domain=domain, directories=[])
215
216 delta = plugin.diff(current_snap, target_snap)
217 cur_manifest: Manifest = cur_rec.manifest if cur_rec else {}
218
219 # ── Gap 1: Pre-flight object existence check ──────────────────────────────
220 # Build the list of file paths that need to be restored (insert / replace /
221 # patch ops whose address is a real file in the target manifest, not a
222 # directory entry). Then verify every required object exists in the local
223 # store BEFORE touching a single file on disk. If anything is missing we
224 # abort here — zero mutations to the working tree.
225 to_restore = [
226 op["address"] for op in delta["ops"]
227 if op["op"] in ("insert", "replace", "patch")
228 and op["address"] in target_snap_rec.manifest
229 ]
230 preflight_missing = [
231 rel_path for rel_path in to_restore
232 if not has_object(root, target_snap_rec.manifest[rel_path])
233 ]
234 if preflight_missing:
235 for rel_path in preflight_missing:
236 object_id = target_snap_rec.manifest[rel_path]
237 print(
238 f"❌ Object {object_id[:8]} for '{sanitize_display(rel_path)}' "
239 "is missing from the local object store.",
240 file=sys.stderr,
241 )
242 print(
243 f"❌ Checkout aborted: {len(preflight_missing)} object(s) not in local "
244 "store. Fetch missing objects from the remote before retrying.\n"
245 " Working tree was NOT modified.",
246 file=sys.stderr,
247 )
248 raise SystemExit(ExitCode.INTERNAL_ERROR)
249
250 # ── Gap 2: Write CHECKOUT_HEAD before first mutation ─────────────────────
251 # Record the intended target so `muse status` can detect an interrupted
252 # checkout and warn the user rather than silently reporting missing files
253 # as uncommitted deletions.
254 marker_path = checkout_head_path(root)
255 marker_label = target_branch or target_snapshot_id
256 write_text_atomic(marker_path, marker_label + "\n")
257
258 # ── Remove files that no longer exist in the target snapshot ─────────────
259 # Skip directory-level delete ops — only actual files (addresses present in
260 # the current manifest) are unlinked here.
261 removed = [
262 op["address"] for op in delta["ops"]
263 if op["op"] == "delete" and op["address"] in cur_manifest
264 ]
265 for rel_path in removed:
266 fp = root / rel_path
267 if fp.exists() and fp.is_file():
268 fp.unlink()
269
270 # ── Restore added and modified files from the content-addressed store ─────
271 # All objects were verified present above; restore_object should not return
272 # False here. A second-layer check is kept as a defensive catch for races
273 # (e.g. another process pruning the store mid-checkout).
274 restore_failures: list[str] = []
275 for rel_path in to_restore:
276 object_id = target_snap_rec.manifest[rel_path]
277 try:
278 safe_dest = contain_path(root, rel_path)
279 except ValueError as exc:
280 logger.warning("⚠️ Skipping unsafe manifest path %r: %s", rel_path, exc)
281 continue
282 if not restore_object(root, object_id, safe_dest):
283 restore_failures.append(rel_path)
284
285 if restore_failures:
286 for rel_path in restore_failures:
287 object_id = target_snap_rec.manifest[rel_path]
288 print(
289 f"❌ Object {object_id[:8]} for '{sanitize_display(rel_path)}' "
290 "disappeared from the store mid-checkout (store was modified "
291 "concurrently?).",
292 file=sys.stderr,
293 )
294 print(
295 f"❌ Checkout incomplete: {len(restore_failures)} object(s) could not "
296 "be restored. Run `muse checkout <branch>` to retry.",
297 file=sys.stderr,
298 )
299 # CHECKOUT_HEAD remains on disk — muse status will detect the
300 # interrupted state and guide the user.
301 raise SystemExit(ExitCode.INTERNAL_ERROR)
302
303 # ── Domain-level post-checkout hook ───────────────────────────────────────
304 plugin.apply(delta, root)
305
306 # ── Clear CHECKOUT_HEAD on success ────────────────────────────────────────
307 marker_path.unlink(missing_ok=True)
308
309
310 def _checkout_with_merge(
311 root: pathlib.Path,
312 target: str,
313 current_branch: str,
314 repo_id: str,
315 *,
316 fmt: str,
317 dry_run: bool,
318 ) -> dict[str, object]:
319 """Perform a branch switch carrying uncommitted changes via three-way merge.
320
321 Implements ``muse checkout -m`` (the Cohen Transform checkout):
322
323 1. Snapshot the dirty working-tree files (modified + deleted tracked files).
324 2. Apply the target-branch snapshot to the working tree normally.
325 3. For each dirty file, three-way merge:
326 base = HEAD version (what we last committed)
327 ours = working-tree version (your uncommitted edit)
328 theirs = target-branch version
329 4. Clean merges: write merged content silently.
330 5. Conflicts: write diff3 + Manyana-labeled markers inline; record in
331 MERGE_STATE.json (same format as ``muse merge`` conflicts so
332 ``muse checkout --ours/--theirs`` works identically).
333
334 Binary files with conflicts are left at the target-branch version and
335 listed as conflicts; the user must resolve them manually.
336
337 Args:
338 root: Repository root.
339 target: Target branch name.
340 current_branch: Current branch name.
341 repo_id: Repository ID string.
342 fmt: Output format (``'text'`` or ``'json'``).
343 dry_run: When ``True``, report what would happen without writing.
344
345 Returns:
346 A result dict suitable for JSON serialisation with keys:
347 ``action``, ``branch``, ``from_branch``, ``commit_id``,
348 ``clean_merges``, ``conflicts``, ``dry_run``.
349 """
350 # ── Capture current dirty state ───────────────────────────────────────────
351 current_snapshot_id = get_head_snapshot_id(root, repo_id, current_branch)
352 head_manifest: dict[str, str] = {}
353 if current_snapshot_id:
354 snap_rec = read_snapshot(root, current_snapshot_id)
355 if snap_rec:
356 head_manifest = dict(snap_rec.manifest)
357
358 _added, modified, deleted, _untracked, _ad, _dd = diff_workdir_vs_snapshot(
359 root, head_manifest
360 )
361 dirty_paths = modified | deleted
362
363 # Read the working-tree content for every modified file BEFORE the
364 # checkout overwrites the working tree.
365 workdir_content: dict[str, bytes] = {}
366 for rel_path in dirty_paths:
367 fp = root / rel_path
368 if fp.is_file():
369 workdir_content[rel_path] = fp.read_bytes()
370 else:
371 workdir_content[rel_path] = b"" # deleted
372
373 if dry_run:
374 target_commit_id = get_head_commit_id(root, target) or ""
375 return {
376 "dry_run": True,
377 "action": "switched",
378 "branch": target,
379 "from_branch": current_branch,
380 "commit_id": target_commit_id,
381 "dirty_paths": sorted(dirty_paths),
382 "clean_merges": [],
383 "conflicts": [],
384 }
385
386 # ── Apply the target snapshot (switches the branch normally) ─────────────
387 target_snapshot_id = get_head_snapshot_id(root, repo_id, target)
388 if target_snapshot_id:
389 _checkout_snapshot(root, target_snapshot_id, current_snapshot_id, target_branch=target)
390
391 target_commit_id = get_head_commit_id(root, target) or ""
392
393 write_head_branch(root, target)
394 append_reflog(
395 root, target,
396 old_id=get_head_commit_id(root, current_branch) or None,
397 new_id=target_commit_id or ("0" * 64),
398 author="user",
399 operation=(
400 f"checkout -m: carrying changes from {sanitize_display(current_branch)} "
401 f"to {sanitize_display(target)}"
402 ),
403 )
404
405 if not dirty_paths:
406 return {
407 "dry_run": False,
408 "action": "switched",
409 "branch": target,
410 "from_branch": current_branch,
411 "commit_id": target_commit_id,
412 "clean_merges": [],
413 "conflicts": [],
414 }
415
416 # ── Three-way merge each dirty file (the Cohen Transform) ─────────────────
417 # Read the target snapshot manifest so we know theirs content.
418 target_manifest: dict[str, str] = {}
419 if target_snapshot_id:
420 target_snap_rec = read_snapshot(root, target_snapshot_id)
421 if target_snap_rec:
422 target_manifest = dict(target_snap_rec.manifest)
423
424 clean_merges: list[str] = []
425 conflicts: list[str] = []
426
427 ours_label = sanitize_display(current_branch)
428 theirs_label = sanitize_display(target)
429
430 for rel_path in sorted(dirty_paths):
431 # base = HEAD version (from the object store)
432 base_oid = head_manifest.get(rel_path)
433 base_bytes = read_object(root, base_oid) if base_oid else b""
434 base_bytes = base_bytes or b""
435
436 # ours = working-tree content captured before checkout
437 ours_bytes = workdir_content.get(rel_path, b"")
438
439 # theirs = target-branch version (now on disk after checkout)
440 theirs_oid = target_manifest.get(rel_path)
441 theirs_bytes = read_object(root, theirs_oid) if theirs_oid else b""
442 theirs_bytes = theirs_bytes or b""
443
444 # Detect binary files: skip line-merge, treat as conflict.
445 def _is_binary(b: bytes) -> bool:
446 return b"\x00" in b[:8000]
447
448 if _is_binary(base_bytes) or _is_binary(ours_bytes) or _is_binary(theirs_bytes):
449 # Binary conflict: leave theirs (already on disk), record conflict.
450 conflicts.append(rel_path)
451 continue
452
453 # Line-level three-way merge via the Cohen Transform.
454 base_lines = base_bytes.decode("utf-8", errors="replace").splitlines(keepends=True)
455 ours_lines = ours_bytes.decode("utf-8", errors="replace").splitlines(keepends=True)
456 theirs_lines = theirs_bytes.decode("utf-8", errors="replace").splitlines(keepends=True)
457
458 merged_lines, has_conflict = three_way_merge_lines(
459 base_lines, ours_lines, theirs_lines,
460 label_ours=f"{ours_label} (uncommitted)",
461 label_base="base (HEAD)",
462 label_theirs=f"{theirs_label}",
463 )
464
465 dest = root / rel_path
466 dest.parent.mkdir(parents=True, exist_ok=True)
467 write_text_atomic(dest, "".join(merged_lines))
468
469 if has_conflict:
470 conflicts.append(rel_path)
471 else:
472 clean_merges.append(rel_path)
473
474 # ── Record MERGE_STATE when conflicts remain ───────────────────────────────
475 if conflicts:
476 ours_commit_id = get_head_commit_id(root, current_branch) or ""
477 write_merge_state(
478 root,
479 base_commit=ours_commit_id,
480 ours_commit=ours_commit_id,
481 theirs_commit=target_commit_id,
482 conflict_paths=conflicts,
483 other_branch=target,
484 )
485
486 return {
487 "dry_run": False,
488 "action": "switched",
489 "branch": target,
490 "from_branch": current_branch,
491 "commit_id": target_commit_id,
492 "clean_merges": clean_merges,
493 "conflicts": conflicts,
494 }
495
496
497 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
498 """Register the ``muse checkout`` subcommand and all its flags."""
499 parser = subparsers.add_parser(
500 "checkout",
501 help="Switch branches or restore working tree from a commit.",
502 description=__doc__,
503 formatter_class=argparse.RawDescriptionHelpFormatter,
504 )
505 parser.add_argument(
506 "target", nargs="?",
507 help="Branch name, commit ID, or (with --ours/--theirs) file path to resolve.",
508 )
509 parser.add_argument(
510 "-b", "--create", action="store_true",
511 help="Create a new branch at HEAD and switch to it.",
512 )
513 parser.add_argument(
514 "--force", "-f", action="store_true",
515 help="Discard uncommitted changes and force the switch.",
516 )
517 parser.add_argument(
518 "--dry-run", "-n", action="store_true", dest="dry_run",
519 help=(
520 "Show what would happen without making any changes. "
521 "Exits 0 if the switch would succeed, 1 if it would be blocked."
522 ),
523 )
524 parser.add_argument(
525 "--format", default="text", dest="fmt",
526 help="Output format: text (default) or json.",
527 )
528 parser.add_argument(
529 "--json", action="store_const", const="json", dest="fmt",
530 help="Shorthand for --format json.",
531 )
532 parser.add_argument(
533 "--ours",
534 dest="resolve_ours",
535 action="store_true",
536 help=(
537 "Resolve conflict(s) by restoring from our branch (HEAD at merge time). "
538 "Use with a file path to resolve one path, or --all to resolve every conflict."
539 ),
540 )
541 parser.add_argument(
542 "--theirs",
543 dest="resolve_theirs",
544 action="store_true",
545 help=(
546 "Resolve conflict(s) by restoring from the branch being merged in. "
547 "Use with a file path to resolve one path, or --all to resolve every conflict."
548 ),
549 )
550 parser.add_argument(
551 "--all",
552 dest="resolve_all",
553 action="store_true",
554 help=(
555 "Resolve ALL unresolved conflicts at once using --ours or --theirs. "
556 "Requires exactly one of --ours or --theirs."
557 ),
558 )
559 parser.add_argument(
560 "--merge", "-m",
561 dest="merge",
562 action="store_true",
563 help=(
564 "Carry uncommitted working-tree changes into the target branch using "
565 "a three-way merge (the Cohen Transform). "
566 "Files that merge cleanly are updated in-place; files that conflict "
567 "receive diff3-style markers with Manyana action labels "
568 "(e.g. ``<<<<<<< ours [deleted]``) and the merge is recorded in "
569 "MERGE_STATE.json for resolution with "
570 "``muse checkout --ours/--theirs``. "
571 "Inspired by ``git checkout -m`` and Bram Cohen's Manyana project."
572 ),
573 )
574 parser.add_argument(
575 "--autostash",
576 dest="autostash",
577 action="store_true",
578 help=(
579 "Automatically stash uncommitted changes before switching branches "
580 "and pop them back onto the working tree after the switch succeeds. "
581 "Equivalent to running ``muse stash`` before checkout and "
582 "``muse stash pop`` after. "
583 "Mutually exclusive with --force and --merge."
584 ),
585 )
586 parser.set_defaults(func=run, merge=False, autostash=False)
587
588
589 def run(args: argparse.Namespace) -> None:
590 """Switch branches or restore working tree from a commit.
591
592 All error messages are written to **stderr**; stdout is reserved for
593 structured output only.
594
595 Agents should pass ``--format json`` to receive a machine-readable result::
596
597 {
598 "action": "created|switched|detached|already_on",
599 "branch": "<name> | null",
600 "commit_id": "<sha256>",
601 "from_branch": "<previous-branch>"
602 }
603
604 ``--dry-run`` (``-n``) is agent-safe: it exits 0 when the switch would
605 succeed and 1 when it would be blocked (dirty tree, missing snapshot, …).
606 When combined with ``--format json`` the JSON payload includes
607 ``"dry_run": true``.
608
609 Conflict resolution::
610
611 # resolve one path
612 muse checkout --ours src/billing.py
613 muse checkout --theirs src/billing.py
614
615 # resolve every conflict at once
616 muse checkout --ours --all
617 muse checkout --theirs --all
618
619 Exit codes::
620
621 0 — success
622 1 — invalid branch name, branch not found, dirty working tree (without --force)
623 3 — internal error (missing snapshot or object)
624 """
625 target: str | None = args.target
626 create: bool = args.create
627 force: bool = args.force
628 dry_run: bool = args.dry_run
629 merge: bool = getattr(args, "merge", False)
630 autostash: bool = getattr(args, "autostash", False)
631 fmt: str = args.fmt
632 resolve_ours: bool = args.resolve_ours
633 resolve_theirs: bool = args.resolve_theirs
634 resolve_all: bool = args.resolve_all
635
636 if fmt not in ("text", "json"):
637 print(
638 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
639 file=sys.stderr,
640 )
641 raise SystemExit(ExitCode.USER_ERROR)
642
643 if autostash and force:
644 print("❌ --autostash and --force are mutually exclusive.", file=sys.stderr)
645 raise SystemExit(ExitCode.USER_ERROR)
646
647 if autostash and merge:
648 print("❌ --autostash and --merge are mutually exclusive.", file=sys.stderr)
649 raise SystemExit(ExitCode.USER_ERROR)
650
651 # ── Conflict resolution mode ──────────────────────────────────────────────
652 if resolve_ours or resolve_theirs:
653 if resolve_ours and resolve_theirs:
654 print("❌ Cannot specify both --ours and --theirs.", file=sys.stderr)
655 raise SystemExit(ExitCode.USER_ERROR)
656
657 if not resolve_all and not target:
658 print(
659 "❌ Specify a file path, or use --all to resolve every conflict.",
660 file=sys.stderr,
661 )
662 raise SystemExit(ExitCode.USER_ERROR)
663
664 root = require_repo()
665 merge_state = read_merge_state(root)
666 if merge_state is None:
667 print(
668 "❌ No merge in progress. --ours/--theirs only work during a conflicted merge.",
669 file=sys.stderr,
670 )
671 raise SystemExit(ExitCode.USER_ERROR)
672
673 side_label = "ours" if resolve_ours else "theirs"
674 source_commit_id = merge_state.ours_commit if resolve_ours else merge_state.theirs_commit
675 if not source_commit_id:
676 print(f"❌ MERGE_STATE has no {side_label} commit recorded.", file=sys.stderr)
677 raise SystemExit(ExitCode.INTERNAL_ERROR)
678
679 source_commit = read_commit(root, source_commit_id)
680 if source_commit is None:
681 print(
682 f"❌ Source commit {source_commit_id[:8]} not found in object store.",
683 file=sys.stderr,
684 )
685 raise SystemExit(ExitCode.INTERNAL_ERROR)
686
687 snap = read_snapshot(root, source_commit.snapshot_id)
688 if snap is None:
689 print(
690 f"❌ Snapshot for commit {source_commit_id[:8]} not found.",
691 file=sys.stderr,
692 )
693 raise SystemExit(ExitCode.INTERNAL_ERROR)
694
695 # ── Determine which conflict paths to resolve ─────────────────────
696 if resolve_all:
697 paths_to_resolve = list(merge_state.conflict_paths)
698 if not paths_to_resolve:
699 if fmt == "json":
700 print(json.dumps({
701 "action": "conflict_resolved_all",
702 "side": side_label,
703 "resolved_count": 0,
704 "remaining_conflicts": 0,
705 }))
706 else:
707 print("✅ No conflicts to resolve — working tree is already clean.")
708 return
709 else:
710 file_path = (target or "").replace("\\", "/")
711 matching = [
712 p for p in merge_state.conflict_paths
713 if p == file_path or p.startswith(f"{file_path}::")
714 ]
715 if not matching:
716 print(
717 f"ℹ️ '{sanitize_display(file_path)}' is not in the conflict list "
718 f"— may already be resolved."
719 )
720 return
721 paths_to_resolve = matching
722
723 # ── Apply resolutions ─────────────────────────────────────────────
724 resolved: list[str] = []
725 for conflict_path in paths_to_resolve:
726 # Extract the bare file path (strip symbol qualifier if present).
727 bare_file = conflict_path.split("::")[0] if "::" in conflict_path else conflict_path
728
729 if bare_file in snap.manifest:
730 object_id = snap.manifest[bare_file]
731 try:
732 safe_dest = contain_path(root, bare_file)
733 except ValueError as exc:
734 print(
735 f"❌ Unsafe path '{sanitize_display(bare_file)}': {exc}",
736 file=sys.stderr,
737 )
738 continue
739 if not restore_object(root, object_id, safe_dest):
740 print(
741 f"❌ Object {object_id[:8]} for '{sanitize_display(bare_file)}' "
742 f"not in local store.",
743 file=sys.stderr,
744 )
745 continue
746 else:
747 # File doesn't exist on the chosen side — delete it.
748 target_path = root / bare_file
749 if target_path.exists():
750 target_path.unlink()
751
752 resolved.append(conflict_path)
753
754 # ── Update MERGE_STATE ────────────────────────────────────────────
755 remaining = [p for p in merge_state.conflict_paths if p not in resolved]
756 write_merge_state(
757 root,
758 base_commit=merge_state.base_commit or "",
759 ours_commit=merge_state.ours_commit or "",
760 theirs_commit=merge_state.theirs_commit or "",
761 conflict_paths=remaining,
762 other_branch=merge_state.other_branch,
763 )
764
765 if resolve_all:
766 if fmt == "json":
767 print(json.dumps({
768 "action": "conflict_resolved_all",
769 "side": side_label,
770 "resolved_count": len(resolved),
771 "remaining_conflicts": len(remaining),
772 }))
773 else:
774 print(f"✅ Resolved {len(resolved)} conflict(s) using {side_label}.")
775 if remaining:
776 print(
777 f" {len(remaining)} could not be resolved automatically "
778 f"— run 'muse conflicts' to inspect."
779 )
780 else:
781 print(" All conflicts resolved. Run 'muse commit' to complete the merge.")
782 else:
783 file_path = (target or "").replace("\\", "/")
784 if fmt == "json":
785 print(json.dumps({
786 "action": "conflict_resolved",
787 "file": file_path,
788 "side": side_label,
789 "remaining_conflicts": len(remaining),
790 }))
791 else:
792 print(f"✅ Resolved '{sanitize_display(file_path)}' using {side_label}.")
793 if remaining:
794 print(
795 f" {len(remaining)} conflict(s) remaining — "
796 f"run 'muse conflicts' to see them."
797 )
798 else:
799 print(" All conflicts resolved. Run 'muse commit' to complete the merge.")
800 return
801 # ── End conflict resolution mode ─────────────────────────────────────────
802
803 if target is None:
804 print(
805 "❌ Specify a branch, commit ID, or use --ours/--theirs with a file path.",
806 file=sys.stderr,
807 )
808 raise SystemExit(ExitCode.USER_ERROR)
809
810 root = require_repo()
811 repo_id = read_repo_id(root)
812 current_branch = read_current_branch(root)
813 muse_dir = root / ".muse"
814
815 # ── --merge: carry uncommitted changes via the Cohen Transform ────────────
816 # Bypass the clean-workdir guard and instead three-way merge each dirty
817 # file into the target branch context. Only valid for branch switches
818 # (not -b/--create, not detached-HEAD checkouts).
819 if merge and not create and target is not None:
820 try:
821 validate_branch_name(target)
822 except ValueError as exc:
823 print(
824 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
825 file=sys.stderr,
826 )
827 raise SystemExit(ExitCode.USER_ERROR)
828
829 ref_file = muse_dir / "refs" / "heads" / target
830 if not ref_file.exists():
831 print(
832 f"❌ Branch '{sanitize_display(target)}' not found. "
833 f"--merge only works with existing branches.",
834 file=sys.stderr,
835 )
836 raise SystemExit(ExitCode.USER_ERROR)
837
838 if target == current_branch:
839 print(f"Already on '{sanitize_display(target)}'")
840 return
841
842 result = _checkout_with_merge(
843 root, target, current_branch, repo_id,
844 fmt=fmt, dry_run=dry_run,
845 )
846
847 conflicts: list[str] = result.get("conflicts", []) # type: ignore[assignment]
848 clean_merges: list[str] = result.get("clean_merges", []) # type: ignore[assignment]
849
850 if fmt == "json":
851 print(json.dumps(result))
852 else:
853 if dry_run:
854 print(f"Would switch to branch '{sanitize_display(target)}' carrying changes")
855 else:
856 print(f"Switched to branch '{sanitize_display(target)}'")
857 if clean_merges:
858 print(f" Merged cleanly: {len(clean_merges)} file(s)")
859 for p in clean_merges:
860 print(f" ✔ {sanitize_display(p)}")
861 if conflicts:
862 print(f" Conflicts: {len(conflicts)} file(s)", file=sys.stderr)
863 for p in conflicts:
864 print(f" CONFLICT (both modified): {sanitize_display(p)}", file=sys.stderr)
865 print(
866 "\nFix conflicts then run 'muse commit' to complete.\n"
867 "Run 'muse diff --conflict' to see what each side changed.",
868 file=sys.stderr,
869 )
870 raise SystemExit(ExitCode.USER_ERROR)
871 return
872 # ── End --merge mode ──────────────────────────────────────────────────────
873
874 # ── Main checkout flow ────────────────────────────────────────────────────
875 # try/finally ensures the autostash is always popped — whether the switch
876 # succeeds, fails validation, or raises SystemExit — so the user's work is
877 # never stranded in the stash.
878 autostash_entry = None
879 try:
880 # Creating a new branch starts at the current HEAD — no files change,
881 # so dirty tracked files cannot be overwritten. The guard only fires
882 # when switching to an *existing* branch whose snapshot differs.
883 if not create:
884 if autostash and not dry_run:
885 # Git idiom: "WIP on <branch>: autostash" is the canonical
886 # stash message. The push makes the tree clean so the switch
887 # proceeds; the finally block pops unconditionally after.
888 autostash_entry = _stash_push_programmatic(
889 root,
890 message=f"WIP on {sanitize_display(current_branch)}: autostash",
891 )
892 if autostash_entry is not None and fmt == "text":
893 print("Stashing local changes before checkout…")
894 elif not autostash:
895 # Normal path: block if dirty (unless --force bypasses).
896 require_clean_workdir(root, "checkout", force=force)
897 # autostash + dry_run: skip both — assume stash would succeed and
898 # the switch would proceed, so the dry-run sees a clean state.
899
900 current_snapshot_id = get_head_snapshot_id(root, repo_id, current_branch)
901
902 if create:
903 try:
904 validate_branch_name(target)
905 except ValueError as exc:
906 print(
907 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
908 file=sys.stderr,
909 )
910 raise SystemExit(ExitCode.USER_ERROR)
911 ref_file = muse_dir / "refs" / "heads" / target
912 if ref_file.exists():
913 print(
914 f"❌ Branch '{sanitize_display(target)}' already exists. "
915 f"Use 'muse checkout {sanitize_display(target)}' to switch to it.",
916 file=sys.stderr,
917 )
918 raise SystemExit(ExitCode.USER_ERROR)
919
920 if dry_run:
921 if fmt == "json":
922 print(json.dumps({
923 "dry_run": True,
924 "action": "created",
925 "branch": target,
926 "from_branch": current_branch,
927 "commit_id": get_head_commit_id(root, current_branch) or "",
928 }))
929 else:
930 print(f"Would create and switch to branch '{sanitize_display(target)}'")
931 return
932
933 current_commit = get_head_commit_id(root, current_branch) or ""
934 if current_commit:
935 write_branch_ref(root, target, current_commit)
936 else:
937 write_text_atomic(ref_file, "")
938 write_head_branch(root, target)
939 append_reflog(
940 root, target, old_id=None, new_id=current_commit or ("0" * 64),
941 author="user",
942 operation=f"branch: created from {sanitize_display(current_branch)}",
943 )
944 if fmt == "json":
945 print(json.dumps({
946 "dry_run": False,
947 "action": "created",
948 "branch": target,
949 "from_branch": current_branch,
950 "commit_id": current_commit,
951 }))
952 else:
953 print(f"Switched to a new branch '{sanitize_display(target)}'")
954 return
955
956 try:
957 validate_branch_name(target)
958 except ValueError as exc:
959 print(
960 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
961 file=sys.stderr,
962 )
963 raise SystemExit(ExitCode.USER_ERROR)
964
965 ref_file = muse_dir / "refs" / "heads" / target
966 if ref_file.exists():
967 if target == current_branch:
968 if force and current_snapshot_id:
969 # --force on the current branch restores the working tree
970 # to HEAD, overwriting modified/deleted tracked files.
971 # Pass None as current_snapshot_id so _checkout_snapshot
972 # diffs empty→HEAD, producing insert ops for every tracked
973 # file. restore_object skips files already correct.
974 if not dry_run:
975 _checkout_snapshot(root, current_snapshot_id, None, target_branch=target)
976 if fmt == "json":
977 print(json.dumps({
978 "dry_run": dry_run,
979 "action": "restored",
980 "branch": target,
981 "from_branch": current_branch,
982 "commit_id": current_snapshot_id,
983 }))
984 else:
985 if dry_run:
986 print(f"Would restore working tree to HEAD of '{sanitize_display(target)}'")
987 else:
988 print(f"HEAD of '{sanitize_display(target)}' restored to working tree")
989 return
990 if fmt == "json":
991 print(json.dumps({
992 "dry_run": dry_run,
993 "action": "already_on",
994 "branch": target,
995 "from_branch": current_branch,
996 "commit_id": get_head_commit_id(root, target) or "",
997 }))
998 else:
999 print(f"Already on '{sanitize_display(target)}'")
1000 return
1001
1002 target_commit_id = get_head_commit_id(root, target) or ""
1003 current_commit_id = get_head_commit_id(root, current_branch) or ""
1004
1005 if dry_run:
1006 if fmt == "json":
1007 print(json.dumps({
1008 "dry_run": True,
1009 "action": "switched",
1010 "branch": target,
1011 "from_branch": current_branch,
1012 "commit_id": target_commit_id,
1013 }))
1014 else:
1015 print(f"Would switch to branch '{sanitize_display(target)}'")
1016 return
1017
1018 target_snapshot_id = get_head_snapshot_id(root, repo_id, target)
1019 if target_snapshot_id:
1020 _checkout_snapshot(
1021 root, target_snapshot_id, current_snapshot_id,
1022 target_branch=target,
1023 )
1024
1025 write_head_branch(root, target)
1026 append_reflog(
1027 root, target, old_id=current_commit_id or None, new_id=target_commit_id or ("0" * 64),
1028 author="user",
1029 operation=(
1030 f"checkout: moving from {sanitize_display(current_branch)} "
1031 f"to {sanitize_display(target)}"
1032 ),
1033 )
1034 if fmt == "json":
1035 print(json.dumps({
1036 "dry_run": False,
1037 "action": "switched",
1038 "branch": target,
1039 "from_branch": current_branch,
1040 "commit_id": target_commit_id,
1041 }))
1042 else:
1043 print(f"Switched to branch '{sanitize_display(target)}'")
1044 return
1045
1046 commit = resolve_commit_ref(root, repo_id, current_branch, target)
1047 if commit is None:
1048 print(
1049 f"❌ '{sanitize_display(target)}' is not a branch or commit ID.",
1050 file=sys.stderr,
1051 )
1052 raise SystemExit(ExitCode.USER_ERROR)
1053
1054 current_commit_id = get_head_commit_id(root, current_branch) or ""
1055
1056 if dry_run:
1057 if fmt == "json":
1058 print(json.dumps({
1059 "dry_run": True,
1060 "action": "detached",
1061 "branch": None,
1062 "from_branch": current_branch,
1063 "commit_id": commit.commit_id,
1064 }))
1065 else:
1066 print(
1067 f"Would detach HEAD at {commit.commit_id[:8]} "
1068 f"{sanitize_display(commit.message or '')}"
1069 )
1070 return
1071
1072 _checkout_snapshot(root, commit.snapshot_id, current_snapshot_id)
1073 # Detached HEAD — no branch name; marker cleared by _checkout_snapshot.
1074 write_head_commit(root, commit.commit_id)
1075 append_reflog(
1076 root, current_branch, old_id=current_commit_id or None, new_id=commit.commit_id,
1077 author="user",
1078 operation=f"checkout: detaching HEAD at {commit.commit_id[:12]}",
1079 )
1080 if fmt == "json":
1081 print(json.dumps({
1082 "dry_run": False,
1083 "action": "detached",
1084 "branch": None,
1085 "from_branch": current_branch,
1086 "commit_id": commit.commit_id,
1087 }))
1088 else:
1089 print(f"HEAD is now at {commit.commit_id[:8]} {sanitize_display(commit.message or '')}")
1090
1091 finally:
1092 # ── Autostash pop ─────────────────────────────────────────────────────
1093 # Mirrors git's "Applied autostash." message. Runs on every exit path
1094 # — success, validation error, SystemExit — so the user's work is
1095 # never stranded in the stash.
1096 if autostash_entry is not None:
1097 _apply_autostash(root, fmt)
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago