gabriel / muse public
switch.py python
611 lines 21.7 KB
Raw
sha256:b5ec4e4a3a73cae0cd08224f32090f2a4836afa0a804cb3231e70c42a3e89295 fix adapter for agent config Human patch 7 days ago
1 """``muse switch`` — focused branch switcher.
2
3 A cleaner, more intentional alternative to ``muse checkout`` for branch
4 operations. It delegates to checkout's internal machinery so all snapshot
5 application, reflog, and dirty-tree logic stays in one place.
6
7 Differences from ``muse checkout``
8 ------------------------------------
9 - **Branch-only**: no file restoring, no conflict resolution.
10 - **``-c``** creates a new branch (like checkout ``-b``).
11 - **``-C``** force-creates: resets the ref if the branch already exists.
12 - **``switch -``**: switches to the previously-checked-out branch, stored
13 in ``.muse/PREV_BRANCH``.
14 - **``--discard-changes``**: explicit name for ``--force``; discards dirty
15 working-tree changes before switching.
16 - **``--detach``**: detach HEAD at a commit (like ``checkout <commit>``
17 without a branch argument).
18 - **``--intent``**: annotate a newly-created branch with a human/agent
19 description of its purpose (only valid with ``-c``).
20 - **``--resumable``**: mark a newly-created branch as a resumable agent
21 checkpoint (only valid with ``-c``).
22
23 JSON schema (``--json``)::
24
25 {
26 "action": "switched" | "created" | "detached" | "already_on" | "reset",
27 "branch": "<name> | null",
28 "from_branch": "<name>",
29 "commit_id": "<sha256:hex>",
30 "dry_run": true | false,
31 "duration_ms": 1.234,
32 "exit_code": 0
33 }
34
35 JSON error schema (``--json``, always to stdout so agents can parse failures)::
36
37 {
38 "error": "<error_key>",
39 "message": "<human-readable message>",
40 "duration_ms": 0.3,
41 "exit_code": 1
42 }
43
44 Exit codes::
45
46 0 — success (or would-succeed in dry-run)
47 1 — user error: branch not found, dirty tree, bad name, no previous branch
48 2 — not a Muse repository
49 3 — internal error
50
51 Examples::
52
53 muse switch feat # switch to existing branch
54 muse switch -c task/new-thing # create and switch
55 muse switch -c task/new --intent "implement feature X" --resumable
56 muse switch -C feat # force-reset feat to HEAD then switch
57 muse switch - # back to previous branch
58 muse switch --detach abc123 # detach HEAD at commit
59 muse switch feat --dry-run --json # preview in JSON
60 muse switch feat --discard-changes # force switch over dirty tree
61 muse switch feat --autoshelf # shelf, switch, pop
62 muse switch feat --merge # carry changes via 3-way merge
63 """
64
65 import argparse
66 import contextlib
67 import io
68 import json as _json
69 import logging
70 import pathlib
71 import sys
72
73 from muse.core.types import NULL_COMMIT_ID
74 from muse.core.paths import prev_branch_path as _prev_branch_path, ref_path as _ref_path
75 from muse.core.errors import ExitCode
76 from muse.core.reflog import append_reflog
77 from muse.core.repo import require_repo
78 from muse.core.refs import read_ref
79 from muse.core.io import write_text_atomic
80 from muse.core.refs import (
81 get_head_commit_id,
82 read_current_branch,
83 read_head,
84 write_branch_ref,
85 write_head_branch,
86 )
87 from muse.core.envelope import EnvelopeJson, make_envelope
88 from muse.core.validation import sanitize_display, validate_branch_name
89 from muse.core.timing import start_timer
90
91 logger = logging.getLogger(__name__)
92
93 class _SwitchJson(EnvelopeJson):
94 action: str
95 branch: str | None
96 from_branch: str
97 commit_id: str
98 dry_run: bool
99
100 # Path of the previous-branch file, relative to the repo's .muse directory.
101 _PREV_BRANCH_FILE = "PREV_BRANCH"
102
103 # ---------------------------------------------------------------------------
104 # PREV_BRANCH helpers
105 # ---------------------------------------------------------------------------
106
107 def _read_prev_branch(root: pathlib.Path) -> str | None:
108 """Return the previously-switched-to branch, or ``None`` if not recorded."""
109 path = _prev_branch_path(root)
110 try:
111 val = path.read_text(encoding="utf-8").strip()
112 return val if val else None
113 except (FileNotFoundError, PermissionError, OSError):
114 return None
115
116 def _write_prev_branch(root: pathlib.Path, branch: str) -> None:
117 """Atomically record *branch* as the previous branch."""
118 write_text_atomic(_prev_branch_path(root), f"{branch}\n")
119
120 # ---------------------------------------------------------------------------
121 # Registration
122 # ---------------------------------------------------------------------------
123
124 def register(
125 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
126 ) -> None:
127 """Register the ``muse switch`` subcommand."""
128 parser = subparsers.add_parser(
129 "switch",
130 help="Switch branches (focused alternative to checkout).",
131 description=__doc__,
132 formatter_class=argparse.RawDescriptionHelpFormatter,
133 )
134 parser.add_argument(
135 "target",
136 nargs="?",
137 metavar="BRANCH | COMMIT | -",
138 help=(
139 "Branch to switch to, commit ID (with --detach), or '-' to "
140 "return to the previously checked-out branch."
141 ),
142 )
143 parser.add_argument(
144 "-c", "--create",
145 action="store_true",
146 dest="create",
147 help="Create a new branch at HEAD and switch to it.",
148 )
149 parser.add_argument(
150 "-C", "--force-create",
151 action="store_true",
152 dest="force_create",
153 help=(
154 "Force-create: create the branch if it doesn't exist, or reset "
155 "its tip to the current HEAD if it does. Then switch to it."
156 ),
157 )
158 parser.add_argument(
159 "--detach",
160 action="store_true",
161 help="Detach HEAD at BRANCH or COMMIT rather than switching branches.",
162 )
163 parser.add_argument(
164 "--discard-changes", "-f",
165 action="store_true",
166 dest="discard_changes",
167 help="Discard uncommitted working-tree changes before switching.",
168 )
169 parser.add_argument(
170 "--merge", "-m",
171 action="store_true",
172 dest="merge",
173 help=(
174 "Carry uncommitted changes into the target branch via a "
175 "three-way merge (the Cohen Transform). Mutually exclusive "
176 "with --discard-changes and --autoshelf."
177 ),
178 )
179 parser.add_argument(
180 "--autoshelf",
181 action="store_true",
182 dest="autoshelf",
183 help=(
184 "Shelf uncommitted changes, switch, then pop them back. "
185 "Mutually exclusive with --discard-changes and --merge."
186 ),
187 )
188 parser.add_argument(
189 "--intent",
190 default=None,
191 metavar="TEXT",
192 help=(
193 "Annotate the new branch with an intent description. "
194 "Only valid with -c/--create."
195 ),
196 )
197 parser.add_argument(
198 "--resumable",
199 action="store_true",
200 default=False,
201 help=(
202 "Mark the new branch as a resumable agent checkpoint "
203 "(discoverable via ``muse branch --resumable``). "
204 "Only valid with -c/--create."
205 ),
206 )
207 parser.add_argument(
208 "-n", "--dry-run",
209 action="store_true",
210 dest="dry_run",
211 help="Preview what would happen without making any changes.",
212 )
213 parser.add_argument(
214 "--json", "-j",
215 action="store_true",
216 dest="json_out",
217 help="Emit machine-readable JSON on stdout.",
218 )
219 parser.set_defaults(
220 func=run,
221 create=False,
222 force_create=False,
223 detach=False,
224 discard_changes=False,
225 merge=False,
226 autoshelf=False,
227 intent=None,
228 resumable=False,
229 dry_run=False,
230 json_out=False,
231 )
232
233 # ---------------------------------------------------------------------------
234 # Run
235 # ---------------------------------------------------------------------------
236
237 def run(args: argparse.Namespace) -> None:
238 """Switch branches, delegating to checkout's internal machinery.
239
240 All snapshot application, reflog, autoshelf, and merge logic lives in
241 checkout so there is exactly one implementation. switch translates its
242 flags into a compatible Namespace then calls checkout.run(). When
243 ``--json`` is set, every error is emitted as a JSON object to stdout so
244 agents can parse failures without inspecting the exit code first.
245
246 Agent quickstart::
247
248 muse switch feat --json
249 muse switch -c task/new-thing --json
250 muse switch -c task/new --intent "implement X" --resumable --json
251 muse switch --dry-run feat --json
252
253 JSON fields::
254
255 action ``"switched"``, ``"created"``, ``"detached"``, ``"already_on"``, or ``"reset"``.
256 branch Target branch name; ``null`` on detached HEAD.
257 from_branch Branch name at the time the command was invoked.
258 commit_id SHA-256 commit ID at the new HEAD.
259 dry_run ``true`` when ``--dry-run`` was passed.
260 muse_version Muse release that produced this output.
261 schema Envelope schema version (int).
262 exit_code ``0`` on success, ``1`` on user error.
263 duration_ms Wall-clock milliseconds for the command.
264 timestamp ISO-8601 UTC timestamp of command completion.
265 warnings List of non-fatal advisory messages.
266
267 Exit codes::
268
269 0 Success (or would-succeed in dry-run).
270 1 User error (branch not found, dirty tree, bad name, no prev branch).
271 2 Not a Muse repository.
272 3 Internal error.
273 """
274 # Lazy import to avoid a circular dependency at module load time.
275 from muse.cli.commands import checkout as checkout_mod
276
277 elapsed = start_timer()
278
279 target: str | None = args.target
280 create: bool = args.create
281 force_create: bool = args.force_create
282 detach: bool = args.detach
283 discard_changes: bool = args.discard_changes
284 merge: bool = getattr(args, "merge", False)
285 autoshelf: bool = getattr(args, "autoshelf", False)
286 intent: str | None = getattr(args, "intent", None)
287 resumable: bool = getattr(args, "resumable", False)
288 dry_run: bool = args.dry_run
289 json_out: bool = args.json_out
290
291 def _emit_error(
292 msg: str,
293 code: int,
294 error_key: str = "switch_failed",
295 **extra: str,
296 ) -> None:
297 """Emit a structured error to stdout (JSON) or stderr (text) then exit."""
298 if json_out:
299 payload = {
300 **make_envelope(elapsed, exit_code=code),
301 "error": error_key,
302 "message": msg,
303 }
304 payload.update(extra)
305 print(_json.dumps(payload))
306 else:
307 print(f"❌ {msg}", file=sys.stderr)
308 raise SystemExit(code)
309
310 # ── Mutual-exclusion guards ───────────────────────────────────────────────
311 if create and force_create:
312 _emit_error(
313 "-c and -C are mutually exclusive.",
314 ExitCode.USER_ERROR,
315 "mutual_exclusion",
316 )
317
318 if create and detach:
319 _emit_error(
320 "--create and --detach are mutually exclusive.",
321 ExitCode.USER_ERROR,
322 "mutual_exclusion",
323 )
324
325 if discard_changes and merge:
326 _emit_error(
327 "--discard-changes and --merge are mutually exclusive.",
328 ExitCode.USER_ERROR,
329 "mutual_exclusion",
330 )
331
332 if discard_changes and autoshelf:
333 _emit_error(
334 "--discard-changes and --autoshelf are mutually exclusive.",
335 ExitCode.USER_ERROR,
336 "mutual_exclusion",
337 )
338
339 if merge and autoshelf:
340 _emit_error(
341 "--merge and --autoshelf are mutually exclusive.",
342 ExitCode.USER_ERROR,
343 "mutual_exclusion",
344 )
345
346 # ── Require a target ─────────────────────────────────────────────────────
347 if target is None:
348 _emit_error(
349 "Specify a branch, '-' for previous, or a commit with --detach.",
350 ExitCode.USER_ERROR,
351 "missing_target",
352 )
353
354 root = require_repo()
355
356 # ── switch - (previous branch) ───────────────────────────────────────────
357 if target == "-":
358 prev = _read_prev_branch(root)
359 if not prev:
360 _emit_error(
361 "No previous branch recorded. "
362 "Switch to a branch first, then use 'muse switch -'.",
363 ExitCode.USER_ERROR,
364 "no_prev_branch",
365 )
366 target = prev
367
368 # ── -C / force-create ────────────────────────────────────────────────────
369 if force_create:
370 _run_force_create(
371 root, target,
372 dry_run=dry_run, fmt="json" if json_out else "text",
373 elapsed_fn=elapsed,
374 )
375 return
376
377 # ── Record current branch for "switch -" before delegating ───────────────
378 current_branch = read_current_branch(root)
379
380 # ── Same-commit fast-path: no files change, dirty tree is safe ───────────
381 # If the target branch already points to the same commit as HEAD, switching
382 # is a pure HEAD ref update — apply_manifest is a no-op and nothing in the
383 # working tree will be touched. Skip the dirty guard and delegate entirely,
384 # matching git switch behaviour.
385 # Only applies when not creating a new branch (create=True means the branch
386 # doesn't exist yet so we can't compare tips).
387 if not create and not dry_run:
388 try:
389 _target_commit = get_head_commit_id(root, target)
390 _current_commit = get_head_commit_id(root, current_branch)
391 except (ValueError, OSError):
392 _target_commit = None
393 _current_commit = None
394 if _target_commit and _target_commit == _current_commit and target != current_branch:
395 # Pure ref switch: just update HEAD, write reflog, done.
396 write_head_branch(root, target)
397 append_reflog(
398 root, target,
399 old_id=_current_commit,
400 new_id=_current_commit,
401 author="user",
402 operation=f"switch: from {current_branch} (same commit)",
403 )
404 _write_prev_branch(root, current_branch)
405 if json_out:
406 print(_json.dumps(_SwitchJson(
407 **make_envelope(elapsed),
408 action="switched",
409 branch=target,
410 from_branch=current_branch,
411 commit_id=_current_commit,
412 dry_run=False,
413 )))
414 else:
415 print(f"Switched to branch '{sanitize_display(target)}'")
416 return
417
418 # ── Build a compatible Namespace for checkout.run() ──────────────────────
419 checkout_args = argparse.Namespace(
420 target=target,
421 create=create, # -b
422 force=discard_changes, # --force
423 dry_run=dry_run,
424 merge=merge,
425 autoshelf=autoshelf,
426 # Switch owns the JSON payload; always run checkout in text mode so
427 # only one structured payload is emitted to stdout.
428 json_out=False,
429 resolve_ours=False,
430 resolve_theirs=False,
431 resolve_all=False,
432 intent=intent,
433 resumable=resumable,
434 )
435
436 if json_out:
437 # Suppress checkout's text output; switch owns the JSON payload.
438 _discard = io.StringIO()
439 try:
440 with contextlib.redirect_stdout(_discard), \
441 contextlib.redirect_stderr(_discard):
442 checkout_mod.run(checkout_args)
443 except SystemExit as exc:
444 raw = exc.code
445 code = int(raw.value) if hasattr(raw, "value") else (int(raw) if raw is not None else 1)
446 print(_json.dumps({
447 **make_envelope(elapsed, exit_code=code),
448 "error": "switch_failed",
449 "message": _discard.getvalue().strip() or "switch failed",
450 }))
451 raise SystemExit(code)
452 except Exception as exc:
453 # Checkout raised a non-SystemExit exception (e.g. ValueError from
454 # validate_branch_name on an internal code path). Wrap it as a
455 # structured JSON error so agents always get parseable output.
456 print(_json.dumps({
457 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
458 "error": "switch_failed",
459 "message": sanitize_display(str(exc)),
460 }))
461 raise SystemExit(ExitCode.USER_ERROR)
462 else:
463 checkout_mod.run(checkout_args)
464
465 # ── Write PREV_BRANCH (only on a real, successful branch switch) ─────────
466 if not dry_run:
467 try:
468 new_branch = read_current_branch(root)
469 if new_branch != current_branch:
470 _write_prev_branch(root, current_branch)
471 except ValueError:
472 _write_prev_branch(root, current_branch)
473
474 # ── Emit switch's own JSON payload ───────────────────────────────────────
475 if json_out:
476 if create:
477 action = "created"
478 branch_out = target
479 commit_id = get_head_commit_id(root, branch_out) or ""
480 elif dry_run:
481 action = "switched"
482 branch_out = target
483 commit_id = get_head_commit_id(root, branch_out) or ""
484 else:
485 head = read_head(root)
486 if head["kind"] == "commit":
487 # Detached HEAD after --detach.
488 action = "detached"
489 branch_out = None
490 commit_id = head["commit_id"]
491 else:
492 new_branch = head["branch"]
493 branch_out = new_branch
494 commit_id = get_head_commit_id(root, new_branch) or ""
495 if new_branch == current_branch:
496 action = "already_on"
497 else:
498 action = "switched"
499
500 print(_json.dumps(_SwitchJson(
501 **make_envelope(elapsed),
502 action=action,
503 branch=sanitize_display(branch_out) if branch_out else None,
504 from_branch=sanitize_display(current_branch),
505 commit_id=commit_id,
506 dry_run=dry_run,
507 )))
508
509 # ---------------------------------------------------------------------------
510 # Internal: force-create (-C)
511 # ---------------------------------------------------------------------------
512
513 def _run_force_create(
514 root: pathlib.Path,
515 target: str,
516 *,
517 dry_run: bool,
518 fmt: str,
519 elapsed_fn: "() -> float",
520 ) -> None:
521 """Implement ``muse switch -C <branch>``.
522
523 Creates *target* if it doesn't exist, or resets its tip to the current
524 HEAD if it does. Then switches to it.
525
526 Args:
527 root: Absolute repo root.
528 target: Branch name to create or reset.
529 dry_run: If True, validate and report without writing anything.
530 fmt: Output format: ``"text"`` or ``"json"``.
531 elapsed_fn: Callable returning milliseconds elapsed since the command
532 started. Used to populate ``duration_ms`` in JSON output.
533 """
534 try:
535 validate_branch_name(target)
536 except ValueError as exc:
537 if fmt == "json":
538 print(_json.dumps({
539 **make_envelope(elapsed_fn, exit_code=ExitCode.USER_ERROR),
540 "error": "invalid_branch_name",
541 "message": sanitize_display(str(exc)),
542 }))
543 else:
544 print(
545 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
546 file=sys.stderr,
547 )
548 raise SystemExit(ExitCode.USER_ERROR)
549
550 current_branch = read_current_branch(root)
551 current_commit = get_head_commit_id(root, current_branch) or ""
552
553 ref_file = _ref_path(root, target)
554 branch_existed = ref_file.exists()
555
556 if dry_run:
557 action = "reset" if branch_existed else "created"
558 if fmt == "json":
559 print(_json.dumps(_SwitchJson(
560 **make_envelope(elapsed_fn),
561 action=action,
562 branch=sanitize_display(target),
563 from_branch=sanitize_display(current_branch),
564 commit_id=current_commit,
565 dry_run=True,
566 )))
567 else:
568 verb = "Reset" if branch_existed else "Create"
569 print(
570 f"[dry-run] Would {verb.lower()} branch "
571 f"'{sanitize_display(target)}' at "
572 f"{current_commit} and switch to it."
573 )
574 return
575
576 # Write the ref (create or overwrite).
577 if current_commit:
578 write_branch_ref(root, target, current_commit)
579 else:
580 write_text_atomic(ref_file, "")
581
582 # Switch HEAD.
583 write_head_branch(root, target)
584
585 append_reflog(
586 root, target,
587 old_id=None,
588 new_id=current_commit or NULL_COMMIT_ID,
589 author="user",
590 operation=(
591 f"switch -C: {'reset' if branch_existed else 'created'} "
592 f"from {sanitize_display(current_branch)}"
593 ),
594 )
595
596 # Record previous branch for "switch -".
597 _write_prev_branch(root, current_branch)
598
599 action = "reset" if branch_existed else "created"
600 if fmt == "json":
601 print(_json.dumps(_SwitchJson(
602 **make_envelope(elapsed_fn),
603 action=action,
604 branch=sanitize_display(target),
605 from_branch=sanitize_display(current_branch),
606 commit_id=current_commit,
607 dry_run=False,
608 )))
609 else:
610 verb = "Reset and switched" if branch_existed else "Switched to a new branch"
611 print(f"{verb} '{sanitize_display(target)}'")
File History 1 commit
sha256:b5ec4e4a3a73cae0cd08224f32090f2a4836afa0a804cb3231e70c42a3e89295 fix adapter for agent config Human patch 7 days ago