Skip to content

Commit 962b2b0

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 fbcbf52 commit 962b2b0

File tree

13 files changed

+677
-24
lines changed

13 files changed

+677
-24
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

ci/tox/build-context/run.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ env | sort | head -4
1111

1212
set -x
1313

14-
tox -e mypy,coverage
14+
tox -e mypy
15+
tox -e coverage -- -vv
1516

1617
$PYTHON -m build
1718

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/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,12 +503,12 @@ def set_utils_global_variables(parsed_args: argparse.Namespace) -> None:
503503

504504
def launch(orig_args: List[str]) -> None:
505505
try:
506-
launch_interruptible(orig_args)
506+
launch_internal(orig_args)
507507
except InteractionStopped:
508508
pass
509509

510510

511-
def launch_interruptible(orig_args: List[str]) -> None:
511+
def launch_internal(orig_args: List[str]) -> None:
512512
initial_current_directory: Optional[str] = utils.get_current_directory_or_none()
513513

514514
try:

git_machete/client/traverse.py

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import itertools
2+
import os
23
from enum import auto
3-
from typing import List, Optional, Type, Union
4+
from typing import Dict, List, Optional, Type, Union
45

56
from git_machete.annotation import Annotation, Qualifiers
67
from git_machete.client.base import (ParsableEnum, PickRoot,
@@ -11,7 +12,7 @@
1112
from git_machete.git_operations import (GitContext, LocalBranchShortName,
1213
SyncToRemoteStatus)
1314
from git_machete.utils import (bold, flat_map, fmt, get_pretty_choices,
14-
get_right_arrow)
15+
get_right_arrow, warn)
1516

1617

