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