diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8350f8f32..83ce1f8e6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,7 @@ ## New in git-machete 3.37.1 +- improved: `traverse` now changes directory to the worktree where a branch is checked out, rather than failing (suggested by @lsierant) - fixed: use `simple` (rather than `none`) mode of squash merge detection in `git machete discover`, to keep parity with the IntelliJ plugin ## New in git-machete 3.37.0 diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1 index 30c197b1f..7840b416a 100644 --- a/docs/man/git-machete.1 +++ b/docs/man/git-machete.1 @@ -2143,6 +2143,8 @@ Operations like \fBgit machete github anno\-prs\fP (\fBgit machete gitlab anno\- and \fBgit machete github checkout\-prs\fP (\fBgit machete gitlab checkout\-mrs\fP) add \fBrebase=no push=no\fP branch qualifiers when the current user is NOT the author of the PR/MR associated with that branch. .sp +\fBNote on git worktrees:\fP if a branch is already checked out in another worktree, \fBtraverse\fP will change directory to that worktree rather than failing. +.sp \fBOptions:\fP .INDENT 0.0 .TP diff --git a/docs/source/cli/traverse.rst b/docs/source/cli/traverse.rst index 6a5cd0b30..8e6cdea5f 100644 --- a/docs/source/cli/traverse.rst +++ b/docs/source/cli/traverse.rst @@ -91,6 +91,8 @@ Operations like ``git machete github anno-prs`` (``git machete gitlab anno-mrs`` and ``git machete github checkout-prs`` (``git machete gitlab checkout-mrs``) add ``rebase=no push=no`` branch qualifiers when the current user is NOT the author of the PR/MR associated with that branch. +**Note on git worktrees:** if a branch is already checked out in another worktree, ``traverse`` will change directory to that worktree rather than failing. + **Options:** -F, --fetch Fetch the remotes of all managed branches at the beginning of traversal (no ``git pull`` involved, only ``git fetch``). diff --git a/git_machete/client/traverse.py b/git_machete/client/traverse.py index 6d2bc0e6e..649d5b618 100644 --- a/git_machete/client/traverse.py +++ b/git_machete/client/traverse.py @@ -1,6 +1,6 @@ import itertools from enum import auto -from typing import List, Optional, Type, Union +from typing import Dict, List, Optional, Type, Union from git_machete.annotation import Annotation, Qualifiers from git_machete.client.base import (ParsableEnum, PickRoot, @@ -11,7 +11,7 @@ from git_machete.git_operations import (GitContext, LocalBranchShortName, SyncToRemoteStatus) from git_machete.utils import (bold, flat_map, fmt, get_pretty_choices, - get_right_arrow) + get_right_arrow, warn) class TraverseReturnTo(ParsableEnum): @@ -46,6 +46,51 @@ def from_string_or_branch(cls: Type['TraverseStartFrom'], value: str, class TraverseMacheteClient(MacheteClientWithCodeHosting): + def _update_worktrees_cache_after_checkout(self, checked_out_branch: LocalBranchShortName) -> None: + """ + Update the worktrees cache after a checkout operation in the current worktree. + This avoids the need to re-fetch the full worktree list. + + Only the current worktree's entry needs to be updated - linked worktrees + don't change when we checkout in a different worktree. + """ + current_worktree_root_dir = self._git.get_current_worktree_root_dir() + + for branch, path in self.__worktree_root_dir_for_branch.items(): + if path == current_worktree_root_dir: # pragma: no branch + del self.__worktree_root_dir_for_branch[branch] + break + + self.__worktree_root_dir_for_branch[checked_out_branch] = current_worktree_root_dir + + def _switch_to_branch_worktree( + self, + target_branch: LocalBranchShortName) -> None: + """ + Switch to the worktree where the branch is checked out, or to main worktree if branch is not checked out. + This may involve changing the current working directory. + Updates the worktrees cache after checkout. + """ + target_worktree_root_dir = self.__worktree_root_dir_for_branch.get(target_branch) + current_worktree_root_dir = self._git.get_current_worktree_root_dir() + + if target_worktree_root_dir is None: + # Branch is not checked out anywhere, need to checkout in main worktree + # Only cd if we're currently in a different worktree (linked worktree) + main_worktree_root_dir = self._git.get_main_worktree_root_dir() + if current_worktree_root_dir != main_worktree_root_dir: + print(f"Changing directory to main worktree at {bold(main_worktree_root_dir)}") + self._git.chdir(main_worktree_root_dir) + self._git.checkout(target_branch) + # Update cache after checkout + self._update_worktrees_cache_after_checkout(target_branch) + else: + # Branch is checked out in a worktree + # Only cd if we're in a different worktree + if current_worktree_root_dir != target_worktree_root_dir: + print(f"Changing directory to {bold(target_worktree_root_dir)} worktree where {bold(target_branch)} is checked out") + self._git.chdir(target_worktree_root_dir) + def traverse( self, *, @@ -86,21 +131,26 @@ def traverse( self._init_code_hosting_client() current_user = self.code_hosting_client.get_current_user_login() + # Store the initial directory for later restoration initial_branch = nearest_remaining_branch = self._git.get_current_branch() + initial_worktree_root = self._git.get_current_worktree_root_dir() + + # Fetch worktrees once at the start to avoid repeated git worktree list calls + self.__worktree_root_dir_for_branch: Dict[LocalBranchShortName, str] = self._git.get_worktree_root_dirs_by_branch() try: if opt_start_from == TraverseStartFrom.ROOT: dest = self.root_branch_for(self._git.get_current_branch(), if_unmanaged=PickRoot.FIRST) self._print_new_line(False) print(f"Checking out the root branch ({bold(dest)})") - self._git.checkout(dest) + self._switch_to_branch_worktree(dest) current_branch = dest elif opt_start_from == TraverseStartFrom.FIRST_ROOT: # Note that we already ensured that there is at least one managed branch. dest = self.managed_branches[0] self._print_new_line(False) print(f"Checking out the first root branch ({bold(dest)})") - self._git.checkout(dest) + self._switch_to_branch_worktree(dest) current_branch = dest elif opt_start_from == TraverseStartFrom.HERE: current_branch = self._git.get_current_branch() @@ -110,7 +160,7 @@ def traverse( self.expect_in_managed_branches(dest) self._print_new_line(False) print(f"Checking out branch {bold(dest)}") - self._git.checkout(dest) + self._switch_to_branch_worktree(dest) current_branch = dest else: raise UnexpectedMacheteException(f"Unexpected value for opt_start_from: {opt_start_from}") @@ -187,7 +237,7 @@ def traverse( if branch != current_branch and needs_any_action: self._print_new_line(False) print(f"Checking out {bold(branch)}") - self._git.checkout(branch) + self._switch_to_branch_worktree(branch) current_branch = branch self._print_new_line(False) self.status( @@ -411,9 +461,12 @@ def traverse( break if opt_return_to == TraverseReturnTo.HERE: - self._git.checkout(initial_branch) + # Return to initial branch + # No point switching back to initial directory as cwd won't propagate back to the calling shell anyway + self._switch_to_branch_worktree(initial_branch) elif opt_return_to == TraverseReturnTo.NEAREST_REMAINING: - self._git.checkout(nearest_remaining_branch) + self._switch_to_branch_worktree(nearest_remaining_branch) + # For NEAREST_REMAINING, we stay in the worktree where the branch is checked out # otherwise opt_return_to == TraverseReturnTo.STAY, so no action is needed self._print_new_line(False) @@ -440,4 +493,12 @@ def traverse( f"The initial branch {bold(initial_branch)} has been slid out. " f"Returned to nearest remaining managed branch {bold(nearest_remaining_branch)}") finally: - pass + # Warn if the initial directory doesn't correspond to the final checked out branch's worktree + final_branch = self._git.get_current_branch() + final_worktree_path = self.__worktree_root_dir_for_branch.get(final_branch) + if final_worktree_path and initial_worktree_root != final_worktree_path: + # Final branch is checked out in a worktree different from where we started + warn( + f"branch {bold(final_branch)} is checked out in worktree at {bold(final_worktree_path)}\n" + f"You may want to change directory with:\n" + f" `cd {final_worktree_path}`") diff --git a/git_machete/generated_docs.py b/git_machete/generated_docs.py index 287cbf4be..676a615b4 100644 --- a/git_machete/generated_docs.py +++ b/git_machete/generated_docs.py @@ -1485,6 +1485,8 @@ and `git machete github checkout-prs` (`git machete gitlab checkout-mrs`) add `rebase=no push=no` branch qualifiers when the current user is NOT the author of the PR/MR associated with that branch. + Note on git worktrees: if a branch is already checked out in another worktree, `traverse` will change directory to that worktree rather than failing. + Options: -F, --fetch diff --git a/git_machete/git_operations.py b/git_machete/git_operations.py index 0521e1a55..d5d737fea 100644 --- a/git_machete/git_operations.py +++ b/git_machete/git_operations.py @@ -219,6 +219,7 @@ def __init__(self) -> None: self.owner: Optional[Any] = None self.__git_version: Optional[Tuple[int, int, int]] = None + self.__main_worktree_root_dir: Optional[str] = None self.__main_worktree_git_dir: Optional[str] = None self.__current_worktree_root_dir: Optional[str] = None self.__current_worktree_git_dir: Optional[str] = None @@ -256,8 +257,13 @@ def flush_caches(self) -> None: self.__short_commit_hash_by_revision_cached = {} self.__flush_current_worktree_caches() + def chdir(self, path: str) -> None: + os.chdir(path) + self.__flush_current_worktree_caches() + def __flush_current_worktree_caches(self) -> None: self.__current_worktree_root_dir = None + self.__main_worktree_root_dir = None def _run_git(self, git_cmd: str, *args: str, flush_caches: bool, allow_non_zero: bool = False) -> int: exit_code = utils.run_cmd(*GIT_EXEC, git_cmd, *args) @@ -573,6 +579,75 @@ def is_merge_in_progress(self) -> bool: def is_revert_in_progress(self) -> bool: return os.path.isfile(self.get_current_worktree_git_subpath("REVERT_HEAD")) + def get_worktree_root_dirs_by_branch(self) -> Dict[LocalBranchShortName, str]: + """ + Returns a dict mapping branch names to their worktree paths. + The main worktree's current branch is included if it's on a branch. + Branches not checked out in any worktree are not in the dict. + Worktrees in detached HEAD state are not included (since they can't be looked up by branch name). + """ + if self.get_git_version() < (2, 5): + # git worktree command was introduced in git 2.5 + return {} + + result = self._popen_git("worktree", "list", "--porcelain") + worktrees: Dict[LocalBranchShortName, str] = {} + + current_worktree_path: Optional[str] = None + current_branch: Optional[LocalBranchShortName] = None + is_detached: bool = False + + for line in result.stdout.strip().splitlines(): + if line.startswith('worktree '): + current_worktree_path = line[len('worktree '):] + elif line.startswith('branch '): + # Format: "branch refs/heads/" + branch_ref = line[len('branch '):] + current_branch = LocalBranchFullName.of(branch_ref).to_short_name() + is_detached = False + elif line.startswith('detached'): + is_detached = True + current_branch = None + elif line == '': + # Empty line marks end of worktree entry + # Only add worktrees that have a branch (not in detached HEAD state) + if current_worktree_path and current_branch is not None and not is_detached: + worktrees[current_branch] = current_worktree_path + current_worktree_path = None + current_branch = None + is_detached = False + + # Handle last entry if no trailing newline + if current_worktree_path and current_branch is not None and not is_detached: + worktrees[current_branch] = current_worktree_path + + return worktrees + + def get_main_worktree_root_dir(self) -> str: + """ + Returns the path to the main worktree (not a linked worktree). + """ + if not self.__main_worktree_root_dir: + if self.get_git_version() < (2, 5): + # git worktree command was introduced in git 2.5 + # If worktrees aren't supported, just return the current root dir + self.__main_worktree_root_dir = self.get_current_worktree_root_dir() + else: + # We can't rely on `git rev-parse --git-common-dir` + # since in some earlier supported versions of git + # this path is apparently printed in a faulty way when dealing with worktrees. + # Let's parse the output of of `git worktree list` instead. + result = self._popen_git("worktree", "list", "--porcelain") + + # The first worktree in the list is always the main worktree + first_entry = result.stdout.splitlines()[0] + if first_entry.startswith('worktree '): + self.__main_worktree_root_dir = first_entry[len('worktree '):] + else: + raise UnexpectedMacheteException("Could not obtain the main worktree path from " + f"`git worktree list --porcelain` output (first line: `{first_entry}`)") + return self.__main_worktree_root_dir + def checkout(self, branch: LocalBranchShortName) -> None: self._run_git("checkout", "--quiet", branch, "--", flush_caches=True) diff --git a/tests/test_git_operations.py b/tests/test_git_operations.py index c1ed3616a..66ef75246 100644 --- a/tests/test_git_operations.py +++ b/tests/test_git_operations.py @@ -1,3 +1,4 @@ +import os from git_machete.git_operations import (AnyBranchName, AnyRevision, FullCommitHash, GitContext, @@ -5,10 +6,11 @@ from .base_test import BaseTest from .mockers import write_to_file -from .mockers_git_repository import (check_out, commit, create_repo, - get_current_commit_hash, - is_ancestor_or_equal, new_branch, - new_orphan_branch, set_git_config_key) +from .mockers_git_repository import (add_worktree, check_out, commit, + create_repo, get_current_commit_hash, + get_git_version, is_ancestor_or_equal, + new_branch, new_orphan_branch, + set_git_config_key) class TestGitOperations(BaseTest): @@ -197,3 +199,103 @@ def test_get_reflog_for_branch_with_at_sign(self) -> None: # If the bug reported in GitHub issue #1481 is not fixed, this method call # should raise an UnexpectedMacheteException. git.get_reflog(AnyBranchName.of("feature@foo")) + + def test_get_worktree_root_dirs_by_branch(self) -> None: + if get_git_version() < (2, 5): + # git worktree command was introduced in git 2.5 + return + + create_repo() + new_branch("main") + commit("main commit") + new_branch("feature-1") + commit("feature-1 commit") + new_branch("feature-2") + commit("feature-2 commit") + new_branch("feature-3") + commit("feature-3 commit") + + git = GitContext() + main_worktree_path = git.get_current_worktree_root_dir() + + # Test 1: Main worktree on a branch, no linked worktrees + check_out("main") + worktrees = git.get_worktree_root_dirs_by_branch() + assert len(worktrees) == 1 + assert worktrees.get(LocalBranchShortName.of("main")) == main_worktree_path + assert git.get_worktree_root_dirs_by_branch().get(LocalBranchShortName.of("main")) == main_worktree_path + assert git.get_worktree_root_dirs_by_branch().get(LocalBranchShortName.of("feature-1")) is None + + # Test 2: Create linked worktrees + feature1_worktree = add_worktree("feature-1") + feature2_worktree = add_worktree("feature-2") + + worktrees = git.get_worktree_root_dirs_by_branch() + assert len(worktrees) == 3 + assert worktrees.get(LocalBranchShortName.of("main")) == main_worktree_path + # On macOS, paths may have /private prefix, so use realpath to normalize + feature1_path = worktrees.get(LocalBranchShortName.of("feature-1")) + assert feature1_path is not None + assert os.path.realpath(feature1_path) == os.path.realpath(feature1_worktree) + feature2_path = worktrees.get(LocalBranchShortName.of("feature-2")) + assert feature2_path is not None + assert os.path.realpath(feature2_path) == os.path.realpath(feature2_worktree) + assert git.get_worktree_root_dirs_by_branch().get(LocalBranchShortName.of("feature-3")) is None + + # Test 3: Main worktree in detached HEAD (should NOT be in dict) + main_commit = git.get_commit_hash_by_revision(LocalBranchShortName.of("main")) + assert main_commit is not None + check_out(main_commit) # Detach HEAD in main worktree + + worktrees = git.get_worktree_root_dirs_by_branch() + assert len(worktrees) == 2 # Only feature-1 and feature-2, not the detached main + assert LocalBranchShortName.of("main") not in worktrees + feature1_path = worktrees.get(LocalBranchShortName.of("feature-1")) + assert feature1_path is not None + assert os.path.realpath(feature1_path) == os.path.realpath(feature1_worktree) + feature2_path = worktrees.get(LocalBranchShortName.of("feature-2")) + assert feature2_path is not None + assert os.path.realpath(feature2_path) == os.path.realpath(feature2_worktree) + + # Test 4: Linked worktree in detached HEAD (should NOT be in dict) + initial_dir = os.getcwd() + os.chdir(feature1_worktree) + feature1_commit = git.get_commit_hash_by_revision(LocalBranchShortName.of("feature-1")) + assert feature1_commit is not None + check_out(feature1_commit) # Detach HEAD in feature-1 worktree + os.chdir(initial_dir) + + worktrees = git.get_worktree_root_dirs_by_branch() + assert len(worktrees) == 1 # Only feature-2, not main (detached) or feature-1 (detached) + assert LocalBranchShortName.of("main") not in worktrees + assert LocalBranchShortName.of("feature-1") not in worktrees + feature2_path = worktrees.get(LocalBranchShortName.of("feature-2")) + assert feature2_path is not None + assert os.path.realpath(feature2_path) == os.path.realpath(feature2_worktree) + + # Test 5: Create another worktree in detached HEAD to ensure they don't overwrite each other + # This tests the bug fix where multiple detached HEADs would overwrite each other with None key + from tempfile import mkdtemp + + from .mockers import execute + feature3_worktree = mkdtemp() + execute(f"git worktree add -f --detach {feature3_worktree} feature-3") + + worktrees = git.get_worktree_root_dirs_by_branch() + # Should still be 1 (only feature-2), detached worktrees should not be included + assert len(worktrees) == 1 + assert LocalBranchShortName.of("feature-3") not in worktrees + + # Test 6: Check out branch in the detached worktree, should now appear + os.chdir(feature1_worktree) + check_out("feature-1") # Back on branch + os.chdir(initial_dir) + + worktrees = git.get_worktree_root_dirs_by_branch() + assert len(worktrees) == 2 # feature-1 and feature-2 + feature1_path = worktrees.get(LocalBranchShortName.of("feature-1")) + assert feature1_path is not None + assert os.path.realpath(feature1_path) == os.path.realpath(feature1_worktree) + feature2_path = worktrees.get(LocalBranchShortName.of("feature-2")) + assert feature2_path is not None + assert os.path.realpath(feature2_path) == os.path.realpath(feature2_worktree) diff --git a/tests/test_traverse.py b/tests/test_traverse.py index dd31969d6..f8d897b1c 100644 --- a/tests/test_traverse.py +++ b/tests/test_traverse.py @@ -1,4 +1,5 @@ import os +import subprocess from pytest_mock import MockerFixture @@ -11,12 +12,12 @@ overridden_environment, rewrite_branch_layout_file, sleep, write_to_file) from .mockers_git_repository import (add_file_and_commit, add_remote, - amend_commit, check_out, commit, - create_repo, create_repo_with_remote, - delete_branch, get_current_branch, - get_git_version, merge, new_branch, push, - remove_remote, reset_to, - set_git_config_key) + add_worktree, amend_commit, check_out, + commit, create_repo, + create_repo_with_remote, delete_branch, + get_current_branch, get_git_version, + merge, new_branch, push, remove_remote, + reset_to, set_git_config_key) class TestTraverse(BaseTest): @@ -1920,3 +1921,284 @@ def test_traverse_yellow_edges(self, mocker: MockerFixture) -> None: Returned to the initial branch feature-2 """ ) + + def test_traverse_with_worktrees(self) -> None: + """Test that traverse can handle branches checked out in separate worktrees.""" + if get_git_version() < (2, 5): + # git worktree command was introduced in git 2.5 + return + + from .mockers import execute + + create_repo_with_remote() + new_branch("develop") + commit("develop commit") + push() + new_branch("feature-1") + commit("feature-1 commit") + push() + new_branch("feature-2") + commit("feature-2 commit") + push() + + body: str = \ + """ + develop + feature-1 + feature-2 + """ + rewrite_branch_layout_file(body) + + # Create worktrees for feature-1 and feature-2 in temp directories + check_out("develop") + feature_1_worktree = add_worktree("feature-1") + feature_2_worktree = add_worktree("feature-2") + + # Modify feature-1 so it needs to be pushed (ahead of remote) + initial_dir = os.getcwd() + os.chdir(feature_1_worktree) + # In worktrees, .git is a file not a directory, so use git command directly + execute("touch feature-1-file.txt") + execute("git add feature-1-file.txt") + execute("git commit -m 'feature-1 additional commit'") + os.chdir(initial_dir) + + # Modify develop so feature-2 needs to be rebased + check_out("develop") + commit("develop additional commit") + push() + + # Now run traverse - it should cd into the worktrees automatically + # Using -y flag so no need to mock input + output = launch_command("traverse", "-y", "--start-from=first-root") + + # Verify key behaviors happened: + # 1. It switched to feature-1 worktree + assert "worktree where feature-1 is checked out" in output + # 2. It switched to feature-2 worktree + assert "worktree where feature-2 is checked out" in output + # 3. Operations were performed + assert "Rebasing feature-1 onto develop" in output + assert "Rebasing feature-2 onto feature-1" in output + + # Verify that feature-1 was pushed in its worktree + os.chdir(feature_1_worktree) + feature_1_local = subprocess.check_output("git rev-parse feature-1", shell=True).decode().strip() + feature_1_remote = subprocess.check_output("git rev-parse origin/feature-1", shell=True).decode().strip() + assert feature_1_local == feature_1_remote + + # Verify that feature-2 was rebased in its worktree + os.chdir(feature_2_worktree) + # feature-2 should now be based on the updated feature-1 + feature_2_log = subprocess.check_output("git log --oneline feature-2", shell=True).decode() + assert "feature-1 additional commit" in feature_2_log + + def test_traverse_cd_from_linked_to_main_worktree(self) -> None: + """Test traverse cd from linked worktree to main worktree for non-checked-out branch.""" + if get_git_version() < (2, 5): + # git worktree command was introduced in git 2.5 + return + + (local_path, _) = create_repo_with_remote() + new_branch("root") + commit() + push() + new_branch("branch-1") + commit() + push() + + body: str = \ + """ + root + branch-1 + """ + rewrite_branch_layout_file(body) + + check_out("root") + new_branch("branch-2") + commit() + # Don't push branch-2 so it will be pushed during traverse + + body = """ + root + branch-1 + branch-2 + """ + rewrite_branch_layout_file(body) + + # Setup: Main worktree on branch-2, linked worktree for branch-1 + check_out("branch-2") # Main worktree on branch-2 + branch_1_worktree = add_worktree("branch-1") # Linked worktree for branch-1 + + # cd into branch-1 linked worktree + os.chdir(branch_1_worktree) + + # Now run traverse --start-from=first-root + # This will: + # 1. Checkout root (not in any worktree) in main worktree - triggers cache update (lines 63-66) + # 2. Checkout branch-1 (already in linked worktree where we are) + # 3. Checkout branch-2 (was in main worktree, now root is there) - triggers cache update again + # The cache updates should cover lines 63-66 where we delete the old branch entry + output = launch_command("traverse", "-y", "--start-from=first-root") + + # Verify lines 88-89 were executed (cd to main worktree for root) + assert "Changing directory to main worktree at" in output, \ + f"Expected 'Changing directory to main worktree at' in output, but got:\n{output}" + + # Verify branch-2 was pushed (confirms traverse completed successfully) + assert "Pushing untracked branch branch-2" in output + + def test_traverse_updates_worktree_cache_on_checkout(self) -> None: + """Test that the worktree cache is properly updated when checking out in the same worktree.""" + if get_git_version() < (2, 5): + # git worktree command was introduced in git 2.5 + return + + (local_path, _) = create_repo_with_remote() + new_branch("root") + commit() + push() + new_branch("branch-1") + commit() + push() + new_branch("branch-2") + commit() + # Don't push branch-2 so it will be pushed during traverse + + body: str = \ + """ + root + branch-1 + branch-2 + """ + rewrite_branch_layout_file(body) + + # Setup: Main worktree on root, create a linked worktree for branch-1 + check_out("root") + add_worktree("branch-1") + + # Run traverse from root in main worktree + # This will: + # 1. Initial cache: {root: main_worktree_path, branch-1: linked_worktree_path} + # 2. Visit root (already checked out in main worktree) - no operation + # 3. Visit branch-1 (in linked worktree) - cd to linked worktree, no checkout + # 4. Visit branch-2 (not checked out anywhere) - cd to main worktree, checkout branch-2 + # - When checking out branch-2, _update_worktrees_cache_after_checkout is called + # - The cache has {root: main_worktree_path, branch-1: linked_worktree_path} + # - current_worktree_path = main_worktree_path + # - Loop finds root with main_worktree_path, deletes it (covers line 64-65) + # - Adds branch-2: main_worktree_path + output = launch_command("traverse", "-y") + + # Verify branch-2 was checked out and pushed + assert "Checking out branch-2" in output + assert "Pushing untracked branch branch-2" in output + + def test_traverse_warns_when_final_branch_in_different_worktree(self) -> None: + if get_git_version() < (2, 5): + return + + create_repo_with_remote() + new_branch("root") + commit("root") + push() + new_branch("branch-1") + commit("branch-1") + push() + new_branch("branch-2") + commit("branch-2") + push() + + body: str = \ + """ + root + branch-1 + branch-2 + """ + rewrite_branch_layout_file(body) + + # Create a worktree for branch-2 + check_out("root") + branch_2_worktree = add_worktree("branch-2") + + # Make root have an additional commit so branch-1 needs rebase + check_out("root") + commit("root additional commit") + push() + + # Start from root (main worktree), traverse should process through branch-2 + check_out("root") + output = launch_command("traverse", "-y") + + # Verify the warning is emitted + assert "branch branch-2 is checked out in worktree at" in output + assert f"You may want to change directory with:\n cd {os.path.realpath(branch_2_worktree)}" in output + + def test_traverse_no_warn_when_final_branch_in_same_worktree(self) -> None: + if get_git_version() < (2, 5): + return + + create_repo_with_remote() + new_branch("root") + commit("root") + push() + new_branch("branch-1") + commit("branch-1") + push() + + body: str = \ + """ + root + branch-1 + """ + rewrite_branch_layout_file(body) + + # Create a worktree for branch-1 + check_out("root") + add_worktree("branch-1") + + # Start from root (main worktree), traverse ends on root (same worktree) + check_out("root") + output = launch_command("traverse", "-y", "--return-to=here") + + # Verify the warning is NOT emitted + assert "Note: branch" not in output + assert "You may want to change directory with:" not in output + + def test_traverse_warns_when_quitting_on_branch_in_different_worktree(self, mocker: MockerFixture) -> None: + if get_git_version() < (2, 5): + return + + create_repo_with_remote() + new_branch("root") + commit("root") + push() + new_branch("branch-1") + commit("branch-1") + push() + + body: str = \ + """ + root + branch-1 + """ + rewrite_branch_layout_file(body) + + # Create a worktree for branch-1 + check_out("root") + branch_1_worktree = add_worktree("branch-1") + + # Make root have an additional commit so branch-1 needs rebase + check_out("root") + commit("root additional commit") + push() + + # Start from root (main worktree), traverse will ask to rebase branch-1, user quits + check_out("root") + self.patch_symbol(mocker, 'builtins.input', mock_input_returning("q")) + output = launch_command("traverse") + + # Verify traverse stops early and the warning is still emitted + assert "Rebase branch-1 onto root?" in output + assert "branch branch-1 is checked out in worktree at" in output + assert f"You may want to change directory with:\n cd {os.path.realpath(branch_1_worktree)}" in output