Skip to main content

ralph_workflow/git_helpers/
rebase.rs

1//! Git rebase operations using libgit2 with Git CLI fallback.
2//!
3//! This module provides functionality to:
4//! - Perform rebase operations onto a specified upstream branch
5//! - Detect and report conflicts during rebase
6//! - Abort an in-progress rebase
7//! - Continue a rebase after conflict resolution
8//! - Get lists of conflicted files
9//! - Handle all rebase failure modes with fault tolerance
10//!
11//! # Architecture
12//!
13//! This module uses a hybrid approach:
14//! - **libgit2**: For repository state detection, validation, and queries
15//! - **Git CLI**: For the actual rebase operation (more reliable)
16//! - **Fallback patterns**: For operations that may fail with libgit2
17//!
18//! The Git CLI is used for rebase operations because:
19//! 1. Better error messages for classification
20//! 2. More robust edge case handling
21//! 3. Better tested across Git versions
22//! 4. Supports autostash and other features reliably
23
24#![deny(unsafe_code)]
25
26/// Git directory name for rebase-apply state (for `git am`-style rebases).
27///
28/// Used by `detect_concurrent_git_operations` and `cleanup_stale_rebase_state`
29/// functions which are only available with the test-utils feature.
30#[cfg(any(test, feature = "test-utils"))]
31const REBASE_APPLY_DIR: &str = "rebase-apply";
32
33/// Git directory name for rebase-merge state (for interactive rebases).
34///
35/// Used by `detect_concurrent_git_operations` and `cleanup_stale_rebase_state`
36/// functions which are only available with the test-utils feature.
37#[cfg(any(test, feature = "test-utils"))]
38const REBASE_MERGE_DIR: &str = "rebase-merge";
39
40use std::io;
41use std::path::Path;
42
43use super::git2_to_io_error;
44
45/// Detailed classification of rebase failure modes.
46///
47/// This enum categorizes all known Git rebase failure modes as documented
48/// in the requirements. Each variant represents a specific category of
49/// failure that may occur during a rebase operation.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum RebaseErrorKind {
52    // Category 1: Rebase Cannot Start
53    /// Invalid or unresolvable revisions (branch doesn't exist, invalid ref, etc.)
54    InvalidRevision { revision: String },
55
56    /// Dirty working tree or index (unstaged or staged changes present)
57    DirtyWorkingTree,
58
59    /// Concurrent or in-progress Git operations (rebase, merge, cherry-pick, etc.)
60    ConcurrentOperation { operation: String },
61
62    /// Repository integrity or storage failures (missing/corrupt objects, disk full, etc.)
63    RepositoryCorrupt { details: String },
64
65    /// Environment or configuration failures (missing user.name, editor unavailable, etc.)
66    EnvironmentFailure { reason: String },
67
68    /// Hook-triggered aborts (pre-rebase hook rejected the operation)
69    HookRejection { hook_name: String },
70
71    // Category 2: Rebase Stops (Interrupted)
72    /// Content conflicts (textual merge conflicts, add/add, modify/delete, etc.)
73    ContentConflict { files: Vec<String> },
74
75    /// Patch application failures (patch does not apply, context mismatch, etc.)
76    PatchApplicationFailed { reason: String },
77
78    /// Interactive todo-driven stops (edit, reword, break, exec commands)
79    InteractiveStop { command: String },
80
81    /// Empty or redundant commits (patch results in no changes)
82    EmptyCommit,
83
84    /// Autostash and stash reapplication failures
85    AutostashFailed { reason: String },
86
87    /// Commit creation failures mid-rebase (hook failures, signing failures, etc.)
88    CommitCreationFailed { reason: String },
89
90    /// Reference update failures (cannot lock branch ref, concurrent ref update, etc.)
91    ReferenceUpdateFailed { reason: String },
92
93    // Category 3: Post-Rebase Failures
94    /// Post-rebase validation failures (tests failing, build failures, etc.)
95    #[cfg(any(test, feature = "test-utils"))]
96    ValidationFailed { reason: String },
97
98    // Category 4: Interrupted/Corrupted State
99    /// Process termination (agent crash, OS kill signal, CI timeout, etc.)
100    #[cfg(any(test, feature = "test-utils"))]
101    ProcessTerminated { reason: String },
102
103    /// Incomplete or inconsistent rebase metadata
104    #[cfg(any(test, feature = "test-utils"))]
105    InconsistentState { details: String },
106
107    // Category 5: Unknown
108    /// Undefined or unknown failure modes
109    Unknown { details: String },
110}
111
112impl RebaseErrorKind {
113    /// Returns a human-readable description of the error.
114    pub fn description(&self) -> String {
115        match self {
116            RebaseErrorKind::InvalidRevision { revision } => {
117                format!("Invalid or unresolvable revision: '{revision}'")
118            }
119            RebaseErrorKind::DirtyWorkingTree => "Working tree has uncommitted changes".to_string(),
120            RebaseErrorKind::ConcurrentOperation { operation } => {
121                format!("Concurrent Git operation in progress: {operation}")
122            }
123            RebaseErrorKind::RepositoryCorrupt { details } => {
124                format!("Repository integrity issue: {details}")
125            }
126            RebaseErrorKind::EnvironmentFailure { reason } => {
127                format!("Environment or configuration failure: {reason}")
128            }
129            RebaseErrorKind::HookRejection { hook_name } => {
130                format!("Hook '{hook_name}' rejected the operation")
131            }
132            RebaseErrorKind::ContentConflict { files } => {
133                format!("Merge conflicts in {} file(s)", files.len())
134            }
135            RebaseErrorKind::PatchApplicationFailed { reason } => {
136                format!("Patch application failed: {reason}")
137            }
138            RebaseErrorKind::InteractiveStop { command } => {
139                format!("Interactive rebase stopped at command: {command}")
140            }
141            RebaseErrorKind::EmptyCommit => "Empty or redundant commit".to_string(),
142            RebaseErrorKind::AutostashFailed { reason } => {
143                format!("Autostash failed: {reason}")
144            }
145            RebaseErrorKind::CommitCreationFailed { reason } => {
146                format!("Commit creation failed: {reason}")
147            }
148            RebaseErrorKind::ReferenceUpdateFailed { reason } => {
149                format!("Reference update failed: {reason}")
150            }
151            #[cfg(any(test, feature = "test-utils"))]
152            RebaseErrorKind::ValidationFailed { reason } => {
153                format!("Post-rebase validation failed: {reason}")
154            }
155            #[cfg(any(test, feature = "test-utils"))]
156            RebaseErrorKind::ProcessTerminated { reason } => {
157                format!("Rebase process terminated: {reason}")
158            }
159            #[cfg(any(test, feature = "test-utils"))]
160            RebaseErrorKind::InconsistentState { details } => {
161                format!("Inconsistent rebase state: {details}")
162            }
163            RebaseErrorKind::Unknown { details } => {
164                format!("Unknown rebase error: {details}")
165            }
166        }
167    }
168
169    /// Returns whether this error can potentially be recovered automatically.
170    #[cfg(any(test, feature = "test-utils"))]
171    pub fn is_recoverable(&self) -> bool {
172        match self {
173            // These are generally recoverable with automatic retry or cleanup
174            RebaseErrorKind::ConcurrentOperation { .. } => true,
175            #[cfg(any(test, feature = "test-utils"))]
176            RebaseErrorKind::ProcessTerminated { .. }
177            | RebaseErrorKind::InconsistentState { .. } => true,
178
179            // These require manual conflict resolution
180            RebaseErrorKind::ContentConflict { .. } => true,
181
182            // These are generally not recoverable without manual intervention
183            RebaseErrorKind::InvalidRevision { .. }
184            | RebaseErrorKind::DirtyWorkingTree
185            | RebaseErrorKind::RepositoryCorrupt { .. }
186            | RebaseErrorKind::EnvironmentFailure { .. }
187            | RebaseErrorKind::HookRejection { .. }
188            | RebaseErrorKind::PatchApplicationFailed { .. }
189            | RebaseErrorKind::InteractiveStop { .. }
190            | RebaseErrorKind::EmptyCommit
191            | RebaseErrorKind::AutostashFailed { .. }
192            | RebaseErrorKind::CommitCreationFailed { .. }
193            | RebaseErrorKind::ReferenceUpdateFailed { .. } => false,
194            #[cfg(any(test, feature = "test-utils"))]
195            RebaseErrorKind::ValidationFailed { .. } => false,
196            RebaseErrorKind::Unknown { .. } => false,
197        }
198    }
199
200    /// Returns the category number (1-5) for this error.
201    #[cfg(any(test, feature = "test-utils"))]
202    pub fn category(&self) -> u8 {
203        match self {
204            RebaseErrorKind::InvalidRevision { .. }
205            | RebaseErrorKind::DirtyWorkingTree
206            | RebaseErrorKind::ConcurrentOperation { .. }
207            | RebaseErrorKind::RepositoryCorrupt { .. }
208            | RebaseErrorKind::EnvironmentFailure { .. }
209            | RebaseErrorKind::HookRejection { .. } => 1,
210
211            RebaseErrorKind::ContentConflict { .. }
212            | RebaseErrorKind::PatchApplicationFailed { .. }
213            | RebaseErrorKind::InteractiveStop { .. }
214            | RebaseErrorKind::EmptyCommit
215            | RebaseErrorKind::AutostashFailed { .. }
216            | RebaseErrorKind::CommitCreationFailed { .. }
217            | RebaseErrorKind::ReferenceUpdateFailed { .. } => 2,
218
219            #[cfg(any(test, feature = "test-utils"))]
220            RebaseErrorKind::ValidationFailed { .. } => 3,
221
222            #[cfg(any(test, feature = "test-utils"))]
223            RebaseErrorKind::ProcessTerminated { .. }
224            | RebaseErrorKind::InconsistentState { .. } => 4,
225
226            RebaseErrorKind::Unknown { .. } => 5,
227        }
228    }
229}
230
231impl std::fmt::Display for RebaseErrorKind {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        write!(f, "{}", self.description())
234    }
235}
236
237impl std::error::Error for RebaseErrorKind {}
238
239/// Result of a rebase operation.
240///
241/// This enum represents the possible outcomes of a rebase operation,
242/// including success, conflicts (recoverable), no-op (not applicable),
243/// and specific failure modes.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum RebaseResult {
246    /// Rebase completed successfully.
247    Success,
248
249    /// Rebase had conflicts that need resolution.
250    Conflicts(Vec<String>),
251
252    /// No rebase was needed (already up-to-date, not applicable, etc.).
253    NoOp { reason: String },
254
255    /// Rebase failed with a specific error kind.
256    Failed(RebaseErrorKind),
257}
258
259impl RebaseResult {
260    /// Returns whether the rebase was successful.
261    #[cfg(any(test, feature = "test-utils"))]
262    pub fn is_success(&self) -> bool {
263        matches!(self, RebaseResult::Success)
264    }
265
266    /// Returns whether the rebase had conflicts (needs resolution).
267    #[cfg(any(test, feature = "test-utils"))]
268    pub fn has_conflicts(&self) -> bool {
269        matches!(self, RebaseResult::Conflicts(_))
270    }
271
272    /// Returns whether the rebase was a no-op (not applicable).
273    #[cfg(any(test, feature = "test-utils"))]
274    pub fn is_noop(&self) -> bool {
275        matches!(self, RebaseResult::NoOp { .. })
276    }
277
278    /// Returns whether the rebase failed.
279    #[cfg(any(test, feature = "test-utils"))]
280    pub fn is_failed(&self) -> bool {
281        matches!(self, RebaseResult::Failed(_))
282    }
283
284    /// Returns the conflict files if this result contains conflicts.
285    #[cfg(any(test, feature = "test-utils"))]
286    pub fn conflict_files(&self) -> Option<&[String]> {
287        match self {
288            RebaseResult::Conflicts(files) => Some(files),
289            RebaseResult::Failed(RebaseErrorKind::ContentConflict { files }) => Some(files),
290            _ => None,
291        }
292    }
293
294    /// Returns the error kind if this result is a failure.
295    #[cfg(any(test, feature = "test-utils"))]
296    pub fn error_kind(&self) -> Option<&RebaseErrorKind> {
297        match self {
298            RebaseResult::Failed(kind) => Some(kind),
299            _ => None,
300        }
301    }
302
303    /// Returns the no-op reason if this result is a no-op.
304    #[cfg(any(test, feature = "test-utils"))]
305    pub fn noop_reason(&self) -> Option<&str> {
306        match self {
307            RebaseResult::NoOp { reason } => Some(reason),
308            _ => None,
309        }
310    }
311}
312
313/// Parse Git CLI output to classify rebase errors.
314///
315/// This function analyzes stderr/stdout from git rebase commands
316/// to determine the specific failure mode.
317pub fn classify_rebase_error(stderr: &str, stdout: &str) -> RebaseErrorKind {
318    let combined = format!("{stderr}\n{stdout}");
319
320    // Category 1: Rebase Cannot Start
321
322    // Invalid revision
323    if combined.contains("invalid revision")
324        || combined.contains("unknown revision")
325        || combined.contains("bad revision")
326        || combined.contains("ambiguous revision")
327        || combined.contains("not found")
328        || combined.contains("does not exist")
329        || combined.contains("bad revision")
330        || combined.contains("no such ref")
331    {
332        // Try to extract the revision name
333        let revision = extract_revision(&combined);
334        return RebaseErrorKind::InvalidRevision {
335            revision: revision.unwrap_or_else(|| "unknown".to_string()),
336        };
337    }
338
339    // Shallow clone (missing history)
340    if combined.contains("shallow")
341        || combined.contains("depth")
342        || combined.contains("unreachable")
343        || combined.contains("needed single revision")
344        || combined.contains("does not have")
345    {
346        return RebaseErrorKind::RepositoryCorrupt {
347            details: format!(
348                "Shallow clone or missing history: {}",
349                extract_error_line(&combined)
350            ),
351        };
352    }
353
354    // Worktree conflict
355    if combined.contains("worktree")
356        || combined.contains("checked out")
357        || combined.contains("another branch")
358        || combined.contains("already checked out")
359    {
360        return RebaseErrorKind::ConcurrentOperation {
361            operation: "branch checked out in another worktree".to_string(),
362        };
363    }
364
365    // Submodule conflict
366    if combined.contains("submodule") || combined.contains(".gitmodules") {
367        return RebaseErrorKind::ContentConflict {
368            files: extract_conflict_files(&combined),
369        };
370    }
371
372    // Dirty working tree
373    if combined.contains("dirty")
374        || combined.contains("uncommitted changes")
375        || combined.contains("local changes")
376        || combined.contains("cannot rebase")
377    {
378        return RebaseErrorKind::DirtyWorkingTree;
379    }
380
381    // Concurrent operation
382    if combined.contains("rebase in progress")
383        || combined.contains("merge in progress")
384        || combined.contains("cherry-pick in progress")
385        || combined.contains("revert in progress")
386        || combined.contains("bisect in progress")
387        || combined.contains("Another git process")
388        || combined.contains("Locked")
389    {
390        let operation = extract_operation(&combined);
391        return RebaseErrorKind::ConcurrentOperation {
392            operation: operation.unwrap_or_else(|| "unknown".to_string()),
393        };
394    }
395
396    // Repository corruption
397    if combined.contains("corrupt")
398        || combined.contains("object not found")
399        || combined.contains("missing object")
400        || combined.contains("invalid object")
401        || combined.contains("bad object")
402        || combined.contains("disk full")
403        || combined.contains("filesystem")
404    {
405        return RebaseErrorKind::RepositoryCorrupt {
406            details: extract_error_line(&combined),
407        };
408    }
409
410    // Environment failure
411    if combined.contains("user.name")
412        || combined.contains("user.email")
413        || combined.contains("author")
414        || combined.contains("committer")
415        || combined.contains("terminal")
416        || combined.contains("editor")
417    {
418        return RebaseErrorKind::EnvironmentFailure {
419            reason: extract_error_line(&combined),
420        };
421    }
422
423    // Hook rejection
424    if combined.contains("pre-rebase")
425        || combined.contains("hook")
426        || combined.contains("rejected by")
427    {
428        return RebaseErrorKind::HookRejection {
429            hook_name: extract_hook_name(&combined),
430        };
431    }
432
433    // Category 2: Rebase Stops (Interrupted)
434
435    // Content conflicts
436    if combined.contains("Conflict")
437        || combined.contains("conflict")
438        || combined.contains("Resolve")
439        || combined.contains("Merge conflict")
440    {
441        return RebaseErrorKind::ContentConflict {
442            files: extract_conflict_files(&combined),
443        };
444    }
445
446    // Patch application failure
447    if combined.contains("patch does not apply")
448        || combined.contains("patch failed")
449        || combined.contains("hunk failed")
450        || combined.contains("context mismatch")
451        || combined.contains("fuzz")
452    {
453        return RebaseErrorKind::PatchApplicationFailed {
454            reason: extract_error_line(&combined),
455        };
456    }
457
458    // Interactive stop
459    if combined.contains("Stopped at")
460        || combined.contains("paused")
461        || combined.contains("edit command")
462    {
463        return RebaseErrorKind::InteractiveStop {
464            command: extract_command(&combined),
465        };
466    }
467
468    // Empty commit
469    if combined.contains("empty")
470        || combined.contains("no changes")
471        || combined.contains("already applied")
472    {
473        return RebaseErrorKind::EmptyCommit;
474    }
475
476    // Autostash failure
477    if combined.contains("autostash") || combined.contains("stash") {
478        return RebaseErrorKind::AutostashFailed {
479            reason: extract_error_line(&combined),
480        };
481    }
482
483    // Commit creation failure
484    if combined.contains("pre-commit")
485        || combined.contains("commit-msg")
486        || combined.contains("prepare-commit-msg")
487        || combined.contains("post-commit")
488        || combined.contains("signing")
489        || combined.contains("GPG")
490    {
491        return RebaseErrorKind::CommitCreationFailed {
492            reason: extract_error_line(&combined),
493        };
494    }
495
496    // Reference update failure
497    if combined.contains("cannot lock")
498        || combined.contains("ref update")
499        || combined.contains("packed-refs")
500        || combined.contains("reflog")
501    {
502        return RebaseErrorKind::ReferenceUpdateFailed {
503            reason: extract_error_line(&combined),
504        };
505    }
506
507    // Category 5: Unknown
508    RebaseErrorKind::Unknown {
509        details: extract_error_line(&combined),
510    }
511}
512
513/// Extract revision name from error output.
514fn extract_revision(output: &str) -> Option<String> {
515    // Look for patterns like "invalid revision 'foo'" or "unknown revision 'bar'"
516    // Using simple string parsing instead of regex for reliability
517    let patterns = [
518        ("invalid revision '", "'"),
519        ("unknown revision '", "'"),
520        ("bad revision '", "'"),
521        ("branch '", "' not found"),
522        ("upstream branch '", "' not found"),
523        ("revision ", " not found"),
524        ("'", "'"),
525    ];
526
527    for (start, end) in patterns {
528        if let Some(start_idx) = output.find(start) {
529            let after_start = &output[start_idx + start.len()..];
530            if let Some(end_idx) = after_start.find(end) {
531                let revision = &after_start[..end_idx];
532                if !revision.is_empty() {
533                    return Some(revision.to_string());
534                }
535            }
536        }
537    }
538
539    // Also try to extract branch names from error messages
540    for line in output.lines() {
541        if line.contains("not found") || line.contains("does not exist") {
542            // Extract potential branch/revision name
543            let words: Vec<&str> = line.split_whitespace().collect();
544            for (i, word) in words.iter().enumerate() {
545                if *word == "'"
546                    || *word == "\""
547                        && i + 2 < words.len()
548                        && (words[i + 2] == "'" || words[i + 2] == "\"")
549                {
550                    return Some(words[i + 1].to_string());
551                }
552            }
553        }
554    }
555
556    None
557}
558
559/// Extract operation name from error output.
560fn extract_operation(output: &str) -> Option<String> {
561    if output.contains("rebase in progress") {
562        Some("rebase".to_string())
563    } else if output.contains("merge in progress") {
564        Some("merge".to_string())
565    } else if output.contains("cherry-pick in progress") {
566        Some("cherry-pick".to_string())
567    } else if output.contains("revert in progress") {
568        Some("revert".to_string())
569    } else if output.contains("bisect in progress") {
570        Some("bisect".to_string())
571    } else {
572        None
573    }
574}
575
576/// Extract hook name from error output.
577fn extract_hook_name(output: &str) -> String {
578    if output.contains("pre-rebase") {
579        "pre-rebase".to_string()
580    } else if output.contains("pre-commit") {
581        "pre-commit".to_string()
582    } else if output.contains("commit-msg") {
583        "commit-msg".to_string()
584    } else if output.contains("post-commit") {
585        "post-commit".to_string()
586    } else {
587        "hook".to_string()
588    }
589}
590
591/// Extract command name from error output.
592fn extract_command(output: &str) -> String {
593    if output.contains("edit") {
594        "edit".to_string()
595    } else if output.contains("reword") {
596        "reword".to_string()
597    } else if output.contains("break") {
598        "break".to_string()
599    } else if output.contains("exec") {
600        "exec".to_string()
601    } else {
602        "unknown".to_string()
603    }
604}
605
606/// Extract the first meaningful error line from output.
607fn extract_error_line(output: &str) -> String {
608    output
609        .lines()
610        .find(|line| {
611            !line.is_empty()
612                && !line.starts_with("hint:")
613                && !line.starts_with("Hint:")
614                && !line.starts_with("note:")
615                && !line.starts_with("Note:")
616        })
617        .map(|s| s.trim().to_string())
618        .unwrap_or_else(|| output.trim().to_string())
619}
620
621/// Extract conflict file paths from error output.
622fn extract_conflict_files(output: &str) -> Vec<String> {
623    let mut files = Vec::new();
624
625    for line in output.lines() {
626        if line.contains("CONFLICT") || line.contains("Conflict") || line.contains("Merge conflict")
627        {
628            // Extract file path from patterns like:
629            // "CONFLICT (content): Merge conflict in src/file.rs"
630            // "Merge conflict in src/file.rs"
631            if let Some(start) = line.find("in ") {
632                let path = line[start + 3..].trim();
633                if !path.is_empty() {
634                    files.push(path.to_string());
635                }
636            }
637        }
638    }
639
640    files
641}
642
643/// Type of concurrent Git operation detected.
644///
645/// Represents the various Git operations that may be in progress
646/// and would block a rebase from starting.
647#[derive(Debug, Clone, PartialEq, Eq)]
648#[cfg(any(test, feature = "test-utils"))]
649pub enum ConcurrentOperation {
650    /// A rebase is already in progress.
651    Rebase,
652    /// A merge is in progress.
653    Merge,
654    /// A cherry-pick is in progress.
655    CherryPick,
656    /// A revert is in progress.
657    Revert,
658    /// A bisect is in progress.
659    Bisect,
660    /// Another Git process is holding locks.
661    OtherGitProcess,
662    /// Unknown concurrent operation.
663    Unknown(String),
664}
665
666#[cfg(any(test, feature = "test-utils"))]
667impl ConcurrentOperation {
668    /// Returns a human-readable description of the operation.
669    pub fn description(&self) -> String {
670        match self {
671            ConcurrentOperation::Rebase => "rebase".to_string(),
672            ConcurrentOperation::Merge => "merge".to_string(),
673            ConcurrentOperation::CherryPick => "cherry-pick".to_string(),
674            ConcurrentOperation::Revert => "revert".to_string(),
675            ConcurrentOperation::Bisect => "bisect".to_string(),
676            ConcurrentOperation::OtherGitProcess => "another Git process".to_string(),
677            ConcurrentOperation::Unknown(s) => format!("unknown operation: {s}"),
678        }
679    }
680}
681
682/// Detect concurrent Git operations that would block a rebase.
683///
684/// This function performs a comprehensive check for any in-progress Git
685/// operations that would prevent a rebase from starting. It checks for:
686/// - Rebase in progress (`.git/rebase-apply` or `.git/rebase-merge`)
687/// - Merge in progress (`.git/MERGE_HEAD`)
688/// - Cherry-pick in progress (`.git/CHERRY_PICK_HEAD`)
689/// - Revert in progress (`.git/REVERT_HEAD`)
690/// - Bisect in progress (`.git/BISECT_*`)
691/// - Lock files held by other processes
692///
693/// # Returns
694///
695/// Returns `Ok(None)` if no concurrent operations are detected,
696/// or `Ok(Some(operation))` with the type of operation detected.
697/// Returns an error if unable to check the repository state.
698///
699/// # Example
700///
701/// ```no_run
702/// use ralph_workflow::git_helpers::rebase::detect_concurrent_git_operations;
703///
704/// match detect_concurrent_git_operations() {
705///     Ok(None) => println!("No concurrent operations detected"),
706///     Ok(Some(op)) => println!("Concurrent operation detected: {}", op.description()),
707///     Err(e) => eprintln!("Error checking: {e}"),
708/// }
709/// ```
710#[cfg(any(test, feature = "test-utils"))]
711pub fn detect_concurrent_git_operations() -> io::Result<Option<ConcurrentOperation>> {
712    use std::fs;
713
714    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
715    let git_dir = repo.path();
716
717    // Check for rebase in progress (multiple possible state directories)
718    let rebase_merge = git_dir.join(REBASE_MERGE_DIR);
719    let rebase_apply = git_dir.join(REBASE_APPLY_DIR);
720    if rebase_merge.exists() || rebase_apply.exists() {
721        return Ok(Some(ConcurrentOperation::Rebase));
722    }
723
724    // Check for merge in progress
725    let merge_head = git_dir.join("MERGE_HEAD");
726    if merge_head.exists() {
727        return Ok(Some(ConcurrentOperation::Merge));
728    }
729
730    // Check for cherry-pick in progress
731    let cherry_pick_head = git_dir.join("CHERRY_PICK_HEAD");
732    if cherry_pick_head.exists() {
733        return Ok(Some(ConcurrentOperation::CherryPick));
734    }
735
736    // Check for revert in progress
737    let revert_head = git_dir.join("REVERT_HEAD");
738    if revert_head.exists() {
739        return Ok(Some(ConcurrentOperation::Revert));
740    }
741
742    // Check for bisect in progress (multiple possible state files)
743    let bisect_log = git_dir.join("BISECT_LOG");
744    let bisect_start = git_dir.join("BISECT_START");
745    let bisect_names = git_dir.join("BISECT_NAMES");
746    if bisect_log.exists() || bisect_start.exists() || bisect_names.exists() {
747        return Ok(Some(ConcurrentOperation::Bisect));
748    }
749
750    // Check for lock files that might indicate concurrent operations
751    let index_lock = git_dir.join("index.lock");
752    let packed_refs_lock = git_dir.join("packed-refs.lock");
753    let head_lock = git_dir.join("HEAD.lock");
754    if index_lock.exists() || packed_refs_lock.exists() || head_lock.exists() {
755        // Lock files might be stale, so we'll report as "other Git process"
756        // The caller can decide whether to wait or clean up
757        return Ok(Some(ConcurrentOperation::OtherGitProcess));
758    }
759
760    // Check for any other state files we might have missed
761    if let Ok(entries) = fs::read_dir(git_dir) {
762        for entry in entries.flatten() {
763            let name = entry.file_name();
764            let name_str = name.to_string_lossy();
765            // Look for other state patterns
766            if name_str.contains("REBASE")
767                || name_str.contains("MERGE")
768                || name_str.contains("CHERRY")
769            {
770                return Ok(Some(ConcurrentOperation::Unknown(name_str.to_string())));
771            }
772        }
773    }
774
775    Ok(None)
776}
777
778/// Check if a rebase is currently in progress using Git CLI.
779///
780/// This is a fallback function that uses Git CLI to detect rebase state
781/// when libgit2 may not accurately report it.
782///
783/// # Returns
784///
785/// Returns `Ok(true)` if a rebase is in progress, `Ok(false)` otherwise.
786#[cfg(any(test, feature = "test-utils"))]
787pub fn rebase_in_progress_cli(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<bool> {
788    let output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
789    Ok(output.stdout.contains("rebasing"))
790}
791
792/// Result of cleaning up stale rebase state.
793///
794/// Provides information about what was cleaned up during the operation.
795#[derive(Debug, Clone, Default)]
796#[cfg(any(test, feature = "test-utils"))]
797pub struct CleanupResult {
798    /// List of state files that were cleaned up
799    pub cleaned_paths: Vec<String>,
800    /// Whether any lock files were removed
801    pub locks_removed: bool,
802}
803
804#[cfg(any(test, feature = "test-utils"))]
805impl CleanupResult {
806    /// Returns true if any cleanup was performed.
807    pub fn has_cleanup(&self) -> bool {
808        !self.cleaned_paths.is_empty() || self.locks_removed
809    }
810
811    /// Returns the number of items cleaned up.
812    pub fn count(&self) -> usize {
813        self.cleaned_paths.len() + if self.locks_removed { 1 } else { 0 }
814    }
815}
816
817/// Clean up stale rebase state files.
818///
819/// This function attempts to clean up stale rebase state that may be
820/// left over from interrupted operations. It validates state before
821/// removal and reports what was cleaned up.
822///
823/// This is a recovery mechanism for concurrent operation detection and
824/// for cleaning up after interrupted rebase operations.
825///
826/// # Returns
827///
828/// Returns `Ok(CleanupResult)` with details of what was cleaned up,
829/// or an error if cleanup failed catastrophically.
830#[cfg(any(test, feature = "test-utils"))]
831pub fn cleanup_stale_rebase_state() -> io::Result<CleanupResult> {
832    use std::fs;
833
834    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
835    let git_dir = repo.path();
836
837    let mut result = CleanupResult::default();
838
839    // List of possible stale rebase state files/directories
840    let stale_paths = [
841        (REBASE_APPLY_DIR, "rebase-apply directory"),
842        (REBASE_MERGE_DIR, "rebase-merge directory"),
843        ("MERGE_HEAD", "merge state"),
844        ("MERGE_MSG", "merge message"),
845        ("CHERRY_PICK_HEAD", "cherry-pick state"),
846        ("REVERT_HEAD", "revert state"),
847        ("COMMIT_EDITMSG", "commit message"),
848    ];
849
850    for (path_name, description) in &stale_paths {
851        let full_path = git_dir.join(path_name);
852        if full_path.exists() {
853            // Try to validate the state before removing
854            let is_valid = validate_state_file(&full_path);
855            if !is_valid.unwrap_or(true) {
856                // State is invalid or corrupted, safe to remove
857                let removed = if full_path.is_dir() {
858                    fs::remove_dir_all(&full_path)
859                        .map(|_| true)
860                        .unwrap_or(false)
861                } else {
862                    fs::remove_file(&full_path).map(|_| true).unwrap_or(false)
863                };
864
865                if removed {
866                    result
867                        .cleaned_paths
868                        .push(format!("{path_name} ({description})"));
869                }
870            }
871        }
872    }
873
874    // Clean up lock files if they exist
875    let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
876    for lock_file in &lock_files {
877        let lock_path = git_dir.join(lock_file);
878        if lock_path.exists() {
879            // Lock files are generally safe to remove if stale
880            if fs::remove_file(&lock_path).is_ok() {
881                result.locks_removed = true;
882                result
883                    .cleaned_paths
884                    .push(format!("{lock_file} (lock file)"));
885            }
886        }
887    }
888
889    Ok(result)
890}
891
892// Validate a Git state file for corruption.
893///
894/// Checks if a state file is valid before attempting to remove it.
895/// This prevents accidental removal of valid in-progress operations.
896///
897/// # Arguments
898///
899/// * `path` - Path to the state file to validate
900///
901/// # Returns
902///
903/// Returns `Ok(true)` if the state appears valid, `Ok(false)` if invalid,
904/// or an error if validation failed.
905#[cfg(any(test, feature = "test-utils"))]
906fn validate_state_file(path: &Path) -> io::Result<bool> {
907    use std::fs;
908
909    if !path.exists() {
910        return Ok(false);
911    }
912
913    // For directories, check if they contain required files
914    if path.is_dir() {
915        // A valid rebase directory should have at least some files
916        let entries = fs::read_dir(path)?;
917        let has_content = entries.count() > 0;
918        return Ok(has_content);
919    }
920
921    // For files, check if they're readable and non-empty
922    if path.is_file() {
923        let metadata = fs::metadata(path)?;
924        if metadata.len() == 0 {
925            // Empty state file is invalid
926            return Ok(false);
927        }
928        // Try to read a small amount to verify file integrity
929        let _ = fs::read(path)?;
930        return Ok(true);
931    }
932
933    Ok(false)
934}
935
936/// Attempt automatic recovery from a rebase failure.
937///
938/// This function implements an escalation strategy for recovering from
939/// rebase failures, trying multiple approaches before giving up:
940///
941/// **Level 1 - Clean state retry**: Reset to clean state and retry
942/// **Level 2 - Lock file removal**: Remove stale lock files
943/// **Level 3 - Abort and restart**: Abort current rebase and restart from checkpoint
944///
945/// # Arguments
946///
947/// * `error_kind` - The error that occurred
948/// * `phase` - The current rebase phase
949/// * `phase_error_count` - Number of errors in the current phase
950///
951/// # Returns
952///
953/// Returns `Ok(true)` if automatic recovery succeeded and operation can continue,
954/// `Ok(false)` if recovery was attempted but operation should still abort,
955/// or an error if recovery itself failed.
956///
957/// # Example
958///
959/// ```no_run
960/// use ralph_workflow::git_helpers::rebase::{attempt_automatic_recovery, RebaseErrorKind};
961/// use ralph_workflow::git_helpers::rebase_checkpoint::RebasePhase;
962///
963/// match attempt_automatic_recovery(&executor, &RebaseErrorKind::Unknown { details: "test".to_string() }, &RebasePhase::ConflictDetected, 2) {
964///     Ok(true) => println!("Recovery succeeded, can continue"),
965///     Ok(false) => println!("Recovery attempted, should abort"),
966///     Err(e) => println!("Recovery failed: {e}"),
967/// }
968/// ```
969#[cfg(any(test, feature = "test-utils"))]
970pub fn attempt_automatic_recovery(
971    executor: &dyn crate::executor::ProcessExecutor,
972    error_kind: &RebaseErrorKind,
973    phase: &crate::git_helpers::rebase_checkpoint::RebasePhase,
974    phase_error_count: u32,
975) -> io::Result<bool> {
976    // Don't attempt recovery for fatal errors
977    match error_kind {
978        RebaseErrorKind::InvalidRevision { .. }
979        | RebaseErrorKind::DirtyWorkingTree
980        | RebaseErrorKind::RepositoryCorrupt { .. }
981        | RebaseErrorKind::EnvironmentFailure { .. }
982        | RebaseErrorKind::HookRejection { .. }
983        | RebaseErrorKind::InteractiveStop { .. }
984        | RebaseErrorKind::Unknown { .. } => {
985            return Ok(false);
986        }
987        _ => {}
988    }
989
990    let max_attempts = phase.max_recovery_attempts();
991    if phase_error_count >= max_attempts {
992        return Ok(false);
993    }
994
995    // Level 1: Try cleaning stale state
996    if cleanup_stale_rebase_state().is_ok() {
997        // If we cleaned something, try to validate the repo is in a good state
998        if validate_git_state().is_ok() {
999            return Ok(true);
1000        }
1001    }
1002
1003    // Level 2: Try removing lock files
1004    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1005    let git_dir = repo.path();
1006    let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
1007    let mut removed_any = false;
1008
1009    for lock_file in &lock_files {
1010        let lock_path = git_dir.join(lock_file);
1011        // Note: std::fs is acceptable here - operating on .git/ internals, not workspace files
1012        if lock_path.exists() && std::fs::remove_file(&lock_path).is_ok() {
1013            removed_any = true;
1014        }
1015    }
1016
1017    if removed_any && validate_git_state().is_ok() {
1018        return Ok(true);
1019    }
1020
1021    // Level 3: For concurrent operations, try to abort the in-progress operation
1022    if let RebaseErrorKind::ConcurrentOperation { .. } = error_kind {
1023        // Try git rebase --abort via executor
1024        let abort_result = executor.execute("git", &["rebase", "--abort"], &[], None);
1025
1026        if abort_result.is_ok() {
1027            // Check if state is now clean
1028            if validate_git_state().is_ok() {
1029                return Ok(true);
1030            }
1031        }
1032    }
1033
1034    // Recovery attempts exhausted or failed
1035    Ok(false)
1036}
1037
1038/// Validate the overall Git repository state for corruption.
1039///
1040/// Performs comprehensive checks on the repository to detect
1041/// corrupted state files, missing objects, or other integrity issues.
1042///
1043/// # Returns
1044///
1045/// Returns `Ok(())` if the repository state appears valid,
1046/// or an error describing the validation failure.
1047#[cfg(any(test, feature = "test-utils"))]
1048pub fn validate_git_state() -> io::Result<()> {
1049    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1050
1051    // Check if the repository head is valid
1052    let _ = repo.head().map_err(|e| {
1053        io::Error::new(
1054            io::ErrorKind::InvalidData,
1055            format!("Repository HEAD is invalid: {e}"),
1056        )
1057    })?;
1058
1059    // Try to access the index to verify it's not corrupted
1060    let _ = repo.index().map_err(|e| {
1061        io::Error::new(
1062            io::ErrorKind::InvalidData,
1063            format!("Repository index is corrupted: {e}"),
1064        )
1065    })?;
1066
1067    // Check for object database integrity by trying to access HEAD
1068    if let Ok(head) = repo.head() {
1069        if let Ok(commit) = head.peel_to_commit() {
1070            // Verify the commit tree is accessible
1071            let _ = commit.tree().map_err(|e| {
1072                io::Error::new(
1073                    io::ErrorKind::InvalidData,
1074                    format!("Object database corruption: {e}"),
1075                )
1076            })?;
1077        }
1078    }
1079
1080    Ok(())
1081}
1082
1083/// Detect dirty working tree using Git CLI.
1084///
1085/// This is a fallback function that uses Git CLI to detect dirty state
1086/// when libgit2 detection may not be sufficient.
1087///
1088/// # Arguments
1089///
1090/// * `executor` - Process executor for dependency injection
1091///
1092/// # Returns
1093///
1094/// Returns `Ok(true)` if the working tree is dirty, `Ok(false)` otherwise.
1095#[cfg(any(test, feature = "test-utils"))]
1096pub fn is_dirty_tree_cli(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<bool> {
1097    let output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
1098
1099    if output.status.success() {
1100        let stdout = output.stdout.trim();
1101        Ok(!stdout.is_empty())
1102    } else {
1103        Err(io::Error::other(format!(
1104            "Failed to check working tree status: {}",
1105            output.stderr
1106        )))
1107    }
1108}
1109
1110/// Validate preconditions before starting a rebase operation.
1111///
1112/// This function performs Category 1 (pre-start) validation checks to ensure
1113/// the repository is in a valid state for rebasing. It checks for common
1114/// issues that would cause a rebase to fail immediately.
1115///
1116/// # Arguments
1117///
1118/// * `executor` - Process executor for dependency injection
1119///
1120/// # Returns
1121///
1122/// Returns `Ok(())` if all preconditions are met, or an error with a
1123/// descriptive message if validation fails.
1124///
1125/// # Validation Checks
1126///
1127/// - Repository integrity (valid HEAD, index, object database)
1128/// - No concurrent Git operations (merge, rebase, cherry-pick, etc.)
1129/// - Git identity is configured (user.name and user.email)
1130/// - Working tree is not dirty (no unstaged or staged changes)
1131/// - Not a shallow clone (shallow clones have limited history)
1132/// - No worktree conflicts (branch not checked out elsewhere)
1133/// - Submodules are initialized and in a valid state
1134/// - Sparse checkout is properly configured (if enabled)
1135///
1136/// # Example
1137///
1138/// ```no_run
1139/// use ralph_workflow::git_helpers::rebase::validate_rebase_preconditions;
1140///
1141/// match validate_rebase_preconditions(&executor) {
1142///     Ok(()) => println!("All preconditions met, safe to rebase"),
1143///     Err(e) => eprintln!("Cannot rebase: {e}"),
1144/// }
1145/// ```
1146#[cfg(any(test, feature = "test-utils"))]
1147pub fn validate_rebase_preconditions(
1148    executor: &dyn crate::executor::ProcessExecutor,
1149) -> io::Result<()> {
1150    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1151
1152    // 1. Check repository integrity
1153    validate_git_state()?;
1154
1155    // 2. Check for concurrent Git operations
1156    if let Some(concurrent_op) = detect_concurrent_git_operations()? {
1157        return Err(io::Error::new(
1158            io::ErrorKind::InvalidInput,
1159            format!(
1160                "Cannot start rebase: {} already in progress. \
1161                 Please complete or abort the current operation first.",
1162                concurrent_op.description()
1163            ),
1164        ));
1165    }
1166
1167    // 3. Check Git identity configuration
1168    let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1169
1170    let user_name = config.get_string("user.name");
1171    let user_email = config.get_string("user.email");
1172
1173    if user_name.is_err() && user_email.is_err() {
1174        return Err(io::Error::new(
1175            io::ErrorKind::InvalidInput,
1176            "Git identity is not configured. Please set user.name and user.email:\n  \
1177             git config --global user.name \"Your Name\"\n  \
1178             git config --global user.email \"you@example.com\"",
1179        ));
1180    }
1181
1182    // 4. Check for dirty working tree using Git CLI via executor
1183    let status_output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
1184
1185    if status_output.status.success() {
1186        let stdout = status_output.stdout.trim();
1187        if !stdout.is_empty() {
1188            return Err(io::Error::new(
1189                io::ErrorKind::InvalidInput,
1190                "Working tree is not clean. Please commit or stash changes before rebasing.",
1191            ));
1192        }
1193    } else {
1194        // If git status fails, try with libgit2 as fallback
1195        let statuses = repo.statuses(None).map_err(|e| {
1196            io::Error::new(
1197                io::ErrorKind::InvalidData,
1198                format!("Failed to check working tree status: {e}"),
1199            )
1200        })?;
1201
1202        if !statuses.is_empty() {
1203            return Err(io::Error::new(
1204                io::ErrorKind::InvalidInput,
1205                "Working tree is not clean. Please commit or stash changes before rebasing.",
1206            ));
1207        }
1208    }
1209
1210    // 5. Check for shallow clone (limited history)
1211    check_shallow_clone()?;
1212
1213    // 6. Check for worktree conflicts (branch checked out in another worktree)
1214    check_worktree_conflicts()?;
1215
1216    // 7. Check submodule state (if submodules exist)
1217    check_submodule_state()?;
1218
1219    // 8. Check sparse checkout configuration (if enabled)
1220    check_sparse_checkout_state()?;
1221
1222    Ok(())
1223}
1224
1225/// Check if the repository is a shallow clone.
1226///
1227/// Shallow clones have limited history and may not have all the commits
1228/// needed for a successful rebase.
1229///
1230/// # Returns
1231///
1232/// Returns `Ok(())` if the repository is a full clone, or an error if
1233/// it's a shallow clone.
1234#[cfg(any(test, feature = "test-utils"))]
1235fn check_shallow_clone() -> io::Result<()> {
1236    use std::fs;
1237
1238    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1239    let git_dir = repo.path();
1240
1241    // Check for shallow marker file
1242    let shallow_file = git_dir.join("shallow");
1243    if shallow_file.exists() {
1244        // This is a shallow clone - read the file to see how many commits we have
1245        let content = fs::read_to_string(&shallow_file).unwrap_or_default();
1246        let line_count = content.lines().count();
1247
1248        return Err(io::Error::new(
1249            io::ErrorKind::InvalidInput,
1250            format!(
1251                "Repository is a shallow clone with {} commits. \
1252                 Rebasing may fail due to missing history. \
1253                 Consider running: git fetch --unshallow",
1254                line_count
1255            ),
1256        ));
1257    }
1258
1259    Ok(())
1260}
1261
1262/// Check if the current branch is checked out in another worktree.
1263///
1264/// Git does not allow a branch to be checked out in multiple worktrees
1265/// simultaneously.
1266///
1267/// # Returns
1268///
1269/// Returns `Ok(())` if the branch is not checked out elsewhere, or an
1270/// error if there's a worktree conflict.
1271#[cfg(any(test, feature = "test-utils"))]
1272fn check_worktree_conflicts() -> io::Result<()> {
1273    use std::fs;
1274
1275    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1276
1277    // Get current branch name
1278    let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1279    let branch_name = match head.shorthand() {
1280        Some(name) if head.is_branch() => name,
1281        _ => return Ok(()), // Detached HEAD or unborn branch - skip check
1282    };
1283
1284    let git_dir = repo.path();
1285    let worktrees_dir = git_dir.join("worktrees");
1286
1287    if !worktrees_dir.exists() {
1288        return Ok(());
1289    }
1290
1291    // Check each worktree to see if our branch is checked out there
1292    let entries = fs::read_dir(&worktrees_dir).map_err(|e| {
1293        io::Error::new(
1294            io::ErrorKind::InvalidData,
1295            format!("Failed to read worktrees directory: {e}"),
1296        )
1297    })?;
1298
1299    for entry in entries.flatten() {
1300        let worktree_path = entry.path();
1301        let worktree_head = worktree_path.join("HEAD");
1302
1303        if worktree_head.exists() {
1304            if let Ok(content) = fs::read_to_string(&worktree_head) {
1305                // Check if this worktree has our branch checked out
1306                if content.contains(&format!("refs/heads/{branch_name}")) {
1307                    // Extract worktree name from path
1308                    let worktree_name = worktree_path
1309                        .file_name()
1310                        .and_then(|n| n.to_str())
1311                        .unwrap_or("unknown");
1312
1313                    return Err(io::Error::new(
1314                        io::ErrorKind::InvalidInput,
1315                        format!(
1316                            "Branch '{branch_name}' is already checked out in worktree '{worktree_name}'. \
1317                             Use 'git worktree add' to create a new worktree for this branch."
1318                        ),
1319                    ));
1320                }
1321            }
1322        }
1323    }
1324
1325    Ok(())
1326}
1327
1328/// Check if submodules are in a valid state.
1329///
1330/// Submodules should be initialized and updated before rebasing to avoid
1331/// conflicts and errors.
1332///
1333/// # Returns
1334///
1335/// Returns `Ok(())` if submodules are in a valid state or no submodules
1336/// exist, or an error if there are submodule issues.
1337#[cfg(any(test, feature = "test-utils"))]
1338fn check_submodule_state() -> io::Result<()> {
1339    use std::fs;
1340
1341    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1342    let git_dir = repo.path();
1343
1344    // Check if .gitmodules exists
1345    let workdir = repo.workdir().unwrap_or(git_dir);
1346    let gitmodules_path = workdir.join(".gitmodules");
1347
1348    if !gitmodules_path.exists() {
1349        return Ok(()); // No submodules
1350    }
1351
1352    // We have submodules - check for common issues
1353    let modules_dir = git_dir.join("modules");
1354    if !modules_dir.exists() {
1355        // .gitmodules exists but .git/modules doesn't - submodules not initialized
1356        return Err(io::Error::new(
1357            io::ErrorKind::InvalidInput,
1358            "Submodules are not initialized. Run: git submodule update --init --recursive",
1359        ));
1360    }
1361
1362    // Check for orphaned submodule references (common issue after rebasing)
1363    let gitmodules_content = fs::read_to_string(&gitmodules_path).unwrap_or_default();
1364    let submodule_count = gitmodules_content.matches("path = ").count();
1365
1366    if submodule_count > 0 {
1367        // Verify each submodule directory exists
1368        for line in gitmodules_content.lines() {
1369            if line.contains("path = ") {
1370                if let Some(path) = line.split("path = ").nth(1) {
1371                    let submodule_path = workdir.join(path.trim());
1372                    if !submodule_path.exists() {
1373                        return Err(io::Error::new(
1374                            io::ErrorKind::InvalidInput,
1375                            format!(
1376                                "Submodule '{}' is not initialized. Run: git submodule update --init --recursive",
1377                                path.trim()
1378                            ),
1379                        ));
1380                    }
1381                }
1382            }
1383        }
1384    }
1385
1386    Ok(())
1387}
1388
1389/// Check if sparse checkout is properly configured.
1390///
1391/// Sparse checkout can cause issues during rebase if files outside the
1392/// sparse checkout cone are modified.
1393///
1394/// # Returns
1395///
1396/// Returns `Ok(())` if sparse checkout is not enabled or is properly
1397/// configured, or an error if there are issues.
1398#[cfg(any(test, feature = "test-utils"))]
1399fn check_sparse_checkout_state() -> io::Result<()> {
1400    use std::fs;
1401
1402    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1403    let git_dir = repo.path();
1404
1405    // Check if sparse checkout is enabled
1406    let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1407
1408    let sparse_checkout = config.get_bool("core.sparseCheckout");
1409    let sparse_checkout_cone = config.get_bool("extensions.sparseCheckoutCone");
1410
1411    match (sparse_checkout, sparse_checkout_cone) {
1412        (Ok(true), _) | (_, Ok(true)) => {
1413            // Sparse checkout is enabled - check if it's properly configured
1414            let info_sparse_dir = git_dir.join("info").join("sparse-checkout");
1415
1416            if !info_sparse_dir.exists() {
1417                // Sparse checkout enabled but no config file - this is a problem
1418                return Err(io::Error::new(
1419                    io::ErrorKind::InvalidInput,
1420                    "Sparse checkout is enabled but not configured. \
1421                     Run: git sparse-checkout init",
1422                ));
1423            }
1424
1425            // Verify the sparse-checkout file has content
1426            if let Ok(content) = fs::read_to_string(&info_sparse_dir) {
1427                if content.trim().is_empty() {
1428                    return Err(io::Error::new(
1429                        io::ErrorKind::InvalidInput,
1430                        "Sparse checkout configuration is empty. \
1431                         Run: git sparse-checkout set <patterns>",
1432                    ));
1433                }
1434            }
1435
1436            // Sparse checkout is enabled - warn but don't fail
1437            // Rebase should work with sparse checkout, but conflicts may occur
1438            // for files outside the sparse checkout cone
1439            // We return Ok to allow the operation, but the caller should be aware
1440        }
1441        (Err(_), _) | (_, Err(_)) => {
1442            // Config not set - sparse checkout not enabled
1443        }
1444        _ => {}
1445    }
1446
1447    Ok(())
1448}
1449
1450/// Perform a rebase onto the specified upstream branch.
1451///
1452/// This function rebases the current branch onto the specified upstream branch.
1453/// It handles the full rebase process including conflict detection and
1454/// classifies all known failure modes.
1455///
1456/// # Arguments
1457///
1458/// * `upstream_branch` - The branch to rebase onto (e.g., "main", "origin/main")
1459/// * `executor` - Process executor for dependency injection
1460///
1461/// # Returns
1462///
1463/// Returns `Ok(RebaseResult)` indicating the outcome, or an error if:
1464/// - The repository cannot be opened
1465/// - The rebase operation fails in an unexpected way
1466///
1467/// # Edge Cases Handled
1468///
1469/// - Empty repository (no commits) - Returns `Ok(RebaseResult::NoOp)` with reason
1470/// - Unborn branch - Returns `Ok(RebaseResult::NoOp)` with reason
1471/// - Already up-to-date - Returns `Ok(RebaseResult::NoOp)` with reason
1472/// - Unrelated branches (no shared ancestor) - Returns `Ok(RebaseResult::NoOp)` with reason
1473/// - On main/master branch - Returns `Ok(RebaseResult::NoOp)` with reason
1474/// - Conflicts during rebase - Returns `Ok(RebaseResult::Conflicts)` or `Failed` with error kind
1475/// - Other failures - Returns `Ok(RebaseResult::Failed)` with appropriate error kind
1476///
1477/// # Note
1478///
1479/// This function uses git CLI for rebase operations as libgit2's rebase API
1480/// has limitations and complexity that make it unreliable for production use.
1481/// The git CLI is more robust and better tested for rebase operations.
1482///
1483pub fn rebase_onto(
1484    upstream_branch: &str,
1485    executor: &dyn crate::executor::ProcessExecutor,
1486) -> io::Result<RebaseResult> {
1487    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1488    rebase_onto_impl(&repo, upstream_branch, executor)
1489}
1490
1491/// Implementation of rebase_onto.
1492fn rebase_onto_impl(
1493    repo: &git2::Repository,
1494    upstream_branch: &str,
1495    executor: &dyn crate::executor::ProcessExecutor,
1496) -> io::Result<RebaseResult> {
1497    // Check if we have any commits
1498
1499    match repo.head() {
1500        Ok(_) => {}
1501        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
1502            // No commits yet - nothing to rebase
1503            return Ok(RebaseResult::NoOp {
1504                reason: "Repository has no commits yet (unborn branch)".to_string(),
1505            });
1506        }
1507        Err(e) => return Err(git2_to_io_error(&e)),
1508    }
1509
1510    // Get the upstream branch to ensure it exists
1511    let upstream_object = match repo.revparse_single(upstream_branch) {
1512        Ok(obj) => obj,
1513        Err(_) => {
1514            return Ok(RebaseResult::Failed(RebaseErrorKind::InvalidRevision {
1515                revision: upstream_branch.to_string(),
1516            }))
1517        }
1518    };
1519
1520    let upstream_commit = upstream_object
1521        .peel_to_commit()
1522        .map_err(|e| git2_to_io_error(&e))?;
1523
1524    // Get our branch commit
1525    let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1526    let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
1527
1528    // Check if we're already up-to-date
1529    if repo
1530        .graph_descendant_of(head_commit.id(), upstream_commit.id())
1531        .map_err(|e| git2_to_io_error(&e))?
1532    {
1533        // Already up-to-date
1534        return Ok(RebaseResult::NoOp {
1535            reason: "Branch is already up-to-date with upstream".to_string(),
1536        });
1537    }
1538
1539    // Check if branches share a common ancestor
1540    // If merge_base fails with NotFound, branches are unrelated
1541    match repo.merge_base(head_commit.id(), upstream_commit.id()) {
1542        Err(e)
1543            if e.class() == git2::ErrorClass::Reference
1544                && e.code() == git2::ErrorCode::NotFound =>
1545        {
1546            // Branches are unrelated - no shared history
1547            return Ok(RebaseResult::NoOp {
1548                reason: format!(
1549                    "No common ancestor between current branch and '{upstream_branch}' (unrelated branches)"
1550                ),
1551            });
1552        }
1553        Err(e) => return Err(git2_to_io_error(&e)),
1554        Ok(_) => {}
1555    }
1556
1557    // Check if we're on main/master or in a detached HEAD state
1558    let branch_name = match head.shorthand() {
1559        Some(name) => name,
1560        None => {
1561            // Detached HEAD state - rebase is not applicable
1562            return Ok(RebaseResult::NoOp {
1563                reason: "HEAD is detached (not on any branch), rebase not applicable".to_string(),
1564            });
1565        }
1566    };
1567
1568    if branch_name == "main" || branch_name == "master" {
1569        return Ok(RebaseResult::NoOp {
1570            reason: format!("Already on '{branch_name}' branch, rebase not applicable"),
1571        });
1572    }
1573
1574    // Use git CLI for rebase via executor - more reliable than libgit2
1575    let output = executor.execute("git", &["rebase", upstream_branch], &[], None)?;
1576
1577    if output.status.success() {
1578        Ok(RebaseResult::Success)
1579    } else {
1580        let stderr = &output.stderr;
1581        let stdout = &output.stdout;
1582
1583        // Use classify_rebase_error to determine specific failure mode
1584        let error_kind = classify_rebase_error(stderr, stdout);
1585
1586        match error_kind {
1587            RebaseErrorKind::ContentConflict { .. } => {
1588                // For conflicts, get of actual conflicted files
1589                match get_conflicted_files() {
1590                    Ok(files) if files.is_empty() => {
1591                        // If we detected a conflict but can't get of files,
1592                        // return error kind with files from error
1593                        if let RebaseErrorKind::ContentConflict { files } = error_kind {
1594                            Ok(RebaseResult::Conflicts(files))
1595                        } else {
1596                            Ok(RebaseResult::Conflicts(vec![]))
1597                        }
1598                    }
1599                    Ok(files) => Ok(RebaseResult::Conflicts(files)),
1600                    Err(_) => Ok(RebaseResult::Conflicts(vec![])),
1601                }
1602            }
1603            RebaseErrorKind::Unknown { .. } => {
1604                // Check for "up to date" message which is actually a no-op
1605                if stderr.contains("up to date") {
1606                    Ok(RebaseResult::NoOp {
1607                        reason: "Branch is already up-to-date with upstream".to_string(),
1608                    })
1609                } else {
1610                    Ok(RebaseResult::Failed(error_kind))
1611                }
1612            }
1613            _ => Ok(RebaseResult::Failed(error_kind)),
1614        }
1615    }
1616}
1617
1618/// Abort the current rebase operation.
1619///
1620/// This cleans up the rebase state and returns the repository to its
1621/// pre-rebase condition.
1622///
1623/// # Arguments
1624///
1625/// * `executor` - Process executor for dependency injection
1626///
1627/// # Returns
1628///
1629/// Returns `Ok(())` if successful, or an error if:
1630/// - No rebase is in progress
1631/// - The abort operation fails
1632///
1633pub fn abort_rebase(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<()> {
1634    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1635    abort_rebase_impl(&repo, executor)
1636}
1637
1638/// Implementation of abort_rebase.
1639fn abort_rebase_impl(
1640    repo: &git2::Repository,
1641    executor: &dyn crate::executor::ProcessExecutor,
1642) -> io::Result<()> {
1643    // Check if a rebase is in progress
1644    let state = repo.state();
1645    if state != git2::RepositoryState::Rebase
1646        && state != git2::RepositoryState::RebaseMerge
1647        && state != git2::RepositoryState::RebaseInteractive
1648    {
1649        return Err(io::Error::new(
1650            io::ErrorKind::InvalidInput,
1651            "No rebase in progress",
1652        ));
1653    }
1654
1655    // Use git CLI for abort via executor
1656    let output = executor.execute("git", &["rebase", "--abort"], &[], None)?;
1657
1658    if output.status.success() {
1659        Ok(())
1660    } else {
1661        Err(io::Error::other(format!(
1662            "Failed to abort rebase: {}",
1663            output.stderr
1664        )))
1665    }
1666}
1667
1668/// Get a list of files that have merge conflicts.
1669///
1670/// This function queries libgit2's index to find all files that are
1671/// currently in a conflicted state.
1672///
1673/// # Returns
1674///
1675/// Returns `Ok(Vec<String>)` containing the paths of conflicted files,
1676/// or an error if the repository cannot be accessed.
1677///
1678pub fn get_conflicted_files() -> io::Result<Vec<String>> {
1679    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1680    get_conflicted_files_impl(&repo)
1681}
1682
1683/// Implementation of get_conflicted_files.
1684fn get_conflicted_files_impl(repo: &git2::Repository) -> io::Result<Vec<String>> {
1685    let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1686
1687    let mut conflicted_files = Vec::new();
1688
1689    // Check if there are any conflicts
1690    if !index.has_conflicts() {
1691        return Ok(conflicted_files);
1692    }
1693
1694    // Get the list of conflicted files
1695    let conflicts = index.conflicts().map_err(|e| git2_to_io_error(&e))?;
1696
1697    for conflict in conflicts {
1698        let conflict = conflict.map_err(|e| git2_to_io_error(&e))?;
1699        // The conflict's `our` entry (stage 2) will have the path
1700        if let Some(our_entry) = conflict.our {
1701            if let Ok(path) = std::str::from_utf8(&our_entry.path) {
1702                let path_str = path.to_string();
1703                if !conflicted_files.contains(&path_str) {
1704                    conflicted_files.push(path_str);
1705                }
1706            }
1707        }
1708    }
1709
1710    Ok(conflicted_files)
1711}
1712
1713/// Extract conflict markers from a file.
1714///
1715/// This function reads a file and returns the conflict sections,
1716/// including both versions of the conflicted content.
1717///
1718/// # Arguments
1719///
1720/// * `path` - Path to the conflicted file (relative to repo root)
1721///
1722/// # Returns
1723///
1724/// Returns `Ok(String)` containing the conflict sections, or an error
1725/// if the file cannot be read.
1726pub fn get_conflict_markers_for_file(path: &Path) -> io::Result<String> {
1727    use std::fs;
1728    use std::io::Read;
1729
1730    let mut file = fs::File::open(path)?;
1731    let mut content = String::new();
1732    file.read_to_string(&mut content)?;
1733
1734    // Extract conflict markers and their content
1735    let mut conflict_sections = Vec::new();
1736    let lines: Vec<&str> = content.lines().collect();
1737    let mut i = 0;
1738
1739    while i < lines.len() {
1740        if lines[i].trim_start().starts_with("<<<<<<<") {
1741            // Found conflict start
1742            let mut section = Vec::new();
1743            section.push(lines[i]);
1744
1745            i += 1;
1746            // Collect "ours" version
1747            while i < lines.len() && !lines[i].trim_start().starts_with("=======") {
1748                section.push(lines[i]);
1749                i += 1;
1750            }
1751
1752            if i < lines.len() {
1753                section.push(lines[i]); // Add the ======= line
1754                i += 1;
1755            }
1756
1757            // Collect "theirs" version
1758            while i < lines.len() && !lines[i].trim_start().starts_with(">>>>>>>") {
1759                section.push(lines[i]);
1760                i += 1;
1761            }
1762
1763            if i < lines.len() {
1764                section.push(lines[i]); // Add the >>>>>>> line
1765                i += 1;
1766            }
1767
1768            conflict_sections.push(section.join("\n"));
1769        } else {
1770            i += 1;
1771        }
1772    }
1773
1774    if conflict_sections.is_empty() {
1775        // No conflict markers found, return empty string
1776        Ok(String::new())
1777    } else {
1778        Ok(conflict_sections.join("\n\n"))
1779    }
1780}
1781
1782/// Verify that a rebase has completed successfully using LibGit2.
1783///
1784/// This function uses LibGit2 exclusively to verify that a rebase operation
1785/// has completed successfully. It checks:
1786/// - Repository state is clean (no rebase in progress)
1787/// - HEAD is valid and not detached (unless expected)
1788/// - Index has no conflicts
1789/// - Current branch is descendant of upstream (rebase succeeded)
1790///
1791/// # Returns
1792///
1793/// Returns `Ok(true)` if rebase is verified as complete, `Ok(false)` if
1794/// rebase is still in progress (conflicts remain), or an error if the
1795/// repository state is invalid.
1796///
1797/// # Note
1798///
1799/// This is the authoritative source for rebase completion verification.
1800/// It does NOT depend on parsing agent output or any other external signals.
1801#[cfg(any(test, feature = "test-utils"))]
1802pub fn verify_rebase_completed(upstream_branch: &str) -> io::Result<bool> {
1803    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1804
1805    // 1. Check if a rebase is still in progress
1806    let state = repo.state();
1807    if state == git2::RepositoryState::Rebase
1808        || state == git2::RepositoryState::RebaseMerge
1809        || state == git2::RepositoryState::RebaseInteractive
1810    {
1811        // Rebase is still in progress
1812        return Ok(false);
1813    }
1814
1815    // 2. Check if there are any remaining conflicts in the index
1816    let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1817    if index.has_conflicts() {
1818        // Conflicts remain in the index
1819        return Ok(false);
1820    }
1821
1822    // 3. Verify HEAD is valid
1823    let head = repo.head().map_err(|e| {
1824        io::Error::new(
1825            io::ErrorKind::InvalidData,
1826            format!("Repository HEAD is invalid: {e}"),
1827        )
1828    })?;
1829
1830    // 4. Verify the current branch is a descendant of upstream
1831    // This confirms the rebase actually succeeded
1832    if let Ok(head_commit) = head.peel_to_commit() {
1833        if let Ok(upstream_object) = repo.revparse_single(upstream_branch) {
1834            if let Ok(upstream_commit) = upstream_object.peel_to_commit() {
1835                // Check if HEAD is now a descendant of upstream
1836                // This means the rebase moved our commits on top of upstream
1837                match repo.graph_descendant_of(head_commit.id(), upstream_commit.id()) {
1838                    Ok(is_descendant) => {
1839                        if is_descendant {
1840                            // HEAD is descendant of upstream - rebase successful
1841                            return Ok(true);
1842                        } else {
1843                            // HEAD is not a descendant - rebase not complete or not applicable
1844                            // (e.g., diverged branches, feature branch ahead of upstream)
1845                            return Ok(false);
1846                        }
1847                    }
1848                    Err(e) => {
1849                        // Can't determine descendant relationship - fall back to conflict check
1850                        let _ = e; // suppress unused warning
1851                    }
1852                }
1853            }
1854        }
1855    }
1856
1857    // If we can't verify descendant relationship, check for conflicts
1858    // as a fallback - if no conflicts and no rebase in progress, consider it complete
1859    Ok(!index.has_conflicts())
1860}
1861
1862/// Continue a rebase after conflict resolution.
1863///
1864/// This function continues a rebase that was paused due to conflicts.
1865/// It should be called after all conflicts have been resolved and
1866/// the resolved files have been staged with `git add`.
1867///
1868/// # Returns
1869///
1870/// Returns `Ok(())` if successful, or an error if:
1871/// - No rebase is in progress
1872/// - Conflicts remain unresolved
1873/// - The continue operation fails
1874///
1875/// **Note:** This function uses the current working directory to discover the repo.
1876pub fn continue_rebase(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<()> {
1877    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1878    continue_rebase_impl(&repo, executor)
1879}
1880
1881/// Implementation of continue_rebase.
1882fn continue_rebase_impl(
1883    repo: &git2::Repository,
1884    executor: &dyn crate::executor::ProcessExecutor,
1885) -> io::Result<()> {
1886    // Check if a rebase is in progress
1887    let state = repo.state();
1888    if state != git2::RepositoryState::Rebase
1889        && state != git2::RepositoryState::RebaseMerge
1890        && state != git2::RepositoryState::RebaseInteractive
1891    {
1892        return Err(io::Error::new(
1893            io::ErrorKind::InvalidInput,
1894            "No rebase in progress",
1895        ));
1896    }
1897
1898    // Check if there are still conflicts
1899    let conflicted = get_conflicted_files()?;
1900    if !conflicted.is_empty() {
1901        return Err(io::Error::new(
1902            io::ErrorKind::InvalidInput,
1903            format!(
1904                "Cannot continue rebase: {} file(s) still have conflicts",
1905                conflicted.len()
1906            ),
1907        ));
1908    }
1909
1910    // Use git CLI for continue via executor
1911    let output = executor.execute("git", &["rebase", "--continue"], &[], None)?;
1912
1913    if output.status.success() {
1914        Ok(())
1915    } else {
1916        Err(io::Error::other(format!(
1917            "Failed to continue rebase: {}",
1918            output.stderr
1919        )))
1920    }
1921}
1922
1923/// Check if a rebase is currently in progress.
1924///
1925/// This function checks the repository state to determine if a rebase
1926/// operation is in progress. This is useful for detecting interrupted
1927/// rebases that need to be resumed or aborted.
1928///
1929/// # Returns
1930///
1931/// Returns `Ok(true)` if a rebase is in progress, `Ok(false)` otherwise,
1932/// or an error if the repository cannot be accessed.
1933///
1934pub fn rebase_in_progress() -> io::Result<bool> {
1935    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1936    rebase_in_progress_impl(&repo)
1937}
1938
1939/// Implementation of rebase_in_progress.
1940fn rebase_in_progress_impl(repo: &git2::Repository) -> io::Result<bool> {
1941    let state = repo.state();
1942    Ok(state == git2::RepositoryState::Rebase
1943        || state == git2::RepositoryState::RebaseMerge
1944        || state == git2::RepositoryState::RebaseInteractive)
1945}
1946
1947#[cfg(test)]
1948mod tests {
1949    use super::*;
1950    use crate::executor::MockProcessExecutor;
1951    use std::sync::Arc;
1952
1953    #[test]
1954    fn test_rebase_result_variants_exist() {
1955        // Test that RebaseResult has the expected variants
1956        let _ = RebaseResult::Success;
1957        let _ = RebaseResult::NoOp {
1958            reason: "test".to_string(),
1959        };
1960        let _ = RebaseResult::Conflicts(vec![]);
1961        let _ = RebaseResult::Failed(RebaseErrorKind::Unknown {
1962            details: "test".to_string(),
1963        });
1964    }
1965
1966    #[test]
1967    fn test_rebase_result_is_noop() {
1968        // Test the is_noop method
1969        assert!(RebaseResult::NoOp {
1970            reason: "test".to_string()
1971        }
1972        .is_noop());
1973        assert!(!RebaseResult::Success.is_noop());
1974        assert!(!RebaseResult::Conflicts(vec![]).is_noop());
1975        assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
1976            details: "test".to_string(),
1977        })
1978        .is_noop());
1979    }
1980
1981    #[test]
1982    fn test_rebase_result_is_success() {
1983        // Test the is_success method
1984        assert!(RebaseResult::Success.is_success());
1985        assert!(!RebaseResult::NoOp {
1986            reason: "test".to_string()
1987        }
1988        .is_success());
1989        assert!(!RebaseResult::Conflicts(vec![]).is_success());
1990        assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
1991            details: "test".to_string(),
1992        })
1993        .is_success());
1994    }
1995
1996    #[test]
1997    fn test_rebase_result_has_conflicts() {
1998        // Test the has_conflicts method
1999        assert!(RebaseResult::Conflicts(vec!["file.txt".to_string()]).has_conflicts());
2000        assert!(!RebaseResult::Success.has_conflicts());
2001        assert!(!RebaseResult::NoOp {
2002            reason: "test".to_string()
2003        }
2004        .has_conflicts());
2005    }
2006
2007    #[test]
2008    fn test_rebase_result_is_failed() {
2009        // Test the is_failed method
2010        assert!(RebaseResult::Failed(RebaseErrorKind::Unknown {
2011            details: "test".to_string(),
2012        })
2013        .is_failed());
2014        assert!(!RebaseResult::Success.is_failed());
2015        assert!(!RebaseResult::NoOp {
2016            reason: "test".to_string()
2017        }
2018        .is_failed());
2019        assert!(!RebaseResult::Conflicts(vec![]).is_failed());
2020    }
2021
2022    #[test]
2023    fn test_rebase_error_kind_description() {
2024        // Test that error kinds produce descriptions
2025        let err = RebaseErrorKind::InvalidRevision {
2026            revision: "main".to_string(),
2027        };
2028        assert!(err.description().contains("main"));
2029
2030        let err = RebaseErrorKind::DirtyWorkingTree;
2031        assert!(err.description().contains("Working tree"));
2032    }
2033
2034    #[test]
2035    fn test_rebase_error_kind_category() {
2036        // Test that error kinds return correct categories
2037        assert_eq!(
2038            RebaseErrorKind::InvalidRevision {
2039                revision: "test".to_string()
2040            }
2041            .category(),
2042            1
2043        );
2044        assert_eq!(
2045            RebaseErrorKind::ContentConflict { files: vec![] }.category(),
2046            2
2047        );
2048        assert_eq!(
2049            RebaseErrorKind::ValidationFailed {
2050                reason: "test".to_string()
2051            }
2052            .category(),
2053            3
2054        );
2055        assert_eq!(
2056            RebaseErrorKind::ProcessTerminated {
2057                reason: "test".to_string()
2058            }
2059            .category(),
2060            4
2061        );
2062        assert_eq!(
2063            RebaseErrorKind::Unknown {
2064                details: "test".to_string()
2065            }
2066            .category(),
2067            5
2068        );
2069    }
2070
2071    #[test]
2072    fn test_rebase_error_kind_is_recoverable() {
2073        // Test that error kinds correctly identify recoverable errors
2074        assert!(RebaseErrorKind::ConcurrentOperation {
2075            operation: "rebase".to_string()
2076        }
2077        .is_recoverable());
2078        assert!(RebaseErrorKind::ContentConflict { files: vec![] }.is_recoverable());
2079        assert!(!RebaseErrorKind::InvalidRevision {
2080            revision: "test".to_string()
2081        }
2082        .is_recoverable());
2083        assert!(!RebaseErrorKind::DirtyWorkingTree.is_recoverable());
2084    }
2085
2086    #[test]
2087    fn test_classify_rebase_error_invalid_revision() {
2088        // Test classification of invalid revision errors
2089        let stderr = "error: invalid revision 'nonexistent'";
2090        let error = classify_rebase_error(stderr, "");
2091        assert!(matches!(error, RebaseErrorKind::InvalidRevision { .. }));
2092    }
2093
2094    #[test]
2095    fn test_classify_rebase_error_conflict() {
2096        // Test classification of conflict errors
2097        let stderr = "CONFLICT (content): Merge conflict in src/main.rs";
2098        let error = classify_rebase_error(stderr, "");
2099        assert!(matches!(error, RebaseErrorKind::ContentConflict { .. }));
2100    }
2101
2102    #[test]
2103    fn test_classify_rebase_error_dirty_tree() {
2104        // Test classification of dirty working tree errors
2105        let stderr = "Cannot rebase: Your index contains uncommitted changes";
2106        let error = classify_rebase_error(stderr, "");
2107        assert!(matches!(error, RebaseErrorKind::DirtyWorkingTree));
2108    }
2109
2110    #[test]
2111    fn test_classify_rebase_error_concurrent_operation() {
2112        // Test classification of concurrent operation errors
2113        let stderr = "Cannot rebase: There is a rebase in progress already";
2114        let error = classify_rebase_error(stderr, "");
2115        assert!(matches!(error, RebaseErrorKind::ConcurrentOperation { .. }));
2116    }
2117
2118    #[test]
2119    fn test_classify_rebase_error_unknown() {
2120        // Test classification of unknown errors
2121        let stderr = "Some completely unexpected error message";
2122        let error = classify_rebase_error(stderr, "");
2123        assert!(matches!(error, RebaseErrorKind::Unknown { .. }));
2124    }
2125
2126    #[test]
2127    fn test_rebase_onto_returns_result() {
2128        use test_helpers::{commit_all, init_git_repo, with_temp_cwd, write_file};
2129
2130        // Test that rebase_onto returns a Result
2131        with_temp_cwd(|dir| {
2132            // Initialize a git repo with an initial commit
2133            let repo = init_git_repo(dir);
2134            write_file(dir.path().join("initial.txt"), "initial content");
2135            let _ = commit_all(&repo, "initial commit");
2136
2137            // Use MockProcessExecutor to avoid spawning real processes
2138            // The mock will return failure for the nonexistent branch
2139            let executor =
2140                Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2141            let result = rebase_onto("nonexistent_branch_that_does_not_exist", executor.as_ref());
2142            // Should return Ok (either with Failed result or other outcome)
2143            assert!(result.is_ok());
2144        });
2145    }
2146
2147    #[test]
2148    fn test_get_conflicted_files_returns_result() {
2149        use test_helpers::{init_git_repo, with_temp_cwd};
2150
2151        // Test that get_conflicted_files returns a Result
2152        with_temp_cwd(|dir| {
2153            // Initialize a git repo first
2154            let _repo = init_git_repo(dir);
2155
2156            let result = get_conflicted_files();
2157            // Should succeed (returns Vec, not error)
2158            assert!(result.is_ok());
2159        });
2160    }
2161
2162    #[test]
2163    fn test_rebase_in_progress_cli_returns_result() {
2164        use test_helpers::{init_git_repo, with_temp_cwd};
2165
2166        // Test that rebase_in_progress_cli returns a Result
2167        with_temp_cwd(|dir| {
2168            // Initialize a git repo first
2169            let _repo = init_git_repo(dir);
2170
2171            // Use MockProcessExecutor to avoid spawning real processes
2172            let executor =
2173                Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2174            let result = rebase_in_progress_cli(executor.as_ref());
2175            // Should succeed (returns bool)
2176            assert!(result.is_ok());
2177        });
2178    }
2179
2180    #[test]
2181    fn test_is_dirty_tree_cli_returns_result() {
2182        use test_helpers::{init_git_repo, with_temp_cwd};
2183
2184        // Test that is_dirty_tree_cli returns a Result
2185        with_temp_cwd(|dir| {
2186            // Initialize a git repo first
2187            let _repo = init_git_repo(dir);
2188
2189            // Use MockProcessExecutor to avoid spawning real processes
2190            let executor =
2191                Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2192            let result = is_dirty_tree_cli(executor.as_ref());
2193            // Should succeed (returns bool)
2194            assert!(result.is_ok());
2195        });
2196    }
2197
2198    #[test]
2199    fn test_cleanup_stale_rebase_state_returns_result() {
2200        use test_helpers::{init_git_repo, with_temp_cwd};
2201
2202        with_temp_cwd(|dir| {
2203            // Initialize a git repo first
2204            let _repo = init_git_repo(dir);
2205
2206            // Test that cleanup_stale_rebase_state returns a Result
2207            let result = cleanup_stale_rebase_state();
2208            // Should succeed even if there's nothing to clean
2209            assert!(result.is_ok());
2210        });
2211    }
2212}