1use crate::errors::{CascadeError, Result};
2use crate::git::{ConflictAnalyzer, GitRepository};
3use crate::stack::{Stack, StackManager, SyncState};
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use tracing::debug;
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 pub original_working_branch: Option<String>,
67}
68
69#[derive(Debug)]
71pub struct RebaseResult {
72 pub success: bool,
74 pub branch_mapping: HashMap<String, String>,
76 pub conflicts: Vec<String>,
78 pub new_commits: Vec<String>,
80 pub error: Option<String>,
82 pub summary: String,
84}
85
86#[allow(dead_code)]
92struct TempBranchCleanupGuard {
93 branches: Vec<String>,
94 cleaned: bool,
95}
96
97#[allow(dead_code)]
98impl TempBranchCleanupGuard {
99 fn new() -> Self {
100 Self {
101 branches: Vec::new(),
102 cleaned: false,
103 }
104 }
105
106 fn add_branch(&mut self, branch: String) {
107 self.branches.push(branch);
108 }
109
110 fn cleanup(&mut self, git_repo: &GitRepository) {
112 if self.cleaned || self.branches.is_empty() {
113 return;
114 }
115
116 tracing::debug!("Cleaning up {} temporary branches", self.branches.len());
117 for branch in &self.branches {
118 if let Err(e) = git_repo.delete_branch_unsafe(branch) {
119 tracing::debug!("Failed to delete temp branch {}: {}", branch, e);
120 }
122 }
123 self.cleaned = true;
124 }
125}
126
127impl Drop for TempBranchCleanupGuard {
128 fn drop(&mut self) {
129 if !self.cleaned && !self.branches.is_empty() {
130 tracing::warn!(
133 "{} temporary branches were not cleaned up: {}",
134 self.branches.len(),
135 self.branches.join(", ")
136 );
137 tracing::warn!("Run 'ca cleanup' to remove orphaned temporary branches");
138 }
139 }
140}
141
142pub struct RebaseManager {
144 stack_manager: StackManager,
145 git_repo: GitRepository,
146 options: RebaseOptions,
147 conflict_analyzer: ConflictAnalyzer,
148}
149
150impl Default for RebaseOptions {
151 fn default() -> Self {
152 Self {
153 strategy: RebaseStrategy::ForcePush,
154 interactive: false,
155 target_base: None,
156 preserve_merges: true,
157 auto_resolve: true,
158 max_retries: 3,
159 skip_pull: None,
160 original_working_branch: None,
161 }
162 }
163}
164
165impl RebaseManager {
166 pub fn new(
168 stack_manager: StackManager,
169 git_repo: GitRepository,
170 options: RebaseOptions,
171 ) -> Self {
172 Self {
173 stack_manager,
174 git_repo,
175 options,
176 conflict_analyzer: ConflictAnalyzer::new(),
177 }
178 }
179
180 pub fn into_stack_manager(self) -> StackManager {
182 self.stack_manager
183 }
184
185 pub fn rebase_stack(&mut self, stack_id: &Uuid) -> Result<RebaseResult> {
187 debug!("Starting rebase for stack {}", stack_id);
188
189 let stack = self
190 .stack_manager
191 .get_stack(stack_id)
192 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
193 .clone();
194
195 match self.options.strategy {
196 RebaseStrategy::ForcePush => self.rebase_with_force_push(&stack),
197 RebaseStrategy::Interactive => self.rebase_interactive(&stack),
198 }
199 }
200
201 fn rebase_with_force_push(&mut self, stack: &Stack) -> Result<RebaseResult> {
205 use crate::cli::output::Output;
206
207 if self.has_in_progress_cherry_pick()? {
209 return self.handle_in_progress_cherry_pick(stack);
210 }
211
212 Output::section(format!("Rebasing stack: {}", stack.name));
214 Output::sub_item(format!("Base branch: {}", stack.base_branch));
215
216 let total_entries = stack.entries.len();
218 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
219 let unmerged_count = total_entries - merged_count;
220
221 if merged_count > 0 {
222 Output::sub_item(format!(
223 "Entries: {} total ({} merged, {} to rebase)",
224 total_entries, merged_count, unmerged_count
225 ));
226 } else {
227 Output::sub_item(format!("Entries: {}", total_entries));
228 }
229
230 let mut result = RebaseResult {
231 success: true,
232 branch_mapping: HashMap::new(),
233 conflicts: Vec::new(),
234 new_commits: Vec::new(),
235 error: None,
236 summary: String::new(),
237 };
238
239 let target_base = self
240 .options
241 .target_base
242 .as_ref()
243 .unwrap_or(&stack.base_branch)
244 .clone(); let original_branch = self
250 .options
251 .original_working_branch
252 .clone()
253 .or_else(|| self.git_repo.get_current_branch().ok());
254
255 let original_branch_for_cleanup = original_branch.clone();
257
258 if let Some(ref orig) = original_branch {
261 if orig == &target_base {
262 debug!(
263 "Original working branch is base branch '{}' - will skip working branch update",
264 orig
265 );
266 }
267 }
268
269 if !self.options.skip_pull.unwrap_or(false) {
272 if let Err(e) = self.pull_latest_changes(&target_base) {
273 Output::warning(format!("Could not pull latest changes: {}", e));
274 }
275 }
276
277 crate::utils::git_lock::wait_for_index_lock(
279 self.git_repo.path(),
280 std::time::Duration::from_secs(5),
281 )?;
282
283 if let Err(e) = self.git_repo.reset_to_head() {
285 if let Some(ref orig) = original_branch_for_cleanup {
287 let _ = self.git_repo.checkout_branch_unsafe(orig);
288 }
289
290 return Err(CascadeError::branch(format!(
291 "Could not reset working directory to HEAD: {}\n\
292 Check if another Git process is running and wait for it to complete before retrying.",
293 e
294 )));
295 }
296
297 let mut current_base = target_base.clone();
298 let entry_count = stack.entries.iter().filter(|e| !e.is_merged).count();
300 let mut temp_branches: Vec<String> = Vec::new(); if entry_count == 0 {
304 println!();
305 if stack.entries.is_empty() {
306 Output::info("Stack has no entries yet");
307 Output::tip("Use 'ca push' to add commits to this stack");
308 result.summary = "Stack is empty".to_string();
309 } else {
310 Output::info("All entries in this stack have been merged");
311 Output::tip("Use 'ca push' to add new commits, or 'ca stack cleanup' to prune merged branches");
312 result.summary = "All entries merged".to_string();
313 }
314
315 println!();
317 Output::success(&result.summary);
318
319 self.stack_manager.save_to_disk()?;
321 return Ok(result);
322 }
323
324 let all_up_to_date = stack
326 .entries
327 .iter()
328 .filter(|entry| !entry.is_merged) .all(|entry| {
330 self.git_repo
331 .is_commit_based_on(&entry.commit_hash, &target_base)
332 .unwrap_or(false)
333 });
334
335 if all_up_to_date {
336 println!();
337 Output::success("Stack is already up-to-date with base branch");
338 result.summary = "Stack is up-to-date".to_string();
339 result.success = true;
340 return Ok(result);
341 }
342
343 let repo_root = self.git_repo.path().to_path_buf();
344 let mut sync_state = SyncState {
345 stack_id: stack.id.to_string(),
346 stack_name: stack.name.clone(),
347 original_branch: original_branch_for_cleanup
348 .clone()
349 .unwrap_or_else(|| target_base.clone()),
350 target_base: target_base.clone(),
351 remaining_entry_ids: stack.entries.iter().map(|e| e.id.to_string()).collect(),
352 current_entry_id: String::new(),
353 current_entry_branch: String::new(),
354 current_temp_branch: String::new(),
355 temp_branches: Vec::new(),
356 };
357
358 let _ = SyncState::delete(&repo_root);
360
361 let mut branches_with_new_commits: std::collections::HashSet<String> =
364 std::collections::HashSet::new();
365 let mut branches_to_push: Vec<(String, String, usize)> = Vec::new(); let mut processed_entries: usize = 0; for (index, entry) in stack.entries.iter().enumerate() {
368 let original_branch = &entry.branch;
369 let entry_id_str = entry.id.to_string();
370
371 sync_state.remaining_entry_ids = stack
372 .entries
373 .iter()
374 .skip(index + 1)
375 .map(|e| e.id.to_string())
376 .collect();
377
378 if entry.is_merged {
380 tracing::debug!(
381 "Entry '{}' is merged into '{}', skipping rebase",
382 original_branch,
383 target_base
384 );
385 continue;
387 }
388
389 processed_entries += 1;
390
391 sync_state.current_entry_id = entry_id_str.clone();
392 sync_state.current_entry_branch = original_branch.clone();
393
394 if self
397 .git_repo
398 .is_commit_based_on(&entry.commit_hash, ¤t_base)
399 .unwrap_or(false)
400 {
401 tracing::debug!(
402 "Entry '{}' is already correctly based on '{}', skipping rebase",
403 original_branch,
404 current_base
405 );
406
407 result
408 .branch_mapping
409 .insert(original_branch.clone(), original_branch.clone());
410
411 current_base = original_branch.clone();
413 continue;
414 }
415
416 if !result.success {
419 tracing::debug!(
420 "Skipping entry '{}' because previous entry failed",
421 original_branch
422 );
423 break;
424 }
425
426 let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
429 temp_branches.push(temp_branch.clone()); sync_state.current_temp_branch = temp_branch.clone();
431
432 if let Err(e) = self
434 .git_repo
435 .create_branch(&temp_branch, Some(¤t_base))
436 {
437 if let Some(ref orig) = original_branch_for_cleanup {
439 let _ = self.git_repo.checkout_branch_unsafe(orig);
440 }
441 return Err(e);
442 }
443
444 if let Err(e) = crate::utils::git_lock::retry_on_lock(4, || {
445 self.git_repo.checkout_branch_silent(&temp_branch)
446 }) {
447 if let Some(ref orig) = original_branch_for_cleanup {
449 let _ = self.git_repo.checkout_branch_unsafe(orig);
450 }
451 return Err(e);
452 }
453
454 sync_state.temp_branches = temp_branches.clone();
456 sync_state.save(&repo_root)?;
457
458 match crate::utils::git_lock::retry_on_lock(4, || {
460 self.cherry_pick_commit(&entry.commit_hash)
461 }) {
462 Ok(new_commit_hash) => {
463 result.new_commits.push(new_commit_hash.clone());
464
465 let rebased_commit_id = self.git_repo.get_head_commit()?.id().to_string();
467
468 self.git_repo
471 .update_branch_to_commit(original_branch, &rebased_commit_id)?;
472
473 result
474 .branch_mapping
475 .insert(original_branch.clone(), original_branch.clone());
476
477 self.update_stack_entry(
479 stack.id,
480 &entry.id,
481 original_branch,
482 &rebased_commit_id,
483 )?;
484 branches_with_new_commits.insert(original_branch.clone());
485
486 if let Some(pr_num) = &entry.pull_request_id {
488 let tree_char = if processed_entries == entry_count {
489 "└─"
490 } else {
491 "├─"
492 };
493 println!(" {} {} (PR #{})", tree_char, original_branch, pr_num);
494 branches_to_push.push((
495 original_branch.clone(),
496 pr_num.clone(),
497 processed_entries - 1,
498 ));
499 }
500
501 current_base = original_branch.clone();
503
504 let _ = SyncState::delete(&repo_root);
505 }
506 Err(e) => {
507 if self.git_repo.get_conflicted_files()?.is_empty() {
509 debug!(
510 "Cherry-pick produced no changes for {} ({}), skipping entry",
511 original_branch,
512 &entry.commit_hash[..8]
513 );
514
515 Output::warning(format!(
516 "Skipping entry '{}' - cherry-pick resulted in no changes",
517 original_branch
518 ));
519 Output::sub_item("This usually means the base branch has moved forward");
520 Output::sub_item("and this entry's changes are already present");
521
522 let _ = std::process::Command::new("git")
524 .args(["cherry-pick", "--abort"])
525 .current_dir(self.git_repo.path())
526 .output();
527
528 let _ = self.git_repo.checkout_branch_unsafe(&target_base);
530 let _ = self.git_repo.delete_branch_unsafe(&temp_branch);
531 let _ = temp_branches.pop();
532 let _ = SyncState::delete(&repo_root);
533
534 continue;
536 }
537
538 result.conflicts.push(entry.commit_hash.clone());
539
540 if !self.options.auto_resolve {
541 println!();
542 Output::error(e.to_string());
543 result.success = false;
544 result.error = Some(format!(
545 "Conflict in {}: {}\n\n\
546 MANUAL CONFLICT RESOLUTION REQUIRED\n\
547 =====================================\n\n\
548 Step 1: Analyze conflicts\n\
549 → Run: ca conflicts\n\
550 → This shows which conflicts are in which files\n\n\
551 Step 2: Resolve conflicts in your editor\n\
552 → Open conflicted files and edit them\n\
553 → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
554 → Keep the code you want\n\
555 → Save the files\n\n\
556 Step 3: Mark conflicts as resolved\n\
557 → Run: git add <resolved-files>\n\
558 → Or: git add -A (to stage all resolved files)\n\n\
559 Step 4: Complete the sync\n\
560 → Run: ca sync continue\n\
561 → Cascade will complete the cherry-pick and continue\n\n\
562 Alternative: Abort and start over\n\
563 → Run: ca sync abort\n\
564 → Then: ca sync (starts fresh)\n\n\
565 TIP: Enable auto-resolution for simple conflicts:\n\
566 → Run: ca sync --auto-resolve\n\
567 → Only complex conflicts will require manual resolution",
568 entry.commit_hash, e
569 ));
570 break;
571 }
572
573 match self.auto_resolve_conflicts(&entry.commit_hash) {
575 Ok(fully_resolved) => {
576 if !fully_resolved {
577 result.success = false;
578 result.error = Some(format!(
579 "Conflicts in commit {}\n\n\
580 To resolve:\n\
581 1. Fix conflicts in your editor\n\
582 2. Stage resolved files: git add <files>\n\
583 3. Continue: ca sync continue\n\n\
584 Or abort:\n\
585 → Run: ca sync abort",
586 &entry.commit_hash[..8]
587 ));
588 break;
589 }
590
591 let commit_message = entry.message.trim().to_string();
594
595 debug!("Checking staged files before commit");
597 let staged_files = self.git_repo.get_staged_files()?;
598
599 if staged_files.is_empty() {
600 debug!(
603 "Cherry-pick resulted in empty commit for {}",
604 &entry.commit_hash[..8]
605 );
606
607 Output::warning(format!(
609 "Skipping entry '{}' - cherry-pick resulted in no changes",
610 original_branch
611 ));
612 Output::sub_item(
613 "This usually means the base branch has moved forward",
614 );
615 Output::sub_item("and this entry's changes are already present");
616
617 let _ = std::process::Command::new("git")
619 .args(["cherry-pick", "--abort"])
620 .current_dir(self.git_repo.path())
621 .output();
622
623 let _ = self.git_repo.checkout_branch_unsafe(&target_base);
624 let _ = self.git_repo.delete_branch_unsafe(&temp_branch);
625 let _ = temp_branches.pop();
626
627 let _ = SyncState::delete(&repo_root);
628
629 continue;
631 }
632
633 debug!("{} files staged", staged_files.len());
634
635 match self.git_repo.commit(&commit_message) {
636 Ok(new_commit_id) => {
637 debug!(
638 "Created commit {} with message '{}'",
639 &new_commit_id[..8],
640 commit_message
641 );
642
643 if let Err(e) = self.git_repo.cleanup_state() {
645 tracing::warn!(
646 "Failed to clean up repository state after auto-resolve: {}",
647 e
648 );
649 }
650
651 Output::success("Auto-resolved conflicts");
652 result.new_commits.push(new_commit_id.clone());
653 let rebased_commit_id = new_commit_id;
654
655 self.cleanup_backup_files()?;
657
658 self.git_repo.update_branch_to_commit(
660 original_branch,
661 &rebased_commit_id,
662 )?;
663
664 branches_with_new_commits.insert(original_branch.clone());
665
666 if let Some(pr_num) = &entry.pull_request_id {
668 let tree_char = if processed_entries == entry_count {
669 "└─"
670 } else {
671 "├─"
672 };
673 println!(
674 " {} {} (PR #{})",
675 tree_char, original_branch, pr_num
676 );
677 branches_to_push.push((
678 original_branch.clone(),
679 pr_num.clone(),
680 processed_entries - 1,
681 ));
682 }
683
684 result
685 .branch_mapping
686 .insert(original_branch.clone(), original_branch.clone());
687
688 self.update_stack_entry(
690 stack.id,
691 &entry.id,
692 original_branch,
693 &rebased_commit_id,
694 )?;
695
696 current_base = original_branch.clone();
698 }
699 Err(commit_err) => {
700 result.success = false;
701 result.error = Some(format!(
702 "Could not commit auto-resolved conflicts: {}\n\n\
703 This usually means:\n\
704 - Another Git process is accessing the repository\n\
705 - File permissions issue\n\
706 - Disk space issue\n\n\
707 Recovery:\n\
708 1. Check if another Git operation is running\n\
709 2. Run 'git status' to check repo state\n\
710 3. Retry 'ca sync' after fixing the issue",
711 commit_err
712 ));
713 break;
714 }
715 }
716 }
717 Err(resolve_err) => {
718 result.success = false;
719 result.error = Some(format!(
720 "Could not resolve conflicts: {}\n\n\
721 Recovery:\n\
722 1. Check repo state: 'git status'\n\
723 2. If files are staged, commit or reset them: 'git reset --hard HEAD'\n\
724 3. Remove any lock files: 'rm -f .git/index.lock'\n\
725 4. Retry 'ca sync'",
726 resolve_err
727 ));
728 break;
729 }
730 }
731 }
732 }
733 }
734
735 if result.success && !temp_branches.is_empty() {
739 if let Err(e) = self.git_repo.checkout_branch_unsafe(&target_base) {
742 debug!("Could not checkout base for cleanup: {}", e);
743 } else {
746 for temp_branch in &temp_branches {
748 if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
749 debug!("Could not delete temp branch {}: {}", temp_branch, e);
750 }
751 }
752 }
753 }
754
755 let pushed_count = branches_to_push.len();
761 let _skipped_count = entry_count - pushed_count; let mut successful_pushes = 0; if !result.success {
765 println!();
766 Output::error("Rebase failed - not pushing any branches");
767 } else {
770 if !branches_to_push.is_empty() {
774 println!();
775
776 if let Err(e) = self.git_repo.fetch_with_retry() {
778 Output::warning(format!("Could not fetch latest remote state: {}", e));
779 Output::tip("Continuing with push, but backup branches may not be created if remote state is unknown");
780 }
781
782 let mut push_results = Vec::new();
784 for (branch_name, _pr_num, _index) in branches_to_push.iter() {
785 let result = self
786 .git_repo
787 .force_push_single_branch_auto_no_fetch(branch_name);
788 push_results.push((branch_name.clone(), result));
789 }
790
791 let mut failed_pushes = 0;
793 for (index, (branch_name, result)) in push_results.iter().enumerate() {
794 match result {
795 Ok(_) => {
796 debug!("Pushed {} successfully", branch_name);
797 successful_pushes += 1;
798 println!(
799 " ✓ Pushed {} ({}/{})",
800 branch_name,
801 index + 1,
802 pushed_count
803 );
804 }
805 Err(e) => {
806 failed_pushes += 1;
807 println!(" ⚠ Could not push '{}': {}", branch_name, e);
808 }
809 }
810 }
811
812 if failed_pushes > 0 {
814 println!(); Output::warning(format!(
816 "{} branch(es) failed to push to remote",
817 failed_pushes
818 ));
819 Output::tip("To retry failed pushes, run: ca sync");
820 }
821 }
822
823 let entries_with_prs = stack
826 .entries
827 .iter()
828 .filter(|e| !e.is_merged && e.pull_request_id.is_some())
829 .count();
830 let entries_word = if entry_count == 1 { "entry" } else { "entries" };
831 let pr_word = if successful_pushes == 1 { "PR" } else { "PRs" };
832
833 let not_submitted_count = entry_count - entries_with_prs;
834 result.summary = if successful_pushes > 0 {
835 let mut parts = format!(
837 "{} {} rebased ({} {} updated",
838 entry_count, entries_word, successful_pushes, pr_word
839 );
840 if not_submitted_count > 0 {
841 parts.push_str(&format!(", {} not yet submitted", not_submitted_count));
842 }
843 parts.push(')');
844 parts
845 } else if entries_with_prs > 0 {
846 if not_submitted_count > 0 {
848 format!(
849 "{} {} rebased ({} with PRs, {} not yet submitted)",
850 entry_count, entries_word, entries_with_prs, not_submitted_count
851 )
852 } else {
853 format!(
854 "{} {} rebased ({} with PRs)",
855 entry_count, entries_word, entries_with_prs
856 )
857 }
858 } else {
859 format!(
860 "{} {} rebased (none submitted to Bitbucket yet)",
861 entry_count, entries_word
862 )
863 };
864 } if result.success {
867 if let Some(ref working_branch_name) = stack.working_branch {
872 if working_branch_name != &target_base {
874 if let Some(last_entry) = stack.entries.last() {
875 let top_branch = &last_entry.branch;
876
877 let working_head = self.git_repo.get_branch_head(working_branch_name);
881 let top_commit = self.git_repo.get_branch_head(top_branch);
882
883 match (working_head, top_commit) {
884 (Ok(working_head), Ok(top_commit)) => {
885 if working_head == top_commit {
886 debug!(
888 "Working branch '{}' already matches top of stack",
889 working_branch_name
890 );
891 } else {
892 match self
894 .git_repo
895 .get_commits_between(&top_commit, &working_head)
896 {
897 Ok(commits) if commits.is_empty() => {
898 debug!(
900 "Updating working branch '{}' to match top of stack ({})",
901 working_branch_name, &top_commit[..8]
902 );
903 if let Err(e) = self.git_repo.update_branch_to_commit(
904 working_branch_name,
905 &top_commit,
906 ) {
907 Output::warning(format!(
908 "Could not update working branch '{}' to top of stack: {}",
909 working_branch_name, e
910 ));
911 }
912 }
913 Ok(commits) => {
914 let stack_summaries: Vec<String> = stack
917 .entries
918 .iter()
919 .map(|e| {
920 e.message
921 .lines()
922 .next()
923 .unwrap_or("")
924 .trim()
925 .to_string()
926 })
927 .collect();
928
929 let all_match_stack = commits.iter().all(|commit| {
930 if let Some(msg) = commit.summary() {
931 stack_summaries
932 .iter()
933 .any(|stack_msg| stack_msg == msg.trim())
934 } else {
935 false
936 }
937 });
938
939 if all_match_stack {
940 debug!(
941 "Working branch has old pre-rebase commits (matching stack messages) - safe to update"
942 );
943 if let Err(e) =
944 self.git_repo.update_branch_to_commit(
945 working_branch_name,
946 &top_commit,
947 )
948 {
949 Output::warning(format!(
950 "Could not update working branch '{}' to top of stack: {}",
951 working_branch_name, e
952 ));
953 }
954 } else {
955 Output::error(format!(
957 "Cannot sync: Working branch '{}' has {} commit(s) not in the stack",
958 working_branch_name, commits.len()
959 ));
960 println!();
961 Output::sub_item(
962 "These commits would be lost if we proceed:",
963 );
964 for (i, commit) in
965 commits.iter().take(5).enumerate()
966 {
967 let message =
968 commit.summary().unwrap_or("(no message)");
969 Output::sub_item(format!(
970 " {}. {} - {}",
971 i + 1,
972 &commit.id().to_string()[..8],
973 message
974 ));
975 }
976 if commits.len() > 5 {
977 Output::sub_item(format!(
978 " ... and {} more",
979 commits.len() - 5
980 ));
981 }
982 println!();
983 Output::tip(
984 "Add these commits to the stack first:",
985 );
986 Output::bullet("Run: ca stack push");
987 Output::bullet("Then run: ca sync");
988 println!();
989
990 if let Some(ref orig) = original_branch_for_cleanup
991 {
992 let _ =
993 self.git_repo.checkout_branch_unsafe(orig);
994 }
995
996 return Err(CascadeError::validation(
997 format!(
998 "Working branch '{}' has {} untracked commit(s). \
999 Add them to the stack with 'ca stack push' before syncing.",
1000 working_branch_name, commits.len()
1001 )
1002 ));
1003 }
1004 }
1005 Err(e) => {
1006 Output::warning(format!(
1008 "Could not verify working branch '{}' is safe to update: {}",
1009 working_branch_name, e
1010 ));
1011 Output::tip(
1012 "Working branch was not updated. \
1013 If it's out of date, run: git reset --hard <top-entry-branch>",
1014 );
1015 }
1016 }
1017 }
1018 }
1019 (Err(e), _) => {
1020 Output::warning(format!(
1021 "Could not read working branch '{}': {}. Skipping update.",
1022 working_branch_name, e
1023 ));
1024 }
1025 (_, Err(e)) => {
1026 Output::warning(format!(
1027 "Could not read top stack branch '{}': {}. Skipping update.",
1028 top_branch, e
1029 ));
1030 }
1031 }
1032 }
1033 } else {
1034 debug!(
1036 "Skipping working branch update - working branch '{}' is the base branch",
1037 working_branch_name
1038 );
1039 }
1040 }
1041
1042 if let Some(ref orig_branch) = original_branch {
1045 if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
1046 debug!(
1047 "Could not return to original branch '{}': {}",
1048 orig_branch, e
1049 );
1050 }
1052 }
1053 }
1054 println!();
1059 if result.success {
1060 Output::success(&result.summary);
1061 } else {
1062 let error_msg = result
1064 .error
1065 .as_deref()
1066 .unwrap_or("Rebase failed for unknown reason");
1067 Output::error(error_msg);
1068 }
1069
1070 self.stack_manager.save_to_disk()?;
1072
1073 if !result.success {
1077 return Err(CascadeError::Branch("Rebase failed".to_string()));
1078 }
1079
1080 Ok(result)
1081 }
1082
1083 fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
1085 tracing::debug!("Starting interactive rebase for stack '{}'", stack.name);
1086
1087 let mut result = RebaseResult {
1088 success: true,
1089 branch_mapping: HashMap::new(),
1090 conflicts: Vec::new(),
1091 new_commits: Vec::new(),
1092 error: None,
1093 summary: String::new(),
1094 };
1095
1096 println!("Interactive Rebase for Stack: {}", stack.name);
1097 println!(" Base branch: {}", stack.base_branch);
1098 println!(" Entries: {}", stack.entries.len());
1099
1100 if self.options.interactive {
1101 println!("\nChoose action for each commit:");
1102 println!(" (p)ick - apply the commit");
1103 println!(" (s)kip - skip this commit");
1104 println!(" (e)dit - edit the commit message");
1105 println!(" (q)uit - abort the rebase");
1106 }
1107
1108 for entry in &stack.entries {
1111 println!(
1112 " {} {} - {}",
1113 entry.short_hash(),
1114 entry.branch,
1115 entry.short_message(50)
1116 );
1117
1118 match self.cherry_pick_commit(&entry.commit_hash) {
1120 Ok(new_commit) => result.new_commits.push(new_commit),
1121 Err(_) => result.conflicts.push(entry.commit_hash.clone()),
1122 }
1123 }
1124
1125 result.summary = format!(
1126 "Interactive rebase processed {} commits",
1127 stack.entries.len()
1128 );
1129 Ok(result)
1130 }
1131
1132 fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
1134 let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
1136
1137 if let Ok(staged_files) = self.git_repo.get_staged_files() {
1139 if !staged_files.is_empty() {
1140 let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
1142 match self.git_repo.commit_staged_changes(&cleanup_message) {
1143 Ok(Some(_)) => {
1144 debug!(
1145 "Committed {} leftover staged files after cherry-pick",
1146 staged_files.len()
1147 );
1148 }
1149 Ok(None) => {
1150 debug!("Staged files were cleared before commit");
1152 }
1153 Err(e) => {
1154 tracing::warn!(
1156 "Failed to commit {} staged files after cherry-pick: {}. \
1157 User may see checkout warning with staged changes.",
1158 staged_files.len(),
1159 e
1160 );
1161 }
1163 }
1164 }
1165 }
1166
1167 Ok(new_commit_hash)
1168 }
1169
1170 fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
1172 debug!("Starting auto-resolve for commit {}", commit_hash);
1173
1174 let has_conflicts = self.git_repo.has_conflicts()?;
1176 debug!("has_conflicts() = {}", has_conflicts);
1177
1178 let cherry_pick_head = self.git_repo.git_dir().join("CHERRY_PICK_HEAD");
1180 let cherry_pick_in_progress = cherry_pick_head.exists();
1181
1182 if !has_conflicts {
1183 debug!("No conflicts detected by Git index");
1184
1185 if cherry_pick_in_progress {
1187 tracing::debug!(
1188 "CHERRY_PICK_HEAD exists but no conflicts in index - aborting cherry-pick"
1189 );
1190
1191 let _ = std::process::Command::new("git")
1193 .args(["cherry-pick", "--abort"])
1194 .current_dir(self.git_repo.path())
1195 .output();
1196
1197 return Err(CascadeError::Branch(format!(
1198 "Cherry-pick failed for {} but Git index shows no conflicts. \
1199 This usually means the cherry-pick was aborted or failed in an unexpected way. \
1200 Please try manual resolution.",
1201 &commit_hash[..8]
1202 )));
1203 }
1204
1205 return Ok(true);
1206 }
1207
1208 let conflicted_files = self.git_repo.get_conflicted_files()?;
1209
1210 if conflicted_files.is_empty() {
1211 debug!("Conflicted files list is empty");
1212 return Ok(true);
1213 }
1214
1215 debug!(
1216 "Found conflicts in {} files: {:?}",
1217 conflicted_files.len(),
1218 conflicted_files
1219 );
1220
1221 let analysis = self
1223 .conflict_analyzer
1224 .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
1225
1226 debug!(
1227 "Conflict analysis: {} total conflicts, {} auto-resolvable",
1228 analysis.total_conflicts, analysis.auto_resolvable_count
1229 );
1230
1231 for recommendation in &analysis.recommendations {
1233 debug!("{}", recommendation);
1234 }
1235
1236 let mut resolved_count = 0;
1237 let mut resolved_files = Vec::new(); let mut failed_files = Vec::new();
1239
1240 for file_analysis in &analysis.files {
1241 debug!(
1242 "Processing file: {} (auto_resolvable: {}, conflicts: {})",
1243 file_analysis.file_path,
1244 file_analysis.auto_resolvable,
1245 file_analysis.conflicts.len()
1246 );
1247
1248 if file_analysis.auto_resolvable {
1249 match self.resolve_file_conflicts_enhanced(
1250 &file_analysis.file_path,
1251 &file_analysis.conflicts,
1252 ) {
1253 Ok(ConflictResolution::Resolved) => {
1254 resolved_count += 1;
1255 resolved_files.push(file_analysis.file_path.clone());
1256 debug!("Successfully resolved {}", file_analysis.file_path);
1257 }
1258 Ok(ConflictResolution::TooComplex) => {
1259 debug!(
1260 "{} too complex for auto-resolution",
1261 file_analysis.file_path
1262 );
1263 failed_files.push(file_analysis.file_path.clone());
1264 }
1265 Err(e) => {
1266 debug!("Failed to resolve {}: {}", file_analysis.file_path, e);
1267 failed_files.push(file_analysis.file_path.clone());
1268 }
1269 }
1270 } else {
1271 failed_files.push(file_analysis.file_path.clone());
1272 debug!(
1273 "{} requires manual resolution ({} conflicts)",
1274 file_analysis.file_path,
1275 file_analysis.conflicts.len()
1276 );
1277 }
1278 }
1279
1280 if resolved_count > 0 {
1281 debug!(
1282 "Resolved {}/{} files",
1283 resolved_count,
1284 conflicted_files.len()
1285 );
1286 debug!("Resolved files: {:?}", resolved_files);
1287
1288 let file_paths: Vec<&str> = resolved_files.iter().map(|s| s.as_str()).collect();
1291 debug!("Staging {} files", file_paths.len());
1292 self.git_repo.stage_files(&file_paths)?;
1293 debug!("Files staged successfully");
1294 } else {
1295 debug!("No files were resolved (resolved_count = 0)");
1296 }
1297
1298 let all_resolved = failed_files.is_empty();
1300
1301 debug!(
1302 "all_resolved = {}, failed_files = {:?}",
1303 all_resolved, failed_files
1304 );
1305
1306 if !all_resolved {
1307 debug!("{} files still need manual resolution", failed_files.len());
1308 }
1309
1310 debug!("Returning all_resolved = {}", all_resolved);
1311 Ok(all_resolved)
1312 }
1313
1314 fn resolve_file_conflicts_enhanced(
1316 &self,
1317 file_path: &str,
1318 conflicts: &[crate::git::ConflictRegion],
1319 ) -> Result<ConflictResolution> {
1320 let repo_path = self.git_repo.path();
1321 let full_path = repo_path.join(file_path);
1322
1323 let mut content = std::fs::read_to_string(&full_path)
1325 .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
1326
1327 if conflicts.is_empty() {
1328 return Ok(ConflictResolution::Resolved);
1329 }
1330
1331 tracing::debug!(
1332 "Resolving {} conflicts in {} using enhanced analysis",
1333 conflicts.len(),
1334 file_path
1335 );
1336
1337 let mut any_resolved = false;
1338
1339 for conflict in conflicts.iter().rev() {
1341 match self.resolve_single_conflict_enhanced(conflict) {
1342 Ok(Some(resolution)) => {
1343 let before = &content[..conflict.start_pos];
1345 let after = &content[conflict.end_pos..];
1346 content = format!("{before}{resolution}{after}");
1347 any_resolved = true;
1348 debug!(
1349 "✅ Resolved {} conflict at lines {}-{} in {}",
1350 format!("{:?}", conflict.conflict_type).to_lowercase(),
1351 conflict.start_line,
1352 conflict.end_line,
1353 file_path
1354 );
1355 }
1356 Ok(None) => {
1357 debug!(
1358 "⚠️ {} conflict at lines {}-{} in {} requires manual resolution",
1359 format!("{:?}", conflict.conflict_type).to_lowercase(),
1360 conflict.start_line,
1361 conflict.end_line,
1362 file_path
1363 );
1364 return Ok(ConflictResolution::TooComplex);
1365 }
1366 Err(e) => {
1367 debug!("❌ Failed to resolve conflict in {}: {}", file_path, e);
1368 return Ok(ConflictResolution::TooComplex);
1369 }
1370 }
1371 }
1372
1373 if any_resolved {
1374 let remaining_conflicts = self.parse_conflict_markers(&content)?;
1376
1377 if remaining_conflicts.is_empty() {
1378 debug!(
1379 "All conflicts resolved in {}, content length: {} bytes",
1380 file_path,
1381 content.len()
1382 );
1383
1384 if content.trim().is_empty() {
1386 tracing::warn!(
1387 "SAFETY CHECK: Resolved content for {} is empty! Aborting auto-resolution.",
1388 file_path
1389 );
1390 return Ok(ConflictResolution::TooComplex);
1391 }
1392
1393 let backup_path = full_path.with_extension("cascade-backup");
1395 if let Ok(original_content) = std::fs::read_to_string(&full_path) {
1396 debug!(
1397 "Backup for {} (original: {} bytes, resolved: {} bytes)",
1398 file_path,
1399 original_content.len(),
1400 content.len()
1401 );
1402 let _ = std::fs::write(&backup_path, original_content);
1403 }
1404
1405 crate::utils::atomic_file::write_string(&full_path, &content)?;
1407
1408 debug!("Wrote {} bytes to {}", content.len(), file_path);
1409 return Ok(ConflictResolution::Resolved);
1410 } else {
1411 tracing::debug!(
1412 "Partially resolved conflicts in {} ({} remaining)",
1413 file_path,
1414 remaining_conflicts.len()
1415 );
1416 }
1417 }
1418
1419 Ok(ConflictResolution::TooComplex)
1420 }
1421
1422 #[allow(dead_code)]
1424 fn count_whitespace_consistency(content: &str) -> usize {
1425 let mut inconsistencies = 0;
1426 let lines: Vec<&str> = content.lines().collect();
1427
1428 for line in &lines {
1429 if line.contains('\t') && line.contains(' ') {
1431 inconsistencies += 1;
1432 }
1433 }
1434
1435 lines.len().saturating_sub(inconsistencies)
1437 }
1438
1439 fn cleanup_backup_files(&self) -> Result<()> {
1441 use std::fs;
1442 use std::path::Path;
1443
1444 let repo_path = self.git_repo.path();
1445
1446 fn remove_backups_recursive(dir: &Path) {
1448 if let Ok(entries) = fs::read_dir(dir) {
1449 for entry in entries.flatten() {
1450 let path = entry.path();
1451
1452 if path.is_dir() {
1453 if path.file_name().and_then(|n| n.to_str()) != Some(".git") {
1455 remove_backups_recursive(&path);
1456 }
1457 } else if let Some(ext) = path.extension() {
1458 if ext == "cascade-backup" {
1459 debug!("Cleaning up backup file: {}", path.display());
1460 if let Err(e) = fs::remove_file(&path) {
1461 tracing::warn!(
1463 "Could not remove backup file {}: {}",
1464 path.display(),
1465 e
1466 );
1467 }
1468 }
1469 }
1470 }
1471 }
1472 }
1473
1474 remove_backups_recursive(repo_path);
1475 Ok(())
1476 }
1477
1478 fn resolve_single_conflict_enhanced(
1480 &self,
1481 conflict: &crate::git::ConflictRegion,
1482 ) -> Result<Option<String>> {
1483 debug!(
1484 "Resolving {} conflict in {} (lines {}-{})",
1485 format!("{:?}", conflict.conflict_type).to_lowercase(),
1486 conflict.file_path,
1487 conflict.start_line,
1488 conflict.end_line
1489 );
1490
1491 use crate::git::ConflictType;
1492
1493 match conflict.conflict_type {
1494 ConflictType::Whitespace => {
1495 let our_normalized = conflict
1498 .our_content
1499 .split_whitespace()
1500 .collect::<Vec<_>>()
1501 .join(" ");
1502 let their_normalized = conflict
1503 .their_content
1504 .split_whitespace()
1505 .collect::<Vec<_>>()
1506 .join(" ");
1507
1508 if our_normalized == their_normalized {
1509 Ok(Some(conflict.their_content.clone()))
1514 } else {
1515 debug!(
1517 "Whitespace conflict has content differences - requires manual resolution"
1518 );
1519 Ok(None)
1520 }
1521 }
1522 ConflictType::LineEnding => {
1523 let normalized = conflict
1525 .our_content
1526 .replace("\r\n", "\n")
1527 .replace('\r', "\n");
1528 Ok(Some(normalized))
1529 }
1530 ConflictType::PureAddition => {
1531 if conflict.our_content.is_empty() && !conflict.their_content.is_empty() {
1535 Ok(Some(conflict.their_content.clone()))
1537 } else if conflict.their_content.is_empty() && !conflict.our_content.is_empty() {
1538 Ok(Some(String::new()))
1540 } else if conflict.our_content.is_empty() && conflict.their_content.is_empty() {
1541 Ok(Some(String::new()))
1543 } else {
1544 debug!(
1550 "PureAddition conflict has content on both sides - requires manual resolution"
1551 );
1552 Ok(None)
1553 }
1554 }
1555 ConflictType::ImportMerge => {
1556 let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1561 let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1562
1563 let all_simple = our_lines.iter().chain(their_lines.iter()).all(|line| {
1565 let trimmed = line.trim();
1566 trimmed.starts_with("import ")
1567 || trimmed.starts_with("from ")
1568 || trimmed.starts_with("use ")
1569 || trimmed.starts_with("#include")
1570 || trimmed.is_empty()
1571 });
1572
1573 if !all_simple {
1574 debug!("ImportMerge contains non-import lines - requires manual resolution");
1575 return Ok(None);
1576 }
1577
1578 let mut all_imports: Vec<&str> = our_lines
1580 .into_iter()
1581 .chain(their_lines)
1582 .filter(|line| !line.trim().is_empty())
1583 .collect();
1584 all_imports.sort();
1585 all_imports.dedup();
1586 Ok(Some(all_imports.join("\n")))
1587 }
1588 ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
1589 Ok(None)
1591 }
1592 }
1593 }
1594
1595 fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
1597 let lines: Vec<&str> = content.lines().collect();
1598 let mut conflicts = Vec::new();
1599 let mut i = 0;
1600
1601 while i < lines.len() {
1602 if lines[i].starts_with("<<<<<<<") {
1603 let start_line = i + 1;
1605 let mut separator_line = None;
1606 let mut end_line = None;
1607
1608 for (j, line) in lines.iter().enumerate().skip(i + 1) {
1610 if line.starts_with("=======") {
1611 separator_line = Some(j + 1);
1612 } else if line.starts_with(">>>>>>>") {
1613 end_line = Some(j + 1);
1614 break;
1615 }
1616 }
1617
1618 if let (Some(sep), Some(end)) = (separator_line, end_line) {
1619 let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
1621 let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
1622
1623 let our_content = lines[(i + 1)..(sep - 1)].join("\n");
1624 let their_content = lines[sep..(end - 1)].join("\n");
1625
1626 conflicts.push(ConflictRegion {
1627 start: start_pos,
1628 end: end_pos,
1629 start_line,
1630 end_line: end,
1631 our_content,
1632 their_content,
1633 });
1634
1635 i = end;
1636 } else {
1637 i += 1;
1638 }
1639 } else {
1640 i += 1;
1641 }
1642 }
1643
1644 Ok(conflicts)
1645 }
1646
1647 fn update_stack_entry(
1650 &mut self,
1651 stack_id: Uuid,
1652 entry_id: &Uuid,
1653 _new_branch: &str,
1654 new_commit_hash: &str,
1655 ) -> Result<()> {
1656 debug!(
1657 "Updating entry {} in stack {} with new commit {}",
1658 entry_id, stack_id, new_commit_hash
1659 );
1660
1661 let stack = self
1663 .stack_manager
1664 .get_stack_mut(&stack_id)
1665 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1666
1667 let entry_exists = stack.entries.iter().any(|e| e.id == *entry_id);
1669
1670 if entry_exists {
1671 let old_hash = stack
1672 .entries
1673 .iter()
1674 .find(|e| e.id == *entry_id)
1675 .map(|e| e.commit_hash.clone())
1676 .unwrap();
1677
1678 debug!(
1679 "Found entry {} - updating commit from '{}' to '{}' (keeping original branch)",
1680 entry_id, old_hash, new_commit_hash
1681 );
1682
1683 stack
1686 .update_entry_commit_hash(entry_id, new_commit_hash.to_string())
1687 .map_err(CascadeError::config)?;
1688
1689 debug!(
1692 "Successfully updated entry {} in stack {}",
1693 entry_id, stack_id
1694 );
1695 Ok(())
1696 } else {
1697 Err(CascadeError::config(format!(
1698 "Entry {entry_id} not found in stack {stack_id}"
1699 )))
1700 }
1701 }
1702
1703 fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1705 match self.git_repo.update_local_branch_from_remote(branch) {
1706 Ok(_) => {
1707 tracing::debug!("Updated base branch '{}' from remote", branch);
1708 Ok(())
1709 }
1710 Err(e) => {
1711 tracing::debug!("Could not update base branch '{}': {}", branch, e);
1712 Ok(())
1714 }
1715 }
1716 }
1717
1718 pub fn is_rebase_in_progress(&self) -> bool {
1720 let git_dir = self.git_repo.git_dir();
1722 git_dir.join("REBASE_HEAD").exists()
1723 || git_dir.join("rebase-merge").exists()
1724 || git_dir.join("rebase-apply").exists()
1725 }
1726
1727 pub fn abort_rebase(&self) -> Result<()> {
1729 tracing::debug!("Aborting rebase operation");
1730
1731 let git_dir = self.git_repo.git_dir();
1732
1733 if git_dir.join("REBASE_HEAD").exists() {
1735 std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1736 CascadeError::Git(git2::Error::from_str(&format!(
1737 "Failed to clean rebase state: {e}"
1738 )))
1739 })?;
1740 }
1741
1742 if git_dir.join("rebase-merge").exists() {
1743 std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1744 CascadeError::Git(git2::Error::from_str(&format!(
1745 "Failed to clean rebase-merge: {e}"
1746 )))
1747 })?;
1748 }
1749
1750 if git_dir.join("rebase-apply").exists() {
1751 std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1752 CascadeError::Git(git2::Error::from_str(&format!(
1753 "Failed to clean rebase-apply: {e}"
1754 )))
1755 })?;
1756 }
1757
1758 tracing::debug!("Rebase aborted successfully");
1759 Ok(())
1760 }
1761
1762 pub fn continue_rebase(&self) -> Result<()> {
1764 tracing::debug!("Continuing rebase operation");
1765
1766 if self.git_repo.has_conflicts()? {
1768 return Err(CascadeError::branch(
1769 "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1770 ));
1771 }
1772
1773 self.git_repo.stage_conflict_resolved_files()?;
1775
1776 tracing::debug!("Rebase continued successfully");
1777 Ok(())
1778 }
1779
1780 fn has_in_progress_cherry_pick(&self) -> Result<bool> {
1782 let git_dir = self.git_repo.git_dir();
1783 Ok(git_dir.join("CHERRY_PICK_HEAD").exists())
1784 }
1785
1786 fn handle_in_progress_cherry_pick(&mut self, stack: &Stack) -> Result<RebaseResult> {
1788 use crate::cli::output::Output;
1789
1790 let git_dir = self.git_repo.git_dir();
1791
1792 Output::section("Resuming in-progress sync");
1793 println!();
1794 Output::info("Detected unfinished cherry-pick from previous sync");
1795 println!();
1796
1797 if self.git_repo.has_conflicts()? {
1799 let conflicted_files = self.git_repo.get_conflicted_files()?;
1800
1801 let result = RebaseResult {
1802 success: false,
1803 branch_mapping: HashMap::new(),
1804 conflicts: conflicted_files.clone(),
1805 new_commits: Vec::new(),
1806 error: Some(format!(
1807 "Cannot continue: {} file(s) still have unresolved conflicts\n\n\
1808 MANUAL CONFLICT RESOLUTION REQUIRED\n\
1809 =====================================\n\n\
1810 Conflicted files:\n{}\n\n\
1811 Step 1: Analyze conflicts\n\
1812 → Run: ca conflicts\n\
1813 → Shows detailed conflict analysis\n\n\
1814 Step 2: Resolve conflicts in your editor\n\
1815 → Open conflicted files and edit them\n\
1816 → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
1817 → Keep the code you want\n\
1818 → Save the files\n\n\
1819 Step 3: Mark conflicts as resolved\n\
1820 → Run: git add <resolved-files>\n\
1821 → Or: git add -A (to stage all resolved files)\n\n\
1822 Step 4: Complete the sync\n\
1823 → Run: ca sync continue\n\
1824 → Cascade will complete the cherry-pick and continue\n\n\
1825 Alternative: Abort and start over\n\
1826 → Run: ca sync abort\n\
1827 → Then: ca sync (starts fresh)",
1828 conflicted_files.len(),
1829 conflicted_files
1830 .iter()
1831 .map(|f| format!(" - {}", f))
1832 .collect::<Vec<_>>()
1833 .join("\n")
1834 )),
1835 summary: "Sync paused - conflicts need resolution".to_string(),
1836 };
1837
1838 return Ok(result);
1839 }
1840
1841 Output::info("Conflicts resolved, continuing cherry-pick...");
1843
1844 self.git_repo.stage_conflict_resolved_files()?;
1846
1847 let cherry_pick_msg_file = git_dir.join("CHERRY_PICK_MSG");
1849 let commit_message = if cherry_pick_msg_file.exists() {
1850 std::fs::read_to_string(&cherry_pick_msg_file)
1851 .unwrap_or_else(|_| "Resolved conflicts".to_string())
1852 } else {
1853 "Resolved conflicts".to_string()
1854 };
1855
1856 match self.git_repo.commit(&commit_message) {
1857 Ok(_new_commit_id) => {
1858 Output::success("Cherry-pick completed");
1859
1860 if git_dir.join("CHERRY_PICK_HEAD").exists() {
1862 let _ = std::fs::remove_file(git_dir.join("CHERRY_PICK_HEAD"));
1863 }
1864 if cherry_pick_msg_file.exists() {
1865 let _ = std::fs::remove_file(&cherry_pick_msg_file);
1866 }
1867
1868 println!();
1869 Output::info("Continuing with rest of stack...");
1870 println!();
1871
1872 self.rebase_with_force_push(stack)
1875 }
1876 Err(e) => {
1877 let result = RebaseResult {
1878 success: false,
1879 branch_mapping: HashMap::new(),
1880 conflicts: Vec::new(),
1881 new_commits: Vec::new(),
1882 error: Some(format!(
1883 "Failed to complete cherry-pick: {}\n\n\
1884 This usually means:\n\
1885 - Another Git process is accessing the repository\n\
1886 - File permissions issue\n\
1887 - Disk space issue\n\n\
1888 Recovery:\n\
1889 1. Check if another Git operation is running\n\
1890 2. Run 'git status' to check repo state\n\
1891 3. Retry 'ca sync' after fixing the issue\n\n\
1892 Or abort and start fresh:\n\
1893 → Run: ca sync abort\n\
1894 → Then: ca sync",
1895 e
1896 )),
1897 summary: "Failed to complete cherry-pick".to_string(),
1898 };
1899
1900 Ok(result)
1901 }
1902 }
1903 }
1904}
1905
1906impl RebaseResult {
1907 pub fn get_summary(&self) -> String {
1909 if self.success {
1910 format!("✅ {}", self.summary)
1911 } else {
1912 format!(
1913 "❌ Rebase failed: {}",
1914 self.error.as_deref().unwrap_or("Unknown error")
1915 )
1916 }
1917 }
1918
1919 pub fn has_conflicts(&self) -> bool {
1921 !self.conflicts.is_empty()
1922 }
1923
1924 pub fn success_count(&self) -> usize {
1926 self.new_commits.len()
1927 }
1928}
1929
1930#[cfg(test)]
1931mod tests {
1932 use super::*;
1933 use std::path::PathBuf;
1934 use std::process::Command;
1935 use tempfile::TempDir;
1936
1937 #[allow(dead_code)]
1938 fn create_test_repo() -> (TempDir, PathBuf) {
1939 let temp_dir = TempDir::new().unwrap();
1940 let repo_path = temp_dir.path().to_path_buf();
1941
1942 Command::new("git")
1944 .args(["init"])
1945 .current_dir(&repo_path)
1946 .output()
1947 .unwrap();
1948 Command::new("git")
1949 .args(["config", "user.name", "Test"])
1950 .current_dir(&repo_path)
1951 .output()
1952 .unwrap();
1953 Command::new("git")
1954 .args(["config", "user.email", "test@test.com"])
1955 .current_dir(&repo_path)
1956 .output()
1957 .unwrap();
1958
1959 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1961 Command::new("git")
1962 .args(["add", "."])
1963 .current_dir(&repo_path)
1964 .output()
1965 .unwrap();
1966 Command::new("git")
1967 .args(["commit", "-m", "Initial"])
1968 .current_dir(&repo_path)
1969 .output()
1970 .unwrap();
1971
1972 (temp_dir, repo_path)
1973 }
1974
1975 #[test]
1976 fn test_conflict_region_creation() {
1977 let region = ConflictRegion {
1978 start: 0,
1979 end: 50,
1980 start_line: 1,
1981 end_line: 3,
1982 our_content: "function test() {\n return true;\n}".to_string(),
1983 their_content: "function test() {\n return true;\n}".to_string(),
1984 };
1985
1986 assert_eq!(region.start_line, 1);
1987 assert_eq!(region.end_line, 3);
1988 assert!(region.our_content.contains("return true"));
1989 assert!(region.their_content.contains("return true"));
1990 }
1991
1992 #[test]
1993 fn test_rebase_strategies() {
1994 assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1995 assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1996 }
1997
1998 #[test]
1999 fn test_rebase_options() {
2000 let options = RebaseOptions::default();
2001 assert_eq!(options.strategy, RebaseStrategy::ForcePush);
2002 assert!(!options.interactive);
2003 assert!(options.auto_resolve);
2004 assert_eq!(options.max_retries, 3);
2005 }
2006
2007 #[test]
2008 fn test_cleanup_guard_tracks_branches() {
2009 let mut guard = TempBranchCleanupGuard::new();
2010 assert!(guard.branches.is_empty());
2011
2012 guard.add_branch("test-branch-1".to_string());
2013 guard.add_branch("test-branch-2".to_string());
2014
2015 assert_eq!(guard.branches.len(), 2);
2016 assert_eq!(guard.branches[0], "test-branch-1");
2017 assert_eq!(guard.branches[1], "test-branch-2");
2018 }
2019
2020 #[test]
2021 fn test_cleanup_guard_prevents_double_cleanup() {
2022 use std::process::Command;
2023 use tempfile::TempDir;
2024
2025 let temp_dir = TempDir::new().unwrap();
2027 let repo_path = temp_dir.path();
2028
2029 Command::new("git")
2030 .args(["init"])
2031 .current_dir(repo_path)
2032 .output()
2033 .unwrap();
2034
2035 Command::new("git")
2036 .args(["config", "user.name", "Test"])
2037 .current_dir(repo_path)
2038 .output()
2039 .unwrap();
2040
2041 Command::new("git")
2042 .args(["config", "user.email", "test@test.com"])
2043 .current_dir(repo_path)
2044 .output()
2045 .unwrap();
2046
2047 std::fs::write(repo_path.join("test.txt"), "test").unwrap();
2049 Command::new("git")
2050 .args(["add", "."])
2051 .current_dir(repo_path)
2052 .output()
2053 .unwrap();
2054 Command::new("git")
2055 .args(["commit", "-m", "initial"])
2056 .current_dir(repo_path)
2057 .output()
2058 .unwrap();
2059
2060 let git_repo = GitRepository::open(repo_path).unwrap();
2061
2062 git_repo.create_branch("test-temp", None).unwrap();
2064
2065 let mut guard = TempBranchCleanupGuard::new();
2066 guard.add_branch("test-temp".to_string());
2067
2068 guard.cleanup(&git_repo);
2070 assert!(guard.cleaned);
2071
2072 guard.cleanup(&git_repo);
2074 assert!(guard.cleaned);
2075 }
2076
2077 #[test]
2078 fn test_rebase_result() {
2079 let result = RebaseResult {
2080 success: true,
2081 branch_mapping: std::collections::HashMap::new(),
2082 conflicts: vec!["abc123".to_string()],
2083 new_commits: vec!["def456".to_string()],
2084 error: None,
2085 summary: "Test summary".to_string(),
2086 };
2087
2088 assert!(result.success);
2089 assert!(result.has_conflicts());
2090 assert_eq!(result.success_count(), 1);
2091 }
2092}