1use crate::errors::{CascadeError, Result};
2use crate::git::{ConflictAnalyzer, GitRepository};
3use crate::stack::{Stack, StackManager};
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::{debug, info, warn};
8use uuid::Uuid;
9
10#[derive(Debug, Clone)]
12enum ConflictResolution {
13 Resolved,
15 TooComplex,
17}
18
19#[derive(Debug, Clone)]
21#[allow(dead_code)]
22struct ConflictRegion {
23 start: usize,
25 end: usize,
27 start_line: usize,
29 end_line: usize,
31 our_content: String,
33 their_content: String,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub enum RebaseStrategy {
40 ForcePush,
43 Interactive,
45}
46
47#[derive(Debug, Clone)]
49pub struct RebaseOptions {
50 pub strategy: RebaseStrategy,
52 pub interactive: bool,
54 pub target_base: Option<String>,
56 pub preserve_merges: bool,
58 pub auto_resolve: bool,
60 pub max_retries: usize,
62 pub skip_pull: Option<bool>,
64}
65
66#[derive(Debug)]
68pub struct RebaseResult {
69 pub success: bool,
71 pub branch_mapping: HashMap<String, String>,
73 pub conflicts: Vec<String>,
75 pub new_commits: Vec<String>,
77 pub error: Option<String>,
79 pub summary: String,
81}
82
83#[allow(dead_code)]
89struct TempBranchCleanupGuard {
90 branches: Vec<String>,
91 cleaned: bool,
92}
93
94#[allow(dead_code)]
95impl TempBranchCleanupGuard {
96 fn new() -> Self {
97 Self {
98 branches: Vec::new(),
99 cleaned: false,
100 }
101 }
102
103 fn add_branch(&mut self, branch: String) {
104 self.branches.push(branch);
105 }
106
107 fn cleanup(&mut self, git_repo: &GitRepository) {
109 if self.cleaned || self.branches.is_empty() {
110 return;
111 }
112
113 info!("๐งน Cleaning up {} temporary branches", self.branches.len());
114 for branch in &self.branches {
115 if let Err(e) = git_repo.delete_branch_unsafe(branch) {
116 warn!("Failed to delete temp branch {}: {}", branch, e);
117 }
119 }
120 self.cleaned = true;
121 }
122}
123
124impl Drop for TempBranchCleanupGuard {
125 fn drop(&mut self) {
126 if !self.cleaned && !self.branches.is_empty() {
127 warn!(
130 "โ ๏ธ {} temporary branches were not cleaned up: {}",
131 self.branches.len(),
132 self.branches.join(", ")
133 );
134 warn!("Run 'ca cleanup' to remove orphaned temporary branches");
135 }
136 }
137}
138
139pub struct RebaseManager {
141 stack_manager: StackManager,
142 git_repo: GitRepository,
143 options: RebaseOptions,
144 conflict_analyzer: ConflictAnalyzer,
145}
146
147impl Default for RebaseOptions {
148 fn default() -> Self {
149 Self {
150 strategy: RebaseStrategy::ForcePush,
151 interactive: false,
152 target_base: None,
153 preserve_merges: true,
154 auto_resolve: true,
155 max_retries: 3,
156 skip_pull: None,
157 }
158 }
159}
160
161impl RebaseManager {
162 pub fn new(
164 stack_manager: StackManager,
165 git_repo: GitRepository,
166 options: RebaseOptions,
167 ) -> Self {
168 Self {
169 stack_manager,
170 git_repo,
171 options,
172 conflict_analyzer: ConflictAnalyzer::new(),
173 }
174 }
175
176 pub fn rebase_stack(&mut self, stack_id: &Uuid) -> Result<RebaseResult> {
178 info!("Starting rebase for stack {}", stack_id);
179
180 let stack = self
181 .stack_manager
182 .get_stack(stack_id)
183 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
184 .clone();
185
186 match self.options.strategy {
187 RebaseStrategy::ForcePush => self.rebase_with_force_push(&stack),
188 RebaseStrategy::Interactive => self.rebase_interactive(&stack),
189 }
190 }
191
192 fn rebase_with_force_push(&mut self, stack: &Stack) -> Result<RebaseResult> {
196 use crate::cli::output::Output;
197
198 Output::section(format!("Rebasing stack: {}", stack.name));
199
200 let mut result = RebaseResult {
201 success: true,
202 branch_mapping: HashMap::new(),
203 conflicts: Vec::new(),
204 new_commits: Vec::new(),
205 error: None,
206 summary: String::new(),
207 };
208
209 let target_base = self
210 .options
211 .target_base
212 .as_ref()
213 .unwrap_or(&stack.base_branch)
214 .clone(); let original_branch = self.git_repo.get_current_branch().ok();
218
219 if self.git_repo.get_current_branch()? != target_base {
221 self.git_repo.checkout_branch(&target_base)?;
222 }
223
224 if !self.options.skip_pull.unwrap_or(false) {
226 if let Err(e) = self.pull_latest_changes(&target_base) {
227 Output::warning(format!("Could not pull latest changes: {}", e));
228 }
229 }
230
231 if let Err(e) = self.git_repo.reset_to_head() {
233 Output::warning(format!("Could not reset working directory: {}", e));
234 }
235
236 let mut current_base = target_base.clone();
237 let entry_count = stack.entries.len();
238 let mut pushed_count = 0;
239 let mut skipped_count = 0;
240 let mut temp_branches: Vec<String> = Vec::new(); println!(); let plural = if entry_count == 1 { "entry" } else { "entries" };
244 println!("๐ Rebasing {} {}", entry_count, plural);
245
246 for (index, entry) in stack.entries.iter().enumerate() {
247 let original_branch = &entry.branch;
248
249 let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
252 temp_branches.push(temp_branch.clone()); self.git_repo
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.force_push_single_branch(original_branch)?;
283 pushed_count += 1;
284 } else {
285 let tree_char = if index + 1 == entry_count {
286 "โโ"
287 } else {
288 "โโ"
289 };
290 println!(" {} {} (not submitted)", tree_char, original_branch);
291 skipped_count += 1;
292 }
293
294 result
295 .branch_mapping
296 .insert(original_branch.clone(), original_branch.clone());
297
298 self.update_stack_entry(
300 stack.id,
301 &entry.id,
302 original_branch,
303 &rebased_commit_id,
304 )?;
305
306 current_base = original_branch.clone();
308 }
309 Err(e) => {
310 println!(); Output::error(format!("Conflict in {}: {}", &entry.commit_hash[..8], e));
312 result.conflicts.push(entry.commit_hash.clone());
313
314 if !self.options.auto_resolve {
315 result.success = false;
316 result.error = Some(format!("Conflict in {}: {}", entry.commit_hash, e));
317 break;
318 }
319
320 match self.auto_resolve_conflicts(&entry.commit_hash) {
322 Ok(_) => {
323 Output::success("Auto-resolved conflicts");
324 }
325 Err(resolve_err) => {
326 result.success = false;
327 result.error =
328 Some(format!("Could not resolve conflicts: {resolve_err}"));
329 break;
330 }
331 }
332 }
333 }
334 }
335
336 if !temp_branches.is_empty() {
339 if let Err(e) = self.git_repo.checkout_branch(&target_base) {
341 Output::warning(format!("Could not checkout base for cleanup: {}", e));
342 }
343
344 for temp_branch in &temp_branches {
346 if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
347 debug!("Could not delete temp branch {}: {}", temp_branch, e);
348 }
349 }
350 }
351
352 if let Some(orig_branch) = original_branch {
354 if let Err(e) = self.git_repo.checkout_branch(&orig_branch) {
355 Output::warning(format!(
356 "Could not return to original branch '{}': {}",
357 orig_branch, e
358 ));
359 }
360 }
361
362 result.summary = if pushed_count > 0 {
364 let pr_plural = if pushed_count == 1 { "" } else { "s" };
365 let entry_plural = if entry_count == 1 { "entry" } else { "entries" };
366
367 if skipped_count > 0 {
368 format!(
369 "{} {} rebased ({} PR{} updated, {} not yet submitted)",
370 entry_count, entry_plural, pushed_count, pr_plural, skipped_count
371 )
372 } else {
373 format!(
374 "{} {} rebased ({} PR{} updated)",
375 entry_count, entry_plural, pushed_count, pr_plural
376 )
377 }
378 } else {
379 let plural = if entry_count == 1 { "entry" } else { "entries" };
380 format!("{} {} rebased (no PRs to update yet)", entry_count, plural)
381 };
382
383 println!(); if result.success {
386 Output::success(&result.summary);
387 } else {
388 Output::error(format!("Rebase failed: {:?}", result.error));
389 }
390
391 self.stack_manager.save_to_disk()?;
393
394 Ok(result)
395 }
396
397 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
399 info!("Starting interactive rebase for stack '{}'", stack.name);
400
401 let mut result = RebaseResult {
402 success: true,
403 branch_mapping: HashMap::new(),
404 conflicts: Vec::new(),
405 new_commits: Vec::new(),
406 error: None,
407 summary: String::new(),
408 };
409
410 println!("๐ Interactive Rebase for Stack: {}", stack.name);
411 println!(" Base branch: {}", stack.base_branch);
412 println!(" Entries: {}", stack.entries.len());
413
414 if self.options.interactive {
415 println!("\nChoose action for each commit:");
416 println!(" (p)ick - apply the commit");
417 println!(" (s)kip - skip this commit");
418 println!(" (e)dit - edit the commit message");
419 println!(" (q)uit - abort the rebase");
420 }
421
422 for entry in &stack.entries {
425 println!(
426 " {} {} - {}",
427 entry.short_hash(),
428 entry.branch,
429 entry.short_message(50)
430 );
431
432 match self.cherry_pick_commit(&entry.commit_hash) {
434 Ok(new_commit) => result.new_commits.push(new_commit),
435 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
436 }
437 }
438
439 result.summary = format!(
440 "Interactive rebase processed {} commits",
441 stack.entries.len()
442 );
443 Ok(result)
444 }
445
446 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
448 let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
450
451 if let Ok(staged_files) = self.git_repo.get_staged_files() {
453 if !staged_files.is_empty() {
454 let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
456 let _ = self.git_repo.commit_staged_changes(&cleanup_message);
457 }
458 }
459
460 Ok(new_commit_hash)
461 }
462
463 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
465 debug!("Attempting to auto-resolve conflicts for {}", commit_hash);
466
467 if !self.git_repo.has_conflicts()? {
469 return Ok(true);
470 }
471
472 let conflicted_files = self.git_repo.get_conflicted_files()?;
473
474 if conflicted_files.is_empty() {
475 return Ok(true);
476 }
477
478 info!(
479 "Found conflicts in {} files: {:?}",
480 conflicted_files.len(),
481 conflicted_files
482 );
483
484 let analysis = self
486 .conflict_analyzer
487 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
488
489 info!(
490 "๐ Conflict analysis: {} total conflicts, {} auto-resolvable",
491 analysis.total_conflicts, analysis.auto_resolvable_count
492 );
493
494 for recommendation in &analysis.recommendations {
496 info!("๐ก {}", recommendation);
497 }
498
499 let mut resolved_count = 0;
500 let mut failed_files = Vec::new();
501
502 for file_analysis in &analysis.files {
503 if file_analysis.auto_resolvable {
504 match self.resolve_file_conflicts_enhanced(
505 &file_analysis.file_path,
506 &file_analysis.conflicts,
507 ) {
508 Ok(ConflictResolution::Resolved) => {
509 resolved_count += 1;
510 info!("โ
Auto-resolved conflicts in {}", file_analysis.file_path);
511 }
512 Ok(ConflictResolution::TooComplex) => {
513 debug!(
514 "โ ๏ธ Conflicts in {} are too complex for auto-resolution",
515 file_analysis.file_path
516 );
517 failed_files.push(file_analysis.file_path.clone());
518 }
519 Err(e) => {
520 warn!(
521 "โ Failed to resolve conflicts in {}: {}",
522 file_analysis.file_path, e
523 );
524 failed_files.push(file_analysis.file_path.clone());
525 }
526 }
527 } else {
528 failed_files.push(file_analysis.file_path.clone());
529 info!(
530 "โ ๏ธ {} requires manual resolution ({} conflicts)",
531 file_analysis.file_path,
532 file_analysis.conflicts.len()
533 );
534 }
535 }
536
537 if resolved_count > 0 {
538 info!(
539 "๐ Auto-resolved conflicts in {}/{} files",
540 resolved_count,
541 conflicted_files.len()
542 );
543
544 self.git_repo.stage_conflict_resolved_files()?;
546 }
547
548 let all_resolved = failed_files.is_empty();
550
551 if !all_resolved {
552 info!(
553 "โ ๏ธ {} files still need manual resolution: {:?}",
554 failed_files.len(),
555 failed_files
556 );
557 }
558
559 Ok(all_resolved)
560 }
561
562 fn resolve_file_conflicts_enhanced(
564 &self,
565 file_path: &str,
566 conflicts: &[crate::git::ConflictRegion],
567 ) -> Result<ConflictResolution> {
568 let repo_path = self.git_repo.path();
569 let full_path = repo_path.join(file_path);
570
571 let mut content = std::fs::read_to_string(&full_path)
573 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
574
575 if conflicts.is_empty() {
576 return Ok(ConflictResolution::Resolved);
577 }
578
579 info!(
580 "Resolving {} conflicts in {} using enhanced analysis",
581 conflicts.len(),
582 file_path
583 );
584
585 let mut any_resolved = false;
586
587 for conflict in conflicts.iter().rev() {
589 match self.resolve_single_conflict_enhanced(conflict) {
590 Ok(Some(resolution)) => {
591 let before = &content[..conflict.start_pos];
593 let after = &content[conflict.end_pos..];
594 content = format!("{before}{resolution}{after}");
595 any_resolved = true;
596 debug!(
597 "โ
Resolved {} conflict at lines {}-{} in {}",
598 format!("{:?}", conflict.conflict_type).to_lowercase(),
599 conflict.start_line,
600 conflict.end_line,
601 file_path
602 );
603 }
604 Ok(None) => {
605 debug!(
606 "โ ๏ธ {} conflict at lines {}-{} in {} requires manual resolution",
607 format!("{:?}", conflict.conflict_type).to_lowercase(),
608 conflict.start_line,
609 conflict.end_line,
610 file_path
611 );
612 return Ok(ConflictResolution::TooComplex);
613 }
614 Err(e) => {
615 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
616 return Ok(ConflictResolution::TooComplex);
617 }
618 }
619 }
620
621 if any_resolved {
622 let remaining_conflicts = self.parse_conflict_markers(&content)?;
624
625 if remaining_conflicts.is_empty() {
626 crate::utils::atomic_file::write_string(&full_path, &content)?;
628
629 return Ok(ConflictResolution::Resolved);
630 } else {
631 info!(
632 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
633 file_path,
634 remaining_conflicts.len()
635 );
636 }
637 }
638
639 Ok(ConflictResolution::TooComplex)
640 }
641
642 fn resolve_single_conflict_enhanced(
644 &self,
645 conflict: &crate::git::ConflictRegion,
646 ) -> Result<Option<String>> {
647 debug!(
648 "Resolving {} conflict in {} (lines {}-{})",
649 format!("{:?}", conflict.conflict_type).to_lowercase(),
650 conflict.file_path,
651 conflict.start_line,
652 conflict.end_line
653 );
654
655 use crate::git::ConflictType;
656
657 match conflict.conflict_type {
658 ConflictType::Whitespace => {
659 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
661 Ok(Some(conflict.our_content.clone()))
662 } else {
663 Ok(Some(conflict.their_content.clone()))
664 }
665 }
666 ConflictType::LineEnding => {
667 let normalized = conflict
669 .our_content
670 .replace("\r\n", "\n")
671 .replace('\r', "\n");
672 Ok(Some(normalized))
673 }
674 ConflictType::PureAddition => {
675 if conflict.our_content.is_empty() {
677 Ok(Some(conflict.their_content.clone()))
678 } else if conflict.their_content.is_empty() {
679 Ok(Some(conflict.our_content.clone()))
680 } else {
681 let combined = format!("{}\n{}", conflict.our_content, conflict.their_content);
683 Ok(Some(combined))
684 }
685 }
686 ConflictType::ImportMerge => {
687 let mut all_imports: Vec<&str> = conflict
689 .our_content
690 .lines()
691 .chain(conflict.their_content.lines())
692 .collect();
693 all_imports.sort();
694 all_imports.dedup();
695 Ok(Some(all_imports.join("\n")))
696 }
697 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
698 Ok(None)
700 }
701 }
702 }
703
704 #[allow(dead_code)]
706 fn resolve_file_conflicts(&self, file_path: &str) -> Result<ConflictResolution> {
707 let repo_path = self.git_repo.path();
708 let full_path = repo_path.join(file_path);
709
710 let content = std::fs::read_to_string(&full_path)
712 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
713
714 let conflicts = self.parse_conflict_markers(&content)?;
716
717 if conflicts.is_empty() {
718 return Ok(ConflictResolution::Resolved);
720 }
721
722 info!(
723 "Found {} conflict regions in {}",
724 conflicts.len(),
725 file_path
726 );
727
728 let mut resolved_content = content;
730 let mut any_resolved = false;
731
732 for conflict in conflicts.iter().rev() {
734 match self.resolve_single_conflict(conflict, file_path) {
735 Ok(Some(resolution)) => {
736 let before = &resolved_content[..conflict.start];
738 let after = &resolved_content[conflict.end..];
739 resolved_content = format!("{before}{resolution}{after}");
740 any_resolved = true;
741 debug!(
742 "โ
Resolved conflict at lines {}-{} in {}",
743 conflict.start_line, conflict.end_line, file_path
744 );
745 }
746 Ok(None) => {
747 debug!(
748 "โ ๏ธ Conflict at lines {}-{} in {} too complex for auto-resolution",
749 conflict.start_line, conflict.end_line, file_path
750 );
751 }
752 Err(e) => {
753 debug!("โ Failed to resolve conflict in {}: {}", file_path, e);
754 }
755 }
756 }
757
758 if any_resolved {
759 let remaining_conflicts = self.parse_conflict_markers(&resolved_content)?;
761
762 if remaining_conflicts.is_empty() {
763 crate::utils::atomic_file::write_string(&full_path, &resolved_content)?;
765
766 return Ok(ConflictResolution::Resolved);
767 } else {
768 info!(
769 "โ ๏ธ Partially resolved conflicts in {} ({} remaining)",
770 file_path,
771 remaining_conflicts.len()
772 );
773 }
774 }
775
776 Ok(ConflictResolution::TooComplex)
777 }
778
779 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
781 let lines: Vec<&str> = content.lines().collect();
782 let mut conflicts = Vec::new();
783 let mut i = 0;
784
785 while i < lines.len() {
786 if lines[i].starts_with("<<<<<<<") {
787 let start_line = i + 1;
789 let mut separator_line = None;
790 let mut end_line = None;
791
792 for (j, line) in lines.iter().enumerate().skip(i + 1) {
794 if line.starts_with("=======") {
795 separator_line = Some(j + 1);
796 } else if line.starts_with(">>>>>>>") {
797 end_line = Some(j + 1);
798 break;
799 }
800 }
801
802 if let (Some(sep), Some(end)) = (separator_line, end_line) {
803 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
805 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
806
807 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
808 let their_content = lines[sep..(end - 1)].join("\n");
809
810 conflicts.push(ConflictRegion {
811 start: start_pos,
812 end: end_pos,
813 start_line,
814 end_line: end,
815 our_content,
816 their_content,
817 });
818
819 i = end;
820 } else {
821 i += 1;
822 }
823 } else {
824 i += 1;
825 }
826 }
827
828 Ok(conflicts)
829 }
830
831 fn resolve_single_conflict(
833 &self,
834 conflict: &ConflictRegion,
835 file_path: &str,
836 ) -> Result<Option<String>> {
837 debug!(
838 "Analyzing conflict in {} (lines {}-{})",
839 file_path, conflict.start_line, conflict.end_line
840 );
841
842 if let Some(resolved) = self.resolve_whitespace_conflict(conflict)? {
844 debug!("Resolved as whitespace-only conflict");
845 return Ok(Some(resolved));
846 }
847
848 if let Some(resolved) = self.resolve_line_ending_conflict(conflict)? {
850 debug!("Resolved as line ending conflict");
851 return Ok(Some(resolved));
852 }
853
854 if let Some(resolved) = self.resolve_addition_conflict(conflict)? {
856 debug!("Resolved as pure addition conflict");
857 return Ok(Some(resolved));
858 }
859
860 if let Some(resolved) = self.resolve_import_conflict(conflict, file_path)? {
862 debug!("Resolved as import reordering conflict");
863 return Ok(Some(resolved));
864 }
865
866 Ok(None)
868 }
869
870 fn resolve_whitespace_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
872 let our_normalized = self.normalize_whitespace(&conflict.our_content);
873 let their_normalized = self.normalize_whitespace(&conflict.their_content);
874
875 if our_normalized == their_normalized {
876 let resolved =
878 if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
879 conflict.our_content.clone()
880 } else {
881 conflict.their_content.clone()
882 };
883
884 return Ok(Some(resolved));
885 }
886
887 Ok(None)
888 }
889
890 fn resolve_line_ending_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
892 let our_normalized = conflict
893 .our_content
894 .replace("\r\n", "\n")
895 .replace('\r', "\n");
896 let their_normalized = conflict
897 .their_content
898 .replace("\r\n", "\n")
899 .replace('\r', "\n");
900
901 if our_normalized == their_normalized {
902 return Ok(Some(our_normalized));
904 }
905
906 Ok(None)
907 }
908
909 fn resolve_addition_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
911 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
912 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
913
914 if our_lines.is_empty() {
916 return Ok(Some(conflict.their_content.clone()));
917 }
918 if their_lines.is_empty() {
919 return Ok(Some(conflict.our_content.clone()));
920 }
921
922 let mut merged_lines = Vec::new();
924 let mut our_idx = 0;
925 let mut their_idx = 0;
926
927 while our_idx < our_lines.len() || their_idx < their_lines.len() {
928 if our_idx >= our_lines.len() {
929 merged_lines.extend_from_slice(&their_lines[their_idx..]);
931 break;
932 } else if their_idx >= their_lines.len() {
933 merged_lines.extend_from_slice(&our_lines[our_idx..]);
935 break;
936 } else if our_lines[our_idx] == their_lines[their_idx] {
937 merged_lines.push(our_lines[our_idx]);
939 our_idx += 1;
940 their_idx += 1;
941 } else {
942 return Ok(None);
944 }
945 }
946
947 Ok(Some(merged_lines.join("\n")))
948 }
949
950 fn resolve_import_conflict(
952 &self,
953 conflict: &ConflictRegion,
954 file_path: &str,
955 ) -> Result<Option<String>> {
956 let is_import_file = file_path.ends_with(".rs")
958 || file_path.ends_with(".py")
959 || file_path.ends_with(".js")
960 || file_path.ends_with(".ts")
961 || file_path.ends_with(".go")
962 || file_path.ends_with(".java")
963 || file_path.ends_with(".swift")
964 || file_path.ends_with(".kt")
965 || file_path.ends_with(".cs");
966
967 if !is_import_file {
968 return Ok(None);
969 }
970
971 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
972 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
973
974 let our_imports = our_lines
976 .iter()
977 .all(|line| self.is_import_line(line, file_path));
978 let their_imports = their_lines
979 .iter()
980 .all(|line| self.is_import_line(line, file_path));
981
982 if our_imports && their_imports {
983 let mut all_imports: Vec<&str> = our_lines.into_iter().chain(their_lines).collect();
985 all_imports.sort();
986 all_imports.dedup();
987
988 return Ok(Some(all_imports.join("\n")));
989 }
990
991 Ok(None)
992 }
993
994 fn is_import_line(&self, line: &str, file_path: &str) -> bool {
996 let trimmed = line.trim();
997
998 if trimmed.is_empty() {
999 return true; }
1001
1002 if file_path.ends_with(".rs") {
1003 return trimmed.starts_with("use ") || trimmed.starts_with("extern crate");
1004 } else if file_path.ends_with(".py") {
1005 return trimmed.starts_with("import ") || trimmed.starts_with("from ");
1006 } else if file_path.ends_with(".js") || file_path.ends_with(".ts") {
1007 return trimmed.starts_with("import ")
1008 || trimmed.starts_with("const ")
1009 || trimmed.starts_with("require(");
1010 } else if file_path.ends_with(".go") {
1011 return trimmed.starts_with("import ") || trimmed == "import (" || trimmed == ")";
1012 } else if file_path.ends_with(".java") {
1013 return trimmed.starts_with("import ");
1014 } else if file_path.ends_with(".swift") {
1015 return trimmed.starts_with("import ") || trimmed.starts_with("@testable import ");
1016 } else if file_path.ends_with(".kt") {
1017 return trimmed.starts_with("import ") || trimmed.starts_with("@file:");
1018 } else if file_path.ends_with(".cs") {
1019 return trimmed.starts_with("using ") || trimmed.starts_with("extern alias ");
1020 }
1021
1022 false
1023 }
1024
1025 fn normalize_whitespace(&self, content: &str) -> String {
1027 content
1028 .lines()
1029 .map(|line| line.trim())
1030 .filter(|line| !line.is_empty())
1031 .collect::<Vec<_>>()
1032 .join("\n")
1033 }
1034
1035 fn update_stack_entry(
1038 &mut self,
1039 stack_id: Uuid,
1040 entry_id: &Uuid,
1041 _new_branch: &str,
1042 new_commit_hash: &str,
1043 ) -> Result<()> {
1044 debug!(
1045 "Updating entry {} in stack {} with new commit {}",
1046 entry_id, stack_id, new_commit_hash
1047 );
1048
1049 let stack = self
1051 .stack_manager
1052 .get_stack_mut(&stack_id)
1053 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1054
1055 if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == *entry_id) {
1057 debug!(
1058 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch '{}')",
1059 entry_id, entry.commit_hash, new_commit_hash, entry.branch
1060 );
1061
1062 entry.commit_hash = new_commit_hash.to_string();
1065
1066 debug!(
1069 "Successfully updated entry {} in stack {}",
1070 entry_id, stack_id
1071 );
1072 Ok(())
1073 } else {
1074 Err(CascadeError::config(format!(
1075 "Entry {entry_id} not found in stack {stack_id}"
1076 )))
1077 }
1078 }
1079
1080 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1082 info!("Pulling latest changes for branch {}", branch);
1083
1084 match self.git_repo.fetch() {
1086 Ok(_) => {
1087 debug!("Fetch successful");
1088 match self.git_repo.pull(branch) {
1090 Ok(_) => {
1091 info!("Pull completed successfully for {}", branch);
1092 Ok(())
1093 }
1094 Err(e) => {
1095 warn!("Pull failed for {}: {}", branch, e);
1096 Ok(())
1098 }
1099 }
1100 }
1101 Err(e) => {
1102 warn!("Fetch failed: {}", e);
1103 Ok(())
1105 }
1106 }
1107 }
1108
1109 pub fn is_rebase_in_progress(&self) -> bool {
1111 let git_dir = self.git_repo.path().join(".git");
1113 git_dir.join("REBASE_HEAD").exists()
1114 || git_dir.join("rebase-merge").exists()
1115 || git_dir.join("rebase-apply").exists()
1116 }
1117
1118 pub fn abort_rebase(&self) -> Result<()> {
1120 info!("Aborting rebase operation");
1121
1122 let git_dir = self.git_repo.path().join(".git");
1123
1124 if git_dir.join("REBASE_HEAD").exists() {
1126 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1127 CascadeError::Git(git2::Error::from_str(&format!(
1128 "Failed to clean rebase state: {e}"
1129 )))
1130 })?;
1131 }
1132
1133 if git_dir.join("rebase-merge").exists() {
1134 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1135 CascadeError::Git(git2::Error::from_str(&format!(
1136 "Failed to clean rebase-merge: {e}"
1137 )))
1138 })?;
1139 }
1140
1141 if git_dir.join("rebase-apply").exists() {
1142 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1143 CascadeError::Git(git2::Error::from_str(&format!(
1144 "Failed to clean rebase-apply: {e}"
1145 )))
1146 })?;
1147 }
1148
1149 info!("Rebase aborted successfully");
1150 Ok(())
1151 }
1152
1153 pub fn continue_rebase(&self) -> Result<()> {
1155 info!("Continuing rebase operation");
1156
1157 if self.git_repo.has_conflicts()? {
1159 return Err(CascadeError::branch(
1160 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1161 ));
1162 }
1163
1164 self.git_repo.stage_conflict_resolved_files()?;
1166
1167 info!("Rebase continued successfully");
1168 Ok(())
1169 }
1170}
1171
1172impl RebaseResult {
1173 pub fn get_summary(&self) -> String {
1175 if self.success {
1176 format!("โ
{}", self.summary)
1177 } else {
1178 format!(
1179 "โ Rebase failed: {}",
1180 self.error.as_deref().unwrap_or("Unknown error")
1181 )
1182 }
1183 }
1184
1185 pub fn has_conflicts(&self) -> bool {
1187 !self.conflicts.is_empty()
1188 }
1189
1190 pub fn success_count(&self) -> usize {
1192 self.new_commits.len()
1193 }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199 use std::path::PathBuf;
1200 use std::process::Command;
1201 use tempfile::TempDir;
1202
1203 #[allow(dead_code)]
1204 fn create_test_repo() -> (TempDir, PathBuf) {
1205 let temp_dir = TempDir::new().unwrap();
1206 let repo_path = temp_dir.path().to_path_buf();
1207
1208 Command::new("git")
1210 .args(["init"])
1211 .current_dir(&repo_path)
1212 .output()
1213 .unwrap();
1214 Command::new("git")
1215 .args(["config", "user.name", "Test"])
1216 .current_dir(&repo_path)
1217 .output()
1218 .unwrap();
1219 Command::new("git")
1220 .args(["config", "user.email", "test@test.com"])
1221 .current_dir(&repo_path)
1222 .output()
1223 .unwrap();
1224
1225 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1227 Command::new("git")
1228 .args(["add", "."])
1229 .current_dir(&repo_path)
1230 .output()
1231 .unwrap();
1232 Command::new("git")
1233 .args(["commit", "-m", "Initial"])
1234 .current_dir(&repo_path)
1235 .output()
1236 .unwrap();
1237
1238 (temp_dir, repo_path)
1239 }
1240
1241 #[test]
1242 fn test_conflict_region_creation() {
1243 let region = ConflictRegion {
1244 start: 0,
1245 end: 50,
1246 start_line: 1,
1247 end_line: 3,
1248 our_content: "function test() {\n return true;\n}".to_string(),
1249 their_content: "function test() {\n return true;\n}".to_string(),
1250 };
1251
1252 assert_eq!(region.start_line, 1);
1253 assert_eq!(region.end_line, 3);
1254 assert!(region.our_content.contains("return true"));
1255 assert!(region.their_content.contains("return true"));
1256 }
1257
1258 #[test]
1259 fn test_rebase_strategies() {
1260 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1261 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1262 }
1263
1264 #[test]
1265 fn test_rebase_options() {
1266 let options = RebaseOptions::default();
1267 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1268 assert!(!options.interactive);
1269 assert!(options.auto_resolve);
1270 assert_eq!(options.max_retries, 3);
1271 }
1272
1273 #[test]
1274 fn test_cleanup_guard_tracks_branches() {
1275 let mut guard = TempBranchCleanupGuard::new();
1276 assert!(guard.branches.is_empty());
1277
1278 guard.add_branch("test-branch-1".to_string());
1279 guard.add_branch("test-branch-2".to_string());
1280
1281 assert_eq!(guard.branches.len(), 2);
1282 assert_eq!(guard.branches[0], "test-branch-1");
1283 assert_eq!(guard.branches[1], "test-branch-2");
1284 }
1285
1286 #[test]
1287 fn test_cleanup_guard_prevents_double_cleanup() {
1288 use std::process::Command;
1289 use tempfile::TempDir;
1290
1291 let temp_dir = TempDir::new().unwrap();
1293 let repo_path = temp_dir.path();
1294
1295 Command::new("git")
1296 .args(["init"])
1297 .current_dir(repo_path)
1298 .output()
1299 .unwrap();
1300
1301 Command::new("git")
1302 .args(["config", "user.name", "Test"])
1303 .current_dir(repo_path)
1304 .output()
1305 .unwrap();
1306
1307 Command::new("git")
1308 .args(["config", "user.email", "test@test.com"])
1309 .current_dir(repo_path)
1310 .output()
1311 .unwrap();
1312
1313 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1315 Command::new("git")
1316 .args(["add", "."])
1317 .current_dir(repo_path)
1318 .output()
1319 .unwrap();
1320 Command::new("git")
1321 .args(["commit", "-m", "initial"])
1322 .current_dir(repo_path)
1323 .output()
1324 .unwrap();
1325
1326 let git_repo = GitRepository::open(repo_path).unwrap();
1327
1328 git_repo.create_branch("test-temp", None).unwrap();
1330
1331 let mut guard = TempBranchCleanupGuard::new();
1332 guard.add_branch("test-temp".to_string());
1333
1334 guard.cleanup(&git_repo);
1336 assert!(guard.cleaned);
1337
1338 guard.cleanup(&git_repo);
1340 assert!(guard.cleaned);
1341 }
1342
1343 #[test]
1344 fn test_rebase_result() {
1345 let result = RebaseResult {
1346 success: true,
1347 branch_mapping: std::collections::HashMap::new(),
1348 conflicts: vec!["abc123".to_string()],
1349 new_commits: vec!["def456".to_string()],
1350 error: None,
1351 summary: "Test summary".to_string(),
1352 };
1353
1354 assert!(result.success);
1355 assert!(result.has_conflicts());
1356 assert_eq!(result.success_count(), 1);
1357 }
1358
1359 #[test]
1360 fn test_import_line_detection() {
1361 let (_temp_dir, repo_path) = create_test_repo();
1362 let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
1363 let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
1364 let options = RebaseOptions::default();
1365 let rebase_manager = RebaseManager::new(stack_manager, git_repo, options);
1366
1367 assert!(rebase_manager.is_import_line("import Foundation", "test.swift"));
1369 assert!(rebase_manager.is_import_line("@testable import MyModule", "test.swift"));
1370 assert!(!rebase_manager.is_import_line("class MyClass {", "test.swift"));
1371
1372 assert!(rebase_manager.is_import_line("import kotlin.collections.*", "test.kt"));
1374 assert!(rebase_manager.is_import_line("@file:JvmName(\"Utils\")", "test.kt"));
1375 assert!(!rebase_manager.is_import_line("fun myFunction() {", "test.kt"));
1376
1377 assert!(rebase_manager.is_import_line("using System;", "test.cs"));
1379 assert!(rebase_manager.is_import_line("using System.Collections.Generic;", "test.cs"));
1380 assert!(rebase_manager.is_import_line("extern alias GridV1;", "test.cs"));
1381 assert!(!rebase_manager.is_import_line("namespace MyNamespace {", "test.cs"));
1382
1383 assert!(rebase_manager.is_import_line("", "test.swift"));
1385 assert!(rebase_manager.is_import_line(" ", "test.kt"));
1386 assert!(rebase_manager.is_import_line("", "test.cs"));
1387 }
1388}