1use crate::errors::{CascadeError, Result};
2use crate::git::{ConflictAnalyzer, GitRepository};
3use crate::stack::{Stack, StackManager, SyncState};
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::debug;
8use uuid::Uuid;
9
10#[derive(Debug, Clone)]
12enum ConflictResolution {
13 Resolved,
15 TooComplex,
17}
18
19#[derive(Debug, Clone)]
21#[allow(dead_code)]
22struct ConflictRegion {
23 start: usize,
25 end: usize,
27 start_line: usize,
29 end_line: usize,
31 our_content: String,
33 their_content: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub enum RebaseStrategy {
40 ForcePush,
43 Interactive,
45}
46
47#[derive(Debug, Clone)]
49pub struct RebaseOptions {
50 pub strategy: RebaseStrategy,
52 pub interactive: bool,
54 pub target_base: Option<String>,
56 pub preserve_merges: bool,
58 pub auto_resolve: bool,
60 pub max_retries: usize,
62 pub skip_pull: Option<bool>,
64 pub original_working_branch: Option<String>,
67}
68
69#[derive(Debug)]
71pub struct RebaseResult {
72 pub success: bool,
74 pub branch_mapping: HashMap<String, String>,
76 pub conflicts: Vec<String>,
78 pub new_commits: Vec<String>,
80 pub error: Option<String>,
82 pub summary: String,
84}
85
86#[allow(dead_code)]
92struct TempBranchCleanupGuard {
93 branches: Vec<String>,
94 cleaned: bool,
95}
96
97#[allow(dead_code)]
98impl TempBranchCleanupGuard {
99 fn new() -> Self {
100 Self {
101 branches: Vec::new(),
102 cleaned: false,
103 }
104 }
105
106 fn add_branch(&mut self, branch: String) {
107 self.branches.push(branch);
108 }
109
110 fn cleanup(&mut self, git_repo: &GitRepository) {
112 if self.cleaned || self.branches.is_empty() {
113 return;
114 }
115
116 tracing::debug!("Cleaning up {} temporary branches", self.branches.len());
117 for branch in &self.branches {
118 if let Err(e) = git_repo.delete_branch_unsafe(branch) {
119 tracing::debug!("Failed to delete temp branch {}: {}", branch, e);
120 }
122 }
123 self.cleaned = true;
124 }
125}
126
127impl Drop for TempBranchCleanupGuard {
128 fn drop(&mut self) {
129 if !self.cleaned && !self.branches.is_empty() {
130 tracing::warn!(
133 "{} temporary branches were not cleaned up: {}",
134 self.branches.len(),
135 self.branches.join(", ")
136 );
137 tracing::warn!("Run 'ca cleanup' to remove orphaned temporary branches");
138 }
139 }
140}
141
142pub struct RebaseManager {
144 stack_manager: StackManager,
145 git_repo: GitRepository,
146 options: RebaseOptions,
147 conflict_analyzer: ConflictAnalyzer,
148}
149
150impl Default for RebaseOptions {
151 fn default() -> Self {
152 Self {
153 strategy: RebaseStrategy::ForcePush,
154 interactive: false,
155 target_base: None,
156 preserve_merges: true,
157 auto_resolve: true,
158 max_retries: 3,
159 skip_pull: None,
160 original_working_branch: None,
161 }
162 }
163}
164
165impl RebaseManager {
166 pub fn new(
168 stack_manager: StackManager,
169 git_repo: GitRepository,
170 options: RebaseOptions,
171 ) -> Self {
172 Self {
173 stack_manager,
174 git_repo,
175 options,
176 conflict_analyzer: ConflictAnalyzer::new(),
177 }
178 }
179
180 pub fn into_stack_manager(self) -> StackManager {
182 self.stack_manager
183 }
184
185 pub fn rebase_stack(&mut self, stack_id: &Uuid) -> Result<RebaseResult> {
187 debug!("Starting rebase for stack {}", stack_id);
188
189 let stack = self
190 .stack_manager
191 .get_stack(stack_id)
192 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
193 .clone();
194
195 match self.options.strategy {
196 RebaseStrategy::ForcePush => self.rebase_with_force_push(&stack),
197 RebaseStrategy::Interactive => self.rebase_interactive(&stack),
198 }
199 }
200
201 fn rebase_with_force_push(&mut self, stack: &Stack) -> Result<RebaseResult> {
205 use crate::cli::output::Output;
206
207 if self.has_in_progress_cherry_pick()? {
209 return self.handle_in_progress_cherry_pick(stack);
210 }
211
212 Output::section(format!("Rebasing stack: {}", stack.name));
214 Output::sub_item(format!("Base branch: {}", stack.base_branch));
215
216 let total_entries = stack.entries.len();
218 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
219 let unmerged_count = total_entries - merged_count;
220
221 if merged_count > 0 {
222 Output::sub_item(format!(
223 "Entries: {} total ({} merged, {} to rebase)",
224 total_entries, merged_count, unmerged_count
225 ));
226 } else {
227 Output::sub_item(format!("Entries: {}", total_entries));
228 }
229
230 let mut result = RebaseResult {
231 success: true,
232 branch_mapping: HashMap::new(),
233 conflicts: Vec::new(),
234 new_commits: Vec::new(),
235 error: None,
236 summary: String::new(),
237 };
238
239 let target_base = self
240 .options
241 .target_base
242 .as_ref()
243 .unwrap_or(&stack.base_branch)
244 .clone(); let original_branch = self
250 .options
251 .original_working_branch
252 .clone()
253 .or_else(|| self.git_repo.get_current_branch().ok());
254
255 let original_branch_for_cleanup = original_branch.clone();
257
258 if let Some(ref orig) = original_branch {
261 if orig == &target_base {
262 debug!(
263 "Original working branch is base branch '{}' - will skip working branch update",
264 orig
265 );
266 }
267 }
268
269 if !self.options.skip_pull.unwrap_or(false) {
272 if let Err(e) = self.pull_latest_changes(&target_base) {
273 Output::warning(format!("Could not pull latest changes: {}", e));
274 }
275 }
276
277 if let Err(e) = self.git_repo.reset_to_head() {
279 if let Some(ref orig) = original_branch_for_cleanup {
281 let _ = self.git_repo.checkout_branch_unsafe(orig);
282 }
283
284 return Err(CascadeError::branch(format!(
285 "Could not reset working directory to HEAD: {}\n\
286 Check if another Git process is running and wait for it to complete before retrying.",
287 e
288 )));
289 }
290
291 let mut current_base = target_base.clone();
292 let entry_count = stack.entries.iter().filter(|e| !e.is_merged).count();
294 let mut temp_branches: Vec<String> = Vec::new(); if entry_count == 0 {
298 println!();
299 if stack.entries.is_empty() {
300 Output::info("Stack has no entries yet");
301 Output::tip("Use 'ca push' to add commits to this stack");
302 result.summary = "Stack is empty".to_string();
303 } else {
304 Output::info("All entries in this stack have been merged");
305 Output::tip("Use 'ca push' to add new commits, or 'ca stack cleanup' to prune merged branches");
306 result.summary = "All entries merged".to_string();
307 }
308
309 println!();
311 Output::success(&result.summary);
312
313 self.stack_manager.save_to_disk()?;
315 return Ok(result);
316 }
317
318 let all_up_to_date = stack
320 .entries
321 .iter()
322 .filter(|entry| !entry.is_merged) .all(|entry| {
324 self.git_repo
325 .is_commit_based_on(&entry.commit_hash, &target_base)
326 .unwrap_or(false)
327 });
328
329 if all_up_to_date {
330 println!();
331 Output::success("Stack is already up-to-date with base branch");
332 result.summary = "Stack is up-to-date".to_string();
333 result.success = true;
334 return Ok(result);
335 }
336
337 let repo_root = self.git_repo.path().to_path_buf();
338 let mut sync_state = SyncState {
339 stack_id: stack.id.to_string(),
340 stack_name: stack.name.clone(),
341 original_branch: original_branch_for_cleanup
342 .clone()
343 .unwrap_or_else(|| target_base.clone()),
344 target_base: target_base.clone(),
345 remaining_entry_ids: stack.entries.iter().map(|e| e.id.to_string()).collect(),
346 current_entry_id: String::new(),
347 current_entry_branch: String::new(),
348 current_temp_branch: String::new(),
349 temp_branches: Vec::new(),
350 };
351
352 let _ = SyncState::delete(&repo_root);
354
355 let mut branches_with_new_commits: std::collections::HashSet<String> =
358 std::collections::HashSet::new();
359 let mut branches_to_push: Vec<(String, String, usize)> = Vec::new(); let mut processed_entries: usize = 0; for (index, entry) in stack.entries.iter().enumerate() {
362 let original_branch = &entry.branch;
363 let entry_id_str = entry.id.to_string();
364
365 sync_state.remaining_entry_ids = stack
366 .entries
367 .iter()
368 .skip(index + 1)
369 .map(|e| e.id.to_string())
370 .collect();
371
372 if entry.is_merged {
374 tracing::debug!(
375 "Entry '{}' is merged into '{}', skipping rebase",
376 original_branch,
377 target_base
378 );
379 continue;
381 }
382
383 processed_entries += 1;
384
385 sync_state.current_entry_id = entry_id_str.clone();
386 sync_state.current_entry_branch = original_branch.clone();
387
388 if self
391 .git_repo
392 .is_commit_based_on(&entry.commit_hash, ¤t_base)
393 .unwrap_or(false)
394 {
395 tracing::debug!(
396 "Entry '{}' is already correctly based on '{}', skipping rebase",
397 original_branch,
398 current_base
399 );
400
401 result
402 .branch_mapping
403 .insert(original_branch.clone(), original_branch.clone());
404
405 current_base = original_branch.clone();
407 continue;
408 }
409
410 if !result.success {
413 tracing::debug!(
414 "Skipping entry '{}' because previous entry failed",
415 original_branch
416 );
417 break;
418 }
419
420 let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
423 temp_branches.push(temp_branch.clone()); sync_state.current_temp_branch = temp_branch.clone();
425
426 if let Err(e) = self
428 .git_repo
429 .create_branch(&temp_branch, Some(¤t_base))
430 {
431 if let Some(ref orig) = original_branch_for_cleanup {
433 let _ = self.git_repo.checkout_branch_unsafe(orig);
434 }
435 return Err(e);
436 }
437
438 if let Err(e) = self.git_repo.checkout_branch_silent(&temp_branch) {
439 if let Some(ref orig) = original_branch_for_cleanup {
441 let _ = self.git_repo.checkout_branch_unsafe(orig);
442 }
443 return Err(e);
444 }
445
446 sync_state.temp_branches = temp_branches.clone();
448 sync_state.save(&repo_root)?;
449
450 match self.cherry_pick_commit(&entry.commit_hash) {
452 Ok(new_commit_hash) => {
453 result.new_commits.push(new_commit_hash.clone());
454
455 let rebased_commit_id = self.git_repo.get_head_commit()?.id().to_string();
457
458 self.git_repo
461 .update_branch_to_commit(original_branch, &rebased_commit_id)?;
462
463 result
464 .branch_mapping
465 .insert(original_branch.clone(), original_branch.clone());
466
467 self.update_stack_entry(
469 stack.id,
470 &entry.id,
471 original_branch,
472 &rebased_commit_id,
473 )?;
474 branches_with_new_commits.insert(original_branch.clone());
475
476 if let Some(pr_num) = &entry.pull_request_id {
478 let tree_char = if processed_entries == entry_count {
479 "└─"
480 } else {
481 "├─"
482 };
483 println!(" {} {} (PR #{})", tree_char, original_branch, pr_num);
484 branches_to_push.push((
485 original_branch.clone(),
486 pr_num.clone(),
487 processed_entries - 1,
488 ));
489 }
490
491 current_base = original_branch.clone();
493
494 let _ = SyncState::delete(&repo_root);
495 }
496 Err(e) => {
497 if self.git_repo.get_conflicted_files()?.is_empty() {
499 debug!(
500 "Cherry-pick produced no changes for {} ({}), skipping entry",
501 original_branch,
502 &entry.commit_hash[..8]
503 );
504
505 Output::warning(format!(
506 "Skipping entry '{}' - cherry-pick resulted in no changes",
507 original_branch
508 ));
509 Output::sub_item("This usually means the base branch has moved forward");
510 Output::sub_item("and this entry's changes are already present");
511
512 let _ = std::process::Command::new("git")
514 .args(["cherry-pick", "--abort"])
515 .current_dir(self.git_repo.path())
516 .output();
517
518 let _ = self.git_repo.checkout_branch_unsafe(&target_base);
520 let _ = self.git_repo.delete_branch_unsafe(&temp_branch);
521 let _ = temp_branches.pop();
522 let _ = SyncState::delete(&repo_root);
523
524 continue;
526 }
527
528 result.conflicts.push(entry.commit_hash.clone());
529
530 if !self.options.auto_resolve {
531 println!();
532 Output::error(e.to_string());
533 result.success = false;
534 result.error = Some(format!(
535 "Conflict in {}: {}\n\n\
536 MANUAL CONFLICT RESOLUTION REQUIRED\n\
537 =====================================\n\n\
538 Step 1: Analyze conflicts\n\
539 → Run: ca conflicts\n\
540 → This shows which conflicts are in which files\n\n\
541 Step 2: Resolve conflicts in your editor\n\
542 → Open conflicted files and edit them\n\
543 → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
544 → Keep the code you want\n\
545 → Save the files\n\n\
546 Step 3: Mark conflicts as resolved\n\
547 → Run: git add <resolved-files>\n\
548 → Or: git add -A (to stage all resolved files)\n\n\
549 Step 4: Complete the sync\n\
550 → Run: ca sync continue\n\
551 → Cascade will complete the cherry-pick and continue\n\n\
552 Alternative: Abort and start over\n\
553 → Run: ca sync abort\n\
554 → Then: ca sync (starts fresh)\n\n\
555 TIP: Enable auto-resolution for simple conflicts:\n\
556 → Run: ca sync --auto-resolve\n\
557 → Only complex conflicts will require manual resolution",
558 entry.commit_hash, e
559 ));
560 break;
561 }
562
563 match self.auto_resolve_conflicts(&entry.commit_hash) {
565 Ok(fully_resolved) => {
566 if !fully_resolved {
567 result.success = false;
568 result.error = Some(format!(
569 "Conflicts in commit {}\n\n\
570 To resolve:\n\
571 1. Fix conflicts in your editor\n\
572 2. Stage resolved files: git add <files>\n\
573 3. Continue: ca sync continue\n\n\
574 Or abort:\n\
575 → Run: ca sync abort",
576 &entry.commit_hash[..8]
577 ));
578 break;
579 }
580
581 let commit_message = entry.message.trim().to_string();
584
585 debug!("Checking staged files before commit");
587 let staged_files = self.git_repo.get_staged_files()?;
588
589 if staged_files.is_empty() {
590 debug!(
593 "Cherry-pick resulted in empty commit for {}",
594 &entry.commit_hash[..8]
595 );
596
597 Output::warning(format!(
599 "Skipping entry '{}' - cherry-pick resulted in no changes",
600 original_branch
601 ));
602 Output::sub_item(
603 "This usually means the base branch has moved forward",
604 );
605 Output::sub_item("and this entry's changes are already present");
606
607 let _ = std::process::Command::new("git")
609 .args(["cherry-pick", "--abort"])
610 .current_dir(self.git_repo.path())
611 .output();
612
613 let _ = self.git_repo.checkout_branch_unsafe(&target_base);
614 let _ = self.git_repo.delete_branch_unsafe(&temp_branch);
615 let _ = temp_branches.pop();
616
617 let _ = SyncState::delete(&repo_root);
618
619 continue;
621 }
622
623 debug!("{} files staged", staged_files.len());
624
625 match self.git_repo.commit(&commit_message) {
626 Ok(new_commit_id) => {
627 debug!(
628 "Created commit {} with message '{}'",
629 &new_commit_id[..8],
630 commit_message
631 );
632
633 if let Err(e) = self.git_repo.cleanup_state() {
635 tracing::warn!(
636 "Failed to clean up repository state after auto-resolve: {}",
637 e
638 );
639 }
640
641 Output::success("Auto-resolved conflicts");
642 result.new_commits.push(new_commit_id.clone());
643 let rebased_commit_id = new_commit_id;
644
645 self.cleanup_backup_files()?;
647
648 self.git_repo.update_branch_to_commit(
650 original_branch,
651 &rebased_commit_id,
652 )?;
653
654 branches_with_new_commits.insert(original_branch.clone());
655
656 if let Some(pr_num) = &entry.pull_request_id {
658 let tree_char = if processed_entries == entry_count {
659 "└─"
660 } else {
661 "├─"
662 };
663 println!(
664 " {} {} (PR #{})",
665 tree_char, original_branch, pr_num
666 );
667 branches_to_push.push((
668 original_branch.clone(),
669 pr_num.clone(),
670 processed_entries - 1,
671 ));
672 }
673
674 result
675 .branch_mapping
676 .insert(original_branch.clone(), original_branch.clone());
677
678 self.update_stack_entry(
680 stack.id,
681 &entry.id,
682 original_branch,
683 &rebased_commit_id,
684 )?;
685
686 current_base = original_branch.clone();
688 }
689 Err(commit_err) => {
690 result.success = false;
691 result.error = Some(format!(
692 "Could not commit auto-resolved conflicts: {}\n\n\
693 This usually means:\n\
694 - Another Git process is accessing the repository\n\
695 - File permissions issue\n\
696 - Disk space issue\n\n\
697 Recovery:\n\
698 1. Check if another Git operation is running\n\
699 2. Run 'git status' to check repo state\n\
700 3. Retry 'ca sync' after fixing the issue",
701 commit_err
702 ));
703 break;
704 }
705 }
706 }
707 Err(resolve_err) => {
708 result.success = false;
709 result.error = Some(format!(
710 "Could not resolve conflicts: {}\n\n\
711 Recovery:\n\
712 1. Check repo state: 'git status'\n\
713 2. If files are staged, commit or reset them: 'git reset --hard HEAD'\n\
714 3. Remove any lock files: 'rm -f .git/index.lock'\n\
715 4. Retry 'ca sync'",
716 resolve_err
717 ));
718 break;
719 }
720 }
721 }
722 }
723 }
724
725 if result.success && !temp_branches.is_empty() {
729 if let Err(e) = self.git_repo.checkout_branch_unsafe(&target_base) {
732 debug!("Could not checkout base for cleanup: {}", e);
733 } else {
736 for temp_branch in &temp_branches {
738 if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
739 debug!("Could not delete temp branch {}: {}", temp_branch, e);
740 }
741 }
742 }
743 }
744
745 let pushed_count = branches_to_push.len();
751 let _skipped_count = entry_count - pushed_count; let mut successful_pushes = 0; if !result.success {
755 println!();
756 Output::error("Rebase failed - not pushing any branches");
757 } else {
760 if !branches_to_push.is_empty() {
764 println!();
765
766 if let Err(e) = self.git_repo.fetch_with_retry() {
768 Output::warning(format!("Could not fetch latest remote state: {}", e));
769 Output::tip("Continuing with push, but backup branches may not be created if remote state is unknown");
770 }
771
772 let mut push_results = Vec::new();
774 for (branch_name, _pr_num, _index) in branches_to_push.iter() {
775 let result = self
776 .git_repo
777 .force_push_single_branch_auto_no_fetch(branch_name);
778 push_results.push((branch_name.clone(), result));
779 }
780
781 let mut failed_pushes = 0;
783 for (index, (branch_name, result)) in push_results.iter().enumerate() {
784 match result {
785 Ok(_) => {
786 debug!("Pushed {} successfully", branch_name);
787 successful_pushes += 1;
788 println!(
789 " ✓ Pushed {} ({}/{})",
790 branch_name,
791 index + 1,
792 pushed_count
793 );
794 }
795 Err(e) => {
796 failed_pushes += 1;
797 println!(" ⚠ Could not push '{}': {}", branch_name, e);
798 }
799 }
800 }
801
802 if failed_pushes > 0 {
804 println!(); Output::warning(format!(
806 "{} branch(es) failed to push to remote",
807 failed_pushes
808 ));
809 Output::tip("To retry failed pushes, run: ca sync");
810 }
811 }
812
813 let entries_word = if entry_count == 1 { "entry" } else { "entries" };
815 let pr_word = if successful_pushes == 1 { "PR" } else { "PRs" };
816
817 result.summary = if successful_pushes > 0 {
818 let not_submitted_count = entry_count - successful_pushes;
819 if not_submitted_count > 0 {
820 format!(
821 "{} {} rebased ({} {} updated, {} not yet submitted)",
822 entry_count, entries_word, successful_pushes, pr_word, not_submitted_count
823 )
824 } else {
825 format!(
826 "{} {} rebased ({} {} updated)",
827 entry_count, entries_word, successful_pushes, pr_word
828 )
829 }
830 } else {
831 format!(
832 "{} {} rebased (none submitted to Bitbucket yet)",
833 entry_count, entries_word
834 )
835 };
836 } if result.success {
839 if let Some(ref working_branch_name) = stack.working_branch {
844 if working_branch_name != &target_base {
846 if let Some(last_entry) = stack.entries.last() {
847 let top_branch = &last_entry.branch;
848
849 if let (Ok(working_head), Ok(top_commit)) = (
852 self.git_repo.get_branch_head(working_branch_name),
853 self.git_repo.get_branch_head(top_branch),
854 ) {
855 if working_head != top_commit {
857 if let Ok(commits) = self
859 .git_repo
860 .get_commits_between(&top_commit, &working_head)
861 {
862 if !commits.is_empty() {
863 let stack_summaries: Vec<String> = stack
868 .entries
869 .iter()
870 .map(|e| {
871 e.message
872 .lines()
873 .next()
874 .unwrap_or("")
875 .trim()
876 .to_string()
877 })
878 .collect();
879
880 let all_match_stack = commits.iter().all(|commit| {
881 if let Some(msg) = commit.summary() {
882 stack_summaries
883 .iter()
884 .any(|stack_msg| stack_msg == msg.trim())
885 } else {
886 false
887 }
888 });
889
890 if all_match_stack {
891 debug!(
896 "Working branch has old pre-rebase commits (matching stack messages) - safe to update"
897 );
898 } else {
899 Output::error(format!(
901 "Cannot sync: Working branch '{}' has {} commit(s) not in the stack",
902 working_branch_name,
903 commits.len()
904 ));
905 println!();
906 Output::sub_item(
907 "These commits would be lost if we proceed:",
908 );
909 for (i, commit) in commits.iter().take(5).enumerate() {
910 let message =
911 commit.summary().unwrap_or("(no message)");
912 Output::sub_item(format!(
913 " {}. {} - {}",
914 i + 1,
915 &commit.id().to_string()[..8],
916 message
917 ));
918 }
919 if commits.len() > 5 {
920 Output::sub_item(format!(
921 " ... and {} more",
922 commits.len() - 5
923 ));
924 }
925 println!();
926 Output::tip("Add these commits to the stack first:");
927 Output::bullet("Run: ca stack push");
928 Output::bullet("Then run: ca sync");
929 println!();
930
931 if let Some(ref orig) = original_branch_for_cleanup {
933 let _ = self.git_repo.checkout_branch_unsafe(orig);
934 }
935
936 return Err(CascadeError::validation(
937 format!(
938 "Working branch '{}' has {} untracked commit(s). Add them to the stack with 'ca stack push' before syncing.",
939 working_branch_name, commits.len()
940 )
941 ));
942 }
943 }
944 }
945 }
946
947 debug!(
949 "Updating working branch '{}' to match top of stack ({})",
950 working_branch_name,
951 &top_commit[..8]
952 );
953
954 if let Err(e) = self
955 .git_repo
956 .update_branch_to_commit(working_branch_name, &top_commit)
957 {
958 Output::warning(format!(
959 "Could not update working branch '{}' to top of stack: {}",
960 working_branch_name, e
961 ));
962 }
963 }
964 }
965 } else {
966 debug!(
968 "Skipping working branch update - working branch '{}' is the base branch",
969 working_branch_name
970 );
971 }
972 }
973
974 if let Some(ref orig_branch) = original_branch {
977 if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
978 debug!(
979 "Could not return to original branch '{}': {}",
980 orig_branch, e
981 );
982 }
984 }
985 }
986 println!();
991 if result.success {
992 Output::success(&result.summary);
993 } else {
994 let error_msg = result
996 .error
997 .as_deref()
998 .unwrap_or("Rebase failed for unknown reason");
999 Output::error(error_msg);
1000 }
1001
1002 self.stack_manager.save_to_disk()?;
1004
1005 if !result.success {
1008 let detailed_error = result.error.as_deref().unwrap_or("Rebase failed");
1009 return Err(CascadeError::Branch(detailed_error.to_string()));
1010 }
1011
1012 Ok(result)
1013 }
1014
1015 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
1017 tracing::debug!("Starting interactive rebase for stack '{}'", stack.name);
1018
1019 let mut result = RebaseResult {
1020 success: true,
1021 branch_mapping: HashMap::new(),
1022 conflicts: Vec::new(),
1023 new_commits: Vec::new(),
1024 error: None,
1025 summary: String::new(),
1026 };
1027
1028 println!("Interactive Rebase for Stack: {}", stack.name);
1029 println!(" Base branch: {}", stack.base_branch);
1030 println!(" Entries: {}", stack.entries.len());
1031
1032 if self.options.interactive {
1033 println!("\nChoose action for each commit:");
1034 println!(" (p)ick - apply the commit");
1035 println!(" (s)kip - skip this commit");
1036 println!(" (e)dit - edit the commit message");
1037 println!(" (q)uit - abort the rebase");
1038 }
1039
1040 for entry in &stack.entries {
1043 println!(
1044 " {} {} - {}",
1045 entry.short_hash(),
1046 entry.branch,
1047 entry.short_message(50)
1048 );
1049
1050 match self.cherry_pick_commit(&entry.commit_hash) {
1052 Ok(new_commit) => result.new_commits.push(new_commit),
1053 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
1054 }
1055 }
1056
1057 result.summary = format!(
1058 "Interactive rebase processed {} commits",
1059 stack.entries.len()
1060 );
1061 Ok(result)
1062 }
1063
1064 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
1066 let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
1068
1069 if let Ok(staged_files) = self.git_repo.get_staged_files() {
1071 if !staged_files.is_empty() {
1072 let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
1074 match self.git_repo.commit_staged_changes(&cleanup_message) {
1075 Ok(Some(_)) => {
1076 debug!(
1077 "Committed {} leftover staged files after cherry-pick",
1078 staged_files.len()
1079 );
1080 }
1081 Ok(None) => {
1082 debug!("Staged files were cleared before commit");
1084 }
1085 Err(e) => {
1086 tracing::warn!(
1088 "Failed to commit {} staged files after cherry-pick: {}. \
1089 User may see checkout warning with staged changes.",
1090 staged_files.len(),
1091 e
1092 );
1093 }
1095 }
1096 }
1097 }
1098
1099 Ok(new_commit_hash)
1100 }
1101
1102 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
1104 debug!("Starting auto-resolve for commit {}", commit_hash);
1105
1106 let has_conflicts = self.git_repo.has_conflicts()?;
1108 debug!("has_conflicts() = {}", has_conflicts);
1109
1110 let cherry_pick_head = self.git_repo.git_dir().join("CHERRY_PICK_HEAD");
1112 let cherry_pick_in_progress = cherry_pick_head.exists();
1113
1114 if !has_conflicts {
1115 debug!("No conflicts detected by Git index");
1116
1117 if cherry_pick_in_progress {
1119 tracing::debug!(
1120 "CHERRY_PICK_HEAD exists but no conflicts in index - aborting cherry-pick"
1121 );
1122
1123 let _ = std::process::Command::new("git")
1125 .args(["cherry-pick", "--abort"])
1126 .current_dir(self.git_repo.path())
1127 .output();
1128
1129 return Err(CascadeError::Branch(format!(
1130 "Cherry-pick failed for {} but Git index shows no conflicts. \
1131 This usually means the cherry-pick was aborted or failed in an unexpected way. \
1132 Please try manual resolution.",
1133 &commit_hash[..8]
1134 )));
1135 }
1136
1137 return Ok(true);
1138 }
1139
1140 let conflicted_files = self.git_repo.get_conflicted_files()?;
1141
1142 if conflicted_files.is_empty() {
1143 debug!("Conflicted files list is empty");
1144 return Ok(true);
1145 }
1146
1147 debug!(
1148 "Found conflicts in {} files: {:?}",
1149 conflicted_files.len(),
1150 conflicted_files
1151 );
1152
1153 let analysis = self
1155 .conflict_analyzer
1156 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
1157
1158 debug!(
1159 "Conflict analysis: {} total conflicts, {} auto-resolvable",
1160 analysis.total_conflicts, analysis.auto_resolvable_count
1161 );
1162
1163 for recommendation in &analysis.recommendations {
1165 debug!("{}", recommendation);
1166 }
1167
1168 let mut resolved_count = 0;
1169 let mut resolved_files = Vec::new(); let mut failed_files = Vec::new();
1171
1172 for file_analysis in &analysis.files {
1173 debug!(
1174 "Processing file: {} (auto_resolvable: {}, conflicts: {})",
1175 file_analysis.file_path,
1176 file_analysis.auto_resolvable,
1177 file_analysis.conflicts.len()
1178 );
1179
1180 if file_analysis.auto_resolvable {
1181 match self.resolve_file_conflicts_enhanced(
1182 &file_analysis.file_path,
1183 &file_analysis.conflicts,
1184 ) {
1185 Ok(ConflictResolution::Resolved) => {
1186 resolved_count += 1;
1187 resolved_files.push(file_analysis.file_path.clone());
1188 debug!("Successfully resolved {}", file_analysis.file_path);
1189 }
1190 Ok(ConflictResolution::TooComplex) => {
1191 debug!(
1192 "{} too complex for auto-resolution",
1193 file_analysis.file_path
1194 );
1195 failed_files.push(file_analysis.file_path.clone());
1196 }
1197 Err(e) => {
1198 debug!("Failed to resolve {}: {}", file_analysis.file_path, e);
1199 failed_files.push(file_analysis.file_path.clone());
1200 }
1201 }
1202 } else {
1203 failed_files.push(file_analysis.file_path.clone());
1204 debug!(
1205 "{} requires manual resolution ({} conflicts)",
1206 file_analysis.file_path,
1207 file_analysis.conflicts.len()
1208 );
1209 }
1210 }
1211
1212 if resolved_count > 0 {
1213 debug!(
1214 "Resolved {}/{} files",
1215 resolved_count,
1216 conflicted_files.len()
1217 );
1218 debug!("Resolved files: {:?}", resolved_files);
1219
1220 let file_paths: Vec<&str> = resolved_files.iter().map(|s| s.as_str()).collect();
1223 debug!("Staging {} files", file_paths.len());
1224 self.git_repo.stage_files(&file_paths)?;
1225 debug!("Files staged successfully");
1226 } else {
1227 debug!("No files were resolved (resolved_count = 0)");
1228 }
1229
1230 let all_resolved = failed_files.is_empty();
1232
1233 debug!(
1234 "all_resolved = {}, failed_files = {:?}",
1235 all_resolved, failed_files
1236 );
1237
1238 if !all_resolved {
1239 debug!("{} files still need manual resolution", failed_files.len());
1240 }
1241
1242 debug!("Returning all_resolved = {}", all_resolved);
1243 Ok(all_resolved)
1244 }
1245
1246 fn resolve_file_conflicts_enhanced(
1248 &self,
1249 file_path: &str,
1250 conflicts: &[crate::git::ConflictRegion],
1251 ) -> Result<ConflictResolution> {
1252 let repo_path = self.git_repo.path();
1253 let full_path = repo_path.join(file_path);
1254
1255 let mut content = std::fs::read_to_string(&full_path)
1257 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
1258
1259 if conflicts.is_empty() {
1260 return Ok(ConflictResolution::Resolved);
1261 }
1262
1263 tracing::debug!(
1264 "Resolving {} conflicts in {} using enhanced analysis",
1265 conflicts.len(),
1266 file_path
1267 );
1268
1269 let mut any_resolved = false;
1270
1271 for conflict in conflicts.iter().rev() {
1273 match self.resolve_single_conflict_enhanced(conflict) {
1274 Ok(Some(resolution)) => {
1275 let before = &content[..conflict.start_pos];
1277 let after = &content[conflict.end_pos..];
1278 content = format!("{before}{resolution}{after}");
1279 any_resolved = true;
1280 debug!(
1281 "✅ Resolved {} conflict at lines {}-{} in {}",
1282 format!("{:?}", conflict.conflict_type).to_lowercase(),
1283 conflict.start_line,
1284 conflict.end_line,
1285 file_path
1286 );
1287 }
1288 Ok(None) => {
1289 debug!(
1290 "⚠️ {} conflict at lines {}-{} in {} requires manual resolution",
1291 format!("{:?}", conflict.conflict_type).to_lowercase(),
1292 conflict.start_line,
1293 conflict.end_line,
1294 file_path
1295 );
1296 return Ok(ConflictResolution::TooComplex);
1297 }
1298 Err(e) => {
1299 debug!("❌ Failed to resolve conflict in {}: {}", file_path, e);
1300 return Ok(ConflictResolution::TooComplex);
1301 }
1302 }
1303 }
1304
1305 if any_resolved {
1306 let remaining_conflicts = self.parse_conflict_markers(&content)?;
1308
1309 if remaining_conflicts.is_empty() {
1310 debug!(
1311 "All conflicts resolved in {}, content length: {} bytes",
1312 file_path,
1313 content.len()
1314 );
1315
1316 if content.trim().is_empty() {
1318 tracing::warn!(
1319 "SAFETY CHECK: Resolved content for {} is empty! Aborting auto-resolution.",
1320 file_path
1321 );
1322 return Ok(ConflictResolution::TooComplex);
1323 }
1324
1325 let backup_path = full_path.with_extension("cascade-backup");
1327 if let Ok(original_content) = std::fs::read_to_string(&full_path) {
1328 debug!(
1329 "Backup for {} (original: {} bytes, resolved: {} bytes)",
1330 file_path,
1331 original_content.len(),
1332 content.len()
1333 );
1334 let _ = std::fs::write(&backup_path, original_content);
1335 }
1336
1337 crate::utils::atomic_file::write_string(&full_path, &content)?;
1339
1340 debug!("Wrote {} bytes to {}", content.len(), file_path);
1341 return Ok(ConflictResolution::Resolved);
1342 } else {
1343 tracing::debug!(
1344 "Partially resolved conflicts in {} ({} remaining)",
1345 file_path,
1346 remaining_conflicts.len()
1347 );
1348 }
1349 }
1350
1351 Ok(ConflictResolution::TooComplex)
1352 }
1353
1354 #[allow(dead_code)]
1356 fn count_whitespace_consistency(content: &str) -> usize {
1357 let mut inconsistencies = 0;
1358 let lines: Vec<&str> = content.lines().collect();
1359
1360 for line in &lines {
1361 if line.contains('\t') && line.contains(' ') {
1363 inconsistencies += 1;
1364 }
1365 }
1366
1367 lines.len().saturating_sub(inconsistencies)
1369 }
1370
1371 fn cleanup_backup_files(&self) -> Result<()> {
1373 use std::fs;
1374 use std::path::Path;
1375
1376 let repo_path = self.git_repo.path();
1377
1378 fn remove_backups_recursive(dir: &Path) {
1380 if let Ok(entries) = fs::read_dir(dir) {
1381 for entry in entries.flatten() {
1382 let path = entry.path();
1383
1384 if path.is_dir() {
1385 if path.file_name().and_then(|n| n.to_str()) != Some(".git") {
1387 remove_backups_recursive(&path);
1388 }
1389 } else if let Some(ext) = path.extension() {
1390 if ext == "cascade-backup" {
1391 debug!("Cleaning up backup file: {}", path.display());
1392 if let Err(e) = fs::remove_file(&path) {
1393 tracing::warn!(
1395 "Could not remove backup file {}: {}",
1396 path.display(),
1397 e
1398 );
1399 }
1400 }
1401 }
1402 }
1403 }
1404 }
1405
1406 remove_backups_recursive(repo_path);
1407 Ok(())
1408 }
1409
1410 fn resolve_single_conflict_enhanced(
1412 &self,
1413 conflict: &crate::git::ConflictRegion,
1414 ) -> Result<Option<String>> {
1415 debug!(
1416 "Resolving {} conflict in {} (lines {}-{})",
1417 format!("{:?}", conflict.conflict_type).to_lowercase(),
1418 conflict.file_path,
1419 conflict.start_line,
1420 conflict.end_line
1421 );
1422
1423 use crate::git::ConflictType;
1424
1425 match conflict.conflict_type {
1426 ConflictType::Whitespace => {
1427 let our_normalized = conflict
1430 .our_content
1431 .split_whitespace()
1432 .collect::<Vec<_>>()
1433 .join(" ");
1434 let their_normalized = conflict
1435 .their_content
1436 .split_whitespace()
1437 .collect::<Vec<_>>()
1438 .join(" ");
1439
1440 if our_normalized == their_normalized {
1441 Ok(Some(conflict.their_content.clone()))
1446 } else {
1447 debug!(
1449 "Whitespace conflict has content differences - requires manual resolution"
1450 );
1451 Ok(None)
1452 }
1453 }
1454 ConflictType::LineEnding => {
1455 let normalized = conflict
1457 .our_content
1458 .replace("\r\n", "\n")
1459 .replace('\r', "\n");
1460 Ok(Some(normalized))
1461 }
1462 ConflictType::PureAddition => {
1463 if conflict.our_content.is_empty() && !conflict.their_content.is_empty() {
1467 Ok(Some(conflict.their_content.clone()))
1469 } else if conflict.their_content.is_empty() && !conflict.our_content.is_empty() {
1470 Ok(Some(String::new()))
1472 } else if conflict.our_content.is_empty() && conflict.their_content.is_empty() {
1473 Ok(Some(String::new()))
1475 } else {
1476 debug!(
1482 "PureAddition conflict has content on both sides - requires manual resolution"
1483 );
1484 Ok(None)
1485 }
1486 }
1487 ConflictType::ImportMerge => {
1488 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1493 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1494
1495 let all_simple = our_lines.iter().chain(their_lines.iter()).all(|line| {
1497 let trimmed = line.trim();
1498 trimmed.starts_with("import ")
1499 || trimmed.starts_with("from ")
1500 || trimmed.starts_with("use ")
1501 || trimmed.starts_with("#include")
1502 || trimmed.is_empty()
1503 });
1504
1505 if !all_simple {
1506 debug!("ImportMerge contains non-import lines - requires manual resolution");
1507 return Ok(None);
1508 }
1509
1510 let mut all_imports: Vec<&str> = our_lines
1512 .into_iter()
1513 .chain(their_lines)
1514 .filter(|line| !line.trim().is_empty())
1515 .collect();
1516 all_imports.sort();
1517 all_imports.dedup();
1518 Ok(Some(all_imports.join("\n")))
1519 }
1520 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
1521 Ok(None)
1523 }
1524 }
1525 }
1526
1527 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
1529 let lines: Vec<&str> = content.lines().collect();
1530 let mut conflicts = Vec::new();
1531 let mut i = 0;
1532
1533 while i < lines.len() {
1534 if lines[i].starts_with("<<<<<<<") {
1535 let start_line = i + 1;
1537 let mut separator_line = None;
1538 let mut end_line = None;
1539
1540 for (j, line) in lines.iter().enumerate().skip(i + 1) {
1542 if line.starts_with("=======") {
1543 separator_line = Some(j + 1);
1544 } else if line.starts_with(">>>>>>>") {
1545 end_line = Some(j + 1);
1546 break;
1547 }
1548 }
1549
1550 if let (Some(sep), Some(end)) = (separator_line, end_line) {
1551 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
1553 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
1554
1555 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
1556 let their_content = lines[sep..(end - 1)].join("\n");
1557
1558 conflicts.push(ConflictRegion {
1559 start: start_pos,
1560 end: end_pos,
1561 start_line,
1562 end_line: end,
1563 our_content,
1564 their_content,
1565 });
1566
1567 i = end;
1568 } else {
1569 i += 1;
1570 }
1571 } else {
1572 i += 1;
1573 }
1574 }
1575
1576 Ok(conflicts)
1577 }
1578
1579 fn update_stack_entry(
1582 &mut self,
1583 stack_id: Uuid,
1584 entry_id: &Uuid,
1585 _new_branch: &str,
1586 new_commit_hash: &str,
1587 ) -> Result<()> {
1588 debug!(
1589 "Updating entry {} in stack {} with new commit {}",
1590 entry_id, stack_id, new_commit_hash
1591 );
1592
1593 let stack = self
1595 .stack_manager
1596 .get_stack_mut(&stack_id)
1597 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1598
1599 let entry_exists = stack.entries.iter().any(|e| e.id == *entry_id);
1601
1602 if entry_exists {
1603 let old_hash = stack
1604 .entries
1605 .iter()
1606 .find(|e| e.id == *entry_id)
1607 .map(|e| e.commit_hash.clone())
1608 .unwrap();
1609
1610 debug!(
1611 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch)",
1612 entry_id, old_hash, new_commit_hash
1613 );
1614
1615 stack
1618 .update_entry_commit_hash(entry_id, new_commit_hash.to_string())
1619 .map_err(CascadeError::config)?;
1620
1621 debug!(
1624 "Successfully updated entry {} in stack {}",
1625 entry_id, stack_id
1626 );
1627 Ok(())
1628 } else {
1629 Err(CascadeError::config(format!(
1630 "Entry {entry_id} not found in stack {stack_id}"
1631 )))
1632 }
1633 }
1634
1635 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1637 tracing::debug!("Pulling latest changes for branch {}", branch);
1638
1639 match self.git_repo.fetch() {
1641 Ok(_) => {
1642 debug!("Fetch successful");
1643 match self.git_repo.pull(branch) {
1645 Ok(_) => {
1646 tracing::debug!("Pull completed successfully for {}", branch);
1647 Ok(())
1648 }
1649 Err(e) => {
1650 tracing::debug!("Pull failed for {}: {}", branch, e);
1651 Ok(())
1653 }
1654 }
1655 }
1656 Err(e) => {
1657 tracing::debug!("Fetch failed: {}", e);
1658 Ok(())
1660 }
1661 }
1662 }
1663
1664 pub fn is_rebase_in_progress(&self) -> bool {
1666 let git_dir = self.git_repo.git_dir();
1668 git_dir.join("REBASE_HEAD").exists()
1669 || git_dir.join("rebase-merge").exists()
1670 || git_dir.join("rebase-apply").exists()
1671 }
1672
1673 pub fn abort_rebase(&self) -> Result<()> {
1675 tracing::debug!("Aborting rebase operation");
1676
1677 let git_dir = self.git_repo.git_dir();
1678
1679 if git_dir.join("REBASE_HEAD").exists() {
1681 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1682 CascadeError::Git(git2::Error::from_str(&format!(
1683 "Failed to clean rebase state: {e}"
1684 )))
1685 })?;
1686 }
1687
1688 if git_dir.join("rebase-merge").exists() {
1689 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1690 CascadeError::Git(git2::Error::from_str(&format!(
1691 "Failed to clean rebase-merge: {e}"
1692 )))
1693 })?;
1694 }
1695
1696 if git_dir.join("rebase-apply").exists() {
1697 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1698 CascadeError::Git(git2::Error::from_str(&format!(
1699 "Failed to clean rebase-apply: {e}"
1700 )))
1701 })?;
1702 }
1703
1704 tracing::debug!("Rebase aborted successfully");
1705 Ok(())
1706 }
1707
1708 pub fn continue_rebase(&self) -> Result<()> {
1710 tracing::debug!("Continuing rebase operation");
1711
1712 if self.git_repo.has_conflicts()? {
1714 return Err(CascadeError::branch(
1715 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1716 ));
1717 }
1718
1719 self.git_repo.stage_conflict_resolved_files()?;
1721
1722 tracing::debug!("Rebase continued successfully");
1723 Ok(())
1724 }
1725
1726 fn has_in_progress_cherry_pick(&self) -> Result<bool> {
1728 let git_dir = self.git_repo.git_dir();
1729 Ok(git_dir.join("CHERRY_PICK_HEAD").exists())
1730 }
1731
1732 fn handle_in_progress_cherry_pick(&mut self, stack: &Stack) -> Result<RebaseResult> {
1734 use crate::cli::output::Output;
1735
1736 let git_dir = self.git_repo.git_dir();
1737
1738 Output::section("Resuming in-progress sync");
1739 println!();
1740 Output::info("Detected unfinished cherry-pick from previous sync");
1741 println!();
1742
1743 if self.git_repo.has_conflicts()? {
1745 let conflicted_files = self.git_repo.get_conflicted_files()?;
1746
1747 let result = RebaseResult {
1748 success: false,
1749 branch_mapping: HashMap::new(),
1750 conflicts: conflicted_files.clone(),
1751 new_commits: Vec::new(),
1752 error: Some(format!(
1753 "Cannot continue: {} file(s) still have unresolved conflicts\n\n\
1754 MANUAL CONFLICT RESOLUTION REQUIRED\n\
1755 =====================================\n\n\
1756 Conflicted files:\n{}\n\n\
1757 Step 1: Analyze conflicts\n\
1758 → Run: ca conflicts\n\
1759 → Shows detailed conflict analysis\n\n\
1760 Step 2: Resolve conflicts in your editor\n\
1761 → Open conflicted files and edit them\n\
1762 → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
1763 → Keep the code you want\n\
1764 → Save the files\n\n\
1765 Step 3: Mark conflicts as resolved\n\
1766 → Run: git add <resolved-files>\n\
1767 → Or: git add -A (to stage all resolved files)\n\n\
1768 Step 4: Complete the sync\n\
1769 → Run: ca sync continue\n\
1770 → Cascade will complete the cherry-pick and continue\n\n\
1771 Alternative: Abort and start over\n\
1772 → Run: ca sync abort\n\
1773 → Then: ca sync (starts fresh)",
1774 conflicted_files.len(),
1775 conflicted_files
1776 .iter()
1777 .map(|f| format!(" - {}", f))
1778 .collect::<Vec<_>>()
1779 .join("\n")
1780 )),
1781 summary: "Sync paused - conflicts need resolution".to_string(),
1782 };
1783
1784 return Ok(result);
1785 }
1786
1787 Output::info("Conflicts resolved, continuing cherry-pick...");
1789
1790 self.git_repo.stage_conflict_resolved_files()?;
1792
1793 let cherry_pick_msg_file = git_dir.join("CHERRY_PICK_MSG");
1795 let commit_message = if cherry_pick_msg_file.exists() {
1796 std::fs::read_to_string(&cherry_pick_msg_file)
1797 .unwrap_or_else(|_| "Resolved conflicts".to_string())
1798 } else {
1799 "Resolved conflicts".to_string()
1800 };
1801
1802 match self.git_repo.commit(&commit_message) {
1803 Ok(_new_commit_id) => {
1804 Output::success("Cherry-pick completed");
1805
1806 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1808 let _ = std::fs::remove_file(git_dir.join("CHERRY_PICK_HEAD"));
1809 }
1810 if cherry_pick_msg_file.exists() {
1811 let _ = std::fs::remove_file(&cherry_pick_msg_file);
1812 }
1813
1814 println!();
1815 Output::info("Continuing with rest of stack...");
1816 println!();
1817
1818 self.rebase_with_force_push(stack)
1821 }
1822 Err(e) => {
1823 let result = RebaseResult {
1824 success: false,
1825 branch_mapping: HashMap::new(),
1826 conflicts: Vec::new(),
1827 new_commits: Vec::new(),
1828 error: Some(format!(
1829 "Failed to complete cherry-pick: {}\n\n\
1830 This usually means:\n\
1831 - Another Git process is accessing the repository\n\
1832 - File permissions issue\n\
1833 - Disk space issue\n\n\
1834 Recovery:\n\
1835 1. Check if another Git operation is running\n\
1836 2. Run 'git status' to check repo state\n\
1837 3. Retry 'ca sync' after fixing the issue\n\n\
1838 Or abort and start fresh:\n\
1839 → Run: ca sync abort\n\
1840 → Then: ca sync",
1841 e
1842 )),
1843 summary: "Failed to complete cherry-pick".to_string(),
1844 };
1845
1846 Ok(result)
1847 }
1848 }
1849 }
1850}
1851
1852impl RebaseResult {
1853 pub fn get_summary(&self) -> String {
1855 if self.success {
1856 format!("✅ {}", self.summary)
1857 } else {
1858 format!(
1859 "❌ Rebase failed: {}",
1860 self.error.as_deref().unwrap_or("Unknown error")
1861 )
1862 }
1863 }
1864
1865 pub fn has_conflicts(&self) -> bool {
1867 !self.conflicts.is_empty()
1868 }
1869
1870 pub fn success_count(&self) -> usize {
1872 self.new_commits.len()
1873 }
1874}
1875
1876#[cfg(test)]
1877mod tests {
1878 use super::*;
1879 use std::path::PathBuf;
1880 use std::process::Command;
1881 use tempfile::TempDir;
1882
1883 #[allow(dead_code)]
1884 fn create_test_repo() -> (TempDir, PathBuf) {
1885 let temp_dir = TempDir::new().unwrap();
1886 let repo_path = temp_dir.path().to_path_buf();
1887
1888 Command::new("git")
1890 .args(["init"])
1891 .current_dir(&repo_path)
1892 .output()
1893 .unwrap();
1894 Command::new("git")
1895 .args(["config", "user.name", "Test"])
1896 .current_dir(&repo_path)
1897 .output()
1898 .unwrap();
1899 Command::new("git")
1900 .args(["config", "user.email", "test@test.com"])
1901 .current_dir(&repo_path)
1902 .output()
1903 .unwrap();
1904
1905 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1907 Command::new("git")
1908 .args(["add", "."])
1909 .current_dir(&repo_path)
1910 .output()
1911 .unwrap();
1912 Command::new("git")
1913 .args(["commit", "-m", "Initial"])
1914 .current_dir(&repo_path)
1915 .output()
1916 .unwrap();
1917
1918 (temp_dir, repo_path)
1919 }
1920
1921 #[test]
1922 fn test_conflict_region_creation() {
1923 let region = ConflictRegion {
1924 start: 0,
1925 end: 50,
1926 start_line: 1,
1927 end_line: 3,
1928 our_content: "function test() {\n return true;\n}".to_string(),
1929 their_content: "function test() {\n return true;\n}".to_string(),
1930 };
1931
1932 assert_eq!(region.start_line, 1);
1933 assert_eq!(region.end_line, 3);
1934 assert!(region.our_content.contains("return true"));
1935 assert!(region.their_content.contains("return true"));
1936 }
1937
1938 #[test]
1939 fn test_rebase_strategies() {
1940 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1941 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1942 }
1943
1944 #[test]
1945 fn test_rebase_options() {
1946 let options = RebaseOptions::default();
1947 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1948 assert!(!options.interactive);
1949 assert!(options.auto_resolve);
1950 assert_eq!(options.max_retries, 3);
1951 }
1952
1953 #[test]
1954 fn test_cleanup_guard_tracks_branches() {
1955 let mut guard = TempBranchCleanupGuard::new();
1956 assert!(guard.branches.is_empty());
1957
1958 guard.add_branch("test-branch-1".to_string());
1959 guard.add_branch("test-branch-2".to_string());
1960
1961 assert_eq!(guard.branches.len(), 2);
1962 assert_eq!(guard.branches[0], "test-branch-1");
1963 assert_eq!(guard.branches[1], "test-branch-2");
1964 }
1965
1966 #[test]
1967 fn test_cleanup_guard_prevents_double_cleanup() {
1968 use std::process::Command;
1969 use tempfile::TempDir;
1970
1971 let temp_dir = TempDir::new().unwrap();
1973 let repo_path = temp_dir.path();
1974
1975 Command::new("git")
1976 .args(["init"])
1977 .current_dir(repo_path)
1978 .output()
1979 .unwrap();
1980
1981 Command::new("git")
1982 .args(["config", "user.name", "Test"])
1983 .current_dir(repo_path)
1984 .output()
1985 .unwrap();
1986
1987 Command::new("git")
1988 .args(["config", "user.email", "test@test.com"])
1989 .current_dir(repo_path)
1990 .output()
1991 .unwrap();
1992
1993 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1995 Command::new("git")
1996 .args(["add", "."])
1997 .current_dir(repo_path)
1998 .output()
1999 .unwrap();
2000 Command::new("git")
2001 .args(["commit", "-m", "initial"])
2002 .current_dir(repo_path)
2003 .output()
2004 .unwrap();
2005
2006 let git_repo = GitRepository::open(repo_path).unwrap();
2007
2008 git_repo.create_branch("test-temp", None).unwrap();
2010
2011 let mut guard = TempBranchCleanupGuard::new();
2012 guard.add_branch("test-temp".to_string());
2013
2014 guard.cleanup(&git_repo);
2016 assert!(guard.cleaned);
2017
2018 guard.cleanup(&git_repo);
2020 assert!(guard.cleaned);
2021 }
2022
2023 #[test]
2024 fn test_rebase_result() {
2025 let result = RebaseResult {
2026 success: true,
2027 branch_mapping: std::collections::HashMap::new(),
2028 conflicts: vec!["abc123".to_string()],
2029 new_commits: vec!["def456".to_string()],
2030 error: None,
2031 summary: "Test summary".to_string(),
2032 };
2033
2034 assert!(result.success);
2035 assert!(result.has_conflicts());
2036 assert_eq!(result.success_count(), 1);
2037 }
2038}