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}
65
66#[derive(Debug)]
68pub struct RebaseResult {
69 pub success: bool,
71 pub branch_mapping: HashMap<String, String>,
73 pub conflicts: Vec<String>,
75 pub new_commits: Vec<String>,
77 pub error: Option<String>,
79 pub summary: String,
81}
82
83#[allow(dead_code)]
89struct TempBranchCleanupGuard {
90 branches: Vec<String>,
91 cleaned: bool,
92}
93
94#[allow(dead_code)]
95impl TempBranchCleanupGuard {
96 fn new() -> Self {
97 Self {
98 branches: Vec::new(),
99 cleaned: false,
100 }
101 }
102
103 fn add_branch(&mut self, branch: String) {
104 self.branches.push(branch);
105 }
106
107 fn cleanup(&mut self, git_repo: &GitRepository) {
109 if self.cleaned || self.branches.is_empty() {
110 return;
111 }
112
113 info!("๐งน Cleaning up {} temporary branches", self.branches.len());
114 for branch in &self.branches {
115 if let Err(e) = git_repo.delete_branch_unsafe(branch) {
116 warn!("Failed to delete temp branch {}: {}", branch, e);
117 }
119 }
120 self.cleaned = true;
121 }
122}
123
124impl Drop for TempBranchCleanupGuard {
125 fn drop(&mut self) {
126 if !self.cleaned && !self.branches.is_empty() {
127 warn!(
130 "โ ๏ธ {} temporary branches were not cleaned up: {}",
131 self.branches.len(),
132 self.branches.join(", ")
133 );
134 warn!("Run 'ca cleanup' to remove orphaned temporary branches");
135 }
136 }
137}
138
139pub struct RebaseManager {
141 stack_manager: StackManager,
142 git_repo: GitRepository,
143 options: RebaseOptions,
144 conflict_analyzer: ConflictAnalyzer,
145}
146
147impl Default for RebaseOptions {
148 fn default() -> Self {
149 Self {
150 strategy: RebaseStrategy::ForcePush,
151 interactive: false,
152 target_base: None,
153 preserve_merges: true,
154 auto_resolve: true,
155 max_retries: 3,
156 skip_pull: None,
157 }
158 }
159}
160
161impl RebaseManager {
162 pub fn new(
164 stack_manager: StackManager,
165 git_repo: GitRepository,
166 options: RebaseOptions,
167 ) -> Self {
168 Self {
169 stack_manager,
170 git_repo,
171 options,
172 conflict_analyzer: ConflictAnalyzer::new(),
173 }
174 }
175
176 pub fn into_stack_manager(self) -> StackManager {
178 self.stack_manager
179 }
180
181 pub fn rebase_stack(&mut self, stack_id: &Uuid) -> Result<RebaseResult> {
183 debug!("Starting rebase for stack {}", stack_id);
184
185 let stack = self
186 .stack_manager
187 .get_stack(stack_id)
188 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
189 .clone();
190
191 match self.options.strategy {
192 RebaseStrategy::ForcePush => self.rebase_with_force_push(&stack),
193 RebaseStrategy::Interactive => self.rebase_interactive(&stack),
194 }
195 }
196
197 fn rebase_with_force_push(&mut self, stack: &Stack) -> Result<RebaseResult> {
201 use crate::cli::output::Output;
202
203 Output::section(format!("Rebasing stack: {}", stack.name));
204
205 let mut result = RebaseResult {
206 success: true,
207 branch_mapping: HashMap::new(),
208 conflicts: Vec::new(),
209 new_commits: Vec::new(),
210 error: None,
211 summary: String::new(),
212 };
213
214 let target_base = self
215 .options
216 .target_base
217 .as_ref()
218 .unwrap_or(&stack.base_branch)
219 .clone(); let original_branch = self.git_repo.get_current_branch().ok();
223
224 if !self.options.skip_pull.unwrap_or(false) {
227 if let Err(e) = self.pull_latest_changes(&target_base) {
228 Output::warning(format!("Could not pull latest changes: {}", e));
229 }
230 }
231
232 if let Err(e) = self.git_repo.reset_to_head() {
234 Output::warning(format!("Could not reset working directory: {}", e));
235 }
236
237 let mut current_base = target_base.clone();
238 let entry_count = stack.entries.len();
239 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" };
244 println!("Rebasing {} {}...", entry_count, plural);
245
246 for (index, entry) in stack.entries.iter().enumerate() {
248 let original_branch = &entry.branch;
249
250 let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
253 temp_branches.push(temp_branch.clone()); self.git_repo
255 .create_branch(&temp_branch, Some(¤t_base))?;
256 self.git_repo.checkout_branch(&temp_branch)?;
257
258 match self.cherry_pick_commit(&entry.commit_hash) {
260 Ok(new_commit_hash) => {
261 result.new_commits.push(new_commit_hash.clone());
262
263 let rebased_commit_id = self.git_repo.get_head_commit()?.id().to_string();
265
266 self.git_repo
269 .update_branch_to_commit(original_branch, &rebased_commit_id)?;
270
271 let tree_char = if index + 1 == entry_count {
273 "โโ"
274 } else {
275 "โโ"
276 };
277
278 if let Some(pr_num) = &entry.pull_request_id {
279 println!(" {} {} (PR #{})", tree_char, original_branch, pr_num);
280 branches_to_push.push((original_branch.clone(), pr_num.clone()));
281 } else {
282 println!(" {} {} (not submitted)", tree_char, original_branch);
283 }
284
285 result
286 .branch_mapping
287 .insert(original_branch.clone(), original_branch.clone());
288
289 self.update_stack_entry(
291 stack.id,
292 &entry.id,
293 original_branch,
294 &rebased_commit_id,
295 )?;
296
297 current_base = original_branch.clone();
299 }
300 Err(e) => {
301 println!(); Output::error(format!("Conflict in {}: {}", &entry.commit_hash[..8], e));
303 result.conflicts.push(entry.commit_hash.clone());
304
305 if !self.options.auto_resolve {
306 result.success = false;
307 result.error = Some(format!("Conflict in {}: {}", entry.commit_hash, e));
308 break;
309 }
310
311 match self.auto_resolve_conflicts(&entry.commit_hash) {
313 Ok(fully_resolved) => {
314 if !fully_resolved {
315 result.success = false;
316 result.error = Some(format!(
317 "Could not auto-resolve all conflicts in {}",
318 &entry.commit_hash[..8]
319 ));
320 break;
321 }
322
323 let commit_message = format!("Auto-resolved conflicts in {}", &entry.commit_hash[..8]);
325 match self.git_repo.commit(&commit_message) {
326 Ok(new_commit_id) => {
327 Output::success("Auto-resolved conflicts");
328 result.new_commits.push(new_commit_id.clone());
329 let rebased_commit_id = new_commit_id;
330
331 self.git_repo
333 .update_branch_to_commit(original_branch, &rebased_commit_id)?;
334
335 let tree_char = if index + 1 == entry_count {
337 "โโ"
338 } else {
339 "โโ"
340 };
341
342 if let Some(pr_num) = &entry.pull_request_id {
343 println!(" {} {} (PR #{})", tree_char, original_branch, pr_num);
344 branches_to_push.push((original_branch.clone(), pr_num.clone()));
345 } else {
346 println!(" {} {} (not submitted)", tree_char, original_branch);
347 }
348
349 result
350 .branch_mapping
351 .insert(original_branch.clone(), original_branch.clone());
352
353 self.update_stack_entry(
355 stack.id,
356 &entry.id,
357 original_branch,
358 &rebased_commit_id,
359 )?;
360
361 current_base = original_branch.clone();
363 }
364 Err(commit_err) => {
365 result.success = false;
366 result.error = Some(format!(
367 "Could not commit auto-resolved conflicts: {}",
368 commit_err
369 ));
370 break;
371 }
372 }
373 }
374 Err(resolve_err) => {
375 result.success = false;
376 result.error =
377 Some(format!("Could not resolve conflicts: {resolve_err}"));
378 break;
379 }
380 }
381 }
382 }
383 }
384
385 if !temp_branches.is_empty() {
388 if let Err(e) = self.git_repo.checkout_branch_silent(&target_base) {
390 Output::warning(format!("Could not checkout base for cleanup: {}", e));
391 }
392
393 for temp_branch in &temp_branches {
395 if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
396 debug!("Could not delete temp branch {}: {}", temp_branch, e);
397 }
398 }
399 }
400
401 let pushed_count = branches_to_push.len();
404 let skipped_count = entry_count - pushed_count;
405
406 if !branches_to_push.is_empty() {
407 println!(); println!("Pushing {} branch{} to remote...", pushed_count, if pushed_count == 1 { "" } else { "es" });
409
410 for (branch_name, _pr_num) in &branches_to_push {
411 match self.git_repo.force_push_single_branch_auto(branch_name) {
412 Ok(_) => {
413 debug!("Pushed {} successfully", branch_name);
414 }
415 Err(e) => {
416 Output::warning(format!("Could not push '{}': {}", branch_name, e));
417 }
419 }
420 }
421 }
422
423 if let Some(ref orig_branch) = original_branch {
426 if let Some(last_entry) = stack.entries.last() {
428 let top_branch = &last_entry.branch;
429
430 if let Ok(top_commit) = self.git_repo.get_branch_head(top_branch) {
432 debug!(
433 "Updating working branch '{}' to match top of stack ({})",
434 orig_branch, &top_commit[..8]
435 );
436
437 if let Err(e) = self.git_repo.update_branch_to_commit(orig_branch, &top_commit) {
438 Output::warning(format!(
439 "Could not update working branch '{}' to top of stack: {}",
440 orig_branch, e
441 ));
442 }
443 }
444 }
445
446 if let Err(e) = self.git_repo.checkout_branch_silent(orig_branch) {
448 Output::warning(format!(
449 "Could not return to original branch '{}': {}",
450 orig_branch, e
451 ));
452 }
453 }
454
455 result.summary = if pushed_count > 0 {
457 let pr_plural = if pushed_count == 1 { "" } else { "s" };
458 let entry_plural = if entry_count == 1 { "entry" } else { "entries" };
459
460 if skipped_count > 0 {
461 format!(
462 "{} {} rebased ({} PR{} updated, {} not yet submitted)",
463 entry_count, entry_plural, pushed_count, pr_plural, skipped_count
464 )
465 } else {
466 format!(
467 "{} {} rebased ({} PR{} updated)",
468 entry_count, entry_plural, pushed_count, pr_plural
469 )
470 }
471 } else {
472 let plural = if entry_count == 1 { "entry" } else { "entries" };
473 format!("{} {} rebased (no PRs to update yet)", entry_count, plural)
474 };
475
476 println!(); if result.success {
479 Output::success(&result.summary);
480 } else {
481 Output::error(format!("Rebase failed: {:?}", result.error));
482 }
483
484 self.stack_manager.save_to_disk()?;
486
487 Ok(result)
488 }
489
490 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
492 info!("Starting interactive rebase for stack '{}'", stack.name);
493
494 let mut result = RebaseResult {
495 success: true,
496 branch_mapping: HashMap::new(),
497 conflicts: Vec::new(),
498 new_commits: Vec::new(),
499 error: None,
500 summary: String::new(),
501 };
502
503 println!("Interactive Rebase for Stack: {}", stack.name);
504 println!(" Base branch: {}", stack.base_branch);
505 println!(" Entries: {}", stack.entries.len());
506
507 if self.options.interactive {
508 println!("\nChoose action for each commit:");
509 println!(" (p)ick - apply the commit");
510 println!(" (s)kip - skip this commit");
511 println!(" (e)dit - edit the commit message");
512 println!(" (q)uit - abort the rebase");
513 }
514
515 for entry in &stack.entries {
518 println!(
519 " {} {} - {}",
520 entry.short_hash(),
521 entry.branch,
522 entry.short_message(50)
523 );
524
525 match self.cherry_pick_commit(&entry.commit_hash) {
527 Ok(new_commit) => result.new_commits.push(new_commit),
528 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
529 }
530 }
531
532 result.summary = format!(
533 "Interactive rebase processed {} commits",
534 stack.entries.len()
535 );
536 Ok(result)
537 }
538
539 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
541 let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
543
544 if let Ok(staged_files) = self.git_repo.get_staged_files() {
546 if !staged_files.is_empty() {
547 let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
549 let _ = self.git_repo.commit_staged_changes(&cleanup_message);
550 }
551 }
552
553 Ok(new_commit_hash)
554 }
555
556 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
558 debug!("Attempting to auto-resolve conflicts for {}", commit_hash);
559
560 if !self.git_repo.has_conflicts()? {
562 return Ok(true);
563 }
564
565 let conflicted_files = self.git_repo.get_conflicted_files()?;
566
567 if conflicted_files.is_empty() {
568 return Ok(true);
569 }
570
571 info!(
572 "Found conflicts in {} files: {:?}",
573 conflicted_files.len(),
574 conflicted_files
575 );
576
577 let analysis = self
579 .conflict_analyzer
580 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
581
582 info!(
583 "๐ Conflict analysis: {} total conflicts, {} auto-resolvable",
584 analysis.total_conflicts, analysis.auto_resolvable_count
585 );
586
587 for recommendation in &analysis.recommendations {
589 info!("๐ก {}", recommendation);
590 }
591
592 let mut resolved_count = 0;
593 let mut failed_files = Vec::new();
594
595 for file_analysis in &analysis.files {
596 if file_analysis.auto_resolvable {
597 match self.resolve_file_conflicts_enhanced(
598 &file_analysis.file_path,
599 &file_analysis.conflicts,
600 ) {
601 Ok(ConflictResolution::Resolved) => {
602 resolved_count += 1;
603 info!("โ
Auto-resolved conflicts in {}", file_analysis.file_path);
604 }
605 Ok(ConflictResolution::TooComplex) => {
606 debug!(
607 "โ ๏ธ Conflicts in {} are too complex for auto-resolution",
608 file_analysis.file_path
609 );
610 failed_files.push(file_analysis.file_path.clone());
611 }
612 Err(e) => {
613 warn!(
614 "โ Failed to resolve conflicts in {}: {}",
615 file_analysis.file_path, e
616 );
617 failed_files.push(file_analysis.file_path.clone());
618 }
619 }
620 } else {
621 failed_files.push(file_analysis.file_path.clone());
622 info!(
623 "โ ๏ธ {} requires manual resolution ({} conflicts)",
624 file_analysis.file_path,
625 file_analysis.conflicts.len()
626 );
627 }
628 }
629
630 if resolved_count > 0 {
631 info!(
632 "๐ Auto-resolved conflicts in {}/{} files",
633 resolved_count,
634 conflicted_files.len()
635 );
636
637 self.git_repo.stage_conflict_resolved_files()?;
639 }
640
641 let all_resolved = failed_files.is_empty();
643
644 if !all_resolved {
645 info!(
646 "โ ๏ธ {} files still need manual resolution: {:?}",
647 failed_files.len(),
648 failed_files
649 );
650 }
651
652 Ok(all_resolved)
653 }
654
655 fn resolve_file_conflicts_enhanced(
657 &self,
658 file_path: &str,
659 conflicts: &[crate::git::ConflictRegion],
660 ) -> Result<ConflictResolution> {
661 let repo_path = self.git_repo.path();
662 let full_path = repo_path.join(file_path);
663
664 let mut content = std::fs::read_to_string(&full_path)
666 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
667
668 if conflicts.is_empty() {
669 return Ok(ConflictResolution::Resolved);
670 }
671
672 info!(
673 "Resolving {} conflicts in {} using enhanced analysis",
674 conflicts.len(),
675 file_path
676 );
677
678 let mut any_resolved = false;
679
680 for conflict in conflicts.iter().rev() {
682 match self.resolve_single_conflict_enhanced(conflict) {
683 Ok(Some(resolution)) => {
684 let before = &content[..conflict.start_pos];
686 let after = &content[conflict.end_pos..];
687 content = format!("{before}{resolution}{after}");
688 any_resolved = true;
689 debug!(
690 "โ
Resolved {} conflict at lines {}-{} in {}",
691 format!("{:?}", conflict.conflict_type).to_lowercase(),
692 conflict.start_line,
693 conflict.end_line,
694 file_path
695 );
696 }
697 Ok(None) => {
698 debug!(
699 "โ ๏ธ {} conflict at lines {}-{} in {} requires manual resolution",
700 format!("{:?}", conflict.conflict_type).to_lowercase(),
701 conflict.start_line,
702 conflict.end_line,
703 file_path
704 );
705 return Ok(ConflictResolution::TooComplex);
706 }
707 Err(e) => {
708 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
709 return Ok(ConflictResolution::TooComplex);
710 }
711 }
712 }
713
714 if any_resolved {
715 let remaining_conflicts = self.parse_conflict_markers(&content)?;
717
718 if remaining_conflicts.is_empty() {
719 crate::utils::atomic_file::write_string(&full_path, &content)?;
721
722 return Ok(ConflictResolution::Resolved);
723 } else {
724 info!(
725 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
726 file_path,
727 remaining_conflicts.len()
728 );
729 }
730 }
731
732 Ok(ConflictResolution::TooComplex)
733 }
734
735 fn resolve_single_conflict_enhanced(
737 &self,
738 conflict: &crate::git::ConflictRegion,
739 ) -> Result<Option<String>> {
740 debug!(
741 "Resolving {} conflict in {} (lines {}-{})",
742 format!("{:?}", conflict.conflict_type).to_lowercase(),
743 conflict.file_path,
744 conflict.start_line,
745 conflict.end_line
746 );
747
748 use crate::git::ConflictType;
749
750 match conflict.conflict_type {
751 ConflictType::Whitespace => {
752 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
754 Ok(Some(conflict.our_content.clone()))
755 } else {
756 Ok(Some(conflict.their_content.clone()))
757 }
758 }
759 ConflictType::LineEnding => {
760 let normalized = conflict
762 .our_content
763 .replace("\r\n", "\n")
764 .replace('\r', "\n");
765 Ok(Some(normalized))
766 }
767 ConflictType::PureAddition => {
768 if conflict.our_content.is_empty() {
770 Ok(Some(conflict.their_content.clone()))
771 } else if conflict.their_content.is_empty() {
772 Ok(Some(conflict.our_content.clone()))
773 } else {
774 let combined = format!("{}\n{}", conflict.our_content, conflict.their_content);
776 Ok(Some(combined))
777 }
778 }
779 ConflictType::ImportMerge => {
780 let mut all_imports: Vec<&str> = conflict
782 .our_content
783 .lines()
784 .chain(conflict.their_content.lines())
785 .collect();
786 all_imports.sort();
787 all_imports.dedup();
788 Ok(Some(all_imports.join("\n")))
789 }
790 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
791 Ok(None)
793 }
794 }
795 }
796
797 #[allow(dead_code)]
799 fn resolve_file_conflicts(&self, file_path: &str) -> Result<ConflictResolution> {
800 let repo_path = self.git_repo.path();
801 let full_path = repo_path.join(file_path);
802
803 let content = std::fs::read_to_string(&full_path)
805 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
806
807 let conflicts = self.parse_conflict_markers(&content)?;
809
810 if conflicts.is_empty() {
811 return Ok(ConflictResolution::Resolved);
813 }
814
815 info!(
816 "Found {} conflict regions in {}",
817 conflicts.len(),
818 file_path
819 );
820
821 let mut resolved_content = content;
823 let mut any_resolved = false;
824
825 for conflict in conflicts.iter().rev() {
827 match self.resolve_single_conflict(conflict, file_path) {
828 Ok(Some(resolution)) => {
829 let before = &resolved_content[..conflict.start];
831 let after = &resolved_content[conflict.end..];
832 resolved_content = format!("{before}{resolution}{after}");
833 any_resolved = true;
834 debug!(
835 "โ
Resolved conflict at lines {}-{} in {}",
836 conflict.start_line, conflict.end_line, file_path
837 );
838 }
839 Ok(None) => {
840 debug!(
841 "โ ๏ธ Conflict at lines {}-{} in {} too complex for auto-resolution",
842 conflict.start_line, conflict.end_line, file_path
843 );
844 }
845 Err(e) => {
846 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
847 }
848 }
849 }
850
851 if any_resolved {
852 let remaining_conflicts = self.parse_conflict_markers(&resolved_content)?;
854
855 if remaining_conflicts.is_empty() {
856 crate::utils::atomic_file::write_string(&full_path, &resolved_content)?;
858
859 return Ok(ConflictResolution::Resolved);
860 } else {
861 info!(
862 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
863 file_path,
864 remaining_conflicts.len()
865 );
866 }
867 }
868
869 Ok(ConflictResolution::TooComplex)
870 }
871
872 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
874 let lines: Vec<&str> = content.lines().collect();
875 let mut conflicts = Vec::new();
876 let mut i = 0;
877
878 while i < lines.len() {
879 if lines[i].starts_with("<<<<<<<") {
880 let start_line = i + 1;
882 let mut separator_line = None;
883 let mut end_line = None;
884
885 for (j, line) in lines.iter().enumerate().skip(i + 1) {
887 if line.starts_with("=======") {
888 separator_line = Some(j + 1);
889 } else if line.starts_with(">>>>>>>") {
890 end_line = Some(j + 1);
891 break;
892 }
893 }
894
895 if let (Some(sep), Some(end)) = (separator_line, end_line) {
896 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
898 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
899
900 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
901 let their_content = lines[sep..(end - 1)].join("\n");
902
903 conflicts.push(ConflictRegion {
904 start: start_pos,
905 end: end_pos,
906 start_line,
907 end_line: end,
908 our_content,
909 their_content,
910 });
911
912 i = end;
913 } else {
914 i += 1;
915 }
916 } else {
917 i += 1;
918 }
919 }
920
921 Ok(conflicts)
922 }
923
924 fn resolve_single_conflict(
926 &self,
927 conflict: &ConflictRegion,
928 file_path: &str,
929 ) -> Result<Option<String>> {
930 debug!(
931 "Analyzing conflict in {} (lines {}-{})",
932 file_path, conflict.start_line, conflict.end_line
933 );
934
935 if let Some(resolved) = self.resolve_whitespace_conflict(conflict)? {
937 debug!("Resolved as whitespace-only conflict");
938 return Ok(Some(resolved));
939 }
940
941 if let Some(resolved) = self.resolve_line_ending_conflict(conflict)? {
943 debug!("Resolved as line ending conflict");
944 return Ok(Some(resolved));
945 }
946
947 if let Some(resolved) = self.resolve_addition_conflict(conflict)? {
949 debug!("Resolved as pure addition conflict");
950 return Ok(Some(resolved));
951 }
952
953 if let Some(resolved) = self.resolve_import_conflict(conflict, file_path)? {
955 debug!("Resolved as import reordering conflict");
956 return Ok(Some(resolved));
957 }
958
959 Ok(None)
961 }
962
963 fn resolve_whitespace_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
965 let our_normalized = self.normalize_whitespace(&conflict.our_content);
966 let their_normalized = self.normalize_whitespace(&conflict.their_content);
967
968 if our_normalized == their_normalized {
969 let resolved =
971 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
972 conflict.our_content.clone()
973 } else {
974 conflict.their_content.clone()
975 };
976
977 return Ok(Some(resolved));
978 }
979
980 Ok(None)
981 }
982
983 fn resolve_line_ending_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
985 let our_normalized = conflict
986 .our_content
987 .replace("\r\n", "\n")
988 .replace('\r', "\n");
989 let their_normalized = conflict
990 .their_content
991 .replace("\r\n", "\n")
992 .replace('\r', "\n");
993
994 if our_normalized == their_normalized {
995 return Ok(Some(our_normalized));
997 }
998
999 Ok(None)
1000 }
1001
1002 fn resolve_addition_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
1004 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1005 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1006
1007 if our_lines.is_empty() {
1009 return Ok(Some(conflict.their_content.clone()));
1010 }
1011 if their_lines.is_empty() {
1012 return Ok(Some(conflict.our_content.clone()));
1013 }
1014
1015 let mut merged_lines = Vec::new();
1017 let mut our_idx = 0;
1018 let mut their_idx = 0;
1019
1020 while our_idx < our_lines.len() || their_idx < their_lines.len() {
1021 if our_idx >= our_lines.len() {
1022 merged_lines.extend_from_slice(&their_lines[their_idx..]);
1024 break;
1025 } else if their_idx >= their_lines.len() {
1026 merged_lines.extend_from_slice(&our_lines[our_idx..]);
1028 break;
1029 } else if our_lines[our_idx] == their_lines[their_idx] {
1030 merged_lines.push(our_lines[our_idx]);
1032 our_idx += 1;
1033 their_idx += 1;
1034 } else {
1035 return Ok(None);
1037 }
1038 }
1039
1040 Ok(Some(merged_lines.join("\n")))
1041 }
1042
1043 fn resolve_import_conflict(
1045 &self,
1046 conflict: &ConflictRegion,
1047 file_path: &str,
1048 ) -> Result<Option<String>> {
1049 let is_import_file = file_path.ends_with(".rs")
1051 || file_path.ends_with(".py")
1052 || file_path.ends_with(".js")
1053 || file_path.ends_with(".ts")
1054 || file_path.ends_with(".go")
1055 || file_path.ends_with(".java")
1056 || file_path.ends_with(".swift")
1057 || file_path.ends_with(".kt")
1058 || file_path.ends_with(".cs");
1059
1060 if !is_import_file {
1061 return Ok(None);
1062 }
1063
1064 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1065 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1066
1067 let our_imports = our_lines
1069 .iter()
1070 .all(|line| self.is_import_line(line, file_path));
1071 let their_imports = their_lines
1072 .iter()
1073 .all(|line| self.is_import_line(line, file_path));
1074
1075 if our_imports && their_imports {
1076 let mut all_imports: Vec<&str> = our_lines.into_iter().chain(their_lines).collect();
1078 all_imports.sort();
1079 all_imports.dedup();
1080
1081 return Ok(Some(all_imports.join("\n")));
1082 }
1083
1084 Ok(None)
1085 }
1086
1087 fn is_import_line(&self, line: &str, file_path: &str) -> bool {
1089 let trimmed = line.trim();
1090
1091 if trimmed.is_empty() {
1092 return true; }
1094
1095 if file_path.ends_with(".rs") {
1096 return trimmed.starts_with("use ") || trimmed.starts_with("extern crate");
1097 } else if file_path.ends_with(".py") {
1098 return trimmed.starts_with("import ") || trimmed.starts_with("from ");
1099 } else if file_path.ends_with(".js") || file_path.ends_with(".ts") {
1100 return trimmed.starts_with("import ")
1101 || trimmed.starts_with("const ")
1102 || trimmed.starts_with("require(");
1103 } else if file_path.ends_with(".go") {
1104 return trimmed.starts_with("import ") || trimmed == "import (" || trimmed == ")";
1105 } else if file_path.ends_with(".java") {
1106 return trimmed.starts_with("import ");
1107 } else if file_path.ends_with(".swift") {
1108 return trimmed.starts_with("import ") || trimmed.starts_with("@testable import ");
1109 } else if file_path.ends_with(".kt") {
1110 return trimmed.starts_with("import ") || trimmed.starts_with("@file:");
1111 } else if file_path.ends_with(".cs") {
1112 return trimmed.starts_with("using ") || trimmed.starts_with("extern alias ");
1113 }
1114
1115 false
1116 }
1117
1118 fn normalize_whitespace(&self, content: &str) -> String {
1120 content
1121 .lines()
1122 .map(|line| line.trim())
1123 .filter(|line| !line.is_empty())
1124 .collect::<Vec<_>>()
1125 .join("\n")
1126 }
1127
1128 fn update_stack_entry(
1131 &mut self,
1132 stack_id: Uuid,
1133 entry_id: &Uuid,
1134 _new_branch: &str,
1135 new_commit_hash: &str,
1136 ) -> Result<()> {
1137 debug!(
1138 "Updating entry {} in stack {} with new commit {}",
1139 entry_id, stack_id, new_commit_hash
1140 );
1141
1142 let stack = self
1144 .stack_manager
1145 .get_stack_mut(&stack_id)
1146 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1147
1148 if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == *entry_id) {
1150 debug!(
1151 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch '{}')",
1152 entry_id, entry.commit_hash, new_commit_hash, entry.branch
1153 );
1154
1155 entry.commit_hash = new_commit_hash.to_string();
1158
1159 debug!(
1162 "Successfully updated entry {} in stack {}",
1163 entry_id, stack_id
1164 );
1165 Ok(())
1166 } else {
1167 Err(CascadeError::config(format!(
1168 "Entry {entry_id} not found in stack {stack_id}"
1169 )))
1170 }
1171 }
1172
1173 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1175 info!("Pulling latest changes for branch {}", branch);
1176
1177 match self.git_repo.fetch() {
1179 Ok(_) => {
1180 debug!("Fetch successful");
1181 match self.git_repo.pull(branch) {
1183 Ok(_) => {
1184 info!("Pull completed successfully for {}", branch);
1185 Ok(())
1186 }
1187 Err(e) => {
1188 warn!("Pull failed for {}: {}", branch, e);
1189 Ok(())
1191 }
1192 }
1193 }
1194 Err(e) => {
1195 warn!("Fetch failed: {}", e);
1196 Ok(())
1198 }
1199 }
1200 }
1201
1202 pub fn is_rebase_in_progress(&self) -> bool {
1204 let git_dir = self.git_repo.path().join(".git");
1206 git_dir.join("REBASE_HEAD").exists()
1207 || git_dir.join("rebase-merge").exists()
1208 || git_dir.join("rebase-apply").exists()
1209 }
1210
1211 pub fn abort_rebase(&self) -> Result<()> {
1213 info!("Aborting rebase operation");
1214
1215 let git_dir = self.git_repo.path().join(".git");
1216
1217 if git_dir.join("REBASE_HEAD").exists() {
1219 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1220 CascadeError::Git(git2::Error::from_str(&format!(
1221 "Failed to clean rebase state: {e}"
1222 )))
1223 })?;
1224 }
1225
1226 if git_dir.join("rebase-merge").exists() {
1227 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1228 CascadeError::Git(git2::Error::from_str(&format!(
1229 "Failed to clean rebase-merge: {e}"
1230 )))
1231 })?;
1232 }
1233
1234 if git_dir.join("rebase-apply").exists() {
1235 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1236 CascadeError::Git(git2::Error::from_str(&format!(
1237 "Failed to clean rebase-apply: {e}"
1238 )))
1239 })?;
1240 }
1241
1242 info!("Rebase aborted successfully");
1243 Ok(())
1244 }
1245
1246 pub fn continue_rebase(&self) -> Result<()> {
1248 info!("Continuing rebase operation");
1249
1250 if self.git_repo.has_conflicts()? {
1252 return Err(CascadeError::branch(
1253 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1254 ));
1255 }
1256
1257 self.git_repo.stage_conflict_resolved_files()?;
1259
1260 info!("Rebase continued successfully");
1261 Ok(())
1262 }
1263}
1264
1265impl RebaseResult {
1266 pub fn get_summary(&self) -> String {
1268 if self.success {
1269 format!("โ
{}", self.summary)
1270 } else {
1271 format!(
1272 "โ Rebase failed: {}",
1273 self.error.as_deref().unwrap_or("Unknown error")
1274 )
1275 }
1276 }
1277
1278 pub fn has_conflicts(&self) -> bool {
1280 !self.conflicts.is_empty()
1281 }
1282
1283 pub fn success_count(&self) -> usize {
1285 self.new_commits.len()
1286 }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292 use std::path::PathBuf;
1293 use std::process::Command;
1294 use tempfile::TempDir;
1295
1296 #[allow(dead_code)]
1297 fn create_test_repo() -> (TempDir, PathBuf) {
1298 let temp_dir = TempDir::new().unwrap();
1299 let repo_path = temp_dir.path().to_path_buf();
1300
1301 Command::new("git")
1303 .args(["init"])
1304 .current_dir(&repo_path)
1305 .output()
1306 .unwrap();
1307 Command::new("git")
1308 .args(["config", "user.name", "Test"])
1309 .current_dir(&repo_path)
1310 .output()
1311 .unwrap();
1312 Command::new("git")
1313 .args(["config", "user.email", "test@test.com"])
1314 .current_dir(&repo_path)
1315 .output()
1316 .unwrap();
1317
1318 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1320 Command::new("git")
1321 .args(["add", "."])
1322 .current_dir(&repo_path)
1323 .output()
1324 .unwrap();
1325 Command::new("git")
1326 .args(["commit", "-m", "Initial"])
1327 .current_dir(&repo_path)
1328 .output()
1329 .unwrap();
1330
1331 (temp_dir, repo_path)
1332 }
1333
1334 #[test]
1335 fn test_conflict_region_creation() {
1336 let region = ConflictRegion {
1337 start: 0,
1338 end: 50,
1339 start_line: 1,
1340 end_line: 3,
1341 our_content: "function test() {\n return true;\n}".to_string(),
1342 their_content: "function test() {\n return true;\n}".to_string(),
1343 };
1344
1345 assert_eq!(region.start_line, 1);
1346 assert_eq!(region.end_line, 3);
1347 assert!(region.our_content.contains("return true"));
1348 assert!(region.their_content.contains("return true"));
1349 }
1350
1351 #[test]
1352 fn test_rebase_strategies() {
1353 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1354 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1355 }
1356
1357 #[test]
1358 fn test_rebase_options() {
1359 let options = RebaseOptions::default();
1360 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1361 assert!(!options.interactive);
1362 assert!(options.auto_resolve);
1363 assert_eq!(options.max_retries, 3);
1364 }
1365
1366 #[test]
1367 fn test_cleanup_guard_tracks_branches() {
1368 let mut guard = TempBranchCleanupGuard::new();
1369 assert!(guard.branches.is_empty());
1370
1371 guard.add_branch("test-branch-1".to_string());
1372 guard.add_branch("test-branch-2".to_string());
1373
1374 assert_eq!(guard.branches.len(), 2);
1375 assert_eq!(guard.branches[0], "test-branch-1");
1376 assert_eq!(guard.branches[1], "test-branch-2");
1377 }
1378
1379 #[test]
1380 fn test_cleanup_guard_prevents_double_cleanup() {
1381 use std::process::Command;
1382 use tempfile::TempDir;
1383
1384 let temp_dir = TempDir::new().unwrap();
1386 let repo_path = temp_dir.path();
1387
1388 Command::new("git")
1389 .args(["init"])
1390 .current_dir(repo_path)
1391 .output()
1392 .unwrap();
1393
1394 Command::new("git")
1395 .args(["config", "user.name", "Test"])
1396 .current_dir(repo_path)
1397 .output()
1398 .unwrap();
1399
1400 Command::new("git")
1401 .args(["config", "user.email", "test@test.com"])
1402 .current_dir(repo_path)
1403 .output()
1404 .unwrap();
1405
1406 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1408 Command::new("git")
1409 .args(["add", "."])
1410 .current_dir(repo_path)
1411 .output()
1412 .unwrap();
1413 Command::new("git")
1414 .args(["commit", "-m", "initial"])
1415 .current_dir(repo_path)
1416 .output()
1417 .unwrap();
1418
1419 let git_repo = GitRepository::open(repo_path).unwrap();
1420
1421 git_repo.create_branch("test-temp", None).unwrap();
1423
1424 let mut guard = TempBranchCleanupGuard::new();
1425 guard.add_branch("test-temp".to_string());
1426
1427 guard.cleanup(&git_repo);
1429 assert!(guard.cleaned);
1430
1431 guard.cleanup(&git_repo);
1433 assert!(guard.cleaned);
1434 }
1435
1436 #[test]
1437 fn test_rebase_result() {
1438 let result = RebaseResult {
1439 success: true,
1440 branch_mapping: std::collections::HashMap::new(),
1441 conflicts: vec!["abc123".to_string()],
1442 new_commits: vec!["def456".to_string()],
1443 error: None,
1444 summary: "Test summary".to_string(),
1445 };
1446
1447 assert!(result.success);
1448 assert!(result.has_conflicts());
1449 assert_eq!(result.success_count(), 1);
1450 }
1451
1452 #[test]
1453 fn test_import_line_detection() {
1454 let (_temp_dir, repo_path) = create_test_repo();
1455 let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
1456 let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
1457 let options = RebaseOptions::default();
1458 let rebase_manager = RebaseManager::new(stack_manager, git_repo, options);
1459
1460 assert!(rebase_manager.is_import_line("import Foundation", "test.swift"));
1462 assert!(rebase_manager.is_import_line("@testable import MyModule", "test.swift"));
1463 assert!(!rebase_manager.is_import_line("class MyClass {", "test.swift"));
1464
1465 assert!(rebase_manager.is_import_line("import kotlin.collections.*", "test.kt"));
1467 assert!(rebase_manager.is_import_line("@file:JvmName(\"Utils\")", "test.kt"));
1468 assert!(!rebase_manager.is_import_line("fun myFunction() {", "test.kt"));
1469
1470 assert!(rebase_manager.is_import_line("using System;", "test.cs"));
1472 assert!(rebase_manager.is_import_line("using System.Collections.Generic;", "test.cs"));
1473 assert!(rebase_manager.is_import_line("extern alias GridV1;", "test.cs"));
1474 assert!(!rebase_manager.is_import_line("namespace MyNamespace {", "test.cs"));
1475
1476 assert!(rebase_manager.is_import_line("", "test.swift"));
1478 assert!(rebase_manager.is_import_line(" ", "test.kt"));
1479 assert!(rebase_manager.is_import_line("", "test.cs"));
1480 }
1481}