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 rebase_stack(&mut self, stack_id: &Uuid) -> Result<RebaseResult> {
178 info!("Starting rebase for stack {}", stack_id);
179
180 let stack = self
181 .stack_manager
182 .get_stack(stack_id)
183 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
184 .clone();
185
186 match self.options.strategy {
187 RebaseStrategy::ForcePush => self.rebase_with_force_push(&stack),
188 RebaseStrategy::Interactive => self.rebase_interactive(&stack),
189 }
190 }
191
192 fn rebase_with_force_push(&mut self, stack: &Stack) -> Result<RebaseResult> {
196 use crate::cli::output::Output;
197
198 Output::section(&format!("Rebasing stack: {}", stack.name));
199
200 let mut result = RebaseResult {
201 success: true,
202 branch_mapping: HashMap::new(),
203 conflicts: Vec::new(),
204 new_commits: Vec::new(),
205 error: None,
206 summary: String::new(),
207 };
208
209 let target_base = self
210 .options
211 .target_base
212 .as_ref()
213 .unwrap_or(&stack.base_branch)
214 .clone(); let original_branch = self.git_repo.get_current_branch().ok();
218
219 if self.git_repo.get_current_branch()? != target_base {
221 self.git_repo.checkout_branch(&target_base)?;
222 }
223
224 if !self.options.skip_pull.unwrap_or(false) {
226 if let Err(e) = self.pull_latest_changes(&target_base) {
227 Output::warning(&format!("Could not pull latest changes: {}", e));
228 }
229 }
230
231 if let Err(e) = self.git_repo.reset_to_head() {
233 Output::warning(&format!("Could not reset working directory: {}", e));
234 }
235
236 let mut current_base = target_base.clone();
237 let entry_count = stack.entries.len();
238 let mut pushed_count = 0;
239 let mut skipped_count = 0;
240 let mut temp_branches: Vec<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() {
247 let original_branch = &entry.branch;
248
249 let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
252 temp_branches.push(temp_branch.clone()); self.git_repo.create_branch(&temp_branch, Some(¤t_base))?;
254 self.git_repo.checkout_branch(&temp_branch)?;
255
256 match self.cherry_pick_commit(&entry.commit_hash) {
258 Ok(new_commit_hash) => {
259 result.new_commits.push(new_commit_hash.clone());
260
261 let rebased_commit_id = self.git_repo.get_head_commit()?.id().to_string();
263
264 self.git_repo.update_branch_to_commit(original_branch, &rebased_commit_id)?;
267
268 if entry.pull_request_id.is_some() {
270 let pr_num = entry.pull_request_id.as_ref().unwrap();
271 let tree_char = if index + 1 == entry_count { "โโ" } else { "โโ" };
272 println!(" {} {} (PR #{})", tree_char, original_branch, pr_num);
273
274 self.git_repo.force_push_single_branch(original_branch)?;
277 pushed_count += 1;
278 } else {
279 let tree_char = if index + 1 == entry_count { "โโ" } else { "โโ" };
280 println!(" {} {} (not submitted)", tree_char, original_branch);
281 skipped_count += 1;
282 }
283
284 result
285 .branch_mapping
286 .insert(original_branch.clone(), original_branch.clone());
287
288 self.update_stack_entry(
290 stack.id,
291 &entry.id,
292 original_branch,
293 &rebased_commit_id,
294 )?;
295
296 current_base = original_branch.clone();
298 }
299 Err(e) => {
300 println!(); Output::error(&format!("Conflict in {}: {}", &entry.commit_hash[..8], e));
302 result.conflicts.push(entry.commit_hash.clone());
303
304 if !self.options.auto_resolve {
305 result.success = false;
306 result.error = Some(format!("Conflict in {}: {}", entry.commit_hash, e));
307 break;
308 }
309
310 match self.auto_resolve_conflicts(&entry.commit_hash) {
312 Ok(_) => {
313 Output::success("Auto-resolved conflicts");
314 }
315 Err(resolve_err) => {
316 result.success = false;
317 result.error =
318 Some(format!("Could not resolve conflicts: {resolve_err}"));
319 break;
320 }
321 }
322 }
323 }
324 }
325
326 if !temp_branches.is_empty() {
329 if let Err(e) = self.git_repo.checkout_branch(&target_base) {
331 Output::warning(&format!("Could not checkout base for cleanup: {}", e));
332 }
333
334 for temp_branch in &temp_branches {
336 if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
337 debug!("Could not delete temp branch {}: {}", temp_branch, e);
338 }
339 }
340 }
341
342 if let Some(orig_branch) = original_branch {
344 if let Err(e) = self.git_repo.checkout_branch(&orig_branch) {
345 Output::warning(&format!("Could not return to original branch '{}': {}", orig_branch, e));
346 }
347 }
348
349 result.summary = if pushed_count > 0 {
351 let pr_plural = if pushed_count == 1 { "" } else { "s" };
352 let entry_plural = if entry_count == 1 { "entry" } else { "entries" };
353
354 if skipped_count > 0 {
355 format!(
356 "{} {} rebased ({} PR{} updated, {} not yet submitted)",
357 entry_count, entry_plural, pushed_count, pr_plural, skipped_count
358 )
359 } else {
360 format!(
361 "{} {} rebased ({} PR{} updated)",
362 entry_count, entry_plural, pushed_count, pr_plural
363 )
364 }
365 } else {
366 let plural = if entry_count == 1 { "entry" } else { "entries" };
367 format!("{} {} rebased (no PRs to update yet)", entry_count, plural)
368 };
369
370 println!(); if result.success {
373 Output::success(&result.summary);
374 } else {
375 Output::error(&format!("Rebase failed: {:?}", result.error));
376 }
377
378 Ok(result)
379 }
380
381 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
383 info!("Starting interactive rebase for stack '{}'", stack.name);
384
385 let mut result = RebaseResult {
386 success: true,
387 branch_mapping: HashMap::new(),
388 conflicts: Vec::new(),
389 new_commits: Vec::new(),
390 error: None,
391 summary: String::new(),
392 };
393
394 println!("๐ Interactive Rebase for Stack: {}", stack.name);
395 println!(" Base branch: {}", stack.base_branch);
396 println!(" Entries: {}", stack.entries.len());
397
398 if self.options.interactive {
399 println!("\nChoose action for each commit:");
400 println!(" (p)ick - apply the commit");
401 println!(" (s)kip - skip this commit");
402 println!(" (e)dit - edit the commit message");
403 println!(" (q)uit - abort the rebase");
404 }
405
406 for entry in &stack.entries {
409 println!(
410 " {} {} - {}",
411 entry.short_hash(),
412 entry.branch,
413 entry.short_message(50)
414 );
415
416 match self.cherry_pick_commit(&entry.commit_hash) {
418 Ok(new_commit) => result.new_commits.push(new_commit),
419 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
420 }
421 }
422
423 result.summary = format!(
424 "Interactive rebase processed {} commits",
425 stack.entries.len()
426 );
427 Ok(result)
428 }
429
430 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
432 let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
434
435 if let Ok(staged_files) = self.git_repo.get_staged_files() {
437 if !staged_files.is_empty() {
438 let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
440 let _ = self.git_repo.commit_staged_changes(&cleanup_message);
441 }
442 }
443
444 Ok(new_commit_hash)
445 }
446
447
448 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
450 debug!("Attempting to auto-resolve conflicts for {}", commit_hash);
451
452 if !self.git_repo.has_conflicts()? {
454 return Ok(true);
455 }
456
457 let conflicted_files = self.git_repo.get_conflicted_files()?;
458
459 if conflicted_files.is_empty() {
460 return Ok(true);
461 }
462
463 info!(
464 "Found conflicts in {} files: {:?}",
465 conflicted_files.len(),
466 conflicted_files
467 );
468
469 let analysis = self
471 .conflict_analyzer
472 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
473
474 info!(
475 "๐ Conflict analysis: {} total conflicts, {} auto-resolvable",
476 analysis.total_conflicts, analysis.auto_resolvable_count
477 );
478
479 for recommendation in &analysis.recommendations {
481 info!("๐ก {}", recommendation);
482 }
483
484 let mut resolved_count = 0;
485 let mut failed_files = Vec::new();
486
487 for file_analysis in &analysis.files {
488 if file_analysis.auto_resolvable {
489 match self.resolve_file_conflicts_enhanced(
490 &file_analysis.file_path,
491 &file_analysis.conflicts,
492 ) {
493 Ok(ConflictResolution::Resolved) => {
494 resolved_count += 1;
495 info!("โ
Auto-resolved conflicts in {}", file_analysis.file_path);
496 }
497 Ok(ConflictResolution::TooComplex) => {
498 debug!(
499 "โ ๏ธ Conflicts in {} are too complex for auto-resolution",
500 file_analysis.file_path
501 );
502 failed_files.push(file_analysis.file_path.clone());
503 }
504 Err(e) => {
505 warn!(
506 "โ Failed to resolve conflicts in {}: {}",
507 file_analysis.file_path, e
508 );
509 failed_files.push(file_analysis.file_path.clone());
510 }
511 }
512 } else {
513 failed_files.push(file_analysis.file_path.clone());
514 info!(
515 "โ ๏ธ {} requires manual resolution ({} conflicts)",
516 file_analysis.file_path,
517 file_analysis.conflicts.len()
518 );
519 }
520 }
521
522 if resolved_count > 0 {
523 info!(
524 "๐ Auto-resolved conflicts in {}/{} files",
525 resolved_count,
526 conflicted_files.len()
527 );
528
529 self.git_repo.stage_conflict_resolved_files()?;
531 }
532
533 let all_resolved = failed_files.is_empty();
535
536 if !all_resolved {
537 info!(
538 "โ ๏ธ {} files still need manual resolution: {:?}",
539 failed_files.len(),
540 failed_files
541 );
542 }
543
544 Ok(all_resolved)
545 }
546
547 fn resolve_file_conflicts_enhanced(
549 &self,
550 file_path: &str,
551 conflicts: &[crate::git::ConflictRegion],
552 ) -> Result<ConflictResolution> {
553 let repo_path = self.git_repo.path();
554 let full_path = repo_path.join(file_path);
555
556 let mut content = std::fs::read_to_string(&full_path)
558 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
559
560 if conflicts.is_empty() {
561 return Ok(ConflictResolution::Resolved);
562 }
563
564 info!(
565 "Resolving {} conflicts in {} using enhanced analysis",
566 conflicts.len(),
567 file_path
568 );
569
570 let mut any_resolved = false;
571
572 for conflict in conflicts.iter().rev() {
574 match self.resolve_single_conflict_enhanced(conflict) {
575 Ok(Some(resolution)) => {
576 let before = &content[..conflict.start_pos];
578 let after = &content[conflict.end_pos..];
579 content = format!("{before}{resolution}{after}");
580 any_resolved = true;
581 debug!(
582 "โ
Resolved {} conflict at lines {}-{} in {}",
583 format!("{:?}", conflict.conflict_type).to_lowercase(),
584 conflict.start_line,
585 conflict.end_line,
586 file_path
587 );
588 }
589 Ok(None) => {
590 debug!(
591 "โ ๏ธ {} conflict at lines {}-{} in {} requires manual resolution",
592 format!("{:?}", conflict.conflict_type).to_lowercase(),
593 conflict.start_line,
594 conflict.end_line,
595 file_path
596 );
597 return Ok(ConflictResolution::TooComplex);
598 }
599 Err(e) => {
600 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
601 return Ok(ConflictResolution::TooComplex);
602 }
603 }
604 }
605
606 if any_resolved {
607 let remaining_conflicts = self.parse_conflict_markers(&content)?;
609
610 if remaining_conflicts.is_empty() {
611 crate::utils::atomic_file::write_string(&full_path, &content)?;
613
614 return Ok(ConflictResolution::Resolved);
615 } else {
616 info!(
617 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
618 file_path,
619 remaining_conflicts.len()
620 );
621 }
622 }
623
624 Ok(ConflictResolution::TooComplex)
625 }
626
627 fn resolve_single_conflict_enhanced(
629 &self,
630 conflict: &crate::git::ConflictRegion,
631 ) -> Result<Option<String>> {
632 debug!(
633 "Resolving {} conflict in {} (lines {}-{})",
634 format!("{:?}", conflict.conflict_type).to_lowercase(),
635 conflict.file_path,
636 conflict.start_line,
637 conflict.end_line
638 );
639
640 use crate::git::ConflictType;
641
642 match conflict.conflict_type {
643 ConflictType::Whitespace => {
644 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
646 Ok(Some(conflict.our_content.clone()))
647 } else {
648 Ok(Some(conflict.their_content.clone()))
649 }
650 }
651 ConflictType::LineEnding => {
652 let normalized = conflict
654 .our_content
655 .replace("\r\n", "\n")
656 .replace('\r', "\n");
657 Ok(Some(normalized))
658 }
659 ConflictType::PureAddition => {
660 if conflict.our_content.is_empty() {
662 Ok(Some(conflict.their_content.clone()))
663 } else if conflict.their_content.is_empty() {
664 Ok(Some(conflict.our_content.clone()))
665 } else {
666 let combined = format!("{}\n{}", conflict.our_content, conflict.their_content);
668 Ok(Some(combined))
669 }
670 }
671 ConflictType::ImportMerge => {
672 let mut all_imports: Vec<&str> = conflict
674 .our_content
675 .lines()
676 .chain(conflict.their_content.lines())
677 .collect();
678 all_imports.sort();
679 all_imports.dedup();
680 Ok(Some(all_imports.join("\n")))
681 }
682 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
683 Ok(None)
685 }
686 }
687 }
688
689 #[allow(dead_code)]
691 fn resolve_file_conflicts(&self, file_path: &str) -> Result<ConflictResolution> {
692 let repo_path = self.git_repo.path();
693 let full_path = repo_path.join(file_path);
694
695 let content = std::fs::read_to_string(&full_path)
697 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
698
699 let conflicts = self.parse_conflict_markers(&content)?;
701
702 if conflicts.is_empty() {
703 return Ok(ConflictResolution::Resolved);
705 }
706
707 info!(
708 "Found {} conflict regions in {}",
709 conflicts.len(),
710 file_path
711 );
712
713 let mut resolved_content = content;
715 let mut any_resolved = false;
716
717 for conflict in conflicts.iter().rev() {
719 match self.resolve_single_conflict(conflict, file_path) {
720 Ok(Some(resolution)) => {
721 let before = &resolved_content[..conflict.start];
723 let after = &resolved_content[conflict.end..];
724 resolved_content = format!("{before}{resolution}{after}");
725 any_resolved = true;
726 debug!(
727 "โ
Resolved conflict at lines {}-{} in {}",
728 conflict.start_line, conflict.end_line, file_path
729 );
730 }
731 Ok(None) => {
732 debug!(
733 "โ ๏ธ Conflict at lines {}-{} in {} too complex for auto-resolution",
734 conflict.start_line, conflict.end_line, file_path
735 );
736 }
737 Err(e) => {
738 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
739 }
740 }
741 }
742
743 if any_resolved {
744 let remaining_conflicts = self.parse_conflict_markers(&resolved_content)?;
746
747 if remaining_conflicts.is_empty() {
748 crate::utils::atomic_file::write_string(&full_path, &resolved_content)?;
750
751 return Ok(ConflictResolution::Resolved);
752 } else {
753 info!(
754 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
755 file_path,
756 remaining_conflicts.len()
757 );
758 }
759 }
760
761 Ok(ConflictResolution::TooComplex)
762 }
763
764 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
766 let lines: Vec<&str> = content.lines().collect();
767 let mut conflicts = Vec::new();
768 let mut i = 0;
769
770 while i < lines.len() {
771 if lines[i].starts_with("<<<<<<<") {
772 let start_line = i + 1;
774 let mut separator_line = None;
775 let mut end_line = None;
776
777 for (j, line) in lines.iter().enumerate().skip(i + 1) {
779 if line.starts_with("=======") {
780 separator_line = Some(j + 1);
781 } else if line.starts_with(">>>>>>>") {
782 end_line = Some(j + 1);
783 break;
784 }
785 }
786
787 if let (Some(sep), Some(end)) = (separator_line, end_line) {
788 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
790 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
791
792 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
793 let their_content = lines[sep..(end - 1)].join("\n");
794
795 conflicts.push(ConflictRegion {
796 start: start_pos,
797 end: end_pos,
798 start_line,
799 end_line: end,
800 our_content,
801 their_content,
802 });
803
804 i = end;
805 } else {
806 i += 1;
807 }
808 } else {
809 i += 1;
810 }
811 }
812
813 Ok(conflicts)
814 }
815
816 fn resolve_single_conflict(
818 &self,
819 conflict: &ConflictRegion,
820 file_path: &str,
821 ) -> Result<Option<String>> {
822 debug!(
823 "Analyzing conflict in {} (lines {}-{})",
824 file_path, conflict.start_line, conflict.end_line
825 );
826
827 if let Some(resolved) = self.resolve_whitespace_conflict(conflict)? {
829 debug!("Resolved as whitespace-only conflict");
830 return Ok(Some(resolved));
831 }
832
833 if let Some(resolved) = self.resolve_line_ending_conflict(conflict)? {
835 debug!("Resolved as line ending conflict");
836 return Ok(Some(resolved));
837 }
838
839 if let Some(resolved) = self.resolve_addition_conflict(conflict)? {
841 debug!("Resolved as pure addition conflict");
842 return Ok(Some(resolved));
843 }
844
845 if let Some(resolved) = self.resolve_import_conflict(conflict, file_path)? {
847 debug!("Resolved as import reordering conflict");
848 return Ok(Some(resolved));
849 }
850
851 Ok(None)
853 }
854
855 fn resolve_whitespace_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
857 let our_normalized = self.normalize_whitespace(&conflict.our_content);
858 let their_normalized = self.normalize_whitespace(&conflict.their_content);
859
860 if our_normalized == their_normalized {
861 let resolved =
863 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
864 conflict.our_content.clone()
865 } else {
866 conflict.their_content.clone()
867 };
868
869 return Ok(Some(resolved));
870 }
871
872 Ok(None)
873 }
874
875 fn resolve_line_ending_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
877 let our_normalized = conflict
878 .our_content
879 .replace("\r\n", "\n")
880 .replace('\r', "\n");
881 let their_normalized = conflict
882 .their_content
883 .replace("\r\n", "\n")
884 .replace('\r', "\n");
885
886 if our_normalized == their_normalized {
887 return Ok(Some(our_normalized));
889 }
890
891 Ok(None)
892 }
893
894 fn resolve_addition_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
896 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
897 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
898
899 if our_lines.is_empty() {
901 return Ok(Some(conflict.their_content.clone()));
902 }
903 if their_lines.is_empty() {
904 return Ok(Some(conflict.our_content.clone()));
905 }
906
907 let mut merged_lines = Vec::new();
909 let mut our_idx = 0;
910 let mut their_idx = 0;
911
912 while our_idx < our_lines.len() || their_idx < their_lines.len() {
913 if our_idx >= our_lines.len() {
914 merged_lines.extend_from_slice(&their_lines[their_idx..]);
916 break;
917 } else if their_idx >= their_lines.len() {
918 merged_lines.extend_from_slice(&our_lines[our_idx..]);
920 break;
921 } else if our_lines[our_idx] == their_lines[their_idx] {
922 merged_lines.push(our_lines[our_idx]);
924 our_idx += 1;
925 their_idx += 1;
926 } else {
927 return Ok(None);
929 }
930 }
931
932 Ok(Some(merged_lines.join("\n")))
933 }
934
935 fn resolve_import_conflict(
937 &self,
938 conflict: &ConflictRegion,
939 file_path: &str,
940 ) -> Result<Option<String>> {
941 let is_import_file = file_path.ends_with(".rs")
943 || file_path.ends_with(".py")
944 || file_path.ends_with(".js")
945 || file_path.ends_with(".ts")
946 || file_path.ends_with(".go")
947 || file_path.ends_with(".java")
948 || file_path.ends_with(".swift")
949 || file_path.ends_with(".kt")
950 || file_path.ends_with(".cs");
951
952 if !is_import_file {
953 return Ok(None);
954 }
955
956 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
957 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
958
959 let our_imports = our_lines
961 .iter()
962 .all(|line| self.is_import_line(line, file_path));
963 let their_imports = their_lines
964 .iter()
965 .all(|line| self.is_import_line(line, file_path));
966
967 if our_imports && their_imports {
968 let mut all_imports: Vec<&str> = our_lines.into_iter().chain(their_lines).collect();
970 all_imports.sort();
971 all_imports.dedup();
972
973 return Ok(Some(all_imports.join("\n")));
974 }
975
976 Ok(None)
977 }
978
979 fn is_import_line(&self, line: &str, file_path: &str) -> bool {
981 let trimmed = line.trim();
982
983 if trimmed.is_empty() {
984 return true; }
986
987 if file_path.ends_with(".rs") {
988 return trimmed.starts_with("use ") || trimmed.starts_with("extern crate");
989 } else if file_path.ends_with(".py") {
990 return trimmed.starts_with("import ") || trimmed.starts_with("from ");
991 } else if file_path.ends_with(".js") || file_path.ends_with(".ts") {
992 return trimmed.starts_with("import ")
993 || trimmed.starts_with("const ")
994 || trimmed.starts_with("require(");
995 } else if file_path.ends_with(".go") {
996 return trimmed.starts_with("import ") || trimmed == "import (" || trimmed == ")";
997 } else if file_path.ends_with(".java") {
998 return trimmed.starts_with("import ");
999 } else if file_path.ends_with(".swift") {
1000 return trimmed.starts_with("import ") || trimmed.starts_with("@testable import ");
1001 } else if file_path.ends_with(".kt") {
1002 return trimmed.starts_with("import ") || trimmed.starts_with("@file:");
1003 } else if file_path.ends_with(".cs") {
1004 return trimmed.starts_with("using ") || trimmed.starts_with("extern alias ");
1005 }
1006
1007 false
1008 }
1009
1010 fn normalize_whitespace(&self, content: &str) -> String {
1012 content
1013 .lines()
1014 .map(|line| line.trim())
1015 .filter(|line| !line.is_empty())
1016 .collect::<Vec<_>>()
1017 .join("\n")
1018 }
1019
1020 fn update_stack_entry(
1023 &mut self,
1024 stack_id: Uuid,
1025 entry_id: &Uuid,
1026 _new_branch: &str,
1027 new_commit_hash: &str,
1028 ) -> Result<()> {
1029 debug!(
1030 "Updating entry {} in stack {} with new commit {}",
1031 entry_id, stack_id, new_commit_hash
1032 );
1033
1034 let stack = self
1036 .stack_manager
1037 .get_stack_mut(&stack_id)
1038 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1039
1040 if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == *entry_id) {
1042 debug!(
1043 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch '{}')",
1044 entry_id, entry.commit_hash, new_commit_hash, entry.branch
1045 );
1046
1047 entry.commit_hash = new_commit_hash.to_string();
1050
1051 debug!(
1054 "Successfully updated entry {} in stack {}",
1055 entry_id, stack_id
1056 );
1057 Ok(())
1058 } else {
1059 Err(CascadeError::config(format!(
1060 "Entry {entry_id} not found in stack {stack_id}"
1061 )))
1062 }
1063 }
1064
1065 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1067 info!("Pulling latest changes for branch {}", branch);
1068
1069 match self.git_repo.fetch() {
1071 Ok(_) => {
1072 debug!("Fetch successful");
1073 match self.git_repo.pull(branch) {
1075 Ok(_) => {
1076 info!("Pull completed successfully for {}", branch);
1077 Ok(())
1078 }
1079 Err(e) => {
1080 warn!("Pull failed for {}: {}", branch, e);
1081 Ok(())
1083 }
1084 }
1085 }
1086 Err(e) => {
1087 warn!("Fetch failed: {}", e);
1088 Ok(())
1090 }
1091 }
1092 }
1093
1094 pub fn is_rebase_in_progress(&self) -> bool {
1096 let git_dir = self.git_repo.path().join(".git");
1098 git_dir.join("REBASE_HEAD").exists()
1099 || git_dir.join("rebase-merge").exists()
1100 || git_dir.join("rebase-apply").exists()
1101 }
1102
1103 pub fn abort_rebase(&self) -> Result<()> {
1105 info!("Aborting rebase operation");
1106
1107 let git_dir = self.git_repo.path().join(".git");
1108
1109 if git_dir.join("REBASE_HEAD").exists() {
1111 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1112 CascadeError::Git(git2::Error::from_str(&format!(
1113 "Failed to clean rebase state: {e}"
1114 )))
1115 })?;
1116 }
1117
1118 if git_dir.join("rebase-merge").exists() {
1119 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1120 CascadeError::Git(git2::Error::from_str(&format!(
1121 "Failed to clean rebase-merge: {e}"
1122 )))
1123 })?;
1124 }
1125
1126 if git_dir.join("rebase-apply").exists() {
1127 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1128 CascadeError::Git(git2::Error::from_str(&format!(
1129 "Failed to clean rebase-apply: {e}"
1130 )))
1131 })?;
1132 }
1133
1134 info!("Rebase aborted successfully");
1135 Ok(())
1136 }
1137
1138 pub fn continue_rebase(&self) -> Result<()> {
1140 info!("Continuing rebase operation");
1141
1142 if self.git_repo.has_conflicts()? {
1144 return Err(CascadeError::branch(
1145 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1146 ));
1147 }
1148
1149 self.git_repo.stage_conflict_resolved_files()?;
1151
1152 info!("Rebase continued successfully");
1153 Ok(())
1154 }
1155}
1156
1157impl RebaseResult {
1158 pub fn get_summary(&self) -> String {
1160 if self.success {
1161 format!("โ
{}", self.summary)
1162 } else {
1163 format!(
1164 "โ Rebase failed: {}",
1165 self.error.as_deref().unwrap_or("Unknown error")
1166 )
1167 }
1168 }
1169
1170 pub fn has_conflicts(&self) -> bool {
1172 !self.conflicts.is_empty()
1173 }
1174
1175 pub fn success_count(&self) -> usize {
1177 self.new_commits.len()
1178 }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184 use std::path::PathBuf;
1185 use std::process::Command;
1186 use tempfile::TempDir;
1187
1188 #[allow(dead_code)]
1189 fn create_test_repo() -> (TempDir, PathBuf) {
1190 let temp_dir = TempDir::new().unwrap();
1191 let repo_path = temp_dir.path().to_path_buf();
1192
1193 Command::new("git")
1195 .args(["init"])
1196 .current_dir(&repo_path)
1197 .output()
1198 .unwrap();
1199 Command::new("git")
1200 .args(["config", "user.name", "Test"])
1201 .current_dir(&repo_path)
1202 .output()
1203 .unwrap();
1204 Command::new("git")
1205 .args(["config", "user.email", "test@test.com"])
1206 .current_dir(&repo_path)
1207 .output()
1208 .unwrap();
1209
1210 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1212 Command::new("git")
1213 .args(["add", "."])
1214 .current_dir(&repo_path)
1215 .output()
1216 .unwrap();
1217 Command::new("git")
1218 .args(["commit", "-m", "Initial"])
1219 .current_dir(&repo_path)
1220 .output()
1221 .unwrap();
1222
1223 (temp_dir, repo_path)
1224 }
1225
1226 #[test]
1227 fn test_conflict_region_creation() {
1228 let region = ConflictRegion {
1229 start: 0,
1230 end: 50,
1231 start_line: 1,
1232 end_line: 3,
1233 our_content: "function test() {\n return true;\n}".to_string(),
1234 their_content: "function test() {\n return true;\n}".to_string(),
1235 };
1236
1237 assert_eq!(region.start_line, 1);
1238 assert_eq!(region.end_line, 3);
1239 assert!(region.our_content.contains("return true"));
1240 assert!(region.their_content.contains("return true"));
1241 }
1242
1243 #[test]
1244 fn test_rebase_strategies() {
1245 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1246 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1247 }
1248
1249 #[test]
1250 fn test_rebase_options() {
1251 let options = RebaseOptions::default();
1252 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1253 assert!(!options.interactive);
1254 assert!(options.auto_resolve);
1255 assert_eq!(options.max_retries, 3);
1256 }
1257
1258 #[test]
1259 fn test_cleanup_guard_tracks_branches() {
1260 let mut guard = TempBranchCleanupGuard::new();
1261 assert!(guard.branches.is_empty());
1262
1263 guard.add_branch("test-branch-1".to_string());
1264 guard.add_branch("test-branch-2".to_string());
1265
1266 assert_eq!(guard.branches.len(), 2);
1267 assert_eq!(guard.branches[0], "test-branch-1");
1268 assert_eq!(guard.branches[1], "test-branch-2");
1269 }
1270
1271 #[test]
1272 fn test_cleanup_guard_prevents_double_cleanup() {
1273 use std::process::Command;
1274 use tempfile::TempDir;
1275
1276 let temp_dir = TempDir::new().unwrap();
1278 let repo_path = temp_dir.path();
1279
1280 Command::new("git")
1281 .args(["init"])
1282 .current_dir(repo_path)
1283 .output()
1284 .unwrap();
1285
1286 Command::new("git")
1287 .args(["config", "user.name", "Test"])
1288 .current_dir(repo_path)
1289 .output()
1290 .unwrap();
1291
1292 Command::new("git")
1293 .args(["config", "user.email", "test@test.com"])
1294 .current_dir(repo_path)
1295 .output()
1296 .unwrap();
1297
1298 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1300 Command::new("git")
1301 .args(["add", "."])
1302 .current_dir(repo_path)
1303 .output()
1304 .unwrap();
1305 Command::new("git")
1306 .args(["commit", "-m", "initial"])
1307 .current_dir(repo_path)
1308 .output()
1309 .unwrap();
1310
1311 let git_repo = GitRepository::open(repo_path).unwrap();
1312
1313 git_repo.create_branch("test-temp", None).unwrap();
1315
1316 let mut guard = TempBranchCleanupGuard::new();
1317 guard.add_branch("test-temp".to_string());
1318
1319 guard.cleanup(&git_repo);
1321 assert!(guard.cleaned);
1322
1323 guard.cleanup(&git_repo);
1325 assert!(guard.cleaned);
1326 }
1327
1328 #[test]
1329 fn test_rebase_result() {
1330 let result = RebaseResult {
1331 success: true,
1332 branch_mapping: std::collections::HashMap::new(),
1333 conflicts: vec!["abc123".to_string()],
1334 new_commits: vec!["def456".to_string()],
1335 error: None,
1336 summary: "Test summary".to_string(),
1337 };
1338
1339 assert!(result.success);
1340 assert!(result.has_conflicts());
1341 assert_eq!(result.success_count(), 1);
1342 }
1343
1344 #[test]
1345 fn test_import_line_detection() {
1346 let (_temp_dir, repo_path) = create_test_repo();
1347 let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
1348 let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
1349 let options = RebaseOptions::default();
1350 let rebase_manager = RebaseManager::new(stack_manager, git_repo, options);
1351
1352 assert!(rebase_manager.is_import_line("import Foundation", "test.swift"));
1354 assert!(rebase_manager.is_import_line("@testable import MyModule", "test.swift"));
1355 assert!(!rebase_manager.is_import_line("class MyClass {", "test.swift"));
1356
1357 assert!(rebase_manager.is_import_line("import kotlin.collections.*", "test.kt"));
1359 assert!(rebase_manager.is_import_line("@file:JvmName(\"Utils\")", "test.kt"));
1360 assert!(!rebase_manager.is_import_line("fun myFunction() {", "test.kt"));
1361
1362 assert!(rebase_manager.is_import_line("using System;", "test.cs"));
1364 assert!(rebase_manager.is_import_line("using System.Collections.Generic;", "test.cs"));
1365 assert!(rebase_manager.is_import_line("extern alias GridV1;", "test.cs"));
1366 assert!(!rebase_manager.is_import_line("namespace MyNamespace {", "test.cs"));
1367
1368 assert!(rebase_manager.is_import_line("", "test.swift"));
1370 assert!(rebase_manager.is_import_line(" ", "test.kt"));
1371 assert!(rebase_manager.is_import_line("", "test.cs"));
1372 }
1373}