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, info, warn};
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 info!("๐งน 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 warn!("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 warn!(
133 "โ ๏ธ {} temporary branches were not cleaned up: {}",
134 self.branches.len(),
135 self.branches.join(", ")
136 );
137 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));
213
214 let mut result = RebaseResult {
215 success: true,
216 branch_mapping: HashMap::new(),
217 conflicts: Vec::new(),
218 new_commits: Vec::new(),
219 error: None,
220 summary: String::new(),
221 };
222
223 let target_base = self
224 .options
225 .target_base
226 .as_ref()
227 .unwrap_or(&stack.base_branch)
228 .clone(); let original_branch = self
234 .options
235 .original_working_branch
236 .clone()
237 .or_else(|| self.git_repo.get_current_branch().ok());
238
239 if let Some(ref orig) = original_branch {
242 if orig == &target_base {
243 debug!(
244 "Original working branch is base branch '{}' - will skip working branch update",
245 orig
246 );
247 }
248 }
249
250 if !self.options.skip_pull.unwrap_or(false) {
253 if let Err(e) = self.pull_latest_changes(&target_base) {
254 Output::warning(format!("Could not pull latest changes: {}", e));
255 }
256 }
257
258 if let Err(e) = self.git_repo.reset_to_head() {
260 Output::warning(format!("Could not reset working directory: {}", e));
261 }
262
263 let mut current_base = target_base.clone();
264 let entry_count = stack.entries.len();
265 let mut temp_branches: Vec<String> = Vec::new(); let mut branches_to_push: Vec<(String, String)> = Vec::new(); println!(); let plural = if entry_count == 1 { "entry" } else { "entries" };
270 println!("Rebasing {} {}...", entry_count, plural);
271
272 for (index, entry) in stack.entries.iter().enumerate() {
274 let original_branch = &entry.branch;
275
276 let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
279 temp_branches.push(temp_branch.clone()); self.git_repo
281 .create_branch(&temp_branch, Some(¤t_base))?;
282 self.git_repo.checkout_branch(&temp_branch)?;
283
284 match self.cherry_pick_commit(&entry.commit_hash) {
286 Ok(new_commit_hash) => {
287 result.new_commits.push(new_commit_hash.clone());
288
289 let rebased_commit_id = self.git_repo.get_head_commit()?.id().to_string();
291
292 self.git_repo
295 .update_branch_to_commit(original_branch, &rebased_commit_id)?;
296
297 let tree_char = if index + 1 == entry_count {
299 "โโ"
300 } else {
301 "โโ"
302 };
303
304 if let Some(pr_num) = &entry.pull_request_id {
305 println!(" {} {} (PR #{})", tree_char, original_branch, pr_num);
306 branches_to_push.push((original_branch.clone(), pr_num.clone()));
307 } else {
308 println!(" {} {} (not submitted)", tree_char, original_branch);
309 }
310
311 result
312 .branch_mapping
313 .insert(original_branch.clone(), original_branch.clone());
314
315 self.update_stack_entry(
317 stack.id,
318 &entry.id,
319 original_branch,
320 &rebased_commit_id,
321 )?;
322
323 current_base = original_branch.clone();
325 }
326 Err(e) => {
327 println!(); Output::error(format!("Conflict in {}: {}", &entry.commit_hash[..8], e));
329 result.conflicts.push(entry.commit_hash.clone());
330
331 if !self.options.auto_resolve {
332 result.success = false;
333 result.error = Some(format!(
334 "Conflict in {}: {}\n\n\
335 MANUAL CONFLICT RESOLUTION REQUIRED\n\
336 =====================================\n\n\
337 Step 1: Analyze conflicts\n\
338 โ Run: ca conflicts\n\
339 โ This shows which conflicts are in which files\n\n\
340 Step 2: Resolve conflicts in your editor\n\
341 โ Open conflicted files and edit them\n\
342 โ Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
343 โ Keep the code you want\n\
344 โ Save the files\n\n\
345 Step 3: Mark conflicts as resolved\n\
346 โ Run: git add <resolved-files>\n\
347 โ Or: git add -A (to stage all resolved files)\n\n\
348 Step 4: Complete the sync\n\
349 โ Run: ca sync\n\
350 โ Cascade will detect resolved conflicts and continue\n\n\
351 Alternative: Abort and start over\n\
352 โ Run: git cherry-pick --abort\n\
353 โ Then: ca sync (starts fresh)\n\n\
354 TIP: Enable auto-resolution for simple conflicts:\n\
355 โ Run: ca sync --auto-resolve\n\
356 โ Only complex conflicts will require manual resolution",
357 entry.commit_hash, e
358 ));
359 break;
360 }
361
362 match self.auto_resolve_conflicts(&entry.commit_hash) {
364 Ok(fully_resolved) => {
365 if !fully_resolved {
366 result.success = false;
367 result.error = Some(format!(
368 "Could not auto-resolve all conflicts in {}\n\n\
369 MANUAL CONFLICT RESOLUTION REQUIRED\n\
370 =====================================\n\n\
371 Some conflicts are too complex for auto-resolution.\n\n\
372 Step 1: Analyze remaining conflicts\n\
373 โ Run: ca conflicts\n\
374 โ Shows which files still have conflicts\n\
375 โ Use --detailed flag for more info\n\n\
376 Step 2: Resolve conflicts in your editor\n\
377 โ Open conflicted files (marked with โ in ca conflicts output)\n\
378 โ Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
379 โ Keep the code you want\n\
380 โ Save the files\n\n\
381 Step 3: Mark conflicts as resolved\n\
382 โ Run: git add <resolved-files>\n\
383 โ Or: git add -A (to stage all resolved files)\n\n\
384 Step 4: Complete the sync\n\
385 โ Run: ca sync\n\
386 โ Cascade will continue from where it left off\n\n\
387 Alternative: Abort and start over\n\
388 โ Run: git cherry-pick --abort\n\
389 โ Then: ca sync (starts fresh)\n\n\
390 BACKUP: If auto-resolution was wrong\n\
391 โ Check for .cascade-backup files in your repo\n\
392 โ These contain the original file content before auto-resolution",
393 &entry.commit_hash[..8]
394 ));
395 break;
396 }
397
398 let commit_message =
400 format!("Auto-resolved conflicts in {}", &entry.commit_hash[..8]);
401
402 warn!("AUTO-RESOLVE DEBUG: About to commit. Checking staged files...");
404 if let Ok(status) = std::process::Command::new("git")
405 .args(["diff", "--cached", "--stat"])
406 .current_dir(self.git_repo.path())
407 .output()
408 {
409 let stat_output = String::from_utf8_lossy(&status.stdout);
410 warn!("AUTO-RESOLVE DEBUG: Staged changes:\n{}", stat_output);
411 }
412
413 match self.git_repo.commit(&commit_message) {
414 Ok(new_commit_id) => {
415 warn!(
416 "AUTO-RESOLVE DEBUG: Created commit {} with message '{}'",
417 &new_commit_id[..8],
418 commit_message
419 );
420
421 if let Ok(show) = std::process::Command::new("git")
423 .args(["show", "--stat", &new_commit_id])
424 .current_dir(self.git_repo.path())
425 .output()
426 {
427 let show_output = String::from_utf8_lossy(&show.stdout);
428 warn!("AUTO-RESOLVE DEBUG: Commit contents:\n{}", show_output);
429 }
430
431 Output::success("Auto-resolved conflicts");
432 result.new_commits.push(new_commit_id.clone());
433 let rebased_commit_id = new_commit_id;
434
435 self.git_repo.update_branch_to_commit(
437 original_branch,
438 &rebased_commit_id,
439 )?;
440
441 let tree_char = if index + 1 == entry_count {
443 "โโ"
444 } else {
445 "โโ"
446 };
447
448 if let Some(pr_num) = &entry.pull_request_id {
449 println!(
450 " {} {} (PR #{})",
451 tree_char, original_branch, pr_num
452 );
453 branches_to_push
454 .push((original_branch.clone(), pr_num.clone()));
455 } else {
456 println!(
457 " {} {} (not submitted)",
458 tree_char, original_branch
459 );
460 }
461
462 result
463 .branch_mapping
464 .insert(original_branch.clone(), original_branch.clone());
465
466 self.update_stack_entry(
468 stack.id,
469 &entry.id,
470 original_branch,
471 &rebased_commit_id,
472 )?;
473
474 current_base = original_branch.clone();
476 }
477 Err(commit_err) => {
478 result.success = false;
479 result.error = Some(format!(
480 "Could not commit auto-resolved conflicts: {}\n\n\
481 This usually means:\n\
482 - Git index is locked (another process accessing repo)\n\
483 - File permissions issue\n\
484 - Disk space issue\n\n\
485 Recovery:\n\
486 1. Check if another Git operation is running\n\
487 2. Run 'rm -f .git/index.lock' if stale lock exists\n\
488 3. Run 'git status' to check repo state\n\
489 4. Retry 'ca sync' after fixing the issue",
490 commit_err
491 ));
492 break;
493 }
494 }
495 }
496 Err(resolve_err) => {
497 result.success = false;
498 result.error = Some(format!(
499 "Could not resolve conflicts: {}\n\n\
500 Recovery:\n\
501 1. Check repo state: 'git status'\n\
502 2. If files are staged, commit or reset them: 'git reset --hard HEAD'\n\
503 3. Remove any lock files: 'rm -f .git/index.lock'\n\
504 4. Retry 'ca sync'",
505 resolve_err
506 ));
507 break;
508 }
509 }
510 }
511 }
512 }
513
514 if !temp_branches.is_empty() {
517 if let Err(e) = self.git_repo.checkout_branch_unsafe(&target_base) {
520 Output::warning(format!("Could not checkout base for cleanup: {}", e));
521 } else {
524 for temp_branch in &temp_branches {
526 if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
527 debug!("Could not delete temp branch {}: {}", temp_branch, e);
528 }
529 }
530 }
531 }
532
533 let pushed_count = branches_to_push.len();
536 let skipped_count = entry_count - pushed_count;
537
538 if !branches_to_push.is_empty() {
539 println!(); println!(
541 "Pushing {} branch{} to remote...",
542 pushed_count,
543 if pushed_count == 1 { "" } else { "es" }
544 );
545
546 for (branch_name, _pr_num) in &branches_to_push {
547 match self.git_repo.force_push_single_branch_auto(branch_name) {
548 Ok(_) => {
549 debug!("Pushed {} successfully", branch_name);
550 }
551 Err(e) => {
552 Output::warning(format!("Could not push '{}': {}", branch_name, e));
553 }
555 }
556 }
557 }
558
559 if let Some(ref orig_branch) = original_branch {
562 if orig_branch != &target_base {
564 if let Some(last_entry) = stack.entries.last() {
566 let top_branch = &last_entry.branch;
567
568 if let Ok(top_commit) = self.git_repo.get_branch_head(top_branch) {
570 debug!(
571 "Updating working branch '{}' to match top of stack ({})",
572 orig_branch,
573 &top_commit[..8]
574 );
575
576 if let Err(e) = self
577 .git_repo
578 .update_branch_to_commit(orig_branch, &top_commit)
579 {
580 Output::warning(format!(
581 "Could not update working branch '{}' to top of stack: {}",
582 orig_branch, e
583 ));
584 }
585 }
586 }
587
588 if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
591 debug!(
592 "Could not return to original branch '{}': {}",
593 orig_branch, e
594 );
595 }
597 } else {
598 debug!(
601 "Skipping working branch update - user was on base branch '{}'",
602 orig_branch
603 );
604 if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
605 debug!("Could not return to base branch '{}': {}", orig_branch, e);
606 }
607 }
608 }
609
610 result.summary = if pushed_count > 0 {
612 let pr_plural = if pushed_count == 1 { "" } else { "s" };
613 let entry_plural = if entry_count == 1 { "entry" } else { "entries" };
614
615 if skipped_count > 0 {
616 format!(
617 "{} {} rebased ({} PR{} updated, {} not yet submitted)",
618 entry_count, entry_plural, pushed_count, pr_plural, skipped_count
619 )
620 } else {
621 format!(
622 "{} {} rebased ({} PR{} updated)",
623 entry_count, entry_plural, pushed_count, pr_plural
624 )
625 }
626 } else {
627 let plural = if entry_count == 1 { "entry" } else { "entries" };
628 format!("{} {} rebased (no PRs to update yet)", entry_count, plural)
629 };
630
631 println!(); if result.success {
634 Output::success(&result.summary);
635 } else {
636 Output::error(format!("Rebase failed: {:?}", result.error));
637 }
638
639 self.stack_manager.save_to_disk()?;
641
642 Ok(result)
643 }
644
645 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
647 info!("Starting interactive rebase for stack '{}'", stack.name);
648
649 let mut result = RebaseResult {
650 success: true,
651 branch_mapping: HashMap::new(),
652 conflicts: Vec::new(),
653 new_commits: Vec::new(),
654 error: None,
655 summary: String::new(),
656 };
657
658 println!("Interactive Rebase for Stack: {}", stack.name);
659 println!(" Base branch: {}", stack.base_branch);
660 println!(" Entries: {}", stack.entries.len());
661
662 if self.options.interactive {
663 println!("\nChoose action for each commit:");
664 println!(" (p)ick - apply the commit");
665 println!(" (s)kip - skip this commit");
666 println!(" (e)dit - edit the commit message");
667 println!(" (q)uit - abort the rebase");
668 }
669
670 for entry in &stack.entries {
673 println!(
674 " {} {} - {}",
675 entry.short_hash(),
676 entry.branch,
677 entry.short_message(50)
678 );
679
680 match self.cherry_pick_commit(&entry.commit_hash) {
682 Ok(new_commit) => result.new_commits.push(new_commit),
683 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
684 }
685 }
686
687 result.summary = format!(
688 "Interactive rebase processed {} commits",
689 stack.entries.len()
690 );
691 Ok(result)
692 }
693
694 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
696 let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
698
699 if let Ok(staged_files) = self.git_repo.get_staged_files() {
701 if !staged_files.is_empty() {
702 let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
704 let _ = self.git_repo.commit_staged_changes(&cleanup_message);
705 }
706 }
707
708 Ok(new_commit_hash)
709 }
710
711 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
713 warn!("=== AUTO-RESOLVE DEBUG: Starting for commit {} ===", commit_hash);
714
715 if !self.git_repo.has_conflicts()? {
717 warn!("AUTO-RESOLVE DEBUG: No conflicts detected by Git");
718 return Ok(true);
719 }
720
721 let conflicted_files = self.git_repo.get_conflicted_files()?;
722
723 if conflicted_files.is_empty() {
724 warn!("AUTO-RESOLVE DEBUG: Conflicted files list is empty");
725 return Ok(true);
726 }
727
728 warn!(
729 "AUTO-RESOLVE DEBUG: Found conflicts in {} files: {:?}",
730 conflicted_files.len(),
731 conflicted_files
732 );
733
734 let analysis = self
736 .conflict_analyzer
737 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
738
739 info!(
740 "๐ Conflict analysis: {} total conflicts, {} auto-resolvable",
741 analysis.total_conflicts, analysis.auto_resolvable_count
742 );
743
744 for recommendation in &analysis.recommendations {
746 info!("๐ก {}", recommendation);
747 }
748
749 let mut resolved_count = 0;
750 let mut resolved_files = Vec::new(); let mut failed_files = Vec::new();
752
753 for file_analysis in &analysis.files {
754 warn!(
755 "AUTO-RESOLVE DEBUG: Processing file: {} (auto_resolvable: {}, conflicts: {})",
756 file_analysis.file_path,
757 file_analysis.auto_resolvable,
758 file_analysis.conflicts.len()
759 );
760
761 if file_analysis.auto_resolvable {
762 match self.resolve_file_conflicts_enhanced(
763 &file_analysis.file_path,
764 &file_analysis.conflicts,
765 ) {
766 Ok(ConflictResolution::Resolved) => {
767 resolved_count += 1;
768 resolved_files.push(file_analysis.file_path.clone());
769 warn!(
770 "AUTO-RESOLVE DEBUG: Successfully resolved {}",
771 file_analysis.file_path
772 );
773 }
774 Ok(ConflictResolution::TooComplex) => {
775 warn!(
776 "AUTO-RESOLVE DEBUG: {} too complex for auto-resolution",
777 file_analysis.file_path
778 );
779 failed_files.push(file_analysis.file_path.clone());
780 }
781 Err(e) => {
782 warn!(
783 "AUTO-RESOLVE DEBUG: Failed to resolve {}: {}",
784 file_analysis.file_path, e
785 );
786 failed_files.push(file_analysis.file_path.clone());
787 }
788 }
789 } else {
790 failed_files.push(file_analysis.file_path.clone());
791 warn!(
792 "AUTO-RESOLVE DEBUG: {} requires manual resolution ({} conflicts)",
793 file_analysis.file_path,
794 file_analysis.conflicts.len()
795 );
796 }
797 }
798
799 if resolved_count > 0 {
800 warn!(
801 "AUTO-RESOLVE DEBUG: Resolved {}/{} files",
802 resolved_count,
803 conflicted_files.len()
804 );
805 warn!("AUTO-RESOLVE DEBUG: Resolved files: {:?}", resolved_files);
806
807 let file_paths: Vec<&str> = resolved_files.iter().map(|s| s.as_str()).collect();
810 warn!("AUTO-RESOLVE DEBUG: Staging {} files", file_paths.len());
811 self.git_repo.stage_files(&file_paths)?;
812 warn!("AUTO-RESOLVE DEBUG: Files staged successfully");
813 } else {
814 warn!("AUTO-RESOLVE DEBUG: No files were resolved (resolved_count = 0)");
815 }
816
817 let all_resolved = failed_files.is_empty();
819
820 warn!(
821 "AUTO-RESOLVE DEBUG: all_resolved = {}, failed_files = {:?}",
822 all_resolved, failed_files
823 );
824
825 if !all_resolved {
826 warn!(
827 "AUTO-RESOLVE DEBUG: {} files still need manual resolution",
828 failed_files.len()
829 );
830 }
831
832 warn!("AUTO-RESOLVE DEBUG: Returning all_resolved = {}", all_resolved);
833 Ok(all_resolved)
834 }
835
836 fn resolve_file_conflicts_enhanced(
838 &self,
839 file_path: &str,
840 conflicts: &[crate::git::ConflictRegion],
841 ) -> Result<ConflictResolution> {
842 let repo_path = self.git_repo.path();
843 let full_path = repo_path.join(file_path);
844
845 let mut content = std::fs::read_to_string(&full_path)
847 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
848
849 if conflicts.is_empty() {
850 return Ok(ConflictResolution::Resolved);
851 }
852
853 info!(
854 "Resolving {} conflicts in {} using enhanced analysis",
855 conflicts.len(),
856 file_path
857 );
858
859 let mut any_resolved = false;
860
861 for conflict in conflicts.iter().rev() {
863 match self.resolve_single_conflict_enhanced(conflict) {
864 Ok(Some(resolution)) => {
865 let before = &content[..conflict.start_pos];
867 let after = &content[conflict.end_pos..];
868 content = format!("{before}{resolution}{after}");
869 any_resolved = true;
870 debug!(
871 "โ
Resolved {} conflict at lines {}-{} in {}",
872 format!("{:?}", conflict.conflict_type).to_lowercase(),
873 conflict.start_line,
874 conflict.end_line,
875 file_path
876 );
877 }
878 Ok(None) => {
879 debug!(
880 "โ ๏ธ {} conflict at lines {}-{} in {} requires manual resolution",
881 format!("{:?}", conflict.conflict_type).to_lowercase(),
882 conflict.start_line,
883 conflict.end_line,
884 file_path
885 );
886 return Ok(ConflictResolution::TooComplex);
887 }
888 Err(e) => {
889 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
890 return Ok(ConflictResolution::TooComplex);
891 }
892 }
893 }
894
895 if any_resolved {
896 let remaining_conflicts = self.parse_conflict_markers(&content)?;
898
899 if remaining_conflicts.is_empty() {
900 warn!(
901 "AUTO-RESOLVE DEBUG: All conflicts resolved in {}, content length: {} bytes",
902 file_path,
903 content.len()
904 );
905
906 if content.trim().is_empty() {
908 warn!(
909 "AUTO-RESOLVE DEBUG: SAFETY CHECK TRIGGERED! Resolved content for {} is empty! Aborting.",
910 file_path
911 );
912 return Ok(ConflictResolution::TooComplex);
913 }
914
915 let backup_path = full_path.with_extension("cascade-backup");
917 if let Ok(original_content) = std::fs::read_to_string(&full_path) {
918 warn!(
919 "AUTO-RESOLVE DEBUG: Backup for {} (original: {} bytes, resolved: {} bytes)",
920 file_path,
921 original_content.len(),
922 content.len()
923 );
924 let _ = std::fs::write(&backup_path, original_content);
925 }
926
927 crate::utils::atomic_file::write_string(&full_path, &content)?;
929
930 warn!(
931 "AUTO-RESOLVE DEBUG: Wrote {} bytes to {}",
932 content.len(),
933 file_path
934 );
935 return Ok(ConflictResolution::Resolved);
936 } else {
937 info!(
938 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
939 file_path,
940 remaining_conflicts.len()
941 );
942 }
943 }
944
945 Ok(ConflictResolution::TooComplex)
946 }
947
948 #[allow(dead_code)]
950 fn count_whitespace_consistency(content: &str) -> usize {
951 let mut inconsistencies = 0;
952 let lines: Vec<&str> = content.lines().collect();
953
954 for line in &lines {
955 if line.contains('\t') && line.contains(' ') {
957 inconsistencies += 1;
958 }
959 }
960
961 lines.len().saturating_sub(inconsistencies)
963 }
964
965 fn resolve_single_conflict_enhanced(
967 &self,
968 conflict: &crate::git::ConflictRegion,
969 ) -> Result<Option<String>> {
970 debug!(
971 "Resolving {} conflict in {} (lines {}-{})",
972 format!("{:?}", conflict.conflict_type).to_lowercase(),
973 conflict.file_path,
974 conflict.start_line,
975 conflict.end_line
976 );
977
978 use crate::git::ConflictType;
979
980 match conflict.conflict_type {
981 ConflictType::Whitespace => {
982 let our_normalized = conflict
985 .our_content
986 .split_whitespace()
987 .collect::<Vec<_>>()
988 .join(" ");
989 let their_normalized = conflict
990 .their_content
991 .split_whitespace()
992 .collect::<Vec<_>>()
993 .join(" ");
994
995 if our_normalized == their_normalized {
996 Ok(Some(conflict.their_content.clone()))
1001 } else {
1002 debug!(
1004 "Whitespace conflict has content differences - requires manual resolution"
1005 );
1006 Ok(None)
1007 }
1008 }
1009 ConflictType::LineEnding => {
1010 let normalized = conflict
1012 .our_content
1013 .replace("\r\n", "\n")
1014 .replace('\r', "\n");
1015 Ok(Some(normalized))
1016 }
1017 ConflictType::PureAddition => {
1018 if conflict.our_content.is_empty() && !conflict.their_content.is_empty() {
1022 Ok(Some(conflict.their_content.clone()))
1024 } else if conflict.their_content.is_empty() && !conflict.our_content.is_empty() {
1025 Ok(Some(String::new()))
1027 } else if conflict.our_content.is_empty() && conflict.their_content.is_empty() {
1028 Ok(Some(String::new()))
1030 } else {
1031 debug!(
1037 "PureAddition conflict has content on both sides - requires manual resolution"
1038 );
1039 Ok(None)
1040 }
1041 }
1042 ConflictType::ImportMerge => {
1043 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1048 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1049
1050 let all_simple = our_lines.iter().chain(their_lines.iter()).all(|line| {
1052 let trimmed = line.trim();
1053 trimmed.starts_with("import ")
1054 || trimmed.starts_with("from ")
1055 || trimmed.starts_with("use ")
1056 || trimmed.starts_with("#include")
1057 || trimmed.is_empty()
1058 });
1059
1060 if !all_simple {
1061 debug!("ImportMerge contains non-import lines - requires manual resolution");
1062 return Ok(None);
1063 }
1064
1065 let mut all_imports: Vec<&str> = our_lines
1067 .into_iter()
1068 .chain(their_lines)
1069 .filter(|line| !line.trim().is_empty())
1070 .collect();
1071 all_imports.sort();
1072 all_imports.dedup();
1073 Ok(Some(all_imports.join("\n")))
1074 }
1075 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
1076 Ok(None)
1078 }
1079 }
1080 }
1081
1082 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
1084 let lines: Vec<&str> = content.lines().collect();
1085 let mut conflicts = Vec::new();
1086 let mut i = 0;
1087
1088 while i < lines.len() {
1089 if lines[i].starts_with("<<<<<<<") {
1090 let start_line = i + 1;
1092 let mut separator_line = None;
1093 let mut end_line = None;
1094
1095 for (j, line) in lines.iter().enumerate().skip(i + 1) {
1097 if line.starts_with("=======") {
1098 separator_line = Some(j + 1);
1099 } else if line.starts_with(">>>>>>>") {
1100 end_line = Some(j + 1);
1101 break;
1102 }
1103 }
1104
1105 if let (Some(sep), Some(end)) = (separator_line, end_line) {
1106 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
1108 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
1109
1110 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
1111 let their_content = lines[sep..(end - 1)].join("\n");
1112
1113 conflicts.push(ConflictRegion {
1114 start: start_pos,
1115 end: end_pos,
1116 start_line,
1117 end_line: end,
1118 our_content,
1119 their_content,
1120 });
1121
1122 i = end;
1123 } else {
1124 i += 1;
1125 }
1126 } else {
1127 i += 1;
1128 }
1129 }
1130
1131 Ok(conflicts)
1132 }
1133
1134 fn resolve_single_conflict(
1136 &self,
1137 conflict: &ConflictRegion,
1138 file_path: &str,
1139 ) -> Result<Option<String>> {
1140 debug!(
1141 "Analyzing conflict in {} (lines {}-{})",
1142 file_path, conflict.start_line, conflict.end_line
1143 );
1144
1145 if let Some(resolved) = self.resolve_whitespace_conflict(conflict)? {
1147 debug!("Resolved as whitespace-only conflict");
1148 return Ok(Some(resolved));
1149 }
1150
1151 if let Some(resolved) = self.resolve_line_ending_conflict(conflict)? {
1153 debug!("Resolved as line ending conflict");
1154 return Ok(Some(resolved));
1155 }
1156
1157 if let Some(resolved) = self.resolve_addition_conflict(conflict)? {
1159 debug!("Resolved as pure addition conflict");
1160 return Ok(Some(resolved));
1161 }
1162
1163 if let Some(resolved) = self.resolve_import_conflict(conflict, file_path)? {
1165 debug!("Resolved as import reordering conflict");
1166 return Ok(Some(resolved));
1167 }
1168
1169 Ok(None)
1171 }
1172
1173 fn resolve_whitespace_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
1175 let our_normalized = self.normalize_whitespace(&conflict.our_content);
1176 let their_normalized = self.normalize_whitespace(&conflict.their_content);
1177
1178 if our_normalized == their_normalized {
1179 let resolved =
1181 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
1182 conflict.our_content.clone()
1183 } else {
1184 conflict.their_content.clone()
1185 };
1186
1187 return Ok(Some(resolved));
1188 }
1189
1190 Ok(None)
1191 }
1192
1193 fn resolve_line_ending_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
1195 let our_normalized = conflict
1196 .our_content
1197 .replace("\r\n", "\n")
1198 .replace('\r', "\n");
1199 let their_normalized = conflict
1200 .their_content
1201 .replace("\r\n", "\n")
1202 .replace('\r', "\n");
1203
1204 if our_normalized == their_normalized {
1205 return Ok(Some(our_normalized));
1207 }
1208
1209 Ok(None)
1210 }
1211
1212 fn resolve_addition_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
1214 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1215 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1216
1217 if our_lines.is_empty() {
1219 return Ok(Some(conflict.their_content.clone()));
1220 }
1221 if their_lines.is_empty() {
1222 return Ok(Some(conflict.our_content.clone()));
1223 }
1224
1225 let mut merged_lines = Vec::new();
1227 let mut our_idx = 0;
1228 let mut their_idx = 0;
1229
1230 while our_idx < our_lines.len() || their_idx < their_lines.len() {
1231 if our_idx >= our_lines.len() {
1232 merged_lines.extend_from_slice(&their_lines[their_idx..]);
1234 break;
1235 } else if their_idx >= their_lines.len() {
1236 merged_lines.extend_from_slice(&our_lines[our_idx..]);
1238 break;
1239 } else if our_lines[our_idx] == their_lines[their_idx] {
1240 merged_lines.push(our_lines[our_idx]);
1242 our_idx += 1;
1243 their_idx += 1;
1244 } else {
1245 return Ok(None);
1247 }
1248 }
1249
1250 Ok(Some(merged_lines.join("\n")))
1251 }
1252
1253 fn resolve_import_conflict(
1255 &self,
1256 conflict: &ConflictRegion,
1257 file_path: &str,
1258 ) -> Result<Option<String>> {
1259 let is_import_file = file_path.ends_with(".rs")
1261 || file_path.ends_with(".py")
1262 || file_path.ends_with(".js")
1263 || file_path.ends_with(".ts")
1264 || file_path.ends_with(".go")
1265 || file_path.ends_with(".java")
1266 || file_path.ends_with(".swift")
1267 || file_path.ends_with(".kt")
1268 || file_path.ends_with(".cs");
1269
1270 if !is_import_file {
1271 return Ok(None);
1272 }
1273
1274 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1275 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1276
1277 let our_imports = our_lines
1279 .iter()
1280 .all(|line| self.is_import_line(line, file_path));
1281 let their_imports = their_lines
1282 .iter()
1283 .all(|line| self.is_import_line(line, file_path));
1284
1285 if our_imports && their_imports {
1286 let mut all_imports: Vec<&str> = our_lines.into_iter().chain(their_lines).collect();
1288 all_imports.sort();
1289 all_imports.dedup();
1290
1291 return Ok(Some(all_imports.join("\n")));
1292 }
1293
1294 Ok(None)
1295 }
1296
1297 fn is_import_line(&self, line: &str, file_path: &str) -> bool {
1299 let trimmed = line.trim();
1300
1301 if trimmed.is_empty() {
1302 return true; }
1304
1305 if file_path.ends_with(".rs") {
1306 return trimmed.starts_with("use ") || trimmed.starts_with("extern crate");
1307 } else if file_path.ends_with(".py") {
1308 return trimmed.starts_with("import ") || trimmed.starts_with("from ");
1309 } else if file_path.ends_with(".js") || file_path.ends_with(".ts") {
1310 return trimmed.starts_with("import ")
1311 || trimmed.starts_with("const ")
1312 || trimmed.starts_with("require(");
1313 } else if file_path.ends_with(".go") {
1314 return trimmed.starts_with("import ") || trimmed == "import (" || trimmed == ")";
1315 } else if file_path.ends_with(".java") {
1316 return trimmed.starts_with("import ");
1317 } else if file_path.ends_with(".swift") {
1318 return trimmed.starts_with("import ") || trimmed.starts_with("@testable import ");
1319 } else if file_path.ends_with(".kt") {
1320 return trimmed.starts_with("import ") || trimmed.starts_with("@file:");
1321 } else if file_path.ends_with(".cs") {
1322 return trimmed.starts_with("using ") || trimmed.starts_with("extern alias ");
1323 }
1324
1325 false
1326 }
1327
1328 fn normalize_whitespace(&self, content: &str) -> String {
1330 content
1331 .lines()
1332 .map(|line| line.trim())
1333 .filter(|line| !line.is_empty())
1334 .collect::<Vec<_>>()
1335 .join("\n")
1336 }
1337
1338 fn update_stack_entry(
1341 &mut self,
1342 stack_id: Uuid,
1343 entry_id: &Uuid,
1344 _new_branch: &str,
1345 new_commit_hash: &str,
1346 ) -> Result<()> {
1347 debug!(
1348 "Updating entry {} in stack {} with new commit {}",
1349 entry_id, stack_id, new_commit_hash
1350 );
1351
1352 let stack = self
1354 .stack_manager
1355 .get_stack_mut(&stack_id)
1356 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1357
1358 if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == *entry_id) {
1360 debug!(
1361 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch '{}')",
1362 entry_id, entry.commit_hash, new_commit_hash, entry.branch
1363 );
1364
1365 entry.commit_hash = new_commit_hash.to_string();
1368
1369 debug!(
1372 "Successfully updated entry {} in stack {}",
1373 entry_id, stack_id
1374 );
1375 Ok(())
1376 } else {
1377 Err(CascadeError::config(format!(
1378 "Entry {entry_id} not found in stack {stack_id}"
1379 )))
1380 }
1381 }
1382
1383 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1385 info!("Pulling latest changes for branch {}", branch);
1386
1387 match self.git_repo.fetch() {
1389 Ok(_) => {
1390 debug!("Fetch successful");
1391 match self.git_repo.pull(branch) {
1393 Ok(_) => {
1394 info!("Pull completed successfully for {}", branch);
1395 Ok(())
1396 }
1397 Err(e) => {
1398 warn!("Pull failed for {}: {}", branch, e);
1399 Ok(())
1401 }
1402 }
1403 }
1404 Err(e) => {
1405 warn!("Fetch failed: {}", e);
1406 Ok(())
1408 }
1409 }
1410 }
1411
1412 pub fn is_rebase_in_progress(&self) -> bool {
1414 let git_dir = self.git_repo.path().join(".git");
1416 git_dir.join("REBASE_HEAD").exists()
1417 || git_dir.join("rebase-merge").exists()
1418 || git_dir.join("rebase-apply").exists()
1419 }
1420
1421 pub fn abort_rebase(&self) -> Result<()> {
1423 info!("Aborting rebase operation");
1424
1425 let git_dir = self.git_repo.path().join(".git");
1426
1427 if git_dir.join("REBASE_HEAD").exists() {
1429 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1430 CascadeError::Git(git2::Error::from_str(&format!(
1431 "Failed to clean rebase state: {e}"
1432 )))
1433 })?;
1434 }
1435
1436 if git_dir.join("rebase-merge").exists() {
1437 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1438 CascadeError::Git(git2::Error::from_str(&format!(
1439 "Failed to clean rebase-merge: {e}"
1440 )))
1441 })?;
1442 }
1443
1444 if git_dir.join("rebase-apply").exists() {
1445 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1446 CascadeError::Git(git2::Error::from_str(&format!(
1447 "Failed to clean rebase-apply: {e}"
1448 )))
1449 })?;
1450 }
1451
1452 info!("Rebase aborted successfully");
1453 Ok(())
1454 }
1455
1456 pub fn continue_rebase(&self) -> Result<()> {
1458 info!("Continuing rebase operation");
1459
1460 if self.git_repo.has_conflicts()? {
1462 return Err(CascadeError::branch(
1463 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1464 ));
1465 }
1466
1467 self.git_repo.stage_conflict_resolved_files()?;
1469
1470 info!("Rebase continued successfully");
1471 Ok(())
1472 }
1473
1474 fn has_in_progress_cherry_pick(&self) -> Result<bool> {
1476 let git_dir = self.git_repo.path().join(".git");
1477 Ok(git_dir.join("CHERRY_PICK_HEAD").exists())
1478 }
1479
1480 fn handle_in_progress_cherry_pick(&mut self, stack: &Stack) -> Result<RebaseResult> {
1482 use crate::cli::output::Output;
1483
1484 let git_dir = self.git_repo.path().join(".git");
1485
1486 Output::section("Resuming in-progress sync");
1487 println!();
1488 Output::info("Detected unfinished cherry-pick from previous sync");
1489 println!();
1490
1491 if self.git_repo.has_conflicts()? {
1493 let conflicted_files = self.git_repo.get_conflicted_files()?;
1494
1495 let result = RebaseResult {
1496 success: false,
1497 branch_mapping: HashMap::new(),
1498 conflicts: conflicted_files.clone(),
1499 new_commits: Vec::new(),
1500 error: Some(format!(
1501 "Cannot continue: {} file(s) still have unresolved conflicts\n\n\
1502 MANUAL CONFLICT RESOLUTION REQUIRED\n\
1503 =====================================\n\n\
1504 Conflicted files:\n{}\n\n\
1505 Step 1: Analyze conflicts\n\
1506 โ Run: ca conflicts\n\
1507 โ Shows detailed conflict analysis\n\n\
1508 Step 2: Resolve conflicts in your editor\n\
1509 โ Open conflicted files and edit them\n\
1510 โ Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
1511 โ Keep the code you want\n\
1512 โ Save the files\n\n\
1513 Step 3: Mark conflicts as resolved\n\
1514 โ Run: git add <resolved-files>\n\
1515 โ Or: git add -A (to stage all resolved files)\n\n\
1516 Step 4: Complete the sync\n\
1517 โ Run: ca sync\n\
1518 โ Cascade will continue from where it left off\n\n\
1519 Alternative: Abort and start over\n\
1520 โ Run: git cherry-pick --abort\n\
1521 โ Then: ca sync (starts fresh)",
1522 conflicted_files.len(),
1523 conflicted_files
1524 .iter()
1525 .map(|f| format!(" - {}", f))
1526 .collect::<Vec<_>>()
1527 .join("\n")
1528 )),
1529 summary: "Sync paused - conflicts need resolution".to_string(),
1530 };
1531
1532 return Ok(result);
1533 }
1534
1535 Output::info("Conflicts resolved, continuing cherry-pick...");
1537
1538 self.git_repo.stage_conflict_resolved_files()?;
1540
1541 let cherry_pick_msg_file = git_dir.join("CHERRY_PICK_MSG");
1543 let commit_message = if cherry_pick_msg_file.exists() {
1544 std::fs::read_to_string(&cherry_pick_msg_file)
1545 .unwrap_or_else(|_| "Resolved conflicts".to_string())
1546 } else {
1547 "Resolved conflicts".to_string()
1548 };
1549
1550 match self.git_repo.commit(&commit_message) {
1551 Ok(_new_commit_id) => {
1552 Output::success("Cherry-pick completed");
1553
1554 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1556 let _ = std::fs::remove_file(git_dir.join("CHERRY_PICK_HEAD"));
1557 }
1558 if cherry_pick_msg_file.exists() {
1559 let _ = std::fs::remove_file(&cherry_pick_msg_file);
1560 }
1561
1562 println!();
1563 Output::info("Continuing with rest of stack...");
1564 println!();
1565
1566 self.rebase_with_force_push(stack)
1569 }
1570 Err(e) => {
1571 let result = RebaseResult {
1572 success: false,
1573 branch_mapping: HashMap::new(),
1574 conflicts: Vec::new(),
1575 new_commits: Vec::new(),
1576 error: Some(format!(
1577 "Failed to complete cherry-pick: {}\n\n\
1578 This usually means:\n\
1579 - Git index is locked (another process accessing repo)\n\
1580 - File permissions issue\n\
1581 - Disk space issue\n\n\
1582 Recovery:\n\
1583 1. Check if another Git operation is running\n\
1584 2. Run 'rm -f .git/index.lock' if stale lock exists\n\
1585 3. Run 'git status' to check repo state\n\
1586 4. Retry 'ca sync' after fixing the issue\n\n\
1587 Or abort and start fresh:\n\
1588 โ Run: git cherry-pick --abort\n\
1589 โ Then: ca sync",
1590 e
1591 )),
1592 summary: "Failed to complete cherry-pick".to_string(),
1593 };
1594
1595 Ok(result)
1596 }
1597 }
1598 }
1599}
1600
1601impl RebaseResult {
1602 pub fn get_summary(&self) -> String {
1604 if self.success {
1605 format!("โ
{}", self.summary)
1606 } else {
1607 format!(
1608 "โ Rebase failed: {}",
1609 self.error.as_deref().unwrap_or("Unknown error")
1610 )
1611 }
1612 }
1613
1614 pub fn has_conflicts(&self) -> bool {
1616 !self.conflicts.is_empty()
1617 }
1618
1619 pub fn success_count(&self) -> usize {
1621 self.new_commits.len()
1622 }
1623}
1624
1625#[cfg(test)]
1626mod tests {
1627 use super::*;
1628 use std::path::PathBuf;
1629 use std::process::Command;
1630 use tempfile::TempDir;
1631
1632 #[allow(dead_code)]
1633 fn create_test_repo() -> (TempDir, PathBuf) {
1634 let temp_dir = TempDir::new().unwrap();
1635 let repo_path = temp_dir.path().to_path_buf();
1636
1637 Command::new("git")
1639 .args(["init"])
1640 .current_dir(&repo_path)
1641 .output()
1642 .unwrap();
1643 Command::new("git")
1644 .args(["config", "user.name", "Test"])
1645 .current_dir(&repo_path)
1646 .output()
1647 .unwrap();
1648 Command::new("git")
1649 .args(["config", "user.email", "test@test.com"])
1650 .current_dir(&repo_path)
1651 .output()
1652 .unwrap();
1653
1654 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1656 Command::new("git")
1657 .args(["add", "."])
1658 .current_dir(&repo_path)
1659 .output()
1660 .unwrap();
1661 Command::new("git")
1662 .args(["commit", "-m", "Initial"])
1663 .current_dir(&repo_path)
1664 .output()
1665 .unwrap();
1666
1667 (temp_dir, repo_path)
1668 }
1669
1670 #[test]
1671 fn test_conflict_region_creation() {
1672 let region = ConflictRegion {
1673 start: 0,
1674 end: 50,
1675 start_line: 1,
1676 end_line: 3,
1677 our_content: "function test() {\n return true;\n}".to_string(),
1678 their_content: "function test() {\n return true;\n}".to_string(),
1679 };
1680
1681 assert_eq!(region.start_line, 1);
1682 assert_eq!(region.end_line, 3);
1683 assert!(region.our_content.contains("return true"));
1684 assert!(region.their_content.contains("return true"));
1685 }
1686
1687 #[test]
1688 fn test_rebase_strategies() {
1689 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1690 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1691 }
1692
1693 #[test]
1694 fn test_rebase_options() {
1695 let options = RebaseOptions::default();
1696 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1697 assert!(!options.interactive);
1698 assert!(options.auto_resolve);
1699 assert_eq!(options.max_retries, 3);
1700 }
1701
1702 #[test]
1703 fn test_cleanup_guard_tracks_branches() {
1704 let mut guard = TempBranchCleanupGuard::new();
1705 assert!(guard.branches.is_empty());
1706
1707 guard.add_branch("test-branch-1".to_string());
1708 guard.add_branch("test-branch-2".to_string());
1709
1710 assert_eq!(guard.branches.len(), 2);
1711 assert_eq!(guard.branches[0], "test-branch-1");
1712 assert_eq!(guard.branches[1], "test-branch-2");
1713 }
1714
1715 #[test]
1716 fn test_cleanup_guard_prevents_double_cleanup() {
1717 use std::process::Command;
1718 use tempfile::TempDir;
1719
1720 let temp_dir = TempDir::new().unwrap();
1722 let repo_path = temp_dir.path();
1723
1724 Command::new("git")
1725 .args(["init"])
1726 .current_dir(repo_path)
1727 .output()
1728 .unwrap();
1729
1730 Command::new("git")
1731 .args(["config", "user.name", "Test"])
1732 .current_dir(repo_path)
1733 .output()
1734 .unwrap();
1735
1736 Command::new("git")
1737 .args(["config", "user.email", "test@test.com"])
1738 .current_dir(repo_path)
1739 .output()
1740 .unwrap();
1741
1742 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1744 Command::new("git")
1745 .args(["add", "."])
1746 .current_dir(repo_path)
1747 .output()
1748 .unwrap();
1749 Command::new("git")
1750 .args(["commit", "-m", "initial"])
1751 .current_dir(repo_path)
1752 .output()
1753 .unwrap();
1754
1755 let git_repo = GitRepository::open(repo_path).unwrap();
1756
1757 git_repo.create_branch("test-temp", None).unwrap();
1759
1760 let mut guard = TempBranchCleanupGuard::new();
1761 guard.add_branch("test-temp".to_string());
1762
1763 guard.cleanup(&git_repo);
1765 assert!(guard.cleaned);
1766
1767 guard.cleanup(&git_repo);
1769 assert!(guard.cleaned);
1770 }
1771
1772 #[test]
1773 fn test_rebase_result() {
1774 let result = RebaseResult {
1775 success: true,
1776 branch_mapping: std::collections::HashMap::new(),
1777 conflicts: vec!["abc123".to_string()],
1778 new_commits: vec!["def456".to_string()],
1779 error: None,
1780 summary: "Test summary".to_string(),
1781 };
1782
1783 assert!(result.success);
1784 assert!(result.has_conflicts());
1785 assert_eq!(result.success_count(), 1);
1786 }
1787
1788 #[test]
1789 fn test_import_line_detection() {
1790 let (_temp_dir, repo_path) = create_test_repo();
1791 let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
1792 let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
1793 let options = RebaseOptions::default();
1794 let rebase_manager = RebaseManager::new(stack_manager, git_repo, options);
1795
1796 assert!(rebase_manager.is_import_line("import Foundation", "test.swift"));
1798 assert!(rebase_manager.is_import_line("@testable import MyModule", "test.swift"));
1799 assert!(!rebase_manager.is_import_line("class MyClass {", "test.swift"));
1800
1801 assert!(rebase_manager.is_import_line("import kotlin.collections.*", "test.kt"));
1803 assert!(rebase_manager.is_import_line("@file:JvmName(\"Utils\")", "test.kt"));
1804 assert!(!rebase_manager.is_import_line("fun myFunction() {", "test.kt"));
1805
1806 assert!(rebase_manager.is_import_line("using System;", "test.cs"));
1808 assert!(rebase_manager.is_import_line("using System.Collections.Generic;", "test.cs"));
1809 assert!(rebase_manager.is_import_line("extern alias GridV1;", "test.cs"));
1810 assert!(!rebase_manager.is_import_line("namespace MyNamespace {", "test.cs"));
1811
1812 assert!(rebase_manager.is_import_line("", "test.swift"));
1814 assert!(rebase_manager.is_import_line(" ", "test.kt"));
1815 assert!(rebase_manager.is_import_line("", "test.cs"));
1816 }
1817}