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