@@ -148,6 +148,15 @@ enum Command {
148148 /// Restack all parent branches recursively up to trunk.
149149 #[ arg( long, short = 'a' , default_value_t = false ) ]
150150 all_parents : bool ,
151+ /// Squash all commits in the branch into a single commit.
152+ #[ arg( long, short = 's' , default_value_t = false ) ]
153+ squash : bool ,
154+ /// Continue a previously interrupted squash operation after conflict resolution.
155+ #[ arg( long, default_value_t = false ) ]
156+ r#continue : bool ,
157+ /// Abort an in-progress squash operation and restore the original branch state.
158+ #[ arg( long, default_value_t = false ) ]
159+ abort : bool ,
151160 } ,
152161 /// Shows the log between the given branch and its parent (git-stack tree) branch.
153162 Log {
@@ -358,6 +367,23 @@ fn inner_main() -> Result<()> {
358367 let current_upstream = git_repo. get_upstream ( "" ) ;
359368 tracing:: debug!( run_version, current_branch, current_upstream) ;
360369
370+ // Check for pending squash operation - block other commands except --continue and --abort
371+ if state. has_pending_squash ( & repo) {
372+ match & args. command {
373+ Some ( Command :: Restack {
374+ r#continue : true , ..
375+ } ) => { /* allowed */ }
376+ Some ( Command :: Restack { abort : true , .. } ) => { /* allowed */ }
377+ _ => {
378+ bail ! (
379+ "A squash operation is in progress for this repository.\n \
380+ Run `git stack restack --continue` after resolving conflicts,\n \
381+ or `git stack restack --abort` to cancel."
382+ ) ;
383+ }
384+ }
385+ }
386+
361387 match args. command {
362388 Some ( Command :: Checkout { branch_name } ) => state. checkout (
363389 & git_repo,
@@ -372,7 +398,18 @@ fn inner_main() -> Result<()> {
372398 fetch,
373399 push,
374400 all_parents,
401+ squash,
402+ r#continue,
403+ abort,
375404 } ) => {
405+ // Handle --continue first
406+ if r#continue {
407+ return handle_squash_continue ( & git_repo, & mut state, & repo) ;
408+ }
409+ // Handle --abort
410+ if abort {
411+ return handle_squash_abort ( & git_repo, & mut state, & repo) ;
412+ }
376413 let restack_branch = branch. clone ( ) . unwrap_or_else ( || current_branch. clone ( ) ) ;
377414 state. try_auto_mount ( & git_repo, & repo, & restack_branch) ?;
378415 restack (
@@ -385,6 +422,7 @@ fn inner_main() -> Result<()> {
385422 fetch,
386423 push,
387424 all_parents,
425+ squash,
388426 )
389427 }
390428 Some ( Command :: Mount { parent_branch } ) => {
@@ -1005,6 +1043,202 @@ fn status(
10051043 Ok ( ( ) )
10061044}
10071045
1046+ /// Get concatenated commit messages between ancestor and branch tip.
1047+ fn get_concatenated_commit_messages ( branch : & str , ancestor : & str ) -> Result < String > {
1048+ let output = run_git ( & [
1049+ "log" ,
1050+ "--reverse" ,
1051+ "--format=%B" ,
1052+ & format ! ( "{}..{}" , ancestor, branch) ,
1053+ ] ) ?;
1054+
1055+ let messages = output. output ( ) . unwrap_or_default ( ) ;
1056+ if messages. trim ( ) . is_empty ( ) {
1057+ bail ! ( "No commits found between {} and {}" , ancestor, branch) ;
1058+ }
1059+
1060+ // Clean up the messages - join with separator for readability
1061+ let cleaned: String = messages
1062+ . split ( "\n \n " )
1063+ . filter ( |s| !s. trim ( ) . is_empty ( ) )
1064+ . collect :: < Vec < _ > > ( )
1065+ . join ( "\n \n " ) ;
1066+
1067+ Ok ( cleaned)
1068+ }
1069+
1070+ /// Complete a squash operation (either after clean merge or after conflict resolution).
1071+ fn complete_squash (
1072+ git_repo : & GitRepo ,
1073+ state : & mut State ,
1074+ repo : & str ,
1075+ pending : & state:: PendingSquashOperation ,
1076+ ) -> Result < ( ) > {
1077+ // Commit with the concatenated messages
1078+ run_git ( & [ "commit" , "-m" , & pending. squash_message ] ) ?;
1079+
1080+ // Move the branch pointer: git checkout -B <branch>
1081+ // This points <branch> to current HEAD (the squashed commit) and checks it out
1082+ run_git ( & [ "checkout" , "-B" , & pending. branch_name ] ) ?;
1083+
1084+ // Clean up temp branch
1085+ let _ = run_git ( & [ "branch" , "-D" , & pending. tmp_branch_name ] ) ;
1086+
1087+ // Clear the pending operation
1088+ state. set_pending_squash ( repo, None ) ;
1089+ state. save_state ( ) ?;
1090+
1091+ println ! (
1092+ "Squash completed for branch '{}'." ,
1093+ pending. branch_name. yellow( )
1094+ ) ;
1095+
1096+ Ok ( ( ) )
1097+ }
1098+
1099+ /// Execute a squash operation for a single branch.
1100+ fn squash_branch (
1101+ git_repo : & GitRepo ,
1102+ state : & mut State ,
1103+ repo : & str ,
1104+ branch : & state:: Branch ,
1105+ parent : & str ,
1106+ ) -> Result < bool > {
1107+ let branch_name = & branch. name ;
1108+ let tmp_branch = format ! ( "tmp-{}" , branch_name) ;
1109+
1110+ // Determine ancestor for commit message range
1111+ let source_sha = git_repo. sha ( branch_name) ?;
1112+ let lkg_ancestor = branch
1113+ . lkg_parent
1114+ . as_deref ( )
1115+ . filter ( |lkg| git_repo. is_ancestor ( lkg, & source_sha) . unwrap_or ( false ) )
1116+ . map ( |s| s. to_string ( ) )
1117+ . or_else ( || git_repo. merge_base ( parent, branch_name) . ok ( ) )
1118+ . ok_or_else ( || anyhow ! ( "Cannot determine ancestor for commit messages" ) ) ?;
1119+
1120+ // Collect commit messages from ancestor to branch tip
1121+ let squash_message = get_concatenated_commit_messages ( branch_name, & lkg_ancestor) ?;
1122+
1123+ // Save original SHA for recovery
1124+ let original_sha = git_repo. sha ( branch_name) ?;
1125+
1126+ // Create pending operation state
1127+ let pending = state:: PendingSquashOperation {
1128+ branch_name : branch_name. clone ( ) ,
1129+ parent_branch : parent. to_string ( ) ,
1130+ tmp_branch_name : tmp_branch. clone ( ) ,
1131+ original_sha,
1132+ squash_message : squash_message. clone ( ) ,
1133+ } ;
1134+ state. set_pending_squash ( repo, Some ( pending. clone ( ) ) ) ;
1135+ state. save_state ( ) ?;
1136+
1137+ // Execute the squash workflow
1138+ // git checkout <parent>
1139+ run_git ( & [ "checkout" , parent] ) ?;
1140+
1141+ // git checkout -B tmp-<branch>
1142+ run_git ( & [ "checkout" , "-B" , & tmp_branch] ) ?;
1143+
1144+ // git merge --squash <branch>
1145+ let merge_status = run_git_status ( & [ "merge" , "--squash" , branch_name] , None ) ?;
1146+
1147+ if !merge_status. success ( ) {
1148+ // Conflict! Print instructions and exit
1149+ eprintln ! ( "{}" , "Merge conflict during squash operation." . red( ) . bold( ) ) ;
1150+ eprintln ! ( ) ;
1151+ eprintln ! ( "Resolve the conflicts, then run:" ) ;
1152+ eprintln ! ( " git add <resolved-files>" ) ;
1153+ eprintln ! ( " {}" , "git stack restack --continue" . green( ) . bold( ) ) ;
1154+ eprintln ! ( ) ;
1155+ eprintln ! ( "Or to abort and restore the original branch:" ) ;
1156+ eprintln ! ( " {}" , "git stack restack --abort" . yellow( ) . bold( ) ) ;
1157+ std:: process:: exit ( 1 ) ;
1158+ }
1159+
1160+ // Complete the squash (no conflict)
1161+ complete_squash ( git_repo, state, repo, & pending) ?;
1162+
1163+ Ok ( true )
1164+ }
1165+
1166+ /// Handle the --continue flag for resuming a squash operation.
1167+ fn handle_squash_continue (
1168+ git_repo : & GitRepo ,
1169+ state : & mut State ,
1170+ repo : & str ,
1171+ ) -> Result < ( ) > {
1172+ let pending = state
1173+ . get_pending_squash ( repo)
1174+ . ok_or_else ( || anyhow ! ( "No pending squash operation to continue." ) ) ?
1175+ . clone ( ) ;
1176+
1177+ // Check if there are unresolved conflicts
1178+ let status_output = run_git ( & [ "status" , "--porcelain" ] ) ?;
1179+ let has_conflicts = status_output
1180+ . output ( )
1181+ . map ( |s| s. lines ( ) . any ( |line| line. starts_with ( "UU" ) || line. starts_with ( "AA" ) ) )
1182+ . unwrap_or ( false ) ;
1183+
1184+ if has_conflicts {
1185+ bail ! (
1186+ "There are still unresolved conflicts. Resolve them and add the files, \
1187+ then run --continue again."
1188+ ) ;
1189+ }
1190+
1191+ // Check if we're on the temp branch
1192+ let current = git_repo. current_branch ( ) ?;
1193+ if current != pending. tmp_branch_name {
1194+ bail ! (
1195+ "Expected to be on branch '{}' but currently on '{}'. \
1196+ Please checkout '{}' and run --continue again.",
1197+ pending. tmp_branch_name,
1198+ current,
1199+ pending. tmp_branch_name
1200+ ) ;
1201+ }
1202+
1203+ // Complete the squash
1204+ complete_squash ( git_repo, state, repo, & pending) ?;
1205+
1206+ Ok ( ( ) )
1207+ }
1208+
1209+ /// Handle the --abort flag for aborting a squash operation.
1210+ fn handle_squash_abort (
1211+ git_repo : & GitRepo ,
1212+ state : & mut State ,
1213+ repo : & str ,
1214+ ) -> Result < ( ) > {
1215+ let pending = state
1216+ . get_pending_squash ( repo)
1217+ . ok_or_else ( || anyhow ! ( "No pending squash operation to abort." ) ) ?
1218+ . clone ( ) ;
1219+
1220+ // Abort any in-progress merge
1221+ let _ = run_git_status ( & [ "merge" , "--abort" ] , None ) ;
1222+
1223+ // Checkout the original branch at its original SHA
1224+ run_git ( & [ "checkout" , "-f" , & pending. original_sha ] ) ?;
1225+ run_git ( & [ "checkout" , "-B" , & pending. branch_name ] ) ?;
1226+
1227+ // Clean up temp branch if it exists
1228+ let _ = run_git ( & [ "branch" , "-D" , & pending. tmp_branch_name ] ) ;
1229+
1230+ // Clear pending state
1231+ state. set_pending_squash ( repo, None ) ;
1232+ state. save_state ( ) ?;
1233+
1234+ println ! (
1235+ "Squash operation aborted. Branch '{}' restored to original state." ,
1236+ pending. branch_name. yellow( )
1237+ ) ;
1238+
1239+ Ok ( ( ) )
1240+ }
1241+
10081242#[ allow( clippy:: too_many_arguments) ]
10091243fn restack (
10101244 git_repo : & GitRepo ,
@@ -1016,6 +1250,7 @@ fn restack(
10161250 fetch : bool ,
10171251 push : bool ,
10181252 all_parents : bool ,
1253+ squash : bool ,
10191254) -> Result < ( ) , anyhow:: Error > {
10201255 let restack_branch = restack_branch. unwrap_or ( orig_branch. clone ( ) ) ;
10211256
@@ -1053,13 +1288,19 @@ fn restack(
10531288 // Find starting_branch in the stacks of branches to determine which stack to use.
10541289 let plan = state. plan_restack ( git_repo, repo, & restack_branch, all_parents) ?;
10551290
1056- tracing:: debug!( ?plan, "Restacking branches with plan. Checking out main..." ) ;
1291+ // Collect plan into owned data to allow mutable access to state during the loop
1292+ let plan_owned: Vec < ( String , state:: Branch ) > = plan
1293+ . into_iter ( )
1294+ . map ( |step| ( step. parent , step. branch . clone ( ) ) )
1295+ . collect ( ) ;
1296+
1297+ tracing:: debug!( "Restacking branches with plan. Checking out main..." ) ;
10571298 git_checkout_main ( git_repo, None ) ?;
10581299
10591300 // Track pushed branches to record SHAs after the loop (avoids borrow issues with plan)
10601301 let mut pushed_branches: Vec < String > = Vec :: new ( ) ;
10611302
1062- for RestackStep { parent, branch } in plan {
1303+ for ( parent, branch) in plan_owned {
10631304 // Ensure the branch exists locally (check it out from remote if needed)
10641305 if !git_repo. branch_exists ( & branch. name ) {
10651306 let remote_ref = format ! ( "{DEFAULT_REMOTE}/{}" , branch. name) ;
@@ -1078,6 +1319,20 @@ fn restack(
10781319 ) ;
10791320 let source = git_repo. sha ( & branch. name ) ?;
10801321
1322+ // Handle squash mode - squash all commits into one on top of parent
1323+ if squash {
1324+ squash_branch ( git_repo, & mut state, repo, & branch, & parent) ?;
1325+ let status = if push {
1326+ git_push ( git_repo, & branch. name ) ?;
1327+ pushed_branches. push ( branch. name . clone ( ) ) ;
1328+ "squashed, pushed"
1329+ } else {
1330+ "squashed"
1331+ } ;
1332+ branch_results. push ( ( branch. name . clone ( ) , status. to_string ( ) ) ) ;
1333+ continue ;
1334+ }
1335+
10811336 if git_repo. is_ancestor ( & parent, & branch. name ) ? {
10821337 tracing:: debug!(
10831338 "Branch '{}' is already stacked on '{}'." ,
0 commit comments