diff --git a/tools/wt-worktree/tests/test_cli.py b/tools/wt-worktree/tests/test_cli.py index 0ec6da8..fedf1f9 100644 --- a/tools/wt-worktree/tests/test_cli.py +++ b/tools/wt-worktree/tests/test_cli.py @@ -442,3 +442,91 @@ def test_detached_worktree_backward_compatibility(runner, initialized_repo, no_p # Should be able to find it by inferred name found_wt = manager.find_worktree_by_name("legacy") assert found_wt is not None + + +# --- checkout branch (switch -c -B) tests --- + + +def test_switch_checkout_branch(runner, initialized_repo, no_prompt): + """Test wt switch -c -B with existing branch.""" + git.create_branch("fix/login-bug", "HEAD", initialized_repo) + + result = runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug"]) + assert result.exit_code == 0 + assert "fix/login-bug" in result.output + assert "login-bug" in result.output + + +def test_switch_checkout_branch_custom_name(runner, initialized_repo, no_prompt): + """Test wt switch -c -B .""" + git.create_branch("fix/login-bug", "HEAD", initialized_repo) + + result = runner.invoke(cli, ["switch", "-c", "review", "-B", "fix/login-bug"]) + assert result.exit_code == 0 + assert "review" in result.output + + +def test_switch_checkout_branch_nonexistent(runner, initialized_repo): + """Test wt switch -c -B with nonexistent branch.""" + result = runner.invoke(cli, ["switch", "-c", "-B", "nonexistent/branch"]) + assert result.exit_code == 3 # EXIT_GIT_ERROR + assert "does not exist" in result.output + + +def test_switch_checkout_branch_requires_create(runner, initialized_repo): + """Test that -B requires -c flag.""" + result = runner.invoke(cli, ["switch", "-B", "fix/login-bug"]) + assert result.exit_code == 2 + assert "requires" in result.output + + +def test_switch_checkout_branch_no_detached(runner, initialized_repo): + """Test that -B cannot be used with -d.""" + result = runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug", "-d"]) + assert result.exit_code == 2 + assert "cannot be used" in result.output.lower() + + +def test_switch_checkout_branch_no_base(runner, initialized_repo): + """Test that -B cannot be used with -b.""" + result = runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug", "-b", "main"]) + assert result.exit_code == 2 + assert "cannot be used" in result.output.lower() + + +def test_switch_checkout_branch_shell_helper(runner, initialized_repo, no_prompt): + """Test wt switch -c -B with --shell-helper.""" + git.create_branch("fix/login-bug", "HEAD", initialized_repo) + + result = runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug", "--shell-helper"]) + assert result.exit_code == 0 + # Output should be just the path + output = result.output.strip() + assert "/" in output + + +def test_switch_checkout_then_switch(runner, initialized_repo, no_prompt): + """Test that wt switch works after checkout.""" + git.create_branch("fix/login-bug", "HEAD", initialized_repo) + runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug"]) + + # Should be able to switch to it by derived name + result = runner.invoke(cli, ["switch", "login-bug"]) + assert result.exit_code == 0 + + +def test_switch_checkout_then_delete(runner, initialized_repo, no_prompt): + """Test that wt delete works after checkout.""" + git.create_branch("fix/login-bug", "HEAD", initialized_repo) + runner.invoke(cli, ["switch", "-c", "-B", "fix/login-bug"]) + + # Should be able to delete by derived name + result = runner.invoke(cli, ["delete", "login-bug", "--force", "--keep-branch"]) + assert result.exit_code == 0 + + +def test_switch_checkout_fetch_requires_branch(runner, initialized_repo): + """Test that --fetch requires -B.""" + result = runner.invoke(cli, ["switch", "-c", "feat", "--fetch"]) + assert result.exit_code == 2 + assert "requires" in result.output diff --git a/tools/wt-worktree/tests/test_worktree.py b/tools/wt-worktree/tests/test_worktree.py index a7cfc05..ee3e40e 100644 --- a/tools/wt-worktree/tests/test_worktree.py +++ b/tools/wt-worktree/tests/test_worktree.py @@ -183,3 +183,93 @@ def test_find_by_full_branch_name(manager): assert wt1 is not None assert wt2 is not None assert wt1["path"] == wt2["path"] + + +# --- checkout_branch tests --- + + +def test_derive_name_from_branch(): + """Test name derivation from branch names.""" + derive = WorktreeManager._derive_name_from_branch + assert derive("fix/login-bug") == "login-bug" + assert derive("feature/add-auth") == "add-auth" + assert derive("origin/feature/pr-123") == "pr-123" + assert derive("main") == "main" + assert derive("claude/some-branch") == "some-branch" + assert derive("origin/main") == "main" + assert derive("a/b/c") == "c" + + +def test_checkout_branch(manager, git_repo): + """Test checking out an existing branch into a new worktree.""" + git.create_branch("fix/login-bug", "HEAD", git_repo) + + wt_path = manager.checkout_branch("fix/login-bug") + + assert wt_path.exists() + wt = manager.find_worktree_by_name("login-bug") + assert wt is not None + assert wt["branch"] == "fix/login-bug" + + +def test_checkout_branch_custom_name(manager, git_repo): + """Test checkout with a custom worktree name.""" + git.create_branch("fix/login-bug", "HEAD", git_repo) + + wt_path = manager.checkout_branch("fix/login-bug", name="review-login") + + assert wt_path.exists() + wt = manager.find_worktree_by_name("review-login") + assert wt is not None + assert wt["branch"] == "fix/login-bug" + + +def test_checkout_nonexistent_branch(manager, git_repo): + """Test checkout of a branch that doesn't exist.""" + with pytest.raises(git.GitError, match="does not exist"): + manager.checkout_branch("nonexistent/branch") + + +def test_checkout_branch_already_has_worktree(manager, git_repo): + """Test checkout of a branch that already has a worktree.""" + git.create_branch("fix/login-bug", "HEAD", git_repo) + manager.checkout_branch("fix/login-bug") + + with pytest.raises(git.GitError, match="already has a worktree"): + manager.checkout_branch("fix/login-bug") + + +def test_checkout_branch_findable_by_derived_name(manager, git_repo): + """Test that checked-out worktree is findable by its derived name.""" + git.create_branch("claude/my-feature", "HEAD", git_repo) + manager.checkout_branch("claude/my-feature") + + # Should be findable by derived name + wt = manager.find_worktree_by_name("my-feature") + assert wt is not None + + # Should also be findable by full branch name + wt2 = manager.find_worktree_by_name("claude/my-feature") + assert wt2 is not None + assert wt["path"] == wt2["path"] + + +def test_checkout_branch_appears_in_list(manager, git_repo): + """Test that checked-out worktree appears in list with correct name.""" + git.create_branch("fix/login-bug", "HEAD", git_repo) + manager.checkout_branch("fix/login-bug") + + worktrees = manager.list_worktrees() + names = [wt["name"] for wt in worktrees] + assert "login-bug" in names + + +def test_checkout_branch_name_conflict(manager, git_repo): + """Test checkout when derived name conflicts with existing path.""" + git.create_branch("fix/bug", "HEAD", git_repo) + git.create_branch("hotfix/bug", "HEAD", git_repo) + + manager.checkout_branch("fix/bug") + + with pytest.raises(git.GitError, match="already exists"): + manager.checkout_branch("hotfix/bug") diff --git a/tools/wt-worktree/wt/cli.py b/tools/wt-worktree/wt/cli.py index 2d721f3..4e1685a 100644 --- a/tools/wt-worktree/wt/cli.py +++ b/tools/wt-worktree/wt/cli.py @@ -87,17 +87,68 @@ def init(ctx: Context, prefix: str, path_pattern: str): @click.argument("name", required=False) @click.option("-c", "--create", is_flag=True, help="Create worktree if it doesn't exist") @click.option("-b", "--base", help="Base branch for new worktree") +@click.option("-B", "--branch", "checkout_branch", default=None, + help="Checkout an existing branch into a new worktree (use with -c)") +@click.option("-f", "--fetch", is_flag=True, help="Fetch branch from remote before checkout") @click.option("-d", "--detached", is_flag=True, help="Create in detached HEAD state") @click.option("--shell-helper", is_flag=True, hidden=True, help="Internal flag for shell integration") @pass_context def switch(ctx: Context, name: Optional[str], create: bool, base: Optional[str], - detached: bool, shell_helper: bool): - """Switch to a worktree, optionally creating it.""" + checkout_branch: Optional[str], fetch: bool, detached: bool, shell_helper: bool): + """Switch to a worktree, optionally creating it. + + Use -B/--branch to checkout an existing branch into a new worktree: + + \b + wt switch -c -B fix/login-bug + wt switch -c review -B fix/login-bug + wt switch -c -B fix/login-bug --fetch + """ if not ctx.repo_root or not ctx.manager: error("Not in a git repository.", EXIT_GIT_ERROR) return + # Validate flag combinations + if checkout_branch and not create: + error("--branch/-B requires --create/-c", EXIT_INVALID_ARGS) + return + + if checkout_branch and detached: + error("--branch/-B cannot be used with --detached/-d", EXIT_INVALID_ARGS) + return + + if checkout_branch and base: + error("--branch/-B cannot be used with --base/-b", EXIT_INVALID_ARGS) + return + + if fetch and not checkout_branch: + error("--fetch/-f requires --branch/-B", EXIT_INVALID_ARGS) + return + + # Handle checkout of existing branch + if checkout_branch: + from .worktree import WorktreeManager + + try: + wt_path = ctx.manager.checkout_branch(checkout_branch, name, fetch) + display_name = name if name else WorktreeManager._derive_name_from_branch(checkout_branch) + + # Record current worktree as previous + current_wt = ctx.manager.get_current_worktree() + if current_wt: + ctx.previous_worktree_file.write_text(str(current_wt["path"])) + + if shell_helper: + print(wt_path) + else: + success(f"Checked out '{checkout_branch}' into worktree '{display_name}'") + info(f"Run: cd {wt_path}") + + except git.GitError as e: + error(str(e), EXIT_GIT_ERROR) + return + # Handle special names if name == "-": # Switch to previous worktree diff --git a/tools/wt-worktree/wt/git.py b/tools/wt-worktree/wt/git.py index 40c3736..8d63a0a 100644 --- a/tools/wt-worktree/wt/git.py +++ b/tools/wt-worktree/wt/git.py @@ -493,6 +493,21 @@ def fetch_remote(remote: str = "origin", path: Optional[Path] = None): run_git(["fetch", remote], cwd=path) +def fetch_branch(branch: str, remote: str = "origin", path: Optional[Path] = None): + """ + Fetch a specific branch from remote. + + Args: + branch: Branch name to fetch + remote: Remote name + path: Repository path + + Raises: + GitError: If fetch fails + """ + run_git(["fetch", remote, branch], cwd=path) + + def enable_worktree_config(path: Path): """ Enable worktree-specific config support. diff --git a/tools/wt-worktree/wt/worktree.py b/tools/wt-worktree/wt/worktree.py index a5f8828..05a4e76 100644 --- a/tools/wt-worktree/wt/worktree.py +++ b/tools/wt-worktree/wt/worktree.py @@ -67,9 +67,14 @@ def list_worktrees(self) -> List[dict]: except git.GitError: wt["message"] = "" - # Extract worktree name from branch or config + # Extract worktree name from stored config, branch, or other sources if wt.get("branch"): - wt["name"] = self.config.extract_worktree_name(wt["branch"]) + # Check for stored name first (used by checkout_branch) + stored_name = git.get_worktree_name(wt["path"]) + if stored_name: + wt["name"] = stored_name + else: + wt["name"] = self.config.extract_worktree_name(wt["branch"]) else: # For detached worktrees, try multiple sources stored_name = git.get_worktree_name(wt["path"]) @@ -244,6 +249,110 @@ def create_worktree(self, name: str, base: Optional[str] = None, return wt_path + def checkout_branch(self, branch: str, name: Optional[str] = None, + fetch: bool = False) -> Path: + """ + Checkout an existing branch into a new worktree. + + Unlike create_worktree(), this uses the branch name as-is (no prefix). + + Args: + branch: Full branch name (e.g., 'fix/login-bug', 'claude/some-branch') + name: Optional custom worktree name. If None, derived from branch. + fetch: Fetch from remote before checking out. + + Returns: + Path to created worktree + + Raises: + git.GitError: If checkout fails + """ + # Derive worktree name if not provided + if name is None: + name = self._derive_name_from_branch(branch) + + # Optionally fetch from remote + if fetch: + try: + git.fetch_branch(branch, "origin", self.repo_root) + except git.GitError: + pass + + # Validate branch exists (locally or as a remote tracking branch) + local_exists = git.branch_exists(branch, self.repo_root) + remote_exists = git.remote_branch_exists(branch, "origin", self.repo_root) + + if not local_exists and not remote_exists: + raise git.GitError( + f"Branch '{branch}' does not exist locally or on remote.\n" + f"Use '--fetch' to fetch from remote first, or check the branch name." + ) + + # Check if branch already has a worktree + exists, existing_path = git.worktree_exists(branch, self.repo_root) + if exists: + raise git.GitError( + f"Branch '{branch}' already has a worktree at {existing_path}\n" + f"Use 'wt switch {name}' to switch to it." + ) + + # Compute worktree path + wt_path = self.config.resolve_path_pattern(name, branch) + + if wt_path.exists(): + raise git.GitError( + f"Path {wt_path} already exists. " + f"Please remove it or use --name to choose a different name." + ) + + # Create worktree using existing branch + try: + git.add_worktree(wt_path, branch, create_branch=False, + repo_path=self.repo_root) + except git.GitError as e: + raise git.GitError(f"Failed to create worktree: {e}") + + # Store worktree name so list_worktrees() can find it by derived name + git.set_worktree_name(name, wt_path) + + # Set upstream tracking if remote exists + if remote_exists: + try: + git.set_upstream(branch, "origin", branch, self.repo_root) + except git.GitError: + try: + git.configure_push_remote(branch, "origin", branch, wt_path) + except git.GitError: + pass + else: + try: + git.configure_push_remote(branch, "origin", branch, wt_path) + except git.GitError: + pass + + return wt_path + + @staticmethod + def _derive_name_from_branch(branch: str) -> str: + """ + Derive a worktree name from a branch name. + + Takes the last path component of the branch name. + + Examples: + fix/login-bug -> login-bug + feature/add-auth -> add-auth + origin/feature/pr -> pr + main -> main + """ + clean = branch + if clean.startswith("origin/"): + clean = clean[len("origin/"):] + + if "/" in clean: + return clean.rsplit("/", 1)[1] + return clean + def delete_worktree(self, name: str, force: bool = False, keep_branch: bool = False) -> bool: """