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