Skip to content

Commit 9366deb

Browse files
committed
feat(status): show the worktree holding each branch
`git stack status` gave no signal that a branch was checked out in another git worktree; the branch rendered exactly like any other local branch, and the conflict only surfaced when a checkout was attempted and git refused. Enumerate worktrees once per render and print the holding worktree's path last on the branch line, in square brackets, in a pale green distinct from the diff-stat green. Every worktree is badged, including the current and main ones, so a single-worktree repo now shows its checked-out branch's repo root. Detached, prunable, and bare worktrees contribute nothing, and a failed enumeration renders the tree with no badges and no error. `worktree_holding_branch` now consumes the same shared map, so one enumeration path and one set of parse rules serve both the checkout guard and the renderer. The TUI renders the same badge in the same position; splitting `branch_line` out of `render_branch_item` makes its spans testable.
1 parent 9853bf8 commit 9366deb

7 files changed

Lines changed: 452 additions & 22 deletions

File tree

src/git.rs

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::{
2+
collections::HashMap,
23
process::{Command, ExitStatus},
34
time::Instant,
45
};
@@ -464,6 +465,33 @@ fn canonicalize_worktree_path(path: &str) -> String {
464465
.unwrap_or_else(|| path.trim_end_matches('/').to_string())
465466
}
466467

