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