Skip to content

Commit 51f7f34

Browse files
stevenpollackclaude
andcommitted
feat(worktrees): list worktrees and switch between them
Add a Worktrees popup (Shift+W from the Status tab) listing the primary and linked worktrees with branch, path, and lock/validity markers, and switch gitui to the selected worktree on Enter by reusing the existing OpenRepo reopen mechanism (absolute worktree paths pass through the handler unchanged). Backend: asyncgit sync::get_worktrees builds WorktreeInfo entries via git2 (worktrees/find_worktree/open_from_worktree), synthesizing the primary tree from commondir and flagging the current worktree; detached HEAD reports no branch. - new asyncgit/src/sync/worktree.rs (+ sync mod wiring, 3 tests) - new src/popups/worktrees.rs (+ app/queue/keys/strings/status wiring) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMQxBdr5f3B3jmDtdvGtTu
1 parent 24128f7 commit 51f7f34

10 files changed

Lines changed: 635 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
* support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514))
12+
* worktrees popup: list worktrees and switch between them ([Shift+W])
1213

1314
### Changed
1415
* use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting

asyncgit/src/sync/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ mod submodules;
3434
mod tags;
3535
mod tree;
3636
pub mod utils;
37+
mod worktree;
3738

3839
pub use blame::{blame_file, BlameHunk, FileBlame};
3940
pub use branch::{
@@ -108,6 +109,7 @@ pub use utils::{
108109
get_head, get_head_tuple, repo_dir, repo_open_error,
109110
stage_add_all, stage_add_file, stage_addremoved, Head,
110111
};
112+
pub use worktree::{get_worktrees, WorktreeInfo};
111113

112114
pub use git2::ResetType;
113115

asyncgit/src/sync/worktree.rs

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
//! read-only listing of git worktrees
2+
3+
use std::path::{Path, PathBuf};
4+
5+
use git2::{Repository, WorktreeLockStatus};
6+
use scopetime::scope_time;
7+
8+
use super::{repo, RepoPath};
9+
use crate::error::Result;
10+
11+
/// name reported for the primary working tree
12+
const MAIN_WORKTREE_NAME: &str = "(main)";
13+
14+
/// Information about one git worktree (the primary tree or a linked one).
15+
#[derive(Debug, Clone)]
16+
pub struct WorktreeInfo {
17+
/// Linked-worktree name; "(main)" for the primary working tree.
18+
pub name: String,
19+
/// Absolute path to the worktree's working directory.
20+
pub path: PathBuf,
21+
/// Short name of the checked-out branch (HEAD), or None if
22+
/// detached/unborn.
23+
pub branch: Option<String>,
24+
/// Whether the worktree's working directory is present/valid.
25+
pub is_valid: bool,
26+
/// Whether the worktree is locked.
27+
pub is_locked: bool,
28+
/// Whether this is the worktree gitui is currently operating in.
29+
pub is_current: bool,
30+
}
31+
32+
/// Lists the worktrees of the repo at `repo_path`, returning the
33+
/// primary working tree first followed by any linked worktrees.
34+
pub fn get_worktrees(
35+
repo_path: &RepoPath,
36+
) -> Result<Vec<WorktreeInfo>> {
37+
scope_time!("get_worktrees");
38+
39+
let repo = repo(repo_path)?;
40+
41+
// working dir gitui is currently operating in, used to flag the
42+
// matching entry as `is_current`.
43+
let current = repo.workdir();
44+
45+
let mut worktrees = Vec::new();
46+
47+
if let Some(main) = main_worktree_info(&repo, current) {
48+
worktrees.push(main);
49+
}
50+
51+
// `iter()` yields `Result<Option<&str>, Error>`; the two
52+
// flattens drop unreadable/non-utf8 names, leaving `&str`.
53+
for name in repo.worktrees()?.iter().flatten().flatten() {
54+
worktrees.push(linked_worktree_info(&repo, name, current)?);
55+
}
56+
57+
Ok(worktrees)
58+
}
59+
60+
/// synthesizes the entry for the primary working tree, which is not
61+
/// part of `Repository::worktrees`. returns `None` for bare repos
62+
/// (no working tree) or when the primary workdir cannot be located.
63+
fn main_worktree_info(
64+
repo: &Repository,
65+
current: Option<&Path>,
66+
) -> Option<WorktreeInfo> {
67+
if repo.is_bare() {
68+
return None;
69+
}
70+
71+
// primary working dir and the repo handle used to read its branch.
72+
let (main_workdir, branch) = if repo.is_worktree() {
73+
// gitui is operating inside a linked worktree; the primary
74+
// tree sits next to the shared common git dir.
75+
let workdir =
76+
repo.commondir().parent().map(Path::to_path_buf)?;
77+
78+
let branch = Repository::open(&workdir)
79+
.ok()
80+
.and_then(|r| head_branch(&r));
81+
82+
(workdir, branch)
83+
} else {
84+
let workdir = repo.workdir()?;
85+
86+
(workdir.to_path_buf(), head_branch(repo))
87+
};
88+
89+
Some(WorktreeInfo {
90+
is_current: same_workdir(&main_workdir, current),
91+
name: MAIN_WORKTREE_NAME.to_string(),
92+
path: main_workdir,
93+
branch,
94+
is_valid: true,
95+
is_locked: false,
96+
})
97+
}
98+
99+
/// builds the entry for a single linked worktree.
100+
fn linked_worktree_info(
101+
repo: &Repository,
102+
name: &str,
103+
current: Option<&Path>,
104+
) -> Result<WorktreeInfo> {
105+
let wt = repo.find_worktree(name)?;
106+
107+
let branch = Repository::open_from_worktree(&wt)
108+
.ok()
109+
.and_then(|r| head_branch(&r));
110+
111+
let is_locked = wt.is_locked().is_ok_and(|status| {
112+
matches!(status, WorktreeLockStatus::Locked(_))
113+
});
114+
115+
Ok(WorktreeInfo {
116+
is_current: same_workdir(wt.path(), current),
117+
name: name.to_string(),
118+
path: wt.path().to_path_buf(),
119+
branch,
120+
is_valid: wt.validate().is_ok(),
121+
is_locked,
122+
})
123+
}
124+
125+
/// short name of the branch a repo's HEAD points at, or `None` when
126+
/// HEAD is unborn or detached.
127+
fn head_branch(repo: &Repository) -> Option<String> {
128+
// a detached HEAD points at a commit, not a branch, and its
129+
// shorthand is "HEAD" rather than a branch name.
130+
if repo.head_detached().unwrap_or(false) {
131+
return None;
132+
}
133+
134+
repo.head()
135+
.ok()
136+
.as_ref()
137+
.and_then(|head| head.shorthand().ok().map(String::from))
138+
}
139+
140+
/// whether `path` and `current` refer to the same working directory,
141+
/// comparing canonicalized paths and falling back to raw equality when
142+
/// canonicalization fails.
143+
fn same_workdir(path: &Path, current: Option<&Path>) -> bool {
144+
current.is_some_and(|current| {
145+
let canon = |p: &Path| std::fs::canonicalize(p).ok();
146+
match (canon(path), canon(current)) {
147+
(Some(a), Some(b)) => a == b,
148+
_ => path == current,
149+
}
150+
})
151+
}
152+
153+
#[cfg(test)]
154+
mod tests {
155+
use super::{get_worktrees, WorktreeInfo};
156+
use crate::sync::{tests::repo_init, RepoPath};
157+
use pretty_assertions::assert_eq;
158+
159+
fn find<'a>(
160+
list: &'a [WorktreeInfo],
161+
name: &str,
162+
) -> &'a WorktreeInfo {
163+
list.iter().find(|w| w.name == name).unwrap()
164+
}
165+
166+
#[test]
167+
fn test_lists_primary_and_linked() {
168+
let (_td, repo) = repo_init().unwrap();
169+
170+
let root = repo.path().parent().unwrap();
171+
let repo_path: RepoPath = root.to_str().unwrap().into();
172+
173+
// linked worktree kept outside the main workdir to avoid
174+
// nesting; `wt_dir` must stay alive for the whole test.
175+
let wt_dir = tempfile::TempDir::new().unwrap();
176+
let wt_path = wt_dir.path().join("wt1");
177+
repo.worktree("wt1", &wt_path, None).unwrap();
178+
179+
let list = get_worktrees(&repo_path).unwrap();
180+
181+
assert_eq!(list.len(), 2);
182+
183+
let linked = find(&list, "wt1");
184+
assert!(linked.path.is_absolute());
185+
assert!(linked.path.ends_with("wt1"));
186+
// git names the branch after the worktree by default.
187+
assert!(linked.branch.is_some());
188+
assert!(!linked.is_current);
189+
190+
let primary = find(&list, "(main)");
191+
assert!(primary.is_current);
192+
}
193+
194+
#[test]
195+
fn test_primary_only() {
196+
let (_td, repo) = repo_init().unwrap();
197+
198+
let root = repo.path().parent().unwrap();
199+
let repo_path: RepoPath = root.to_str().unwrap().into();
200+
201+
let list = get_worktrees(&repo_path).unwrap();
202+
203+
assert_eq!(list.len(), 1);
204+
assert_eq!(list[0].name, "(main)");
205+
assert!(list[0].is_current);
206+
assert!(list[0].is_valid);
207+
assert!(!list[0].is_locked);
208+
}
209+
210+
#[test]
211+
fn test_detached_head_has_no_branch() {
212+
let (_td, repo) = repo_init().unwrap();
213+
214+
let root = repo.path().parent().unwrap();
215+
let repo_path: RepoPath = root.to_str().unwrap().into();
216+
217+
let oid = repo.head().unwrap().target().unwrap();
218+
repo.set_head_detached(oid).unwrap();
219+
220+
let list = get_worktrees(&repo_path).unwrap();
221+
222+
assert_eq!(list.len(), 1);
223+
assert_eq!(list[0].name, "(main)");
224+
assert!(list[0].branch.is_none());
225+
}
226+
}

