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() -> io::Result<bool> {
777    use std::process::Command;
778
779    let output = Command::new("git").args(["status", "--porcelain"]).output();
780
781    match output {
782        Ok(result) => {
783            let stdout = String::from_utf8_lossy(&result.stdout);
784            // Check for rebase state indicators
785            Ok(stdout.contains("rebasing"))
786        }
787        Err(e) => Err(io::Error::other(format!(
788            "Failed to check rebase status: {e}"
789        ))),
790    }
791}
792
793/// Result of cleaning up stale rebase state.
794///
795/// Provides information about what was cleaned up during the operation.
796#[derive(Debug, Clone, Default)]
797#[cfg(any(test, feature = "test-utils"))]
798pub struct CleanupResult {
799    /// List of state files that were cleaned up
800    pub cleaned_paths: Vec<String>,
801    /// Whether any lock files were removed
802    pub locks_removed: bool,
803}
804
805#[cfg(any(test, feature = "test-utils"))]
806impl CleanupResult {
807    /// Returns true if any cleanup was performed.
808    pub fn has_cleanup(&self) -> bool {
809        !self.cleaned_paths.is_empty() || self.locks_removed
810    }
811
812    /// Returns the number of items cleaned up.
813    pub fn count(&self) -> usize {
814        self.cleaned_paths.len() + if self.locks_removed { 1 } else { 0 }
815    }
816}
817
818/// Clean up stale rebase state files.
819///
820/// This function attempts to clean up stale rebase state that may be
821/// left over from interrupted operations. It validates state before
822/// removal and reports what was cleaned up.
823///
824/// This is a recovery mechanism for concurrent operation detection and
825/// for cleaning up after interrupted rebase operations.
826///
827/// # Returns
828///
829/// Returns `Ok(CleanupResult)` with details of what was cleaned up,
830/// or an error if cleanup failed catastrophically.
831#[cfg(any(test, feature = "test-utils"))]
832pub fn cleanup_stale_rebase_state() -> io::Result<CleanupResult> {
833    use std::fs;
834
835    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
836    let git_dir = repo.path();
837
838    let mut result = CleanupResult::default();
839
840    // List of possible stale rebase state files/directories
841    let stale_paths = [
842        ("rebase-apply", "rebase-apply directory"),
843        ("rebase-merge", "rebase-merge directory"),
844        ("MERGE_HEAD", "merge state"),
845        ("MERGE_MSG", "merge message"),
846        ("CHERRY_PICK_HEAD", "cherry-pick state"),
847        ("REVERT_HEAD", "revert state"),
848        ("COMMIT_EDITMSG", "commit message"),
849    ];
850
851    for (path_name, description) in &stale_paths {
852        let full_path = git_dir.join(path_name);
853        if full_path.exists() {
854            // Try to validate the state before removing
855            let is_valid = validate_state_file(&full_path);
856            if !is_valid.unwrap_or(true) {
857                // State is invalid or corrupted, safe to remove
858                let removed = if full_path.is_dir() {
859                    fs::remove_dir_all(&full_path)
860                        .map(|_| true)
861                        .unwrap_or(false)
862                } else {
863                    fs::remove_file(&full_path).map(|_| true).unwrap_or(false)
864                };
865
866                if removed {
867                    result
868                        .cleaned_paths
869                        .push(format!("{path_name} ({description})"));
870                }
871            }
872        }
873    }
874
875    // Clean up lock files if they exist
876    let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
877    for lock_file in &lock_files {
878        let lock_path = git_dir.join(lock_file);
879        if lock_path.exists() {
880            // Lock files are generally safe to remove if stale
881            if fs::remove_file(&lock_path).is_ok() {
882                result.locks_removed = true;
883                result
884                    .cleaned_paths
885                    .push(format!("{lock_file} (lock file)"));
886            }
887        }
888    }
889
890    Ok(result)
891}
892
893// Validate a Git state file for corruption.
894///
895/// Checks if a state file is valid before attempting to remove it.
896/// This prevents accidental removal of valid in-progress operations.
897///
898/// # Arguments
899///
900/// * `path` - Path to the state file to validate
901///
902/// # Returns
903///
904/// Returns `Ok(true)` if the state appears valid, `Ok(false)` if invalid,
905/// or an error if validation failed.
906#[cfg(any(test, feature = "test-utils"))]
907fn validate_state_file(path: &Path) -> io::Result<bool> {
908    use std::fs;
909
910    if !path.exists() {
911        return Ok(false);
912    }
913
914    // For directories, check if they contain required files
915    if path.is_dir() {
916        // A valid rebase directory should have at least some files
917        let entries = fs::read_dir(path)?;
918        let has_content = entries.count() > 0;
919        return Ok(has_content);
920    }
921
922    // For files, check if they're readable and non-empty
923    if path.is_file() {
924        let metadata = fs::metadata(path)?;
925        if metadata.len() == 0 {
926            // Empty state file is invalid
927            return Ok(false);
928        }
929        // Try to read a small amount to verify file integrity
930        let _ = fs::read(path)?;
931        return Ok(true);
932    }
933
934    Ok(false)
935}
936
937/// Attempt automatic recovery from a rebase failure.
938///
939/// This function implements an escalation strategy for recovering from
940/// rebase failures, trying multiple approaches before giving up:
941///
942/// **Level 1 - Clean state retry**: Reset to clean state and retry
943/// **Level 2 - Lock file removal**: Remove stale lock files
944/// **Level 3 - Abort and restart**: Abort current rebase and restart from checkpoint
945///
946/// # Arguments
947///
948/// * `error_kind` - The error that occurred
949/// * `phase` - The current rebase phase
950/// * `phase_error_count` - Number of errors in the current phase
951///
952/// # Returns
953///
954/// Returns `Ok(true)` if automatic recovery succeeded and operation can continue,
955/// `Ok(false)` if recovery was attempted but operation should still abort,
956/// or an error if recovery itself failed.
957///
958/// # Example
959///
960/// ```no_run
961/// use ralph_workflow::git_helpers::rebase::{attempt_automatic_recovery, RebaseErrorKind};
962/// use ralph_workflow::git_helpers::rebase_checkpoint::RebasePhase;
963///
964/// match attempt_automatic_recovery(&RebaseErrorKind::Unknown { details: "test".to_string() }, &RebasePhase::ConflictDetected, 2) {
965///     Ok(true) => println!("Recovery succeeded, can continue"),
966///     Ok(false) => println!("Recovery attempted, should abort"),
967///     Err(e) => println!("Recovery failed: {e}"),
968/// }
969/// ```
970#[cfg(any(test, feature = "test-utils"))]
971pub fn attempt_automatic_recovery(
972    error_kind: &RebaseErrorKind,
973    phase: &crate::git_helpers::rebase_checkpoint::RebasePhase,
974    phase_error_count: u32,
975) -> io::Result<bool> {
976    use std::process::Command;
977
978    // Don't attempt recovery for fatal errors
979    match error_kind {
980        RebaseErrorKind::InvalidRevision { .. }
981        | RebaseErrorKind::DirtyWorkingTree
982        | RebaseErrorKind::RepositoryCorrupt { .. }
983        | RebaseErrorKind::EnvironmentFailure { .. }
984        | RebaseErrorKind::HookRejection { .. }
985        | RebaseErrorKind::InteractiveStop { .. }
986        | RebaseErrorKind::Unknown { .. } => {
987            return Ok(false);
988        }
989        _ => {}
990    }
991
992    let max_attempts = phase.max_recovery_attempts();
993    if phase_error_count >= max_attempts {
994        return Ok(false);
995    }
996
997    // Level 1: Try cleaning stale state
998    if cleanup_stale_rebase_state().is_ok() {
999        // If we cleaned something, try to validate the repo is in a good state
1000        if validate_git_state().is_ok() {
1001            return Ok(true);
1002        }
1003    }
1004
1005    // Level 2: Try removing lock files
1006    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1007    let git_dir = repo.path();
1008    let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
1009    let mut removed_any = false;
1010
1011    for lock_file in &lock_files {
1012        let lock_path = git_dir.join(lock_file);
1013        if lock_path.exists() && std::fs::remove_file(&lock_path).is_ok() {
1014            removed_any = true;
1015        }
1016    }
1017
1018    if removed_any && validate_git_state().is_ok() {
1019        return Ok(true);
1020    }
1021
1022    // Level 3: For concurrent operations, try to abort the in-progress operation
1023    if let RebaseErrorKind::ConcurrentOperation { .. } = error_kind {
1024        // Try git rebase --abort
1025        let abort_result = Command::new("git").args(["rebase", "--abort"]).output();
1026
1027        if abort_result.is_ok() {
1028            // Check if state is now clean
1029            if validate_git_state().is_ok() {
1030                return Ok(true);
1031            }
1032        }
1033    }
1034
1035    // Recovery attempts exhausted or failed
1036    Ok(false)
1037}
1038
1039/// Validate the overall Git repository state for corruption.
1040///
1041/// Performs comprehensive checks on the repository to detect
1042/// corrupted state files, missing objects, or other integrity issues.
1043///
1044/// # Returns
1045///
1046/// Returns `Ok(())` if the repository state appears valid,
1047/// or an error describing the validation failure.
1048#[cfg(any(test, feature = "test-utils"))]
1049pub fn validate_git_state() -> io::Result<()> {
1050    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1051
1052    // Check if the repository head is valid
1053    let _ = repo.head().map_err(|e| {
1054        io::Error::new(
1055            io::ErrorKind::InvalidData,
1056            format!("Repository HEAD is invalid: {e}"),
1057        )
1058    })?;
1059
1060    // Try to access the index to verify it's not corrupted
1061    let _ = repo.index().map_err(|e| {
1062        io::Error::new(
1063            io::ErrorKind::InvalidData,
1064            format!("Repository index is corrupted: {e}"),
1065        )
1066    })?;
1067
1068    // Check for object database integrity by trying to access HEAD
1069    if let Ok(head) = repo.head() {
1070        if let Ok(commit) = head.peel_to_commit() {
1071            // Verify the commit tree is accessible
1072            let _ = commit.tree().map_err(|e| {
1073                io::Error::new(
1074                    io::ErrorKind::InvalidData,
1075                    format!("Object database corruption: {e}"),
1076                )
1077            })?;
1078        }
1079    }
1080
1081    Ok(())
1082}
1083
1084/// Restore repository state from reflog.
1085///
1086/// This is a fallback recovery mechanism that attempts to restore
1087/// the repository to a previous state using the reflog.
1088///
1089/// # Arguments
1090///
1091/// * `ref_name` - The reference to restore (e.g., "HEAD", "refs/heads/main")
1092/// * `steps_back` - Number of steps back in the reflog to go
1093///
1094/// # Returns
1095///
1096/// Returns `Ok(())` if restore succeeded, or an error if it failed.
1097#[cfg(any(test, feature = "test-utils"))]
1098pub fn restore_from_reflog(ref_name: &str, steps_back: usize) -> io::Result<()> {
1099    use std::process::Command;
1100
1101    // Use Git CLI to reset to the reflog entry
1102    let refspec = format!("{ref_name}@{{{steps_back}}}");
1103    let output = Command::new("git")
1104        .args(["reset", "--hard", &refspec])
1105        .output();
1106
1107    match output {
1108        Ok(result) if result.status.success() => Ok(()),
1109        Ok(result) => {
1110            let stderr = String::from_utf8_lossy(&result.stderr);
1111            Err(io::Error::other(format!(
1112                "Failed to restore from reflog: {stderr}",
1113            )))
1114        }
1115        Err(e) => Err(io::Error::other(format!(
1116            "Failed to execute git reset: {e}"
1117        ))),
1118    }
1119}
1120
1121/// Detect dirty working tree using Git CLI.
1122///
1123/// This is a fallback function that uses Git CLI to detect dirty state
1124/// when libgit2 detection may not be sufficient.
1125///
1126/// # Returns
1127///
1128/// Returns `Ok(true)` if the working tree is dirty, `Ok(false)` otherwise.
1129#[cfg(any(test, feature = "test-utils"))]
1130pub fn is_dirty_tree_cli() -> io::Result<bool> {
1131    use std::process::Command;
1132
1133    let output = Command::new("git").args(["status", "--porcelain"]).output();
1134
1135    match output {
1136        Ok(result) => {
1137            let stdout = String::from_utf8_lossy(&result.stdout);
1138            Ok(!stdout.trim().is_empty())
1139        }
1140        Err(e) => Err(io::Error::other(format!(
1141            "Failed to check working tree status: {e}"
1142        ))),
1143    }
1144}
1145
1146/// Validate preconditions before starting a rebase operation.
1147///
1148/// This function performs Category 1 (pre-start) validation checks to ensure
1149/// the repository is in a valid state for rebasing. It checks for common
1150/// issues that would cause a rebase to fail immediately.
1151///
1152/// # Returns
1153///
1154/// Returns `Ok(())` if all preconditions are met, or an error with a
1155/// descriptive message if validation fails.
1156///
1157/// # Validation Checks
1158///
1159/// - Repository integrity (valid HEAD, index, object database)
1160/// - No concurrent Git operations (merge, rebase, cherry-pick, etc.)
1161/// - Git identity is configured (user.name and user.email)
1162/// - Working tree is not dirty (no unstaged or staged changes)
1163/// - Not a shallow clone (shallow clones have limited history)
1164/// - No worktree conflicts (branch not checked out elsewhere)
1165/// - Submodules are initialized and in a valid state
1166/// - Sparse checkout is properly configured (if enabled)
1167///
1168/// # Example
1169///
1170/// ```no_run
1171/// use ralph_workflow::git_helpers::rebase::validate_rebase_preconditions;
1172///
1173/// match validate_rebase_preconditions() {
1174///     Ok(()) => println!("All preconditions met, safe to rebase"),
1175///     Err(e) => eprintln!("Cannot rebase: {e}"),
1176/// }
1177/// ```
1178#[cfg(any(test, feature = "test-utils"))]
1179pub fn validate_rebase_preconditions() -> io::Result<()> {
1180    use std::process::Command;
1181
1182    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1183
1184    // 1. Check repository integrity
1185    validate_git_state()?;
1186
1187    // 2. Check for concurrent Git operations
1188    if let Some(concurrent_op) = detect_concurrent_git_operations()? {
1189        return Err(io::Error::new(
1190            io::ErrorKind::InvalidInput,
1191            format!(
1192                "Cannot start rebase: {} already in progress. \
1193                 Please complete or abort the current operation first.",
1194                concurrent_op.description()
1195            ),
1196        ));
1197    }
1198
1199    // 3. Check Git identity configuration
1200    let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1201
1202    let user_name = config.get_string("user.name");
1203    let user_email = config.get_string("user.email");
1204
1205    if user_name.is_err() && user_email.is_err() {
1206        return Err(io::Error::new(
1207            io::ErrorKind::InvalidInput,
1208            "Git identity is not configured. Please set user.name and user.email:\n  \
1209             git config --global user.name \"Your Name\"\n  \
1210             git config --global user.email \"you@example.com\"",
1211        ));
1212    }
1213
1214    // 4. Check for dirty working tree using Git CLI
1215    let status_output = Command::new("git").args(["status", "--porcelain"]).output();
1216
1217    match status_output {
1218        Ok(result) => {
1219            let stdout = String::from_utf8_lossy(&result.stdout);
1220            if !stdout.trim().is_empty() {
1221                return Err(io::Error::new(
1222                    io::ErrorKind::InvalidInput,
1223                    "Working tree is not clean. Please commit or stash changes before rebasing.",
1224                ));
1225            }
1226        }
1227        Err(_e) => {
1228            // If git status fails, try with libgit2 as fallback
1229            let statuses = repo.statuses(None).map_err(|e| {
1230                io::Error::new(
1231                    io::ErrorKind::InvalidData,
1232                    format!("Failed to check working tree status: {e}"),
1233                )
1234            })?;
1235
1236            if !statuses.is_empty() {
1237                return Err(io::Error::new(
1238                    io::ErrorKind::InvalidInput,
1239                    "Working tree is not clean. Please commit or stash changes before rebasing.",
1240                ));
1241            }
1242        }
1243    }
1244
1245    // 5. Check for shallow clone (limited history)
1246    check_shallow_clone()?;
1247
1248    // 6. Check for worktree conflicts (branch checked out in another worktree)
1249    check_worktree_conflicts()?;
1250
1251    // 7. Check submodule state (if submodules exist)
1252    check_submodule_state()?;
1253
1254    // 8. Check sparse checkout configuration (if enabled)
1255    check_sparse_checkout_state()?;
1256
1257    Ok(())
1258}
1259
1260/// Check if the repository is a shallow clone.
1261///
1262/// Shallow clones have limited history and may not have all the commits
1263/// needed for a successful rebase.
1264///
1265/// # Returns
1266///
1267/// Returns `Ok(())` if the repository is a full clone, or an error if
1268/// it's a shallow clone.
1269#[cfg(any(test, feature = "test-utils"))]
1270fn check_shallow_clone() -> io::Result<()> {
1271    use std::fs;
1272
1273    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1274    let git_dir = repo.path();
1275
1276    // Check for shallow marker file
1277    let shallow_file = git_dir.join("shallow");
1278    if shallow_file.exists() {
1279        // This is a shallow clone - read the file to see how many commits we have
1280        let content = fs::read_to_string(&shallow_file).unwrap_or_default();
1281        let line_count = content.lines().count();
1282
1283        return Err(io::Error::new(
1284            io::ErrorKind::InvalidInput,
1285            format!(
1286                "Repository is a shallow clone with {} commits. \
1287                 Rebasing may fail due to missing history. \
1288                 Consider running: git fetch --unshallow",
1289                line_count
1290            ),
1291        ));
1292    }
1293
1294    Ok(())
1295}
1296
1297/// Check if the current branch is checked out in another worktree.
1298///
1299/// Git does not allow a branch to be checked out in multiple worktrees
1300/// simultaneously.
1301///
1302/// # Returns
1303///
1304/// Returns `Ok(())` if the branch is not checked out elsewhere, or an
1305/// error if there's a worktree conflict.
1306#[cfg(any(test, feature = "test-utils"))]
1307fn check_worktree_conflicts() -> io::Result<()> {
1308    use std::fs;
1309
1310    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1311
1312    // Get current branch name
1313    let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1314    let branch_name = match head.shorthand() {
1315        Some(name) if head.is_branch() => name,
1316        _ => return Ok(()), // Detached HEAD or unborn branch - skip check
1317    };
1318
1319    let git_dir = repo.path();
1320    let worktrees_dir = git_dir.join("worktrees");
1321
1322    if !worktrees_dir.exists() {
1323        return Ok(());
1324    }
1325
1326    // Check each worktree to see if our branch is checked out there
1327    let entries = fs::read_dir(&worktrees_dir).map_err(|e| {
1328        io::Error::new(
1329            io::ErrorKind::InvalidData,
1330            format!("Failed to read worktrees directory: {e}"),
1331        )
1332    })?;
1333
1334    for entry in entries.flatten() {
1335        let worktree_path = entry.path();
1336        let worktree_head = worktree_path.join("HEAD");
1337
1338        if worktree_head.exists() {
1339            if let Ok(content) = fs::read_to_string(&worktree_head) {
1340                // Check if this worktree has our branch checked out
1341                if content.contains(&format!("refs/heads/{branch_name}")) {
1342                    // Extract worktree name from path
1343                    let worktree_name = worktree_path
1344                        .file_name()
1345                        .and_then(|n| n.to_str())
1346                        .unwrap_or("unknown");
1347
1348                    return Err(io::Error::new(
1349                        io::ErrorKind::InvalidInput,
1350                        format!(
1351                            "Branch '{branch_name}' is already checked out in worktree '{worktree_name}'. \
1352                             Use 'git worktree add' to create a new worktree for this branch."
1353                        ),
1354                    ));
1355                }
1356            }
1357        }
1358    }
1359
1360    Ok(())
1361}
1362
1363/// Check if submodules are in a valid state.
1364///
1365/// Submodules should be initialized and updated before rebasing to avoid
1366/// conflicts and errors.
1367///
1368/// # Returns
1369///
1370/// Returns `Ok(())` if submodules are in a valid state or no submodules
1371/// exist, or an error if there are submodule issues.
1372#[cfg(any(test, feature = "test-utils"))]
1373fn check_submodule_state() -> io::Result<()> {
1374    use std::fs;
1375
1376    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1377    let git_dir = repo.path();
1378
1379    // Check if .gitmodules exists
1380    let workdir = repo.workdir().unwrap_or(git_dir);
1381    let gitmodules_path = workdir.join(".gitmodules");
1382
1383    if !gitmodules_path.exists() {
1384        return Ok(()); // No submodules
1385    }
1386
1387    // We have submodules - check for common issues
1388    let modules_dir = git_dir.join("modules");
1389    if !modules_dir.exists() {
1390        // .gitmodules exists but .git/modules doesn't - submodules not initialized
1391        return Err(io::Error::new(
1392            io::ErrorKind::InvalidInput,
1393            "Submodules are not initialized. Run: git submodule update --init --recursive",
1394        ));
1395    }
1396
1397    // Check for orphaned submodule references (common issue after rebasing)
1398    let gitmodules_content = fs::read_to_string(&gitmodules_path).unwrap_or_default();
1399    let submodule_count = gitmodules_content.matches("path = ").count();
1400
1401    if submodule_count > 0 {
1402        // Verify each submodule directory exists
1403        for line in gitmodules_content.lines() {
1404            if line.contains("path = ") {
1405                if let Some(path) = line.split("path = ").nth(1) {
1406                    let submodule_path = workdir.join(path.trim());
1407                    if !submodule_path.exists() {
1408                        return Err(io::Error::new(
1409                            io::ErrorKind::InvalidInput,
1410                            format!(
1411                                "Submodule '{}' is not initialized. Run: git submodule update --init --recursive",
1412                                path.trim()
1413                            ),
1414                        ));
1415                    }
1416                }
1417            }
1418        }
1419    }
1420
1421    Ok(())
1422}
1423
1424/// Check if sparse checkout is properly configured.
1425///
1426/// Sparse checkout can cause issues during rebase if files outside the
1427/// sparse checkout cone are modified.
1428///
1429/// # Returns
1430///
1431/// Returns `Ok(())` if sparse checkout is not enabled or is properly
1432/// configured, or an error if there are issues.
1433#[cfg(any(test, feature = "test-utils"))]
1434fn check_sparse_checkout_state() -> io::Result<()> {
1435    use std::fs;
1436
1437    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1438    let git_dir = repo.path();
1439
1440    // Check if sparse checkout is enabled
1441    let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1442
1443    let sparse_checkout = config.get_bool("core.sparseCheckout");
1444    let sparse_checkout_cone = config.get_bool("extensions.sparseCheckoutCone");
1445
1446    match (sparse_checkout, sparse_checkout_cone) {
1447        (Ok(true), _) | (_, Ok(true)) => {
1448            // Sparse checkout is enabled - check if it's properly configured
1449            let info_sparse_dir = git_dir.join("info").join("sparse-checkout");
1450
1451            if !info_sparse_dir.exists() {
1452                // Sparse checkout enabled but no config file - this is a problem
1453                return Err(io::Error::new(
1454                    io::ErrorKind::InvalidInput,
1455                    "Sparse checkout is enabled but not configured. \
1456                     Run: git sparse-checkout init",
1457                ));
1458            }
1459
1460            // Verify the sparse-checkout file has content
1461            if let Ok(content) = fs::read_to_string(&info_sparse_dir) {
1462                if content.trim().is_empty() {
1463                    return Err(io::Error::new(
1464                        io::ErrorKind::InvalidInput,
1465                        "Sparse checkout configuration is empty. \
1466                         Run: git sparse-checkout set <patterns>",
1467                    ));
1468                }
1469            }
1470
1471            // Sparse checkout is enabled - warn but don't fail
1472            // Rebase should work with sparse checkout, but conflicts may occur
1473            // for files outside the sparse checkout cone
1474            // We return Ok to allow the operation, but the caller should be aware
1475        }
1476        (Err(_), _) | (_, Err(_)) => {
1477            // Config not set - sparse checkout not enabled
1478        }
1479        _ => {}
1480    }
1481
1482    Ok(())
1483}
1484
1485/// Perform a rebase onto the specified upstream branch.
1486///
1487/// This function rebases the current branch onto the specified upstream branch.
1488/// It handles the full rebase process including conflict detection and
1489/// classifies all known failure modes.
1490///
1491/// # Arguments
1492///
1493/// * `upstream_branch` - The branch to rebase onto (e.g., "main", "origin/main")
1494///
1495/// # Returns
1496///
1497/// Returns `Ok(RebaseResult)` indicating the outcome, or an error if:
1498/// - The repository cannot be opened
1499/// - The rebase operation fails in an unexpected way
1500///
1501/// # Edge Cases Handled
1502///
1503/// - Empty repository (no commits) - Returns `Ok(RebaseResult::NoOp)` with reason
1504/// - Unborn branch - Returns `Ok(RebaseResult::NoOp)` with reason
1505/// - Already up-to-date - Returns `Ok(RebaseResult::NoOp)` with reason
1506/// - Unrelated branches (no shared ancestor) - Returns `Ok(RebaseResult::NoOp)` with reason
1507/// - On main/master branch - Returns `Ok(RebaseResult::NoOp)` with reason
1508/// - Conflicts during rebase - Returns `Ok(RebaseResult::Conflicts)` or `Failed` with error kind
1509/// - Other failures - Returns `Ok(RebaseResult::Failed)` with appropriate error kind
1510///
1511/// # Note
1512///
1513/// This function uses git CLI for rebase operations as libgit2's rebase API
1514/// has limitations and complexity that make it unreliable for production use.
1515/// The git CLI is more robust and better tested for rebase operations.
1516pub fn rebase_onto(upstream_branch: &str) -> io::Result<RebaseResult> {
1517    use std::process::Command;
1518
1519    // Check if we have any commits
1520    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1521
1522    match repo.head() {
1523        Ok(_) => {}
1524        Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
1525            // No commits yet - nothing to rebase
1526            return Ok(RebaseResult::NoOp {
1527                reason: "Repository has no commits yet (unborn branch)".to_string(),
1528            });
1529        }
1530        Err(e) => return Err(git2_to_io_error(&e)),
1531    }
1532
1533    // Get the upstream branch to ensure it exists
1534    let upstream_object = match repo.revparse_single(upstream_branch) {
1535        Ok(obj) => obj,
1536        Err(_) => {
1537            return Ok(RebaseResult::Failed(RebaseErrorKind::InvalidRevision {
1538                revision: upstream_branch.to_string(),
1539            }))
1540        }
1541    };
1542
1543    let upstream_commit = upstream_object
1544        .peel_to_commit()
1545        .map_err(|e| git2_to_io_error(&e))?;
1546
1547    // Get our branch commit
1548    let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1549    let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
1550
1551    // Check if we're already up-to-date
1552    if repo
1553        .graph_descendant_of(head_commit.id(), upstream_commit.id())
1554        .map_err(|e| git2_to_io_error(&e))?
1555    {
1556        // Already up-to-date
1557        return Ok(RebaseResult::NoOp {
1558            reason: "Branch is already up-to-date with upstream".to_string(),
1559        });
1560    }
1561
1562    // Check if branches share a common ancestor
1563    // If merge_base fails with NotFound, the branches are unrelated
1564    match repo.merge_base(head_commit.id(), upstream_commit.id()) {
1565        Err(e)
1566            if e.class() == git2::ErrorClass::Reference
1567                && e.code() == git2::ErrorCode::NotFound =>
1568        {
1569            // Branches are unrelated - no shared history
1570            return Ok(RebaseResult::NoOp {
1571                reason: format!(
1572                    "No common ancestor between current branch and '{upstream_branch}' (unrelated branches)"
1573                ),
1574            });
1575        }
1576        Err(e) => return Err(git2_to_io_error(&e)),
1577        Ok(_) => {}
1578    }
1579
1580    // Check if we're on main/master or in a detached HEAD state
1581    let branch_name = match head.shorthand() {
1582        Some(name) => name,
1583        None => {
1584            // Detached HEAD state - rebase is not applicable
1585            return Ok(RebaseResult::NoOp {
1586                reason: "HEAD is detached (not on any branch), rebase not applicable".to_string(),
1587            });
1588        }
1589    };
1590
1591    if branch_name == "main" || branch_name == "master" {
1592        return Ok(RebaseResult::NoOp {
1593            reason: format!("Already on '{branch_name}' branch, rebase not applicable"),
1594        });
1595    }
1596
1597    // Use git CLI for rebase - more reliable than libgit2
1598    let output = Command::new("git")
1599        .args(["rebase", upstream_branch])
1600        .output();
1601
1602    match output {
1603        Ok(result) => {
1604            if result.status.success() {
1605                Ok(RebaseResult::Success)
1606            } else {
1607                let stderr = String::from_utf8_lossy(&result.stderr);
1608                let stdout = String::from_utf8_lossy(&result.stdout);
1609
1610                // Use classify_rebase_error to determine the specific failure mode
1611                let error_kind = classify_rebase_error(&stderr, &stdout);
1612
1613                match error_kind {
1614                    RebaseErrorKind::ContentConflict { .. } => {
1615                        // For conflicts, get the actual conflicted files
1616                        match get_conflicted_files() {
1617                            Ok(files) if files.is_empty() => {
1618                                // If we detected a conflict but can't get the files,
1619                                // return the error kind with the files from the error
1620                                if let RebaseErrorKind::ContentConflict { files } = error_kind {
1621                                    Ok(RebaseResult::Conflicts(files))
1622                                } else {
1623                                    Ok(RebaseResult::Conflicts(vec![]))
1624                                }
1625                            }
1626                            Ok(files) => Ok(RebaseResult::Conflicts(files)),
1627                            Err(_) => Ok(RebaseResult::Conflicts(vec![])),
1628                        }
1629                    }
1630                    RebaseErrorKind::Unknown { .. } => {
1631                        // Check for "up to date" message which is actually a no-op
1632                        if stderr.contains("up to date") {
1633                            Ok(RebaseResult::NoOp {
1634                                reason: "Branch is already up-to-date with upstream".to_string(),
1635                            })
1636                        } else {
1637                            Ok(RebaseResult::Failed(error_kind))
1638                        }
1639                    }
1640                    _ => Ok(RebaseResult::Failed(error_kind)),
1641                }
1642            }
1643        }
1644        Err(e) => Err(io::Error::other(format!(
1645            "Failed to execute git rebase: {e}"
1646        ))),
1647    }
1648}
1649
1650/// Abort the current rebase operation.
1651///
1652/// This cleans up the rebase state and returns the repository to its
1653/// pre-rebase condition.
1654///
1655/// # Returns
1656///
1657/// Returns `Ok(())` if successful, or an error if:
1658/// - No rebase is in progress
1659/// - The abort operation fails
1660pub fn abort_rebase() -> io::Result<()> {
1661    use std::process::Command;
1662
1663    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1664
1665    // Check if a rebase is in progress
1666    let state = repo.state();
1667    if state != git2::RepositoryState::Rebase
1668        && state != git2::RepositoryState::RebaseMerge
1669        && state != git2::RepositoryState::RebaseInteractive
1670    {
1671        return Err(io::Error::new(
1672            io::ErrorKind::InvalidInput,
1673            "No rebase in progress",
1674        ));
1675    }
1676
1677    // Use git CLI for abort
1678    let output = Command::new("git").args(["rebase", "--abort"]).output();
1679
1680    match output {
1681        Ok(result) => {
1682            if result.status.success() {
1683                Ok(())
1684            } else {
1685                let stderr = String::from_utf8_lossy(&result.stderr);
1686                Err(io::Error::other(format!(
1687                    "Failed to abort rebase: {stderr}"
1688                )))
1689            }
1690        }
1691        Err(e) => Err(io::Error::other(format!(
1692            "Failed to execute git rebase --abort: {e}"
1693        ))),
1694    }
1695}
1696
1697/// Get a list of files that have merge conflicts.
1698///
1699/// This function queries libgit2's index to find all files that are
1700/// currently in a conflicted state.
1701///
1702/// # Returns
1703///
1704/// Returns `Ok(Vec<String>)` containing the paths of conflicted files,
1705/// or an error if the repository cannot be accessed.
1706pub fn get_conflicted_files() -> io::Result<Vec<String>> {
1707    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1708    let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1709
1710    let mut conflicted_files = Vec::new();
1711
1712    // Check if there are any conflicts
1713    if !index.has_conflicts() {
1714        return Ok(conflicted_files);
1715    }
1716
1717    // Get the list of conflicted files
1718    let conflicts = index.conflicts().map_err(|e| git2_to_io_error(&e))?;
1719
1720    for conflict in conflicts {
1721        let conflict = conflict.map_err(|e| git2_to_io_error(&e))?;
1722        // The conflict's `our` entry (stage 2) will have the path
1723        if let Some(our_entry) = conflict.our {
1724            if let Ok(path) = std::str::from_utf8(&our_entry.path) {
1725                let path_str = path.to_string();
1726                if !conflicted_files.contains(&path_str) {
1727                    conflicted_files.push(path_str);
1728                }
1729            }
1730        }
1731    }
1732
1733    Ok(conflicted_files)
1734}
1735
1736/// Extract conflict markers from a file.
1737///
1738/// This function reads a file and returns the conflict sections,
1739/// including both versions of the conflicted content.
1740///
1741/// # Arguments
1742///
1743/// * `path` - Path to the conflicted file (relative to repo root)
1744///
1745/// # Returns
1746///
1747/// Returns `Ok(String)` containing the conflict sections, or an error
1748/// if the file cannot be read.
1749pub fn get_conflict_markers_for_file(path: &Path) -> io::Result<String> {
1750    use std::fs;
1751    use std::io::Read;
1752
1753    let mut file = fs::File::open(path)?;
1754    let mut content = String::new();
1755    file.read_to_string(&mut content)?;
1756
1757    // Extract conflict markers and their content
1758    let mut conflict_sections = Vec::new();
1759    let lines: Vec<&str> = content.lines().collect();
1760    let mut i = 0;
1761
1762    while i < lines.len() {
1763        if lines[i].trim_start().starts_with("<<<<<<<") {
1764            // Found conflict start
1765            let mut section = Vec::new();
1766            section.push(lines[i]);
1767
1768            i += 1;
1769            // Collect "ours" version
1770            while i < lines.len() && !lines[i].trim_start().starts_with("=======") {
1771                section.push(lines[i]);
1772                i += 1;
1773            }
1774
1775            if i < lines.len() {
1776                section.push(lines[i]); // Add the ======= line
1777                i += 1;
1778            }
1779
1780            // Collect "theirs" version
1781            while i < lines.len() && !lines[i].trim_start().starts_with(">>>>>>>") {
1782                section.push(lines[i]);
1783                i += 1;
1784            }
1785
1786            if i < lines.len() {
1787                section.push(lines[i]); // Add the >>>>>>> line
1788                i += 1;
1789            }
1790
1791            conflict_sections.push(section.join("\n"));
1792        } else {
1793            i += 1;
1794        }
1795    }
1796
1797    if conflict_sections.is_empty() {
1798        // No conflict markers found, return empty string
1799        Ok(String::new())
1800    } else {
1801        Ok(conflict_sections.join("\n\n"))
1802    }
1803}
1804
1805/// Verify that a rebase has completed successfully using LibGit2.
1806///
1807/// This function uses LibGit2 exclusively to verify that a rebase operation
1808/// has completed successfully. It checks:
1809/// - Repository state is clean (no rebase in progress)
1810/// - HEAD is valid and not detached (unless expected)
1811/// - Index has no conflicts
1812/// - Current branch is descendant of upstream (rebase succeeded)
1813///
1814/// # Returns
1815///
1816/// Returns `Ok(true)` if rebase is verified as complete, `Ok(false)` if
1817/// rebase is still in progress (conflicts remain), or an error if the
1818/// repository state is invalid.
1819///
1820/// # Note
1821///
1822/// This is the authoritative source for rebase completion verification.
1823/// It does NOT depend on parsing agent output or any other external signals.
1824#[cfg(any(test, feature = "test-utils"))]
1825pub fn verify_rebase_completed(upstream_branch: &str) -> io::Result<bool> {
1826    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1827
1828    // 1. Check if a rebase is still in progress
1829    let state = repo.state();
1830    if state == git2::RepositoryState::Rebase
1831        || state == git2::RepositoryState::RebaseMerge
1832        || state == git2::RepositoryState::RebaseInteractive
1833    {
1834        // Rebase is still in progress
1835        return Ok(false);
1836    }
1837
1838    // 2. Check if there are any remaining conflicts in the index
1839    let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1840    if index.has_conflicts() {
1841        // Conflicts remain in the index
1842        return Ok(false);
1843    }
1844
1845    // 3. Verify HEAD is valid
1846    let head = repo.head().map_err(|e| {
1847        io::Error::new(
1848            io::ErrorKind::InvalidData,
1849            format!("Repository HEAD is invalid: {e}"),
1850        )
1851    })?;
1852
1853    // 4. Verify the current branch is a descendant of upstream
1854    // This confirms the rebase actually succeeded
1855    if let Ok(head_commit) = head.peel_to_commit() {
1856        if let Ok(upstream_object) = repo.revparse_single(upstream_branch) {
1857            if let Ok(upstream_commit) = upstream_object.peel_to_commit() {
1858                // Check if HEAD is now a descendant of upstream
1859                // This means the rebase moved our commits on top of upstream
1860                match repo.graph_descendant_of(head_commit.id(), upstream_commit.id()) {
1861                    Ok(is_descendant) => {
1862                        if is_descendant {
1863                            // HEAD is descendant of upstream - rebase successful
1864                            return Ok(true);
1865                        } else {
1866                            // HEAD is not a descendant - rebase not complete or not applicable
1867                            // (e.g., diverged branches, feature branch ahead of upstream)
1868                            return Ok(false);
1869                        }
1870                    }
1871                    Err(e) => {
1872                        // Can't determine descendant relationship - fall back to conflict check
1873                        let _ = e; // suppress unused warning
1874                    }
1875                }
1876            }
1877        }
1878    }
1879
1880    // If we can't verify descendant relationship, check for conflicts
1881    // as a fallback - if no conflicts and no rebase in progress, consider it complete
1882    Ok(!index.has_conflicts())
1883}
1884
1885/// Validate that the repository is in a good state after rebase.
1886///
1887/// Performs post-rebase validation to ensure the repository is usable:
1888/// - Checks if HEAD is valid (not detached unexpectedly)
1889/// - Verifies index integrity
1890/// - Checks for obvious corruption issues
1891///
1892/// # Returns
1893///
1894/// Returns `Ok(())` if validation passes, or an error if issues are detected.
1895///
1896/// # Note
1897///
1898/// This is a lightweight validation that runs automatically after successful
1899/// rebase. For project-specific validation (tests, builds), use
1900/// `validate_post_rebase_with_checks()` instead.
1901#[cfg(any(test, feature = "test-utils"))]
1902pub fn validate_post_rebase_state() -> io::Result<()> {
1903    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1904
1905    // Check HEAD is valid
1906    let head = repo.head().map_err(|e| {
1907        io::Error::new(
1908            io::ErrorKind::InvalidData,
1909            format!("Repository HEAD is invalid after rebase: {e}"),
1910        )
1911    })?;
1912
1913    // Check if we're detached (check if HEAD has a symbolic name)
1914    // If head.shorthand() returns None, we might be in a detached state
1915    let is_detached = head.shorthand().is_none();
1916    if is_detached {
1917        return Err(io::Error::new(
1918            io::ErrorKind::InvalidData,
1919            "HEAD is detached after rebase - this may indicate a problem",
1920        ));
1921    }
1922
1923    // Verify index integrity
1924    let _index = repo.index().map_err(|e| {
1925        io::Error::new(
1926            io::ErrorKind::InvalidData,
1927            format!("Repository index is corrupted after rebase: {e}"),
1928        )
1929    })?;
1930
1931    // Try to access HEAD commit to verify object database
1932    let head_commit = head.peel_to_commit().map_err(|e| {
1933        io::Error::new(
1934            io::ErrorKind::InvalidData,
1935            format!("Cannot access HEAD commit after rebase: {e}"),
1936        )
1937    })?;
1938
1939    // Verify the commit tree is accessible
1940    let _tree = head_commit.tree().map_err(|e| {
1941        io::Error::new(
1942            io::ErrorKind::InvalidData,
1943            format!("Object database corruption after rebase: {e}"),
1944        )
1945    })?;
1946
1947    Ok(())
1948}
1949
1950/// Result of post-rebase validation with project checks.
1951///
1952/// Provides detailed information about what passed or failed during
1953/// post-rebase validation including project-specific checks.
1954#[cfg(any(test, feature = "test-utils"))]
1955#[derive(Debug, Clone, Default)]
1956pub struct PostRebaseValidationResult {
1957    /// Git state validation passed
1958    pub git_state_valid: bool,
1959    /// Build validation passed (if run)
1960    pub build_valid: Option<bool>,
1961    /// Test validation passed (if run)
1962    pub tests_valid: Option<bool>,
1963    /// Lint validation passed (if run)
1964    pub lint_valid: Option<bool>,
1965    /// Any errors or warnings encountered
1966    pub messages: Vec<String>,
1967}
1968
1969#[cfg(any(test, feature = "test-utils"))]
1970impl PostRebaseValidationResult {
1971    /// Returns true if all validations that were run passed.
1972    pub fn is_valid(&self) -> bool {
1973        self.git_state_valid
1974            && self.build_valid.is_none_or(|v| v)
1975            && self.tests_valid.is_none_or(|v| v)
1976            && self.lint_valid.is_none_or(|v| v)
1977    }
1978
1979    /// Returns a summary message describing the validation result.
1980    pub fn summary(&self) -> String {
1981        if self.is_valid() {
1982            "All validations passed".to_string()
1983        } else {
1984            let mut failures = Vec::new();
1985            if !self.git_state_valid {
1986                failures.push("Git state validation failed".to_string());
1987            }
1988            if self.build_valid == Some(false) {
1989                failures.push("Build validation failed".to_string());
1990            }
1991            if self.tests_valid == Some(false) {
1992                failures.push("Test validation failed".to_string());
1993            }
1994            if self.lint_valid == Some(false) {
1995                failures.push("Lint validation failed".to_string());
1996            }
1997            failures.join("; ")
1998        }
1999    }
2000}
2001
2002/// Validate post-rebase state with optional project-specific checks.
2003///
2004/// This function performs comprehensive validation after a rebase:
2005/// 1. Git state validation (HEAD, index, object database)
2006/// 2. Optional build validation (cargo build --release for Rust projects)
2007/// 3. Optional test validation (cargo test --lib for Rust projects)
2008/// 4. Optional lint validation (cargo clippy for Rust projects)
2009///
2010/// # Arguments
2011///
2012/// * `run_build_checks` - If true, run build validation
2013/// * `run_test_checks` - If true, run test validation
2014/// * `run_lint_checks` - If true, run lint validation
2015///
2016/// # Returns
2017///
2018/// Returns `Ok(PostRebaseValidationResult)` with validation details,
2019/// or an error if validation setup fails.
2020///
2021/// # Note
2022///
2023/// Project-specific checks are only run for Rust projects (detected by
2024/// presence of Cargo.toml). For other project types, these checks are
2025/// silently skipped.
2026#[cfg(any(test, feature = "test-utils"))]
2027pub fn validate_post_rebase_with_checks(
2028    run_build_checks: bool,
2029    run_test_checks: bool,
2030    run_lint_checks: bool,
2031) -> io::Result<PostRebaseValidationResult> {
2032    use std::path::Path;
2033    use std::process::Command;
2034
2035    let git_state_valid = validate_post_rebase_state().is_ok();
2036    let mut result = PostRebaseValidationResult {
2037        git_state_valid,
2038        ..Default::default()
2039    };
2040
2041    if !result.git_state_valid {
2042        result
2043            .messages
2044            .push("Git state validation failed".to_string());
2045    }
2046
2047    // Check if this is a Rust project
2048    let is_rust_project = Path::new("Cargo.toml").exists();
2049
2050    if !is_rust_project {
2051        result
2052            .messages
2053            .push("Not a Rust project - skipping project-specific checks".to_string());
2054        return Ok(result);
2055    }
2056
2057    // Run build checks if requested
2058    if run_build_checks {
2059        result
2060            .messages
2061            .push("Running build validation...".to_string());
2062        let build_output = Command::new("cargo").args(["build", "--release"]).output();
2063
2064        result.build_valid = Some(match build_output {
2065            Ok(output) => output.status.success(),
2066            Err(e) => {
2067                result.messages.push(format!("Failed to run build: {e}"));
2068                false
2069            }
2070        });
2071
2072        if result.build_valid == Some(false) {
2073            result
2074                .messages
2075                .push("Build validation failed - project may not compile".to_string());
2076        }
2077    }
2078
2079    // Run test checks if requested
2080    if run_test_checks {
2081        result
2082            .messages
2083            .push("Running test validation...".to_string());
2084        let test_output = Command::new("cargo")
2085            .args(["test", "--lib", "--all-features"])
2086            .output();
2087
2088        result.tests_valid = Some(match test_output {
2089            Ok(output) => output.status.success(),
2090            Err(e) => {
2091                result.messages.push(format!("Failed to run tests: {e}"));
2092                false
2093            }
2094        });
2095
2096        if result.tests_valid == Some(false) {
2097            result
2098                .messages
2099                .push("Test validation failed - some tests may be broken".to_string());
2100        }
2101    }
2102
2103    // Run lint checks if requested
2104    if run_lint_checks {
2105        result
2106            .messages
2107            .push("Running lint validation...".to_string());
2108        let lint_output = Command::new("cargo")
2109            .args(["clippy", "--lib", "--all-features", "-D", "warnings"])
2110            .output();
2111
2112        result.lint_valid = Some(match lint_output {
2113            Ok(output) => output.status.success(),
2114            Err(e) => {
2115                result.messages.push(format!("Failed to run clippy: {e}"));
2116                false
2117            }
2118        });
2119
2120        if result.lint_valid == Some(false) {
2121            result
2122                .messages
2123                .push("Lint validation failed - code may have lint issues".to_string());
2124        }
2125    }
2126
2127    Ok(result)
2128}
2129
2130/// Continue a rebase after conflict resolution.
2131///
2132/// This function continues a rebase that was paused due to conflicts.
2133/// It should be called after all conflicts have been resolved and
2134/// the resolved files have been staged with `git add`.
2135///
2136/// # Returns
2137///
2138/// Returns `Ok(())` if successful, or an error if:
2139/// - No rebase is in progress
2140/// - Conflicts remain unresolved
2141/// - The continue operation fails
2142pub fn continue_rebase() -> io::Result<()> {
2143    use std::process::Command;
2144
2145    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
2146
2147    // Check if a rebase is in progress
2148    let state = repo.state();
2149    if state != git2::RepositoryState::Rebase
2150        && state != git2::RepositoryState::RebaseMerge
2151        && state != git2::RepositoryState::RebaseInteractive
2152    {
2153        return Err(io::Error::new(
2154            io::ErrorKind::InvalidInput,
2155            "No rebase in progress",
2156        ));
2157    }
2158
2159    // Check if there are still conflicts
2160    let conflicted = get_conflicted_files()?;
2161    if !conflicted.is_empty() {
2162        return Err(io::Error::new(
2163            io::ErrorKind::InvalidInput,
2164            format!(
2165                "Cannot continue rebase: {} file(s) still have conflicts",
2166                conflicted.len()
2167            ),
2168        ));
2169    }
2170
2171    // Use git CLI for continue
2172    let output = Command::new("git").args(["rebase", "--continue"]).output();
2173
2174    match output {
2175        Ok(result) => {
2176            if result.status.success() {
2177                Ok(())
2178            } else {
2179                let stderr = String::from_utf8_lossy(&result.stderr);
2180                Err(io::Error::other(format!(
2181                    "Failed to continue rebase: {stderr}"
2182                )))
2183            }
2184        }
2185        Err(e) => Err(io::Error::other(format!(
2186            "Failed to execute git rebase --continue: {e}"
2187        ))),
2188    }
2189}
2190
2191/// Check if a rebase is currently in progress.
2192///
2193/// This function checks the repository state to determine if a rebase
2194/// operation is in progress. This is useful for detecting interrupted
2195/// rebases that need to be resumed or aborted.
2196///
2197/// # Returns
2198///
2199/// Returns `Ok(true)` if a rebase is in progress, `Ok(false)` otherwise,
2200/// or an error if the repository cannot be accessed.
2201pub fn rebase_in_progress() -> io::Result<bool> {
2202    let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
2203    let state = repo.state();
2204    Ok(state == git2::RepositoryState::Rebase
2205        || state == git2::RepositoryState::RebaseMerge
2206        || state == git2::RepositoryState::RebaseInteractive)
2207}
2208
2209#[cfg(test)]
2210mod tests {
2211    use super::*;
2212
2213    #[test]
2214    fn test_rebase_result_variants_exist() {
2215        // Test that RebaseResult has the expected variants
2216        let _ = RebaseResult::Success;
2217        let _ = RebaseResult::NoOp {
2218            reason: "test".to_string(),
2219        };
2220        let _ = RebaseResult::Conflicts(vec![]);
2221        let _ = RebaseResult::Failed(RebaseErrorKind::Unknown {
2222            details: "test".to_string(),
2223        });
2224    }
2225
2226    #[test]
2227    fn test_rebase_result_is_noop() {
2228        // Test the is_noop method
2229        assert!(RebaseResult::NoOp {
2230            reason: "test".to_string()
2231        }
2232        .is_noop());
2233        assert!(!RebaseResult::Success.is_noop());
2234        assert!(!RebaseResult::Conflicts(vec![]).is_noop());
2235        assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
2236            details: "test".to_string(),
2237        })
2238        .is_noop());
2239    }
2240
2241    #[test]
2242    fn test_rebase_result_is_success() {
2243        // Test the is_success method
2244        assert!(RebaseResult::Success.is_success());
2245        assert!(!RebaseResult::NoOp {
2246            reason: "test".to_string()
2247        }
2248        .is_success());
2249        assert!(!RebaseResult::Conflicts(vec![]).is_success());
2250        assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
2251            details: "test".to_string(),
2252        })
2253        .is_success());
2254    }
2255
2256    #[test]
2257    fn test_rebase_result_has_conflicts() {
2258        // Test the has_conflicts method
2259        assert!(RebaseResult::Conflicts(vec!["file.txt".to_string()]).has_conflicts());
2260        assert!(!RebaseResult::Success.has_conflicts());
2261        assert!(!RebaseResult::NoOp {
2262            reason: "test".to_string()
2263        }
2264        .has_conflicts());
2265    }
2266
2267    #[test]
2268    fn test_rebase_result_is_failed() {
2269        // Test the is_failed method
2270        assert!(RebaseResult::Failed(RebaseErrorKind::Unknown {
2271            details: "test".to_string(),
2272        })
2273        .is_failed());
2274        assert!(!RebaseResult::Success.is_failed());
2275        assert!(!RebaseResult::NoOp {
2276            reason: "test".to_string()
2277        }
2278        .is_failed());
2279        assert!(!RebaseResult::Conflicts(vec![]).is_failed());
2280    }
2281
2282    #[test]
2283    fn test_rebase_error_kind_description() {
2284        // Test that error kinds produce descriptions
2285        let err = RebaseErrorKind::InvalidRevision {
2286            revision: "main".to_string(),
2287        };
2288        assert!(err.description().contains("main"));
2289
2290        let err = RebaseErrorKind::DirtyWorkingTree;
2291        assert!(err.description().contains("Working tree"));
2292    }
2293
2294    #[test]
2295    fn test_rebase_error_kind_category() {
2296        // Test that error kinds return correct categories
2297        assert_eq!(
2298            RebaseErrorKind::InvalidRevision {
2299                revision: "test".to_string()
2300            }
2301            .category(),
2302            1
2303        );
2304        assert_eq!(
2305            RebaseErrorKind::ContentConflict { files: vec![] }.category(),
2306            2
2307        );
2308        assert_eq!(
2309            RebaseErrorKind::ValidationFailed {
2310                reason: "test".to_string()
2311            }
2312            .category(),
2313            3
2314        );
2315        assert_eq!(
2316            RebaseErrorKind::ProcessTerminated {
2317                reason: "test".to_string()
2318            }
2319            .category(),
2320            4
2321        );
2322        assert_eq!(
2323            RebaseErrorKind::Unknown {
2324                details: "test".to_string()
2325            }
2326            .category(),
2327            5
2328        );
2329    }
2330
2331    #[test]
2332    fn test_rebase_error_kind_is_recoverable() {
2333        // Test that error kinds correctly identify recoverable errors
2334        assert!(RebaseErrorKind::ConcurrentOperation {
2335            operation: "rebase".to_string()
2336        }
2337        .is_recoverable());
2338        assert!(RebaseErrorKind::ContentConflict { files: vec![] }.is_recoverable());
2339        assert!(!RebaseErrorKind::InvalidRevision {
2340            revision: "test".to_string()
2341        }
2342        .is_recoverable());
2343        assert!(!RebaseErrorKind::DirtyWorkingTree.is_recoverable());
2344    }
2345
2346    #[test]
2347    fn test_classify_rebase_error_invalid_revision() {
2348        // Test classification of invalid revision errors
2349        let stderr = "error: invalid revision 'nonexistent'";
2350        let error = classify_rebase_error(stderr, "");
2351        assert!(matches!(error, RebaseErrorKind::InvalidRevision { .. }));
2352    }
2353
2354    #[test]
2355    fn test_classify_rebase_error_conflict() {
2356        // Test classification of conflict errors
2357        let stderr = "CONFLICT (content): Merge conflict in src/main.rs";
2358        let error = classify_rebase_error(stderr, "");
2359        assert!(matches!(error, RebaseErrorKind::ContentConflict { .. }));
2360    }
2361
2362    #[test]
2363    fn test_classify_rebase_error_dirty_tree() {
2364        // Test classification of dirty working tree errors
2365        let stderr = "Cannot rebase: Your index contains uncommitted changes";
2366        let error = classify_rebase_error(stderr, "");
2367        assert!(matches!(error, RebaseErrorKind::DirtyWorkingTree));
2368    }
2369
2370    #[test]
2371    fn test_classify_rebase_error_concurrent_operation() {
2372        // Test classification of concurrent operation errors
2373        let stderr = "Cannot rebase: There is a rebase in progress already";
2374        let error = classify_rebase_error(stderr, "");
2375        assert!(matches!(error, RebaseErrorKind::ConcurrentOperation { .. }));
2376    }
2377
2378    #[test]
2379    fn test_classify_rebase_error_unknown() {
2380        // Test classification of unknown errors
2381        let stderr = "Some completely unexpected error message";
2382        let error = classify_rebase_error(stderr, "");
2383        assert!(matches!(error, RebaseErrorKind::Unknown { .. }));
2384    }
2385
2386    #[test]
2387    fn test_rebase_onto_returns_result() {
2388        use test_helpers::{commit_all, init_git_repo, with_temp_cwd, write_file};
2389
2390        // Test that rebase_onto returns a Result
2391        with_temp_cwd(|dir| {
2392            // Initialize a git repo with an initial commit
2393            let repo = init_git_repo(dir);
2394            write_file(dir.path().join("initial.txt"), "initial content");
2395            let _ = commit_all(&repo, "initial commit");
2396
2397            // We use a non-existent branch to test error handling
2398            let result = rebase_onto("nonexistent_branch_that_does_not_exist");
2399            // Should return Ok (either with Failed result or other outcome)
2400            assert!(result.is_ok());
2401        });
2402    }
2403
2404    #[test]
2405    fn test_get_conflicted_files_returns_result() {
2406        use test_helpers::{init_git_repo, with_temp_cwd};
2407
2408        // Test that get_conflicted_files returns a Result
2409        with_temp_cwd(|dir| {
2410            // Initialize a git repo first
2411            let _repo = init_git_repo(dir);
2412
2413            let result = get_conflicted_files();
2414            // Should succeed (returns Vec, not error)
2415            assert!(result.is_ok());
2416        });
2417    }
2418
2419    #[test]
2420    fn test_rebase_in_progress_cli_returns_result() {
2421        use test_helpers::{init_git_repo, with_temp_cwd};
2422
2423        // Test that rebase_in_progress_cli returns a Result
2424        with_temp_cwd(|dir| {
2425            // Initialize a git repo first
2426            let _repo = init_git_repo(dir);
2427
2428            let result = rebase_in_progress_cli();
2429            // Should succeed (returns bool)
2430            assert!(result.is_ok());
2431        });
2432    }
2433
2434    #[test]
2435    fn test_is_dirty_tree_cli_returns_result() {
2436        use test_helpers::{init_git_repo, with_temp_cwd};
2437
2438        // Test that is_dirty_tree_cli returns a Result
2439        with_temp_cwd(|dir| {
2440            // Initialize a git repo first
2441            let _repo = init_git_repo(dir);
2442
2443            let result = is_dirty_tree_cli();
2444            // Should succeed (returns bool)
2445            assert!(result.is_ok());
2446        });
2447    }
2448
2449    #[test]
2450    fn test_cleanup_stale_rebase_state_returns_result() {
2451        use test_helpers::{init_git_repo, with_temp_cwd};
2452
2453        with_temp_cwd(|dir| {
2454            // Initialize a git repo first
2455            let _repo = init_git_repo(dir);
2456
2457            // Test that cleanup_stale_rebase_state returns a Result
2458            let result = cleanup_stale_rebase_state();
2459            // Should succeed even if there's nothing to clean
2460            assert!(result.is_ok());
2461        });
2462    }
2463}