Skip to main content

ralph_workflow/git_helpers/
rebase_kinds.rs

1// Error type definitions, classification, and parsing for rebase operations.
2//
3// This file contains:
4// - RebaseErrorKind enum with all failure mode categories
5// - RebaseResult enum for operation outcomes
6// - Error classification functions for Git CLI output parsing
7// - Helper functions for extracting information from error messages
8
9/// Detailed classification of rebase failure modes.
10///
11/// This enum categorizes all known Git rebase failure modes as documented
12/// in the requirements. Each variant represents a specific category of
13/// failure that may occur during a rebase operation.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum RebaseErrorKind {
16    // Category 1: Rebase Cannot Start
17    /// Invalid or unresolvable revisions (branch doesn't exist, invalid ref, etc.)
18    InvalidRevision { revision: String },
19
20    /// Dirty working tree or index (unstaged or staged changes present)
21    DirtyWorkingTree,
22
23    /// Concurrent or in-progress Git operations (rebase, merge, cherry-pick, etc.)
24    ConcurrentOperation { operation: String },
25
26    /// Repository integrity or storage failures (missing/corrupt objects, disk full, etc.)
27    RepositoryCorrupt { details: String },
28
29    /// Environment or configuration failures (missing user.name, editor unavailable, etc.)
30    EnvironmentFailure { reason: String },
31
32    /// Hook-triggered aborts (pre-rebase hook rejected the operation)
33    HookRejection { hook_name: String },
34
35    // Category 2: Rebase Stops (Interrupted)
36    /// Content conflicts (textual merge conflicts, add/add, modify/delete, etc.)
37    ContentConflict { files: Vec<String> },
38
39    /// Patch application failures (patch does not apply, context mismatch, etc.)
40    PatchApplicationFailed { reason: String },
41
42    /// Interactive todo-driven stops (edit, reword, break, exec commands)
43    InteractiveStop { command: String },
44
45    /// Empty or redundant commits (patch results in no changes)
46    EmptyCommit,
47
48    /// Autostash and stash reapplication failures
49    AutostashFailed { reason: String },
50
51    /// Commit creation failures mid-rebase (hook failures, signing failures, etc.)
52    CommitCreationFailed { reason: String },
53
54    /// Reference update failures (cannot lock branch ref, concurrent ref update, etc.)
55    ReferenceUpdateFailed { reason: String },
56
57    // Category 3: Post-Rebase Failures
58    /// Post-rebase validation failures (tests failing, build failures, etc.)
59    #[cfg(any(test, feature = "test-utils"))]
60    ValidationFailed { reason: String },
61
62    // Category 4: Interrupted/Corrupted State
63    /// Process termination (agent crash, OS kill signal, CI timeout, etc.)
64    #[cfg(any(test, feature = "test-utils"))]
65    ProcessTerminated { reason: String },
66
67    /// Incomplete or inconsistent rebase metadata
68    #[cfg(any(test, feature = "test-utils"))]
69    InconsistentState { details: String },
70
71    // Category 5: Unknown
72    /// Undefined or unknown failure modes
73    Unknown { details: String },
74}
75
76impl RebaseErrorKind {
77    /// Returns a human-readable description of the error.
78    #[must_use]
79    pub fn description(&self) -> String {
80        describe_rebase_error_kind(self)
81    }
82}
83
84fn describe_invalid_revision(revision: &str) -> String {
85    format!("Invalid or unresolvable revision: '{revision}'")
86}
87
88fn describe_dirty_working_tree() -> String {
89    "Working tree has uncommitted changes".to_string()
90}
91
92fn describe_concurrent_operation(operation: &str) -> String {
93    format!("Concurrent Git operation in progress: {operation}")
94}
95
96fn describe_repository_corrupt(details: &str) -> String {
97    format!("Repository integrity issue: {details}")
98}
99
100fn describe_environment_failure(reason: &str) -> String {
101    format!("Environment or configuration failure: {reason}")
102}
103
104fn describe_hook_rejection(hook_name: &str) -> String {
105    format!("Hook '{hook_name}' rejected the operation")
106}
107
108fn describe_content_conflict(file_count: usize) -> String {
109    format!("Merge conflicts in {file_count} file(s)",)
110}
111
112fn describe_patch_application_failed(reason: &str) -> String {
113    format!("Patch application failed: {reason}")
114}
115
116fn describe_interactive_stop(command: &str) -> String {
117    format!("Interactive rebase stopped at command: {command}")
118}
119
120fn describe_empty_commit() -> String {
121    "Empty or redundant commit".to_string()
122}
123
124fn describe_autostash_failed(reason: &str) -> String {
125    format!("Autostash failed: {reason}")
126}
127
128fn describe_commit_creation_failed(reason: &str) -> String {
129    format!("Commit creation failed: {reason}")
130}
131
132fn describe_reference_update_failed(reason: &str) -> String {
133    format!("Reference update failed: {reason}")
134}
135
136#[cfg(any(test, feature = "test-utils"))]
137fn describe_validation_failed(reason: &str) -> String {
138    format!("Post-rebase validation failed: {reason}")
139}
140
141#[cfg(any(test, feature = "test-utils"))]
142fn describe_process_terminated(reason: &str) -> String {
143    format!("Rebase process terminated: {reason}")
144}
145
146#[cfg(any(test, feature = "test-utils"))]
147fn describe_inconsistent_state(details: &str) -> String {
148    format!("Inconsistent rebase state: {details}")
149}
150
151fn describe_unknown(details: &str) -> String {
152    format!("Unknown rebase error: {details}")
153}
154
155fn describe_rebase_error_kind(kind: &RebaseErrorKind) -> String {
156    match kind {
157        RebaseErrorKind::InvalidRevision { revision } => describe_invalid_revision(revision),
158        RebaseErrorKind::DirtyWorkingTree => describe_dirty_working_tree(),
159        RebaseErrorKind::ConcurrentOperation { operation } => {
160            describe_concurrent_operation(operation)
161        }
162        RebaseErrorKind::RepositoryCorrupt { details } => describe_repository_corrupt(details),
163        RebaseErrorKind::EnvironmentFailure { reason } => describe_environment_failure(reason),
164        RebaseErrorKind::HookRejection { hook_name } => describe_hook_rejection(hook_name),
165        RebaseErrorKind::ContentConflict { files } => describe_content_conflict(files.len()),
166        RebaseErrorKind::PatchApplicationFailed { reason } => {
167            describe_patch_application_failed(reason)
168        }
169        RebaseErrorKind::InteractiveStop { command } => describe_interactive_stop(command),
170        RebaseErrorKind::EmptyCommit => describe_empty_commit(),
171        RebaseErrorKind::AutostashFailed { reason } => describe_autostash_failed(reason),
172        RebaseErrorKind::CommitCreationFailed { reason } => describe_commit_creation_failed(reason),
173        RebaseErrorKind::ReferenceUpdateFailed { reason } => {
174            describe_reference_update_failed(reason)
175        }
176        #[cfg(any(test, feature = "test-utils"))]
177        RebaseErrorKind::ValidationFailed { reason } => describe_validation_failed(reason),
178        #[cfg(any(test, feature = "test-utils"))]
179        RebaseErrorKind::ProcessTerminated { reason } => describe_process_terminated(reason),
180        #[cfg(any(test, feature = "test-utils"))]
181        RebaseErrorKind::InconsistentState { details } => describe_inconsistent_state(details),
182        RebaseErrorKind::Unknown { details } => describe_unknown(details),
183    }
184}
185
186impl RebaseErrorKind {
187    /// Returns whether this error can potentially be recovered automatically.
188    #[cfg(any(test, feature = "test-utils"))]
189    #[must_use]
190    pub const fn is_recoverable(&self) -> bool {
191        match self {
192            // These are generally recoverable with automatic retry or cleanup
193            Self::ConcurrentOperation { .. } => true,
194            #[cfg(any(test, feature = "test-utils"))]
195            Self::ProcessTerminated { .. } | Self::InconsistentState { .. } => true,
196
197            // These require manual conflict resolution
198            Self::ContentConflict { .. } => true,
199
200            // These are generally not recoverable without manual intervention
201            Self::InvalidRevision { .. }
202            | Self::DirtyWorkingTree
203            | Self::RepositoryCorrupt { .. }
204            | Self::EnvironmentFailure { .. }
205            | Self::HookRejection { .. }
206            | Self::PatchApplicationFailed { .. }
207            | Self::InteractiveStop { .. }
208            | Self::EmptyCommit
209            | Self::AutostashFailed { .. }
210            | Self::CommitCreationFailed { .. }
211            | Self::ReferenceUpdateFailed { .. } => false,
212            #[cfg(any(test, feature = "test-utils"))]
213            Self::ValidationFailed { .. } => false,
214            Self::Unknown { .. } => false,
215        }
216    }
217
218    /// Returns the category number (1-5) for this error.
219    #[cfg(any(test, feature = "test-utils"))]
220    #[must_use]
221    pub const fn category(&self) -> u8 {
222        match self {
223            Self::InvalidRevision { .. }
224            | Self::DirtyWorkingTree
225            | Self::ConcurrentOperation { .. }
226            | Self::RepositoryCorrupt { .. }
227            | Self::EnvironmentFailure { .. }
228            | Self::HookRejection { .. } => 1,
229
230            Self::ContentConflict { .. }
231            | Self::PatchApplicationFailed { .. }
232            | Self::InteractiveStop { .. }
233            | Self::EmptyCommit
234            | Self::AutostashFailed { .. }
235            | Self::CommitCreationFailed { .. }
236            | Self::ReferenceUpdateFailed { .. } => 2,
237
238            #[cfg(any(test, feature = "test-utils"))]
239            Self::ValidationFailed { .. } => 3,
240
241            #[cfg(any(test, feature = "test-utils"))]
242            Self::ProcessTerminated { .. } | Self::InconsistentState { .. } => 4,
243
244            Self::Unknown { .. } => 5,
245        }
246    }
247}
248
249impl std::fmt::Display for RebaseErrorKind {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        write!(f, "{}", self.description())
252    }
253}
254
255impl std::error::Error for RebaseErrorKind {}
256
257/// Result of a rebase operation.
258///
259/// This enum represents the possible outcomes of a rebase operation,
260/// including success, conflicts (recoverable), no-op (not applicable),
261/// and specific failure modes.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub enum RebaseResult {
264    /// Rebase completed successfully.
265    Success,
266
267    /// Rebase had conflicts that need resolution.
268    Conflicts(Vec<String>),
269
270    /// No rebase was needed (already up-to-date, not applicable, etc.).
271    NoOp { reason: String },
272
273    /// Rebase failed with a specific error kind.
274    Failed(RebaseErrorKind),
275}
276
277impl RebaseResult {
278    /// Returns whether the rebase was successful.
279    #[cfg(any(test, feature = "test-utils"))]
280    #[must_use]
281    pub const fn is_success(&self) -> bool {
282        matches!(self, Self::Success)
283    }
284
285    /// Returns whether the rebase had conflicts (needs resolution).
286    #[cfg(any(test, feature = "test-utils"))]
287    #[must_use]
288    pub const fn has_conflicts(&self) -> bool {
289        matches!(self, Self::Conflicts(_))
290    }
291
292    /// Returns whether the rebase was a no-op (not applicable).
293    #[cfg(any(test, feature = "test-utils"))]
294    #[must_use]
295    pub const fn is_noop(&self) -> bool {
296        matches!(self, Self::NoOp { .. })
297    }
298
299    /// Returns whether the rebase failed.
300    #[cfg(any(test, feature = "test-utils"))]
301    #[must_use]
302    pub const fn is_failed(&self) -> bool {
303        matches!(self, Self::Failed(_))
304    }
305
306    /// Returns the conflict files if this result contains conflicts.
307    #[cfg(any(test, feature = "test-utils"))]
308    #[must_use]
309    pub fn conflict_files(&self) -> Option<&[String]> {
310        match self {
311            Self::Conflicts(files) | Self::Failed(RebaseErrorKind::ContentConflict { files }) => {
312                Some(files)
313            }
314            _ => None,
315        }
316    }
317
318    /// Returns the error kind if this result is a failure.
319    #[cfg(any(test, feature = "test-utils"))]
320    #[must_use]
321    pub const fn error_kind(&self) -> Option<&RebaseErrorKind> {
322        match self {
323            Self::Failed(kind) => Some(kind),
324            _ => None,
325        }
326    }
327
328    /// Returns the no-op reason if this result is a no-op.
329    #[cfg(any(test, feature = "test-utils"))]
330    #[must_use]
331    pub fn noop_reason(&self) -> Option<&str> {
332        match self {
333            Self::NoOp { reason } => Some(reason),
334            _ => None,
335        }
336    }
337}