Skip to content

Commit d01bd6f

Browse files
author
Test User
committed
perf(worktree): optimize lightweight selection flows
1 parent 105ebee commit d01bd6f

File tree

11 files changed

+359
-19
lines changed

11 files changed

+359
-19
lines changed
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
pub use crate::infrastructure::git::{CommitInfo, GitWorktreeManager, WorktreeInfo};
1+
pub use crate::infrastructure::git::{
2+
BasicWorktreeInfo, CommitInfo, GitWorktreeManager, WorktreeInfo,
3+
};

src/adapters/git/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ pub mod git_worktree_repository;
22
pub mod repo_discovery;
33
pub mod worktree_lock;
44

5-
pub use git_worktree_repository::{CommitInfo, GitWorktreeManager, WorktreeInfo};
5+
pub use git_worktree_repository::{
6+
BasicWorktreeInfo, CommitInfo, GitWorktreeManager, WorktreeInfo,
7+
};
68
pub use repo_discovery::{open_repository_at_path, open_repository_from_env};
79
pub use worktree_lock::WorktreeLock;

src/domain/worktree.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
pub use crate::adapters::git::WorktreeInfo;
1+
pub use crate::adapters::git::{BasicWorktreeInfo, WorktreeInfo};

src/infrastructure/git.rs

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,65 @@ impl GitWorktreeManager {
293293
Ok(worktrees)
294294
}
295295

