Skip to main content

ralph_workflow/git_helpers/
rebase_classification.rs

1/// Classify a Git rebase CLI error from stderr/stdout output.
2///
3/// Pure policy: maps output patterns to specific error kinds.
4fn classify_invalid_revision(output: &str) -> Option<RebaseErrorKind> {
5    if output.contains("invalid revision")
6        || output.contains("unknown revision")
7        || output.contains("bad revision")
8        || output.contains("ambiguous revision")
9        || output.contains("not found")
10        || output.contains("does not exist")
11        || output.contains("no such ref")
12    {
13        let revision = extract_revision(output);
14        Some(RebaseErrorKind::InvalidRevision {
15            revision: revision.unwrap_or_else(|| "unknown".to_string()),
16        })
17    } else {
18        None
19    }
20}
21
22fn classify_shallow_or_missing_history(output: &str) -> Option<RebaseErrorKind> {
23    if output.contains("shallow")
24        || output.contains("depth")
25        || output.contains("unreachable")
26        || output.contains("needed single revision")
27        || output.contains("does not have")
28    {
29        Some(RebaseErrorKind::RepositoryCorrupt {
30            details: format!(
31                "Shallow clone or missing history: {}",
32                extract_error_line(output)
33            ),
34        })
35    } else {
36        None
37    }
38}
39
40fn classify_worktree_conflict(output: &str) -> Option<RebaseErrorKind> {
41    if output.contains("worktree")
42        || output.contains("checked out")
43        || output.contains("another branch")
44        || output.contains("already checked out")
45    {
46        Some(RebaseErrorKind::ConcurrentOperation {
47            operation: "branch checked out in another worktree".to_string(),
48        })
49    } else {
50        None
51    }
52}
53
54fn classify_submodule_conflict(output: &str) -> Option<RebaseErrorKind> {
55    if output.contains("submodule") || output.contains(".gitmodules") {
56        Some(RebaseErrorKind::ContentConflict {
57            files: extract_conflict_files(output),
58        })
59    } else {
60        None
61    }
62}
63
64fn classify_dirty_working_tree(output: &str) -> Option<RebaseErrorKind> {
65    if output.contains("dirty")
66        || output.contains("uncommitted changes")
67        || output.contains("local changes")
68        || output.contains("cannot rebase")
69    {
70        Some(RebaseErrorKind::DirtyWorkingTree)
71    } else {
72        None
73    }
74}
75
76fn classify_concurrent_operation(output: &str) -> Option<RebaseErrorKind> {
77    if output.contains("rebase in progress")
78        || output.contains("merge in progress")
79        || output.contains("cherry-pick in progress")
80        || output.contains("revert in progress")
81        || output.contains("bisect in progress")
82        || output.contains("Another git process")
83        || output.contains("Locked")
84    {
85        let operation = extract_operation(output);
86        Some(RebaseErrorKind::ConcurrentOperation {
87            operation: operation.unwrap_or_else(|| "unknown".to_string()),
88        })
89    } else {
90        None
91    }
92}
93
94fn classify_repository_corruption(output: &str) -> Option<RebaseErrorKind> {
95    if output.contains("corrupt")
96        || output.contains("object not found")
97        || output.contains("missing object")
98        || output.contains("invalid object")
99        || output.contains("bad object")
100        || output.contains("disk full")
101        || output.contains("filesystem")
102    {
103        Some(RebaseErrorKind::RepositoryCorrupt {
104            details: extract_error_line(output),
105        })
106    } else {
107        None
108    }
109}
110
111fn classify_environment_failure(output: &str) -> Option<RebaseErrorKind> {
112    if output.contains("user.name")
113        || output.contains("user.email")
114        || output.contains("author")
115        || output.contains("committer")
116        || output.contains("terminal")
117        || output.contains("editor")
118    {
119        Some(RebaseErrorKind::EnvironmentFailure {
120            reason: extract_error_line(output),
121        })
122    } else {
123        None
124    }
125}
126
127fn classify_hook_rejection(output: &str) -> Option<RebaseErrorKind> {
128    if output.contains("pre-rebase") || output.contains("hook") || output.contains("rejected by") {
129        Some(RebaseErrorKind::HookRejection {
130            hook_name: extract_hook_name(output),
131        })
132    } else {
133        None
134    }
135}
136
137fn classify_content_conflict(output: &str) -> Option<RebaseErrorKind> {
138    if output.contains("Conflict")
139        || output.contains("conflict")
140        || output.contains("Resolve")
141        || output.contains("Merge conflict")
142    {
143        Some(RebaseErrorKind::ContentConflict {
144            files: extract_conflict_files(output),
145        })
146    } else {
147        None
148    }
149}
150
151fn classify_patch_failure(output: &str) -> Option<RebaseErrorKind> {
152    if output.contains("patch does not apply")
153        || output.contains("patch failed")
154        || output.contains("hunk failed")
155        || output.contains("context mismatch")
156        || output.contains("fuzz")
157    {
158        Some(RebaseErrorKind::PatchApplicationFailed {
159            reason: extract_error_line(output),
160        })
161    } else {
162        None
163    }
164}
165
166fn classify_interactive_stop(output: &str) -> Option<RebaseErrorKind> {
167    if output.contains("Stopped at") || output.contains("paused") || output.contains("edit command")
168    {
169        Some(RebaseErrorKind::InteractiveStop {
170            command: extract_command(output),
171        })
172    } else {
173        None
174    }
175}
176
177fn classify_empty_commit(output: &str) -> Option<RebaseErrorKind> {
178    if output.contains("empty")
179        || output.contains("no changes")
180        || output.contains("already applied")
181    {
182        Some(RebaseErrorKind::EmptyCommit)
183    } else {
184        None
185    }
186}
187
188fn classify_autostash_failure(output: &str) -> Option<RebaseErrorKind> {
189    if output.contains("autostash") || output.contains("stash") {
190        Some(RebaseErrorKind::AutostashFailed {
191            reason: extract_error_line(output),
192        })
193    } else {
194        None
195    }
196}
197
198fn classify_commit_creation_failure(output: &str) -> Option<RebaseErrorKind> {
199    if output.contains("pre-commit")
200        || output.contains("commit-msg")
201        || output.contains("prepare-commit-msg")
202        || output.contains("post-commit")
203        || output.contains("signing")
204        || output.contains("GPG")
205    {
206        Some(RebaseErrorKind::CommitCreationFailed {
207            reason: extract_error_line(output),
208        })
209    } else {
210        None
211    }
212}
213
214fn classify_reference_update_failure(output: &str) -> Option<RebaseErrorKind> {
215    if output.contains("cannot lock")
216        || output.contains("ref update")
217        || output.contains("packed-refs")
218        || output.contains("reflog")
219    {
220        Some(RebaseErrorKind::ReferenceUpdateFailed {
221            reason: extract_error_line(output),
222        })
223    } else {
224        None
225    }
226}
227
228/// Parse Git CLI output to classify rebase errors.
229///
230/// This function analyzes stderr/stdout from git rebase commands
231/// to determine the specific failure mode.
232pub fn classify_rebase_error(stderr: &str, stdout: &str) -> RebaseErrorKind {
233    let output = format!("{stderr}\n{stdout}");
234
235    classify_invalid_revision(&output)
236        .or_else(|| classify_shallow_or_missing_history(&output))
237        .or_else(|| classify_worktree_conflict(&output))
238        .or_else(|| classify_submodule_conflict(&output))
239        .or_else(|| classify_dirty_working_tree(&output))
240        .or_else(|| classify_concurrent_operation(&output))
241        .or_else(|| classify_repository_corruption(&output))
242        .or_else(|| classify_environment_failure(&output))
243        .or_else(|| classify_hook_rejection(&output))
244        .or_else(|| classify_content_conflict(&output))
245        .or_else(|| classify_patch_failure(&output))
246        .or_else(|| classify_interactive_stop(&output))
247        .or_else(|| classify_empty_commit(&output))
248        .or_else(|| classify_autostash_failure(&output))
249        .or_else(|| classify_commit_creation_failure(&output))
250        .or_else(|| classify_reference_update_failure(&output))
251        .unwrap_or_else(|| RebaseErrorKind::Unknown {
252            details: extract_error_line(&output),
253        })
254}
255
256/// Extract revision name from error output.
257fn extract_revision(output: &str) -> Option<String> {
258    // Look for patterns like "invalid revision 'foo'" or "unknown revision 'bar'"
259    // Using simple string parsing instead of regex for reliability
260    let patterns = [
261        ("invalid revision '", "'"),
262        ("unknown revision '", "'"),
263        ("bad revision '", "'"),
264        ("branch '", "' not found"),
265        ("upstream branch '", "' not found"),
266        ("revision ", " not found"),
267        ("'", "'"),
268    ];
269
270    patterns.iter().find_map(|(start, end)| {
271        let start_idx = output.find(start)?;
272        let after_start = &output[start_idx + start.len()..];
273        let end_idx = after_start.find(end)?;
274        let revision = &after_start[..end_idx];
275        (!revision.is_empty()).then_some(revision.to_string())
276    })?;
277
278    // Also try to extract branch names from error messages
279    output
280        .lines()
281        .find(|line| line.contains("not found") || line.contains("does not exist"))
282        .and_then(|line| {
283            let words: Vec<&str> = line.split_whitespace().collect();
284            words
285                .iter()
286                .position(|word| {
287                    *word == "'" || (*word == "\"" && words.iter().take(3).any(|w| *w == "\""))
288                })
289                .and_then(|i| words.get(i + 1))
290                .map(|w| w.to_string())
291        })
292}
293
294/// Extract operation name from error output.
295fn extract_operation(output: &str) -> Option<String> {
296    [
297        ("rebase in progress", "rebase"),
298        ("merge in progress", "merge"),
299        ("cherry-pick in progress", "cherry-pick"),
300        ("revert in progress", "revert"),
301        ("bisect in progress", "bisect"),
302    ]
303    .iter()
304    .find(|(pattern, _)| output.contains(pattern))
305    .map(|(_, name)| name.to_string())
306}
307
308fn extract_hook_name(output: &str) -> String {
309    [
310        ("pre-rebase", "pre-rebase"),
311        ("pre-commit", "pre-commit"),
312        ("commit-msg", "commit-msg"),
313        ("post-commit", "post-commit"),
314    ]
315    .iter()
316    .find(|(pattern, _)| output.contains(pattern))
317    .map(|(_, name)| name)
318    .unwrap_or(&"hook")
319    .to_string()
320}
321
322fn extract_command(output: &str) -> String {
323    ["edit", "reword", "break", "exec"]
324        .iter()
325        .find(|cmd| output.contains(*cmd))
326        .copied()
327        .unwrap_or("unknown")
328        .to_string()
329}
330
331/// Extract the first meaningful error line from output.
332fn extract_error_line(output: &str) -> String {
333    output
334        .lines()
335        .find(|line| {
336            !line.is_empty()
337                && !line.starts_with("hint:")
338                && !line.starts_with("Hint:")
339                && !line.starts_with("note:")
340                && !line.starts_with("Note:")
341        })
342        .map_or_else(|| output.trim().to_string(), |s| s.trim().to_string())
343}
344
345/// Extract conflict file paths from error output.
346fn extract_conflict_files(output: &str) -> Vec<String> {
347    output
348        .lines()
349        .filter(|line| {
350            line.contains("CONFLICT")
351                || line.contains("Conflict")
352                || line.contains("Merge conflict")
353        })
354        .filter_map(|line| {
355            line.find("in ").map(|start| {
356                let path = line[start + 3..].trim();
357                (!path.is_empty()).then_some(path.to_string())
358            })
359        })
360        .flatten()
361        .collect()
362}