@@ -36,7 +36,7 @@ pub enum StackMethod {
3636 Merge ,
3737}
3838
39- #[ derive( Debug , Serialize , Deserialize ) ]
39+ #[ derive( Debug , Clone , Serialize , Deserialize ) ]
4040pub struct Branch {
4141 /// The name of the branch or ref.
4242 pub name : String ,
@@ -239,6 +239,161 @@ impl State {
239239 Ok ( ( ) )
240240 }
241241
242+ pub ( crate ) fn cleanup_missing_branches (
243+ & mut self ,
244+ repo : & str ,
245+ dry_run : bool ,
246+ all : bool ,
247+ ) -> Result < ( ) > {
248+ if all {
249+ self . cleanup_all_trees ( dry_run)
250+ } else {
251+ self . cleanup_single_tree ( repo, dry_run)
252+ }
253+ }
254+
255+ fn cleanup_single_tree ( & mut self , repo : & str , dry_run : bool ) -> Result < ( ) > {
256+ let Some ( tree) = self . trees . get_mut ( repo) else {
257+ println ! ( "No stack tree found for repo {}" , repo. yellow( ) ) ;
258+ return Ok ( ( ) ) ;
259+ } ;
260+
261+ let mut removed_branches = Vec :: new ( ) ;
262+ let mut remounted_branches = Vec :: new ( ) ;
263+
264+ // Recursively cleanup the tree
265+ cleanup_tree_recursive ( tree, & mut removed_branches, & mut remounted_branches) ;
266+
267+ if removed_branches. is_empty ( ) {
268+ println ! ( "No missing branches found. Tree is clean." ) ;
269+ return Ok ( ( ) ) ;
270+ }
271+
272+ // Print summary
273+ println ! ( "Cleanup summary for {}:" , repo. yellow( ) ) ;
274+ println ! ( ) ;
275+ println ! ( "Removed branches (no longer exist locally):" ) ;
276+ for branch_name in & removed_branches {
277+ println ! ( " - {}" , branch_name. red( ) ) ;
278+ }
279+
280+ if !remounted_branches. is_empty ( ) {
281+ println ! ( ) ;
282+ println ! ( "Re-mounted branches (moved to grandparent):" ) ;
283+ for ( branch_name, new_parent) in & remounted_branches {
284+ println ! (
285+ " - {} {} {}" ,
286+ branch_name. yellow( ) ,
287+ "→" . truecolor( 90 , 90 , 90 ) ,
288+ new_parent. green( )
289+ ) ;
290+ }
291+ }
292+
293+ if dry_run {
294+ println ! ( ) ;
295+ println ! ( "{}" , "Dry run mode: no changes were saved." . bright_blue( ) ) ;
296+ } else {
297+ self . save_state ( ) ?;
298+ println ! ( ) ;
299+ println ! ( "{}" , "Changes saved." . green( ) ) ;
300+ }
301+
302+ Ok ( ( ) )
303+ }
304+
305+ fn cleanup_all_trees ( & mut self , dry_run : bool ) -> Result < ( ) > {
306+ let mut repos_to_remove = Vec :: new ( ) ;
307+ let mut total_removed_branches = 0 ;
308+ let mut total_remounted_branches = 0 ;
309+
310+ // Collect all repo paths first to avoid borrow checker issues
311+ let repo_paths: Vec < String > = self . trees . keys ( ) . cloned ( ) . collect ( ) ;
312+
313+ println ! ( "Scanning {} repositories..." , repo_paths. len( ) ) ;
314+ println ! ( ) ;
315+
316+ for repo_path in & repo_paths {
317+ // Check if the directory exists
318+ if !std:: path:: Path :: new ( repo_path) . exists ( ) {
319+ println ! (
320+ "{}: {}" ,
321+ repo_path. yellow( ) ,
322+ "directory does not exist" . red( )
323+ ) ;
324+ repos_to_remove. push ( repo_path. clone ( ) ) ;
325+ continue ;
326+ }
327+
328+ // Check if git works in this directory
329+ let original_dir = std:: env:: current_dir ( ) ?;
330+ let git_works = std:: env:: set_current_dir ( repo_path) . is_ok ( )
331+ && run_git ( & [ "rev-parse" , "--git-dir" ] ) . is_ok ( ) ;
332+
333+ // Always restore the original directory
334+ std:: env:: set_current_dir ( original_dir) ?;
335+
336+ if !git_works {
337+ println ! ( "{}: {}" , repo_path. yellow( ) , "git is not working" . red( ) ) ;
338+ repos_to_remove. push ( repo_path. clone ( ) ) ;
339+ continue ;
340+ }
341+
342+ // Change to the repo directory and clean up branches
343+ let original_dir = std:: env:: current_dir ( ) ?;
344+ std:: env:: set_current_dir ( repo_path) ?;
345+
346+ let tree = self . trees . get_mut ( repo_path) . unwrap ( ) ;
347+ let mut removed_branches = Vec :: new ( ) ;
348+ let mut remounted_branches = Vec :: new ( ) ;
349+
350+ cleanup_tree_recursive ( tree, & mut removed_branches, & mut remounted_branches) ;
351+
352+ std:: env:: set_current_dir ( original_dir) ?;
353+
354+ if !removed_branches. is_empty ( ) || !remounted_branches. is_empty ( ) {
355+ println ! (
356+ "{}: cleaned up {} branches, re-mounted {}" ,
357+ repo_path. yellow( ) ,
358+ removed_branches. len( ) . to_string( ) . red( ) ,
359+ remounted_branches. len( ) . to_string( ) . green( )
360+ ) ;
361+ total_removed_branches += removed_branches. len ( ) ;
362+ total_remounted_branches += remounted_branches. len ( ) ;
363+ } else {
364+ println ! ( "{}: {}" , repo_path. yellow( ) , "clean" . green( ) ) ;
365+ }
366+ }
367+
368+ // Remove invalid repos
369+ if !repos_to_remove. is_empty ( ) {
370+ println ! ( ) ;
371+ println ! ( "Removing {} invalid repositories:" , repos_to_remove. len( ) ) ;
372+ for repo_path in & repos_to_remove {
373+ println ! ( " - {}" , repo_path. red( ) ) ;
374+ self . trees . remove ( repo_path) ;
375+ }
376+ }
377+
378+ println ! ( ) ;
379+ println ! ( "Summary:" ) ;
380+ println ! ( " Repositories scanned: {}" , repo_paths. len( ) ) ;
381+ println ! ( " Invalid repositories removed: {}" , repos_to_remove. len( ) ) ;
382+ println ! ( " Branches removed: {}" , total_removed_branches) ;
383+ println ! ( " Branches re-mounted: {}" , total_remounted_branches) ;
384+
385+ if dry_run {
386+ println ! ( ) ;
387+ println ! ( "{}" , "Dry run mode: no changes were saved." . bright_blue( ) ) ;
388+ } else {
389+ self . save_state ( ) ?;
390+ println ! ( ) ;
391+ println ! ( "{}" , "Changes saved." . green( ) ) ;
392+ }
393+
394+ Ok ( ( ) )
395+ }
396+
242397 pub ( crate ) fn ensure_trunk ( & mut self , repo : & str ) -> Result < GitTrunk > {
243398 let trunk = git_trunk ( ) ?;
244399 // The branch might not exist in git, let's create it, and add it to the tree.
@@ -521,6 +676,46 @@ fn is_branch_mentioned_in_tree(branch_name: &str, branch: &Branch) -> bool {
521676 }
522677 false
523678}
679+
680+ /// Recursively cleans up missing branches from the tree.
681+ /// Returns the number of branches cleaned up at this level.
682+ fn cleanup_tree_recursive (
683+ branch : & mut Branch ,
684+ removed_branches : & mut Vec < String > ,
685+ remounted_branches : & mut Vec < ( String , String ) > ,
686+ ) {
687+ // First, recursively process all children
688+ for child in & mut branch. branches {
689+ cleanup_tree_recursive ( child, removed_branches, remounted_branches) ;
690+ }
691+
692+ // Collect branches to remove and their children to adopt
693+ let mut branches_to_adopt: Vec < Branch > = Vec :: new ( ) ;
694+ let mut indices_to_remove = Vec :: new ( ) ;
695+
696+ for ( index, child) in branch. branches . iter ( ) . enumerate ( ) {
697+ if !git_branch_exists ( & child. name ) {
698+ // This branch doesn't exist locally, mark it for removal
699+ removed_branches. push ( child. name . clone ( ) ) ;
700+
701+ // Collect its children to be adopted by the current branch
702+ for grandchild in & child. branches {
703+ branches_to_adopt. push ( grandchild. clone ( ) ) ;
704+ remounted_branches. push ( ( grandchild. name . clone ( ) , branch. name . clone ( ) ) ) ;
705+ }
706+
707+ indices_to_remove. push ( index) ;
708+ }
709+ }
710+
711+ // Remove missing branches (in reverse order to maintain indices)
712+ for & index in indices_to_remove. iter ( ) . rev ( ) {
713+ branch. branches . remove ( index) ;
714+ }
715+
716+ // Add adopted branches
717+ branch. branches . extend ( branches_to_adopt) ;
718+ }
524719fn find_stack_with_branch < ' a > (
525720 stacks : & ' a mut [ Vec < String > ] ,
526721 current_branch : & str ,
0 commit comments