gabriel / muse public
switch.py python
573 lines 20.0 KB
Raw
sha256:b5ec4e4a3a73cae0cd08224f32090f2a4836afa0a804cb3231e70c42a3e89295 fix adapter for agent config Human patch 15 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 # ── Build a compatible Namespace for checkout.run() ──────────────────────
381 checkout_args = argparse.Namespace(
382 target=target,
383 create=create, # -b
384 force=discard_changes, # --force
385 dry_run=dry_run,
386 merge=merge,
387 autoshelf=autoshelf,
388 # Switch owns the JSON payload; always run checkout in text mode so
389 # only one structured payload is emitted to stdout.
390 json_out=False,
391 resolve_ours=False,
392 resolve_theirs=False,
393 resolve_all=False,
394 intent=intent,
395 resumable=resumable,
396 )
397
398 if json_out:
399 # Suppress checkout's text output; switch owns the JSON payload.
400 _discard = io.StringIO()
401 try:
402 with contextlib.redirect_stdout(_discard), \
403 contextlib.redirect_stderr(_discard):
404 checkout_mod.run(checkout_args)
405 except SystemExit as exc:
406 raw = exc.code
407 code = int(raw.value) if hasattr(raw, "value") else (int(raw) if raw is not None else 1)
408 print(_json.dumps({
409 **make_envelope(elapsed, exit_code=code),
410 "error": "switch_failed",
411 "message": _discard.getvalue().strip() or "switch failed",
412 }))
413 raise SystemExit(code)
414 except Exception as exc:
415 # Checkout raised a non-SystemExit exception (e.g. ValueError from
416 # validate_branch_name on an internal code path). Wrap it as a
417 # structured JSON error so agents always get parseable output.
418 print(_json.dumps({
419 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
420 "error": "switch_failed",
421 "message": sanitize_display(str(exc)),
422 }))
423 raise SystemExit(ExitCode.USER_ERROR)
424 else:
425 checkout_mod.run(checkout_args)
426
427 # ── Write PREV_BRANCH (only on a real, successful branch switch) ─────────
428 if not dry_run:
429 try:
430 new_branch = read_current_branch(root)
431 if new_branch != current_branch:
432 _write_prev_branch(root, current_branch)
433 except ValueError:
434 _write_prev_branch(root, current_branch)
435
436 # ── Emit switch's own JSON payload ───────────────────────────────────────
437 if json_out:
438 if create:
439 action = "created"
440 branch_out = target
441 commit_id = get_head_commit_id(root, branch_out) or ""
442 elif dry_run:
443 action = "switched"
444 branch_out = target
445 commit_id = get_head_commit_id(root, branch_out) or ""
446 else:
447 head = read_head(root)
448 if head["kind"] == "commit":
449 # Detached HEAD after --detach.
450 action = "detached"
451 branch_out = None
452 commit_id = head["commit_id"]
453 else:
454 new_branch = head["branch"]
455 branch_out = new_branch
456 commit_id = get_head_commit_id(root, new_branch) or ""
457 if new_branch == current_branch:
458 action = "already_on"
459 else:
460 action = "switched"
461
462 print(_json.dumps(_SwitchJson(
463 **make_envelope(elapsed),
464 action=action,
465 branch=sanitize_display(branch_out) if branch_out else None,
466 from_branch=sanitize_display(current_branch),
467 commit_id=commit_id,
468 dry_run=dry_run,
469 )))
470
471 # ---------------------------------------------------------------------------
472 # Internal: force-create (-C)
473 # ---------------------------------------------------------------------------
474
475 def _run_force_create(
476 root: pathlib.Path,
477 target: str,
478 *,
479 dry_run: bool,
480 fmt: str,
481 elapsed_fn: "() -> float",
482 ) -> None:
483 """Implement ``muse switch -C <branch>``.
484
485 Creates *target* if it doesn't exist, or resets its tip to the current
486 HEAD if it does. Then switches to it.
487
488 Args:
489 root: Absolute repo root.
490 target: Branch name to create or reset.
491 dry_run: If True, validate and report without writing anything.
492 fmt: Output format: ``"text"`` or ``"json"``.
493 elapsed_fn: Callable returning milliseconds elapsed since the command
494 started. Used to populate ``duration_ms`` in JSON output.
495 """
496 try:
497 validate_branch_name(target)
498 except ValueError as exc:
499 if fmt == "json":
500 print(_json.dumps({
501 **make_envelope(elapsed_fn, exit_code=ExitCode.USER_ERROR),
502 "error": "invalid_branch_name",
503 "message": sanitize_display(str(exc)),
504 }))
505 else:
506 print(
507 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
508 file=sys.stderr,
509 )
510 raise SystemExit(ExitCode.USER_ERROR)
511
512 current_branch = read_current_branch(root)
513 current_commit = get_head_commit_id(root, current_branch) or ""
514
515 ref_file = _ref_path(root, target)
516 branch_existed = ref_file.exists()
517
518 if dry_run:
519 action = "reset" if branch_existed else "created"
520 if fmt == "json":
521 print(_json.dumps(_SwitchJson(
522 **make_envelope(elapsed_fn),
523 action=action,
524 branch=sanitize_display(target),
525 from_branch=sanitize_display(current_branch),
526 commit_id=current_commit,
527 dry_run=True,
528 )))
529 else:
530 verb = "Reset" if branch_existed else "Create"
531 print(
532 f"[dry-run] Would {verb.lower()} branch "
533 f"'{sanitize_display(target)}' at "
534 f"{current_commit} and switch to it."
535 )
536 return
537
538 # Write the ref (create or overwrite).
539 if current_commit:
540 write_branch_ref(root, target, current_commit)
541 else:
542 write_text_atomic(ref_file, "")
543
544 # Switch HEAD.
545 write_head_branch(root, target)
546
547 append_reflog(
548 root, target,
549 old_id=None,
550 new_id=current_commit or NULL_COMMIT_ID,
551 author="user",
552 operation=(
553 f"switch -C: {'reset' if branch_existed else 'created'} "
554 f"from {sanitize_display(current_branch)}"
555 ),
556 )
557
558 # Record previous branch for "switch -".
559 _write_prev_branch(root, current_branch)
560
561 action = "reset" if branch_existed else "created"
562 if fmt == "json":
563 print(_json.dumps(_SwitchJson(
564 **make_envelope(elapsed_fn),
565 action=action,
566 branch=sanitize_display(target),
567 from_branch=sanitize_display(current_branch),
568 commit_id=current_commit,
569 dry_run=False,
570 )))
571 else:
572 verb = "Reset and switched" if branch_existed else "Switched to a new branch"
573 print(f"{verb} '{sanitize_display(target)}'")
File History 1 commit
sha256:b5ec4e4a3a73cae0cd08224f32090f2a4836afa0a804cb3231e70c42a3e89295 fix adapter for agent config Human patch 15 days ago