checkout.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 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) |