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, info, warn};
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}
65
66/// Result of a rebase operation
67#[derive(Debug)]
68pub struct RebaseResult {
69    /// Whether the rebase was successful
70    pub success: bool,
71    /// Old branch to new branch mapping
72    pub branch_mapping: HashMap<String, String>,
73    /// Commits that had conflicts
74    pub conflicts: Vec<String>,
75    /// New commit hashes
76    pub new_commits: Vec<String>,
77    /// Error message if rebase failed
78    pub error: Option<String>,
79    /// Summary of changes made
80    pub summary: String,
81}
82
83/// RAII guard to ensure temporary branches are cleaned up even on error/panic
84///
85/// This stores branch names and provides a cleanup method that can be called
86/// with a GitRepository reference. The Drop trait ensures cleanup happens
87/// even if the rebase function panics or returns early with an error.
88#[allow(dead_code)]
89struct TempBranchCleanupGuard {
90    branches: Vec<String>,
91    cleaned: bool,
92}
93
94#[allow(dead_code)]
95impl TempBranchCleanupGuard {
96    fn new() -> Self {
97        Self {
98            branches: Vec::new(),
99            cleaned: false,
100        }
101    }
102
103    fn add_branch(&mut self, branch: String) {
104        self.branches.push(branch);
105    }
106
107    /// Perform cleanup with provided git repository
108    fn cleanup(&mut self, git_repo: &GitRepository) {
109        if self.cleaned || self.branches.is_empty() {
110            return;
111        }
112
113        info!("๐Ÿงน Cleaning up {} temporary branches", self.branches.len());
114        for branch in &self.branches {
115            if let Err(e) = git_repo.delete_branch_unsafe(branch) {
116                warn!("Failed to delete temp branch {}: {}", branch, e);
117                // Continue with cleanup even if one fails
118            }
119        }
120        self.cleaned = true;
121    }
122}
123
124impl Drop for TempBranchCleanupGuard {
125    fn drop(&mut self) {
126        if !self.cleaned && !self.branches.is_empty() {
127            // This path is only hit on panic or unexpected early return
128            // We can't access git_repo here, so just log the branches that need manual cleanup
129            warn!(
130                "โš ๏ธ  {} temporary branches were not cleaned up: {}",
131                self.branches.len(),
132                self.branches.join(", ")
133            );
134            warn!("Run 'ca cleanup' to remove orphaned temporary branches");
135        }
136    }
137}
138
139/// Manages rebase operations for stacks
140pub struct RebaseManager {
141    stack_manager: StackManager,
142    git_repo: GitRepository,
143    options: RebaseOptions,
144    conflict_analyzer: ConflictAnalyzer,
145}
146
147impl Default for RebaseOptions {
148    fn default() -> Self {
149        Self {
150            strategy: RebaseStrategy::ForcePush,
151            interactive: false,
152            target_base: None,
153            preserve_merges: true,
154            auto_resolve: true,
155            max_retries: 3,
156            skip_pull: None,
157        }
158    }
159}
160
161impl RebaseManager {
162    /// Create a new rebase manager
163    pub fn new(
164        stack_manager: StackManager,
165        git_repo: GitRepository,
166        options: RebaseOptions,
167    ) -> Self {
168        Self {
169            stack_manager,
170            git_repo,
171            options,
172            conflict_analyzer: ConflictAnalyzer::new(),
173        }
174    }
175
176    /// Rebase an entire stack onto a new base
177    pub fn rebase_stack(&mut self, stack_id: &Uuid) -> Result<RebaseResult> {
178        info!("Starting rebase for stack {}", stack_id);
179
180        let stack = self
181            .stack_manager
182            .get_stack(stack_id)
183            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
184            .clone();
185
186        match self.options.strategy {
187            RebaseStrategy::ForcePush => self.rebase_with_force_push(&stack),
188            RebaseStrategy::Interactive => self.rebase_interactive(&stack),
189        }
190    }
191
192    /// Rebase using force-push strategy (industry standard for stacked diffs)
193    /// This updates local branches in-place, then force-pushes ONLY branches with existing PRs
194    /// to preserve PR history - the approach used by Graphite, Phabricator, spr, etc.
195    fn rebase_with_force_push(&mut self, stack: &Stack) -> Result<RebaseResult> {
196        use crate::cli::output::Output;
197
198        Output::section(format!("Rebasing stack: {}", stack.name));
199
200        let mut result = RebaseResult {
201            success: true,
202            branch_mapping: HashMap::new(),
203            conflicts: Vec::new(),
204            new_commits: Vec::new(),
205            error: None,
206            summary: String::new(),
207        };
208
209        let target_base = self
210            .options
211            .target_base
212            .as_ref()
213            .unwrap_or(&stack.base_branch)
214            .clone(); // Clone to avoid borrow issues
215
216        // Save original working branch to restore later
217        let original_branch = self.git_repo.get_current_branch().ok();
218
219        // Ensure we're on the base branch
220        if self.git_repo.get_current_branch()? != target_base {
221            self.git_repo.checkout_branch(&target_base)?;
222        }
223
224        // Only pull if not already done by caller (like sync command)
225        if !self.options.skip_pull.unwrap_or(false) {
226            if let Err(e) = self.pull_latest_changes(&target_base) {
227                Output::warning(format!("Could not pull latest changes: {}", e));
228            }
229        }
230
231        // Reset working directory to clean state before rebase
232        if let Err(e) = self.git_repo.reset_to_head() {
233            Output::warning(format!("Could not reset working directory: {}", e));
234        }
235
236        let mut current_base = target_base.clone();
237        let entry_count = stack.entries.len();
238        let mut pushed_count = 0;
239        let mut skipped_count = 0;
240        let mut temp_branches: Vec<String> = Vec::new(); // Track temp branches for cleanup
241
242        println!(); // Spacing before tree
243        let plural = if entry_count == 1 { "entry" } else { "entries" };
244        println!("๐Ÿ“‹ Rebasing {} {}", entry_count, plural);
245
246        for (index, entry) in stack.entries.iter().enumerate() {
247            let original_branch = &entry.branch;
248
249            // Create a temporary branch from the current base
250            // This avoids committing directly to protected branches like develop/main
251            let temp_branch = format!("{}-temp-{}", original_branch, Utc::now().timestamp());
252            temp_branches.push(temp_branch.clone()); // Track for cleanup
253            self.git_repo
254                .create_branch(&temp_branch, Some(&current_base))?;
255            self.git_repo.checkout_branch(&temp_branch)?;
256
257            // Cherry-pick the commit onto the temp branch (NOT the protected base!)
258            match self.cherry_pick_commit(&entry.commit_hash) {
259                Ok(new_commit_hash) => {
260                    result.new_commits.push(new_commit_hash.clone());
261
262                    // Get the commit that's now at HEAD (the cherry-picked commit)
263                    let rebased_commit_id = self.git_repo.get_head_commit()?.id().to_string();
264
265                    // Update the original branch to point to this rebased commit
266                    // This is LOCAL ONLY - moves refs/heads/<branch> to the commit on temp branch
267                    self.git_repo
268                        .update_branch_to_commit(original_branch, &rebased_commit_id)?;
269
270                    // Only force-push to REMOTE if this entry has a PR
271                    if entry.pull_request_id.is_some() {
272                        let pr_num = entry.pull_request_id.as_ref().unwrap();
273                        let tree_char = if index + 1 == entry_count {
274                            "โ””โ”€"
275                        } else {
276                            "โ”œโ”€"
277                        };
278                        println!("   {} {} (PR #{})", tree_char, original_branch, pr_num);
279
280                        // NOW do the actual force-push to remote (git push --force origin <branch>)
281                        // This updates the PR with the rebased commits
282                        self.git_repo.force_push_single_branch(original_branch)?;
283                        pushed_count += 1;
284                    } else {
285                        let tree_char = if index + 1 == entry_count {
286                            "โ””โ”€"
287                        } else {
288                            "โ”œโ”€"
289                        };
290                        println!("   {} {} (not submitted)", tree_char, original_branch);
291                        skipped_count += 1;
292                    }
293
294                    result
295                        .branch_mapping
296                        .insert(original_branch.clone(), original_branch.clone());
297
298                    // Update stack entry with new commit hash
299                    self.update_stack_entry(
300                        stack.id,
301                        &entry.id,
302                        original_branch,
303                        &rebased_commit_id,
304                    )?;
305
306                    // This branch becomes the base for the next entry
307                    current_base = original_branch.clone();
308                }
309                Err(e) => {
310                    println!(); // Spacing before error
311                    Output::error(format!("Conflict in {}: {}", &entry.commit_hash[..8], e));
312                    result.conflicts.push(entry.commit_hash.clone());
313
314                    if !self.options.auto_resolve {
315                        result.success = false;
316                        result.error = Some(format!("Conflict in {}: {}", entry.commit_hash, e));
317                        break;
318                    }
319
320                    // Try to resolve automatically
321                    match self.auto_resolve_conflicts(&entry.commit_hash) {
322                        Ok(_) => {
323                            Output::success("Auto-resolved conflicts");
324                        }
325                        Err(resolve_err) => {
326                            result.success = false;
327                            result.error =
328                                Some(format!("Could not resolve conflicts: {resolve_err}"));
329                            break;
330                        }
331                    }
332                }
333            }
334        }
335
336        // Cleanup temp branches before returning to original branch
337        // Must checkout away from temp branches first
338        if !temp_branches.is_empty() {
339            // Checkout base branch to allow temp branch deletion
340            if let Err(e) = self.git_repo.checkout_branch(&target_base) {
341                Output::warning(format!("Could not checkout base for cleanup: {}", e));
342            }
343
344            // Delete all temp branches
345            for temp_branch in &temp_branches {
346                if let Err(e) = self.git_repo.delete_branch_unsafe(temp_branch) {
347                    debug!("Could not delete temp branch {}: {}", temp_branch, e);
348                }
349            }
350        }
351
352        // Return to original working branch
353        if let Some(orig_branch) = original_branch {
354            if let Err(e) = self.git_repo.checkout_branch(&orig_branch) {
355                Output::warning(format!(
356                    "Could not return to original branch '{}': {}",
357                    orig_branch, e
358                ));
359            }
360        }
361
362        // Build summary message
363        result.summary = if pushed_count > 0 {
364            let pr_plural = if pushed_count == 1 { "" } else { "s" };
365            let entry_plural = if entry_count == 1 { "entry" } else { "entries" };
366
367            if skipped_count > 0 {
368                format!(
369                    "{} {} rebased ({} PR{} updated, {} not yet submitted)",
370                    entry_count, entry_plural, pushed_count, pr_plural, skipped_count
371                )
372            } else {
373                format!(
374                    "{} {} rebased ({} PR{} updated)",
375                    entry_count, entry_plural, pushed_count, pr_plural
376                )
377            }
378        } else {
379            let plural = if entry_count == 1 { "entry" } else { "entries" };
380            format!("{} {} rebased (no PRs to update yet)", entry_count, plural)
381        };
382
383        // Display result with proper formatting
384        println!(); // Spacing after tree
385        if result.success {
386            Output::success(&result.summary);
387        } else {
388            Output::error(format!("Rebase failed: {:?}", result.error));
389        }
390
391        // Save the updated stack metadata to disk
392        self.stack_manager.save_to_disk()?;
393
394        Ok(result)
395    }
396
397    /// Interactive rebase with user input
398    fn rebase_interactive(&mut self, stack: &Stack) -> Result<RebaseResult> {
399        info!("Starting interactive rebase for stack '{}'", stack.name);
400
401        let mut result = RebaseResult {
402            success: true,
403            branch_mapping: HashMap::new(),
404            conflicts: Vec::new(),
405            new_commits: Vec::new(),
406            error: None,
407            summary: String::new(),
408        };
409
410        println!("๐Ÿ”„ Interactive Rebase for Stack: {}", stack.name);
411        println!("   Base branch: {}", stack.base_branch);
412        println!("   Entries: {}", stack.entries.len());
413
414        if self.options.interactive {
415            println!("\nChoose action for each commit:");
416            println!("  (p)ick   - apply the commit");
417            println!("  (s)kip   - skip this commit");
418            println!("  (e)dit   - edit the commit message");
419            println!("  (q)uit   - abort the rebase");
420        }
421
422        // For now, automatically pick all commits
423        // In a real implementation, this would prompt the user
424        for entry in &stack.entries {
425            println!(
426                "  {} {} - {}",
427                entry.short_hash(),
428                entry.branch,
429                entry.short_message(50)
430            );
431
432            // Auto-pick for demo purposes
433            match self.cherry_pick_commit(&entry.commit_hash) {
434                Ok(new_commit) => result.new_commits.push(new_commit),
435                Err(_) => result.conflicts.push(entry.commit_hash.clone()),
436            }
437        }
438
439        result.summary = format!(
440            "Interactive rebase processed {} commits",
441            stack.entries.len()
442        );
443        Ok(result)
444    }
445
446    /// Cherry-pick a commit onto the current branch
447    fn cherry_pick_commit(&self, commit_hash: &str) -> Result<String> {
448        // Use the real cherry-pick implementation from GitRepository
449        let new_commit_hash = self.git_repo.cherry_pick(commit_hash)?;
450
451        // Check for any leftover staged changes after successful cherry-pick
452        if let Ok(staged_files) = self.git_repo.get_staged_files() {
453            if !staged_files.is_empty() {
454                // Commit any leftover staged changes silently
455                let cleanup_message = format!("Cleanup after cherry-pick {}", &commit_hash[..8]);
456                let _ = self.git_repo.commit_staged_changes(&cleanup_message);
457            }
458        }
459
460        Ok(new_commit_hash)
461    }
462
463    /// Attempt to automatically resolve conflicts
464    fn auto_resolve_conflicts(&self, commit_hash: &str) -> Result<bool> {
465        debug!("Attempting to auto-resolve conflicts for {}", commit_hash);
466
467        // Check if there are actually conflicts
468        if !self.git_repo.has_conflicts()? {
469            return Ok(true);
470        }
471
472        let conflicted_files = self.git_repo.get_conflicted_files()?;
473
474        if conflicted_files.is_empty() {
475            return Ok(true);
476        }
477
478        info!(
479            "Found conflicts in {} files: {:?}",
480            conflicted_files.len(),
481            conflicted_files
482        );
483
484        // Use the new conflict analyzer for detailed analysis
485        let analysis = self
486            .conflict_analyzer
487            .analyze_conflicts(&conflicted_files, self.git_repo.path())?;
488
489        info!(
490            "๐Ÿ” Conflict analysis: {} total conflicts, {} auto-resolvable",
491            analysis.total_conflicts, analysis.auto_resolvable_count
492        );
493
494        // Display recommendations
495        for recommendation in &analysis.recommendations {
496            info!("๐Ÿ’ก {}", recommendation);
497        }
498
499        let mut resolved_count = 0;
500        let mut failed_files = Vec::new();
501
502        for file_analysis in &analysis.files {
503            if file_analysis.auto_resolvable {
504                match self.resolve_file_conflicts_enhanced(
505                    &file_analysis.file_path,
506                    &file_analysis.conflicts,
507                ) {
508                    Ok(ConflictResolution::Resolved) => {
509                        resolved_count += 1;
510                        info!("โœ… Auto-resolved conflicts in {}", file_analysis.file_path);
511                    }
512                    Ok(ConflictResolution::TooComplex) => {
513                        debug!(
514                            "โš ๏ธ  Conflicts in {} are too complex for auto-resolution",
515                            file_analysis.file_path
516                        );
517                        failed_files.push(file_analysis.file_path.clone());
518                    }
519                    Err(e) => {
520                        warn!(
521                            "โŒ Failed to resolve conflicts in {}: {}",
522                            file_analysis.file_path, e
523                        );
524                        failed_files.push(file_analysis.file_path.clone());
525                    }
526                }
527            } else {
528                failed_files.push(file_analysis.file_path.clone());
529                info!(
530                    "โš ๏ธ  {} requires manual resolution ({} conflicts)",
531                    file_analysis.file_path,
532                    file_analysis.conflicts.len()
533                );
534            }
535        }
536
537        if resolved_count > 0 {
538            info!(
539                "๐ŸŽ‰ Auto-resolved conflicts in {}/{} files",
540                resolved_count,
541                conflicted_files.len()
542            );
543
544            // Stage all resolved files
545            self.git_repo.stage_conflict_resolved_files()?;
546        }
547
548        // Return true only if ALL conflicts were resolved
549        let all_resolved = failed_files.is_empty();
550
551        if !all_resolved {
552            info!(
553                "โš ๏ธ  {} files still need manual resolution: {:?}",
554                failed_files.len(),
555                failed_files
556            );
557        }
558
559        Ok(all_resolved)
560    }
561
562    /// Resolve conflicts using enhanced analysis
563    fn resolve_file_conflicts_enhanced(
564        &self,
565        file_path: &str,
566        conflicts: &[crate::git::ConflictRegion],
567    ) -> Result<ConflictResolution> {
568        let repo_path = self.git_repo.path();
569        let full_path = repo_path.join(file_path);
570
571        // Read the file content with conflict markers
572        let mut content = std::fs::read_to_string(&full_path)
573            .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
574
575        if conflicts.is_empty() {
576            return Ok(ConflictResolution::Resolved);
577        }
578
579        info!(
580            "Resolving {} conflicts in {} using enhanced analysis",
581            conflicts.len(),
582            file_path
583        );
584
585        let mut any_resolved = false;
586
587        // Process conflicts in reverse order to maintain string indices
588        for conflict in conflicts.iter().rev() {
589            match self.resolve_single_conflict_enhanced(conflict) {
590                Ok(Some(resolution)) => {
591                    // Replace the conflict region with the resolved content
592                    let before = &content[..conflict.start_pos];
593                    let after = &content[conflict.end_pos..];
594                    content = format!("{before}{resolution}{after}");
595                    any_resolved = true;
596                    debug!(
597                        "โœ… Resolved {} conflict at lines {}-{} in {}",
598                        format!("{:?}", conflict.conflict_type).to_lowercase(),
599                        conflict.start_line,
600                        conflict.end_line,
601                        file_path
602                    );
603                }
604                Ok(None) => {
605                    debug!(
606                        "โš ๏ธ  {} conflict at lines {}-{} in {} requires manual resolution",
607                        format!("{:?}", conflict.conflict_type).to_lowercase(),
608                        conflict.start_line,
609                        conflict.end_line,
610                        file_path
611                    );
612                    return Ok(ConflictResolution::TooComplex);
613                }
614                Err(e) => {
615                    debug!("โŒ Failed to resolve conflict in {}: {}", file_path, e);
616                    return Ok(ConflictResolution::TooComplex);
617                }
618            }
619        }
620
621        if any_resolved {
622            // Check if we resolved ALL conflicts in this file
623            let remaining_conflicts = self.parse_conflict_markers(&content)?;
624
625            if remaining_conflicts.is_empty() {
626                // All conflicts resolved - write the file back atomically
627                crate::utils::atomic_file::write_string(&full_path, &content)?;
628
629                return Ok(ConflictResolution::Resolved);
630            } else {
631                info!(
632                    "โš ๏ธ  Partially resolved conflicts in {} ({} remaining)",
633                    file_path,
634                    remaining_conflicts.len()
635                );
636            }
637        }
638
639        Ok(ConflictResolution::TooComplex)
640    }
641
642    /// Resolve a single conflict using enhanced analysis
643    fn resolve_single_conflict_enhanced(
644        &self,
645        conflict: &crate::git::ConflictRegion,
646    ) -> Result<Option<String>> {
647        debug!(
648            "Resolving {} conflict in {} (lines {}-{})",
649            format!("{:?}", conflict.conflict_type).to_lowercase(),
650            conflict.file_path,
651            conflict.start_line,
652            conflict.end_line
653        );
654
655        use crate::git::ConflictType;
656
657        match conflict.conflict_type {
658            ConflictType::Whitespace => {
659                // Use the version with better formatting
660                if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
661                    Ok(Some(conflict.our_content.clone()))
662                } else {
663                    Ok(Some(conflict.their_content.clone()))
664                }
665            }
666            ConflictType::LineEnding => {
667                // Normalize to Unix line endings
668                let normalized = conflict
669                    .our_content
670                    .replace("\r\n", "\n")
671                    .replace('\r', "\n");
672                Ok(Some(normalized))
673            }
674            ConflictType::PureAddition => {
675                // Merge both additions
676                if conflict.our_content.is_empty() {
677                    Ok(Some(conflict.their_content.clone()))
678                } else if conflict.their_content.is_empty() {
679                    Ok(Some(conflict.our_content.clone()))
680                } else {
681                    // Try to combine both
682                    let combined = format!("{}\n{}", conflict.our_content, conflict.their_content);
683                    Ok(Some(combined))
684                }
685            }
686            ConflictType::ImportMerge => {
687                // Sort and merge imports
688                let mut all_imports: Vec<&str> = conflict
689                    .our_content
690                    .lines()
691                    .chain(conflict.their_content.lines())
692                    .collect();
693                all_imports.sort();
694                all_imports.dedup();
695                Ok(Some(all_imports.join("\n")))
696            }
697            ConflictType::Structural | ConflictType::ContentOverlap | ConflictType::Complex => {
698                // These require manual resolution
699                Ok(None)
700            }
701        }
702    }
703
704    /// Resolve conflicts in a single file using smart strategies
705    #[allow(dead_code)]
706    fn resolve_file_conflicts(&self, file_path: &str) -> Result<ConflictResolution> {
707        let repo_path = self.git_repo.path();
708        let full_path = repo_path.join(file_path);
709
710        // Read the file content with conflict markers
711        let content = std::fs::read_to_string(&full_path)
712            .map_err(|e| CascadeError::config(format!("Failed to read file {file_path}: {e}")))?;
713
714        // Parse conflicts from the file
715        let conflicts = self.parse_conflict_markers(&content)?;
716
717        if conflicts.is_empty() {
718            // No conflict markers found - file might already be resolved
719            return Ok(ConflictResolution::Resolved);
720        }
721
722        info!(
723            "Found {} conflict regions in {}",
724            conflicts.len(),
725            file_path
726        );
727
728        // Try to resolve each conflict using our strategies
729        let mut resolved_content = content;
730        let mut any_resolved = false;
731
732        // Process conflicts in reverse order to maintain string indices
733        for conflict in conflicts.iter().rev() {
734            match self.resolve_single_conflict(conflict, file_path) {
735                Ok(Some(resolution)) => {
736                    // Replace the conflict region with the resolved content
737                    let before = &resolved_content[..conflict.start];
738                    let after = &resolved_content[conflict.end..];
739                    resolved_content = format!("{before}{resolution}{after}");
740                    any_resolved = true;
741                    debug!(
742                        "โœ… Resolved conflict at lines {}-{} in {}",
743                        conflict.start_line, conflict.end_line, file_path
744                    );
745                }
746                Ok(None) => {
747                    debug!(
748                        "โš ๏ธ  Conflict at lines {}-{} in {} too complex for auto-resolution",
749                        conflict.start_line, conflict.end_line, file_path
750                    );
751                }
752                Err(e) => {
753                    debug!("โŒ Failed to resolve conflict in {}: {}", file_path, e);
754                }
755            }
756        }
757
758        if any_resolved {
759            // Check if we resolved ALL conflicts in this file
760            let remaining_conflicts = self.parse_conflict_markers(&resolved_content)?;
761
762            if remaining_conflicts.is_empty() {
763                // All conflicts resolved - write the file back atomically
764                crate::utils::atomic_file::write_string(&full_path, &resolved_content)?;
765
766                return Ok(ConflictResolution::Resolved);
767            } else {
768                info!(
769                    "โš ๏ธ  Partially resolved conflicts in {} ({} remaining)",
770                    file_path,
771                    remaining_conflicts.len()
772                );
773            }
774        }
775
776        Ok(ConflictResolution::TooComplex)
777    }
778
779    /// Parse conflict markers from file content
780    fn parse_conflict_markers(&self, content: &str) -> Result<Vec<ConflictRegion>> {
781        let lines: Vec<&str> = content.lines().collect();
782        let mut conflicts = Vec::new();
783        let mut i = 0;
784
785        while i < lines.len() {
786            if lines[i].starts_with("<<<<<<<") {
787                // Found start of conflict
788                let start_line = i + 1;
789                let mut separator_line = None;
790                let mut end_line = None;
791
792                // Find the separator and end
793                for (j, line) in lines.iter().enumerate().skip(i + 1) {
794                    if line.starts_with("=======") {
795                        separator_line = Some(j + 1);
796                    } else if line.starts_with(">>>>>>>") {
797                        end_line = Some(j + 1);
798                        break;
799                    }
800                }
801
802                if let (Some(sep), Some(end)) = (separator_line, end_line) {
803                    // Calculate byte positions
804                    let start_pos = lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
805                    let end_pos = lines[..end].iter().map(|l| l.len() + 1).sum::<usize>();
806
807                    let our_content = lines[(i + 1)..(sep - 1)].join("\n");
808                    let their_content = lines[sep..(end - 1)].join("\n");
809
810                    conflicts.push(ConflictRegion {
811                        start: start_pos,
812                        end: end_pos,
813                        start_line,
814                        end_line: end,
815                        our_content,
816                        their_content,
817                    });
818
819                    i = end;
820                } else {
821                    i += 1;
822                }
823            } else {
824                i += 1;
825            }
826        }
827
828        Ok(conflicts)
829    }
830
831    /// Resolve a single conflict using smart strategies
832    fn resolve_single_conflict(
833        &self,
834        conflict: &ConflictRegion,
835        file_path: &str,
836    ) -> Result<Option<String>> {
837        debug!(
838            "Analyzing conflict in {} (lines {}-{})",
839            file_path, conflict.start_line, conflict.end_line
840        );
841
842        // Strategy 1: Whitespace-only differences
843        if let Some(resolved) = self.resolve_whitespace_conflict(conflict)? {
844            debug!("Resolved as whitespace-only conflict");
845            return Ok(Some(resolved));
846        }
847
848        // Strategy 2: Line ending differences
849        if let Some(resolved) = self.resolve_line_ending_conflict(conflict)? {
850            debug!("Resolved as line ending conflict");
851            return Ok(Some(resolved));
852        }
853
854        // Strategy 3: Pure addition conflicts (no overlapping changes)
855        if let Some(resolved) = self.resolve_addition_conflict(conflict)? {
856            debug!("Resolved as pure addition conflict");
857            return Ok(Some(resolved));
858        }
859
860        // Strategy 4: Import/dependency reordering
861        if let Some(resolved) = self.resolve_import_conflict(conflict, file_path)? {
862            debug!("Resolved as import reordering conflict");
863            return Ok(Some(resolved));
864        }
865
866        // No strategy could resolve this conflict
867        Ok(None)
868    }
869
870    /// Resolve conflicts that only differ by whitespace
871    fn resolve_whitespace_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
872        let our_normalized = self.normalize_whitespace(&conflict.our_content);
873        let their_normalized = self.normalize_whitespace(&conflict.their_content);
874
875        if our_normalized == their_normalized {
876            // Only whitespace differences - prefer the version with better formatting
877            let resolved =
878                if conflict.our_content.trim().len() >= conflict.their_content.trim().len() {
879                    conflict.our_content.clone()
880                } else {
881                    conflict.their_content.clone()
882                };
883
884            return Ok(Some(resolved));
885        }
886
887        Ok(None)
888    }
889
890    /// Resolve conflicts that only differ by line endings
891    fn resolve_line_ending_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
892        let our_normalized = conflict
893            .our_content
894            .replace("\r\n", "\n")
895            .replace('\r', "\n");
896        let their_normalized = conflict
897            .their_content
898            .replace("\r\n", "\n")
899            .replace('\r', "\n");
900
901        if our_normalized == their_normalized {
902            // Only line ending differences - prefer Unix line endings
903            return Ok(Some(our_normalized));
904        }
905
906        Ok(None)
907    }
908
909    /// Resolve conflicts where both sides only add lines (no overlapping edits)
910    fn resolve_addition_conflict(&self, conflict: &ConflictRegion) -> Result<Option<String>> {
911        let our_lines: Vec<&str> = conflict.our_content.lines().collect();
912        let their_lines: Vec<&str> = conflict.their_content.lines().collect();
913
914        // Check if one side is a subset of the other (pure addition)
915        if our_lines.is_empty() {
916            return Ok(Some(conflict.their_content.clone()));
917        }
918        if their_lines.is_empty() {
919            return Ok(Some(conflict.our_content.clone()));
920        }
921
922        // Try to merge additions intelligently
923        let mut merged_lines = Vec::new();
924        let mut our_idx = 0;
925        let mut their_idx = 0;
926
927        while our_idx < our_lines.len() || their_idx < their_lines.len() {
928            if our_idx >= our_lines.len() {
929                // Only their lines left
930                merged_lines.extend_from_slice(&their_lines[their_idx..]);
931                break;
932            } else if their_idx >= their_lines.len() {
933                // Only our lines left
934                merged_lines.extend_from_slice(&our_lines[our_idx..]);
935                break;
936            } else if our_lines[our_idx] == their_lines[their_idx] {
937                // Same line - add once
938                merged_lines.push(our_lines[our_idx]);
939                our_idx += 1;
940                their_idx += 1;
941            } else {
942                // Different lines - this might be too complex
943                return Ok(None);
944            }
945        }
946
947        Ok(Some(merged_lines.join("\n")))
948    }
949
950    /// Resolve import/dependency conflicts by sorting and merging
951    fn resolve_import_conflict(
952        &self,
953        conflict: &ConflictRegion,
954        file_path: &str,
955    ) -> Result<Option<String>> {
956        // Only apply to likely import sections in common file types
957        let is_import_file = file_path.ends_with(".rs")
958            || file_path.ends_with(".py")
959            || file_path.ends_with(".js")
960            || file_path.ends_with(".ts")
961            || file_path.ends_with(".go")
962            || file_path.ends_with(".java")
963            || file_path.ends_with(".swift")
964            || file_path.ends_with(".kt")
965            || file_path.ends_with(".cs");
966
967        if !is_import_file {
968            return Ok(None);
969        }
970
971        let our_lines: Vec<&str> = conflict.our_content.lines().collect();
972        let their_lines: Vec<&str> = conflict.their_content.lines().collect();
973
974        // Check if all lines look like imports/uses
975        let our_imports = our_lines
976            .iter()
977            .all(|line| self.is_import_line(line, file_path));
978        let their_imports = their_lines
979            .iter()
980            .all(|line| self.is_import_line(line, file_path));
981
982        if our_imports && their_imports {
983            // Merge and sort imports
984            let mut all_imports: Vec<&str> = our_lines.into_iter().chain(their_lines).collect();
985            all_imports.sort();
986            all_imports.dedup();
987
988            return Ok(Some(all_imports.join("\n")));
989        }
990
991        Ok(None)
992    }
993
994    /// Check if a line looks like an import statement
995    fn is_import_line(&self, line: &str, file_path: &str) -> bool {
996        let trimmed = line.trim();
997
998        if trimmed.is_empty() {
999            return true; // Empty lines are OK in import sections
1000        }
1001
1002        if file_path.ends_with(".rs") {
1003            return trimmed.starts_with("use ") || trimmed.starts_with("extern crate");
1004        } else if file_path.ends_with(".py") {
1005            return trimmed.starts_with("import ") || trimmed.starts_with("from ");
1006        } else if file_path.ends_with(".js") || file_path.ends_with(".ts") {
1007            return trimmed.starts_with("import ")
1008                || trimmed.starts_with("const ")
1009                || trimmed.starts_with("require(");
1010        } else if file_path.ends_with(".go") {
1011            return trimmed.starts_with("import ") || trimmed == "import (" || trimmed == ")";
1012        } else if file_path.ends_with(".java") {
1013            return trimmed.starts_with("import ");
1014        } else if file_path.ends_with(".swift") {
1015            return trimmed.starts_with("import ") || trimmed.starts_with("@testable import ");
1016        } else if file_path.ends_with(".kt") {
1017            return trimmed.starts_with("import ") || trimmed.starts_with("@file:");
1018        } else if file_path.ends_with(".cs") {
1019            return trimmed.starts_with("using ") || trimmed.starts_with("extern alias ");
1020        }
1021
1022        false
1023    }
1024
1025    /// Normalize whitespace for comparison
1026    fn normalize_whitespace(&self, content: &str) -> String {
1027        content
1028            .lines()
1029            .map(|line| line.trim())
1030            .filter(|line| !line.is_empty())
1031            .collect::<Vec<_>>()
1032            .join("\n")
1033    }
1034
1035    /// Update a stack entry with new commit information
1036    /// NOTE: We keep the original branch name to preserve PR mapping, only update commit hash
1037    fn update_stack_entry(
1038        &mut self,
1039        stack_id: Uuid,
1040        entry_id: &Uuid,
1041        _new_branch: &str,
1042        new_commit_hash: &str,
1043    ) -> Result<()> {
1044        debug!(
1045            "Updating entry {} in stack {} with new commit {}",
1046            entry_id, stack_id, new_commit_hash
1047        );
1048
1049        // Get the stack and update the entry
1050        let stack = self
1051            .stack_manager
1052            .get_stack_mut(&stack_id)
1053            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1054
1055        // Find and update the entry
1056        if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == *entry_id) {
1057            debug!(
1058                "Found entry {} - updating commit from '{}' to '{}' (keeping original branch '{}')",
1059                entry_id, entry.commit_hash, new_commit_hash, entry.branch
1060            );
1061
1062            // CRITICAL: Keep the original branch name to preserve PR mapping
1063            // Only update the commit hash to point to the new rebased commit
1064            entry.commit_hash = new_commit_hash.to_string();
1065
1066            // Note: Stack will be saved by the caller (StackManager) after rebase completes
1067
1068            debug!(
1069                "Successfully updated entry {} in stack {}",
1070                entry_id, stack_id
1071            );
1072            Ok(())
1073        } else {
1074            Err(CascadeError::config(format!(
1075                "Entry {entry_id} not found in stack {stack_id}"
1076            )))
1077        }
1078    }
1079
1080    /// Pull latest changes from remote
1081    fn pull_latest_changes(&self, branch: &str) -> Result<()> {
1082        info!("Pulling latest changes for branch {}", branch);
1083
1084        // First try to fetch (this might fail if no remote exists)
1085        match self.git_repo.fetch() {
1086            Ok(_) => {
1087                debug!("Fetch successful");
1088                // Now try to pull the specific branch
1089                match self.git_repo.pull(branch) {
1090                    Ok(_) => {
1091                        info!("Pull completed successfully for {}", branch);
1092                        Ok(())
1093                    }
1094                    Err(e) => {
1095                        warn!("Pull failed for {}: {}", branch, e);
1096                        // Don't fail the entire rebase for pull issues
1097                        Ok(())
1098                    }
1099                }
1100            }
1101            Err(e) => {
1102                warn!("Fetch failed: {}", e);
1103                // Don't fail if there's no remote configured
1104                Ok(())
1105            }
1106        }
1107    }
1108
1109    /// Check if rebase is in progress
1110    pub fn is_rebase_in_progress(&self) -> bool {
1111        // Check for git rebase state files
1112        let git_dir = self.git_repo.path().join(".git");
1113        git_dir.join("REBASE_HEAD").exists()
1114            || git_dir.join("rebase-merge").exists()
1115            || git_dir.join("rebase-apply").exists()
1116    }
1117
1118    /// Abort an in-progress rebase
1119    pub fn abort_rebase(&self) -> Result<()> {
1120        info!("Aborting rebase operation");
1121
1122        let git_dir = self.git_repo.path().join(".git");
1123
1124        // Clean up rebase state files
1125        if git_dir.join("REBASE_HEAD").exists() {
1126            std::fs::remove_file(git_dir.join("REBASE_HEAD")).map_err(|e| {
1127                CascadeError::Git(git2::Error::from_str(&format!(
1128                    "Failed to clean rebase state: {e}"
1129                )))
1130            })?;
1131        }
1132
1133        if git_dir.join("rebase-merge").exists() {
1134            std::fs::remove_dir_all(git_dir.join("rebase-merge")).map_err(|e| {
1135                CascadeError::Git(git2::Error::from_str(&format!(
1136                    "Failed to clean rebase-merge: {e}"
1137                )))
1138            })?;
1139        }
1140
1141        if git_dir.join("rebase-apply").exists() {
1142            std::fs::remove_dir_all(git_dir.join("rebase-apply")).map_err(|e| {
1143                CascadeError::Git(git2::Error::from_str(&format!(
1144                    "Failed to clean rebase-apply: {e}"
1145                )))
1146            })?;
1147        }
1148
1149        info!("Rebase aborted successfully");
1150        Ok(())
1151    }
1152
1153    /// Continue an in-progress rebase after conflict resolution
1154    pub fn continue_rebase(&self) -> Result<()> {
1155        info!("Continuing rebase operation");
1156
1157        // Check if there are still conflicts
1158        if self.git_repo.has_conflicts()? {
1159            return Err(CascadeError::branch(
1160                "Cannot continue rebase: there are unresolved conflicts. Resolve conflicts and stage files first.".to_string()
1161            ));
1162        }
1163
1164        // Stage resolved files
1165        self.git_repo.stage_conflict_resolved_files()?;
1166
1167        info!("Rebase continued successfully");
1168        Ok(())
1169    }
1170}
1171
1172impl RebaseResult {
1173    /// Get a summary of the rebase operation
1174    pub fn get_summary(&self) -> String {
1175        if self.success {
1176            format!("โœ… {}", self.summary)
1177        } else {
1178            format!(
1179                "โŒ Rebase failed: {}",
1180                self.error.as_deref().unwrap_or("Unknown error")
1181            )
1182        }
1183    }
1184
1185    /// Check if any conflicts occurred
1186    pub fn has_conflicts(&self) -> bool {
1187        !self.conflicts.is_empty()
1188    }
1189
1190    /// Get the number of successful operations
1191    pub fn success_count(&self) -> usize {
1192        self.new_commits.len()
1193    }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use std::path::PathBuf;
1200    use std::process::Command;
1201    use tempfile::TempDir;
1202
1203    #[allow(dead_code)]
1204    fn create_test_repo() -> (TempDir, PathBuf) {
1205        let temp_dir = TempDir::new().unwrap();
1206        let repo_path = temp_dir.path().to_path_buf();
1207
1208        // Initialize git repository
1209        Command::new("git")
1210            .args(["init"])
1211            .current_dir(&repo_path)
1212            .output()
1213            .unwrap();
1214        Command::new("git")
1215            .args(["config", "user.name", "Test"])
1216            .current_dir(&repo_path)
1217            .output()
1218            .unwrap();
1219        Command::new("git")
1220            .args(["config", "user.email", "test@test.com"])
1221            .current_dir(&repo_path)
1222            .output()
1223            .unwrap();
1224
1225        // Create initial commit
1226        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1227        Command::new("git")
1228            .args(["add", "."])
1229            .current_dir(&repo_path)
1230            .output()
1231            .unwrap();
1232        Command::new("git")
1233            .args(["commit", "-m", "Initial"])
1234            .current_dir(&repo_path)
1235            .output()
1236            .unwrap();
1237
1238        (temp_dir, repo_path)
1239    }
1240
1241    #[test]
1242    fn test_conflict_region_creation() {
1243        let region = ConflictRegion {
1244            start: 0,
1245            end: 50,
1246            start_line: 1,
1247            end_line: 3,
1248            our_content: "function test() {\n    return true;\n}".to_string(),
1249            their_content: "function test() {\n  return true;\n}".to_string(),
1250        };
1251
1252        assert_eq!(region.start_line, 1);
1253        assert_eq!(region.end_line, 3);
1254        assert!(region.our_content.contains("return true"));
1255        assert!(region.their_content.contains("return true"));
1256    }
1257
1258    #[test]
1259    fn test_rebase_strategies() {
1260        assert_eq!(RebaseStrategy::ForcePush, RebaseStrategy::ForcePush);
1261        assert_eq!(RebaseStrategy::Interactive, RebaseStrategy::Interactive);
1262    }
1263
1264    #[test]
1265    fn test_rebase_options() {
1266        let options = RebaseOptions::default();
1267        assert_eq!(options.strategy, RebaseStrategy::ForcePush);
1268        assert!(!options.interactive);
1269        assert!(options.auto_resolve);
1270        assert_eq!(options.max_retries, 3);
1271    }
1272
1273    #[test]
1274    fn test_cleanup_guard_tracks_branches() {
1275        let mut guard = TempBranchCleanupGuard::new();
1276        assert!(guard.branches.is_empty());
1277
1278        guard.add_branch("test-branch-1".to_string());
1279        guard.add_branch("test-branch-2".to_string());
1280
1281        assert_eq!(guard.branches.len(), 2);
1282        assert_eq!(guard.branches[0], "test-branch-1");
1283        assert_eq!(guard.branches[1], "test-branch-2");
1284    }
1285
1286    #[test]
1287    fn test_cleanup_guard_prevents_double_cleanup() {
1288        use std::process::Command;
1289        use tempfile::TempDir;
1290
1291        // Create a temporary git repo
1292        let temp_dir = TempDir::new().unwrap();
1293        let repo_path = temp_dir.path();
1294
1295        Command::new("git")
1296            .args(["init"])
1297            .current_dir(repo_path)
1298            .output()
1299            .unwrap();
1300
1301        Command::new("git")
1302            .args(["config", "user.name", "Test"])
1303            .current_dir(repo_path)
1304            .output()
1305            .unwrap();
1306
1307        Command::new("git")
1308            .args(["config", "user.email", "test@test.com"])
1309            .current_dir(repo_path)
1310            .output()
1311            .unwrap();
1312
1313        // Create initial commit
1314        std::fs::write(repo_path.join("test.txt"), "test").unwrap();
1315        Command::new("git")
1316            .args(["add", "."])
1317            .current_dir(repo_path)
1318            .output()
1319            .unwrap();
1320        Command::new("git")
1321            .args(["commit", "-m", "initial"])
1322            .current_dir(repo_path)
1323            .output()
1324            .unwrap();
1325
1326        let git_repo = GitRepository::open(repo_path).unwrap();
1327
1328        // Create a test branch
1329        git_repo.create_branch("test-temp", None).unwrap();
1330
1331        let mut guard = TempBranchCleanupGuard::new();
1332        guard.add_branch("test-temp".to_string());
1333
1334        // First cleanup should work
1335        guard.cleanup(&git_repo);
1336        assert!(guard.cleaned);
1337
1338        // Second cleanup should be a no-op (shouldn't panic)
1339        guard.cleanup(&git_repo);
1340        assert!(guard.cleaned);
1341    }
1342
1343    #[test]
1344    fn test_rebase_result() {
1345        let result = RebaseResult {
1346            success: true,
1347            branch_mapping: std::collections::HashMap::new(),
1348            conflicts: vec!["abc123".to_string()],
1349            new_commits: vec!["def456".to_string()],
1350            error: None,
1351            summary: "Test summary".to_string(),
1352        };
1353
1354        assert!(result.success);
1355        assert!(result.has_conflicts());
1356        assert_eq!(result.success_count(), 1);
1357    }
1358
1359    #[test]
1360    fn test_import_line_detection() {
1361        let (_temp_dir, repo_path) = create_test_repo();
1362        let git_repo = crate::git::GitRepository::open(&repo_path).unwrap();
1363        let stack_manager = crate::stack::StackManager::new(&repo_path).unwrap();
1364        let options = RebaseOptions::default();
1365        let rebase_manager = RebaseManager::new(stack_manager, git_repo, options);
1366
1367        // Test Swift import detection
1368        assert!(rebase_manager.is_import_line("import Foundation", "test.swift"));
1369        assert!(rebase_manager.is_import_line("@testable import MyModule", "test.swift"));
1370        assert!(!rebase_manager.is_import_line("class MyClass {", "test.swift"));
1371
1372        // Test Kotlin import detection
1373        assert!(rebase_manager.is_import_line("import kotlin.collections.*", "test.kt"));
1374        assert!(rebase_manager.is_import_line("@file:JvmName(\"Utils\")", "test.kt"));
1375        assert!(!rebase_manager.is_import_line("fun myFunction() {", "test.kt"));
1376
1377        // Test C# import detection
1378        assert!(rebase_manager.is_import_line("using System;", "test.cs"));
1379        assert!(rebase_manager.is_import_line("using System.Collections.Generic;", "test.cs"));
1380        assert!(rebase_manager.is_import_line("extern alias GridV1;", "test.cs"));
1381        assert!(!rebase_manager.is_import_line("namespace MyNamespace {", "test.cs"));
1382
1383        // Test empty lines are allowed in import sections
1384        assert!(rebase_manager.is_import_line("", "test.swift"));
1385        assert!(rebase_manager.is_import_line("   ", "test.kt"));
1386        assert!(rebase_manager.is_import_line("", "test.cs"));
1387    }
1388}