11use 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.
474502fn 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}
0 commit comments