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