"""``muse worktree`` — manage multiple simultaneous branch checkouts. Worktrees let you work on multiple branches at once without stashing or switching — each worktree is an independent working directory, but they all share the same ``.muse/`` object store. This is especially powerful for agents: one agent per worktree, each autonomously developing a feature on its own branch, with zero interference. Use ``--json`` on any subcommand for machine-readable output. Subcommands:: muse worktree add [--path PATH] [-b NEW_BRANCH] [--json] muse worktree list [--json] muse worktree prune [--dry-run] [--json] muse worktree remove [--force] [--json] muse worktree repair [--json] muse worktree status [--json] Layout:: myproject/ ← main worktree (holds .muse/) myproject-feat-audio/ ← linked worktree for feat/audio Exit codes:: 0 — success 1 — user error (invalid name/branch, worktree not found, path conflict) 2 — internal error """ from __future__ import annotations import argparse import json import logging import pathlib import sys from typing import TypedDict from muse.core.errors import ExitCode from muse.core.repo import require_repo from muse.core.store import get_head_commit_id, write_text_atomic from muse.core.validation import sanitize_display, validate_branch_name from muse.core.worktree import ( WorktreeInfo, WorktreeStatusResult, add_worktree, get_worktree_status, list_worktrees, prune_worktrees, remove_worktree, repair_worktree_pointers, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Typed JSON schemas # --------------------------------------------------------------------------- class _WorktreeAddJson(TypedDict): """JSON output of ``muse worktree add --json``.""" name: str branch: str path: str head_commit: str | None class _WorktreeListEntryJson(TypedDict): """One entry in the JSON array from ``muse worktree list --json``.""" name: str branch: str path: str head_commit: str | None is_main: bool class _WorktreeRemoveJson(TypedDict): """JSON output of ``muse worktree remove --json``.""" name: str status: str class _WorktreePruneJson(TypedDict): """JSON output of ``muse worktree prune --json``.""" pruned: list[str] count: int dry_run: bool # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _fmt_info(wt: WorktreeInfo) -> str: """Format a worktree row for human-readable ``list`` output.""" prefix = "* " if wt.is_main else " " head = wt.head_commit[:12] if wt.head_commit else "(no commits)" return ( f"{prefix}{sanitize_display(wt.name):<24} " f"{sanitize_display(wt.branch):<30} " f"{head} " f"{sanitize_display(str(wt.path))}" ) def _info_to_json(wt: WorktreeInfo) -> _WorktreeListEntryJson: return _WorktreeListEntryJson( name=sanitize_display(wt.name), branch=sanitize_display(wt.branch), path=str(wt.path), head_commit=wt.head_commit, is_main=wt.is_main, ) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- def run_worktree_add(args: argparse.Namespace) -> None: """Create a new linked worktree checked out at *branch*. The new worktree is placed at ``/-`` by default, or at ``--path PATH`` when specified. Its working directory is pre-populated from the branch's latest snapshot. When ``-b`` / ``--create-branch`` is given, a new branch is created from BRANCH (used as the start point) and the worktree checks out that new branch. This mirrors ``git worktree add -b ``. JSON schema:: { "name": "", "branch": "", "path": "", "head_commit": " | null" } Exit codes:: 0 — success 1 — invalid branch name, branch does not exist, worktree already exists, directory already exists, or path validation error 2 — not inside a Muse repository """ name: str = args.name branch: str = args.branch create_branch: str | None = getattr(args, "create_branch", None) worktree_path_arg: str | None = getattr(args, "worktree_path", None) explicit_path: pathlib.Path | None = ( pathlib.Path(worktree_path_arg).resolve() if worktree_path_arg else None ) output_json: bool = args.output_json root = require_repo() # When -b is given, create the new branch from BRANCH (start point) first. checkout_branch = branch if create_branch is not None: try: validate_branch_name(create_branch) except ValueError as exc: print(f"❌ Invalid branch name: {sanitize_display(str(exc))}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) new_ref = root / ".muse" / "refs" / "heads" / create_branch if new_ref.exists(): print( f"❌ Branch '{sanitize_display(create_branch)}' already exists.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) start_commit = get_head_commit_id(root, branch) if start_commit is None: print( f"❌ Start-point branch '{sanitize_display(branch)}' has no commits.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) new_ref.parent.mkdir(parents=True, exist_ok=True) write_text_atomic(new_ref, f"{start_commit}\n") checkout_branch = create_branch try: wt_path = add_worktree(root, name, checkout_branch, path=explicit_path) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if output_json: print(json.dumps(_WorktreeAddJson( name=name, branch=checkout_branch, path=str(wt_path), head_commit=get_head_commit_id(root, checkout_branch), ))) else: print(f"✅ Worktree '{sanitize_display(name)}' created at {sanitize_display(str(wt_path))}") print(f" Branch: {sanitize_display(checkout_branch)}") def run_worktree_list(args: argparse.Namespace) -> None: """List all worktrees (main + linked). The main worktree is always first; linked worktrees follow in lexicographic order of name. ``name`` and ``branch`` in both text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema (array element):: { "name": "", "branch": "", "path": "", "head_commit": " | null", "is_main": true | false } Exit codes:: 0 — success (even when no linked worktrees exist) 2 — not inside a Muse repository """ output_json: bool = args.output_json root = require_repo() worktrees = list_worktrees(root) if output_json: print(json.dumps([_info_to_json(wt) for wt in worktrees])) return if not worktrees: print("No worktrees.") return header = f"{' name':<26} {'branch':<30} {'HEAD':12} path" print(header) print("-" * len(header)) for wt in worktrees: print(_fmt_info(wt)) def run_worktree_status(args: argparse.Namespace) -> None: """Show the status of a single worktree. Reports the worktree's branch, HEAD commit, and whether the working directory is present on disk. Useful for agents checking whether a peer agent's worktree is still active. ``name`` and ``branch`` in both text and JSON output are sanitized — ANSI control sequences are stripped before display or serialisation. JSON schema:: { "name": "", "branch": "", "path": "", "head_commit": " | null", "present": true | false, "is_main": true | false } Exit codes:: 0 — success 1 — worktree does not exist, or name is invalid 2 — not inside a Muse repository """ name: str = args.name output_json: bool = args.output_json root = require_repo() try: status = get_worktree_status(root, name) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if output_json: print(json.dumps(WorktreeStatusResult( name=sanitize_display(status["name"]), branch=sanitize_display(status["branch"]), path=sanitize_display(status["path"]), head_commit=status["head_commit"], present=status["present"], is_main=status["is_main"], ))) return present_flag = "✅ present" if status["present"] else "❌ missing" head = status["head_commit"][:12] if status["head_commit"] else "(no commits)" main_flag = " [main]" if status["is_main"] else "" print(f"worktree: {sanitize_display(status['name'])}{main_flag}") print(f"branch: {sanitize_display(status['branch'])}") print(f"HEAD: {head}") print(f"path: {sanitize_display(status['path'])}") print(f"status: {present_flag}") def run_worktree_remove(args: argparse.Namespace) -> None: """Remove a linked worktree and its working directory. The branch itself is not deleted — only the worktree directory and its metadata are removed. Commits already pushed from the worktree remain in the shared object store. The main worktree cannot be removed. Removal is refused if the worktree path is a symlink or resolves inside ``.muse/`` (safety guard). JSON schema:: { "name": "", "status": "removed" } Exit codes:: 0 — success 1 — worktree does not exist, name is invalid, or removal refused (symlink / .muse overlap) 2 — not inside a Muse repository """ name: str = args.name force: bool = args.force output_json: bool = args.output_json root = require_repo() try: remove_worktree(root, name, force=force) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if output_json: print(json.dumps(_WorktreeRemoveJson(name=sanitize_display(name), status="removed"))) else: print(f"✅ Worktree '{sanitize_display(name)}' removed.") def run_worktree_prune(args: argparse.Namespace) -> None: """Remove metadata entries for worktrees whose directories no longer exist. A worktree is considered stale when its registered path is absent from the filesystem. Prune deletes the metadata file and HEAD pointer for each stale entry; it does not touch any branch refs. Pass ``--dry-run`` to preview which entries would be pruned without making any filesystem changes. All names in both text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema:: { "pruned": ["", ...], "count": , "dry_run": true | false } Exit codes:: 0 — success (even when nothing was pruned) 2 — not inside a Muse repository """ output_json: bool = args.output_json dry_run: bool = args.dry_run root = require_repo() pruned = prune_worktrees(root, dry_run=dry_run) if output_json: print(json.dumps(_WorktreePruneJson( pruned=[sanitize_display(n) for n in pruned], count=len(pruned), dry_run=dry_run, ))) return if not pruned: print("Nothing to prune.") return prefix = "[dry-run] would prune" if dry_run else "pruned" for name in pruned: print(f" {prefix}: {sanitize_display(name)}") action = "Would prune" if dry_run else "Pruned" print(f"{action} {len(pruned)} stale worktree(s).") def run_worktree_repair(args: argparse.Namespace) -> None: """Write missing .muse pointer files for all registered linked worktrees. Idempotent — safe to run multiple times. Re-writes the pointer even when it already exists. Skip worktrees whose directories are absent (they should be pruned instead). Useful to retroactively fix worktrees created before pointer-file support was added, or to repair a pointer that was accidentally deleted. All names in both text and JSON output are sanitized — ANSI control sequences are stripped. JSON schema:: { "repaired": ["", ...] } Exit codes:: 0 — success (even when nothing needed repair) 2 — not inside a Muse repository """ output_json: bool = args.output_json repo_root = require_repo() repaired = repair_worktree_pointers(repo_root) if output_json: print(json.dumps({"repaired": [sanitize_display(n) for n in repaired]})) else: if repaired: for name in repaired: print(f" repaired {sanitize_display(name)}") print(f"\n✅ {len(repaired)} worktree(s) repaired.") else: print("✅ All worktrees already have pointer files.") # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``worktree`` subcommand.""" parser = subparsers.add_parser( "worktree", help="Manage multiple simultaneous branch checkouts.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") subs.required = True # ── add ─────────────────────────────────────────────────────────────────── add_p = subs.add_parser( "add", help="Create a new linked worktree checked out at a branch.", description=( "Create a new linked worktree checked out at BRANCH.\n\n" "The worktree is placed at /-NAME by default,\n" "or at --path PATH when specified.\n\n" "Branch creation\n" "---------------\n" " -b NEW_BRANCH Create NEW_BRANCH from BRANCH (start point) and\n" " check it out in the new worktree.\n\n" "Agent quickstart\n" "----------------\n" " muse worktree add feat-x feat/x --json\n" " muse worktree add my-wt main -b feat/new --json\n" " muse worktree add custom main --path /tmp/my-wt --json\n\n" "JSON output schema\n" "------------------\n" ' {"name": "", "branch": "",\n' ' "path": "", "head_commit": " | null"}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — invalid name/branch, branch missing, worktree/dir already exists\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) add_p.add_argument("name", metavar="NAME", help="Worktree name.") add_p.add_argument("branch", metavar="BRANCH", help="Branch to check out.") add_p.add_argument( "--path", metavar="PATH", dest="worktree_path", default=None, help=( "Explicit filesystem path for the worktree directory. " "When omitted, placed at /-." ), ) add_p.add_argument( "-b", "--create-branch", metavar="NEW_BRANCH", dest="create_branch", default=None, help=( "Create NEW_BRANCH from BRANCH (start point) and check out the " "new branch in the worktree." ), ) add_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) add_p.set_defaults(func=run_worktree_add) # ── list ────────────────────────────────────────────────────────────────── list_p = subs.add_parser( "list", help="List all worktrees (main + linked).", description=( "List all worktrees: the main worktree and every linked worktree.\n\n" "The main worktree is always first; linked worktrees follow in\n" "lexicographic order of name. Output is sanitized — ANSI control\n" "sequences are stripped from name, branch, and path.\n\n" "Agent quickstart\n" "----------------\n" " muse worktree list --json\n\n" "JSON output schema (array element)\n" "----------------------------------\n" ' {"name": "", "branch": "", "path": "",\n' ' "head_commit": " | null", "is_main": true|false}\n\n' "Exit codes\n" "----------\n" " 0 — success (even when no linked worktrees exist)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) list_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON array to stdout.", ) list_p.set_defaults(func=run_worktree_list) # ── prune ───────────────────────────────────────────────────────────────── prune_p = subs.add_parser( "prune", help="Remove metadata entries for missing worktrees.", description=( "Remove metadata for worktrees whose directories no longer exist.\n\n" "A worktree is stale when its registered path is absent from disk.\n" "Prune removes the metadata file and HEAD pointer for each stale\n" "entry; branch refs are never touched.\n\n" "Use --dry-run to preview without making any changes.\n\n" "Agent quickstart\n" "----------------\n" " muse worktree prune --json\n" " muse worktree prune --dry-run --json\n\n" "JSON output schema\n" "------------------\n" ' {"pruned": ["", ...], "count": , "dry_run": true|false}\n\n' "Exit codes\n" "----------\n" " 0 — success (even when nothing was pruned)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) prune_p.add_argument( "--dry-run", action="store_true", dest="dry_run", default=False, help="Preview what would be pruned without making changes.", ) prune_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) prune_p.set_defaults(func=run_worktree_prune) # ── remove ──────────────────────────────────────────────────────────────── remove_p = subs.add_parser( "remove", help="Remove a linked worktree and its working directory.", description=( "Remove a linked worktree: deletes the working directory and its\n" "metadata. The branch is NOT deleted. Already-pushed commits\n" "remain in the shared object store.\n\n" "Safety guards refuse removal when the worktree path is a symlink\n" "or resolves inside .muse/ (would corrupt the repository).\n\n" "Agent quickstart\n" "----------------\n" " muse worktree remove feat-x --json\n\n" "JSON output schema\n" "------------------\n" ' {"name": "", "status": "removed"}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — worktree missing, invalid name, or removal refused\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) remove_p.add_argument("name", metavar="NAME", help="Worktree name to remove.") remove_p.add_argument( "--force", action="store_true", help="Force removal (accepted for interface compatibility).", ) remove_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) remove_p.set_defaults(func=run_worktree_remove) # ── repair ──────────────────────────────────────────────────────────────── repair_p = subs.add_parser( "repair", help="Write missing .muse pointer files into existing linked worktrees.", description=( "Write (or re-write) the .muse pointer file in every registered\n" "linked worktree whose directory is present on disk.\n\n" "Idempotent — safe to run multiple times. Worktrees whose\n" "directories are absent are skipped (use 'prune' for those).\n\n" "Agent quickstart\n" "----------------\n" " muse worktree repair --json\n\n" "JSON output schema\n" "------------------\n" ' {"repaired": ["", ...]}\n\n' "Exit codes\n" "----------\n" " 0 — success (even when nothing needed repair)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) repair_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) repair_p.set_defaults(func=run_worktree_repair) # ── status ──────────────────────────────────────────────────────────────── status_p = subs.add_parser( "status", help="Show the status of a single worktree.", description=( "Show branch, HEAD commit, path, and presence of a single worktree.\n\n" "Pass 'main' or '(main)' to query the main worktree. Output fields\n" "are sanitized — ANSI control sequences are stripped.\n\n" "Agent quickstart\n" "----------------\n" " muse worktree status feat-x --json\n" " muse worktree status main --json\n\n" "JSON output schema\n" "------------------\n" ' {"name": "", "branch": "", "path": "",\n' ' "head_commit": " | null", "present": true|false,\n' ' "is_main": true|false}\n\n' "Exit codes\n" "----------\n" " 0 — success\n" " 1 — worktree does not exist, or name is invalid\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) status_p.add_argument("name", metavar="NAME", help="Worktree name (or 'main' / '(main)').") status_p.add_argument( "--json", "-j", action="store_true", dest="output_json", default=False, help="Emit machine-readable JSON to stdout.", ) status_p.set_defaults(func=run_worktree_status)