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                                        let stack_messages: Vec<String> = stack
866                                            .entries
867                                            .iter()
868                                            .map(|e| e.message.trim().to_string())
869                                            .collect();
870
871                                        let all_match_stack = commits.iter().all(|commit| {
872                                            if let Some(msg) = commit.summary() {
873                                                stack_messages
874                                                    .iter()
875                                                    .any(|stack_msg| stack_msg == msg.trim())
876                                            } else {
877                                                false
878                                            }
879                                        });
880
881                                        if all_match_stack {
882                                            // These are the old pre-rebase versions of stack entries
883                                            // Safe to update working branch to new rebased top
884                                            // Note: commits.len() may be less than stack.entries.len() if only
885                                            // some entries were rebased (e.g., after amending one entry)
886                                            debug!(
887                                            "Working branch has old pre-rebase commits (matching stack messages) - safe to update"
888                                        );
889                                        } else {
890                                            // These are truly new commits not in the stack!
891                                            Output::error(format!(
892                                            "Cannot sync: Working branch '{}' has {} commit(s) not in the stack",
893                                            working_branch_name,
894                                            commits.len()
895                                        ));
896                                            println!();
897                                            Output::sub_item(
898                                                "These commits would be lost if we proceed:",
899                                            );
900                                            for (i, commit) in commits.iter().take(5).enumerate() {
901                                                let message =
902                                                    commit.summary().unwrap_or("(no message)");
903                                                Output::sub_item(format!(
904                                                    "  {}. {} - {}",
905                                                    i + 1,
906                                                    &commit.id().to_string()[..8],
907                                                    message
908                                                ));
909                                            }
910                                            if commits.len() > 5 {
911                                                Output::sub_item(format!(
912                                                    "  ... and {} more",
913                                                    commits.len() - 5
914                                                ));
915                                            }
916                                            println!();
917                                            Output::tip("Add these commits to the stack first:");
918                                            Output::bullet("Run: ca stack push");
919                                            Output::bullet("Then run: ca sync");
920                                            println!();
921
922                                            // Restore original branch before returning error
923                                            if let Some(ref orig) = original_branch_for_cleanup {
924                                                let _ = self.git_repo.checkout_branch_unsafe(orig);
925                                            }
926
927                                            return Err(CascadeError::validation(
928                                            format!(
929                                                "Working branch '{}' has {} untracked commit(s). Add them to the stack with 'ca stack push' before syncing.",
930                                                working_branch_name, commits.len()
931                                            )
932                                        ));
933                                        }
934                                    }
935                                }
936                            }
937
938                            // Safe to update - working branch matches top of stack or is behind
939                            debug!(
940                                "Updating working branch '{}' to match top of stack ({})",
941                                working_branch_name,
942                                &top_commit[..8]
943                            );
944
945                            if let Err(e) = self
946                                .git_repo
947                                .update_branch_to_commit(working_branch_name, &top_commit)
948                            {
949                                Output::warning(format!(
950                                    "Could not update working branch '{}' to top of stack: {}",
951                                    working_branch_name, e
952                                ));
953                            }
954                        }
955                    }
956                } else {
957                    // Working branch is the base branch - this is unusual
958                    debug!(
959                        "Skipping working branch update - working branch '{}' is the base branch",
960                        working_branch_name
961                    );
962                }
963            }
964
965            // Return user to their original Git branch (regardless of working branch updates)
966            // This ensures user experience is preserved even when they ran `ca sync` from a stack entry branch
967            if let Some(ref orig_branch) = original_branch {
968                if let Err(e) = self.git_repo.checkout_branch_unsafe(orig_branch) {
969                    debug!(
970                        "Could not return to original branch '{}': {}",
971                        orig_branch, e
972                    );
973                    // Non-critical: User is left on base branch instead
974                }
975            }
976        }
977        // Note: Summary is now built inside the successful rebase block above (around line 745)
978        // If rebase failed, we'll have an error message but no summary
979
980        // Display result with proper formatting
981        println!();
982        if result.success {
983            Output::success(&result.summary);
984        } else {
985            // Display error with proper icon
986            let error_msg = result
987                .error
988                .as_deref()
989                .unwrap_or("Rebase failed for unknown reason");
990            Output::error(error_msg);
991        }
992
993        // Save the updated stack metadata to disk
994        self.stack_manager.save_to_disk()?;
995
996        // CRITICAL: Return error if rebase failed
997        // Don't return Ok(result) with result.success = false - that's confusing!
998        if !result.success {
999            let detailed_error = result.error.as_deref().unwrap_or("Rebase failed");
1000            return Err(CascadeError::Branch(detailed_error.to_string()));
1001        }
1002
1003        Ok(result)
1004    }
1005
1006    /// Interactive rebase with user input
1007    fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
1008        tracing::debug!("Starting interactive rebase for stack '{}'", stack.name);
1009
1010        let mut result = RebaseResult {
1011            success: true,
1012            branch_mapping: HashMap::new(),
1013            conflicts: Vec::new(),
1014            new_commits: Vec::new(),
1015            error: None,
1016            summary: String::new(),
1017        };
1018
1019        println!("Interactive Rebase for Stack: {}", stack.name);
1020        println!("   Base branch: {}", stack.base_branch);
1021        println!("   Entries: {}", stack.entries.len());
1022
1023        if self.options.interactive {
1024            println!("\nChoose action for each commit:");
1025            println!("  (p)ick   - apply the commit");
1026            println!("  (s)kip   - skip this commit");
1027            println!("  (e)dit   - edit the commit message");
1028            println!("  (q)uit   - abort the rebase");
1029        }
1030
1031        // For now, automatically pick all commits
1032        // In a real implementation, this would prompt the user
1033        for entry in &stack.entries {
1034            println!(
1035                "  {} {} - {}",
1036                entry.short_hash(),
1037                entry.branch,
1038                entry.short_message(50)
1039            );
1040
1041            // Auto-pick for demo purposes
1042            match self.cherry_pick_commit(&entry.commit_hash) {
1043                Ok(new_commit) => result.new_commits.push(new_commit),
1044                Err(_) => result.conflicts.push(entry.commit_hash.clone()),
1045            }
1046        }
1047
1048        result.summary = format!(
1049            "Interactive rebase processed {} commits",
1050            stack.entries.len()
1051        );
1052        Ok(result)
1053    }
1054
1055    /// Cherry-pick a commit onto the current branch
1056    fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
1057        // Use the real cherry-pick implementation from GitRepository
1058        let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
1059
1060        // Check for any leftover staged changes after successful cherry-pick
1061        if let Ok(staged_files) = self.git_repo.get_staged_files() {
1062            if !staged_files.is_empty() {
1063                // CRITICAL: Must commit staged changes - if this fails, user gets checkout warning
1064                let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
1065                match self.git_repo.commit_staged_changes(&cleanup_message) {
1066                    Ok(Some(_)) => {
1067                        debug!(
1068                            "Committed {} leftover staged files after cherry-pick",
1069                            staged_files.len()
1070                        );
1071                    }
1072                    Ok(None) => {
1073                        // Files were unstaged between check and commit - not critical
1074                        debug!("Staged files were cleared before commit");
1075                    }
1076                    Err(e) => {
1077                        // This is serious - staged files will remain and cause checkout warning
1078                        tracing::warn!(
1079                            "Failed to commit {} staged files after cherry-pick: {}. \
1080                             User may see checkout warning with staged changes.",
1081                            staged_files.len(),
1082                            e
1083                        );
1084                        // Don't fail the entire rebase for this - but user will need to handle staged files
1085                    }
1086                }
1087            }
1088        }
1089
1090        Ok(new_commit_hash)
1091    }
1092
1093    /// Attempt to automatically resolve conflicts
1094    fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
1095        debug!("Starting auto-resolve for commit {}", commit_hash);
1096
1097        // Check if there are actually conflicts
1098        let has_conflicts = self.git_repo.has_conflicts()?;
1099        debug!("has_conflicts() = {}", has_conflicts);
1100
1101        // Check if cherry-pick is in progress
1102        let cherry_pick_head = self.git_repo.path().join(".git").join("CHERRY_PICK_HEAD");
1103        let cherry_pick_in_progress = cherry_pick_head.exists();
1104
1105        if !has_conflicts {
1106            debug!("No conflicts detected by Git index");
1107
1108            // If cherry-pick is in progress but no conflicts detected, something is wrong
1109            if cherry_pick_in_progress {
1110                tracing::debug!(
1111                    "CHERRY_PICK_HEAD exists but no conflicts in index - aborting cherry-pick"
1112                );
1113
1114                // Abort the cherry-pick to clean up
1115                let _ = std::process::Command::new("git")
1116                    .args(["cherry-pick", "--abort"])
1117                    .current_dir(self.git_repo.path())
1118                    .output();
1119
1120                return Err(CascadeError::Branch(format!(
1121                    "Cherry-pick failed for {} but Git index shows no conflicts. \
1122                     This usually means the cherry-pick was aborted or failed in an unexpected way. \
1123                     Please try manual resolution.",
1124                    &commit_hash[..8]
1125                )));
1126            }
1127
1128            return Ok(true);
1129        }
1130
1131        let conflicted_files = self.git_repo.get_conflicted_files()?;
1132
1133        if conflicted_files.is_empty() {
1134            debug!("Conflicted files list is empty");
1135            return Ok(true);
1136        }
1137
1138        debug!(
1139            "Found conflicts in {} files: {:?}",
1140            conflicted_files.len(),
1141            conflicted_files
1142        );
1143
1144        // Use the new conflict analyzer for detailed analysis
1145        let analysis = self
1146            .conflict_analyzer
1147            .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
1148
1149        debug!(
1150            "Conflict analysis: {} total conflicts, {} auto-resolvable",
1151            analysis.total_conflicts, analysis.auto_resolvable_count
1152        );
1153
1154        // Display recommendations
1155        for recommendation in &analysis.recommendations {
1156            debug!("{}", recommendation);
1157        }
1158
1159        let mut resolved_count = 0;
1160        let mut resolved_files = Vec::new(); // Track which files were actually resolved
1161        let mut failed_files = Vec::new();
1162
1163        for file_analysis in &analysis.files {
1164            debug!(
1165                "Processing file: {} (auto_resolvable: {}, conflicts: {})",
1166                file_analysis.file_path,
1167                file_analysis.auto_resolvable,
1168                file_analysis.conflicts.len()
1169            );
1170
1171            if file_analysis.auto_resolvable {
1172                match self.resolve_file_conflicts_enhanced(
1173                    &file_analysis.file_path,
1174                    &file_analysis.conflicts,
1175                ) {
1176                    Ok(ConflictResolution::Resolved) => {
1177                        resolved_count += 1;
1178                        resolved_files.push(file_analysis.file_path.clone());
1179                        debug!("Successfully resolved {}", file_analysis.file_path);
1180                    }
1181                    Ok(ConflictResolution::TooComplex) => {
1182                        debug!(
1183                            "{} too complex for auto-resolution",
1184                            file_analysis.file_path
1185                        );
1186                        failed_files.push(file_analysis.file_path.clone());
1187                    }
1188                    Err(e) => {
1189                        debug!("Failed to resolve {}: {}", file_analysis.file_path, e);
1190                        failed_files.push(file_analysis.file_path.clone());
1191                    }
1192                }
1193            } else {
1194                failed_files.push(file_analysis.file_path.clone());
1195                debug!(
1196                    "{} requires manual resolution ({} conflicts)",
1197                    file_analysis.file_path,
1198                    file_analysis.conflicts.len()
1199                );
1200            }
1201        }
1202
1203        if resolved_count > 0 {
1204            debug!(
1205                "Resolved {}/{} files",
1206                resolved_count,
1207                conflicted_files.len()
1208            );
1209            debug!("Resolved files: {:?}", resolved_files);
1210
1211            // CRITICAL: Only stage files that were successfully resolved
1212            // This prevents staging files that still have conflict markers
1213            let file_paths: Vec<&str> = resolved_files.iter().map(|s| s.as_str()).collect();
1214            debug!("Staging {} files", file_paths.len());
1215            self.git_repo.stage_files(&file_paths)?;
1216            debug!("Files staged successfully");
1217        } else {
1218            debug!("No files were resolved (resolved_count = 0)");
1219        }
1220
1221        // Return true only if ALL conflicts were resolved
1222        let all_resolved = failed_files.is_empty();
1223
1224        debug!(
1225            "all_resolved = {}, failed_files = {:?}",
1226            all_resolved, failed_files
1227        );
1228
1229        if !all_resolved {
1230            debug!("{} files still need manual resolution", failed_files.len());
1231        }
1232
1233        debug!("Returning all_resolved = {}", all_resolved);
1234        Ok(all_resolved)
1235    }
1236
1237    /// Resolve conflicts using enhanced analysis
1238    fn resolve_file_conflicts_enhanced(
1239        &self,
1240        file_path: &str,
1241        conflicts: &[crate::git::ConflictRegion],
1242    ) -> Result<ConflictResolution> {
1243        let repo_path = self.git_repo.path();
1244        let full_path = repo_path.join(file_path);
1245
1246        // Read the file content with conflict markers
1247        let mut content = std::fs::read_to_string(&full_path)
1248            .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
1249
1250        if conflicts.is_empty() {
1251            return Ok(ConflictResolution::Resolved);
1252        }
1253
1254        tracing::debug!(
1255            "Resolving {} conflicts in {} using enhanced analysis",
1256            conflicts.len(),
1257            file_path
1258        );
1259
1260        let mut any_resolved = false;
1261
1262        // Process conflicts in reverse order to maintain string indices
1263        for conflict in conflicts.iter().rev() {
1264            match self.resolve_single_conflict_enhanced(conflict) {
1265                Ok(Some(resolution)) => {
1266                    // Replace the conflict region with the resolved content
1267                    let before = &content[..conflict.start_pos];
1268                    let after = &content[conflict.end_pos..];
1269                    content = format!("{before}{resolution}{after}");
1270                    any_resolved = true;
1271                    debug!(
1272                        "✅ Resolved {} conflict at lines {}-{} in {}",
1273                        format!("{:?}", conflict.conflict_type).to_lowercase(),
1274                        conflict.start_line,
1275                        conflict.end_line,
1276                        file_path
1277                    );
1278                }
1279                Ok(None) => {
1280                    debug!(
1281                        "⚠️  {} conflict at lines {}-{} in {} requires manual resolution",
1282                        format!("{:?}", conflict.conflict_type).to_lowercase(),
1283                        conflict.start_line,
1284                        conflict.end_line,
1285                        file_path
1286                    );
1287                    return Ok(ConflictResolution::TooComplex);
1288                }
1289                Err(e) => {
1290                    debug!("❌ Failed to resolve conflict in {}: {}", file_path, e);
1291                    return Ok(ConflictResolution::TooComplex);
1292                }
1293            }
1294        }
1295
1296        if any_resolved {
1297            // Check if we resolved ALL conflicts in this file
1298            let remaining_conflicts = self.parse_conflict_markers(&content)?;
1299
1300            if remaining_conflicts.is_empty() {
1301                debug!(
1302                    "All conflicts resolved in {}, content length: {} bytes",
1303                    file_path,
1304                    content.len()
1305                );
1306
1307                // CRITICAL SAFETY CHECK: Don't write empty files!
1308                if content.trim().is_empty() {
1309                    tracing::warn!(
1310                        "SAFETY CHECK: Resolved content for {} is empty! Aborting auto-resolution.",
1311                        file_path
1312                    );
1313                    return Ok(ConflictResolution::TooComplex);
1314                }
1315
1316                // SAFETY: Create backup before writing resolved content
1317                let backup_path = full_path.with_extension("cascade-backup");
1318                if let Ok(original_content) = std::fs::read_to_string(&full_path) {
1319                    debug!(
1320                        "Backup for {} (original: {} bytes, resolved: {} bytes)",
1321                        file_path,
1322                        original_content.len(),
1323                        content.len()
1324                    );
1325                    let _ = std::fs::write(&backup_path, original_content);
1326                }
1327
1328                // All conflicts resolved - write the file back atomically
1329                crate::utils::atomic_file::write_string(&full_path, &content)?;
1330
1331                debug!("Wrote {} bytes to {}", content.len(), file_path);
1332                return Ok(ConflictResolution::Resolved);
1333            } else {
1334                tracing::debug!(
1335                    "Partially resolved conflicts in {} ({} remaining)",
1336                    file_path,
1337                    remaining_conflicts.len()
1338                );
1339            }
1340        }
1341
1342        Ok(ConflictResolution::TooComplex)
1343    }
1344
1345    /// Helper to count whitespace consistency (lower is better)
1346    #[allow(dead_code)]
1347    fn count_whitespace_consistency(content: &str) -> usize {
1348        let mut inconsistencies = 0;
1349        let lines: Vec<&str> = content.lines().collect();
1350
1351        for line in &lines {
1352            // Check for mixed tabs and spaces
1353            if line.contains('\t') && line.contains(' ') {
1354                inconsistencies += 1;
1355            }
1356        }
1357
1358        // Penalize for inconsistencies
1359        lines.len().saturating_sub(inconsistencies)
1360    }
1361
1362    /// Clean up .cascade-backup files from the repository after successful conflict resolution
1363    fn cleanup_backup_files(&self) -> Result<()> {
1364        use std::fs;
1365        use std::path::Path;
1366
1367        let repo_path = self.git_repo.path();
1368
1369        // Recursively find and remove all .cascade-backup files
1370        fn remove_backups_recursive(dir: &Path) {
1371            if let Ok(entries) = fs::read_dir(dir) {
1372                for entry in entries.flatten() {
1373                    let path = entry.path();
1374
1375                    if path.is_dir() {
1376                        // Skip .git directory
1377                        if path.file_name().and_then(|n| n.to_str()) != Some(".git") {
1378                            remove_backups_recursive(&path);
1379                        }
1380                    } else if let Some(ext) = path.extension() {
1381                        if ext == "cascade-backup" {
1382                            debug!("Cleaning up backup file: {}", path.display());
1383                            if let Err(e) = fs::remove_file(&path) {
1384                                // Log but don't fail - backup cleanup is not critical
1385                                tracing::warn!(
1386                                    "Could not remove backup file {}: {}",
1387                                    path.display(),
1388                                    e
1389                                );
1390                            }
1391                        }
1392                    }
1393                }
1394            }
1395        }
1396
1397        remove_backups_recursive(repo_path);
1398        Ok(())
1399    }
1400
1401    /// Resolve a single conflict using enhanced analysis
1402    fn resolve_single_conflict_enhanced(
1403        &self,
1404        conflict: &crate::git::ConflictRegion,
1405    ) -> Result<Option<String>> {
1406        debug!(
1407            "Resolving {} conflict in {} (lines {}-{})",
1408            format!("{:?}", conflict.conflict_type).to_lowercase(),
1409            conflict.file_path,
1410            conflict.start_line,
1411            conflict.end_line
1412        );
1413
1414        use crate::git::ConflictType;
1415
1416        match conflict.conflict_type {
1417            ConflictType::Whitespace => {
1418                // SAFETY: Only resolve if the content is truly identical except for whitespace
1419                // Otherwise, it might be intentional formatting changes
1420                let our_normalized = conflict
1421                    .our_content
1422                    .split_whitespace()
1423                    .collect::<Vec<_>>()
1424                    .join(" ");
1425                let their_normalized = conflict
1426                    .their_content
1427                    .split_whitespace()
1428                    .collect::<Vec<_>>()
1429                    .join(" ");
1430
1431                if our_normalized == their_normalized {
1432                    // Content is identical - in cherry-pick context, ALWAYS prefer THEIRS
1433                    // CRITICAL: In cherry-pick, OURS=base branch, THEIRS=commit being applied
1434                    // We must keep the commit's changes (THEIRS), not the base (OURS)
1435                    // Otherwise we delete the user's code!
1436                    Ok(Some(conflict.their_content.clone()))
1437                } else {
1438                    // Content differs beyond whitespace - not safe to auto-resolve
1439                    debug!(
1440                        "Whitespace conflict has content differences - requires manual resolution"
1441                    );
1442                    Ok(None)
1443                }
1444            }
1445            ConflictType::LineEnding => {
1446                // Normalize to Unix line endings
1447                let normalized = conflict
1448                    .our_content
1449                    .replace("\r\n", "\n")
1450                    .replace('\r', "\n");
1451                Ok(Some(normalized))
1452            }
1453            ConflictType::PureAddition => {
1454                // CRITICAL: In cherry-pick, OURS=base, THEIRS=commit being applied
1455                // We must respect what the commit does (THEIRS), not what the base has (OURS)
1456
1457                if conflict.our_content.is_empty() && !conflict.their_content.is_empty() {
1458                    // Base is empty, commit adds content → keep the addition
1459                    Ok(Some(conflict.their_content.clone()))
1460                } else if conflict.their_content.is_empty() && !conflict.our_content.is_empty() {
1461                    // Base has content, commit removes it → keep it removed (empty)
1462                    Ok(Some(String::new()))
1463                } else if conflict.our_content.is_empty() && conflict.their_content.is_empty() {
1464                    // Both empty → keep empty
1465                    Ok(Some(String::new()))
1466                } else {
1467                    // Both sides have content - this could be:
1468                    // - Duplicate function definitions
1469                    // - Conflicting logic
1470                    // - Different implementations of same feature
1471                    // Too risky to auto-merge - require manual resolution
1472                    debug!(
1473                        "PureAddition conflict has content on both sides - requires manual resolution"
1474                    );
1475                    Ok(None)
1476                }
1477            }
1478            ConflictType::ImportMerge => {
1479                // SAFETY: Only merge simple single-line imports
1480                // Multi-line imports or complex cases require manual resolution
1481
1482                // Check if all imports are single-line and look like imports
1483                let our_lines: Vec<&str> = conflict.our_content.lines().collect();
1484                let their_lines: Vec<&str> = conflict.their_content.lines().collect();
1485
1486                // Verify all lines look like simple imports (heuristic check)
1487                let all_simple = our_lines.iter().chain(their_lines.iter()).all(|line| {
1488                    let trimmed = line.trim();
1489                    trimmed.starts_with("import ")
1490                        || trimmed.starts_with("from ")
1491                        || trimmed.starts_with("use ")
1492                        || trimmed.starts_with("#include")
1493                        || trimmed.is_empty()
1494                });
1495
1496                if !all_simple {
1497                    debug!("ImportMerge contains non-import lines - requires manual resolution");
1498                    return Ok(None);
1499                }
1500
1501                // Merge and deduplicate imports
1502                let mut all_imports: Vec<&str> = our_lines
1503                    .into_iter()
1504                    .chain(their_lines)
1505                    .filter(|line| !line.trim().is_empty())
1506                    .collect();
1507                all_imports.sort();
1508                all_imports.dedup();
1509                Ok(Some(all_imports.join("\n")))
1510            }
1511            ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
1512                // These require manual resolution
1513                Ok(None)
1514            }
1515        }
1516    }
1517
1518    /// Parse conflict markers from file content
1519    fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
1520        let lines: Vec<&str> = content.lines().collect();
1521        let mut conflicts = Vec::new();
1522        let mut i = 0;
1523
1524        while i < lines.len() {
1525            if lines[i].starts_with("<<<<<<<") {
1526                // Found start of conflict
1527                let start_line = i + 1;
1528                let mut separator_line = None;
1529                let mut end_line = None;
1530
1531                // Find the separator and end
1532                for (j, line) in lines.iter().enumerate().skip(i + 1) {
1533                    if line.starts_with("=======") {
1534                        separator_line = Some(j + 1);
1535                    } else if line.starts_with(">>>>>>>") {
1536                        end_line = Some(j + 1);
1537                        break;
1538                    }
1539                }
1540
1541                if let (Some(sep), Some(end)) = (separator_line, end_line) {
1542                    // Calculate byte positions
1543                    let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
1544                    let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
1545
1546                    let our_content = lines[(i + 1)..(sep - 1)].join("\n");
1547                    let their_content = lines[sep..(end - 1)].join("\n");
1548
1549                    conflicts.push(ConflictRegion {
1550                        start: start_pos,
1551                        end: end_pos,
1552                        start_line,
1553                        end_line: end,
1554                        our_content,
1555                        their_content,
1556                    });
1557
1558                    i = end;
1559                } else {
1560                    i += 1;
1561                }
1562            } else {
1563                i += 1;
1564            }
1565        }
1566
1567        Ok(conflicts)
1568    }
1569
1570    /// Update a stack entry with new commit information
1571    /// NOTE: We keep the original branch name to preserve PR mapping, only update commit hash
1572    fn update_stack_entry(
1573        &mut self,
1574        stack_id: Uuid,
1575        entry_id: &Uuid,
1576        _new_branch: &str,
1577        new_commit_hash: &str,
1578    ) -> Result<()> {
1579        debug!(
1580            "Updating entry {} in stack {} with new commit {}",
1581            entry_id, stack_id, new_commit_hash
1582        );
1583
1584        // Get the stack and update the entry
1585        let stack = self
1586            .stack_manager
1587            .get_stack_mut(&stack_id)
1588            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1589
1590        // Get entry info before mutation
1591        let entry_exists = stack.entries.iter().any(|e| e.id == *entry_id);
1592
1593        if entry_exists {
1594            let old_hash = stack
1595                .entries
1596                .iter()
1597                .find(|e| e.id == *entry_id)
1598                .map(|e| e.commit_hash.clone())
1599                .unwrap();
1600
1601            debug!(
1602                "Found entry {} - updating commit from '{}' to '{}' (keeping original branch)",
1603                entry_id, old_hash, new_commit_hash
1604            );
1605
1606            // CRITICAL: Keep the original branch name to preserve PR mapping
1607            // Only update the commit hash to point to the new rebased commit using safe wrapper
1608            stack
1609                .update_entry_commit_hash(entry_id, new_commit_hash.to_string())
1610                .map_err(CascadeError::config)?;
1611
1612            // Note: Stack will be saved by the caller (StackManager) after rebase completes
1613
1614            debug!(
1615                "Successfully updated entry {} in stack {}",
1616                entry_id, stack_id
1617            );
1618            Ok(())
1619        } else {
1620            Err(CascadeError::config(format!(
1621                "Entry {entry_id} not found in stack {stack_id}"
1622            )))
1623        }
1624    }
1625
1626    /// Pull latest changes from remote
1627    fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1628        tracing::debug!("Pulling latest changes for branch {}", branch);
1629
1630        // First try to fetch (this might fail if no remote exists)
1631        match self.git_repo.fetch() {
1632            Ok(_) => {
1633                debug!("Fetch successful");
1634                // Now try to pull the specific branch
1635                match self.git_repo.pull(branch) {
1636                    Ok(_) => {
1637                        tracing::debug!("Pull completed successfully for {}", branch);
1638                        Ok(())
1639                    }
1640                    Err(e) => {
1641                        tracing::debug!("Pull failed for {}: {}", branch, e);
1642                        // Don't fail the entire rebase for pull issues
1643                        Ok(())
1644                    }
1645                }
1646            }
1647            Err(e) => {
1648                tracing::debug!("Fetch failed: {}", e);
1649                // Don't fail if there's no remote configured
1650                Ok(())
1651            }
1652        }
1653    }
1654
1655    /// Check if rebase is in progress
1656    pub fn is_rebase_in_progress(&self) -> bool {
1657        // Check for git rebase state files
1658        let git_dir = self.git_repo.path().join(".git");
1659        git_dir.join("REBASE_HEAD").exists()
1660            || git_dir.join("rebase-merge").exists()
1661            || git_dir.join("rebase-apply").exists()
1662    }
1663
1664    /// Abort an in-progress rebase
1665    pub fn abort_rebase(&self) -> Result<()> {
1666        tracing::debug!("Aborting rebase operation");
1667
1668        let git_dir = self.git_repo.path().join(".git");
1669
1670        // Clean up rebase state files
1671        if git_dir.join("REBASE_HEAD").exists() {
1672            std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1673                CascadeError::Git(git2::Error::from_str(&format!(
1674                    "Failed to clean rebase state: {e}"
1675                )))
1676            })?;
1677        }
1678
1679        if git_dir.join("rebase-merge").exists() {
1680            std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1681                CascadeError::Git(git2::Error::from_str(&format!(
1682                    "Failed to clean rebase-merge: {e}"
1683                )))
1684            })?;
1685        }
1686
1687        if git_dir.join("rebase-apply").exists() {
1688            std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1689                CascadeError::Git(git2::Error::from_str(&format!(
1690                    "Failed to clean rebase-apply: {e}"
1691                )))
1692            })?;
1693        }
1694
1695        tracing::debug!("Rebase aborted successfully");
1696        Ok(())
1697    }
1698
1699    /// Continue an in-progress rebase after conflict resolution
1700    pub fn continue_rebase(&self) -> Result<()> {
1701        tracing::debug!("Continuing rebase operation");
1702
1703        // Check if there are still conflicts
1704        if self.git_repo.has_conflicts()? {
1705            return Err(CascadeError::branch(
1706                "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1707            ));
1708        }
1709
1710        // Stage resolved files
1711        self.git_repo.stage_conflict_resolved_files()?;
1712
1713        tracing::debug!("Rebase continued successfully");
1714        Ok(())
1715    }
1716
1717    /// Check if there's an in-progress cherry-pick operation
1718    fn has_in_progress_cherry_pick(&self) -> Result<bool> {
1719        let git_dir = self.git_repo.path().join(".git");
1720        Ok(git_dir.join("CHERRY_PICK_HEAD").exists())
1721    }
1722
1723    /// Handle resuming an in-progress cherry-pick from a previous failed sync
1724    fn handle_in_progress_cherry_pick(&mut self, stack: &Stack) -> Result<RebaseResult> {
1725        use crate::cli::output::Output;
1726
1727        let git_dir = self.git_repo.path().join(".git");
1728
1729        Output::section("Resuming in-progress sync");
1730        println!();
1731        Output::info("Detected unfinished cherry-pick from previous sync");
1732        println!();
1733
1734        // Check if conflicts are resolved
1735        if self.git_repo.has_conflicts()? {
1736            let conflicted_files = self.git_repo.get_conflicted_files()?;
1737
1738            let result = RebaseResult {
1739                success: false,
1740                branch_mapping: HashMap::new(),
1741                conflicts: conflicted_files.clone(),
1742                new_commits: Vec::new(),
1743                error: Some(format!(
1744                    "Cannot continue: {} file(s) still have unresolved conflicts\n\n\
1745                    MANUAL CONFLICT RESOLUTION REQUIRED\n\
1746                    =====================================\n\n\
1747                    Conflicted files:\n{}\n\n\
1748                    Step 1: Analyze conflicts\n\
1749                    → Run: ca conflicts\n\
1750                    → Shows detailed conflict analysis\n\n\
1751                    Step 2: Resolve conflicts in your editor\n\
1752                    → Open conflicted files and edit them\n\
1753                    → Remove conflict markers (<<<<<<, ======, >>>>>>)\n\
1754                    → Keep the code you want\n\
1755                    → Save the files\n\n\
1756                    Step 3: Mark conflicts as resolved\n\
1757                    → Run: git add <resolved-files>\n\
1758                    → Or: git add -A (to stage all resolved files)\n\n\
1759                    Step 4: Complete the sync\n\
1760                    → Run: ca sync continue\n\
1761                    → Cascade will complete the cherry-pick and continue\n\n\
1762                    Alternative: Abort and start over\n\
1763                    → Run: ca sync abort\n\
1764                    → Then: ca sync (starts fresh)",
1765                    conflicted_files.len(),
1766                    conflicted_files
1767                        .iter()
1768                        .map(|f| format!("  - {}", f))
1769                        .collect::<Vec<_>>()
1770                        .join("\n")
1771                )),
1772                summary: "Sync paused - conflicts need resolution".to_string(),
1773            };
1774
1775            return Ok(result);
1776        }
1777
1778        // Conflicts are resolved - continue the cherry-pick
1779        Output::info("Conflicts resolved, continuing cherry-pick...");
1780
1781        // Stage all resolved files
1782        self.git_repo.stage_conflict_resolved_files()?;
1783
1784        // Complete the cherry-pick by committing
1785        let cherry_pick_msg_file = git_dir.join("CHERRY_PICK_MSG");
1786        let commit_message = if cherry_pick_msg_file.exists() {
1787            std::fs::read_to_string(&cherry_pick_msg_file)
1788                .unwrap_or_else(|_| "Resolved conflicts".to_string())
1789        } else {
1790            "Resolved conflicts".to_string()
1791        };
1792
1793        match self.git_repo.commit(&commit_message) {
1794            Ok(_new_commit_id) => {
1795                Output::success("Cherry-pick completed");
1796
1797                // Clean up cherry-pick state
1798                if git_dir.join("CHERRY_PICK_HEAD").exists() {
1799                    let _ = std::fs::remove_file(git_dir.join("CHERRY_PICK_HEAD"));
1800                }
1801                if cherry_pick_msg_file.exists() {
1802                    let _ = std::fs::remove_file(&cherry_pick_msg_file);
1803                }
1804
1805                println!();
1806                Output::info("Continuing with rest of stack...");
1807                println!();
1808
1809                // Now continue with the rest of the rebase
1810                // We need to restart the full rebase since we don't track which entry we were on
1811                self.rebase_with_force_push(stack)
1812            }
1813            Err(e) => {
1814                let result = RebaseResult {
1815                    success: false,
1816                    branch_mapping: HashMap::new(),
1817                    conflicts: Vec::new(),
1818                    new_commits: Vec::new(),
1819                    error: Some(format!(
1820                        "Failed to complete cherry-pick: {}\n\n\
1821                        This usually means:\n\
1822                        - Another Git process is accessing the repository\n\
1823                        - File permissions issue\n\
1824                        - Disk space issue\n\n\
1825                        Recovery:\n\
1826                        1. Check if another Git operation is running\n\
1827                        2. Run 'git status' to check repo state\n\
1828                        3. Retry 'ca sync' after fixing the issue\n\n\
1829                        Or abort and start fresh:\n\
1830                        → Run: ca sync abort\n\
1831                        → Then: ca sync",
1832                        e
1833                    )),
1834                    summary: "Failed to complete cherry-pick".to_string(),
1835                };
1836
1837                Ok(result)
1838            }
1839        }
1840    }
1841}
1842
1843impl RebaseResult {
1844    /// Get a summary of the rebase operation
1845    pub fn get_summary(&self) -> String {
1846        if self.success {
1847            format!("✅ {}", self.summary)
1848        } else {
1849            format!(
1850                "❌ Rebase failed: {}",
1851                self.error.as_deref().unwrap_or("Unknown error")
1852            )
1853        }
1854    }
1855
1856    /// Check if any conflicts occurred
1857    pub fn has_conflicts(&self) -> bool {
1858        !self.conflicts.is_empty()
1859    }
1860
1861    /// Get the number of successful operations
1862    pub fn success_count(&self) -> usize {
1863        self.new_commits.len()
1864    }
1865}
1866
1867#[cfg(test)]
1868mod tests {
1869    use super::*;
1870    use std::path::PathBuf;
1871    use std::process::Command;
1872    use tempfile::TempDir;
1873
1874    #[allow(dead_code)]
1875    fn create_test_repo() -> (TempDir, PathBuf) {
1876        let temp_dir = TempDir::new().unwrap();
1877        let repo_path = temp_dir.path().to_path_buf();
1878
1879        // Initialize git repository
1880        Command::new("git")
1881            .args(["init"])
1882            .current_dir(&repo_path)
1883            .output()
1884            .unwrap();
1885        Command::new("git")
1886            .args(["config", "user.name", "Test"])
1887            .current_dir(&repo_path)
1888            .output()
1889            .unwrap();
1890        Command::new("git")
1891            .args(["config", "user.email", "test@test.com"])
1892            .current_dir(&repo_path)
1893            .output()
1894            .unwrap();
1895
1896        // Create initial commit
1897        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1898        Command::new("git")
1899            .args(["add", "."])
1900            .current_dir(&repo_path)
1901            .output()
1902            .unwrap();
1903        Command::new("git")
1904            .args(["commit", "-m", "Initial"])
1905            .current_dir(&repo_path)
1906            .output()
1907            .unwrap();
1908
1909        (temp_dir, repo_path)
1910    }
1911
1912    #[test]
1913    fn test_conflict_region_creation() {
1914        let region = ConflictRegion {
1915            start: 0,
1916            end: 50,
1917            start_line: 1,
1918            end_line: 3,
1919            our_content: "function test() {\n    return true;\n}".to_string(),
1920            their_content: "function test() {\n  return true;\n}".to_string(),
1921        };
1922
1923        assert_eq!(region.start_line, 1);
1924        assert_eq!(region.end_line, 3);
1925        assert!(region.our_content.contains("return true"));
1926        assert!(region.their_content.contains("return true"));
1927    }
1928
1929    #[test]
1930    fn test_rebase_strategies() {
1931        assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1932        assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1933    }
1934
1935    #[test]
1936    fn test_rebase_options() {
1937        let options = RebaseOptions::default();
1938        assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1939        assert!(!options.interactive);
1940        assert!(options.auto_resolve);
1941        assert_eq!(options.max_retries, 3);
1942    }
1943
1944    #[test]
1945    fn test_cleanup_guard_tracks_branches() {
1946        let mut guard = TempBranchCleanupGuard::new();
1947        assert!(guard.branches.is_empty());
1948
1949        guard.add_branch("test-branch-1".to_string());
1950        guard.add_branch("test-branch-2".to_string());
1951
1952        assert_eq!(guard.branches.len(), 2);
1953        assert_eq!(guard.branches[0], "test-branch-1");
1954        assert_eq!(guard.branches[1], "test-branch-2");
1955    }
1956
1957    #[test]
1958    fn test_cleanup_guard_prevents_double_cleanup() {
1959        use std::process::Command;
1960        use tempfile::TempDir;
1961
1962        // Create a temporary git repo
1963        let temp_dir = TempDir::new().unwrap();
1964        let repo_path = temp_dir.path();
1965
1966        Command::new("git")
1967            .args(["init"])
1968            .current_dir(repo_path)
1969            .output()
1970            .unwrap();
1971
1972        Command::new("git")
1973            .args(["config", "user.name", "Test"])
1974            .current_dir(repo_path)
1975            .output()
1976            .unwrap();
1977
1978        Command::new("git")
1979            .args(["config", "user.email", "test@test.com"])
1980            .current_dir(repo_path)
1981            .output()
1982            .unwrap();
1983
1984        // Create initial commit
1985        std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1986        Command::new("git")
1987            .args(["add", "."])
1988            .current_dir(repo_path)
1989            .output()
1990            .unwrap();
1991        Command::new("git")
1992            .args(["commit", "-m", "initial"])
1993            .current_dir(repo_path)
1994            .output()
1995            .unwrap();
1996
1997        let git_repo = GitRepository::open(repo_path).unwrap();
1998
1999        // Create a test branch
2000        git_repo.create_branch("test-temp", None).unwrap();
2001
2002        let mut guard = TempBranchCleanupGuard::new();
2003        guard.add_branch("test-temp".to_string());
2004
2005        // First cleanup should work
2006        guard.cleanup(&git_repo);
2007        assert!(guard.cleaned);
2008
2009        // Second cleanup should be a no-op (shouldn't panic)
2010        guard.cleanup(&git_repo);
2011        assert!(guard.cleaned);
2012    }
2013
2014    #[test]
2015    fn test_rebase_result() {
2016        let result = RebaseResult {
2017            success: true,
2018            branch_mapping: std::collections::HashMap::new(),
2019            conflicts: vec!["abc123".to_string()],
2020            new_commits: vec!["def456".to_string()],
2021            error: None,
2022            summary: "Test summary".to_string(),
2023        };
2024
2025        assert!(result.success);
2026        assert!(result.has_conflicts());
2027        assert_eq!(result.success_count(), 1);
2028    }
2029}