worktree.py
python
| 1 | """``muse worktree`` — manage multiple simultaneous branch checkouts. |
| 2 | |
| 3 | Worktrees let you work on multiple branches at once without stashing or |
| 4 | switching — each worktree is an independent ``state/`` directory, but they |
| 5 | all share the same ``.muse/`` object store. |
| 6 | |
| 7 | This is especially powerful for agents: one agent per worktree, each |
| 8 | autonomously developing a feature on its own branch, with zero interference. |
| 9 | |
| 10 | Subcommands:: |
| 11 | |
| 12 | muse worktree add <name> <branch> — create a new linked worktree |
| 13 | muse worktree list — list all worktrees |
| 14 | muse worktree remove <name> — remove a linked worktree |
| 15 | muse worktree prune — remove metadata for missing worktrees |
| 16 | |
| 17 | Layout:: |
| 18 | |
| 19 | myproject/ ← main worktree |
| 20 | state/ ← main working files |
| 21 | .muse/ ← shared store |
| 22 | |
| 23 | myproject-feat-audio/ ← linked worktree for feat/audio |
| 24 | state/ |
| 25 | """ |
| 26 | |
| 27 | from __future__ import annotations |
| 28 | |
| 29 | import logging |
| 30 | |
| 31 | import typer |
| 32 | |
| 33 | from muse.core.errors import ExitCode |
| 34 | from muse.core.repo import require_repo |
| 35 | from muse.core.validation import sanitize_display |
| 36 | from muse.core.worktree import ( |
| 37 | WorktreeInfo, |
| 38 | add_worktree, |
| 39 | list_worktrees, |
| 40 | prune_worktrees, |
| 41 | remove_worktree, |
| 42 | ) |
| 43 | |
| 44 | logger = logging.getLogger(__name__) |
| 45 | app = typer.Typer( |
| 46 | help="Manage multiple simultaneous branch checkouts.", |
| 47 | no_args_is_help=True, |
| 48 | ) |
| 49 | |
| 50 | |
| 51 | def _fmt_info(wt: WorktreeInfo) -> str: |
| 52 | prefix = "* " if wt.is_main else " " |
| 53 | head = wt.head_commit[:12] if wt.head_commit else "(no commits)" |
| 54 | return f"{prefix}{wt.name:<24} {sanitize_display(wt.branch):<30} {head} {wt.path}" |
| 55 | |
| 56 | |
| 57 | @app.command("add") |
| 58 | def worktree_add( |
| 59 | name: str = typer.Argument(..., help="Short identifier for the worktree (no spaces)."), |
| 60 | branch: str = typer.Argument(..., help="Branch to check out in the new worktree."), |
| 61 | ) -> None: |
| 62 | """Create a new linked worktree checked out at *branch*. |
| 63 | |
| 64 | The new worktree is created as a sibling directory of the repository root, |
| 65 | named ``<repo>-<name>``. Its ``state/`` directory is pre-populated from |
| 66 | the branch's latest snapshot. |
| 67 | |
| 68 | Examples:: |
| 69 | |
| 70 | muse worktree add feat-audio feat/audio |
| 71 | muse worktree add hotfix-001 hotfix/001 |
| 72 | """ |
| 73 | root = require_repo() |
| 74 | try: |
| 75 | wt_path = add_worktree(root, name, branch) |
| 76 | except ValueError as exc: |
| 77 | typer.echo(f"❌ {exc}") |
| 78 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 79 | typer.echo(f"✅ Worktree '{sanitize_display(name)}' created at {wt_path}") |
| 80 | typer.echo(f" Branch: {sanitize_display(branch)}") |
| 81 | |
| 82 | |
| 83 | @app.command("list") |
| 84 | def worktree_list() -> None: |
| 85 | """List all worktrees (main + linked).""" |
| 86 | root = require_repo() |
| 87 | worktrees = list_worktrees(root) |
| 88 | if not worktrees: |
| 89 | typer.echo("No worktrees.") |
| 90 | return |
| 91 | header = f"{' name':<26} {'branch':<30} {'HEAD':12} path" |
| 92 | typer.echo(header) |
| 93 | typer.echo("-" * len(header)) |
| 94 | for wt in worktrees: |
| 95 | typer.echo(_fmt_info(wt)) |
| 96 | |
| 97 | |
| 98 | @app.command("remove") |
| 99 | def worktree_remove( |
| 100 | name: str = typer.Argument(..., help="Name of the worktree to remove."), |
| 101 | force: bool = typer.Option(False, "--force", "-f", help="Remove even if the worktree has unsaved changes."), |
| 102 | ) -> None: |
| 103 | """Remove a linked worktree and its state/ directory. |
| 104 | |
| 105 | The branch itself is not deleted — only the worktree directory and its |
| 106 | metadata are removed. Commits already pushed from the worktree remain in |
| 107 | the shared store. |
| 108 | """ |
| 109 | root = require_repo() |
| 110 | try: |
| 111 | remove_worktree(root, name, force=force) |
| 112 | except ValueError as exc: |
| 113 | typer.echo(f"❌ {exc}") |
| 114 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 115 | typer.echo(f"✅ Worktree '{sanitize_display(name)}' removed.") |
| 116 | |
| 117 | |
| 118 | @app.command("prune") |
| 119 | def worktree_prune() -> None: |
| 120 | """Remove metadata entries for worktrees whose directories no longer exist.""" |
| 121 | root = require_repo() |
| 122 | pruned = prune_worktrees(root) |
| 123 | if not pruned: |
| 124 | typer.echo("Nothing to prune.") |
| 125 | return |
| 126 | for name in pruned: |
| 127 | typer.echo(f" pruned: {sanitize_display(name)}") |
| 128 | typer.echo(f"Pruned {len(pruned)} stale worktree(s).") |