cgcardona / muse public
worktree.py python
128 lines 4.1 KB
e0353dfe feat: muse reflog, gc, archive, bisect, blame, worktree, workspace Gabriel Cardona <cgcardona@gmail.com> 6h ago
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).")