Skip to main content

cascade_cli/stack/
rebase.rs

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