Skip to content

Commit 64f2b56

Browse files
committed
Implement worktree support for traverse command
- Add get_worktrees() and get_worktree_path() methods to GitOperations - Modify traverse to cd into worktrees where branches are checked out - Cache worktree information to avoid repeated git worktree list calls - Add informative messages when changing directories to worktrees - Add comprehensive tests for worktree handling including detached HEAD cases - Add integration test for traverse with multiple worktrees Fixes #1522
1 parent a4f4137 commit 64f2b56

File tree

8 files changed

+546
-19
lines changed

8 files changed

+546
-19
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## New in git-machete 3.37.1
44

5+
- improved: `traverse` now changes directory to the worktree where a branch is checked out, rather than failing (suggested by @lsierant)
56
- fixed: use `simple` (rather than `none`) mode of squash merge detection in `git machete discover`, to keep parity with the IntelliJ plugin
67

78
## New in git-machete 3.37.0

docs/man/git-machete.1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2143,6 +2143,8 @@ Operations like \fBgit machete github anno\-prs\fP (\fBgit machete gitlab anno\-
21432143
and \fBgit machete github checkout\-prs\fP (\fBgit machete gitlab checkout\-mrs\fP) add \fBrebase=no push=no\fP branch qualifiers
21442144
when the current user is NOT the author of the PR/MR associated with that branch.
21452145
.sp
2146+
\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.
2147+
.sp
21462148
\fBOptions:\fP
21472149
.INDENT 0.0
21482150
.TP

docs/source/cli/traverse.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ Operations like ``git machete github anno-prs`` (``git machete gitlab anno-mrs``
9191
and ``git machete github checkout-prs`` (``git machete gitlab checkout-mrs``) add ``rebase=no push=no`` branch qualifiers
9292
when the current user is NOT the author of the PR/MR associated with that branch.
9393

94+
**Note on git worktrees:** if a branch is already checked out in another worktree, ``traverse`` will change directory to that worktree rather than failing.
95+
9496
**Options:**
9597

9698
-F, --fetch Fetch the remotes of all managed branches at the beginning of traversal (no ``git pull`` involved, only ``git fetch``).

git_machete/client/traverse.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import itertools
22
from enum import auto
3-
from typing import List, Optional, Type, Union
3+
from typing import Dict, List, Optional, Type, Union
44

55
from git_machete.annotation import Annotation, Qualifiers
66
from git_machete.client.base import (ParsableEnum, PickRoot,
@@ -11,7 +11,7 @@
1111
from git_machete.git_operations import (GitContext, LocalBranchShortName,
1212
SyncToRemoteStatus)
1313
from git_machete.utils import (bold, flat_map, fmt, get_pretty_choices,
14-
get_right_arrow)
14+
get_right_arrow, warn)
1515

1616

1717
class TraverseReturnTo(ParsableEnum):
@@ -46,6 +46,51 @@ def from_string_or_branch(cls: Type['TraverseStartFrom'], value: str,
4646

4747

4848
class TraverseMacheteClient(MacheteClientWithCodeHosting):
49+
def _update_worktrees_cache_after_checkout(self, checked_out_branch: LocalBranchShortName) -> None:
50+
"""
51+
Update the worktrees cache after a checkout operation in the current worktree.
52+
This avoids the need to re-fetch the full worktree list.
53+
54+
Only the current worktree's entry needs to be updated - linked worktrees
55+
don't change when we checkout in a different worktree.
56+
"""
57+
current_worktree_root_dir = self._git.get_current_worktree_root_dir()
58+
59+
for branch, path in self.__worktree_root_dir_for_branch.items():
60+
if path == current_worktree_root_dir: # pragma: no branch
61+
del self.__worktree_root_dir_for_branch[branch]
62+
break
63+
64+
self.__worktree_root_dir_for_branch[checked_out_branch] = current_worktree_root_dir
65+
66+
def _switch_to_branch_worktree(
67+
self,
68+
target_branch: LocalBranchShortName) -> None:
69+
"""
70+
Switch to the worktree where the branch is checked out, or to main worktree if branch is not checked out.
71+
This may involve changing the current working directory.
72+
Updates the worktrees cache after checkout.
73+
"""
74+
target_worktree_root_dir = self.__worktree_root_dir_for_branch.get(target_branch)
75+
current_worktree_root_dir = self._git.get_current_worktree_root_dir()
76+
77+
if target_worktree_root_dir is None:
78+
# Branch is not checked out anywhere, need to checkout in main worktree
79+
# Only cd if we're currently in a different worktree (linked worktree)
80+
main_worktree_root_dir = self._git.get_main_worktree_root_dir()
81+
if current_worktree_root_dir != main_worktree_root_dir:
82+
print(f"Changing directory to main worktree at {bold(main_worktree_root_dir)}")
83+
self._git.chdir(main_worktree_root_dir)
84+
self._git.checkout(target_branch)
85+
# Update cache after checkout
86+
self._update_worktrees_cache_after_checkout(target_branch)
87+
else:
88+
# Branch is checked out in a worktree
89+
# Only cd if we're in a different worktree
90+
if current_worktree_root_dir != target_worktree_root_dir:
91+
print(f"Changing directory to {bold(target_worktree_root_dir)} worktree where {bold(target_branch)} is checked out")
92+
self._git.chdir(target_worktree_root_dir)
93+
4994
def traverse(
5095
self,
5196
*,
@@ -86,21 +131,26 @@ def traverse(
86131
self._init_code_hosting_client()
87132
current_user = self.code_hosting_client.get_current_user_login()
88133

134+
# Store the initial directory for later restoration
89135
initial_branch = nearest_remaining_branch = self._git.get_current_branch()
136+
initial_worktree_root = self._git.get_current_worktree_root_dir()
137+
138+
# Fetch worktrees once at the start to avoid repeated git worktree list calls
139+
self.__worktree_root_dir_for_branch: Dict[LocalBranchShortName, str] = self._git.get_worktree_root_dirs_by_branch()
90140

91141
try:
92142
if opt_start_from == TraverseStartFrom.ROOT:
93143
dest = self.root_branch_for(self._git.get_current_branch(), if_unmanaged=PickRoot.FIRST)
94144
self._print_new_line(False)
95145
print(f"Checking out the root branch ({bold(dest)})")
96-
self._git.checkout(dest)
146+
self._switch_to_branch_worktree(dest)
97147
current_branch = dest
98148
elif opt_start_from == TraverseStartFrom.FIRST_ROOT:
99149
# Note that we already ensured that there is at least one managed branch.
100150
dest = self.managed_branches[0]
101151
self._print_new_line(False)
102152
print(f"Checking out the first root branch ({bold(dest)})")
103-
self._git.checkout(dest)
153+
self._switch_to_branch_worktree(dest)
104154
current_branch = dest
105155
elif opt_start_from == TraverseStartFrom.HERE:
106156
current_branch = self._git.get_current_branch()
@@ -110,7 +160,7 @@ def traverse(
110160
self.expect_in_managed_branches(dest)
111161
self._print_new_line(False)
112162
print(f"Checking out branch {bold(dest)}")
113-
self._git.checkout(dest)
163+
self._switch_to_branch_worktree(dest)
114164
current_branch = dest
115165
else:
116166
raise UnexpectedMacheteException(f"Unexpected value for opt_start_from: {opt_start_from}")
@@ -187,7 +237,7 @@ def traverse(
187237
if branch != current_branch and needs_any_action:
188238
self._print_new_line(False)
189239
print(f"Checking out {bold(branch)}")
190-
self._git.checkout(branch)
240+
self._switch_to_branch_worktree(branch)
191241
current_branch = branch
192242
self._print_new_line(False)
193243
self.status(
@@ -411,9 +461,12 @@ def traverse(
411461
break
412462

413463
if opt_return_to == TraverseReturnTo.HERE:
414-
self._git.checkout(initial_branch)
464+
# Return to initial branch
465+
# No point switching back to initial directory as cwd won't propagate back to the calling shell anyway
466+
self._switch_to_branch_worktree(initial_branch)
415467
elif opt_return_to == TraverseReturnTo.NEAREST_REMAINING:
416-
self._git.checkout(nearest_remaining_branch)
468+
self._switch_to_branch_worktree(nearest_remaining_branch)
469+
# For NEAREST_REMAINING, we stay in the worktree where the branch is checked out
417470
# otherwise opt_return_to == TraverseReturnTo.STAY, so no action is needed
418471

419472
self._print_new_line(False)
@@ -440,4 +493,12 @@ def traverse(
440493
f"The initial branch {bold(initial_branch)} has been slid out. "
441494
f"Returned to nearest remaining managed branch {bold(nearest_remaining_branch)}")
442495
finally:
443-
pass
496+
# Warn if the initial directory doesn't correspond to the final checked out branch's worktree
497+
final_branch = self._git.get_current_branch()
498+
final_worktree_path = self.__worktree_root_dir_for_branch.get(final_branch)
499+
if final_worktree_path and initial_worktree_root != final_worktree_path:
500+
# Final branch is checked out in a worktree different from where we started
501+
warn(
502+
f"branch {bold(final_branch)} is checked out in worktree at {bold(final_worktree_path)}\n"
503+
f"You may want to change directory with:\n"
504+
f" `cd {final_worktree_path}`")

git_machete/generated_docs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,8 @@
14851485
and `git machete github checkout-prs` (`git machete gitlab checkout-mrs`) add `rebase=no push=no` branch qualifiers
14861486
when the current user is NOT the author of the PR/MR associated with that branch.
14871487
1488+
<b>Note on git worktrees:</b> if a branch is already checked out in another worktree, `traverse` will change directory to that worktree rather than failing.
1489+
14881490
<b>Options:</b>
14891491
14901492
<b>-F</b>, <b>--fetch</b>

git_machete/git_operations.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ def __init__(self) -> None:
219219
self.owner: Optional[Any] = None
220220

221221
self.__git_version: Optional[Tuple[int, int, int]] = None
222+
self.__main_worktree_root_dir: Optional[str] = None
222223
self.__main_worktree_git_dir: Optional[str] = None
223224
self.__current_worktree_root_dir: Optional[str] = None
224225
self.__current_worktree_git_dir: Optional[str] = None
@@ -256,8 +257,13 @@ def flush_caches(self) -> None:
256257
self.__short_commit_hash_by_revision_cached = {}
257258
self.__flush_current_worktree_caches()
258259

260+
def chdir(self, path: str) -> None:
261+
os.chdir(path)
262+
self.__flush_current_worktree_caches()
263+
259264
def __flush_current_worktree_caches(self) -> None:
260265
self.__current_worktree_root_dir = None
266+
self.__main_worktree_root_dir = None
261267

262268
def _run_git(self, git_cmd: str, *args: str, flush_caches: bool, allow_non_zero: bool = False) -> int:
263269
exit_code = utils.run_cmd(*GIT_EXEC, git_cmd, *args)
@@ -573,6 +579,75 @@ def is_merge_in_progress(self) -> bool:
573579
def is_revert_in_progress(self) -> bool:
574580
return os.path.isfile(self.get_current_worktree_git_subpath("REVERT_HEAD"))
575581

582+
def get_worktree_root_dirs_by_branch(self) -> Dict[LocalBranchShortName, str]:
583+
"""
584+
Returns a dict mapping branch names to their worktree paths.
585+
The main worktree's current branch is included if it's on a branch.
586+
Branches not checked out in any worktree are not in the dict.
587+
Worktrees in detached HEAD state are not included (since they can't be looked up by branch name).
588+
"""
589+
if self.get_git_version() < (2, 5):
590+
# git worktree command was introduced in git 2.5
591+
return {}
592+
593+
result = self._popen_git("worktree", "list", "--porcelain")
594+
worktrees: Dict[LocalBranchShortName, str] = {}
595+
596+
current_worktree_path: Optional[str] = None
597+
current_branch: Optional[LocalBranchShortName] = None
598+
is_detached: bool = False
599+
600+
for line in result.stdout.strip().splitlines():
601+
if line.startswith('worktree '):
602+
current_worktree_path = line[len('worktree '):]
603+
elif line.startswith('branch '):
604+
# Format: "branch refs/heads/<branch-name>"
605+
branch_ref = line[len('branch '):]
606+
current_branch = LocalBranchFullName.of(branch_ref).to_short_name()
607+
is_detached = False
608+
elif line.startswith('detached'):
609+
is_detached = True
610+
current_branch = None
611+
elif line == '':
612+
# Empty line marks end of worktree entry
613+
# Only add worktrees that have a branch (not in detached HEAD state)
614+
if current_worktree_path and current_branch is not None and not is_detached:
615+
worktrees[current_branch] = current_worktree_path
616+
current_worktree_path = None
617+
current_branch = None
618+
is_detached = False
619+
620+
# Handle last entry if no trailing newline
621+
if current_worktree_path and current_branch is not None and not is_detached:
622+
worktrees[current_branch] = current_worktree_path
623+
624+
return worktrees
625+
626+
def get_main_worktree_root_dir(self) -> str:
627+
"""
628+
Returns the path to the main worktree (not a linked worktree).
629+
"""
630+
if not self.__main_worktree_root_dir:
631+
if self.get_git_version() < (2, 5):
632+
# git worktree command was introduced in git 2.5
633+
# If worktrees aren't supported, just return the current root dir
634+
self.__main_worktree_root_dir = self.get_current_worktree_root_dir()
635+
else:
636+
# We can't rely on `git rev-parse --git-common-dir`
637+
# since in some earlier supported versions of git
638+
# this path is apparently printed in a faulty way when dealing with worktrees.
639+
# Let's parse the output of of `git worktree list` instead.
640+
result = self._popen_git("worktree", "list", "--porcelain")
641+
642+
# The first worktree in the list is always the main worktree
643+
first_entry = result.stdout.splitlines()[0]
644+
if first_entry.startswith('worktree '):
645+
self.__main_worktree_root_dir = first_entry[len('worktree '):]
646+
else:
647+
raise UnexpectedMacheteException("Could not obtain the main worktree path from "
648+
f"`git worktree list --porcelain` output (first line: `{first_entry}`)")
649+
return self.__main_worktree_root_dir
650+
576651
def checkout(self, branch: LocalBranchShortName) -> None:
577652
self._run_git("checkout", "--quiet", branch, "--", flush_caches=True)
578653

0 commit comments

Comments
 (0)