1use crate::errors::{CascadeError, Result};
2use crate::git::{ConflictAnalyzer, GitRepository};
3use crate::stack::{Stack, StackManager};
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 Output::sub_item(format!("Entries: {}", stack.entries.len()));
216
217 let mut result = RebaseResult {
218 success: true,
219 branch_mapping: HashMap::new(),
220 conflicts: Vec::new(),
221 new_commits: Vec::new(),
222 error: None,
223 summary: String::new(),
224 };
225
226 let target_base = self
227 .options
228 .target_base
229 .as_ref()
230 .unwrap_or(&stack.base_branch)
231 .clone(); let original_branch = self
237 .options
238 .original_working_branch
239 .clone()
240 .or_else(|| self.git_repo.get_current_branch().ok());
241
242 let original_branch_for_cleanup = original_branch.clone();
244
245 if let Some(ref orig) = original_branch {
248 if orig == &target_base {
249 debug!(
250 "Original working branch is base branch '{}' - will skip working branch update",
251 orig
252 );
253 }
254 }
255
256 if !self.options.skip_pull.unwrap_or(false) {
259 if let Err(e) = self.pull_latest_changes(&target_base) {
260 Output::warning(format!("Could not pull latest changes: {}", e));
261 }
262 }
263
264 if let Err(e) = self.git_repo.reset_to_head() {
266 Output::warning(format!("Could not reset working directory: {}", e));
267 }
268
269 let mut current_base = target_base.clone();
270 let entry_count = stack.entries.iter().filter(|e| !e.is_merged).count();
272 let mut temp_branches: Vec<String> = Vec::new(); let mut branches_to_push: Vec<(String, String, usize)> = Vec::new(); let mut processed_entries: usize = 0; if entry_count == 0 {
278 println!();
279 if stack.entries.is_empty() {
280 Output::info("Stack has no entries yet");
281 Output::tip("Use 'ca push' to add commits to this stack");
282 result.summary = "Stack is empty".to_string();
283 } else {
284 Output::info("All entries in this stack have been merged");
285 Output::tip("Use 'ca push' to add new commits, or 'ca stack cleanup' to prune merged branches");
286 result.summary = "All entries merged".to_string();
287 }
288
289 println!();
291 Output::success(&result.summary);
292
293 self.stack_manager.save_to_disk()?;
295 return Ok(result);
296 }
297
298 let all_up_to_date = stack
300 .entries
301 .iter()
302 .filter(|entry| !entry.is_merged) .all(|entry| {
304 self.git_repo
305 .is_commit_based_on(&entry.commit_hash, &target_base)
306 .unwrap_or(false)
307 });
308
309 if all_up_to_date {
310 println!();
311 Output::success("Stack is already up-to-date with base branch");
312 result.summary = "Stack is up-to-date".to_string();
313 result.success = true;
314 return Ok(result);
315 }
316
317 for entry in stack.entries.iter() {
319 let original_branch = &entry.branch;
320
321 if entry.is_merged {
323 tracing::debug!("Entry '{}' is merged, skipping rebase", original_branch);
324 current_base = original_branch.clone();
326 continue;
327 }
328
329 processed_entries += 1;
330
331 if self
334 .git_repo
335 .is_commit_based_on(&entry.commit_hash, ¤t_base)
336 .unwrap_or(false)
337 {
338 tracing::debug!(
339 "Entry '{}' is already correctly based on '{}', skipping rebase",
340 original_branch,
341 current_base
342 );
343
344 if let Some(pr_num) = &entry.pull_request_id {
346 branches_to_push.push((
347 original_branch.clone(),
348 pr_num.clone(),
349 processed_entries - 1,
350 ));
351 }
352
353 result
354 .branch_mapping
355 .insert(original_branch.clone(), original_branch.clone());
356
357 current_base = original_branch.clone();
359 continue;
360 }
361
362 if !result.success {
365 tracing::debug!(
366 "Skipping entry '{}' because previous entry failed",
367 original_branch
368 );
369 break;
370 }
371
372 let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
375 temp_branches.push(temp_branch.clone()); if let Err(e) = self
379 .git_repo
380 .create_branch(&temp_branch, Some(¤t_base))
381 {
382 if let Some(ref orig) = original_branch_for_cleanup {
384 let _ = self.git_repo.checkout_branch_unsafe(orig);
385 }
386 return Err(e);
387 }
388
389 if let Err(e) = self.git_repo.checkout_branch_silent(&temp_branch) {
390 if let Some(ref orig) = original_branch_for_cleanup {
392 let _ = self.git_repo.checkout_branch_unsafe(orig);
393 }
394 return Err(e);
395 }
396
397 match self.cherry_pick_commit(&entry.commit_hash) {
399 Ok(new_commit_hash) => {
400 result.new_commits.push(new_commit_hash.clone());
401
402 let rebased_commit_id = self.git_repo.get_head_commit()?.id().to_string();
404
405 self.git_repo
408 .update_branch_to_commit(original_branch, &rebased_commit_id)?;
409
410 if let Some(pr_num) = &entry.pull_request_id {
412 let tree_char = if processed_entries == entry_count {
413 "└─"
414 } else {
415 "├─"
416 };
417 println!(" {} {} (PR #{})", tree_char, original_branch, pr_num);
418 branches_to_push.push((
419 original_branch.clone(),
420 pr_num.clone(),
421 processed_entries - 1,
422 ));
423 }
424
425 result
426 .branch_mapping
427 .insert(original_branch.clone(), original_branch.clone());
428
429 self.update_stack_entry(
431 stack.id,
432 &entry.id,
433 original_branch,
434 &rebased_commit_id,
435 )?;
436
437 current_base = original_branch.clone();
439 }
440 Err(e) => {
441 result.conflicts.push(entry.commit_hash.clone());
442
443 if !self.options.auto_resolve {
444 println!();
445 Output::error(e.to_string());
446 result.success = false;
447 result.error = Some(format!(
448 "Conflict in {}: {}\n\n\
449 MANUAL CONFLICT RESOLUTION REQUIRED\n\
450 =====================================\n\n\
451 Step 1: Analyze conflicts\n\
452 → Run: ca conflicts\n\
453 → This shows which conflicts are in which files\n\n\
454 Step 2: Resolve conflicts in your editor\n\
455 → Open conflicted files and edit them\n\
456 → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
457 → Keep the code you want\n\
458 → Save the files\n\n\
459 Step 3: Mark conflicts as resolved\n\
460 → Run: git add <resolved-files>\n\
461 → Or: git add -A (to stage all resolved files)\n\n\
462 Step 4: Complete the sync\n\
463 → Run: ca sync\n\
464 → Cascade will detect resolved conflicts and continue\n\n\
465 Alternative: Abort and start over\n\
466 → Run: git cherry-pick --abort\n\
467 → Then: ca sync (starts fresh)\n\n\
468 TIP: Enable auto-resolution for simple conflicts:\n\
469 → Run: ca sync --auto-resolve\n\
470 → Only complex conflicts will require manual resolution",
471 entry.commit_hash, e
472 ));
473 break;
474 }
475
476 match self.auto_resolve_conflicts(&entry.commit_hash) {
478 Ok(fully_resolved) => {
479 if !fully_resolved {
480 result.success = false;
481 result.error = Some(format!(
482 "Conflicts in commit {}\n\n\
483 To resolve:\n\
484 1. Fix conflicts in your editor\n\
485 2. Run: ca sync --continue\n\n\
486 Or abort:\n\
487 → Run: git cherry-pick --abort",
488 &entry.commit_hash[..8]
489 ));
490 break;
491 }
492
493 let commit_message = entry.message.trim().to_string();
496
497 debug!("Checking staged files before commit");
499 let staged_files = self.git_repo.get_staged_files()?;
500
501 if staged_files.is_empty() {
502 debug!(
505 "Cherry-pick resulted in empty commit for {}",
506 &entry.commit_hash[..8]
507 );
508
509 Output::warning(format!(
511 "Skipping entry '{}' - cherry-pick resulted in no changes",
512 original_branch
513 ));
514 Output::sub_item(
515 "This usually means the base branch has moved forward",
516 );
517 Output::sub_item("and this entry's changes are already present");
518
519 let _ = std::process::Command::new("git")
521 .args(["cherry-pick", "--abort"])
522 .current_dir(self.git_repo.path())
523 .output();
524
525 continue;
527 }
528
529 debug!("{} files staged", staged_files.len());
530
531 match self.git_repo.commit(&commit_message) {
532 Ok(new_commit_id) => {
533 debug!(
534 "Created commit {} with message '{}'",
535 &new_commit_id[..8],
536 commit_message
537 );
538
539 Output::success("Auto-resolved conflicts");
540 result.new_commits.push(new_commit_id.clone());
541 let rebased_commit_id = new_commit_id;
542
543 self.cleanup_backup_files()?;
545
546 self.git_repo.update_branch_to_commit(
548 original_branch,
549 &rebased_commit_id,
550 )?;
551
552 if let Some(pr_num) = &entry.pull_request_id {
554 let tree_char = if processed_entries == entry_count {
555 "└─"
556 } else {
557 "├─"
558 };
559 println!(
560 " {} {} (PR #{})",
561 tree_char, original_branch, pr_num
562 );
563 branches_to_push.push((
564 original_branch.clone(),
565 pr_num.clone(),
566 processed_entries - 1,
567 ));
568 }
569
570 result
571 .branch_mapping
572 .insert(original_branch.clone(), original_branch.clone());
573
574 self.update_stack_entry(
576 stack.id,
577 &entry.id,
578 original_branch,
579 &rebased_commit_id,
580 )?;
581
582 current_base = original_branch.clone();
584 }
585 Err(commit_err) => {
586 result.success = false;
587 result.error = Some(format!(
588 "Could not commit auto-resolved conflicts: {}\n\n\
589 This usually means:\n\
590 - Git index is locked (another process accessing repo)\n\
591 - File permissions issue\n\
592 - Disk space issue\n\n\
593 Recovery:\n\
594 1. Check if another Git operation is running\n\
595 2. Run 'rm -f .git/index.lock' if stale lock exists\n\
596 3. Run 'git status' to check repo state\n\
597 4. Retry 'ca sync' after fixing the issue",
598 commit_err
599 ));
600 break;
601 }
602 }
603 }
604 Err(resolve_err) => {
605 result.success = false;
606 result.error = Some(format!(
607 "Could not resolve conflicts: {}\n\n\
608 Recovery:\n\
609 1. Check repo state: 'git status'\n\
610 2. If files are staged, commit or reset them: 'git reset --hard HEAD'\n\
611 3. Remove any lock files: 'rm -f .git/index.lock'\n\
612 4. Retry 'ca sync'",
613 resolve_err
614 ));
615 break;
616 }
617 }
618 }
619 }
620 }
621
622 if !temp_branches.is_empty() {
625 if let Err(e) = self.git_repo.checkout_branch_unsafe(&target_base) {
628 debug!("Could not checkout base for cleanup: {}", e);
629 } else {
632 for temp_branch in &temp_branches {
634 if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
635 debug!("Could not delete temp branch {}: {}", temp_branch, e);
636 }
637 }
638 }
639 }
640
641 let pushed_count = branches_to_push.len();
647 let _skipped_count = entry_count - pushed_count; let mut successful_pushes = 0; if !result.success {
651 println!();
652 Output::error("Rebase failed - not pushing any branches");
653 } else {
656 if !branches_to_push.is_empty() {
660 println!();
661
662 let mut push_results = Vec::new();
664 for (branch_name, _pr_num, _index) in branches_to_push.iter() {
665 let result = self.git_repo.force_push_single_branch_auto(branch_name);
666 push_results.push((branch_name.clone(), result));
667 }
668
669 let mut failed_pushes = 0;
671 for (index, (branch_name, result)) in push_results.iter().enumerate() {
672 match result {
673 Ok(_) => {
674 debug!("Pushed {} successfully", branch_name);
675 successful_pushes += 1;
676 println!(
677 " ✓ Pushed {} ({}/{})",
678 branch_name,
679 index + 1,
680 pushed_count
681 );
682 }
683 Err(e) => {
684 failed_pushes += 1;
685 println!(" ⚠ Could not push '{}': {}", branch_name, e);
686 }
687 }
688 }
689
690 if failed_pushes > 0 {
692 println!(); Output::warning(format!(
694 "{} branch(es) failed to push to remote",
695 failed_pushes
696 ));
697 Output::tip("To retry failed pushes, run: ca sync");
698 }
699 }
700
701 let entries_word = if entry_count == 1 { "entry" } else { "entries" };
703 let pr_word = if successful_pushes == 1 { "PR" } else { "PRs" };
704
705 result.summary = if successful_pushes > 0 {
706 let not_submitted_count = entry_count - successful_pushes;
707 if not_submitted_count > 0 {
708 format!(
709 "{} {} rebased ({} {} updated, {} not yet submitted)",
710 entry_count, entries_word, successful_pushes, pr_word, not_submitted_count
711 )
712 } else {
713 format!(
714 "{} {} rebased ({} {} updated)",
715 entry_count, entries_word, successful_pushes, pr_word
716 )
717 }
718 } else {
719 format!(
720 "{} {} rebased (none submitted to Bitbucket yet)",
721 entry_count, entries_word
722 )
723 };
724 } if let Some(ref working_branch_name) = stack.working_branch {
731 if working_branch_name != &target_base {
733 if let Some(last_entry) = stack.entries.last() {
734 let top_branch = &last_entry.branch;
735
736 if let (Ok(working_head), Ok(top_commit)) = (
739 self.git_repo.get_branch_head(working_branch_name),
740 self.git_repo.get_branch_head(top_branch),
741 ) {
742 if working_head != top_commit {
744 if let Ok(commits) = self
746 .git_repo
747 .get_commits_between(&top_commit, &working_head)
748 {
749 if !commits.is_empty() {
750 let stack_messages: Vec<String> = stack
753 .entries
754 .iter()
755 .map(|e| e.message.trim().to_string())
756 .collect();
757
758 let all_match_stack = commits.iter().all(|commit| {
759 if let Some(msg) = commit.summary() {
760 stack_messages
761 .iter()
762 .any(|stack_msg| stack_msg == msg.trim())
763 } else {
764 false
765 }
766 });
767
768 if all_match_stack {
769 debug!(
774 "Working branch has old pre-rebase commits (matching stack messages) - safe to update"
775 );
776 } else {
777 Output::error(format!(
779 "Cannot sync: Working branch '{}' has {} commit(s) not in the stack",
780 working_branch_name,
781 commits.len()
782 ));
783 println!();
784 Output::sub_item(
785 "These commits would be lost if we proceed:",
786 );
787 for (i, commit) in commits.iter().take(5).enumerate() {
788 let message =
789 commit.summary().unwrap_or("(no message)");
790 Output::sub_item(format!(
791 " {}. {} - {}",
792 i + 1,
793 &commit.id().to_string()[..8],
794 message
795 ));
796 }
797 if commits.len() > 5 {
798 Output::sub_item(format!(
799 " ... and {} more",
800 commits.len() - 5
801 ));
802 }
803 println!();
804 Output::tip("Add these commits to the stack first:");
805 Output::bullet("Run: ca stack push");
806 Output::bullet("Then run: ca sync");
807 println!();
808
809 if let Some(ref orig) = original_branch_for_cleanup {
811 let _ = self.git_repo.checkout_branch_unsafe(orig);
812 }
813
814 return Err(CascadeError::validation(
815 format!(
816 "Working branch '{}' has {} untracked commit(s). Add them to the stack with 'ca stack push' before syncing.",
817 working_branch_name, commits.len()
818 )
819 ));
820 }
821 }
822 }
823 }
824
825 debug!(
827 "Updating working branch '{}' to match top of stack ({})",
828 working_branch_name,
829 &top_commit[..8]
830 );
831
832 if let Err(e) = self
833 .git_repo
834 .update_branch_to_commit(working_branch_name, &top_commit)
835 {
836 Output::warning(format!(
837 "Could not update working branch '{}' to top of stack: {}",
838 working_branch_name, e
839 ));
840 }
841 }
842 }
843 } else {
844 debug!(
846 "Skipping working branch update - working branch '{}' is the base branch",
847 working_branch_name
848 );
849 }
850 }
851
852 if let Some(ref orig_branch) = original_branch {
855 if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
856 debug!(
857 "Could not return to original branch '{}': {}",
858 orig_branch, e
859 );
860 }
862 }
863 println!();
868 if result.success {
869 Output::success(&result.summary);
870 } else {
871 let error_msg = result
873 .error
874 .as_deref()
875 .unwrap_or("Rebase failed for unknown reason");
876 Output::error(error_msg);
877 }
878
879 self.stack_manager.save_to_disk()?;
881
882 if !result.success {
885 if let Some(ref orig_branch) = original_branch {
887 if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
888 debug!(
889 "Could not return to original branch '{}' after error: {}",
890 orig_branch, e
891 );
892 }
893 }
894
895 let detailed_error = result.error.as_deref().unwrap_or("Rebase failed");
897 return Err(CascadeError::Branch(detailed_error.to_string()));
898 }
899
900 Ok(result)
901 }
902
903 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
905 tracing::debug!("Starting interactive rebase for stack '{}'", stack.name);
906
907 let mut result = RebaseResult {
908 success: true,
909 branch_mapping: HashMap::new(),
910 conflicts: Vec::new(),
911 new_commits: Vec::new(),
912 error: None,
913 summary: String::new(),
914 };
915
916 println!("Interactive Rebase for Stack: {}", stack.name);
917 println!(" Base branch: {}", stack.base_branch);
918 println!(" Entries: {}", stack.entries.len());
919
920 if self.options.interactive {
921 println!("\nChoose action for each commit:");
922 println!(" (p)ick - apply the commit");
923 println!(" (s)kip - skip this commit");
924 println!(" (e)dit - edit the commit message");
925 println!(" (q)uit - abort the rebase");
926 }
927
928 for entry in &stack.entries {
931 println!(
932 " {} {} - {}",
933 entry.short_hash(),
934 entry.branch,
935 entry.short_message(50)
936 );
937
938 match self.cherry_pick_commit(&entry.commit_hash) {
940 Ok(new_commit) => result.new_commits.push(new_commit),
941 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
942 }
943 }
944
945 result.summary = format!(
946 "Interactive rebase processed {} commits",
947 stack.entries.len()
948 );
949 Ok(result)
950 }
951
952 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
954 let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
956
957 if let Ok(staged_files) = self.git_repo.get_staged_files() {
959 if !staged_files.is_empty() {
960 let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
962 match self.git_repo.commit_staged_changes(&cleanup_message) {
963 Ok(Some(_)) => {
964 debug!(
965 "Committed {} leftover staged files after cherry-pick",
966 staged_files.len()
967 );
968 }
969 Ok(None) => {
970 debug!("Staged files were cleared before commit");
972 }
973 Err(e) => {
974 tracing::warn!(
976 "Failed to commit {} staged files after cherry-pick: {}. \
977 User may see checkout warning with staged changes.",
978 staged_files.len(),
979 e
980 );
981 }
983 }
984 }
985 }
986
987 Ok(new_commit_hash)
988 }
989
990 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
992 debug!("Starting auto-resolve for commit {}", commit_hash);
993
994 let has_conflicts = self.git_repo.has_conflicts()?;
996 debug!("has_conflicts() = {}", has_conflicts);
997
998 let cherry_pick_head = self.git_repo.path().join(".git").join("CHERRY_PICK_HEAD");
1000 let cherry_pick_in_progress = cherry_pick_head.exists();
1001
1002 if !has_conflicts {
1003 debug!("No conflicts detected by Git index");
1004
1005 if cherry_pick_in_progress {
1007 tracing::debug!(
1008 "CHERRY_PICK_HEAD exists but no conflicts in index - aborting cherry-pick"
1009 );
1010
1011 let _ = std::process::Command::new("git")
1013 .args(["cherry-pick", "--abort"])
1014 .current_dir(self.git_repo.path())
1015 .output();
1016
1017 return Err(CascadeError::Branch(format!(
1018 "Cherry-pick failed for {} but Git index shows no conflicts. \
1019 This usually means the cherry-pick was aborted or failed in an unexpected way. \
1020 Please try manual resolution.",
1021 &commit_hash[..8]
1022 )));
1023 }
1024
1025 return Ok(true);
1026 }
1027
1028 let conflicted_files = self.git_repo.get_conflicted_files()?;
1029
1030 if conflicted_files.is_empty() {
1031 debug!("Conflicted files list is empty");
1032 return Ok(true);
1033 }
1034
1035 debug!(
1036 "Found conflicts in {} files: {:?}",
1037 conflicted_files.len(),
1038 conflicted_files
1039 );
1040
1041 let analysis = self
1043 .conflict_analyzer
1044 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
1045
1046 debug!(
1047 "Conflict analysis: {} total conflicts, {} auto-resolvable",
1048 analysis.total_conflicts, analysis.auto_resolvable_count
1049 );
1050
1051 for recommendation in &analysis.recommendations {
1053 debug!("{}", recommendation);
1054 }
1055
1056 let mut resolved_count = 0;
1057 let mut resolved_files = Vec::new(); let mut failed_files = Vec::new();
1059
1060 for file_analysis in &analysis.files {
1061 debug!(
1062 "Processing file: {} (auto_resolvable: {}, conflicts: {})",
1063 file_analysis.file_path,
1064 file_analysis.auto_resolvable,
1065 file_analysis.conflicts.len()
1066 );
1067
1068 if file_analysis.auto_resolvable {
1069 match self.resolve_file_conflicts_enhanced(
1070 &file_analysis.file_path,
1071 &file_analysis.conflicts,
1072 ) {
1073 Ok(ConflictResolution::Resolved) => {
1074 resolved_count += 1;
1075 resolved_files.push(file_analysis.file_path.clone());
1076 debug!("Successfully resolved {}", file_analysis.file_path);
1077 }
1078 Ok(ConflictResolution::TooComplex) => {
1079 debug!(
1080 "{} too complex for auto-resolution",
1081 file_analysis.file_path
1082 );
1083 failed_files.push(file_analysis.file_path.clone());
1084 }
1085 Err(e) => {
1086 debug!("Failed to resolve {}: {}", file_analysis.file_path, e);
1087 failed_files.push(file_analysis.file_path.clone());
1088 }
1089 }
1090 } else {
1091 failed_files.push(file_analysis.file_path.clone());
1092 debug!(
1093 "{} requires manual resolution ({} conflicts)",
1094 file_analysis.file_path,
1095 file_analysis.conflicts.len()
1096 );
1097 }
1098 }
1099
1100 if resolved_count > 0 {
1101 debug!(
1102 "Resolved {}/{} files",
1103 resolved_count,
1104 conflicted_files.len()
1105 );
1106 debug!("Resolved files: {:?}", resolved_files);
1107
1108 let file_paths: Vec<&str> = resolved_files.iter().map(|s| s.as_str()).collect();
1111 debug!("Staging {} files", file_paths.len());
1112 self.git_repo.stage_files(&file_paths)?;
1113 debug!("Files staged successfully");
1114 } else {
1115 debug!("No files were resolved (resolved_count = 0)");
1116 }
1117
1118 let all_resolved = failed_files.is_empty();
1120
1121 debug!(
1122 "all_resolved = {}, failed_files = {:?}",
1123 all_resolved, failed_files
1124 );
1125
1126 if !all_resolved {
1127 debug!("{} files still need manual resolution", failed_files.len());
1128 }
1129
1130 debug!("Returning all_resolved = {}", all_resolved);
1131 Ok(all_resolved)
1132 }
1133
1134 fn resolve_file_conflicts_enhanced(
1136 &self,
1137 file_path: &str,
1138 conflicts: &[crate::git::ConflictRegion],
1139 ) -> Result<ConflictResolution> {
1140 let repo_path = self.git_repo.path();
1141 let full_path = repo_path.join(file_path);
1142
1143 let mut content = std::fs::read_to_string(&full_path)
1145 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
1146
1147 if conflicts.is_empty() {
1148 return Ok(ConflictResolution::Resolved);
1149 }
1150
1151 tracing::debug!(
1152 "Resolving {} conflicts in {} using enhanced analysis",
1153 conflicts.len(),
1154 file_path
1155 );
1156
1157 let mut any_resolved = false;
1158
1159 for conflict in conflicts.iter().rev() {
1161 match self.resolve_single_conflict_enhanced(conflict) {
1162 Ok(Some(resolution)) => {
1163 let before = &content[..conflict.start_pos];
1165 let after = &content[conflict.end_pos..];
1166 content = format!("{before}{resolution}{after}");
1167 any_resolved = true;
1168 debug!(
1169 "✅ Resolved {} conflict at lines {}-{} in {}",
1170 format!("{:?}", conflict.conflict_type).to_lowercase(),
1171 conflict.start_line,
1172 conflict.end_line,
1173 file_path
1174 );
1175 }
1176 Ok(None) => {
1177 debug!(
1178 "⚠️ {} conflict at lines {}-{} in {} requires manual resolution",
1179 format!("{:?}", conflict.conflict_type).to_lowercase(),
1180 conflict.start_line,
1181 conflict.end_line,
1182 file_path
1183 );
1184 return Ok(ConflictResolution::TooComplex);
1185 }
1186 Err(e) => {
1187 debug!("❌ Failed to resolve conflict in {}: {}", file_path, e);
1188 return Ok(ConflictResolution::TooComplex);
1189 }
1190 }
1191 }
1192
1193 if any_resolved {
1194 let remaining_conflicts = self.parse_conflict_markers(&content)?;
1196
1197 if remaining_conflicts.is_empty() {
1198 debug!(
1199 "All conflicts resolved in {}, content length: {} bytes",
1200 file_path,
1201 content.len()
1202 );
1203
1204 if content.trim().is_empty() {
1206 tracing::warn!(
1207 "SAFETY CHECK: Resolved content for {} is empty! Aborting auto-resolution.",
1208 file_path
1209 );
1210 return Ok(ConflictResolution::TooComplex);
1211 }
1212
1213 let backup_path = full_path.with_extension("cascade-backup");
1215 if let Ok(original_content) = std::fs::read_to_string(&full_path) {
1216 debug!(
1217 "Backup for {} (original: {} bytes, resolved: {} bytes)",
1218 file_path,
1219 original_content.len(),
1220 content.len()
1221 );
1222 let _ = std::fs::write(&backup_path, original_content);
1223 }
1224
1225 crate::utils::atomic_file::write_string(&full_path, &content)?;
1227
1228 debug!("Wrote {} bytes to {}", content.len(), file_path);
1229 return Ok(ConflictResolution::Resolved);
1230 } else {
1231 tracing::debug!(
1232 "Partially resolved conflicts in {} ({} remaining)",
1233 file_path,
1234 remaining_conflicts.len()
1235 );
1236 }
1237 }
1238
1239 Ok(ConflictResolution::TooComplex)
1240 }
1241
1242 #[allow(dead_code)]
1244 fn count_whitespace_consistency(content: &str) -> usize {
1245 let mut inconsistencies = 0;
1246 let lines: Vec<&str> = content.lines().collect();
1247
1248 for line in &lines {
1249 if line.contains('\t') && line.contains(' ') {
1251 inconsistencies += 1;
1252 }
1253 }
1254
1255 lines.len().saturating_sub(inconsistencies)
1257 }
1258
1259 fn cleanup_backup_files(&self) -> Result<()> {
1261 use std::fs;
1262 use std::path::Path;
1263
1264 let repo_path = self.git_repo.path();
1265
1266 fn remove_backups_recursive(dir: &Path) {
1268 if let Ok(entries) = fs::read_dir(dir) {
1269 for entry in entries.flatten() {
1270 let path = entry.path();
1271
1272 if path.is_dir() {
1273 if path.file_name().and_then(|n| n.to_str()) != Some(".git") {
1275 remove_backups_recursive(&path);
1276 }
1277 } else if let Some(ext) = path.extension() {
1278 if ext == "cascade-backup" {
1279 debug!("Cleaning up backup file: {}", path.display());
1280 if let Err(e) = fs::remove_file(&path) {
1281 tracing::warn!(
1283 "Could not remove backup file {}: {}",
1284 path.display(),
1285 e
1286 );
1287 }
1288 }
1289 }
1290 }
1291 }
1292 }
1293
1294 remove_backups_recursive(repo_path);
1295 Ok(())
1296 }
1297
1298 fn resolve_single_conflict_enhanced(
1300 &self,
1301 conflict: &crate::git::ConflictRegion,
1302 ) -> Result<Option<String>> {
1303 debug!(
1304 "Resolving {} conflict in {} (lines {}-{})",
1305 format!("{:?}", conflict.conflict_type).to_lowercase(),
1306 conflict.file_path,
1307 conflict.start_line,
1308 conflict.end_line
1309 );
1310
1311 use crate::git::ConflictType;
1312
1313 match conflict.conflict_type {
1314 ConflictType::Whitespace => {
1315 let our_normalized = conflict
1318 .our_content
1319 .split_whitespace()
1320 .collect::<Vec<_>>()
1321 .join(" ");
1322 let their_normalized = conflict
1323 .their_content
1324 .split_whitespace()
1325 .collect::<Vec<_>>()
1326 .join(" ");
1327
1328 if our_normalized == their_normalized {
1329 Ok(Some(conflict.their_content.clone()))
1334 } else {
1335 debug!(
1337 "Whitespace conflict has content differences - requires manual resolution"
1338 );
1339 Ok(None)
1340 }
1341 }
1342 ConflictType::LineEnding => {
1343 let normalized = conflict
1345 .our_content
1346 .replace("\r\n", "\n")
1347 .replace('\r', "\n");
1348 Ok(Some(normalized))
1349 }
1350 ConflictType::PureAddition => {
1351 if conflict.our_content.is_empty() && !conflict.their_content.is_empty() {
1355 Ok(Some(conflict.their_content.clone()))
1357 } else if conflict.their_content.is_empty() && !conflict.our_content.is_empty() {
1358 Ok(Some(String::new()))
1360 } else if conflict.our_content.is_empty() && conflict.their_content.is_empty() {
1361 Ok(Some(String::new()))
1363 } else {
1364 debug!(
1370 "PureAddition conflict has content on both sides - requires manual resolution"
1371 );
1372 Ok(None)
1373 }
1374 }
1375 ConflictType::ImportMerge => {
1376 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1381 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1382
1383 let all_simple = our_lines.iter().chain(their_lines.iter()).all(|line| {
1385 let trimmed = line.trim();
1386 trimmed.starts_with("import ")
1387 || trimmed.starts_with("from ")
1388 || trimmed.starts_with("use ")
1389 || trimmed.starts_with("#include")
1390 || trimmed.is_empty()
1391 });
1392
1393 if !all_simple {
1394 debug!("ImportMerge contains non-import lines - requires manual resolution");
1395 return Ok(None);
1396 }
1397
1398 let mut all_imports: Vec<&str> = our_lines
1400 .into_iter()
1401 .chain(their_lines)
1402 .filter(|line| !line.trim().is_empty())
1403 .collect();
1404 all_imports.sort();
1405 all_imports.dedup();
1406 Ok(Some(all_imports.join("\n")))
1407 }
1408 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
1409 Ok(None)
1411 }
1412 }
1413 }
1414
1415 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
1417 let lines: Vec<&str> = content.lines().collect();
1418 let mut conflicts = Vec::new();
1419 let mut i = 0;
1420
1421 while i < lines.len() {
1422 if lines[i].starts_with("<<<<<<<") {
1423 let start_line = i + 1;
1425 let mut separator_line = None;
1426 let mut end_line = None;
1427
1428 for (j, line) in lines.iter().enumerate().skip(i + 1) {
1430 if line.starts_with("=======") {
1431 separator_line = Some(j + 1);
1432 } else if line.starts_with(">>>>>>>") {
1433 end_line = Some(j + 1);
1434 break;
1435 }
1436 }
1437
1438 if let (Some(sep), Some(end)) = (separator_line, end_line) {
1439 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
1441 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
1442
1443 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
1444 let their_content = lines[sep..(end - 1)].join("\n");
1445
1446 conflicts.push(ConflictRegion {
1447 start: start_pos,
1448 end: end_pos,
1449 start_line,
1450 end_line: end,
1451 our_content,
1452 their_content,
1453 });
1454
1455 i = end;
1456 } else {
1457 i += 1;
1458 }
1459 } else {
1460 i += 1;
1461 }
1462 }
1463
1464 Ok(conflicts)
1465 }
1466
1467 fn update_stack_entry(
1470 &mut self,
1471 stack_id: Uuid,
1472 entry_id: &Uuid,
1473 _new_branch: &str,
1474 new_commit_hash: &str,
1475 ) -> Result<()> {
1476 debug!(
1477 "Updating entry {} in stack {} with new commit {}",
1478 entry_id, stack_id, new_commit_hash
1479 );
1480
1481 let stack = self
1483 .stack_manager
1484 .get_stack_mut(&stack_id)
1485 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1486
1487 let entry_exists = stack.entries.iter().any(|e| e.id == *entry_id);
1489
1490 if entry_exists {
1491 let old_hash = stack
1492 .entries
1493 .iter()
1494 .find(|e| e.id == *entry_id)
1495 .map(|e| e.commit_hash.clone())
1496 .unwrap();
1497
1498 debug!(
1499 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch)",
1500 entry_id, old_hash, new_commit_hash
1501 );
1502
1503 stack
1506 .update_entry_commit_hash(entry_id, new_commit_hash.to_string())
1507 .map_err(CascadeError::config)?;
1508
1509 debug!(
1512 "Successfully updated entry {} in stack {}",
1513 entry_id, stack_id
1514 );
1515 Ok(())
1516 } else {
1517 Err(CascadeError::config(format!(
1518 "Entry {entry_id} not found in stack {stack_id}"
1519 )))
1520 }
1521 }
1522
1523 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1525 tracing::debug!("Pulling latest changes for branch {}", branch);
1526
1527 match self.git_repo.fetch() {
1529 Ok(_) => {
1530 debug!("Fetch successful");
1531 match self.git_repo.pull(branch) {
1533 Ok(_) => {
1534 tracing::debug!("Pull completed successfully for {}", branch);
1535 Ok(())
1536 }
1537 Err(e) => {
1538 tracing::debug!("Pull failed for {}: {}", branch, e);
1539 Ok(())
1541 }
1542 }
1543 }
1544 Err(e) => {
1545 tracing::debug!("Fetch failed: {}", e);
1546 Ok(())
1548 }
1549 }
1550 }
1551
1552 pub fn is_rebase_in_progress(&self) -> bool {
1554 let git_dir = self.git_repo.path().join(".git");
1556 git_dir.join("REBASE_HEAD").exists()
1557 || git_dir.join("rebase-merge").exists()
1558 || git_dir.join("rebase-apply").exists()
1559 }
1560
1561 pub fn abort_rebase(&self) -> Result<()> {
1563 tracing::debug!("Aborting rebase operation");
1564
1565 let git_dir = self.git_repo.path().join(".git");
1566
1567 if git_dir.join("REBASE_HEAD").exists() {
1569 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1570 CascadeError::Git(git2::Error::from_str(&format!(
1571 "Failed to clean rebase state: {e}"
1572 )))
1573 })?;
1574 }
1575
1576 if git_dir.join("rebase-merge").exists() {
1577 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1578 CascadeError::Git(git2::Error::from_str(&format!(
1579 "Failed to clean rebase-merge: {e}"
1580 )))
1581 })?;
1582 }
1583
1584 if git_dir.join("rebase-apply").exists() {
1585 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1586 CascadeError::Git(git2::Error::from_str(&format!(
1587 "Failed to clean rebase-apply: {e}"
1588 )))
1589 })?;
1590 }
1591
1592 tracing::debug!("Rebase aborted successfully");
1593 Ok(())
1594 }
1595
1596 pub fn continue_rebase(&self) -> Result<()> {
1598 tracing::debug!("Continuing rebase operation");
1599
1600 if self.git_repo.has_conflicts()? {
1602 return Err(CascadeError::branch(
1603 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1604 ));
1605 }
1606
1607 self.git_repo.stage_conflict_resolved_files()?;
1609
1610 tracing::debug!("Rebase continued successfully");
1611 Ok(())
1612 }
1613
1614 fn has_in_progress_cherry_pick(&self) -> Result<bool> {
1616 let git_dir = self.git_repo.path().join(".git");
1617 Ok(git_dir.join("CHERRY_PICK_HEAD").exists())
1618 }
1619
1620 fn handle_in_progress_cherry_pick(&mut self, stack: &Stack) -> Result<RebaseResult> {
1622 use crate::cli::output::Output;
1623
1624 let git_dir = self.git_repo.path().join(".git");
1625
1626 Output::section("Resuming in-progress sync");
1627 println!();
1628 Output::info("Detected unfinished cherry-pick from previous sync");
1629 println!();
1630
1631 if self.git_repo.has_conflicts()? {
1633 let conflicted_files = self.git_repo.get_conflicted_files()?;
1634
1635 let result = RebaseResult {
1636 success: false,
1637 branch_mapping: HashMap::new(),
1638 conflicts: conflicted_files.clone(),
1639 new_commits: Vec::new(),
1640 error: Some(format!(
1641 "Cannot continue: {} file(s) still have unresolved conflicts\n\n\
1642 MANUAL CONFLICT RESOLUTION REQUIRED\n\
1643 =====================================\n\n\
1644 Conflicted files:\n{}\n\n\
1645 Step 1: Analyze conflicts\n\
1646 → Run: ca conflicts\n\
1647 → Shows detailed conflict analysis\n\n\
1648 Step 2: Resolve conflicts in your editor\n\
1649 → Open conflicted files and edit them\n\
1650 → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
1651 → Keep the code you want\n\
1652 → Save the files\n\n\
1653 Step 3: Mark conflicts as resolved\n\
1654 → Run: git add <resolved-files>\n\
1655 → Or: git add -A (to stage all resolved files)\n\n\
1656 Step 4: Complete the sync\n\
1657 → Run: ca sync\n\
1658 → Cascade will continue from where it left off\n\n\
1659 Alternative: Abort and start over\n\
1660 → Run: git cherry-pick --abort\n\
1661 → Then: ca sync (starts fresh)",
1662 conflicted_files.len(),
1663 conflicted_files
1664 .iter()
1665 .map(|f| format!(" - {}", f))
1666 .collect::<Vec<_>>()
1667 .join("\n")
1668 )),
1669 summary: "Sync paused - conflicts need resolution".to_string(),
1670 };
1671
1672 return Ok(result);
1673 }
1674
1675 Output::info("Conflicts resolved, continuing cherry-pick...");
1677
1678 self.git_repo.stage_conflict_resolved_files()?;
1680
1681 let cherry_pick_msg_file = git_dir.join("CHERRY_PICK_MSG");
1683 let commit_message = if cherry_pick_msg_file.exists() {
1684 std::fs::read_to_string(&cherry_pick_msg_file)
1685 .unwrap_or_else(|_| "Resolved conflicts".to_string())
1686 } else {
1687 "Resolved conflicts".to_string()
1688 };
1689
1690 match self.git_repo.commit(&commit_message) {
1691 Ok(_new_commit_id) => {
1692 Output::success("Cherry-pick completed");
1693
1694 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1696 let _ = std::fs::remove_file(git_dir.join("CHERRY_PICK_HEAD"));
1697 }
1698 if cherry_pick_msg_file.exists() {
1699 let _ = std::fs::remove_file(&cherry_pick_msg_file);
1700 }
1701
1702 println!();
1703 Output::info("Continuing with rest of stack...");
1704 println!();
1705
1706 self.rebase_with_force_push(stack)
1709 }
1710 Err(e) => {
1711 let result = RebaseResult {
1712 success: false,
1713 branch_mapping: HashMap::new(),
1714 conflicts: Vec::new(),
1715 new_commits: Vec::new(),
1716 error: Some(format!(
1717 "Failed to complete cherry-pick: {}\n\n\
1718 This usually means:\n\
1719 - Git index is locked (another process accessing repo)\n\
1720 - File permissions issue\n\
1721 - Disk space issue\n\n\
1722 Recovery:\n\
1723 1. Check if another Git operation is running\n\
1724 2. Run 'rm -f .git/index.lock' if stale lock exists\n\
1725 3. Run 'git status' to check repo state\n\
1726 4. Retry 'ca sync' after fixing the issue\n\n\
1727 Or abort and start fresh:\n\
1728 → Run: git cherry-pick --abort\n\
1729 → Then: ca sync",
1730 e
1731 )),
1732 summary: "Failed to complete cherry-pick".to_string(),
1733 };
1734
1735 Ok(result)
1736 }
1737 }
1738 }
1739}
1740
1741impl RebaseResult {
1742 pub fn get_summary(&self) -> String {
1744 if self.success {
1745 format!("✅ {}", self.summary)
1746 } else {
1747 format!(
1748 "❌ Rebase failed: {}",
1749 self.error.as_deref().unwrap_or("Unknown error")
1750 )
1751 }
1752 }
1753
1754 pub fn has_conflicts(&self) -> bool {
1756 !self.conflicts.is_empty()
1757 }
1758
1759 pub fn success_count(&self) -> usize {
1761 self.new_commits.len()
1762 }
1763}
1764
1765#[cfg(test)]
1766mod tests {
1767 use super::*;
1768 use std::path::PathBuf;
1769 use std::process::Command;
1770 use tempfile::TempDir;
1771
1772 #[allow(dead_code)]
1773 fn create_test_repo() -> (TempDir, PathBuf) {
1774 let temp_dir = TempDir::new().unwrap();
1775 let repo_path = temp_dir.path().to_path_buf();
1776
1777 Command::new("git")
1779 .args(["init"])
1780 .current_dir(&repo_path)
1781 .output()
1782 .unwrap();
1783 Command::new("git")
1784 .args(["config", "user.name", "Test"])
1785 .current_dir(&repo_path)
1786 .output()
1787 .unwrap();
1788 Command::new("git")
1789 .args(["config", "user.email", "test@test.com"])
1790 .current_dir(&repo_path)
1791 .output()
1792 .unwrap();
1793
1794 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1796 Command::new("git")
1797 .args(["add", "."])
1798 .current_dir(&repo_path)
1799 .output()
1800 .unwrap();
1801 Command::new("git")
1802 .args(["commit", "-m", "Initial"])
1803 .current_dir(&repo_path)
1804 .output()
1805 .unwrap();
1806
1807 (temp_dir, repo_path)
1808 }
1809
1810 #[test]
1811 fn test_conflict_region_creation() {
1812 let region = ConflictRegion {
1813 start: 0,
1814 end: 50,
1815 start_line: 1,
1816 end_line: 3,
1817 our_content: "function test() {\n return true;\n}".to_string(),
1818 their_content: "function test() {\n return true;\n}".to_string(),
1819 };
1820
1821 assert_eq!(region.start_line, 1);
1822 assert_eq!(region.end_line, 3);
1823 assert!(region.our_content.contains("return true"));
1824 assert!(region.their_content.contains("return true"));
1825 }
1826
1827 #[test]
1828 fn test_rebase_strategies() {
1829 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1830 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1831 }
1832
1833 #[test]
1834 fn test_rebase_options() {
1835 let options = RebaseOptions::default();
1836 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1837 assert!(!options.interactive);
1838 assert!(options.auto_resolve);
1839 assert_eq!(options.max_retries, 3);
1840 }
1841
1842 #[test]
1843 fn test_cleanup_guard_tracks_branches() {
1844 let mut guard = TempBranchCleanupGuard::new();
1845 assert!(guard.branches.is_empty());
1846
1847 guard.add_branch("test-branch-1".to_string());
1848 guard.add_branch("test-branch-2".to_string());
1849
1850 assert_eq!(guard.branches.len(), 2);
1851 assert_eq!(guard.branches[0], "test-branch-1");
1852 assert_eq!(guard.branches[1], "test-branch-2");
1853 }
1854
1855 #[test]
1856 fn test_cleanup_guard_prevents_double_cleanup() {
1857 use std::process::Command;
1858 use tempfile::TempDir;
1859
1860 let temp_dir = TempDir::new().unwrap();
1862 let repo_path = temp_dir.path();
1863
1864 Command::new("git")
1865 .args(["init"])
1866 .current_dir(repo_path)
1867 .output()
1868 .unwrap();
1869
1870 Command::new("git")
1871 .args(["config", "user.name", "Test"])
1872 .current_dir(repo_path)
1873 .output()
1874 .unwrap();
1875
1876 Command::new("git")
1877 .args(["config", "user.email", "test@test.com"])
1878 .current_dir(repo_path)
1879 .output()
1880 .unwrap();
1881
1882 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1884 Command::new("git")
1885 .args(["add", "."])
1886 .current_dir(repo_path)
1887 .output()
1888 .unwrap();
1889 Command::new("git")
1890 .args(["commit", "-m", "initial"])
1891 .current_dir(repo_path)
1892 .output()
1893 .unwrap();
1894
1895 let git_repo = GitRepository::open(repo_path).unwrap();
1896
1897 git_repo.create_branch("test-temp", None).unwrap();
1899
1900 let mut guard = TempBranchCleanupGuard::new();
1901 guard.add_branch("test-temp".to_string());
1902
1903 guard.cleanup(&git_repo);
1905 assert!(guard.cleaned);
1906
1907 guard.cleanup(&git_repo);
1909 assert!(guard.cleaned);
1910 }
1911
1912 #[test]
1913 fn test_rebase_result() {
1914 let result = RebaseResult {
1915 success: true,
1916 branch_mapping: std::collections::HashMap::new(),
1917 conflicts: vec!["abc123".to_string()],
1918 new_commits: vec!["def456".to_string()],
1919 error: None,
1920 summary: "Test summary".to_string(),
1921 };
1922
1923 assert!(result.success);
1924 assert!(result.has_conflicts());
1925 assert_eq!(result.success_count(), 1);
1926 }
1927}