@@ -682,6 +682,32 @@ fn build_target_state(git_repo: &GitRepo, local: &LocalState, remote: &RemoteSta
682682
683683// ============== Stage 3: Diff Functions ==============
684684
685+ /// Walk up the parent chain from `branch`, skipping ancestors that are themselves
686+ /// being unmounted, and return the first surviving ancestor (falling back to
687+ /// `trunk` when we run off the top of the chain).
688+ fn resolve_repoint (
689+ branch : & str ,
690+ branches : & HashMap < String , LocalBranch > ,
691+ trunk : & str ,
692+ unmount_set : & HashSet < String > ,
693+ ) -> String {
694+ let mut visited: HashSet < String > = HashSet :: new ( ) ;
695+ let mut candidate: Option < String > = branches. get ( branch) . and_then ( |b| b. parent . clone ( ) ) ;
696+
697+ while let Some ( name) = candidate {
698+ if name == trunk || !unmount_set. contains ( & name) {
699+ return name;
700+ }
701+ if !visited. insert ( name. clone ( ) ) {
702+ // Cycle guard (shouldn't happen in a well-formed tree).
703+ return trunk. to_string ( ) ;
704+ }
705+ candidate = branches. get ( & name) . and_then ( |b| b. parent . clone ( ) ) ;
706+ }
707+
708+ trunk. to_string ( )
709+ }
710+
685711/// Compute the sync plan by diffing current state against target
686712fn compute_sync_plan (
687713 git_repo : & GitRepo ,
@@ -807,7 +833,6 @@ fn compute_sync_plan(
807833
808834 // Detect merged/closed PRs and handle unmounting (pull direction)
809835 // This runs regardless of push_only/pull_only since it's about reconciling state
810- let mut branches_to_unmount: Vec < ( String , String ) > = Vec :: new ( ) ; // (branch, repoint_to)
811836 let mut branches_to_delete: Vec < String > = Vec :: new ( ) ;
812837
813838 tracing:: debug!(
@@ -816,53 +841,60 @@ fn compute_sync_plan(
816841 remote. closed_prs. keys( ) . collect:: <Vec <_>>( )
817842 ) ;
818843
819- for ( branch_name, local_branch) in & local. branches {
820- // Skip trunk
844+ // First pass: collect the raw set of branches to unmount (eligible by closed PR).
845+ let mut unmount_set: HashSet < String > = HashSet :: new ( ) ;
846+ for branch_name in local. branches . keys ( ) {
821847 if branch_name == & local. trunk {
822848 continue ;
823849 }
824-
825- // Check if this branch has a merged/closed PR but no open PR
826850 if !remote. prs . contains_key ( branch_name)
827851 && let Some ( closed_pr) = remote. closed_prs . get ( branch_name)
828- {
829- tracing:: debug!(
830- "Branch '{}' has closed PR #{} with state {:?}" ,
831- branch_name,
832- closed_pr. number,
833- closed_pr. state
834- ) ;
835-
836- // Any closed PR (merged or just closed) should unmount from git-stack
837- if matches ! (
852+ && matches ! (
838853 closed_pr. state,
839854 RemotePrState :: Merged | RemotePrState :: Closed
840- ) {
841- // This branch's PR was merged/closed - it should be unmounted
842- // Children should be repointed to this branch's parent
843- let repoint_to = local_branch
844- . parent
845- . clone ( )
846- . unwrap_or_else ( || local. trunk . clone ( ) ) ;
847- branches_to_unmount. push ( ( branch_name. clone ( ) , repoint_to) ) ;
848-
849- // Determine if local branch is safe to delete
850- // Safe if: merged, OR (closed AND remote exists AND local is ancestor of remote)
851- let safe_to_delete = if closed_pr. state == RemotePrState :: Merged {
852- true
853- } else {
854- // Closed but not merged - check if remote has our work
855- let remote_ref = format ! ( "{}/{}" , DEFAULT_REMOTE , branch_name) ;
856- git_repo. ref_exists ( & remote_ref)
857- && git_repo
858- . is_ancestor ( branch_name, & remote_ref)
859- . unwrap_or ( false )
860- } ;
861-
862- if safe_to_delete {
863- branches_to_delete. push ( branch_name. clone ( ) ) ;
864- }
865- }
855+ )
856+ {
857+ unmount_set. insert ( branch_name. clone ( ) ) ;
858+ }
859+ }
860+
861+ // Second pass: emit unmount entries with transitively-resolved repoint targets,
862+ // and compute safe-to-delete.
863+ let mut branches_to_unmount: Vec < ( String , String ) > = Vec :: new ( ) ; // (branch, repoint_to)
864+ for branch_name in local. branches . keys ( ) {
865+ if !unmount_set. contains ( branch_name) {
866+ continue ;
867+ }
868+ let closed_pr = remote
869+ . closed_prs
870+ . get ( branch_name)
871+ . expect ( "unmount_set membership implies closed PR" ) ;
872+
873+ tracing:: debug!(
874+ "Branch '{}' has closed PR #{} with state {:?}" ,
875+ branch_name,
876+ closed_pr. number,
877+ closed_pr. state
878+ ) ;
879+
880+ let repoint_to = resolve_repoint ( branch_name, & local. branches , & local. trunk , & unmount_set) ;
881+ branches_to_unmount. push ( ( branch_name. clone ( ) , repoint_to) ) ;
882+
883+ // Determine if local branch is safe to delete
884+ // Safe if: merged, OR (closed AND remote exists AND local is ancestor of remote)
885+ let safe_to_delete = if closed_pr. state == RemotePrState :: Merged {
886+ true
887+ } else {
888+ // Closed but not merged - check if remote has our work
889+ let remote_ref = format ! ( "{}/{}" , DEFAULT_REMOTE , branch_name) ;
890+ git_repo. ref_exists ( & remote_ref)
891+ && git_repo
892+ . is_ancestor ( branch_name, & remote_ref)
893+ . unwrap_or ( false )
894+ } ;
895+
896+ if safe_to_delete {
897+ branches_to_delete. push ( branch_name. clone ( ) ) ;
866898 }
867899 }
868900
@@ -1567,3 +1599,74 @@ fn find_branch_by_name_mut<'a>(tree: &'a mut Branch, name: &str) -> Option<&'a m
15671599 }
15681600 None
15691601}
1602+
1603+ #[ cfg( test) ]
1604+ mod tests {
1605+ use super :: * ;
1606+
1607+ fn lb ( parent : Option < & str > ) -> LocalBranch {
1608+ LocalBranch {
1609+ parent : parent. map ( String :: from) ,
1610+ pr_number : None ,
1611+ pushed_to_remote : true ,
1612+ }
1613+ }
1614+
1615+ #[ test]
1616+ fn resolve_repoint_skips_chain_of_unmounted_ancestors ( ) {
1617+ // Two chains on top of main, all closed-PR → all in unmount_set.
1618+ //
1619+ // main ← grpc-level-08 ← grpc-level-09 ← grpc-level-10 (leaf)
1620+ // main ← sadhan/09 ← sadhan/12 ← sadhan/15 ← sadhan/17
1621+ let trunk = "main" . to_string ( ) ;
1622+ let mut branches: HashMap < String , LocalBranch > = HashMap :: new ( ) ;
1623+ branches. insert ( "grpc-level-08" . into ( ) , lb ( Some ( "main" ) ) ) ;
1624+ branches. insert ( "grpc-level-09" . into ( ) , lb ( Some ( "grpc-level-08" ) ) ) ;
1625+ branches. insert ( "grpc-level-10" . into ( ) , lb ( Some ( "grpc-level-09" ) ) ) ;
1626+ branches. insert ( "sadhan/09" . into ( ) , lb ( Some ( "main" ) ) ) ;
1627+ branches. insert ( "sadhan/12" . into ( ) , lb ( Some ( "sadhan/09" ) ) ) ;
1628+ branches. insert ( "sadhan/15" . into ( ) , lb ( Some ( "sadhan/12" ) ) ) ;
1629+ branches. insert ( "sadhan/17" . into ( ) , lb ( Some ( "sadhan/15" ) ) ) ;
1630+
1631+ let unmount_set: HashSet < String > = branches. keys ( ) . cloned ( ) . collect ( ) ;
1632+
1633+ for branch in branches. keys ( ) {
1634+ let got = resolve_repoint ( branch, & branches, & trunk, & unmount_set) ;
1635+ assert_eq ! (
1636+ got, trunk,
1637+ "branch '{branch}' should resolve past all unmounted ancestors to trunk, got '{got}'"
1638+ ) ;
1639+ }
1640+ }
1641+
1642+ #[ test]
1643+ fn resolve_repoint_stops_at_first_surviving_ancestor ( ) {
1644+ // main ← A (live) ← B (unmount) ← C (unmount) ← D (leaf, unmount)
1645+ // D, C, B are being unmounted; A survives.
1646+ // Expected: D and C and B all repoint to A.
1647+ let trunk = "main" . to_string ( ) ;
1648+ let mut branches: HashMap < String , LocalBranch > = HashMap :: new ( ) ;
1649+ branches. insert ( "A" . into ( ) , lb ( Some ( "main" ) ) ) ;
1650+ branches. insert ( "B" . into ( ) , lb ( Some ( "A" ) ) ) ;
1651+ branches. insert ( "C" . into ( ) , lb ( Some ( "B" ) ) ) ;
1652+ branches. insert ( "D" . into ( ) , lb ( Some ( "C" ) ) ) ;
1653+
1654+ let unmount_set: HashSet < String > = [ "B" , "C" , "D" ] . iter ( ) . map ( |s| s. to_string ( ) ) . collect ( ) ;
1655+
1656+ assert_eq ! ( resolve_repoint( "D" , & branches, & trunk, & unmount_set) , "A" ) ;
1657+ assert_eq ! ( resolve_repoint( "C" , & branches, & trunk, & unmount_set) , "A" ) ;
1658+ assert_eq ! ( resolve_repoint( "B" , & branches, & trunk, & unmount_set) , "A" ) ;
1659+ }
1660+
1661+ #[ test]
1662+ fn resolve_repoint_falls_back_to_trunk_when_no_parent ( ) {
1663+ let trunk = "main" . to_string ( ) ;
1664+ let mut branches: HashMap < String , LocalBranch > = HashMap :: new ( ) ;
1665+ branches. insert ( "orphan" . into ( ) , lb ( None ) ) ;
1666+ let unmount_set: HashSet < String > = HashSet :: new ( ) ;
1667+ assert_eq ! (
1668+ resolve_repoint( "orphan" , & branches, & trunk, & unmount_set) ,
1669+ trunk
1670+ ) ;
1671+ }
1672+ }
0 commit comments