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