Skip to main content

cascade_cli/stack/
rebase.rs

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/// Conflict resolution result
11#[derive(Debug, Clone)]
12enum ConflictResolution {
13    /// Conflict was successfully resolved
14    Resolved,
15    /// Conflict is too complex for automatic resolution
16    TooComplex,
17}
18
19/// Represents a conflict region in a file
20#[derive(Debug, Clone)]
21#[allow(dead_code)]
22struct ConflictRegion {
23    /// Byte position where conflict starts
24    start: usize,
25    /// Byte position where conflict ends  
26    end: usize,
27    /// Line number where conflict starts
28    start_line: usize,
29    /// Line number where conflict ends
30    end_line: usize,
31    /// Content from "our" side (before separator)
32    our_content: String,
33    /// Content from "their" side (after separator)
34    their_content: String,
35}
36
37/// Strategy for rebasing stacks (force-push is the only valid approach for preserving PR history)
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub enum RebaseStrategy {
40    /// Force-push rebased commits to original branches (preserves PR history)
41    /// This is the industry standard used by Graphite, Phabricator, spr, etc.
42    ForcePush,
43    /// Interactive rebase with conflict resolution
44    Interactive,
45}
46
47/// Options for rebase operations
48#[derive(Debug, Clone)]
49pub struct RebaseOptions {
50    /// The rebase strategy to use
51    pub strategy: RebaseStrategy,
52    /// Whether to run interactively (prompt for user input)
53    pub interactive: bool,
54    /// Target base branch to rebase onto
55    pub target_base: Option<String>,
56    /// Whether to preserve merge commits
57    pub preserve_merges: bool,
58    /// Whether to auto-resolve simple conflicts
59    pub auto_resolve: bool,
60    /// Maximum number of retries for conflict resolution
61    pub max_retries: usize,
62    /// Skip pulling latest changes (when already done by caller)
63    pub skip_pull: Option<bool>,
64    /// Original working branch to restore after rebase (if different from base)
65    /// This is critical to prevent updating the base branch when sync checks out to it
66    pub original_working_branch: Option<String>,
67}
68
69/// Result of a rebase operation
70#[derive(Debug)]
71pub struct RebaseResult {
72    /// Whether the rebase was successful
73    pub success: bool,
74    /// Old branch to new branch mapping
75    pub branch_mapping: HashMap<String, String>,
76    /// Commits that had conflicts
77    pub conflicts: Vec<String>,
78    /// New commit hashes
79    pub new_commits: Vec<String>,
80    /// Error message if rebase failed
81    pub error: Option<String>,
82    /// Summary of changes made
83    pub summary: String,
84}
85
86/// RAII guard to ensure temporary branches are cleaned up even on error/panic
87///
88/// This stores branch names and provides a cleanup method that can be called
89/// with a GitRepository reference. The Drop trait ensures cleanup happens
90/// even if the rebase function panics or returns early with an error.
91#[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    /// Perform cleanup with provided git repository
111    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                // Continue with cleanup even if one fails
121            }
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            // This path is only hit on panic or unexpected early return
131            // We can't access git_repo here, so just log the branches that need manual cleanup
132            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
142/// Manages rebase operations for stacks
143pub 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    /// Create a new rebase manager
167    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    /// Consume the rebase manager and return the updated stack manager
181    pub fn into_stack_manager(self) -> StackManager {
182        self.stack_manager
183    }
184
185    /// Rebase an entire stack onto a new base
186    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    /// Rebase using force-push strategy (industry standard for stacked diffs)
202    /// This updates local branches in-place, then force-pushes ONLY branches with existing PRs
203    /// to preserve PR history - the approach used by Graphite, Phabricator, spr, etc.
204    fn rebase_with_force_push(&mut self, stack: &Stack) -> Result<RebaseResult> {
205        use crate::cli::output::Output;
206
207        // Check if there's an in-progress cherry-pick from a previous failed sync
208        if self.has_in_progress_cherry_pick()? {
209            return self.handle_in_progress_cherry_pick(stack);
210        }
211
212        // Print section header
213        Output::section(format!("Rebasing stack: {}", stack.name));
214        Output::sub_item(format!("Base branch: {}", stack.base_branch));
215
216        // Show entry breakdown (total vs unmerged)
217        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(); // Clone to avoid borrow issues
245
246        // Use the original working branch passed in options, or detect current branch
247        // CRITICAL: sync_stack passes the original branch before it checks out to base
248        // This prevents us from thinking we started on the base branch
249        let original_branch = self
250            .options
251            .original_working_branch
252            .clone()
253            .or_else(|| self.git_repo.get_current_branch().ok());
254
255        // Store original branch for cleanup on early error returns
256        let original_branch_for_cleanup = original_branch.clone();
257
258        // SAFETY: Warn if we're starting on the base branch (unusual but valid)
259        // This can happen if user manually runs rebase while on base branch
260        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        // Note: Caller (sync_stack) has already checked out base branch when skip_pull=true
270        // Only pull if not already done by caller (like sync command)
271        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        // Reset working directory to clean state before rebase
278        if let Err(e) = self.git_repo.reset_to_head() {
279            // Attempt to restore the original branch before bailing
280            if let Some(ref orig) = original_branch_for_cleanup {
281                let _ = self.git_repo.checkout_branch_unsafe(orig);
282            }
283
284            return Err(CascadeError::branch(format!(
285                "Could not reset working directory to HEAD: {}\n\
286                Check if another Git process is running and wait for it to complete before retrying.",
287                e
288            )));
289        }
290
291        let mut current_base = target_base.clone();
292        // Count only unmerged entries for display purposes
293        let entry_count = stack.entries.iter().filter(|e| !e.is_merged).count();
294        let mut temp_branches: Vec<String> = Vec::new(); // Track temp branches for cleanup
295
296        // Handle empty stack early (no unmerged entries)
297        if entry_count == 0 {
298            println!();
299            if stack.entries.is_empty() {
300                Output::info("Stack has no entries yet");
301                Output::tip("Use 'ca push' to add commits to this stack");
302                result.summary = "Stack is empty".to_string();
303            } else {
304                Output::info("All entries in this stack have been merged");
305                Output::tip("Use 'ca push' to add new commits, or 'ca stack cleanup' to prune merged branches");
306                result.summary = "All entries merged".to_string();
307            }
308
309            // Print success with summary (consistent with non-empty path)
310            println!();
311            Output::success(&result.summary);
312
313            // Save metadata and return
314            self.stack_manager.save_to_disk()?;
315            return Ok(result);
316        }
317
318        // Check if ALL unmerged entries are already correctly based - if so, skip rebase entirely
319        let all_up_to_date = stack
320            .entries
321            .iter()
322            .filter(|entry| !entry.is_merged) // Only check unmerged entries
323            .all(|entry| {
324                self.git_repo
325                    .is_commit_based_on(&entry.commit_hash, &target_base)
326                    .unwrap_or(false)
327            });
328
329        if all_up_to_date {
330            println!();
331            Output::success("Stack is already up-to-date with base branch");
332            result.summary = "Stack is up-to-date".to_string();
333            result.success = true;
334            return Ok(result);
335        }
336
337        let repo_root = self.git_repo.path().to_path_buf();
338        let mut sync_state = SyncState {
339            stack_id: stack.id.to_string(),
340            stack_name: stack.name.clone(),
341            original_branch: original_branch_for_cleanup
342                .clone()
343                .unwrap_or_else(|| target_base.clone()),
344            target_base: target_base.clone(),
345            remaining_entry_ids: stack.entries.iter().map(|e| e.id.to_string()).collect(),
346            current_entry_id: String::new(),
347            current_entry_branch: String::new(),
348            current_temp_branch: String::new(),
349            temp_branches: Vec::new(),
350        };
351
352        // Remove any stale sync state before starting
353        let _ = SyncState::delete(&repo_root);
354
355        // Phase 1: Rebase all entries locally (libgit2 only - no CLI commands)
356        // Track which branches actually changed so we only push what’s needed
357        let mut branches_with_new_commits: std::collections::HashSet<String> =
358            std::collections::HashSet::new();
359        let mut branches_to_push: Vec<(String, String, usize)> = Vec::new(); // (branch_name, pr_number, display_index)
360        let mut processed_entries: usize = 0; // Count unmerged entries we actually process
361        for (index, entry) in stack.entries.iter().enumerate() {
362            let original_branch = &entry.branch;
363            let entry_id_str = entry.id.to_string();
364
365            sync_state.remaining_entry_ids = stack
366                .entries
367                .iter()
368                .skip(index + 1)
369                .map(|e| e.id.to_string())
370                .collect();
371
372            // Skip merged entries - they're already in the base branch
373            if entry.is_merged {
374                tracing::debug!(
375                    "Entry '{}' is merged into '{}', skipping rebase",
376                    original_branch,
377                    target_base
378                );
379                // Don't advance current_base; next entry will rebase onto the base branch
380                continue;
381            }
382
383            processed_entries += 1;
384
385            sync_state.current_entry_id = entry_id_str.clone();
386            sync_state.current_entry_branch = original_branch.clone();
387
388            // Check if this entry is already correctly based on the current base
389            // If so, skip rebasing it (avoids creating duplicate commits)
390            if self
391                .git_repo
392                .is_commit_based_on(&entry.commit_hash, &current_base)
393                .unwrap_or(false)
394            {
395                tracing::debug!(
396                    "Entry '{}' is already correctly based on '{}', skipping rebase",
397                    original_branch,
398                    current_base
399                );
400
401                result
402                    .branch_mapping
403                    .insert(original_branch.clone(), original_branch.clone());
404
405                // This branch becomes the base for the next entry
406                current_base = original_branch.clone();
407                continue;
408            }
409
410            // CRITICAL: Bail early if a previous entry failed
411            // Don't continue rebasing subsequent entries or we'll create corrupt state
412            if !result.success {
413                tracing::debug!(
414                    "Skipping entry '{}' because previous entry failed",
415                    original_branch
416                );
417                break;
418            }
419
420            // Create a temporary branch from the current base
421            // This avoids committing directly to protected branches like develop/main
422            let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
423            temp_branches.push(temp_branch.clone()); // Track for cleanup
424            sync_state.current_temp_branch = temp_branch.clone();
425
426            // Create and checkout temp branch - restore original branch on error
427            if let Err(e) = self
428                .git_repo
429                .create_branch(&temp_branch, Some(&current_base))
430            {
431                // Restore original branch before returning error
432                if let Some(ref orig) = original_branch_for_cleanup {
433                    let _ = self.git_repo.checkout_branch_unsafe(orig);
434                }
435                return Err(e);
436            }
437
438            if let Err(e) = self.git_repo.checkout_branch_silent(&temp_branch) {
439                // Restore original branch before returning error
440                if let Some(ref orig) = original_branch_for_cleanup {
441                    let _ = self.git_repo.checkout_branch_unsafe(orig);
442                }
443                return Err(e);
444            }
445
446            // Persist sync state now that the temp branch exists and we're on it
447            sync_state.temp_branches = temp_branches.clone();
448            sync_state.save(&repo_root)?;
449
450            // Cherry-pick the commit onto the temp branch (NOT the protected base!)
451            match self.cherry_pick_commit(&entry.commit_hash) {
452                Ok(new_commit_hash) => {
453                    result.new_commits.push(new_commit_hash.clone());
454
455                    // Get the commit that's now at HEAD (the cherry-picked commit)
456                    let rebased_commit_id = self.git_repo.get_head_commit()?.id().to_string();
457
458                    // Update the original branch to point to this rebased commit
459                    // This is LOCAL ONLY - moves refs/heads/<branch> to the commit on temp branch
460                    self.git_repo
461                        .update_branch_to_commit(original_branch, &rebased_commit_id)?;
462
463                    result
464                        .branch_mapping
465                        .insert(original_branch.clone(), original_branch.clone());
466
467                    // Update stack entry with new commit hash
468                    self.update_stack_entry(
469                        stack.id,
470                        &entry.id,
471                        original_branch,
472                        &rebased_commit_id,
473                    )?;
474                    branches_with_new_commits.insert(original_branch.clone());
475
476                    // Print tree item immediately and track for push phase
477                    if let Some(pr_num) = &entry.pull_request_id {
478                        let tree_char = if processed_entries == entry_count {
479                            "└─"
480                        } else {
481                            "├─"
482                        };
483                        println!("   {} {} (PR #{})", tree_char, original_branch, pr_num);
484                        branches_to_push.push((
485                            original_branch.clone(),
486                            pr_num.clone(),
487                            processed_entries - 1,
488                        ));
489                    }
490
491                    // This branch becomes the base for the next entry
492                    current_base = original_branch.clone();
493
494                    let _ = SyncState::delete(&repo_root);
495                }
496                Err(e) => {
497                    // Detect no-op cherry-picks (commit already applied)
498                    if self.git_repo.get_conflicted_files()?.is_empty() {
499                        debug!(
500                            "Cherry-pick produced no changes for {} ({}), skipping entry",
501                            original_branch,
502                            &entry.commit_hash[..8]
503                        );
504
505                        Output::warning(format!(
506                            "Skipping entry '{}' - cherry-pick resulted in no changes",
507                            original_branch
508                        ));
509                        Output::sub_item("This usually means the base branch has moved forward");
510                        Output::sub_item("and this entry's changes are already present");
511
512                        // Abort the empty cherry-pick to clean state
513                        let _ = std::process::Command::new("git")
514                            .args(["cherry-pick", "--abort"])
515                            .current_dir(self.git_repo.path())
516                            .output();
517
518                        // Clean up temp branch and return to base for next entry
519                        let _ = self.git_repo.checkout_branch_unsafe(&target_base);
520                        let _ = self.git_repo.delete_branch_unsafe(&temp_branch);
521                        let _ = temp_branches.pop();
522                        let _ = SyncState::delete(&repo_root);
523
524                        // Continue with next entry without marking failure
525                        continue;
526                    }
527
528                    result.conflicts.push(entry.commit_hash.clone());
529
530                    if !self.options.auto_resolve {
531                        println!();
532                        Output::error(e.to_string());
533                        result.success = false;
534                        result.error = Some(format!(
535                            "Conflict in {}: {}\n\n\
536                            MANUAL CONFLICT RESOLUTION REQUIRED\n\
537                            =====================================\n\n\
538                            Step 1: Analyze conflicts\n\
539                            → Run: ca conflicts\n\
540                            → This shows which conflicts are in which files\n\n\
541                            Step 2: Resolve conflicts in your editor\n\
542                            → Open conflicted files and edit them\n\
543                            → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
544                            → Keep the code you want\n\
545                            → Save the files\n\n\
546                            Step 3: Mark conflicts as resolved\n\
547                            → Run: git add <resolved-files>\n\
548                            → Or: git add -A (to stage all resolved files)\n\n\
549                            Step 4: Complete the sync\n\
550                            → Run: ca sync continue\n\
551                            → Cascade will complete the cherry-pick and continue\n\n\
552                            Alternative: Abort and start over\n\
553                            → Run: ca sync abort\n\
554                            → Then: ca sync (starts fresh)\n\n\
555                            TIP: Enable auto-resolution for simple conflicts:\n\
556                            → Run: ca sync --auto-resolve\n\
557                            → Only complex conflicts will require manual resolution",
558                            entry.commit_hash, e
559                        ));
560                        break;
561                    }
562
563                    // Try to resolve automatically
564                    match self.auto_resolve_conflicts(&entry.commit_hash) {
565                        Ok(fully_resolved) => {
566                            if !fully_resolved {
567                                result.success = false;
568                                result.error = Some(format!(
569                                    "Conflicts in commit {}\n\n\
570                                    To resolve:\n\
571                                    1. Fix conflicts in your editor\n\
572                                    2. Stage resolved files: git add <files>\n\
573                                    3. Continue: ca sync continue\n\n\
574                                    Or abort:\n\
575                                    → Run: ca sync abort",
576                                    &entry.commit_hash[..8]
577                                ));
578                                break;
579                            }
580
581                            // Commit the resolved changes with the ORIGINAL commit message
582                            // This preserves the user's intended commit message even after auto-resolution
583                            let commit_message = entry.message.trim().to_string();
584
585                            // CRITICAL: Check if there are actually changes to commit
586                            debug!("Checking staged files before commit");
587                            let staged_files = self.git_repo.get_staged_files()?;
588
589                            if staged_files.is_empty() {
590                                // Empty commit detected - this happens when base branch moved forward
591                                // and the cherry-pick resulted in no changes (all changes already present)
592                                debug!(
593                                    "Cherry-pick resulted in empty commit for {}",
594                                    &entry.commit_hash[..8]
595                                );
596
597                                // This is normal when develop has moved forward - skip this commit
598                                Output::warning(format!(
599                                    "Skipping entry '{}' - cherry-pick resulted in no changes",
600                                    original_branch
601                                ));
602                                Output::sub_item(
603                                    "This usually means the base branch has moved forward",
604                                );
605                                Output::sub_item("and this entry's changes are already present");
606
607                                // Clean up the failed cherry-pick
608                                let _ = std::process::Command::new("git")
609                                    .args(["cherry-pick", "--abort"])
610                                    .current_dir(self.git_repo.path())
611                                    .output();
612
613                                let _ = self.git_repo.checkout_branch_unsafe(&target_base);
614                                let _ = self.git_repo.delete_branch_unsafe(&temp_branch);
615                                let _ = temp_branches.pop();
616
617                                let _ = SyncState::delete(&repo_root);
618
619                                // Continue to next entry instead of failing
620                                continue;
621                            }
622
623                            debug!("{} files staged", staged_files.len());
624
625                            match self.git_repo.commit(&commit_message) {
626                                Ok(new_commit_id) => {
627                                    debug!(
628                                        "Created commit {} with message '{}'",
629                                        &new_commit_id[..8],
630                                        commit_message
631                                    );
632
633                                    // Clean up any in-progress cherry-pick state (CHERRY_PICK_HEAD, etc.)
634                                    if let Err(e) = self.git_repo.cleanup_state() {
635                                        tracing::warn!(
636                                            "Failed to clean up repository state after auto-resolve: {}",
637                                            e
638                                        );
639                                    }
640
641                                    Output::success("Auto-resolved conflicts");
642                                    result.new_commits.push(new_commit_id.clone());
643                                    let rebased_commit_id = new_commit_id;
644
645                                    // Clean up backup files after successful commit
646                                    self.cleanup_backup_files()?;
647
648                                    // Update the original branch to point to this rebased commit
649                                    self.git_repo.update_branch_to_commit(
650                                        original_branch,
651                                        &rebased_commit_id,
652                                    )?;
653
654                                    branches_with_new_commits.insert(original_branch.clone());
655
656                                    // Print tree item immediately and track for push phase
657                                    if let Some(pr_num) = &entry.pull_request_id {
658                                        let tree_char = if processed_entries == entry_count {
659                                            "└─"
660                                        } else {
661                                            "├─"
662                                        };
663                                        println!(
664                                            "   {} {} (PR #{})",
665                                            tree_char, original_branch, pr_num
666                                        );
667                                        branches_to_push.push((
668                                            original_branch.clone(),
669                                            pr_num.clone(),
670                                            processed_entries - 1,
671                                        ));
672                                    }
673
674                                    result
675                                        .branch_mapping
676                                        .insert(original_branch.clone(), original_branch.clone());
677
678                                    // Update stack entry with new commit hash
679                                    self.update_stack_entry(
680                                        stack.id,
681                                        &entry.id,
682                                        original_branch,
683                                        &rebased_commit_id,
684                                    )?;
685
686                                    // This branch becomes the base for the next entry
687                                    current_base = original_branch.clone();
688                                }
689                                Err(commit_err) => {
690                                    result.success = false;
691                                    result.error = Some(format!(
692                                        "Could not commit auto-resolved conflicts: {}\n\n\
693                                        This usually means:\n\
694                                        - Another Git process is accessing the repository\n\
695                                        - File permissions issue\n\
696                                        - Disk space issue\n\n\
697                                        Recovery:\n\
698                                        1. Check if another Git operation is running\n\
699                                        2. Run 'git status' to check repo state\n\
700                                        3. Retry 'ca sync' after fixing the issue",
701                                        commit_err
702                                    ));
703                                    break;
704                                }
705                            }
706                        }
707                        Err(resolve_err) => {
708                            result.success = false;
709                            result.error = Some(format!(
710                                "Could not resolve conflicts: {}\n\n\
711                                Recovery:\n\
712                                1. Check repo state: 'git status'\n\
713                                2. If files are staged, commit or reset them: 'git reset --hard HEAD'\n\
714                                3. Remove any lock files: 'rm -f .git/index.lock'\n\
715                                4. Retry 'ca sync'",
716                                resolve_err
717                            ));
718                            break;
719                        }
720                    }
721                }
722            }
723        }
724
725        // Cleanup temp branches before returning to original branch.
726        // Only do this when the rebase succeeded; on conflicts we leave the temp
727        // branch intact so the user can resolve the issue.
728        if result.success && !temp_branches.is_empty() {
729            // Force checkout to base branch to allow temp branch deletion
730            // Use unsafe checkout to bypass safety checks since we know this is cleanup
731            if let Err(e) = self.git_repo.checkout_branch_unsafe(&target_base) {
732                debug!("Could not checkout base for cleanup: {}", e);
733                // If we can't checkout, we can't delete temp branches
734                // This is non-critical - temp branches will be cleaned up eventually
735            } else {
736                // Successfully checked out - now delete temp branches
737                for temp_branch in &temp_branches {
738                    if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
739                        debug!("Could not delete temp branch {}: {}", temp_branch, e);
740                    }
741                }
742            }
743        }
744
745        // CRITICAL: Don't push branches if rebase failed
746        // If result.success is false, we had a conflict or error during rebase
747        // Pushing partial results would create corrupt state
748
749        // Declare these variables outside the block so they're accessible for summary
750        let pushed_count = branches_to_push.len();
751        let _skipped_count = entry_count - pushed_count; // Not used in new summary logic
752        let mut successful_pushes = 0; // Track successful pushes for summary
753
754        if !result.success {
755            println!();
756            Output::error("Rebase failed - not pushing any branches");
757            // Error details are in result.error, will be returned at end of function
758            // Skip the push phase and jump straight to cleanup/return
759        } else {
760            // Phase 2: Push all branches with PRs to remote (git CLI - after all libgit2 operations)
761            // This batch approach prevents index lock conflicts between libgit2 and git CLI
762
763            if !branches_to_push.is_empty() {
764                println!();
765
766                // Fetch once before pushing all branches (avoid redundant fetches per branch)
767                if let Err(e) = self.git_repo.fetch_with_retry() {
768                    Output::warning(format!("Could not fetch latest remote state: {}", e));
769                    Output::tip("Continuing with push, but backup branches may not be created if remote state is unknown");
770                }
771
772                // Push all branches (using no-fetch variant since we already fetched)
773                let mut push_results = Vec::new();
774                for (branch_name, _pr_num, _index) in branches_to_push.iter() {
775                    let result = self
776                        .git_repo
777                        .force_push_single_branch_auto_no_fetch(branch_name);
778                    push_results.push((branch_name.clone(), result));
779                }
780
781                // Show static results for each push
782                let mut failed_pushes = 0;
783                for (index, (branch_name, result)) in push_results.iter().enumerate() {
784                    match result {
785                        Ok(_) => {
786                            debug!("Pushed {} successfully", branch_name);
787                            successful_pushes += 1;
788                            println!(
789                                "   ✓ Pushed {} ({}/{})",
790                                branch_name,
791                                index + 1,
792                                pushed_count
793                            );
794                        }
795                        Err(e) => {
796                            failed_pushes += 1;
797                            println!("   ⚠ Could not push '{}': {}", branch_name, e);
798                        }
799                    }
800                }
801
802                // If any pushes failed, show recovery instructions
803                if failed_pushes > 0 {
804                    println!(); // Spacing
805                    Output::warning(format!(
806                        "{} branch(es) failed to push to remote",
807                        failed_pushes
808                    ));
809                    Output::tip("To retry failed pushes, run: ca sync");
810                }
811            }
812
813            // Build result summary
814            let entries_word = if entry_count == 1 { "entry" } else { "entries" };
815            let pr_word = if successful_pushes == 1 { "PR" } else { "PRs" };
816
817            result.summary = if successful_pushes > 0 {
818                let not_submitted_count = entry_count - successful_pushes;
819                if not_submitted_count > 0 {
820                    format!(
821                        "{} {} rebased ({} {} updated, {} not yet submitted)",
822                        entry_count, entries_word, successful_pushes, pr_word, not_submitted_count
823                    )
824                } else {
825                    format!(
826                        "{} {} rebased ({} {} updated)",
827                        entry_count, entries_word, successful_pushes, pr_word
828                    )
829                }
830            } else {
831                format!(
832                    "{} {} rebased (none submitted to Bitbucket yet)",
833                    entry_count, entries_word
834                )
835            };
836        } // End of successful rebase block
837
838        if result.success {
839            // Update working branch to point to the top of the rebased stack
840            // This ensures subsequent `ca push` doesn't re-add old commits
841            // CRITICAL: Use the authoritative working_branch from stack metadata, not current Git state!
842            // This prevents corruption when user runs `ca sync` while checked out to a stack entry branch
843            if let Some(ref working_branch_name) = stack.working_branch {
844                // CRITICAL: Never update the base branch! Only update working branches
845                if working_branch_name != &target_base {
846                    if let Some(last_entry) = stack.entries.last() {
847                        let top_branch = &last_entry.branch;
848
849                        // SAFETY CHECK: Detect if working branch has commits beyond the last stack entry
850                        // If it does, we need to preserve them - don't force-update the working branch
851                        if let (Ok(working_head), Ok(top_commit)) = (
852                            self.git_repo.get_branch_head(working_branch_name),
853                            self.git_repo.get_branch_head(top_branch),
854                        ) {
855                            // Check if working branch is ahead of top stack entry
856                            if working_head != top_commit {
857                                // Get commits between top of stack and working branch head
858                                if let Ok(commits) = self
859                                    .git_repo
860                                    .get_commits_between(&top_commit, &working_head)
861                                {
862                                    if !commits.is_empty() {
863                                        // Check if these commits match the stack entry messages
864                                        // If so, they're likely old pre-rebase versions, not new work
865                                        // Compare first lines (summaries) only, since commit.summary()
866                                        // returns just the first line while entry.message stores the full body
867                                        let stack_summaries: Vec<String> = stack
868                                            .entries
869                                            .iter()
870                                            .map(|e| {
871                                                e.message
872                                                    .lines()
873                                                    .next()
874                                                    .unwrap_or("")
875                                                    .trim()
876                                                    .to_string()
877                                            })
878                                            .collect();
879
880                                        let all_match_stack = commits.iter().all(|commit| {
881                                            if let Some(msg) = commit.summary() {
882                                                stack_summaries
883                                                    .iter()
884                                                    .any(|stack_msg| stack_msg == msg.trim())
885                                            } else {
886                                                false
887                                            }
888                                        });
889
890                                        if all_match_stack {
891                                            // These are the old pre-rebase versions of stack entries
892                                            // Safe to update working branch to new rebased top
893                                            // Note: commits.len() may be less than stack.entries.len() if only
894                                            // some entries were rebased (e.g., after amending one entry)
895                                            debug!(
896                                            "Working branch has old pre-rebase commits (matching stack messages) - safe to update"
897                                        );
898                                        } else {
899                                            // These are truly new commits not in the stack!
900                                            Output::error(format!(
901                                            "Cannot sync: Working branch '{}' has {} commit(s) not in the stack",
902                                            working_branch_name,
903                                            commits.len()
904                                        ));
905                                            println!();
906                                            Output::sub_item(
907                                                "These commits would be lost if we proceed:",
908                                            );
909                                            for (i, commit) in commits.iter().take(5).enumerate() {
910                                                let message =
911                                                    commit.summary().unwrap_or("(no message)");
912                                                Output::sub_item(format!(
913                                                    "  {}. {} - {}",
914                                                    i + 1,
915                                                    &commit.id().to_string()[..8],
916                                                    message
917                                                ));
918                                            }
919                                            if commits.len() > 5 {
920                                                Output::sub_item(format!(
921                                                    "  ... and {} more",
922                                                    commits.len() - 5
923                                                ));
924                                            }
925                                            println!();
926                                            Output::tip("Add these commits to the stack first:");
927                                            Output::bullet("Run: ca stack push");
928                                            Output::bullet("Then run: ca sync");
929                                            println!();
930
931                                            // Restore original branch before returning error
932                                            if let Some(ref orig) = original_branch_for_cleanup {
933                                                let _ = self.git_repo.checkout_branch_unsafe(orig);
934                                            }
935
936                                            return Err(CascadeError::validation(
937                                            format!(
938                                                "Working branch '{}' has {} untracked commit(s). Add them to the stack with 'ca stack push' before syncing.",
939                                                working_branch_name, commits.len()
940                                            )
941                                        ));
942                                        }
943                                    }
944                                }
945                            }
946
947                            // Safe to update - working branch matches top of stack or is behind
948                            debug!(
949                                "Updating working branch '{}' to match top of stack ({})",
950                                working_branch_name,
951                                &top_commit[..8]
952                            );
953
954                            if let Err(e) = self
955                                .git_repo
956                                .update_branch_to_commit(working_branch_name, &top_commit)
957                            {
958                                Output::warning(format!(
959                                    "Could not update working branch '{}' to top of stack: {}",
960                                    working_branch_name, e
961                                ));
962                            }
963                        }
964                    }
965                } else {
966                    // Working branch is the base branch - this is unusual
967                    debug!(
968                        "Skipping working branch update - working branch '{}' is the base branch",
969                        working_branch_name
970                    );
971                }
972            }
973
974            // Return user to their original Git branch (regardless of working branch updates)
975            // This ensures user experience is preserved even when they ran `ca sync` from a stack entry branch
976            if let Some(ref orig_branch) = original_branch {
977                if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
978                    debug!(
979                        "Could not return to original branch '{}': {}",
980                        orig_branch, e
981                    );
982                    // Non-critical: User is left on base branch instead
983                }
984            }
985        }
986        // Note: Summary is now built inside the successful rebase block above (around line 745)
987        // If rebase failed, we'll have an error message but no summary
988
989        // Display result with proper formatting
990        println!();
991        if result.success {
992            Output::success(&result.summary);
993        } else {
994            // Display error with proper icon
995            let error_msg = result
996                .error
997                .as_deref()
998                .unwrap_or("Rebase failed for unknown reason");
999            Output::error(error_msg);
1000        }
1001
1002        // Save the updated stack metadata to disk
1003        self.stack_manager.save_to_disk()?;
1004
1005        // CRITICAL: Return error if rebase failed
1006        // Don't return Ok(result) with result.success = false - that's confusing!
1007        if !result.success {
1008            let detailed_error = result.error.as_deref().unwrap_or("Rebase failed");
1009            return Err(CascadeError::Branch(detailed_error.to_string()));
1010        }
1011
1012        Ok(result)
1013    }
1014
1015    /// Interactive rebase with user input
1016    fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
1017        tracing::debug!("Starting interactive rebase for stack '{}'", stack.name);
1018
1019        let mut result = RebaseResult {
1020            success: true,
1021            branch_mapping: HashMap::new(),
1022            conflicts: Vec::new(),
1023            new_commits: Vec::new(),
1024            error: None,
1025            summary: String::new(),
1026        };
1027
1028        println!("Interactive Rebase for Stack: {}", stack.name);
1029        println!("   Base branch: {}", stack.base_branch);
1030        println!("   Entries: {}", stack.entries.len());
1031
1032        if self.options.interactive {
1033            println!("\nChoose action for each commit:");
1034            println!("  (p)ick   - apply the commit");
1035            println!("  (s)kip   - skip this commit");
1036            println!("  (e)dit   - edit the commit message");
1037            println!("  (q)uit   - abort the rebase");
1038        }
1039
1040        // For now, automatically pick all commits
1041        // In a real implementation, this would prompt the user
1042        for entry in &stack.entries {
1043            println!(
1044                "  {} {} - {}",
1045                entry.short_hash(),
1046                entry.branch,
1047                entry.short_message(50)
1048            );
1049
1050            // Auto-pick for demo purposes
1051            match self.cherry_pick_commit(&entry.commit_hash) {
1052                Ok(new_commit) => result.new_commits.push(new_commit),
1053                Err(_) => result.conflicts.push(entry.commit_hash.clone()),
1054            }
1055        }
1056
1057        result.summary = format!(
1058            "Interactive rebase processed {} commits",
1059            stack.entries.len()
1060        );
1061        Ok(result)
1062    }
1063
1064    /// Cherry-pick a commit onto the current branch
1065    fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
1066        // Use the real cherry-pick implementation from GitRepository
1067        let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
1068
1069        // Check for any leftover staged changes after successful cherry-pick
1070        if let Ok(staged_files) = self.git_repo.get_staged_files() {
1071            if !staged_files.is_empty() {
1072                // CRITICAL: Must commit staged changes - if this fails, user gets checkout warning
1073                let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
1074                match self.git_repo.commit_staged_changes(&cleanup_message) {
1075                    Ok(Some(_)) => {
1076                        debug!(
1077                            "Committed {} leftover staged files after cherry-pick",
1078                            staged_files.len()
1079                        );
1080                    }
1081                    Ok(None) => {
1082                        // Files were unstaged between check and commit - not critical
1083                        debug!("Staged files were cleared before commit");
1084                    }
1085                    Err(e) => {
1086                        // This is serious - staged files will remain and cause checkout warning
1087                        tracing::warn!(
1088                            "Failed to commit {} staged files after cherry-pick: {}. \
1089                             User may see checkout warning with staged changes.",
1090                            staged_files.len(),
1091                            e
1092                        );
1093                        // Don't fail the entire rebase for this - but user will need to handle staged files
1094                    }
1095                }
1096            }
1097        }
1098
1099        Ok(new_commit_hash)
1100    }
1101
1102    /// Attempt to automatically resolve conflicts
1103    fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
1104        debug!("Starting auto-resolve for commit {}", commit_hash);
1105
1106        // Check if there are actually conflicts
1107        let has_conflicts = self.git_repo.has_conflicts()?;
1108        debug!("has_conflicts() = {}", has_conflicts);
1109
1110        // Check if cherry-pick is in progress
1111        let cherry_pick_head = self.git_repo.git_dir().join("CHERRY_PICK_HEAD");
1112        let cherry_pick_in_progress = cherry_pick_head.exists();
1113
1114        if !has_conflicts {
1115            debug!("No conflicts detected by Git index");
1116
1117            // If cherry-pick is in progress but no conflicts detected, something is wrong
1118            if cherry_pick_in_progress {
1119                tracing::debug!(
1120                    "CHERRY_PICK_HEAD exists but no conflicts in index - aborting cherry-pick"
1121                );
1122
1123                // Abort the cherry-pick to clean up
1124                let _ = std::process::Command::new("git")
1125                    .args(["cherry-pick", "--abort"])
1126                    .current_dir(self.git_repo.path())
1127                    .output();
1128
1129                return Err(CascadeError::Branch(format!(
1130                    "Cherry-pick failed for {} but Git index shows no conflicts. \
1131                     This usually means the cherry-pick was aborted or failed in an unexpected way. \
1132                     Please try manual resolution.",
1133                    &commit_hash[..8]
1134                )));
1135            }
1136
1137            return Ok(true);
1138        }
1139
1140        let conflicted_files = self.git_repo.get_conflicted_files()?;
1141
1142        if conflicted_files.is_empty() {
1143            debug!("Conflicted files list is empty");
1144            return Ok(true);
1145        }
1146
1147        debug!(
1148            "Found conflicts in {} files: {:?}",
1149            conflicted_files.len(),
1150            conflicted_files
1151        );
1152
1153        // Use the new conflict analyzer for detailed analysis
1154        let analysis = self
1155            .conflict_analyzer
1156            .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
1157
1158        debug!(
1159            "Conflict analysis: {} total conflicts, {} auto-resolvable",
1160            analysis.total_conflicts, analysis.auto_resolvable_count
1161        );
1162
1163        // Display recommendations
1164        for recommendation in &analysis.recommendations {
1165            debug!("{}", recommendation);
1166        }
1167
1168        let mut resolved_count = 0;
1169        let mut resolved_files = Vec::new(); // Track which files were actually resolved
1170        let mut failed_files = Vec::new();
1171
1172        for file_analysis in &analysis.files {
1173            debug!(
1174                "Processing file: {} (auto_resolvable: {}, conflicts: {})",
1175                file_analysis.file_path,
1176                file_analysis.auto_resolvable,
1177                file_analysis.conflicts.len()
1178            );
1179
1180            if file_analysis.auto_resolvable {
1181                match self.resolve_file_conflicts_enhanced(
1182                    &file_analysis.file_path,
1183                    &file_analysis.conflicts,
1184                ) {
1185                    Ok(ConflictResolution::Resolved) => {
1186                        resolved_count += 1;
1187                        resolved_files.push(file_analysis.file_path.clone());
1188                        debug!("Successfully resolved {}", file_analysis.file_path);
1189                    }
1190                    Ok(ConflictResolution::TooComplex) => {
1191                        debug!(
1192                            "{} too complex for auto-resolution",
1193                            file_analysis.file_path
1194                        );
1195                        failed_files.push(file_analysis.file_path.clone());
1196                    }
1197                    Err(e) => {
1198                        debug!("Failed to resolve {}: {}", file_analysis.file_path, e);
1199                        failed_files.push(file_analysis.file_path.clone());
1200                    }
1201                }
1202            } else {
1203                failed_files.push(file_analysis.file_path.clone());
1204                debug!(
1205                    "{} requires manual resolution ({} conflicts)",
1206                    file_analysis.file_path,
1207                    file_analysis.conflicts.len()
1208                );
1209            }
1210        }
1211
1212        if resolved_count > 0 {
1213            debug!(
1214                "Resolved {}/{} files",
1215                resolved_count,
1216                conflicted_files.len()
1217            );
1218            debug!("Resolved files: {:?}", resolved_files);
1219
1220            // CRITICAL: Only stage files that were successfully resolved
1221            // This prevents staging files that still have conflict markers
1222            let file_paths: Vec<&str> = resolved_files.iter().map(|s| s.as_str()).collect();
1223            debug!("Staging {} files", file_paths.len());
1224            self.git_repo.stage_files(&file_paths)?;
1225            debug!("Files staged successfully");
1226        } else {
1227            debug!("No files were resolved (resolved_count = 0)");
1228        }
1229
1230        // Return true only if ALL conflicts were resolved
1231        let all_resolved = failed_files.is_empty();
1232
1233        debug!(
1234            "all_resolved = {}, failed_files = {:?}",
1235            all_resolved, failed_files
1236        );
1237
1238        if !all_resolved {
1239            debug!("{} files still need manual resolution", failed_files.len());
1240        }
1241
1242        debug!("Returning all_resolved = {}", all_resolved);
1243        Ok(all_resolved)
1244    }
1245
1246    /// Resolve conflicts using enhanced analysis
1247    fn resolve_file_conflicts_enhanced(
1248        &self,
1249        file_path: &str,
1250        conflicts: &[crate::git::ConflictRegion],
1251    ) -> Result<ConflictResolution> {
1252        let repo_path = self.git_repo.path();
1253        let full_path = repo_path.join(file_path);
1254
1255        // Read the file content with conflict markers
1256        let mut content = std::fs::read_to_string(&full_path)
1257            .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
1258
1259        if conflicts.is_empty() {
1260            return Ok(ConflictResolution::Resolved);
1261        }
1262
1263        tracing::debug!(
1264            "Resolving {} conflicts in {} using enhanced analysis",
1265            conflicts.len(),
1266            file_path
1267        );
1268
1269        let mut any_resolved = false;
1270
1271        // Process conflicts in reverse order to maintain string indices
1272        for conflict in conflicts.iter().rev() {
1273            match self.resolve_single_conflict_enhanced(conflict) {
1274                Ok(Some(resolution)) => {
1275                    // Replace the conflict region with the resolved content
1276                    let before = &content[..conflict.start_pos];
1277                    let after = &content[conflict.end_pos..];
1278                    content = format!("{before}{resolution}{after}");
1279                    any_resolved = true;
1280                    debug!(
1281                        "✅ Resolved {} conflict at lines {}-{} in {}",
1282                        format!("{:?}", conflict.conflict_type).to_lowercase(),
1283                        conflict.start_line,
1284                        conflict.end_line,
1285                        file_path
1286                    );
1287                }
1288                Ok(None) => {
1289                    debug!(
1290                        "⚠️  {} conflict at lines {}-{} in {} requires manual resolution",
1291                        format!("{:?}", conflict.conflict_type).to_lowercase(),
1292                        conflict.start_line,
1293                        conflict.end_line,
1294                        file_path
1295                    );
1296                    return Ok(ConflictResolution::TooComplex);
1297                }
1298                Err(e) => {
1299                    debug!("❌ Failed to resolve conflict in {}: {}", file_path, e);
1300                    return Ok(ConflictResolution::TooComplex);
1301                }
1302            }
1303        }
1304
1305        if any_resolved {
1306            // Check if we resolved ALL conflicts in this file
1307            let remaining_conflicts = self.parse_conflict_markers(&content)?;
1308
1309            if remaining_conflicts.is_empty() {
1310                debug!(
1311                    "All conflicts resolved in {}, content length: {} bytes",
1312                    file_path,
1313                    content.len()
1314                );
1315
1316                // CRITICAL SAFETY CHECK: Don't write empty files!
1317                if content.trim().is_empty() {
1318                    tracing::warn!(
1319                        "SAFETY CHECK: Resolved content for {} is empty! Aborting auto-resolution.",
1320                        file_path
1321                    );
1322                    return Ok(ConflictResolution::TooComplex);
1323                }
1324
1325                // SAFETY: Create backup before writing resolved content
1326                let backup_path = full_path.with_extension("cascade-backup");
1327                if let Ok(original_content) = std::fs::read_to_string(&full_path) {
1328                    debug!(
1329                        "Backup for {} (original: {} bytes, resolved: {} bytes)",
1330                        file_path,
1331                        original_content.len(),
1332                        content.len()
1333                    );
1334                    let _ = std::fs::write(&backup_path, original_content);
1335                }
1336
1337                // All conflicts resolved - write the file back atomically
1338                crate::utils::atomic_file::write_string(&full_path, &content)?;
1339
1340                debug!("Wrote {} bytes to {}", content.len(), file_path);
1341                return Ok(ConflictResolution::Resolved);
1342            } else {
1343                tracing::debug!(
1344                    "Partially resolved conflicts in {} ({} remaining)",
1345                    file_path,
1346                    remaining_conflicts.len()
1347                );
1348            }
1349        }
1350
1351        Ok(ConflictResolution::TooComplex)
1352    }
1353
1354    /// Helper to count whitespace consistency (lower is better)
1355    #[allow(dead_code)]
1356    fn count_whitespace_consistency(content: &str) -> usize {
1357        let mut inconsistencies = 0;
1358        let lines: Vec<&str> = content.lines().collect();
1359
1360        for line in &lines {
1361            // Check for mixed tabs and spaces
1362            if line.contains('\t') && line.contains(' ') {
1363                inconsistencies += 1;
1364            }
1365        }
1366
1367        // Penalize for inconsistencies
1368        lines.len().saturating_sub(inconsistencies)
1369    }
1370
1371    /// Clean up .cascade-backup files from the repository after successful conflict resolution
1372    fn cleanup_backup_files(&self) -> Result<()> {
1373        use std::fs;
1374        use std::path::Path;
1375
1376        let repo_path = self.git_repo.path();
1377
1378        // Recursively find and remove all .cascade-backup files
1379        fn remove_backups_recursive(dir: &Path) {
1380            if let Ok(entries) = fs::read_dir(dir) {
1381                for entry in entries.flatten() {
1382                    let path = entry.path();
1383
1384                    if path.is_dir() {
1385                        // Skip .git directory
1386                        if path.file_name().and_then(|n| n.to_str()) != Some(".git") {
1387                            remove_backups_recursive(&path);
1388                        }
1389                    } else if let Some(ext) = path.extension() {
1390                        if ext == "cascade-backup" {
1391                            debug!("Cleaning up backup file: {}", path.display());
1392                            if let Err(e) = fs::remove_file(&path) {
1393                                // Log but don't fail - backup cleanup is not critical
1394                                tracing::warn!(
1395                                    "Could not remove backup file {}: {}",
1396                                    path.display(),
1397                                    e
1398                                );
1399                            }
1400                        }
1401                    }
1402                }
1403            }
1404        }
1405
1406        remove_backups_recursive(repo_path);
1407        Ok(())
1408    }
1409
1410    /// Resolve a single conflict using enhanced analysis
1411    fn resolve_single_conflict_enhanced(
1412        &self,
1413        conflict: &crate::git::ConflictRegion,
1414    ) -> Result<Option<String>> {
1415        debug!(
1416            "Resolving {} conflict in {} (lines {}-{})",
1417            format!("{:?}", conflict.conflict_type).to_lowercase(),
1418            conflict.file_path,
1419            conflict.start_line,
1420            conflict.end_line
1421        );
1422
1423        use crate::git::ConflictType;
1424
1425        match conflict.conflict_type {
1426            ConflictType::Whitespace => {
1427                // SAFETY: Only resolve if the content is truly identical except for whitespace
1428                // Otherwise, it might be intentional formatting changes
1429                let our_normalized = conflict
1430                    .our_content
1431                    .split_whitespace()
1432                    .collect::<Vec<_>>()
1433                    .join(" ");
1434                let their_normalized = conflict
1435                    .their_content
1436                    .split_whitespace()
1437                    .collect::<Vec<_>>()
1438                    .join(" ");
1439
1440                if our_normalized == their_normalized {
1441                    // Content is identical - in cherry-pick context, ALWAYS prefer THEIRS
1442                    // CRITICAL: In cherry-pick, OURS=base branch, THEIRS=commit being applied
1443                    // We must keep the commit's changes (THEIRS), not the base (OURS)
1444                    // Otherwise we delete the user's code!
1445                    Ok(Some(conflict.their_content.clone()))
1446                } else {
1447                    // Content differs beyond whitespace - not safe to auto-resolve
1448                    debug!(
1449                        "Whitespace conflict has content differences - requires manual resolution"
1450                    );
1451                    Ok(None)
1452                }
1453            }
1454            ConflictType::LineEnding => {
1455                // Normalize to Unix line endings
1456                let normalized = conflict
1457                    .our_content
1458                    .replace("\r\n", "\n")
1459                    .replace('\r', "\n");
1460                Ok(Some(normalized))
1461            }
1462            ConflictType::PureAddition => {
1463                // CRITICAL: In cherry-pick, OURS=base, THEIRS=commit being applied
1464                // We must respect what the commit does (THEIRS), not what the base has (OURS)
1465
1466                if conflict.our_content.is_empty() && !conflict.their_content.is_empty() {
1467                    // Base is empty, commit adds content → keep the addition
1468                    Ok(Some(conflict.their_content.clone()))
1469                } else if conflict.their_content.is_empty() && !conflict.our_content.is_empty() {
1470                    // Base has content, commit removes it → keep it removed (empty)
1471                    Ok(Some(String::new()))
1472                } else if conflict.our_content.is_empty() && conflict.their_content.is_empty() {
1473                    // Both empty → keep empty
1474                    Ok(Some(String::new()))
1475                } else {
1476                    // Both sides have content - this could be:
1477                    // - Duplicate function definitions
1478                    // - Conflicting logic
1479                    // - Different implementations of same feature
1480                    // Too risky to auto-merge - require manual resolution
1481                    debug!(
1482                        "PureAddition conflict has content on both sides - requires manual resolution"
1483                    );
1484                    Ok(None)
1485                }
1486            }
1487            ConflictType::ImportMerge => {
1488                // SAFETY: Only merge simple single-line imports
1489                // Multi-line imports or complex cases require manual resolution
1490
1491                // Check if all imports are single-line and look like imports
1492                let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1493                let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1494
1495                // Verify all lines look like simple imports (heuristic check)
1496                let all_simple = our_lines.iter().chain(their_lines.iter()).all(|line| {
1497                    let trimmed = line.trim();
1498                    trimmed.starts_with("import ")
1499                        || trimmed.starts_with("from ")
1500                        || trimmed.starts_with("use ")
1501                        || trimmed.starts_with("#include")
1502                        || trimmed.is_empty()
1503                });
1504
1505                if !all_simple {
1506                    debug!("ImportMerge contains non-import lines - requires manual resolution");
1507                    return Ok(None);
1508                }
1509
1510                // Merge and deduplicate imports
1511                let mut all_imports: Vec<&str> = our_lines
1512                    .into_iter()
1513                    .chain(their_lines)
1514                    .filter(|line| !line.trim().is_empty())
1515                    .collect();
1516                all_imports.sort();
1517                all_imports.dedup();
1518                Ok(Some(all_imports.join("\n")))
1519            }
1520            ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
1521                // These require manual resolution
1522                Ok(None)
1523            }
1524        }
1525    }
1526
1527    /// Parse conflict markers from file content
1528    fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
1529        let lines: Vec<&str> = content.lines().collect();
1530        let mut conflicts = Vec::new();
1531        let mut i = 0;
1532
1533        while i < lines.len() {
1534            if lines[i].starts_with("<<<<<<<") {
1535                // Found start of conflict
1536                let start_line = i + 1;
1537                let mut separator_line = None;
1538                let mut end_line = None;
1539
1540                // Find the separator and end
1541                for (j, line) in lines.iter().enumerate().skip(i + 1) {
1542                    if line.starts_with("=======") {
1543                        separator_line = Some(j + 1);
1544                    } else if line.starts_with(">>>>>>>") {
1545                        end_line = Some(j + 1);
1546                        break;
1547                    }
1548                }
1549
1550                if let (Some(sep), Some(end)) = (separator_line, end_line) {
1551                    // Calculate byte positions
1552                    let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
1553                    let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
1554
1555                    let our_content = lines[(i + 1)..(sep - 1)].join("\n");
1556                    let their_content = lines[sep..(end - 1)].join("\n");
1557
1558                    conflicts.push(ConflictRegion {
1559                        start: start_pos,
1560                        end: end_pos,
1561                        start_line,
1562                        end_line: end,
1563                        our_content,
1564                        their_content,
1565                    });
1566
1567                    i = end;
1568                } else {
1569                    i += 1;
1570                }
1571            } else {
1572                i += 1;
1573            }
1574        }
1575
1576        Ok(conflicts)
1577    }
1578
1579    /// Update a stack entry with new commit information
1580    /// NOTE: We keep the original branch name to preserve PR mapping, only update commit hash
1581    fn update_stack_entry(
1582        &mut self,
1583        stack_id: Uuid,
1584        entry_id: &Uuid,
1585        _new_branch: &str,
1586        new_commit_hash: &str,
1587    ) -> Result<()> {
1588        debug!(
1589            "Updating entry {} in stack {} with new commit {}",
1590            entry_id, stack_id, new_commit_hash
1591        );
1592
1593        // Get the stack and update the entry
1594        let stack = self
1595            .stack_manager
1596            .get_stack_mut(&stack_id)
1597            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1598
1599        // Get entry info before mutation
1600        let entry_exists = stack.entries.iter().any(|e| e.id == *entry_id);
1601
1602        if entry_exists {
1603            let old_hash = stack
1604                .entries
1605                .iter()
1606                .find(|e| e.id == *entry_id)
1607                .map(|e| e.commit_hash.clone())
1608                .unwrap();
1609
1610            debug!(
1611                "Found entry {} - updating commit from '{}' to '{}' (keeping original branch)",
1612                entry_id, old_hash, new_commit_hash
1613            );
1614
1615            // CRITICAL: Keep the original branch name to preserve PR mapping
1616            // Only update the commit hash to point to the new rebased commit using safe wrapper
1617            stack
1618                .update_entry_commit_hash(entry_id, new_commit_hash.to_string())
1619                .map_err(CascadeError::config)?;
1620
1621            // Note: Stack will be saved by the caller (StackManager) after rebase completes
1622
1623            debug!(
1624                "Successfully updated entry {} in stack {}",
1625                entry_id, stack_id
1626            );
1627            Ok(())
1628        } else {
1629            Err(CascadeError::config(format!(
1630                "Entry {entry_id} not found in stack {stack_id}"
1631            )))
1632        }
1633    }
1634
1635    /// Pull latest changes from remote
1636    fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1637        tracing::debug!("Pulling latest changes for branch {}", branch);
1638
1639        // First try to fetch (this might fail if no remote exists)
1640        match self.git_repo.fetch() {
1641            Ok(_) => {
1642                debug!("Fetch successful");
1643                // Now try to pull the specific branch
1644                match self.git_repo.pull(branch) {
1645                    Ok(_) => {
1646                        tracing::debug!("Pull completed successfully for {}", branch);
1647                        Ok(())
1648                    }
1649                    Err(e) => {
1650                        tracing::debug!("Pull failed for {}: {}", branch, e);
1651                        // Don't fail the entire rebase for pull issues
1652                        Ok(())
1653                    }
1654                }
1655            }
1656            Err(e) => {
1657                tracing::debug!("Fetch failed: {}", e);
1658                // Don't fail if there's no remote configured
1659                Ok(())
1660            }
1661        }
1662    }
1663
1664    /// Check if rebase is in progress
1665    pub fn is_rebase_in_progress(&self) -> bool {
1666        // Check for git rebase state files
1667        let git_dir = self.git_repo.git_dir();
1668        git_dir.join("REBASE_HEAD").exists()
1669            || git_dir.join("rebase-merge").exists()
1670            || git_dir.join("rebase-apply").exists()
1671    }
1672
1673    /// Abort an in-progress rebase
1674    pub fn abort_rebase(&self) -> Result<()> {
1675        tracing::debug!("Aborting rebase operation");
1676
1677        let git_dir = self.git_repo.git_dir();
1678
1679        // Clean up rebase state files
1680        if git_dir.join("REBASE_HEAD").exists() {
1681            std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1682                CascadeError::Git(git2::Error::from_str(&format!(
1683                    "Failed to clean rebase state: {e}"
1684                )))
1685            })?;
1686        }
1687
1688        if git_dir.join("rebase-merge").exists() {
1689            std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1690                CascadeError::Git(git2::Error::from_str(&format!(
1691                    "Failed to clean rebase-merge: {e}"
1692                )))
1693            })?;
1694        }
1695
1696        if git_dir.join("rebase-apply").exists() {
1697            std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1698                CascadeError::Git(git2::Error::from_str(&format!(
1699                    "Failed to clean rebase-apply: {e}"
1700                )))
1701            })?;
1702        }
1703
1704        tracing::debug!("Rebase aborted successfully");
1705        Ok(())
1706    }
1707
1708    /// Continue an in-progress rebase after conflict resolution
1709    pub fn continue_rebase(&self) -> Result<()> {
1710        tracing::debug!("Continuing rebase operation");
1711
1712        // Check if there are still conflicts
1713        if self.git_repo.has_conflicts()? {
1714            return Err(CascadeError::branch(
1715                "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1716            ));
1717        }
1718
1719        // Stage resolved files
1720        self.git_repo.stage_conflict_resolved_files()?;
1721
1722        tracing::debug!("Rebase continued successfully");
1723        Ok(())
1724    }
1725
1726    /// Check if there's an in-progress cherry-pick operation
1727    fn has_in_progress_cherry_pick(&self) -> Result<bool> {
1728        let git_dir = self.git_repo.git_dir();
1729        Ok(git_dir.join("CHERRY_PICK_HEAD").exists())
1730    }
1731
1732    /// Handle resuming an in-progress cherry-pick from a previous failed sync
1733    fn handle_in_progress_cherry_pick(&mut self, stack: &Stack) -> Result<RebaseResult> {
1734        use crate::cli::output::Output;
1735
1736        let git_dir = self.git_repo.git_dir();
1737
1738        Output::section("Resuming in-progress sync");
1739        println!();
1740        Output::info("Detected unfinished cherry-pick from previous sync");
1741        println!();
1742
1743        // Check if conflicts are resolved
1744        if self.git_repo.has_conflicts()? {
1745            let conflicted_files = self.git_repo.get_conflicted_files()?;
1746
1747            let result = RebaseResult {
1748                success: false,
1749                branch_mapping: HashMap::new(),
1750                conflicts: conflicted_files.clone(),
1751                new_commits: Vec::new(),
1752                error: Some(format!(
1753                    "Cannot continue: {} file(s) still have unresolved conflicts\n\n\
1754                    MANUAL CONFLICT RESOLUTION REQUIRED\n\
1755                    =====================================\n\n\
1756                    Conflicted files:\n{}\n\n\
1757                    Step 1: Analyze conflicts\n\
1758                    → Run: ca conflicts\n\
1759                    → Shows detailed conflict analysis\n\n\
1760                    Step 2: Resolve conflicts in your editor\n\
1761                    → Open conflicted files and edit them\n\
1762                    → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
1763                    → Keep the code you want\n\
1764                    → Save the files\n\n\
1765                    Step 3: Mark conflicts as resolved\n\
1766                    → Run: git add <resolved-files>\n\
1767                    → Or: git add -A (to stage all resolved files)\n\n\
1768                    Step 4: Complete the sync\n\
1769                    → Run: ca sync continue\n\
1770                    → Cascade will complete the cherry-pick and continue\n\n\
1771                    Alternative: Abort and start over\n\
1772                    → Run: ca sync abort\n\
1773                    → Then: ca sync (starts fresh)",
1774                    conflicted_files.len(),
1775                    conflicted_files
1776                        .iter()
1777                        .map(|f| format!("  - {}", f))
1778                        .collect::<Vec<_>>()
1779                        .join("\n")
1780                )),
1781                summary: "Sync paused - conflicts need resolution".to_string(),
1782            };
1783
1784            return Ok(result);
1785        }
1786
1787        // Conflicts are resolved - continue the cherry-pick
1788        Output::info("Conflicts resolved, continuing cherry-pick...");
1789
1790        // Stage all resolved files
1791        self.git_repo.stage_conflict_resolved_files()?;
1792
1793        // Complete the cherry-pick by committing
1794        let cherry_pick_msg_file = git_dir.join("CHERRY_PICK_MSG");
1795        let commit_message = if cherry_pick_msg_file.exists() {
1796            std::fs::read_to_string(&cherry_pick_msg_file)
1797                .unwrap_or_else(|_| "Resolved conflicts".to_string())
1798        } else {
1799            "Resolved conflicts".to_string()
1800        };
1801
1802        match self.git_repo.commit(&commit_message) {
1803            Ok(_new_commit_id) => {
1804                Output::success("Cherry-pick completed");
1805
1806                // Clean up cherry-pick state
1807                if git_dir.join("CHERRY_PICK_HEAD").exists() {
1808                    let _ = std::fs::remove_file(git_dir.join("CHERRY_PICK_HEAD"));
1809                }
1810                if cherry_pick_msg_file.exists() {
1811                    let _ = std::fs::remove_file(&cherry_pick_msg_file);
1812                }
1813
1814                println!();
1815                Output::info("Continuing with rest of stack...");
1816                println!();
1817
1818                // Now continue with the rest of the rebase
1819                // We need to restart the full rebase since we don't track which entry we were on
1820                self.rebase_with_force_push(stack)
1821            }
1822            Err(e) => {
1823                let result = RebaseResult {
1824                    success: false,
1825                    branch_mapping: HashMap::new(),
1826                    conflicts: Vec::new(),
1827                    new_commits: Vec::new(),
1828                    error: Some(format!(
1829                        "Failed to complete cherry-pick: {}\n\n\
1830                        This usually means:\n\
1831                        - Another Git process is accessing the repository\n\
1832                        - File permissions issue\n\
1833                        - Disk space issue\n\n\
1834                        Recovery:\n\
1835                        1. Check if another Git operation is running\n\
1836                        2. Run 'git status' to check repo state\n\
1837                        3. Retry 'ca sync' after fixing the issue\n\n\
1838                        Or abort and start fresh:\n\
1839                        → Run: ca sync abort\n\
1840                        → Then: ca sync",
1841                        e
1842                    )),
1843                    summary: "Failed to complete cherry-pick".to_string(),
1844                };
1845
1846                Ok(result)
1847            }
1848        }
1849    }
1850}
1851
1852impl RebaseResult {
1853    /// Get a summary of the rebase operation
1854    pub fn get_summary(&self) -> String {
1855        if self.success {
1856            format!("✅ {}", self.summary)
1857        } else {
1858            format!(
1859                "❌ Rebase failed: {}",
1860                self.error.as_deref().unwrap_or("Unknown error")
1861            )
1862        }
1863    }
1864
1865    /// Check if any conflicts occurred
1866    pub fn has_conflicts(&self) -> bool {
1867        !self.conflicts.is_empty()
1868    }
1869
1870    /// Get the number of successful operations
1871    pub fn success_count(&self) -> usize {
1872        self.new_commits.len()
1873    }
1874}
1875
1876#[cfg(test)]
1877mod tests {
1878    use super::*;
1879    use std::path::PathBuf;
1880    use std::process::Command;
1881    use tempfile::TempDir;
1882
1883    #[allow(dead_code)]
1884    fn create_test_repo() -> (TempDir, PathBuf) {
1885        let temp_dir = TempDir::new().unwrap();
1886        let repo_path = temp_dir.path().to_path_buf();
1887
1888        // Initialize git repository
1889        Command::new("git")
1890            .args(["init"])
1891            .current_dir(&repo_path)
1892            .output()
1893            .unwrap();
1894        Command::new("git")
1895            .args(["config", "user.name", "Test"])
1896            .current_dir(&repo_path)
1897            .output()
1898            .unwrap();
1899        Command::new("git")
1900            .args(["config", "user.email", "test@test.com"])
1901            .current_dir(&repo_path)
1902            .output()
1903            .unwrap();
1904
1905        // Create initial commit
1906        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1907        Command::new("git")
1908            .args(["add", "."])
1909            .current_dir(&repo_path)
1910            .output()
1911            .unwrap();
1912        Command::new("git")
1913            .args(["commit", "-m", "Initial"])
1914            .current_dir(&repo_path)
1915            .output()
1916            .unwrap();
1917
1918        (temp_dir, repo_path)
1919    }
1920
1921    #[test]
1922    fn test_conflict_region_creation() {
1923        let region = ConflictRegion {
1924            start: 0,
1925            end: 50,
1926            start_line: 1,
1927            end_line: 3,
1928            our_content: "function test() {\n    return true;\n}".to_string(),
1929            their_content: "function test() {\n  return true;\n}".to_string(),
1930        };
1931
1932        assert_eq!(region.start_line, 1);
1933        assert_eq!(region.end_line, 3);
1934        assert!(region.our_content.contains("return true"));
1935        assert!(region.their_content.contains("return true"));
1936    }
1937
1938    #[test]
1939    fn test_rebase_strategies() {
1940        assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1941        assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1942    }
1943
1944    #[test]
1945    fn test_rebase_options() {
1946        let options = RebaseOptions::default();
1947        assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1948        assert!(!options.interactive);
1949        assert!(options.auto_resolve);
1950        assert_eq!(options.max_retries, 3);
1951    }
1952
1953    #[test]
1954    fn test_cleanup_guard_tracks_branches() {
1955        let mut guard = TempBranchCleanupGuard::new();
1956        assert!(guard.branches.is_empty());
1957
1958        guard.add_branch("test-branch-1".to_string());
1959        guard.add_branch("test-branch-2".to_string());
1960
1961        assert_eq!(guard.branches.len(), 2);
1962        assert_eq!(guard.branches[0], "test-branch-1");
1963        assert_eq!(guard.branches[1], "test-branch-2");
1964    }
1965
1966    #[test]
1967    fn test_cleanup_guard_prevents_double_cleanup() {
1968        use std::process::Command;
1969        use tempfile::TempDir;
1970
1971        // Create a temporary git repo
1972        let temp_dir = TempDir::new().unwrap();
1973        let repo_path = temp_dir.path();
1974
1975        Command::new("git")
1976            .args(["init"])
1977            .current_dir(repo_path)
1978            .output()
1979            .unwrap();
1980
1981        Command::new("git")
1982            .args(["config", "user.name", "Test"])
1983            .current_dir(repo_path)
1984            .output()
1985            .unwrap();
1986
1987        Command::new("git")
1988            .args(["config", "user.email", "test@test.com"])
1989            .current_dir(repo_path)
1990            .output()
1991            .unwrap();
1992
1993        // Create initial commit
1994        std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1995        Command::new("git")
1996            .args(["add", "."])
1997            .current_dir(repo_path)
1998            .output()
1999            .unwrap();
2000        Command::new("git")
2001            .args(["commit", "-m", "initial"])
2002            .current_dir(repo_path)
2003            .output()
2004            .unwrap();
2005
2006        let git_repo = GitRepository::open(repo_path).unwrap();
2007
2008        // Create a test branch
2009        git_repo.create_branch("test-temp", None).unwrap();
2010
2011        let mut guard = TempBranchCleanupGuard::new();
2012        guard.add_branch("test-temp".to_string());
2013
2014        // First cleanup should work
2015        guard.cleanup(&git_repo);
2016        assert!(guard.cleaned);
2017
2018        // Second cleanup should be a no-op (shouldn't panic)
2019        guard.cleanup(&git_repo);
2020        assert!(guard.cleaned);
2021    }
2022
2023    #[test]
2024    fn test_rebase_result() {
2025        let result = RebaseResult {
2026            success: true,
2027            branch_mapping: std::collections::HashMap::new(),
2028            conflicts: vec!["abc123".to_string()],
2029            new_commits: vec!["def456".to_string()],
2030            error: None,
2031            summary: "Test summary".to_string(),
2032        };
2033
2034        assert!(result.success);
2035        assert!(result.has_conflicts());
2036        assert_eq!(result.success_count(), 1);
2037    }
2038}