cascade_cli/stack/
rebase.rs

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