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