296+
/// Returns whether the repository has any linked worktrees.
297+
///
298+
/// This intentionally does not count the main worktree. Its semantics must
299+
/// stay aligned with `list_worktrees()` and the `create` first-worktree flow.
300+
pub fn has_linked_worktrees(&self) -> Result<bool> {
301+
let worktree_names = self.repo.worktrees()?;
302+
Ok(worktree_names.iter().flatten().next().is_some())
303+
}
304+
305+
/// Lists linked worktrees without loading status or commit metadata.
306+
///
307+
/// This preserves the same ordering and identity fields as `list_worktrees()`,
308+
/// but avoids the expensive per-worktree status scan used by detailed views.
309+
pub fn list_worktrees_basic(&self) -> Result<Vec<BasicWorktreeInfo>> {
310+
let mut worktrees = Vec::new();
311+
let worktree_names = self.repo.worktrees()?;
312+
313+
for name in worktree_names.iter().flatten() {
314+
if let Ok(worktree) = self.repo.find_worktree(name) {
315+
let path = worktree.path();
316+
let is_current = self.is_current_worktree(path);
317+
let is_locked = worktree.is_locked().is_ok();
318+
319+
let branch = if let Ok(wt_repo) = Repository::open(path) {
320+
if let Ok(head) = wt_repo.head() {
321+
if let Some(shorthand) = head.shorthand() {
322+
shorthand.to_string()
323+
} else {
324+
String::from(DEFAULT_BRANCH_DETACHED)
325+
}
326+
} else {
327+
String::from(DEFAULT_BRANCH_DETACHED)
328+
}
329+
} else {
330+
String::from(DEFAULT_BRANCH_UNKNOWN)
331+
};
332+
333+
let display_name = path
334+
.file_name()
335+
.and_then(|name| name.to_str())
336+
.unwrap_or(name)
337+
.to_string();
338+
339+
worktrees.push(BasicWorktreeInfo {
340+
name: display_name,
341+
git_name: name.to_string(),
342+
path: path.to_path_buf(),
343+
branch,
344+
is_locked,
345+
is_current,
346+
});
347+
}
348+
}
349+
350+
worktrees.sort_by(|a, b| a.name.cmp(&b.name));
351+
352+
Ok(worktrees)
353+
}
354+
296355
/// Checks if the given path is the current worktree
297356
///
298357
/// # Arguments
@@ -1158,7 +1217,7 @@ impl GitWorktreeManager {
11581217
branch_name: &str,
11591218
worktree_name: &str,
11601219
) -> Result<bool> {
1161-
let worktrees = self.list_worktrees()?;
1220+
let worktrees = self.list_worktrees_basic()?;
11621221
let mut count = 0;
11631222
let mut found_in_target = false;
11641223

@@ -1583,6 +1642,26 @@ pub struct WorktreeInfo {
15831642
pub ahead_behind: Option<(usize, usize)>, // (ahead, behind)
15841643
}
15851644

1645+
/// Lightweight information about a Git worktree.
1646+
///
1647+
/// This keeps only the fields needed for selection UIs and identity resolution,
1648+
/// without opening each worktree to inspect status or commit metadata.
1649+
#[derive(Debug, Clone, PartialEq, Eq)]
1650+
pub struct BasicWorktreeInfo {
1651+
/// The display name of the worktree (derived from the directory name)
1652+
pub name: String,
1653+
/// The internal Git name of the worktree (from .git/worktrees/)
1654+
pub git_name: String,
1655+
/// The absolute filesystem path to the worktree
1656+
pub path: PathBuf,
1657+
/// The current branch name or "detached" if in detached HEAD state
1658+
pub branch: String,
1659+
/// Whether the worktree is locked (prevents deletion)
1660+
pub is_locked: bool,
1661+
/// Whether this is the currently active worktree
1662+
pub is_current: bool,
1663+
}
1664+
15861665
/// Information about a Git commit
15871666
///
15881667
/// Contains basic information about a commit for display purposes.

src/usecases/create_worktree.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,7 @@ pub fn create_worktree_with_ui(
8585
println!("{header}");
8686
println!();
8787

88-
let existing_worktrees = manager.list_worktrees()?;
89-
let has_worktrees = !existing_worktrees.is_empty();
88+
let has_linked_worktrees = manager.has_linked_worktrees()?;
9089

9190
let name = match ui.input(PROMPT_WORKTREE_NAME) {
9291
Ok(name) => name.trim().to_string(),
@@ -106,7 +105,7 @@ pub fn create_worktree_with_ui(
106105
}
107106
};
108107

109-
let final_name = if !has_worktrees {
108+
let final_name = if !has_linked_worktrees {
110109
println!();
111110
let msg = MSG_FIRST_WORKTREE_CHOOSE.bright_cyan();
112111
println!("{msg}");

src/usecases/delete_worktree.rs

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use dialoguer::{Confirm, MultiSelect};
55
use crate::adapters::git::GitWorktreeManager;
66
use crate::adapters::hooks::{self, HookContext};
77
use crate::constants::{section_header, DEFAULT_MENU_SELECTION, HOOK_PRE_REMOVE};
8-
use crate::domain::worktree::WorktreeInfo;
8+
use crate::domain::worktree::{BasicWorktreeInfo, WorktreeInfo};
99
use crate::ui::{DialoguerUI, UserInterface};
1010
use crate::utils::{self, get_theme, press_any_key_to_continue};
1111

@@ -71,6 +71,20 @@ pub fn prepare_batch_delete_items(worktrees: &[WorktreeInfo]) -> Vec<String> {
7171
.collect()
7272
}
7373

74+
/// Pure business logic for filtering deletable worktrees for batch operations.
75+
pub fn get_deletable_worktrees_basic(worktrees: &[BasicWorktreeInfo]) -> Vec<&BasicWorktreeInfo> {
76+
worktrees.iter().filter(|w| !w.is_current).collect()
77+
}
78+
79+
/// Pure business logic for preparing batch delete labels from lightweight worktrees.
80+
pub fn prepare_batch_delete_items_basic(worktrees: &[BasicWorktreeInfo]) -> Vec<String> {
81+
worktrees
82+
.iter()
83+
.filter(|w| !w.is_current)
84+
.map(|w| format!("{} ({})", w.name, w.branch))
85+
.collect()
86+
}
87+
7488
/// Pure business logic for analyzing deletion requirements
7589
pub fn analyze_deletion(
7690
worktree: &WorktreeInfo,
@@ -238,7 +252,7 @@ pub fn batch_delete_worktrees() -> Result<()> {
238252
}
239253

240254
fn batch_delete_worktrees_internal(manager: &GitWorktreeManager) -> Result<()> {
241-
let worktrees = manager.list_worktrees()?;
255+
let worktrees = manager.list_worktrees_basic()?;
242256

243257
if worktrees.is_empty() {
244258
println!();
@@ -249,8 +263,7 @@ fn batch_delete_worktrees_internal(manager: &GitWorktreeManager) -> Result<()> {
249263
return Ok(());
250264
}
251265

252-
let deletable_worktrees: Vec<&WorktreeInfo> =
253-
worktrees.iter().filter(|w| !w.is_current).collect();
266+
let deletable_worktrees = get_deletable_worktrees_basic(&worktrees);
254267

255268
if deletable_worktrees.is_empty() {
256269
println!();
@@ -270,7 +283,7 @@ fn batch_delete_worktrees_internal(manager: &GitWorktreeManager) -> Result<()> {
270283
println!("{header}");
271284
println!();
272285

273-
let items = prepare_batch_delete_items(&worktrees);
286+
let items = prepare_batch_delete_items_basic(&worktrees);
274287

275288
let selections = MultiSelect::with_theme(&get_theme())
276289
.with_prompt(
@@ -284,7 +297,7 @@ fn batch_delete_worktrees_internal(manager: &GitWorktreeManager) -> Result<()> {
284297
_ => return Ok(()),
285298
};
286299

287-
let selected_worktrees: Vec<&WorktreeInfo> =
300+
let selected_worktrees: Vec<&BasicWorktreeInfo> =
288301
selections.iter().map(|&i| deletable_worktrees[i]).collect();
289302

290303
let mut branches_to_delete = Vec::new();
@@ -573,4 +586,66 @@ mod tests {
573586
assert!(items[0].contains("feature/test"));
574587
assert!(!items[0].contains("main"));
575588
}
589+
590+
#[test]
591+
fn test_prepare_batch_delete_items_basic_preserves_order_and_filters_current() {
592+
let worktrees = vec![
593+
BasicWorktreeInfo {
594+
name: "z-current".to_string(),
595+
git_name: "z-current".to_string(),
596+
path: std::path::PathBuf::from("/test/z-current"),
597+
branch: "main".to_string(),
598+
is_current: true,
599+
is_locked: false,
600+
},
601+
BasicWorktreeInfo {
602+
name: "alpha".to_string(),
603+
git_name: "alpha".to_string(),
604+
path: std::path::PathBuf::from("/test/alpha"),
605+
branch: "alpha".to_string(),
606+
is_current: false,
607+
is_locked: false,
608+
},
609+
BasicWorktreeInfo {
610+
name: "beta-renamed".to_string(),
611+
git_name: "beta-original".to_string(),
612+
path: std::path::PathBuf::from("/test/beta-renamed"),
613+
branch: "beta-branch".to_string(),
614+
is_current: false,
615+
is_locked: false,
616+
},
617+
];
618+
619+
let items = prepare_batch_delete_items_basic(&worktrees);
620+
621+
assert_eq!(items, vec!["alpha (alpha)", "beta-renamed (beta-branch)"]);
622+
}
623+
624+
#[test]
625+
fn test_get_deletable_worktrees_basic_preserves_identity_mapping() {
626+
let worktrees = vec![
627+
BasicWorktreeInfo {
628+
name: "main".to_string(),
629+
git_name: "main".to_string(),
630+
path: std::path::PathBuf::from("/test/main"),
631+
branch: "main".to_string(),
632+
is_current: true,
633+
is_locked: false,
634+
},
635+
BasicWorktreeInfo {
636+
name: "renamed-feature".to_string(),
637+
git_name: "feature-original".to_string(),
638+
path: std::path::PathBuf::from("/test/renamed-feature"),
639+
branch: "feature-branch".to_string(),
640+
is_current: false,
641+
is_locked: false,
642+
},
643+
];
644+
645+
let deletable = get_deletable_worktrees_basic(&worktrees);
646+
647+
assert_eq!(deletable.len(), 1);
648+
assert_eq!(deletable[0].name, "renamed-feature");
649+
assert_eq!(deletable[0].git_name, "feature-original");
650+
}
576651
}

src/usecases/rename_worktree.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::constants::{
66
section_header, DEFAULT_BRANCH_DETACHED, DEFAULT_BRANCH_UNKNOWN, DEFAULT_MENU_SELECTION,
77
};
88
use crate::domain::validation::validate_worktree_name;
9-
use crate::domain::worktree::WorktreeInfo;
9+
use crate::domain::worktree::{BasicWorktreeInfo, WorktreeInfo};
1010
use crate::ui::{DialoguerUI, UserInterface};
1111
use crate::utils::{self, press_any_key_to_continue};
1212

@@ -52,6 +52,23 @@ pub struct RenameAnalysis {
5252
pub is_feature_branch: bool,
5353
}
5454

55+
fn lightweight_worktrees_to_display(worktrees: Vec<BasicWorktreeInfo>) -> Vec<WorktreeInfo> {
56+
worktrees
57+
.into_iter()
58+
.map(|worktree| WorktreeInfo {
59+
name: worktree.name,
60+
git_name: worktree.git_name,
61+
path: worktree.path,
62+
branch: worktree.branch,
63+
is_locked: worktree.is_locked,
64+
is_current: worktree.is_current,
65+
has_changes: false,
66+
last_commit: None,
67+
ahead_behind: None,
68+
})
69+
.collect()
70+
}
71+
5572
/// Pure business logic for filtering renameable worktrees
5673
pub fn get_renameable_worktrees(worktrees: &[WorktreeInfo]) -> Vec<&WorktreeInfo> {
5774
worktrees.iter().filter(|w| !w.is_current).collect()
@@ -151,7 +168,7 @@ pub fn rename_worktree() -> Result<()> {
151168

152169
/// Internal implementation of rename_worktree with dependency injection
153170
pub fn rename_worktree_with_ui(manager: &GitWorktreeManager, ui: &dyn UserInterface) -> Result<()> {
154-
let worktrees = manager.list_worktrees()?;
171+
let worktrees = lightweight_worktrees_to_display(manager.list_worktrees_basic()?);
155172

156173
if worktrees.is_empty() {
157174
println!();

src/usecases/search_worktrees.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::constants::{
99
MSG_NO_WORKTREES_TO_SEARCH, MSG_SEARCH_FUZZY_ENABLED, PROMPT_SELECT_WORKTREE_SWITCH,
1010
SEARCH_CURRENT_INDICATOR,
1111
};
12-
use crate::domain::worktree::WorktreeInfo;
12+
use crate::domain::worktree::{BasicWorktreeInfo, WorktreeInfo};
1313
use crate::utils::{self, get_theme, press_any_key_to_continue};
1414

1515
#[derive(Debug, Clone)]
@@ -25,6 +25,23 @@ pub struct SearchAnalysis {
2525
pub has_current: bool,
2626
}
2727

28+
fn lightweight_worktrees_to_display(worktrees: Vec<BasicWorktreeInfo>) -> Vec<WorktreeInfo> {
29+
worktrees
30+
.into_iter()
31+
.map(|worktree| WorktreeInfo {
32+
name: worktree.name,
33+
git_name: worktree.git_name,
34+
path: worktree.path,
35+
branch: worktree.branch,
36+
is_locked: worktree.is_locked,
37+
is_current: worktree.is_current,
38+
has_changes: false,
39+
last_commit: None,
40+
ahead_behind: None,
41+
})
42+
.collect()
43+
}
44+
2845
pub fn create_search_items(worktrees: &[WorktreeInfo]) -> SearchAnalysis {
2946
let items: Vec<String> = worktrees
3047
.iter()
@@ -63,7 +80,7 @@ pub fn search_worktrees() -> Result<bool> {
6380
}
6481

6582
fn search_worktrees_internal(manager: &GitWorktreeManager) -> Result<bool> {
66-
let worktrees = manager.list_worktrees()?;
83+
let worktrees = lightweight_worktrees_to_display(manager.list_worktrees_basic()?);
6784

6885
if worktrees.is_empty() {
6986
println!();

src/usecases/switch_worktree.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::adapters::shell::switch_file::write_switch_path;
77
use crate::constants::{
88
section_header, DEFAULT_MENU_SELECTION, HOOK_POST_SWITCH, MSG_ALREADY_IN_WORKTREE,
99
};
10-
use crate::domain::worktree::WorktreeInfo;
10+
use crate::domain::worktree::{BasicWorktreeInfo, WorktreeInfo};
1111
use crate::ui::{DialoguerUI, UserInterface};
1212
use crate::utils::{self, press_any_key_to_continue};
1313

@@ -60,6 +60,23 @@ pub struct SwitchAnalysis {
6060
pub is_already_current: bool,
6161
}
6262

63+
fn lightweight_worktrees_to_display(worktrees: Vec<BasicWorktreeInfo>) -> Vec<WorktreeInfo> {
64+
worktrees
65+
.into_iter()
66+
.map(|worktree| WorktreeInfo {
67+
name: worktree.name,
68+
git_name: worktree.git_name,
69+
path: worktree.path,
70+
branch: worktree.branch,
71+
is_locked: worktree.is_locked,
72+
is_current: worktree.is_current,
73+
has_changes: false,
74+
last_commit: None,
75+
ahead_behind: None,
76+
})
77+
.collect()
78+
}
79+
6380
/// Pure business logic for sorting worktrees for display
6481
pub fn sort_worktrees_for_display(mut worktrees: Vec<WorktreeInfo>) -> Vec<WorktreeInfo> {
6582
worktrees.sort_by(|a, b| {
@@ -122,7 +139,7 @@ pub fn switch_worktree_with_ui(
122139
manager: &GitWorktreeManager,
123140
ui: &dyn UserInterface,
124141
) -> Result<bool> {
125-
let worktrees = manager.list_worktrees()?;
142+
let worktrees = lightweight_worktrees_to_display(manager.list_worktrees_basic()?);
126143

127144
if worktrees.is_empty() {
128145
println!();

0 commit comments

Comments
 (0)