ralph_workflow/git_helpers/
rebase_classification.rs1fn 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
228pub 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
256fn extract_revision(output: &str) -> Option<String> {
258 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 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
294fn 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
331fn 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
345fn 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}