Skip to main content

ralph_workflow/git_helpers/
rebase.rs

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