1718
class TraverseReturnTo(ParsableEnum):
@@ -46,6 +47,57 @@ def from_string_or_branch(cls: Type['TraverseStartFrom'], value: str,
4647

4748

4849
class TraverseMacheteClient(MacheteClientWithCodeHosting):
50+
def _update_worktrees_cache_after_checkout(self, checked_out_branch: LocalBranchShortName) -> None:
51+
"""
52+
Update the worktrees cache after a checkout operation in the current worktree.
53+
This avoids the need to re-fetch the full worktree list.
54+
55+
Only the current worktree's entry needs to be updated - linked worktrees
56+
don't change when we checkout in a different worktree.
57+
"""
58+
current_worktree_path = self._git.get_root_dir()
59+
60+
# Remove the old branch entry for this worktree (if any)
61+
# We need to find which branch was previously checked out in this worktree
62+
for branch, path in self.__worktrees_cache.items():
63+
if path == current_worktree_path: # pragma: no branch
64+
del self.__worktrees_cache[branch]
65+
break
66+
67+
# Add the new branch entry for this worktree
68+
self.__worktrees_cache[checked_out_branch] = current_worktree_path
69+
70+
def _switch_to_branch_worktree(
71+
self,
72+
branch: LocalBranchShortName) -> None:
73+
"""
74+
Switch to the worktree where the branch is checked out, or to main worktree if branch is not checked out.
75+
This may involve changing the current working directory.
76+
Updates the worktrees cache after checkout.
77+
"""
78+
worktree_path = self.__worktrees_cache.get(branch)
79+
current_worktree_root = self._git.get_root_dir()
80+
81+
if worktree_path is None:
82+
# Branch is not checked out anywhere, need to checkout in main worktree
83+
# Only cd if we're currently in a different worktree (linked worktree)
84+
main_worktree_path = self._git.get_main_worktree_path()
85+
if current_worktree_root != main_worktree_path:
86+
print(f"Changing directory to main worktree at {bold(main_worktree_path)}")
87+
os.chdir(main_worktree_path)
88+
# checkout() below will flush all caches, including __root_dir
89+
self._git.checkout(branch)
90+
# Update cache after checkout
91+
self._update_worktrees_cache_after_checkout(branch)
92+
else:
93+
# Branch is checked out in a worktree
94+
# Only cd if we're in a different worktree
95+
if current_worktree_root != worktree_path:
96+
print(f"Changing directory to {bold(worktree_path)} worktree where {bold(branch)} is checked out")
97+
os.chdir(worktree_path)
98+
# Flush root dir cache after directory change so get_root_dir() returns the correct path
99+
self._git.flush_root_dir_cache()
100+
49101
def traverse(
50102
self,
51103
*,
@@ -86,21 +138,26 @@ def traverse(
86138
self._init_code_hosting_client()
87139
current_user = self.code_hosting_client.get_current_user_login()
88140

141+
# Store the initial directory for later restoration
89142
initial_branch = nearest_remaining_branch = self._git.get_current_branch()
143+
initial_worktree_root = self._git.get_root_dir()
144+
145+
# Fetch worktrees once at the start to avoid repeated git worktree list calls
146+
self.__worktrees_cache: Dict[LocalBranchShortName, str] = self._git.get_worktrees()
90147

91148
try:
92149
if opt_start_from == TraverseStartFrom.ROOT:
93150
dest = self.root_branch_for(self._git.get_current_branch(), if_unmanaged=PickRoot.FIRST)
94151
self._print_new_line(False)
95152
print(f"Checking out the root branch ({bold(dest)})")
96-
self._git.checkout(dest)
153+
self._switch_to_branch_worktree(dest)
97154
current_branch = dest
98155
elif opt_start_from == TraverseStartFrom.FIRST_ROOT:
99156
# Note that we already ensured that there is at least one managed branch.
100157
dest = self.managed_branches[0]
101158
self._print_new_line(False)
102159
print(f"Checking out the first root branch ({bold(dest)})")
103-
self._git.checkout(dest)
160+
self._switch_to_branch_worktree(dest)
104161
current_branch = dest
105162
elif opt_start_from == TraverseStartFrom.HERE:
106163
current_branch = self._git.get_current_branch()
@@ -110,7 +167,7 @@ def traverse(
110167
self.expect_in_managed_branches(dest)
111168
self._print_new_line(False)
112169
print(f"Checking out branch {bold(dest)}")
113-
self._git.checkout(dest)
170+
self._switch_to_branch_worktree(dest)
114171
current_branch = dest
115172
else:
116173
raise UnexpectedMacheteException(f"Unexpected value for opt_start_from: {opt_start_from}")
@@ -187,7 +244,7 @@ def traverse(
187244
if branch != current_branch and needs_any_action:
188245
self._print_new_line(False)
189246
print(f"Checking out {bold(branch)}")
190-
self._git.checkout(branch)
247+
self._switch_to_branch_worktree(branch)
191248
current_branch = branch
192249
self._print_new_line(False)
193250
self.status(
@@ -411,9 +468,12 @@ def traverse(
411468
break
412469

413470
if opt_return_to == TraverseReturnTo.HERE:
414-
self._git.checkout(initial_branch)
471+
# Return to initial branch
472+
# No point switching back to initial directory as cwd won't propagate back to the calling shell anyway
473+
self._switch_to_branch_worktree(initial_branch)
415474
elif opt_return_to == TraverseReturnTo.NEAREST_REMAINING:
416-
self._git.checkout(nearest_remaining_branch)
475+
self._switch_to_branch_worktree(nearest_remaining_branch)
476+
# For NEAREST_REMAINING, we stay in the worktree where the branch is checked out
417477
# otherwise opt_return_to == TraverseReturnTo.STAY, so no action is needed
418478

419479
self._print_new_line(False)
@@ -440,4 +500,12 @@ def traverse(
440500
f"The initial branch {bold(initial_branch)} has been slid out. "
441501
f"Returned to nearest remaining managed branch {bold(nearest_remaining_branch)}")
442502
finally:
443-
pass
503+
# Warn if the initial directory doesn't correspond to the final checked out branch's worktree
504+
final_branch = self._git.get_current_branch()
505+
final_worktree_path = self.__worktrees_cache.get(final_branch)
506+
if final_worktree_path and initial_worktree_root != final_worktree_path:
507+
# Final branch is checked out in a worktree different from where we started
508+
warn(
509+
f"Note: branch {bold(final_branch)} is checked out in worktree at {bold(final_worktree_path)}.\n"
510+
f"You may want to change directory with:\n"
511+
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: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ def __init__(self) -> None:
220220

221221
self.__git_version: Optional[Tuple[int, int, int]] = None
222222
self.__root_dir: Optional[str] = None
223+
self.__main_worktree: Optional[str] = None
223224
self.__main_git_dir: Optional[str] = None
224225
self.__worktree_git_dir: Optional[str] = None
225226

@@ -253,8 +254,14 @@ def flush_caches(self) -> None:
253254
self.__remote_branches_cached = None
254255
self.__remotes_cached = None
255256
self.__removed_from_remote = None
257+
self.__root_dir = None
258+
self.__main_worktree = None
256259
self.__short_commit_hash_by_revision_cached = {}
257260

261+
def flush_root_dir_cache(self) -> None:
262+
"""Flush only the cached root directory. Useful after changing directories with os.chdir()."""
263+
self.__root_dir = None
264+
258265
def _run_git(self, git_cmd: str, *args: str, flush_caches: bool, allow_non_zero: bool = False) -> int:
259266
exit_code = utils.run_cmd(*GIT_EXEC, git_cmd, *args)
260267
if flush_caches:
@@ -569,6 +576,83 @@ def is_merge_in_progress(self) -> bool:
569576
def is_revert_in_progress(self) -> bool:
570577
return os.path.isfile(self.get_worktree_git_subpath("REVERT_HEAD"))
571578

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env bash
2+
3+
set -e -o pipefail -u
4+
5+
root_dir=$(realpath "${1-$HOME}")
6+
7+
# First, set up the regular sandbox
8+
script_dir=$(cd "$(dirname "$0")" && pwd)
9+
"$script_dir/setup-sandbox" "$root_dir"
10+
11+
# Now create worktrees
12+
cd "$root_dir/machete-sandbox"
13+
14+
echo
15+
echo "Creating worktrees..."
16+
echo
17+
18+
# Create worktrees for some branches
19+
git worktree add "$root_dir/machete-sandbox-worktrees/allow-ownership-link" allow-ownership-link
20+
echo "Created worktree for allow-ownership-link at $root_dir/machete-sandbox-worktrees/allow-ownership-link"
21+
22+
git worktree add "$root_dir/machete-sandbox-worktrees/build-chain" build-chain
23+
echo "Created worktree for build-chain at $root_dir/machete-sandbox-worktrees/build-chain"
24+
25+
git worktree add "$root_dir/machete-sandbox-worktrees/master" master
26+
echo "Created worktree for master at $root_dir/machete-sandbox-worktrees/master"
27+
28+
# Create a worktree with detached HEAD at the tip of call-ws
29+
call_ws_sha=$(git rev-parse call-ws)
30+
git worktree add --detach "$root_dir/machete-sandbox-worktrees/detached-call-ws" "$call_ws_sha"
31+
echo "Created worktree with detached HEAD at $root_dir/machete-sandbox-worktrees/detached-call-ws (commit $call_ws_sha from call-ws)"
32+
33+
# Create another worktree with detached HEAD at the tip of hotfix/add-trigger
34+
hotfix_sha=$(git rev-parse hotfix/add-trigger)
35+
git worktree add --detach "$root_dir/machete-sandbox-worktrees/detached-hotfix" "$hotfix_sha"
36+
echo "Created worktree with detached HEAD at $root_dir/machete-sandbox-worktrees/detached-hotfix (commit $hotfix_sha from hotfix/add-trigger)"
37+
38+
echo
39+
echo "Worktree list:"
40+
git worktree list
41+
echo
42+
echo
43+
git machete status
44+
echo
45+
echo

tests/mockers_git_repository.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,13 @@ def set_git_config_key(key: str, value: str) -> None:
153153

154154
def unset_git_config_key(key: str) -> None:
155155
execute(f'git config --unset {key}')
156+
157+
158+
def add_worktree(branch: str) -> str:
159+
"""
160+
Create a new worktree for the specified branch in a temporary directory.
161+
Returns the path to the created worktree.
162+
"""
163+
worktree_path = mkdtemp()
164+
execute(f"git worktree add -f {worktree_path} {branch}")
165+
return worktree_path

0 commit comments

Comments
 (0)