468+
/// Map short branch name to the git-reported path of the worktree holding it.
469+
///
470+
/// Entries with no `branch` line (detached HEAD, bare repo) contribute nothing:
471+
/// no branch is checked out there. Prunable worktrees are skipped because their
472+
/// directory is gone, so reporting the path would point at nothing. The current
473+
/// worktree is *kept* — callers that only care about *other* worktrees filter it
474+
/// out themselves.
475+
fn worktree_map_from_entries(entries: Vec<WorktreeEntry>) -> HashMap<String, String> {
476+
entries
477+
.into_iter()
478+
.filter(|entry| !entry.prunable)
479+
.filter_map(|entry| entry.branch.map(|branch| (branch, entry.path)))
480+
.collect()
481+
}
482+
483+
/// Enumerate every worktree once, mapping checked-out branch to worktree path.
484+
///
485+
/// Returns an empty map when enumeration fails for any reason: callers use this
486+
/// for display and for diagnostic pre-checks, neither of which may fail because
487+
/// `git worktree list` did.
488+
pub(crate) fn worktree_paths_by_branch() -> HashMap<String, String> {
489+
match run_git(&["worktree", "list", "--porcelain"]) {
490+
Ok(out) => worktree_map_from_entries(parse_worktree_list(&out.stdout)),
491+
Err(_) => HashMap::new(),
492+
}
493+
}
494+
467495
/// If `branch` is checked out in a worktree *other than* the current one,
468496
/// return that worktree's git-reported path. Returns `None` when the branch is
469497
/// not checked out elsewhere, is checked out in the current worktree (a valid
@@ -473,21 +501,12 @@ fn canonicalize_worktree_path(path: &str) -> String {
473501
/// never block a normal checkout.
474502
fn worktree_holding_branch(git_repo: &GitRepo, branch: &str) -> Option<String> {
475503
let current_root = canonicalize_worktree_path(&git_repo.root().ok()?);
476-
let out = run_git(&["worktree", "list", "--porcelain"]).ok()?;
477-
for entry in parse_worktree_list(&out.stdout) {
478-
if entry.branch.as_deref() != Some(branch) {
479-
continue;
480-
}
481-
if entry.prunable {
482-
return None;
483-
}
484-
if canonicalize_worktree_path(&entry.path) == current_root {
485-
// Already on this branch in the current worktree — valid no-op.
486-
return None;
487-
}
488-
return Some(entry.path);
504+
let path = worktree_paths_by_branch().remove(branch)?;
505+
if canonicalize_worktree_path(&path) == current_root {
506+
// Already on this branch in the current worktree — valid no-op.
507+
return None;
489508
}
490-
None
509+
Some(path)
491510
}
492511

493512
/// Check out an existing tracked branch, first explaining the common failure
@@ -777,4 +796,60 @@ mod tests {
777796
fn parse_worktree_list_empty_input() {
778797
assert!(parse_worktree_list("").is_empty());
779798
}
799+
800+
#[test]
801+
fn worktree_map_keeps_branch_worktrees_and_drops_the_rest() {
802+
// Main worktree on `main`, a linked worktree on `feature`, a detached
803+
// worktree, and a stale worktree that still names `abandoned`.
804+
let output = "worktree /repo\n\
805+
HEAD 1111111111111111111111111111111111111111\n\
806+
branch refs/heads/main\n\
807+
\n\
808+
worktree /wt/feature-wt\n\
809+
HEAD 2222222222222222222222222222222222222222\n\
810+
branch refs/heads/feature\n\
811+
\n\
812+
worktree /wt/detached-wt\n\
813+
HEAD 3333333333333333333333333333333333333333\n\
814+
detached\n\
815+
\n\
816+
worktree /gone/abandoned-wt\n\
817+
HEAD 4444444444444444444444444444444444444444\n\
818+
branch refs/heads/abandoned\n\
819+
prunable gitdir file points to non-existent location\n";
820+
let map = worktree_map_from_entries(parse_worktree_list(output));
821+
822+
assert_eq!(map.len(), 2);
823+
assert_eq!(map.get("main").map(String::as_str), Some("/repo"));
824+
assert_eq!(
825+
map.get("feature").map(String::as_str),
826+
Some("/wt/feature-wt")
827+
);
828+
// A detached worktree has no branch to attribute the path to.
829+
assert!(!map.values().any(|p| p == "/wt/detached-wt"));
830+
// A prunable worktree's directory is gone; printing its path is worse
831+
// than printing nothing.
832+
assert!(!map.contains_key("abandoned"));
833+
}
834+
835+
#[test]
836+
fn worktree_map_includes_the_lone_worktree() {
837+
// No worktree-count gate: a repo with only its main worktree still maps
838+
// that worktree's checked-out branch.
839+
let output = "worktree /repo\n\
840+
HEAD 1111111111111111111111111111111111111111\n\
841+
branch refs/heads/main\n";
842+
let map = worktree_map_from_entries(parse_worktree_list(output));
843+
844+
assert_eq!(map.len(), 1);
845+
assert_eq!(map.get("main").map(String::as_str), Some("/repo"));
846+
}
847+
848+
#[test]
849+
fn worktree_map_empty_for_bare_repo() {
850+
// A bare repo emits `bare` and no branch line.
851+
let output = "worktree /repo.git\n\
852+
bare\n";
853+
assert!(worktree_map_from_entries(parse_worktree_list(output)).is_empty());
854+
}
780855
}

src/render/cli.rs

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,30 @@ fn apply_color(s: &str, color: ThemeColor) -> colored::ColoredString {
2525
s.truecolor(r, g, b)
2626
}
2727

28+
/// Replace a leading `$HOME` with `~`, leaving any other path unchanged.
29+
pub fn abbreviate_home(path: &str) -> String {
30+
abbreviate_with_home(path, std::env::var("HOME").ok().as_deref())
31+
}
32+
33+
/// Core of [`abbreviate_home`], with `home` passed explicitly so it is testable
34+
/// without mutating the process environment. Only matches on a path-segment
35+
/// boundary, so `/home/willy` is not rewritten under `HOME=/home/will`.
36+
fn abbreviate_with_home(path: &str, home: Option<&str>) -> String {
37+
let Some(home) = home.map(|h| h.trim_end_matches('/')) else {
38+
return path.to_string();
39+
};
40+
if home.is_empty() {
41+
return path.to_string();
42+
}
43+
if path == home {
44+
return "~".to_string();
45+
}
46+
match path.strip_prefix(home) {
47+
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
48+
_ => path.to_string(),
49+
}
50+
}
51+
2852
/// Render the tree to the CLI.
2953
pub fn render_cli(tree: &RenderableTree, verbose: bool) {
3054
for branch in &tree.branches {
@@ -110,10 +134,36 @@ fn render_branch(branch: &RenderableBranch, verbose: bool) {
110134
})
111135
.unwrap_or_default();
112136

137+
// Holding worktree. Placed last on the line by both renderers below.
138+
let worktree = branch
139+
.worktree_path
140+
.as_deref()
141+
.map(|path| {
142+
format!(
143+
" [{}]",
144+
apply_color(&abbreviate_home(path), theme::WORKTREE.apply_dim(dim))
145+
)
146+
})
147+
.unwrap_or_default();
148+
113149
if verbose {
114-
render_verbose_line(branch, &branch_name, &diff_stats, &local_status, dim);
150+
render_verbose_line(
151+
branch,
152+
&branch_name,
153+
&diff_stats,
154+
&local_status,
155+
&worktree,
156+
dim,
157+
);
115158
} else {
116-
render_simple_line(branch, &branch_name, &diff_stats, &local_status, dim);
159+
render_simple_line(
160+
branch,
161+
&branch_name,
162+
&diff_stats,
163+
&local_status,
164+
&worktree,
165+
dim,
166+
);
117167
}
118168
}
119169

@@ -122,6 +172,7 @@ fn render_simple_line(
122172
branch_name: &colored::ColoredString,
123173
diff_stats: &str,
124174
local_status: &str,
175+
worktree: &str,
125176
dim: f32,
126177
) {
127178
// PR info
@@ -158,18 +209,22 @@ fn render_simple_line(
158209
})
159210
.unwrap_or_default();
160211

161-
println!("{}{}{}{}", branch_name, diff_stats, local_status, pr_info);
212+
println!(
213+
"{}{}{}{}{}",
214+
branch_name, diff_stats, local_status, pr_info, worktree
215+
);
162216
}
163217

164218
fn render_verbose_line(
165219
branch: &RenderableBranch,
166220
branch_name: &colored::ColoredString,
167221
diff_stats: &str,
168222
local_status: &str,
223+
worktree: &str,
169224
dim: f32,
170225
) {
171226
let Some(ref status) = branch.status else {
172-
println!("{}", branch_name);
227+
println!("{}{}", branch_name, worktree);
173228
return;
174229
};
175230

@@ -245,7 +300,7 @@ fn render_verbose_line(
245300
.unwrap_or_default();
246301

247302
println!(
248-
"{}{}{} ({}) {}{}{}{}",
303+
"{}{}{} ({}) {}{}{}{}{}",
249304
branch_name,
250305
diff_stats,
251306
local_status,
@@ -254,6 +309,7 @@ fn render_verbose_line(
254309
upstream_info,
255310
lkg_info,
256311
method_info,
312+
worktree,
257313
);
258314

259315
// Note preview
@@ -270,3 +326,51 @@ fn render_verbose_line(
270326
println!(" {} {}", apply_color("›", theme::TREE), note_display);
271327
}
272328
}
329+
330+
#[cfg(test)]
331+
mod tests {
332+
use super::abbreviate_with_home;
333+
334+
#[test]
335+
fn abbreviates_a_path_under_home() {
336+
assert_eq!(
337+
abbreviate_with_home("/home/will/src/proj", Some("/home/will")),
338+
"~/src/proj"
339+
);
340+
assert_eq!(abbreviate_with_home("/home/will", Some("/home/will")), "~");
341+
// A trailing slash on HOME must not leave a doubled separator.
342+
assert_eq!(
343+
abbreviate_with_home("/home/will/src", Some("/home/will/")),
344+
"~/src"
345+
);
346+
}
347+
348+
#[test]
349+
fn passes_through_a_path_outside_home() {
350+
assert_eq!(
351+
abbreviate_with_home("/var/tmp/feature-wt", Some("/home/will")),
352+
"/var/tmp/feature-wt"
353+
);
354+
}
355+
356+
#[test]
357+
fn passes_through_when_home_is_unset_or_empty() {
358+
assert_eq!(
359+
abbreviate_with_home("/home/will/src", None),
360+
"/home/will/src"
361+
);
362+
assert_eq!(
363+
abbreviate_with_home("/home/will/src", Some("")),
364+
"/home/will/src"
365+
);
366+
}
367+
368+
#[test]
369+
fn does_not_match_a_partial_path_segment() {
370+
// `/home/willy` is a different user than `HOME=/home/will`.
371+
assert_eq!(
372+
abbreviate_with_home("/home/willy/src", Some("/home/will")),
373+
"/home/willy/src"
374+
);
375+
}
376+
}

src/render/colors.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ pub mod theme {
3737
pub const UPSTREAM: ThemeColor = ThemeColor(88, 88, 88);
3838
pub const STACKED_ON: ThemeColor = ThemeColor(90, 120, 87);
3939
pub const BLUE: ThemeColor = ThemeColor(131, 165, 152);
40+
/// Worktree paths. Deliberately paler than `GREEN` (which already marks
41+
/// descendant branch names, `+additions`, and staged counts) so a diff-stat
42+
/// badge and a worktree path don't read as the same token.
43+
pub const WORKTREE: ThemeColor = ThemeColor(168, 230, 163);
4044
}
4145

4246
/// Compute a deterministic RGB color from a string using its hash.

src/render/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ pub mod cli;
44
pub mod colors;
55
pub mod tree_data;
66

7-
pub use cli::render_cli;
7+
pub use cli::{abbreviate_home, render_cli};
88
pub use colors::ThemeColor;
99
pub use tree_data::{
1010
BranchRenderStatus, PrRenderInfo, RenderableBranch, RenderableTree, apply_pr_cache,

src/render/tree_data.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use std::collections::{HashMap, HashSet};
44

55
use crate::{
6-
git::get_local_status,
6+
git::{get_local_status, worktree_paths_by_branch},
77
git2_ops::GitRepo,
88
github::{PrDisplayState, PullRequest},
99
state::Branch,
@@ -95,6 +95,10 @@ pub struct RenderableBranch {
9595
pub note_preview: Option<String>,
9696
/// Verbose details (populated when verbose mode is requested).
9797
pub verbose: Option<VerboseDetails>,
98+
/// Absolute path of the git worktree holding this branch, if any. Stored raw
99+
/// as git reports it; renderers abbreviate it so the shortening policy lives
100+
/// in one place.
101+
pub worktree_path: Option<String>,
98102
/// Index in the flattened list (for TUI cursor navigation).
99103
pub index: usize,
100104
}
@@ -222,6 +226,8 @@ pub fn compute_renderable_tree(
222226
let hidden =
223227
compute_hidden_branches(tree, current_branch, authors_filter, pr_authors, show_all);
224228
let mut diff_cache = DiffStatsCache::new();
229+
// One subprocess for the whole walk, regardless of branch count.
230+
let worktrees = worktree_paths_by_branch();
225231

226232
flatten_tree(
227233
git_repo,
@@ -233,6 +239,7 @@ pub fn compute_renderable_tree(
233239
authors_filter,
234240
pr_authors,
235241
&hidden,
242+
&worktrees,
236243
&mut branches,
237244
&mut current_branch_index,
238245
&mut diff_cache,
@@ -271,6 +278,7 @@ fn flatten_tree(
271278
authors_filter: &[String],
272279
pr_authors: &HashMap<String, String>,
273280
hidden: &HashSet<String>,
281+
worktrees: &HashMap<String, String>,
274282
result: &mut Vec<RenderableBranch>,
275283
current_branch_index: &mut Option<usize>,
276284
cache: &mut DiffStatsCache,
@@ -371,6 +379,7 @@ fn flatten_tree(
371379
pr_info,
372380
note_preview,
373381
verbose: verbose_details,
382+
worktree_path: worktrees.get(&branch.name).cloned(),
374383
index,
375384
});
376385
}
@@ -431,6 +440,7 @@ fn flatten_tree(
431440
authors_filter,
432441
pr_authors,
433442
hidden,
443+
worktrees,
434444
result,
435445
current_branch_index,
436446
cache,
@@ -741,6 +751,7 @@ mod tests {
741751
pr_info: None,
742752
note_preview: None,
743753
verbose: None,
754+
worktree_path: None,
744755
index,
745756
}
746757
}

0 commit comments

Comments
 (0)