cascade_cli/stack/
rebase.rs

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