Skip to content

Commit 52ac350

Browse files
dev-ankitclaude
andauthored
Add checkout_branch command to switch to existing branches (#19)
## Summary Add a new `checkout_branch()` method to create worktrees from existing branches without creating new branches. This enables users to check out branches created by others or on remote repositories directly into worktrees. ## Key Changes - **New `checkout_branch()` method** in `WorktreeManager`: - Checks out an existing branch into a new worktree (no branch creation) - Supports optional custom worktree names via `name` parameter - Supports fetching from remote before checkout via `fetch` parameter - Validates that branch exists locally or on remote - Prevents duplicate worktrees for the same branch - Stores worktree name for later retrieval by derived name - **New `_derive_name_from_branch()` static method**: - Extracts worktree name from branch name (e.g., `fix/login-bug` → `login-bug`) - Handles remote-tracking branches (strips `origin/` prefix) - Takes the last path component of hierarchical branch names - **Updated `list_worktrees()` method**: - Now checks for stored worktree names first (set by `checkout_branch()`) - Falls back to extracting name from branch if no stored name exists - Ensures worktrees created via `checkout_branch()` are findable by derived name - **New `fetch_branch()` git helper**: - Fetches a specific branch from remote - Used by `checkout_branch()` when `--fetch` flag is provided - **CLI integration** (`wt switch -c -B`): - Added `-B/--branch` option to `switch` command for checking out existing branches - Added `-f/--fetch` option to fetch before checkout - Validates flag combinations (e.g., `-B` requires `-c`, cannot be used with `-d` or `-b`) - Comprehensive test coverage for all flag combinations and edge cases ## Implementation Details - Worktree names are stored in git config (`worktree.<path>.name`) to persist the derived name - The `checkout_branch()` method uses `git worktree add` with `create_branch=False` to avoid creating new branches - Upstream tracking is configured automatically if the remote branch exists - Full backward compatibility maintained with existing worktree creation methods https://claude.ai/code/session_0189JpNR1U5W4vguS2XTetXg Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5988804 commit 52ac350

5 files changed

Lines changed: 357 additions & 4 deletions

File tree

tools/wt-worktree/tests/test_cli.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,3 +442,91 @@ def test_detached_worktree_backward_compatibility(runner, initialized_repo, no_p
442442
# Should be able to find it by inferred name
443443
found_wt = manager.find_worktree_by_name("legacy")
444444
assert found_wt is not None
445+
446+
447+
# --- checkout branch (switch -c -B) tests ---
448+
449+
450+
def test_switch_checkout_branch(runner, initialized_repo, no_prompt):
451+
"""Test wt switch -c -B with existing branch."""
452+
git.create_branch("fix/login-bug", "HEAD", initialized_repo)
453+
454+
result = runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug"])
455+
assert result.exit_code == 0
456+
assert "fix/login-bug" in result.output
457+
assert "login-bug" in result.output
458+
459+
460+
def test_switch_checkout_branch_custom_name(runner, initialized_repo, no_prompt):
461+
"""Test wt switch -c <name> -B <branch>."""
462+
git.create_branch("fix/login-bug", "HEAD", initialized_repo)
463+
464+
result = runner.invoke(cli, ["switch", "-c", "review", "-B", "fix/login-bug"])
465+
assert result.exit_code == 0
466+
assert "review" in result.output
467+
468+
469+
def test_switch_checkout_branch_nonexistent(runner, initialized_repo):
470+
"""Test wt switch -c -B with nonexistent branch."""
471+
result = runner.invoke(cli, ["switch", "-c", "-B", "nonexistent/branch"])
472+
assert result.exit_code == 3 # EXIT_GIT_ERROR
473+
assert "does not exist" in result.output
474+
475+
476+
def test_switch_checkout_branch_requires_create(runner, initialized_repo):
477+
"""Test that -B requires -c flag."""
478+
result = runner.invoke(cli, ["switch", "-B", "fix/login-bug"])
479+
assert result.exit_code == 2
480+
assert "requires" in result.output
481+
482+
483+
def test_switch_checkout_branch_no_detached(runner, initialized_repo):
484+
"""Test that -B cannot be used with -d."""
485+
result = runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug", "-d"])
486+
assert result.exit_code == 2
487+
assert "cannot be used" in result.output.lower()
488+
489+
490+
def test_switch_checkout_branch_no_base(runner, initialized_repo):
491+
"""Test that -B cannot be used with -b."""
492+
result = runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug", "-b", "main"])
493+
assert result.exit_code == 2
494+
assert "cannot be used" in result.output.lower()
495+
496+
497+
def test_switch_checkout_branch_shell_helper(runner, initialized_repo, no_prompt):
498+
"""Test wt switch -c -B with --shell-helper."""
499+
git.create_branch("fix/login-bug", "HEAD", initialized_repo)
500+
501+
result = runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug", "--shell-helper"])
502+
assert result.exit_code == 0
503+
# Output should be just the path
504+
output = result.output.strip()
505+
assert "/" in output
506+
507+
508+
def test_switch_checkout_then_switch(runner, initialized_repo, no_prompt):
509+
"""Test that wt switch works after checkout."""
510+
git.create_branch("fix/login-bug", "HEAD", initialized_repo)
511+
runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug"])
512+
513+
# Should be able to switch to it by derived name
514+
result = runner.invoke(cli, ["switch", "login-bug"])
515+
assert result.exit_code == 0
516+
517+
518+
def test_switch_checkout_then_delete(runner, initialized_repo, no_prompt):
519+
"""Test that wt delete works after checkout."""
520+
git.create_branch("fix/login-bug", "HEAD", initialized_repo)
521+
runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug"])
522+
523+
# Should be able to delete by derived name
524+
result = runner.invoke(cli, ["delete", "login-bug", "--force", "--keep-branch"])
525+
assert result.exit_code == 0
526+
527+
528+
def test_switch_checkout_fetch_requires_branch(runner, initialized_repo):
529+
"""Test that --fetch requires -B."""
530+
result = runner.invoke(cli, ["switch", "-c", "feat", "--fetch"])
531+
assert result.exit_code == 2
532+
assert "requires" in result.output

tools/wt-worktree/tests/test_worktree.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,93 @@ def test_find_by_full_branch_name(manager):
183183
assert wt1 is not None
184184
assert wt2 is not None
185185
assert wt1["path"] == wt2["path"]
186+
187+
188+
# --- checkout_branch tests ---
189+
190+
191+
def test_derive_name_from_branch():
192+
"""Test name derivation from branch names."""
193+
derive = WorktreeManager._derive_name_from_branch
194+
assert derive("fix/login-bug") == "login-bug"
195+
assert derive("feature/add-auth") == "add-auth"
196+
assert derive("origin/feature/pr-123") == "pr-123"
197+
assert derive("main") == "main"
198+
assert derive("claude/some-branch") == "some-branch"
199+
assert derive("origin/main") == "main"
200+
assert derive("a/b/c") == "c"
201+
202+
203+
def test_checkout_branch(manager, git_repo):
204+
"""Test checking out an existing branch into a new worktree."""
205+
git.create_branch("fix/login-bug", "HEAD", git_repo)
206+
207+
wt_path = manager.checkout_branch("fix/login-bug")
208+
209+
assert wt_path.exists()
210+
wt = manager.find_worktree_by_name("login-bug")
211+
assert wt is not None
212+
assert wt["branch"] == "fix/login-bug"
213+
214+
215+
def test_checkout_branch_custom_name(manager, git_repo):
216+
"""Test checkout with a custom worktree name."""
217+
git.create_branch("fix/login-bug", "HEAD", git_repo)
218+
219+
wt_path = manager.checkout_branch("fix/login-bug", name="review-login")
220+
221+
assert wt_path.exists()
222+
wt = manager.find_worktree_by_name("review-login")
223+
assert wt is not None
224+
assert wt["branch"] == "fix/login-bug"
225+
226+
227+
def test_checkout_nonexistent_branch(manager, git_repo):
228+
"""Test checkout of a branch that doesn't exist."""
229+
with pytest.raises(git.GitError, match="does not exist"):
230+
manager.checkout_branch("nonexistent/branch")
231+
232+
233+
def test_checkout_branch_already_has_worktree(manager, git_repo):
234+
"""Test checkout of a branch that already has a worktree."""
235+
git.create_branch("fix/login-bug", "HEAD", git_repo)
236+
manager.checkout_branch("fix/login-bug")
237+
238+
with pytest.raises(git.GitError, match="already has a worktree"):
239+
manager.checkout_branch("fix/login-bug")
240+
241+
242+
def test_checkout_branch_findable_by_derived_name(manager, git_repo):
243+
"""Test that checked-out worktree is findable by its derived name."""
244+
git.create_branch("claude/my-feature", "HEAD", git_repo)
245+
manager.checkout_branch("claude/my-feature")
246+
247+
# Should be findable by derived name
248+
wt = manager.find_worktree_by_name("my-feature")
249+
assert wt is not None
250+
251+
# Should also be findable by full branch name
252+
wt2 = manager.find_worktree_by_name("claude/my-feature")
253+
assert wt2 is not None
254+
assert wt["path"] == wt2["path"]
255+
256+
257+
def test_checkout_branch_appears_in_list(manager, git_repo):
258+
"""Test that checked-out worktree appears in list with correct name."""
259+
git.create_branch("fix/login-bug", "HEAD", git_repo)
260+
manager.checkout_branch("fix/login-bug")
261+
262+
worktrees = manager.list_worktrees()
263+
names = [wt["name"] for wt in worktrees]
264+
assert "login-bug" in names
265+
266+
267+
def test_checkout_branch_name_conflict(manager, git_repo):
268+
"""Test checkout when derived name conflicts with existing path."""
269+
git.create_branch("fix/bug", "HEAD", git_repo)
270+
git.create_branch("hotfix/bug", "HEAD", git_repo)
271+
272+
manager.checkout_branch("fix/bug")
273+
274+
with pytest.raises(git.GitError, match="already exists"):
275+
manager.checkout_branch("hotfix/bug")

tools/wt-worktree/wt/cli.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,68 @@ def init(ctx: Context, prefix: str, path_pattern: str):
8787
@click.argument("name", required=False)
8888
@click.option("-c", "--create", is_flag=True, help="Create worktree if it doesn't exist")
8989
@click.option("-b", "--base", help="Base branch for new worktree")
90+
@click.option("-B", "--branch", "checkout_branch", default=None,
91+
help="Checkout an existing branch into a new worktree (use with -c)")
92+
@click.option("-f", "--fetch", is_flag=True, help="Fetch branch from remote before checkout")
9093
@click.option("-d", "--detached", is_flag=True, help="Create in detached HEAD state")
9194
@click.option("--shell-helper", is_flag=True, hidden=True,
9295
help="Internal flag for shell integration")
9396
@pass_context
9497
def switch(ctx: Context, name: Optional[str], create: bool, base: Optional[str],
95-
detached: bool, shell_helper: bool):
96-
"""Switch to a worktree, optionally creating it."""
98+
checkout_branch: Optional[str], fetch: bool, detached: bool, shell_helper: bool):
99+
"""Switch to a worktree, optionally creating it.
100+
101+
Use -B/--branch to checkout an existing branch into a new worktree:
102+
103+
\b
104+
wt switch -c -B fix/login-bug
105+
wt switch -c review -B fix/login-bug
106+
wt switch -c -B fix/login-bug --fetch
107+
"""
97108
if not ctx.repo_root or not ctx.manager:
98109
error("Not in a git repository.", EXIT_GIT_ERROR)
99110
return
100111

112+
# Validate flag combinations
113+
if checkout_branch and not create:
114+
error("--branch/-B requires --create/-c", EXIT_INVALID_ARGS)
115+
return
116+
117+
if checkout_branch and detached:
118+
error("--branch/-B cannot be used with --detached/-d", EXIT_INVALID_ARGS)
119+
return
120+
121+
if checkout_branch and base:
122+
error("--branch/-B cannot be used with --base/-b", EXIT_INVALID_ARGS)
123+
return
124+
125+
if fetch and not checkout_branch:
126+
error("--fetch/-f requires --branch/-B", EXIT_INVALID_ARGS)
127+
return
128+
129+
# Handle checkout of existing branch
130+
if checkout_branch:
131+
from .worktree import WorktreeManager
132+
133+
try:
134+
wt_path = ctx.manager.checkout_branch(checkout_branch, name, fetch)
135+
display_name = name if name else WorktreeManager._derive_name_from_branch(checkout_branch)
136+
137+
# Record current worktree as previous
138+
current_wt = ctx.manager.get_current_worktree()
139+
if current_wt:
140+
ctx.previous_worktree_file.write_text(str(current_wt["path"]))
141+
142+
if shell_helper:
143+
print(wt_path)
144+
else:
145+
success(f"Checked out '{checkout_branch}' into worktree '{display_name}'")
146+
info(f"Run: cd {wt_path}")
147+
148+
except git.GitError as e:
149+
error(str(e), EXIT_GIT_ERROR)
150+
return
151+
101152
# Handle special names
102153
if name == "-":
103154
# Switch to previous worktree

tools/wt-worktree/wt/git.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,21 @@ def fetch_remote(remote: str = "origin", path: Optional[Path] = None):
493493
run_git(["fetch", remote], cwd=path)
494494

495495

496+
def fetch_branch(branch: str, remote: str = "origin", path: Optional[Path] = None):
497+
"""
498+
Fetch a specific branch from remote.
499+
500+
Args:
501+
branch: Branch name to fetch
502+
remote: Remote name
503+
path: Repository path
504+
505+
Raises:
506+
GitError: If fetch fails
507+
"""
508+
run_git(["fetch", remote, branch], cwd=path)
509+
510+
496511
def enable_worktree_config(path: Path):
497512
"""
498513
Enable worktree-specific config support.

0 commit comments

Comments
 (0)