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_messages: Vec<String> = stack
866 .entries
867 .iter()
868 .map(|e| e.message.trim().to_string())
869 .collect();
870
871 let all_match_stack = commits.iter().all(|commit| {
872 if let Some(msg) = commit.summary() {
873 stack_messages
874 .iter()
875 .any(|stack_msg| stack_msg == msg.trim())
876 } else {
877 false
878 }
879 });
880
881 if all_match_stack {
882 debug!(
887 "Working branch has old pre-rebase commits (matching stack messages) - safe to update"
888 );
889 } else {
890 Output::error(format!(
892 "Cannot sync: Working branch '{}' has {} commit(s) not in the stack",
893 working_branch_name,
894 commits.len()
895 ));
896 println!();
897 Output::sub_item(
898 "These commits would be lost if we proceed:",
899 );
900 for (i, commit) in commits.iter().take(5).enumerate() {
901 let message =
902 commit.summary().unwrap_or("(no message)");
903 Output::sub_item(format!(
904 " {}. {} - {}",
905 i + 1,
906 &commit.id().to_string()[..8],
907 message
908 ));
909 }
910 if commits.len() > 5 {
911 Output::sub_item(format!(
912 " ... and {} more",
913 commits.len() - 5
914 ));
915 }
916 println!();
917 Output::tip("Add these commits to the stack first:");
918 Output::bullet("Run: ca stack push");
919 Output::bullet("Then run: ca sync");
920 println!();
921
922 if let Some(ref orig) = original_branch_for_cleanup {
924 let _ = self.git_repo.checkout_branch_unsafe(orig);
925 }
926
927 return Err(CascadeError::validation(
928 format!(
929 "Working branch '{}' has {} untracked commit(s). Add them to the stack with 'ca stack push' before syncing.",
930 working_branch_name, commits.len()
931 )
932 ));
933 }
934 }
935 }
936 }
937
938 debug!(
940 "Updating working branch '{}' to match top of stack ({})",
941 working_branch_name,
942 &top_commit[..8]
943 );
944
945 if let Err(e) = self
946 .git_repo
947 .update_branch_to_commit(working_branch_name, &top_commit)
948 {
949 Output::warning(format!(
950 "Could not update working branch '{}' to top of stack: {}",
951 working_branch_name, e
952 ));
953 }
954 }
955 }
956 } else {
957 debug!(
959 "Skipping working branch update - working branch '{}' is the base branch",
960 working_branch_name
961 );
962 }
963 }
964
965 if let Some(ref orig_branch) = original_branch {
968 if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
969 debug!(
970 "Could not return to original branch '{}': {}",
971 orig_branch, e
972 );
973 }
975 }
976 }
977 println!();
982 if result.success {
983 Output::success(&result.summary);
984 } else {
985 let error_msg = result
987 .error
988 .as_deref()
989 .unwrap_or("Rebase failed for unknown reason");
990 Output::error(error_msg);
991 }
992
993 self.stack_manager.save_to_disk()?;
995
996 if !result.success {
999 let detailed_error = result.error.as_deref().unwrap_or("Rebase failed");
1000 return Err(CascadeError::Branch(detailed_error.to_string()));
1001 }
1002
1003 Ok(result)
1004 }
1005
1006 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
1008 tracing::debug!("Starting interactive rebase for stack '{}'", stack.name);
1009
1010 let mut result = RebaseResult {
1011 success: true,
1012 branch_mapping: HashMap::new(),
1013 conflicts: Vec::new(),
1014 new_commits: Vec::new(),
1015 error: None,
1016 summary: String::new(),
1017 };
1018
1019 println!("Interactive Rebase for Stack: {}", stack.name);
1020 println!(" Base branch: {}", stack.base_branch);
1021 println!(" Entries: {}", stack.entries.len());
1022
1023 if self.options.interactive {
1024 println!("\nChoose action for each commit:");
1025 println!(" (p)ick - apply the commit");
1026 println!(" (s)kip - skip this commit");
1027 println!(" (e)dit - edit the commit message");
1028 println!(" (q)uit - abort the rebase");
1029 }
1030
1031 for entry in &stack.entries {
1034 println!(
1035 " {} {} - {}",
1036 entry.short_hash(),
1037 entry.branch,
1038 entry.short_message(50)
1039 );
1040
1041 match self.cherry_pick_commit(&entry.commit_hash) {
1043 Ok(new_commit) => result.new_commits.push(new_commit),
1044 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
1045 }
1046 }
1047
1048 result.summary = format!(
1049 "Interactive rebase processed {} commits",
1050 stack.entries.len()
1051 );
1052 Ok(result)
1053 }
1054
1055 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
1057 let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
1059
1060 if let Ok(staged_files) = self.git_repo.get_staged_files() {
1062 if !staged_files.is_empty() {
1063 let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
1065 match self.git_repo.commit_staged_changes(&cleanup_message) {
1066 Ok(Some(_)) => {
1067 debug!(
1068 "Committed {} leftover staged files after cherry-pick",
1069 staged_files.len()
1070 );
1071 }
1072 Ok(None) => {
1073 debug!("Staged files were cleared before commit");
1075 }
1076 Err(e) => {
1077 tracing::warn!(
1079 "Failed to commit {} staged files after cherry-pick: {}. \
1080 User may see checkout warning with staged changes.",
1081 staged_files.len(),
1082 e
1083 );
1084 }
1086 }
1087 }
1088 }
1089
1090 Ok(new_commit_hash)
1091 }
1092
1093 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
1095 debug!("Starting auto-resolve for commit {}", commit_hash);
1096
1097 let has_conflicts = self.git_repo.has_conflicts()?;
1099 debug!("has_conflicts() = {}", has_conflicts);
1100
1101 let cherry_pick_head = self.git_repo.path().join(".git").join("CHERRY_PICK_HEAD");
1103 let cherry_pick_in_progress = cherry_pick_head.exists();
1104
1105 if !has_conflicts {
1106 debug!("No conflicts detected by Git index");
1107
1108 if cherry_pick_in_progress {
1110 tracing::debug!(
1111 "CHERRY_PICK_HEAD exists but no conflicts in index - aborting cherry-pick"
1112 );
1113
1114 let _ = std::process::Command::new("git")
1116 .args(["cherry-pick", "--abort"])
1117 .current_dir(self.git_repo.path())
1118 .output();
1119
1120 return Err(CascadeError::Branch(format!(
1121 "Cherry-pick failed for {} but Git index shows no conflicts. \
1122 This usually means the cherry-pick was aborted or failed in an unexpected way. \
1123 Please try manual resolution.",
1124 &commit_hash[..8]
1125 )));
1126 }
1127
1128 return Ok(true);
1129 }
1130
1131 let conflicted_files = self.git_repo.get_conflicted_files()?;
1132
1133 if conflicted_files.is_empty() {
1134 debug!("Conflicted files list is empty");
1135 return Ok(true);
1136 }
1137
1138 debug!(
1139 "Found conflicts in {} files: {:?}",
1140 conflicted_files.len(),
1141 conflicted_files
1142 );
1143
1144 let analysis = self
1146 .conflict_analyzer
1147 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
1148
1149 debug!(
1150 "Conflict analysis: {} total conflicts, {} auto-resolvable",
1151 analysis.total_conflicts, analysis.auto_resolvable_count
1152 );
1153
1154 for recommendation in &analysis.recommendations {
1156 debug!("{}", recommendation);
1157 }
1158
1159 let mut resolved_count = 0;
1160 let mut resolved_files = Vec::new(); let mut failed_files = Vec::new();
1162
1163 for file_analysis in &analysis.files {
1164 debug!(
1165 "Processing file: {} (auto_resolvable: {}, conflicts: {})",
1166 file_analysis.file_path,
1167 file_analysis.auto_resolvable,
1168 file_analysis.conflicts.len()
1169 );
1170
1171 if file_analysis.auto_resolvable {
1172 match self.resolve_file_conflicts_enhanced(
1173 &file_analysis.file_path,
1174 &file_analysis.conflicts,
1175 ) {
1176 Ok(ConflictResolution::Resolved) => {
1177 resolved_count += 1;
1178 resolved_files.push(file_analysis.file_path.clone());
1179 debug!("Successfully resolved {}", file_analysis.file_path);
1180 }
1181 Ok(ConflictResolution::TooComplex) => {
1182 debug!(
1183 "{} too complex for auto-resolution",
1184 file_analysis.file_path
1185 );
1186 failed_files.push(file_analysis.file_path.clone());
1187 }
1188 Err(e) => {
1189 debug!("Failed to resolve {}: {}", file_analysis.file_path, e);
1190 failed_files.push(file_analysis.file_path.clone());
1191 }
1192 }
1193 } else {
1194 failed_files.push(file_analysis.file_path.clone());
1195 debug!(
1196 "{} requires manual resolution ({} conflicts)",
1197 file_analysis.file_path,
1198 file_analysis.conflicts.len()
1199 );
1200 }
1201 }
1202
1203 if resolved_count > 0 {
1204 debug!(
1205 "Resolved {}/{} files",
1206 resolved_count,
1207 conflicted_files.len()
1208 );
1209 debug!("Resolved files: {:?}", resolved_files);
1210
1211 let file_paths: Vec<&str> = resolved_files.iter().map(|s| s.as_str()).collect();
1214 debug!("Staging {} files", file_paths.len());
1215 self.git_repo.stage_files(&file_paths)?;
1216 debug!("Files staged successfully");
1217 } else {
1218 debug!("No files were resolved (resolved_count = 0)");
1219 }
1220
1221 let all_resolved = failed_files.is_empty();
1223
1224 debug!(
1225 "all_resolved = {}, failed_files = {:?}",
1226 all_resolved, failed_files
1227 );
1228
1229 if !all_resolved {
1230 debug!("{} files still need manual resolution", failed_files.len());
1231 }
1232
1233 debug!("Returning all_resolved = {}", all_resolved);
1234 Ok(all_resolved)
1235 }
1236
1237 fn resolve_file_conflicts_enhanced(
1239 &self,
1240 file_path: &str,
1241 conflicts: &[crate::git::ConflictRegion],
1242 ) -> Result<ConflictResolution> {
1243 let repo_path = self.git_repo.path();
1244 let full_path = repo_path.join(file_path);
1245
1246 let mut content = std::fs::read_to_string(&full_path)
1248 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
1249
1250 if conflicts.is_empty() {
1251 return Ok(ConflictResolution::Resolved);
1252 }
1253
1254 tracing::debug!(
1255 "Resolving {} conflicts in {} using enhanced analysis",
1256 conflicts.len(),
1257 file_path
1258 );
1259
1260 let mut any_resolved = false;
1261
1262 for conflict in conflicts.iter().rev() {
1264 match self.resolve_single_conflict_enhanced(conflict) {
1265 Ok(Some(resolution)) => {
1266 let before = &content[..conflict.start_pos];
1268 let after = &content[conflict.end_pos..];
1269 content = format!("{before}{resolution}{after}");
1270 any_resolved = true;
1271 debug!(
1272 "✅ Resolved {} conflict at lines {}-{} in {}",
1273 format!("{:?}", conflict.conflict_type).to_lowercase(),
1274 conflict.start_line,
1275 conflict.end_line,
1276 file_path
1277 );
1278 }
1279 Ok(None) => {
1280 debug!(
1281 "⚠️ {} conflict at lines {}-{} in {} requires manual resolution",
1282 format!("{:?}", conflict.conflict_type).to_lowercase(),
1283 conflict.start_line,
1284 conflict.end_line,
1285 file_path
1286 );
1287 return Ok(ConflictResolution::TooComplex);
1288 }
1289 Err(e) => {
1290 debug!("❌ Failed to resolve conflict in {}: {}", file_path, e);
1291 return Ok(ConflictResolution::TooComplex);
1292 }
1293 }
1294 }
1295
1296 if any_resolved {
1297 let remaining_conflicts = self.parse_conflict_markers(&content)?;
1299
1300 if remaining_conflicts.is_empty() {
1301 debug!(
1302 "All conflicts resolved in {}, content length: {} bytes",
1303 file_path,
1304 content.len()
1305 );
1306
1307 if content.trim().is_empty() {
1309 tracing::warn!(
1310 "SAFETY CHECK: Resolved content for {} is empty! Aborting auto-resolution.",
1311 file_path
1312 );
1313 return Ok(ConflictResolution::TooComplex);
1314 }
1315
1316 let backup_path = full_path.with_extension("cascade-backup");
1318 if let Ok(original_content) = std::fs::read_to_string(&full_path) {
1319 debug!(
1320 "Backup for {} (original: {} bytes, resolved: {} bytes)",
1321 file_path,
1322 original_content.len(),
1323 content.len()
1324 );
1325 let _ = std::fs::write(&backup_path, original_content);
1326 }
1327
1328 crate::utils::atomic_file::write_string(&full_path, &content)?;
1330
1331 debug!("Wrote {} bytes to {}", content.len(), file_path);
1332 return Ok(ConflictResolution::Resolved);
1333 } else {
1334 tracing::debug!(
1335 "Partially resolved conflicts in {} ({} remaining)",
1336 file_path,
1337 remaining_conflicts.len()
1338 );
1339 }
1340 }
1341
1342 Ok(ConflictResolution::TooComplex)
1343 }
1344
1345 #[allow(dead_code)]
1347 fn count_whitespace_consistency(content: &str) -> usize {
1348 let mut inconsistencies = 0;
1349 let lines: Vec<&str> = content.lines().collect();
1350
1351 for line in &lines {
1352 if line.contains('\t') && line.contains(' ') {
1354 inconsistencies += 1;
1355 }
1356 }
1357
1358 lines.len().saturating_sub(inconsistencies)
1360 }
1361
1362 fn cleanup_backup_files(&self) -> Result<()> {
1364 use std::fs;
1365 use std::path::Path;
1366
1367 let repo_path = self.git_repo.path();
1368
1369 fn remove_backups_recursive(dir: &Path) {
1371 if let Ok(entries) = fs::read_dir(dir) {
1372 for entry in entries.flatten() {
1373 let path = entry.path();
1374
1375 if path.is_dir() {
1376 if path.file_name().and_then(|n| n.to_str()) != Some(".git") {
1378 remove_backups_recursive(&path);
1379 }
1380 } else if let Some(ext) = path.extension() {
1381 if ext == "cascade-backup" {
1382 debug!("Cleaning up backup file: {}", path.display());
1383 if let Err(e) = fs::remove_file(&path) {
1384 tracing::warn!(
1386 "Could not remove backup file {}: {}",
1387 path.display(),
1388 e
1389 );
1390 }
1391 }
1392 }
1393 }
1394 }
1395 }
1396
1397 remove_backups_recursive(repo_path);
1398 Ok(())
1399 }
1400
1401 fn resolve_single_conflict_enhanced(
1403 &self,
1404 conflict: &crate::git::ConflictRegion,
1405 ) -> Result<Option<String>> {
1406 debug!(
1407 "Resolving {} conflict in {} (lines {}-{})",
1408 format!("{:?}", conflict.conflict_type).to_lowercase(),
1409 conflict.file_path,
1410 conflict.start_line,
1411 conflict.end_line
1412 );
1413
1414 use crate::git::ConflictType;
1415
1416 match conflict.conflict_type {
1417 ConflictType::Whitespace => {
1418 let our_normalized = conflict
1421 .our_content
1422 .split_whitespace()
1423 .collect::<Vec<_>>()
1424 .join(" ");
1425 let their_normalized = conflict
1426 .their_content
1427 .split_whitespace()
1428 .collect::<Vec<_>>()
1429 .join(" ");
1430
1431 if our_normalized == their_normalized {
1432 Ok(Some(conflict.their_content.clone()))
1437 } else {
1438 debug!(
1440 "Whitespace conflict has content differences - requires manual resolution"
1441 );
1442 Ok(None)
1443 }
1444 }
1445 ConflictType::LineEnding => {
1446 let normalized = conflict
1448 .our_content
1449 .replace("\r\n", "\n")
1450 .replace('\r', "\n");
1451 Ok(Some(normalized))
1452 }
1453 ConflictType::PureAddition => {
1454 if conflict.our_content.is_empty() && !conflict.their_content.is_empty() {
1458 Ok(Some(conflict.their_content.clone()))
1460 } else if conflict.their_content.is_empty() && !conflict.our_content.is_empty() {
1461 Ok(Some(String::new()))
1463 } else if conflict.our_content.is_empty() && conflict.their_content.is_empty() {
1464 Ok(Some(String::new()))
1466 } else {
1467 debug!(
1473 "PureAddition conflict has content on both sides - requires manual resolution"
1474 );
1475 Ok(None)
1476 }
1477 }
1478 ConflictType::ImportMerge => {
1479 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1484 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1485
1486 let all_simple = our_lines.iter().chain(their_lines.iter()).all(|line| {
1488 let trimmed = line.trim();
1489 trimmed.starts_with("import ")
1490 || trimmed.starts_with("from ")
1491 || trimmed.starts_with("use ")
1492 || trimmed.starts_with("#include")
1493 || trimmed.is_empty()
1494 });
1495
1496 if !all_simple {
1497 debug!("ImportMerge contains non-import lines - requires manual resolution");
1498 return Ok(None);
1499 }
1500
1501 let mut all_imports: Vec<&str> = our_lines
1503 .into_iter()
1504 .chain(their_lines)
1505 .filter(|line| !line.trim().is_empty())
1506 .collect();
1507 all_imports.sort();
1508 all_imports.dedup();
1509 Ok(Some(all_imports.join("\n")))
1510 }
1511 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
1512 Ok(None)
1514 }
1515 }
1516 }
1517
1518 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
1520 let lines: Vec<&str> = content.lines().collect();
1521 let mut conflicts = Vec::new();
1522 let mut i = 0;
1523
1524 while i < lines.len() {
1525 if lines[i].starts_with("<<<<<<<") {
1526 let start_line = i + 1;
1528 let mut separator_line = None;
1529 let mut end_line = None;
1530
1531 for (j, line) in lines.iter().enumerate().skip(i + 1) {
1533 if line.starts_with("=======") {
1534 separator_line = Some(j + 1);
1535 } else if line.starts_with(">>>>>>>") {
1536 end_line = Some(j + 1);
1537 break;
1538 }
1539 }
1540
1541 if let (Some(sep), Some(end)) = (separator_line, end_line) {
1542 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
1544 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
1545
1546 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
1547 let their_content = lines[sep..(end - 1)].join("\n");
1548
1549 conflicts.push(ConflictRegion {
1550 start: start_pos,
1551 end: end_pos,
1552 start_line,
1553 end_line: end,
1554 our_content,
1555 their_content,
1556 });
1557
1558 i = end;
1559 } else {
1560 i += 1;
1561 }
1562 } else {
1563 i += 1;
1564 }
1565 }
1566
1567 Ok(conflicts)
1568 }
1569
1570 fn update_stack_entry(
1573 &mut self,
1574 stack_id: Uuid,
1575 entry_id: &Uuid,
1576 _new_branch: &str,
1577 new_commit_hash: &str,
1578 ) -> Result<()> {
1579 debug!(
1580 "Updating entry {} in stack {} with new commit {}",
1581 entry_id, stack_id, new_commit_hash
1582 );
1583
1584 let stack = self
1586 .stack_manager
1587 .get_stack_mut(&stack_id)
1588 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1589
1590 let entry_exists = stack.entries.iter().any(|e| e.id == *entry_id);
1592
1593 if entry_exists {
1594 let old_hash = stack
1595 .entries
1596 .iter()
1597 .find(|e| e.id == *entry_id)
1598 .map(|e| e.commit_hash.clone())
1599 .unwrap();
1600
1601 debug!(
1602 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch)",
1603 entry_id, old_hash, new_commit_hash
1604 );
1605
1606 stack
1609 .update_entry_commit_hash(entry_id, new_commit_hash.to_string())
1610 .map_err(CascadeError::config)?;
1611
1612 debug!(
1615 "Successfully updated entry {} in stack {}",
1616 entry_id, stack_id
1617 );
1618 Ok(())
1619 } else {
1620 Err(CascadeError::config(format!(
1621 "Entry {entry_id} not found in stack {stack_id}"
1622 )))
1623 }
1624 }
1625
1626 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1628 tracing::debug!("Pulling latest changes for branch {}", branch);
1629
1630 match self.git_repo.fetch() {
1632 Ok(_) => {
1633 debug!("Fetch successful");
1634 match self.git_repo.pull(branch) {
1636 Ok(_) => {
1637 tracing::debug!("Pull completed successfully for {}", branch);
1638 Ok(())
1639 }
1640 Err(e) => {
1641 tracing::debug!("Pull failed for {}: {}", branch, e);
1642 Ok(())
1644 }
1645 }
1646 }
1647 Err(e) => {
1648 tracing::debug!("Fetch failed: {}", e);
1649 Ok(())
1651 }
1652 }
1653 }
1654
1655 pub fn is_rebase_in_progress(&self) -> bool {
1657 let git_dir = self.git_repo.path().join(".git");
1659 git_dir.join("REBASE_HEAD").exists()
1660 || git_dir.join("rebase-merge").exists()
1661 || git_dir.join("rebase-apply").exists()
1662 }
1663
1664 pub fn abort_rebase(&self) -> Result<()> {
1666 tracing::debug!("Aborting rebase operation");
1667
1668 let git_dir = self.git_repo.path().join(".git");
1669
1670 if git_dir.join("REBASE_HEAD").exists() {
1672 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1673 CascadeError::Git(git2::Error::from_str(&format!(
1674 "Failed to clean rebase state: {e}"
1675 )))
1676 })?;
1677 }
1678
1679 if git_dir.join("rebase-merge").exists() {
1680 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1681 CascadeError::Git(git2::Error::from_str(&format!(
1682 "Failed to clean rebase-merge: {e}"
1683 )))
1684 })?;
1685 }
1686
1687 if git_dir.join("rebase-apply").exists() {
1688 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1689 CascadeError::Git(git2::Error::from_str(&format!(
1690 "Failed to clean rebase-apply: {e}"
1691 )))
1692 })?;
1693 }
1694
1695 tracing::debug!("Rebase aborted successfully");
1696 Ok(())
1697 }
1698
1699 pub fn continue_rebase(&self) -> Result<()> {
1701 tracing::debug!("Continuing rebase operation");
1702
1703 if self.git_repo.has_conflicts()? {
1705 return Err(CascadeError::branch(
1706 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1707 ));
1708 }
1709
1710 self.git_repo.stage_conflict_resolved_files()?;
1712
1713 tracing::debug!("Rebase continued successfully");
1714 Ok(())
1715 }
1716
1717 fn has_in_progress_cherry_pick(&self) -> Result<bool> {
1719 let git_dir = self.git_repo.path().join(".git");
1720 Ok(git_dir.join("CHERRY_PICK_HEAD").exists())
1721 }
1722
1723 fn handle_in_progress_cherry_pick(&mut self, stack: &Stack) -> Result<RebaseResult> {
1725 use crate::cli::output::Output;
1726
1727 let git_dir = self.git_repo.path().join(".git");
1728
1729 Output::section("Resuming in-progress sync");
1730 println!();
1731 Output::info("Detected unfinished cherry-pick from previous sync");
1732 println!();
1733
1734 if self.git_repo.has_conflicts()? {
1736 let conflicted_files = self.git_repo.get_conflicted_files()?;
1737
1738 let result = RebaseResult {
1739 success: false,
1740 branch_mapping: HashMap::new(),
1741 conflicts: conflicted_files.clone(),
1742 new_commits: Vec::new(),
1743 error: Some(format!(
1744 "Cannot continue: {} file(s) still have unresolved conflicts\n\n\
1745 MANUAL CONFLICT RESOLUTION REQUIRED\n\
1746 =====================================\n\n\
1747 Conflicted files:\n{}\n\n\
1748 Step 1: Analyze conflicts\n\
1749 → Run: ca conflicts\n\
1750 → Shows detailed conflict analysis\n\n\
1751 Step 2: Resolve conflicts in your editor\n\
1752 → Open conflicted files and edit them\n\
1753 → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
1754 → Keep the code you want\n\
1755 → Save the files\n\n\
1756 Step 3: Mark conflicts as resolved\n\
1757 → Run: git add <resolved-files>\n\
1758 → Or: git add -A (to stage all resolved files)\n\n\
1759 Step 4: Complete the sync\n\
1760 → Run: ca sync continue\n\
1761 → Cascade will complete the cherry-pick and continue\n\n\
1762 Alternative: Abort and start over\n\
1763 → Run: ca sync abort\n\
1764 → Then: ca sync (starts fresh)",
1765 conflicted_files.len(),
1766 conflicted_files
1767 .iter()
1768 .map(|f| format!(" - {}", f))
1769 .collect::<Vec<_>>()
1770 .join("\n")
1771 )),
1772 summary: "Sync paused - conflicts need resolution".to_string(),
1773 };
1774
1775 return Ok(result);
1776 }
1777
1778 Output::info("Conflicts resolved, continuing cherry-pick...");
1780
1781 self.git_repo.stage_conflict_resolved_files()?;
1783
1784 let cherry_pick_msg_file = git_dir.join("CHERRY_PICK_MSG");
1786 let commit_message = if cherry_pick_msg_file.exists() {
1787 std::fs::read_to_string(&cherry_pick_msg_file)
1788 .unwrap_or_else(|_| "Resolved conflicts".to_string())
1789 } else {
1790 "Resolved conflicts".to_string()
1791 };
1792
1793 match self.git_repo.commit(&commit_message) {
1794 Ok(_new_commit_id) => {
1795 Output::success("Cherry-pick completed");
1796
1797 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1799 let _ = std::fs::remove_file(git_dir.join("CHERRY_PICK_HEAD"));
1800 }
1801 if cherry_pick_msg_file.exists() {
1802 let _ = std::fs::remove_file(&cherry_pick_msg_file);
1803 }
1804
1805 println!();
1806 Output::info("Continuing with rest of stack...");
1807 println!();
1808
1809 self.rebase_with_force_push(stack)
1812 }
1813 Err(e) => {
1814 let result = RebaseResult {
1815 success: false,
1816 branch_mapping: HashMap::new(),
1817 conflicts: Vec::new(),
1818 new_commits: Vec::new(),
1819 error: Some(format!(
1820 "Failed to complete cherry-pick: {}\n\n\
1821 This usually means:\n\
1822 - Another Git process is accessing the repository\n\
1823 - File permissions issue\n\
1824 - Disk space issue\n\n\
1825 Recovery:\n\
1826 1. Check if another Git operation is running\n\
1827 2. Run 'git status' to check repo state\n\
1828 3. Retry 'ca sync' after fixing the issue\n\n\
1829 Or abort and start fresh:\n\
1830 → Run: ca sync abort\n\
1831 → Then: ca sync",
1832 e
1833 )),
1834 summary: "Failed to complete cherry-pick".to_string(),
1835 };
1836
1837 Ok(result)
1838 }
1839 }
1840 }
1841}
1842
1843impl RebaseResult {
1844 pub fn get_summary(&self) -> String {
1846 if self.success {
1847 format!("✅ {}", self.summary)
1848 } else {
1849 format!(
1850 "❌ Rebase failed: {}",
1851 self.error.as_deref().unwrap_or("Unknown error")
1852 )
1853 }
1854 }
1855
1856 pub fn has_conflicts(&self) -> bool {
1858 !self.conflicts.is_empty()
1859 }
1860
1861 pub fn success_count(&self) -> usize {
1863 self.new_commits.len()
1864 }
1865}
1866
1867#[cfg(test)]
1868mod tests {
1869 use super::*;
1870 use std::path::PathBuf;
1871 use std::process::Command;
1872 use tempfile::TempDir;
1873
1874 #[allow(dead_code)]
1875 fn create_test_repo() -> (TempDir, PathBuf) {
1876 let temp_dir = TempDir::new().unwrap();
1877 let repo_path = temp_dir.path().to_path_buf();
1878
1879 Command::new("git")
1881 .args(["init"])
1882 .current_dir(&repo_path)
1883 .output()
1884 .unwrap();
1885 Command::new("git")
1886 .args(["config", "user.name", "Test"])
1887 .current_dir(&repo_path)
1888 .output()
1889 .unwrap();
1890 Command::new("git")
1891 .args(["config", "user.email", "test@test.com"])
1892 .current_dir(&repo_path)
1893 .output()
1894 .unwrap();
1895
1896 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1898 Command::new("git")
1899 .args(["add", "."])
1900 .current_dir(&repo_path)
1901 .output()
1902 .unwrap();
1903 Command::new("git")
1904 .args(["commit", "-m", "Initial"])
1905 .current_dir(&repo_path)
1906 .output()
1907 .unwrap();
1908
1909 (temp_dir, repo_path)
1910 }
1911
1912 #[test]
1913 fn test_conflict_region_creation() {
1914 let region = ConflictRegion {
1915 start: 0,
1916 end: 50,
1917 start_line: 1,
1918 end_line: 3,
1919 our_content: "function test() {\n return true;\n}".to_string(),
1920 their_content: "function test() {\n return true;\n}".to_string(),
1921 };
1922
1923 assert_eq!(region.start_line, 1);
1924 assert_eq!(region.end_line, 3);
1925 assert!(region.our_content.contains("return true"));
1926 assert!(region.their_content.contains("return true"));
1927 }
1928
1929 #[test]
1930 fn test_rebase_strategies() {
1931 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1932 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1933 }
1934
1935 #[test]
1936 fn test_rebase_options() {
1937 let options = RebaseOptions::default();
1938 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1939 assert!(!options.interactive);
1940 assert!(options.auto_resolve);
1941 assert_eq!(options.max_retries, 3);
1942 }
1943
1944 #[test]
1945 fn test_cleanup_guard_tracks_branches() {
1946 let mut guard = TempBranchCleanupGuard::new();
1947 assert!(guard.branches.is_empty());
1948
1949 guard.add_branch("test-branch-1".to_string());
1950 guard.add_branch("test-branch-2".to_string());
1951
1952 assert_eq!(guard.branches.len(), 2);
1953 assert_eq!(guard.branches[0], "test-branch-1");
1954 assert_eq!(guard.branches[1], "test-branch-2");
1955 }
1956
1957 #[test]
1958 fn test_cleanup_guard_prevents_double_cleanup() {
1959 use std::process::Command;
1960 use tempfile::TempDir;
1961
1962 let temp_dir = TempDir::new().unwrap();
1964 let repo_path = temp_dir.path();
1965
1966 Command::new("git")
1967 .args(["init"])
1968 .current_dir(repo_path)
1969 .output()
1970 .unwrap();
1971
1972 Command::new("git")
1973 .args(["config", "user.name", "Test"])
1974 .current_dir(repo_path)
1975 .output()
1976 .unwrap();
1977
1978 Command::new("git")
1979 .args(["config", "user.email", "test@test.com"])
1980 .current_dir(repo_path)
1981 .output()
1982 .unwrap();
1983
1984 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1986 Command::new("git")
1987 .args(["add", "."])
1988 .current_dir(repo_path)
1989 .output()
1990 .unwrap();
1991 Command::new("git")
1992 .args(["commit", "-m", "initial"])
1993 .current_dir(repo_path)
1994 .output()
1995 .unwrap();
1996
1997 let git_repo = GitRepository::open(repo_path).unwrap();
1998
1999 git_repo.create_branch("test-temp", None).unwrap();
2001
2002 let mut guard = TempBranchCleanupGuard::new();
2003 guard.add_branch("test-temp".to_string());
2004
2005 guard.cleanup(&git_repo);
2007 assert!(guard.cleaned);
2008
2009 guard.cleanup(&git_repo);
2011 assert!(guard.cleaned);
2012 }
2013
2014 #[test]
2015 fn test_rebase_result() {
2016 let result = RebaseResult {
2017 success: true,
2018 branch_mapping: std::collections::HashMap::new(),
2019 conflicts: vec!["abc123".to_string()],
2020 new_commits: vec!["def456".to_string()],
2021 error: None,
2022 summary: "Test summary".to_string(),
2023 };
2024
2025 assert!(result.success);
2026 assert!(result.has_conflicts());
2027 assert_eq!(result.success_count(), 1);
2028 }
2029}