src/app.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use crate::{
2020
PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup,
2121
RenameRemotePopup, ResetPopup, RevisionFilesPopup,
2222
StashMsgPopup, SubmodulesListPopup, TagCommitPopup,
23-
TagListPopup, UpdateRemoteUrlPopup,
23+
TagListPopup, UpdateRemoteUrlPopup, WorktreesPopup,
2424
},
2525
queue::{
2626
Action, AppTabs, InternalEvent, NeedsUpdate, Queue,
@@ -97,6 +97,7 @@ pub struct App {
9797
select_branch_popup: BranchListPopup,
9898
options_popup: OptionsPopup,
9999
submodule_popup: SubmodulesListPopup,
100+
worktree_popup: WorktreesPopup,
100101
tags_popup: TagListPopup,
101102
reset_popup: ResetPopup,
102103
checkout_option_popup: CheckoutOptionPopup,
@@ -221,6 +222,7 @@ impl App {
221222
tags_popup: TagListPopup::new(&env),
222223
options_popup: OptionsPopup::new(&env),
223224
submodule_popup: SubmodulesListPopup::new(&env),
225+
worktree_popup: WorktreesPopup::new(&env),
224226
log_search_popup: LogSearchPopupPopup::new(&env),
225227
fuzzy_find_popup: FuzzyFindPopup::new(&env),
226228
do_quit: QuitState::None,
@@ -523,6 +525,7 @@ impl App {
523525
select_branch_popup,
524526
revision_files_popup,
525527
submodule_popup,
528+
worktree_popup,
526529
tags_popup,
527530
options_popup,
528531
help_popup,
@@ -552,6 +555,7 @@ impl App {
552555
rename_remote_popup,
553556
update_remote_url_popup,
554557
submodule_popup,
558+
worktree_popup,
555559
tags_popup,
556560
reset_popup,
557561
checkout_option_popup,
@@ -797,6 +801,9 @@ impl App {
797801
InternalEvent::ViewSubmodules => {
798802
self.submodule_popup.open()?;
799803
}
804+
InternalEvent::ViewWorktrees => {
805+
self.worktree_popup.open()?;
806+
}
800807
InternalEvent::Tags => {
801808
self.tags_popup.open()?;
802809
}

src/keys/key_list.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ pub struct KeysList {
118118
pub stage_unstage_item: GituiKeyEvent,
119119
pub tag_annotate: GituiKeyEvent,
120120
pub view_submodules: GituiKeyEvent,
121+
pub view_worktrees: GituiKeyEvent,
121122
pub view_remotes: GituiKeyEvent,
122123
pub update_remote_name: GituiKeyEvent,
123124
pub update_remote_url: GituiKeyEvent,
@@ -216,6 +217,7 @@ impl Default for KeysList {
216217
stage_unstage_item: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
217218
tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
218219
view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT),
220+
view_worktrees: GituiKeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT),
219221
view_remotes: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
220222
update_remote_name: GituiKeyEvent::new(KeyCode::Char('n'),KeyModifiers::NONE),
221223
update_remote_url: GituiKeyEvent::new(KeyCode::Char('u'),KeyModifiers::NONE),

src/popups/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ mod submodules;
2929
mod tag_commit;
3030
mod taglist;
3131
mod update_remote_url;
32+
mod worktrees;
3233

3334
pub use blame_file::{BlameFileOpen, BlameFilePopup};
3435
pub use branchlist::BranchListPopup;
@@ -61,6 +62,7 @@ pub use submodules::SubmodulesListPopup;
6162
pub use tag_commit::TagCommitPopup;
6263
pub use taglist::TagListPopup;
6364
pub use update_remote_url::UpdateRemoteUrlPopup;
65+
pub use worktrees::WorktreesPopup;
6466

6567
use crate::ui::style::Theme;
6668
use ratatui::{

0 commit comments

Comments
 (0)