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
83struct TempBranchCleanupGuard {
89 branches: Vec<String>,
90 cleaned: bool,
91}
92
93impl TempBranchCleanupGuard {
94 fn new() -> Self {
95 Self {
96 branches: Vec::new(),
97 cleaned: false,
98 }
99 }
100
101 fn add_branch(&mut self, branch: String) {
102 self.branches.push(branch);
103 }
104
105 fn cleanup(&mut self, git_repo: &GitRepository) {
107 if self.cleaned || self.branches.is_empty() {
108 return;
109 }
110
111 info!("๐งน Cleaning up {} temporary branches", self.branches.len());
112 for branch in &self.branches {
113 if let Err(e) = git_repo.delete_branch_unsafe(branch) {
114 warn!("Failed to delete temp branch {}: {}", branch, e);
115 }
117 }
118 self.cleaned = true;
119 }
120}
121
122impl Drop for TempBranchCleanupGuard {
123 fn drop(&mut self) {
124 if !self.cleaned && !self.branches.is_empty() {
125 warn!(
128 "โ ๏ธ {} temporary branches were not cleaned up: {}",
129 self.branches.len(),
130 self.branches.join(", ")
131 );
132 warn!("Run 'ca cleanup' to remove orphaned temporary branches");
133 }
134 }
135}
136
137pub struct RebaseManager {
139 stack_manager: StackManager,
140 git_repo: GitRepository,
141 options: RebaseOptions,
142 conflict_analyzer: ConflictAnalyzer,
143}
144
145impl Default for RebaseOptions {
146 fn default() -> Self {
147 Self {
148 strategy: RebaseStrategy::ForcePush,
149 interactive: false,
150 target_base: None,
151 preserve_merges: true,
152 auto_resolve: true,
153 max_retries: 3,
154 skip_pull: None,
155 }
156 }
157}
158
159impl RebaseManager {
160 pub fn new(
162 stack_manager: StackManager,
163 git_repo: GitRepository,
164 options: RebaseOptions,
165 ) -> Self {
166 Self {
167 stack_manager,
168 git_repo,
169 options,
170 conflict_analyzer: ConflictAnalyzer::new(),
171 }
172 }
173
174 pub fn rebase_stack(&mut self, stack_id: &Uuid) -> Result<RebaseResult> {
176 info!("Starting rebase for stack {}", stack_id);
177
178 let stack = self
179 .stack_manager
180 .get_stack(stack_id)
181 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
182 .clone();
183
184 match self.options.strategy {
185 RebaseStrategy::ForcePush => self.rebase_with_force_push(&stack),
186 RebaseStrategy::Interactive => self.rebase_interactive(&stack),
187 }
188 }
189
190 fn rebase_with_force_push(&mut self, stack: &Stack) -> Result<RebaseResult> {
194 info!(
195 "Rebasing stack '{}' using force-push strategy (preserves PR history)",
196 stack.name
197 );
198
199 let mut result = RebaseResult {
200 success: true,
201 branch_mapping: HashMap::new(),
202 conflicts: Vec::new(),
203 new_commits: Vec::new(),
204 error: None,
205 summary: String::new(),
206 };
207
208 let target_base = self
209 .options
210 .target_base
211 .as_ref()
212 .unwrap_or(&stack.base_branch);
213
214 if self.git_repo.get_current_branch()? != *target_base {
216 self.git_repo.checkout_branch(target_base)?;
217 }
218
219 if !self.options.skip_pull.unwrap_or(false) {
222 if let Err(e) = self.pull_latest_changes(target_base) {
223 warn!("Failed to pull latest changes: {}", e);
224 }
225 } else {
226 debug!("Skipping pull - already done by caller");
227 }
228
229 debug!("Resetting working directory to clean state before rebase");
233 if let Err(e) = self.git_repo.reset_to_head() {
234 warn!("Failed to reset working directory: {}", e);
235 }
237
238 let mut current_base = target_base.clone();
239
240 let mut cleanup_guard = TempBranchCleanupGuard::new();
242
243 for (index, entry) in stack.entries.iter().enumerate() {
244 debug!("Processing entry {}: {}", index, entry.short_hash());
245
246 let original_branch = &entry.branch;
247
248 let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
250
251 cleanup_guard.add_branch(temp_branch.clone());
253
254 self.git_repo
256 .create_branch(&temp_branch, Some(¤t_base))?;
257 self.git_repo.checkout_branch(&temp_branch)?;
258
259 match self.cherry_pick_commit(&entry.commit_hash) {
261 Ok(new_commit_hash) => {
262 result.new_commits.push(new_commit_hash.clone());
263
264 info!(
267 "Force-pushing {} content to {} to preserve PR history",
268 temp_branch, original_branch
269 );
270
271 self.git_repo
272 .force_push_branch(original_branch, &temp_branch)?;
273
274 result
275 .branch_mapping
276 .insert(original_branch.clone(), original_branch.clone());
277
278 self.update_stack_entry(
280 stack.id,
281 &entry.id,
282 original_branch,
283 &new_commit_hash,
284 )?;
285
286 current_base = original_branch.clone();
288 }
289 Err(e) => {
290 warn!("Failed to cherry-pick {}: {}", entry.commit_hash, e);
291 result.conflicts.push(entry.commit_hash.clone());
292
293 if !self.options.auto_resolve {
294 result.success = false;
295 result.error = Some(format!("Conflict in {}: {}", entry.commit_hash, e));
296 break;
297 }
298
299 match self.auto_resolve_conflicts(&entry.commit_hash) {
301 Ok(_) => {
302 info!("Auto-resolved conflicts for {}", entry.commit_hash);
303 }
304 Err(resolve_err) => {
305 result.success = false;
306 result.error =
307 Some(format!("Could not resolve conflicts: {resolve_err}"));
308 break;
309 }
310 }
311 }
312 }
313 }
314
315 cleanup_guard.cleanup(&self.git_repo);
318
319 result.summary = format!(
320 "Rebased {} entries using force-push strategy. PR history preserved.",
321 stack.entries.len()
322 );
323
324 if result.success {
325 info!("โ
Rebase completed successfully - all PRs updated");
326 } else {
327 warn!("โ Rebase failed: {:?}", result.error);
328 }
329
330 Ok(result)
331 }
332
333 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
335 info!("Starting interactive rebase for stack '{}'", stack.name);
336
337 let mut result = RebaseResult {
338 success: true,
339 branch_mapping: HashMap::new(),
340 conflicts: Vec::new(),
341 new_commits: Vec::new(),
342 error: None,
343 summary: String::new(),
344 };
345
346 println!("๐ Interactive Rebase for Stack: {}", stack.name);
347 println!(" Base branch: {}", stack.base_branch);
348 println!(" Entries: {}", stack.entries.len());
349
350 if self.options.interactive {
351 println!("\nChoose action for each commit:");
352 println!(" (p)ick - apply the commit");
353 println!(" (s)kip - skip this commit");
354 println!(" (e)dit - edit the commit message");
355 println!(" (q)uit - abort the rebase");
356 }
357
358 for entry in &stack.entries {
361 println!(
362 " {} {} - {}",
363 entry.short_hash(),
364 entry.branch,
365 entry.short_message(50)
366 );
367
368 match self.cherry_pick_commit(&entry.commit_hash) {
370 Ok(new_commit) => result.new_commits.push(new_commit),
371 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
372 }
373 }
374
375 result.summary = format!(
376 "Interactive rebase processed {} commits",
377 stack.entries.len()
378 );
379 Ok(result)
380 }
381
382 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
384 debug!("Cherry-picking commit {}", commit_hash);
385
386 match self.git_repo.cherry_pick(commit_hash) {
388 Ok(new_commit_hash) => {
389 info!("โ
Cherry-picked {} -> {}", commit_hash, new_commit_hash);
390
391 match self.git_repo.get_staged_files() {
393 Ok(staged_files) if !staged_files.is_empty() => {
394 warn!(
395 "Found {} staged files after successful cherry-pick. Committing them.",
396 staged_files.len()
397 );
398
399 let cleanup_message =
401 format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
402 match self.git_repo.commit_staged_changes(&cleanup_message) {
403 Ok(Some(cleanup_commit)) => {
404 info!(
405 "โ
Committed {} leftover staged files as {}",
406 staged_files.len(),
407 &cleanup_commit[..8]
408 );
409 Ok(new_commit_hash) }
411 Ok(None) => {
412 Ok(new_commit_hash)
414 }
415 Err(commit_err) => {
416 warn!("Failed to commit leftover staged changes: {}", commit_err);
417 Ok(new_commit_hash)
419 }
420 }
421 }
422 _ => {
423 Ok(new_commit_hash)
425 }
426 }
427 }
428 Err(e) => {
429 match self.git_repo.get_staged_files() {
431 Ok(staged_files) if !staged_files.is_empty() => {
432 warn!(
433 "Cherry-pick failed but found {} staged files. Attempting to commit them.",
434 staged_files.len()
435 );
436
437 let commit_message =
439 format!("Cherry-pick (partial): {}", &commit_hash[..8]);
440 match self.git_repo.commit_staged_changes(&commit_message) {
441 Ok(Some(commit_hash)) => {
442 info!("โ
Committed staged changes as {}", &commit_hash[..8]);
443 Ok(commit_hash)
444 }
445 Ok(None) => {
446 Err(e)
448 }
449 Err(commit_err) => {
450 warn!("Failed to commit staged changes: {}", commit_err);
451 Err(e)
452 }
453 }
454 }
455 _ => {
456 Err(e)
458 }
459 }
460 }
461 }
462 }
463
464 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
466 debug!("Attempting to auto-resolve conflicts for {}", commit_hash);
467
468 if !self.git_repo.has_conflicts()? {
470 return Ok(true);
471 }
472
473 let conflicted_files = self.git_repo.get_conflicted_files()?;
474
475 if conflicted_files.is_empty() {
476 return Ok(true);
477 }
478
479 info!(
480 "Found conflicts in {} files: {:?}",
481 conflicted_files.len(),
482 conflicted_files
483 );
484
485 let analysis = self
487 .conflict_analyzer
488 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
489
490 info!(
491 "๐ Conflict analysis: {} total conflicts, {} auto-resolvable",
492 analysis.total_conflicts, analysis.auto_resolvable_count
493 );
494
495 for recommendation in &analysis.recommendations {
497 info!("๐ก {}", recommendation);
498 }
499
500 let mut resolved_count = 0;
501 let mut failed_files = Vec::new();
502
503 for file_analysis in &analysis.files {
504 if file_analysis.auto_resolvable {
505 match self.resolve_file_conflicts_enhanced(
506 &file_analysis.file_path,
507 &file_analysis.conflicts,
508 ) {
509 Ok(ConflictResolution::Resolved) => {
510 resolved_count += 1;
511 info!("โ
Auto-resolved conflicts in {}", file_analysis.file_path);
512 }
513 Ok(ConflictResolution::TooComplex) => {
514 debug!(
515 "โ ๏ธ Conflicts in {} are too complex for auto-resolution",
516 file_analysis.file_path
517 );
518 failed_files.push(file_analysis.file_path.clone());
519 }
520 Err(e) => {
521 warn!(
522 "โ Failed to resolve conflicts in {}: {}",
523 file_analysis.file_path, e
524 );
525 failed_files.push(file_analysis.file_path.clone());
526 }
527 }
528 } else {
529 failed_files.push(file_analysis.file_path.clone());
530 info!(
531 "โ ๏ธ {} requires manual resolution ({} conflicts)",
532 file_analysis.file_path,
533 file_analysis.conflicts.len()
534 );
535 }
536 }
537
538 if resolved_count > 0 {
539 info!(
540 "๐ Auto-resolved conflicts in {}/{} files",
541 resolved_count,
542 conflicted_files.len()
543 );
544
545 self.git_repo.stage_conflict_resolved_files()?;
547 }
548
549 let all_resolved = failed_files.is_empty();
551
552 if !all_resolved {
553 info!(
554 "โ ๏ธ {} files still need manual resolution: {:?}",
555 failed_files.len(),
556 failed_files
557 );
558 }
559
560 Ok(all_resolved)
561 }
562
563 fn resolve_file_conflicts_enhanced(
565 &self,
566 file_path: &str,
567 conflicts: &[crate::git::ConflictRegion],
568 ) -> Result<ConflictResolution> {
569 let repo_path = self.git_repo.path();
570 let full_path = repo_path.join(file_path);
571
572 let mut content = std::fs::read_to_string(&full_path)
574 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
575
576 if conflicts.is_empty() {
577 return Ok(ConflictResolution::Resolved);
578 }
579
580 info!(
581 "Resolving {} conflicts in {} using enhanced analysis",
582 conflicts.len(),
583 file_path
584 );
585
586 let mut any_resolved = false;
587
588 for conflict in conflicts.iter().rev() {
590 match self.resolve_single_conflict_enhanced(conflict) {
591 Ok(Some(resolution)) => {
592 let before = &content[..conflict.start_pos];
594 let after = &content[conflict.end_pos..];
595 content = format!("{before}{resolution}{after}");
596 any_resolved = true;
597 debug!(
598 "โ
Resolved {} conflict at lines {}-{} in {}",
599 format!("{:?}", conflict.conflict_type).to_lowercase(),
600 conflict.start_line,
601 conflict.end_line,
602 file_path
603 );
604 }
605 Ok(None) => {
606 debug!(
607 "โ ๏ธ {} conflict at lines {}-{} in {} requires manual resolution",
608 format!("{:?}", conflict.conflict_type).to_lowercase(),
609 conflict.start_line,
610 conflict.end_line,
611 file_path
612 );
613 return Ok(ConflictResolution::TooComplex);
614 }
615 Err(e) => {
616 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
617 return Ok(ConflictResolution::TooComplex);
618 }
619 }
620 }
621
622 if any_resolved {
623 let remaining_conflicts = self.parse_conflict_markers(&content)?;
625
626 if remaining_conflicts.is_empty() {
627 crate::utils::atomic_file::write_string(&full_path, &content)?;
629
630 return Ok(ConflictResolution::Resolved);
631 } else {
632 info!(
633 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
634 file_path,
635 remaining_conflicts.len()
636 );
637 }
638 }
639
640 Ok(ConflictResolution::TooComplex)
641 }
642
643 fn resolve_single_conflict_enhanced(
645 &self,
646 conflict: &crate::git::ConflictRegion,
647 ) -> Result<Option<String>> {
648 debug!(
649 "Resolving {} conflict in {} (lines {}-{})",
650 format!("{:?}", conflict.conflict_type).to_lowercase(),
651 conflict.file_path,
652 conflict.start_line,
653 conflict.end_line
654 );
655
656 use crate::git::ConflictType;
657
658 match conflict.conflict_type {
659 ConflictType::Whitespace => {
660 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
662 Ok(Some(conflict.our_content.clone()))
663 } else {
664 Ok(Some(conflict.their_content.clone()))
665 }
666 }
667 ConflictType::LineEnding => {
668 let normalized = conflict
670 .our_content
671 .replace("\r\n", "\n")
672 .replace('\r', "\n");
673 Ok(Some(normalized))
674 }
675 ConflictType::PureAddition => {
676 if conflict.our_content.is_empty() {
678 Ok(Some(conflict.their_content.clone()))
679 } else if conflict.their_content.is_empty() {
680 Ok(Some(conflict.our_content.clone()))
681 } else {
682 let combined = format!("{}\n{}", conflict.our_content, conflict.their_content);
684 Ok(Some(combined))
685 }
686 }
687 ConflictType::ImportMerge => {
688 let mut all_imports: Vec<&str> = conflict
690 .our_content
691 .lines()
692 .chain(conflict.their_content.lines())
693 .collect();
694 all_imports.sort();
695 all_imports.dedup();
696 Ok(Some(all_imports.join("\n")))
697 }
698 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
699 Ok(None)
701 }
702 }
703 }
704
705 #[allow(dead_code)]
707 fn resolve_file_conflicts(&self, file_path: &str) -> Result<ConflictResolution> {
708 let repo_path = self.git_repo.path();
709 let full_path = repo_path.join(file_path);
710
711 let content = std::fs::read_to_string(&full_path)
713 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
714
715 let conflicts = self.parse_conflict_markers(&content)?;
717
718 if conflicts.is_empty() {
719 return Ok(ConflictResolution::Resolved);
721 }
722
723 info!(
724 "Found {} conflict regions in {}",
725 conflicts.len(),
726 file_path
727 );
728
729 let mut resolved_content = content;
731 let mut any_resolved = false;
732
733 for conflict in conflicts.iter().rev() {
735 match self.resolve_single_conflict(conflict, file_path) {
736 Ok(Some(resolution)) => {
737 let before = &resolved_content[..conflict.start];
739 let after = &resolved_content[conflict.end..];
740 resolved_content = format!("{before}{resolution}{after}");
741 any_resolved = true;
742 debug!(
743 "โ
Resolved conflict at lines {}-{} in {}",
744 conflict.start_line, conflict.end_line, file_path
745 );
746 }
747 Ok(None) => {
748 debug!(
749 "โ ๏ธ Conflict at lines {}-{} in {} too complex for auto-resolution",
750 conflict.start_line, conflict.end_line, file_path
751 );
752 }
753 Err(e) => {
754 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
755 }
756 }
757 }
758
759 if any_resolved {
760 let remaining_conflicts = self.parse_conflict_markers(&resolved_content)?;
762
763 if remaining_conflicts.is_empty() {
764 crate::utils::atomic_file::write_string(&full_path, &resolved_content)?;
766
767 return Ok(ConflictResolution::Resolved);
768 } else {
769 info!(
770 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
771 file_path,
772 remaining_conflicts.len()
773 );
774 }
775 }
776
777 Ok(ConflictResolution::TooComplex)
778 }
779
780 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
782 let lines: Vec<&str> = content.lines().collect();
783 let mut conflicts = Vec::new();
784 let mut i = 0;
785
786 while i < lines.len() {
787 if lines[i].starts_with("<<<<<<<") {
788 let start_line = i + 1;
790 let mut separator_line = None;
791 let mut end_line = None;
792
793 for (j, line) in lines.iter().enumerate().skip(i + 1) {
795 if line.starts_with("=======") {
796 separator_line = Some(j + 1);
797 } else if line.starts_with(">>>>>>>") {
798 end_line = Some(j + 1);
799 break;
800 }
801 }
802
803 if let (Some(sep), Some(end)) = (separator_line, end_line) {
804 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
806 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
807
808 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
809 let their_content = lines[sep..(end - 1)].join("\n");
810
811 conflicts.push(ConflictRegion {
812 start: start_pos,
813 end: end_pos,
814 start_line,
815 end_line: end,
816 our_content,
817 their_content,
818 });
819
820 i = end;
821 } else {
822 i += 1;
823 }
824 } else {
825 i += 1;
826 }
827 }
828
829 Ok(conflicts)
830 }
831
832 fn resolve_single_conflict(
834 &self,
835 conflict: &ConflictRegion,
836 file_path: &str,
837 ) -> Result<Option<String>> {
838 debug!(
839 "Analyzing conflict in {} (lines {}-{})",
840 file_path, conflict.start_line, conflict.end_line
841 );
842
843 if let Some(resolved) = self.resolve_whitespace_conflict(conflict)? {
845 debug!("Resolved as whitespace-only conflict");
846 return Ok(Some(resolved));
847 }
848
849 if let Some(resolved) = self.resolve_line_ending_conflict(conflict)? {
851 debug!("Resolved as line ending conflict");
852 return Ok(Some(resolved));
853 }
854
855 if let Some(resolved) = self.resolve_addition_conflict(conflict)? {
857 debug!("Resolved as pure addition conflict");
858 return Ok(Some(resolved));
859 }
860
861 if let Some(resolved) = self.resolve_import_conflict(conflict, file_path)? {
863 debug!("Resolved as import reordering conflict");
864 return Ok(Some(resolved));
865 }
866
867 Ok(None)
869 }
870
871 fn resolve_whitespace_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
873 let our_normalized = self.normalize_whitespace(&conflict.our_content);
874 let their_normalized = self.normalize_whitespace(&conflict.their_content);
875
876 if our_normalized == their_normalized {
877 let resolved =
879 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
880 conflict.our_content.clone()
881 } else {
882 conflict.their_content.clone()
883 };
884
885 return Ok(Some(resolved));
886 }
887
888 Ok(None)
889 }
890
891 fn resolve_line_ending_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
893 let our_normalized = conflict
894 .our_content
895 .replace("\r\n", "\n")
896 .replace('\r', "\n");
897 let their_normalized = conflict
898 .their_content
899 .replace("\r\n", "\n")
900 .replace('\r', "\n");
901
902 if our_normalized == their_normalized {
903 return Ok(Some(our_normalized));
905 }
906
907 Ok(None)
908 }
909
910 fn resolve_addition_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
912 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
913 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
914
915 if our_lines.is_empty() {
917 return Ok(Some(conflict.their_content.clone()));
918 }
919 if their_lines.is_empty() {
920 return Ok(Some(conflict.our_content.clone()));
921 }
922
923 let mut merged_lines = Vec::new();
925 let mut our_idx = 0;
926 let mut their_idx = 0;
927
928 while our_idx < our_lines.len() || their_idx < their_lines.len() {
929 if our_idx >= our_lines.len() {
930 merged_lines.extend_from_slice(&their_lines[their_idx..]);
932 break;
933 } else if their_idx >= their_lines.len() {
934 merged_lines.extend_from_slice(&our_lines[our_idx..]);
936 break;
937 } else if our_lines[our_idx] == their_lines[their_idx] {
938 merged_lines.push(our_lines[our_idx]);
940 our_idx += 1;
941 their_idx += 1;
942 } else {
943 return Ok(None);
945 }
946 }
947
948 Ok(Some(merged_lines.join("\n")))
949 }
950
951 fn resolve_import_conflict(
953 &self,
954 conflict: &ConflictRegion,
955 file_path: &str,
956 ) -> Result<Option<String>> {
957 let is_import_file = file_path.ends_with(".rs")
959 || file_path.ends_with(".py")
960 || file_path.ends_with(".js")
961 || file_path.ends_with(".ts")
962 || file_path.ends_with(".go")
963 || file_path.ends_with(".java")
964 || file_path.ends_with(".swift")
965 || file_path.ends_with(".kt")
966 || file_path.ends_with(".cs");
967
968 if !is_import_file {
969 return Ok(None);
970 }
971
972 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
973 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
974
975 let our_imports = our_lines
977 .iter()
978 .all(|line| self.is_import_line(line, file_path));
979 let their_imports = their_lines
980 .iter()
981 .all(|line| self.is_import_line(line, file_path));
982
983 if our_imports && their_imports {
984 let mut all_imports: Vec<&str> = our_lines.into_iter().chain(their_lines).collect();
986 all_imports.sort();
987 all_imports.dedup();
988
989 return Ok(Some(all_imports.join("\n")));
990 }
991
992 Ok(None)
993 }
994
995 fn is_import_line(&self, line: &str, file_path: &str) -> bool {
997 let trimmed = line.trim();
998
999 if trimmed.is_empty() {
1000 return true; }
1002
1003 if file_path.ends_with(".rs") {
1004 return trimmed.starts_with("use ") || trimmed.starts_with("extern crate");
1005 } else if file_path.ends_with(".py") {
1006 return trimmed.starts_with("import ") || trimmed.starts_with("from ");
1007 } else if file_path.ends_with(".js") || file_path.ends_with(".ts") {
1008 return trimmed.starts_with("import ")
1009 || trimmed.starts_with("const ")
1010 || trimmed.starts_with("require(");
1011 } else if file_path.ends_with(".go") {
1012 return trimmed.starts_with("import ") || trimmed == "import (" || trimmed == ")";
1013 } else if file_path.ends_with(".java") {
1014 return trimmed.starts_with("import ");
1015 } else if file_path.ends_with(".swift") {
1016 return trimmed.starts_with("import ") || trimmed.starts_with("@testable import ");
1017 } else if file_path.ends_with(".kt") {
1018 return trimmed.starts_with("import ") || trimmed.starts_with("@file:");
1019 } else if file_path.ends_with(".cs") {
1020 return trimmed.starts_with("using ") || trimmed.starts_with("extern alias ");
1021 }
1022
1023 false
1024 }
1025
1026 fn normalize_whitespace(&self, content: &str) -> String {
1028 content
1029 .lines()
1030 .map(|line| line.trim())
1031 .filter(|line| !line.is_empty())
1032 .collect::<Vec<_>>()
1033 .join("\n")
1034 }
1035
1036 fn update_stack_entry(
1039 &mut self,
1040 stack_id: Uuid,
1041 entry_id: &Uuid,
1042 _new_branch: &str,
1043 new_commit_hash: &str,
1044 ) -> Result<()> {
1045 debug!(
1046 "Updating entry {} in stack {} with new commit {}",
1047 entry_id, stack_id, new_commit_hash
1048 );
1049
1050 let stack = self
1052 .stack_manager
1053 .get_stack_mut(&stack_id)
1054 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1055
1056 if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == *entry_id) {
1058 debug!(
1059 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch '{}')",
1060 entry_id, entry.commit_hash, new_commit_hash, entry.branch
1061 );
1062
1063 entry.commit_hash = new_commit_hash.to_string();
1066
1067 debug!(
1070 "Successfully updated entry {} in stack {}",
1071 entry_id, stack_id
1072 );
1073 Ok(())
1074 } else {
1075 Err(CascadeError::config(format!(
1076 "Entry {entry_id} not found in stack {stack_id}"
1077 )))
1078 }
1079 }
1080
1081 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1083 info!("Pulling latest changes for branch {}", branch);
1084
1085 match self.git_repo.fetch() {
1087 Ok(_) => {
1088 debug!("Fetch successful");
1089 match self.git_repo.pull(branch) {
1091 Ok(_) => {
1092 info!("Pull completed successfully for {}", branch);
1093 Ok(())
1094 }
1095 Err(e) => {
1096 warn!("Pull failed for {}: {}", branch, e);
1097 Ok(())
1099 }
1100 }
1101 }
1102 Err(e) => {
1103 warn!("Fetch failed: {}", e);
1104 Ok(())
1106 }
1107 }
1108 }
1109
1110 pub fn is_rebase_in_progress(&self) -> bool {
1112 let git_dir = self.git_repo.path().join(".git");
1114 git_dir.join("REBASE_HEAD").exists()
1115 || git_dir.join("rebase-merge").exists()
1116 || git_dir.join("rebase-apply").exists()
1117 }
1118
1119 pub fn abort_rebase(&self) -> Result<()> {
1121 info!("Aborting rebase operation");
1122
1123 let git_dir = self.git_repo.path().join(".git");
1124
1125 if git_dir.join("REBASE_HEAD").exists() {
1127 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1128 CascadeError::Git(git2::Error::from_str(&format!(
1129 "Failed to clean rebase state: {e}"
1130 )))
1131 })?;
1132 }
1133
1134 if git_dir.join("rebase-merge").exists() {
1135 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1136 CascadeError::Git(git2::Error::from_str(&format!(
1137 "Failed to clean rebase-merge: {e}"
1138 )))
1139 })?;
1140 }
1141
1142 if git_dir.join("rebase-apply").exists() {
1143 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1144 CascadeError::Git(git2::Error::from_str(&format!(
1145 "Failed to clean rebase-apply: {e}"
1146 )))
1147 })?;
1148 }
1149
1150 info!("Rebase aborted successfully");
1151 Ok(())
1152 }
1153
1154 pub fn continue_rebase(&self) -> Result<()> {
1156 info!("Continuing rebase operation");
1157
1158 if self.git_repo.has_conflicts()? {
1160 return Err(CascadeError::branch(
1161 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1162 ));
1163 }
1164
1165 self.git_repo.stage_conflict_resolved_files()?;
1167
1168 info!("Rebase continued successfully");
1169 Ok(())
1170 }
1171}
1172
1173impl RebaseResult {
1174 pub fn get_summary(&self) -> String {
1176 if self.success {
1177 format!("โ
{}", self.summary)
1178 } else {
1179 format!(
1180 "โ Rebase failed: {}",
1181 self.error.as_deref().unwrap_or("Unknown error")
1182 )
1183 }
1184 }
1185
1186 pub fn has_conflicts(&self) -> bool {
1188 !self.conflicts.is_empty()
1189 }
1190
1191 pub fn success_count(&self) -> usize {
1193 self.new_commits.len()
1194 }
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199 use super::*;
1200 use std::path::PathBuf;
1201 use std::process::Command;
1202 use tempfile::TempDir;
1203
1204 #[allow(dead_code)]
1205 fn create_test_repo() -> (TempDir, PathBuf) {
1206 let temp_dir = TempDir::new().unwrap();
1207 let repo_path = temp_dir.path().to_path_buf();
1208
1209 Command::new("git")
1211 .args(["init"])
1212 .current_dir(&repo_path)
1213 .output()
1214 .unwrap();
1215 Command::new("git")
1216 .args(["config", "user.name", "Test"])
1217 .current_dir(&repo_path)
1218 .output()
1219 .unwrap();
1220 Command::new("git")
1221 .args(["config", "user.email", "test@test.com"])
1222 .current_dir(&repo_path)
1223 .output()
1224 .unwrap();
1225
1226 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1228 Command::new("git")
1229 .args(["add", "."])
1230 .current_dir(&repo_path)
1231 .output()
1232 .unwrap();
1233 Command::new("git")
1234 .args(["commit", "-m", "Initial"])
1235 .current_dir(&repo_path)
1236 .output()
1237 .unwrap();
1238
1239 (temp_dir, repo_path)
1240 }
1241
1242 #[test]
1243 fn test_conflict_region_creation() {
1244 let region = ConflictRegion {
1245 start: 0,
1246 end: 50,
1247 start_line: 1,
1248 end_line: 3,
1249 our_content: "function test() {\n return true;\n}".to_string(),
1250 their_content: "function test() {\n return true;\n}".to_string(),
1251 };
1252
1253 assert_eq!(region.start_line, 1);
1254 assert_eq!(region.end_line, 3);
1255 assert!(region.our_content.contains("return true"));
1256 assert!(region.their_content.contains("return true"));
1257 }
1258
1259 #[test]
1260 fn test_rebase_strategies() {
1261 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1262 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1263 }
1264
1265 #[test]
1266 fn test_rebase_options() {
1267 let options = RebaseOptions::default();
1268 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1269 assert!(!options.interactive);
1270 assert!(options.auto_resolve);
1271 assert_eq!(options.max_retries, 3);
1272 }
1273
1274 #[test]
1275 fn test_cleanup_guard_tracks_branches() {
1276 let mut guard = TempBranchCleanupGuard::new();
1277 assert!(guard.branches.is_empty());
1278
1279 guard.add_branch("test-branch-1".to_string());
1280 guard.add_branch("test-branch-2".to_string());
1281
1282 assert_eq!(guard.branches.len(), 2);
1283 assert_eq!(guard.branches[0], "test-branch-1");
1284 assert_eq!(guard.branches[1], "test-branch-2");
1285 }
1286
1287 #[test]
1288 fn test_cleanup_guard_prevents_double_cleanup() {
1289 use std::process::Command;
1290 use tempfile::TempDir;
1291
1292 let temp_dir = TempDir::new().unwrap();
1294 let repo_path = temp_dir.path();
1295
1296 Command::new("git")
1297 .args(["init"])
1298 .current_dir(repo_path)
1299 .output()
1300 .unwrap();
1301
1302 Command::new("git")
1303 .args(["config", "user.name", "Test"])
1304 .current_dir(repo_path)
1305 .output()
1306 .unwrap();
1307
1308 Command::new("git")
1309 .args(["config", "user.email", "test@test.com"])
1310 .current_dir(repo_path)
1311 .output()
1312 .unwrap();
1313
1314 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1316 Command::new("git")
1317 .args(["add", "."])
1318 .current_dir(repo_path)
1319 .output()
1320 .unwrap();
1321 Command::new("git")
1322 .args(["commit", "-m", "initial"])
1323 .current_dir(repo_path)
1324 .output()
1325 .unwrap();
1326
1327 let git_repo = GitRepository::open(repo_path).unwrap();
1328
1329 git_repo.create_branch("test-temp", None).unwrap();
1331
1332 let mut guard = TempBranchCleanupGuard::new();
1333 guard.add_branch("test-temp".to_string());
1334
1335 guard.cleanup(&git_repo);
1337 assert!(guard.cleaned);
1338
1339 guard.cleanup(&git_repo);
1341 assert!(guard.cleaned);
1342 }
1343
1344 #[test]
1345 fn test_rebase_result() {
1346 let result = RebaseResult {
1347 success: true,
1348 branch_mapping: std::collections::HashMap::new(),
1349 conflicts: vec!["abc123".to_string()],
1350 new_commits: vec!["def456".to_string()],
1351 error: None,
1352 summary: "Test summary".to_string(),
1353 };
1354
1355 assert!(result.success);
1356 assert!(result.has_conflicts());
1357 assert_eq!(result.success_count(), 1);
1358 }
1359
1360 #[test]
1361 fn test_import_line_detection() {
1362 let (_temp_dir, repo_path) = create_test_repo();
1363 let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
1364 let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
1365 let options = RebaseOptions::default();
1366 let rebase_manager = RebaseManager::new(stack_manager, git_repo, options);
1367
1368 assert!(rebase_manager.is_import_line("import Foundation", "test.swift"));
1370 assert!(rebase_manager.is_import_line("@testable import MyModule", "test.swift"));
1371 assert!(!rebase_manager.is_import_line("class MyClass {", "test.swift"));
1372
1373 assert!(rebase_manager.is_import_line("import kotlin.collections.*", "test.kt"));
1375 assert!(rebase_manager.is_import_line("@file:JvmName(\"Utils\")", "test.kt"));
1376 assert!(!rebase_manager.is_import_line("fun myFunction() {", "test.kt"));
1377
1378 assert!(rebase_manager.is_import_line("using System;", "test.cs"));
1380 assert!(rebase_manager.is_import_line("using System.Collections.Generic;", "test.cs"));
1381 assert!(rebase_manager.is_import_line("extern alias GridV1;", "test.cs"));
1382 assert!(!rebase_manager.is_import_line("namespace MyNamespace {", "test.cs"));
1383
1384 assert!(rebase_manager.is_import_line("", "test.swift"));
1386 assert!(rebase_manager.is_import_line(" ", "test.kt"));
1387 assert!(rebase_manager.is_import_line("", "test.cs"));
1388 }
1389}