1#![deny(unsafe_code)]
25
26use std::io;
27use std::path::Path;
28
29fn git2_to_io_error(err: &git2::Error) -> io::Error {
31 io::Error::other(err.to_string())
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum RebaseErrorKind {
41 InvalidRevision { revision: String },
44
45 DirtyWorkingTree,
47
48 ConcurrentOperation { operation: String },
50
51 RepositoryCorrupt { details: String },
53
54 EnvironmentFailure { reason: String },
56
57 HookRejection { hook_name: String },
59
60 ContentConflict { files: Vec<String> },
63
64 PatchApplicationFailed { reason: String },
66
67 InteractiveStop { command: String },
69
70 EmptyCommit,
72
73 AutostashFailed { reason: String },
75
76 CommitCreationFailed { reason: String },
78
79 ReferenceUpdateFailed { reason: String },
81
82 #[cfg(any(test, feature = "test-utils"))]
85 ValidationFailed { reason: String },
86
87 #[cfg(any(test, feature = "test-utils"))]
90 ProcessTerminated { reason: String },
91
92 #[cfg(any(test, feature = "test-utils"))]
94 InconsistentState { details: String },
95
96 Unknown { details: String },
99}
100
101impl RebaseErrorKind {
102 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 #[cfg(any(test, feature = "test-utils"))]
160 pub fn is_recoverable(&self) -> bool {
161 match self {
162 RebaseErrorKind::ConcurrentOperation { .. } => true,
164 #[cfg(any(test, feature = "test-utils"))]
165 RebaseErrorKind::ProcessTerminated { .. }
166 | RebaseErrorKind::InconsistentState { .. } => true,
167
168 RebaseErrorKind::ContentConflict { .. } => true,
170
171 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum RebaseResult {
235 Success,
237
238 Conflicts(Vec<String>),
240
241 NoOp { reason: String },
243
244 Failed(RebaseErrorKind),
246}
247
248impl RebaseResult {
249 #[cfg(any(test, feature = "test-utils"))]
251 pub fn is_success(&self) -> bool {
252 matches!(self, RebaseResult::Success)
253 }
254
255 #[cfg(any(test, feature = "test-utils"))]
257 pub fn has_conflicts(&self) -> bool {
258 matches!(self, RebaseResult::Conflicts(_))
259 }
260
261 #[cfg(any(test, feature = "test-utils"))]
263 pub fn is_noop(&self) -> bool {
264 matches!(self, RebaseResult::NoOp { .. })
265 }
266
267 #[cfg(any(test, feature = "test-utils"))]
269 pub fn is_failed(&self) -> bool {
270 matches!(self, RebaseResult::Failed(_))
271 }
272
273 #[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 #[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 #[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
302pub fn classify_rebase_error(stderr: &str, stdout: &str) -> RebaseErrorKind {
307 let combined = format!("{stderr}\n{stdout}");
308
309 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 let revision = extract_revision(&combined);
323 return RebaseErrorKind::InvalidRevision {
324 revision: revision.unwrap_or_else(|| "unknown".to_string()),
325 };
326 }
327
328 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 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 if combined.contains("submodule") || combined.contains(".gitmodules") {
356 return RebaseErrorKind::ContentConflict {
357 files: extract_conflict_files(&combined),
358 };
359 }
360
361 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 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 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 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 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 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 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 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 if combined.contains("empty")
459 || combined.contains("no changes")
460 || combined.contains("already applied")
461 {
462 return RebaseErrorKind::EmptyCommit;
463 }
464
465 if combined.contains("autostash") || combined.contains("stash") {
467 return RebaseErrorKind::AutostashFailed {
468 reason: extract_error_line(&combined),
469 };
470 }
471
472 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 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 RebaseErrorKind::Unknown {
498 details: extract_error_line(&combined),
499 }
500}
501
502fn extract_revision(output: &str) -> Option<String> {
504 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 for line in output.lines() {
530 if line.contains("not found") || line.contains("does not exist") {
531 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
548fn 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
565fn 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
580fn 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
595fn 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
610fn 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 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#[derive(Debug, Clone, PartialEq, Eq)]
637#[cfg(any(test, feature = "test-utils"))]
638pub enum ConcurrentOperation {
639 Rebase,
641 Merge,
643 CherryPick,
645 Revert,
647 Bisect,
649 OtherGitProcess,
651 Unknown(String),
653}
654
655#[cfg(any(test, feature = "test-utils"))]
656impl ConcurrentOperation {
657 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#[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 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 let merge_head = git_dir.join("MERGE_HEAD");
715 if merge_head.exists() {
716 return Ok(Some(ConcurrentOperation::Merge));
717 }
718
719 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 let revert_head = git_dir.join("REVERT_HEAD");
727 if revert_head.exists() {
728 return Ok(Some(ConcurrentOperation::Revert));
729 }
730
731 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 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 return Ok(Some(ConcurrentOperation::OtherGitProcess));
747 }
748
749 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 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#[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#[derive(Debug, Clone, Default)]
785#[cfg(any(test, feature = "test-utils"))]
786pub struct CleanupResult {
787 pub cleaned_paths: Vec<String>,
789 pub locks_removed: bool,
791}
792
793#[cfg(any(test, feature = "test-utils"))]
794impl CleanupResult {
795 pub fn has_cleanup(&self) -> bool {
797 !self.cleaned_paths.is_empty() || self.locks_removed
798 }
799
800 pub fn count(&self) -> usize {
802 self.cleaned_paths.len() + if self.locks_removed { 1 } else { 0 }
803 }
804}
805
806#[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 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 let is_valid = validate_state_file(&full_path);
844 if !is_valid.unwrap_or(true) {
845 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 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 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#[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 if path.is_dir() {
904 let entries = fs::read_dir(path)?;
906 let has_content = entries.count() > 0;
907 return Ok(has_content);
908 }
909
910 if path.is_file() {
912 let metadata = fs::metadata(path)?;
913 if metadata.len() == 0 {
914 return Ok(false);
916 }
917 let _ = fs::read(path)?;
919 return Ok(true);
920 }
921
922 Ok(false)
923}
924
925#[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 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 if cleanup_stale_rebase_state().is_ok() {
986 if validate_git_state().is_ok() {
988 return Ok(true);
989 }
990 }
991
992 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 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 if let RebaseErrorKind::ConcurrentOperation { .. } = error_kind {
1012 let abort_result = executor.execute("git", &["rebase", "--abort"], &[], None);
1014
1015 if abort_result.is_ok() {
1016 if validate_git_state().is_ok() {
1018 return Ok(true);
1019 }
1020 }
1021 }
1022
1023 Ok(false)
1025}
1026
1027#[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 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 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 if let Ok(head) = repo.head() {
1058 if let Ok(commit) = head.peel_to_commit() {
1059 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#[cfg(any(test, feature = "test-utils"))]
1085pub fn is_dirty_tree_cli(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<bool> {
1086 let output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
1087
1088 if output.status.success() {
1089 let stdout = output.stdout.trim();
1090 Ok(!stdout.is_empty())
1091 } else {
1092 Err(io::Error::other(format!(
1093 "Failed to check working tree status: {}",
1094 output.stderr
1095 )))
1096 }
1097}
1098
1099#[cfg(any(test, feature = "test-utils"))]
1136pub fn validate_rebase_preconditions(
1137 executor: &dyn crate::executor::ProcessExecutor,
1138) -> io::Result<()> {
1139 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1140
1141 validate_git_state()?;
1143
1144 if let Some(concurrent_op) = detect_concurrent_git_operations()? {
1146 return Err(io::Error::new(
1147 io::ErrorKind::InvalidInput,
1148 format!(
1149 "Cannot start rebase: {} already in progress. \
1150 Please complete or abort the current operation first.",
1151 concurrent_op.description()
1152 ),
1153 ));
1154 }
1155
1156 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1158
1159 let user_name = config.get_string("user.name");
1160 let user_email = config.get_string("user.email");
1161
1162 if user_name.is_err() && user_email.is_err() {
1163 return Err(io::Error::new(
1164 io::ErrorKind::InvalidInput,
1165 "Git identity is not configured. Please set user.name and user.email:\n \
1166 git config --global user.name \"Your Name\"\n \
1167 git config --global user.email \"you@example.com\"",
1168 ));
1169 }
1170
1171 let status_output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
1173
1174 if status_output.status.success() {
1175 let stdout = status_output.stdout.trim();
1176 if !stdout.is_empty() {
1177 return Err(io::Error::new(
1178 io::ErrorKind::InvalidInput,
1179 "Working tree is not clean. Please commit or stash changes before rebasing.",
1180 ));
1181 }
1182 } else {
1183 let statuses = repo.statuses(None).map_err(|e| {
1185 io::Error::new(
1186 io::ErrorKind::InvalidData,
1187 format!("Failed to check working tree status: {e}"),
1188 )
1189 })?;
1190
1191 if !statuses.is_empty() {
1192 return Err(io::Error::new(
1193 io::ErrorKind::InvalidInput,
1194 "Working tree is not clean. Please commit or stash changes before rebasing.",
1195 ));
1196 }
1197 }
1198
1199 check_shallow_clone()?;
1201
1202 check_worktree_conflicts()?;
1204
1205 check_submodule_state()?;
1207
1208 check_sparse_checkout_state()?;
1210
1211 Ok(())
1212}
1213
1214#[cfg(any(test, feature = "test-utils"))]
1224fn check_shallow_clone() -> io::Result<()> {
1225 use std::fs;
1226
1227 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1228 let git_dir = repo.path();
1229
1230 let shallow_file = git_dir.join("shallow");
1232 if shallow_file.exists() {
1233 let content = fs::read_to_string(&shallow_file).unwrap_or_default();
1235 let line_count = content.lines().count();
1236
1237 return Err(io::Error::new(
1238 io::ErrorKind::InvalidInput,
1239 format!(
1240 "Repository is a shallow clone with {} commits. \
1241 Rebasing may fail due to missing history. \
1242 Consider running: git fetch --unshallow",
1243 line_count
1244 ),
1245 ));
1246 }
1247
1248 Ok(())
1249}
1250
1251#[cfg(any(test, feature = "test-utils"))]
1261fn check_worktree_conflicts() -> io::Result<()> {
1262 use std::fs;
1263
1264 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1265
1266 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1268 let branch_name = match head.shorthand() {
1269 Some(name) if head.is_branch() => name,
1270 _ => return Ok(()), };
1272
1273 let git_dir = repo.path();
1274 let worktrees_dir = git_dir.join("worktrees");
1275
1276 if !worktrees_dir.exists() {
1277 return Ok(());
1278 }
1279
1280 let entries = fs::read_dir(&worktrees_dir).map_err(|e| {
1282 io::Error::new(
1283 io::ErrorKind::InvalidData,
1284 format!("Failed to read worktrees directory: {e}"),
1285 )
1286 })?;
1287
1288 for entry in entries.flatten() {
1289 let worktree_path = entry.path();
1290 let worktree_head = worktree_path.join("HEAD");
1291
1292 if worktree_head.exists() {
1293 if let Ok(content) = fs::read_to_string(&worktree_head) {
1294 if content.contains(&format!("refs/heads/{branch_name}")) {
1296 let worktree_name = worktree_path
1298 .file_name()
1299 .and_then(|n| n.to_str())
1300 .unwrap_or("unknown");
1301
1302 return Err(io::Error::new(
1303 io::ErrorKind::InvalidInput,
1304 format!(
1305 "Branch '{branch_name}' is already checked out in worktree '{worktree_name}'. \
1306 Use 'git worktree add' to create a new worktree for this branch."
1307 ),
1308 ));
1309 }
1310 }
1311 }
1312 }
1313
1314 Ok(())
1315}
1316
1317#[cfg(any(test, feature = "test-utils"))]
1327fn check_submodule_state() -> io::Result<()> {
1328 use std::fs;
1329
1330 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1331 let git_dir = repo.path();
1332
1333 let workdir = repo.workdir().unwrap_or(git_dir);
1335 let gitmodules_path = workdir.join(".gitmodules");
1336
1337 if !gitmodules_path.exists() {
1338 return Ok(()); }
1340
1341 let modules_dir = git_dir.join("modules");
1343 if !modules_dir.exists() {
1344 return Err(io::Error::new(
1346 io::ErrorKind::InvalidInput,
1347 "Submodules are not initialized. Run: git submodule update --init --recursive",
1348 ));
1349 }
1350
1351 let gitmodules_content = fs::read_to_string(&gitmodules_path).unwrap_or_default();
1353 let submodule_count = gitmodules_content.matches("path = ").count();
1354
1355 if submodule_count > 0 {
1356 for line in gitmodules_content.lines() {
1358 if line.contains("path = ") {
1359 if let Some(path) = line.split("path = ").nth(1) {
1360 let submodule_path = workdir.join(path.trim());
1361 if !submodule_path.exists() {
1362 return Err(io::Error::new(
1363 io::ErrorKind::InvalidInput,
1364 format!(
1365 "Submodule '{}' is not initialized. Run: git submodule update --init --recursive",
1366 path.trim()
1367 ),
1368 ));
1369 }
1370 }
1371 }
1372 }
1373 }
1374
1375 Ok(())
1376}
1377
1378#[cfg(any(test, feature = "test-utils"))]
1388fn check_sparse_checkout_state() -> io::Result<()> {
1389 use std::fs;
1390
1391 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1392 let git_dir = repo.path();
1393
1394 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1396
1397 let sparse_checkout = config.get_bool("core.sparseCheckout");
1398 let sparse_checkout_cone = config.get_bool("extensions.sparseCheckoutCone");
1399
1400 match (sparse_checkout, sparse_checkout_cone) {
1401 (Ok(true), _) | (_, Ok(true)) => {
1402 let info_sparse_dir = git_dir.join("info").join("sparse-checkout");
1404
1405 if !info_sparse_dir.exists() {
1406 return Err(io::Error::new(
1408 io::ErrorKind::InvalidInput,
1409 "Sparse checkout is enabled but not configured. \
1410 Run: git sparse-checkout init",
1411 ));
1412 }
1413
1414 if let Ok(content) = fs::read_to_string(&info_sparse_dir) {
1416 if content.trim().is_empty() {
1417 return Err(io::Error::new(
1418 io::ErrorKind::InvalidInput,
1419 "Sparse checkout configuration is empty. \
1420 Run: git sparse-checkout set <patterns>",
1421 ));
1422 }
1423 }
1424
1425 }
1430 (Err(_), _) | (_, Err(_)) => {
1431 }
1433 _ => {}
1434 }
1435
1436 Ok(())
1437}
1438
1439pub fn rebase_onto(
1473 upstream_branch: &str,
1474 executor: &dyn crate::executor::ProcessExecutor,
1475) -> io::Result<RebaseResult> {
1476 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1477 rebase_onto_impl(&repo, upstream_branch, executor)
1478}
1479
1480fn rebase_onto_impl(
1482 repo: &git2::Repository,
1483 upstream_branch: &str,
1484 executor: &dyn crate::executor::ProcessExecutor,
1485) -> io::Result<RebaseResult> {
1486 match repo.head() {
1489 Ok(_) => {}
1490 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
1491 return Ok(RebaseResult::NoOp {
1493 reason: "Repository has no commits yet (unborn branch)".to_string(),
1494 });
1495 }
1496 Err(e) => return Err(git2_to_io_error(&e)),
1497 }
1498
1499 let upstream_object = match repo.revparse_single(upstream_branch) {
1501 Ok(obj) => obj,
1502 Err(_) => {
1503 return Ok(RebaseResult::Failed(RebaseErrorKind::InvalidRevision {
1504 revision: upstream_branch.to_string(),
1505 }))
1506 }
1507 };
1508
1509 let upstream_commit = upstream_object
1510 .peel_to_commit()
1511 .map_err(|e| git2_to_io_error(&e))?;
1512
1513 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1515 let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
1516
1517 if repo
1519 .graph_descendant_of(head_commit.id(), upstream_commit.id())
1520 .map_err(|e| git2_to_io_error(&e))?
1521 {
1522 return Ok(RebaseResult::NoOp {
1524 reason: "Branch is already up-to-date with upstream".to_string(),
1525 });
1526 }
1527
1528 match repo.merge_base(head_commit.id(), upstream_commit.id()) {
1531 Err(e)
1532 if e.class() == git2::ErrorClass::Reference
1533 && e.code() == git2::ErrorCode::NotFound =>
1534 {
1535 return Ok(RebaseResult::NoOp {
1537 reason: format!(
1538 "No common ancestor between current branch and '{upstream_branch}' (unrelated branches)"
1539 ),
1540 });
1541 }
1542 Err(e) => return Err(git2_to_io_error(&e)),
1543 Ok(_) => {}
1544 }
1545
1546 let branch_name = match head.shorthand() {
1548 Some(name) => name,
1549 None => {
1550 return Ok(RebaseResult::NoOp {
1552 reason: "HEAD is detached (not on any branch), rebase not applicable".to_string(),
1553 });
1554 }
1555 };
1556
1557 if branch_name == "main" || branch_name == "master" {
1558 return Ok(RebaseResult::NoOp {
1559 reason: format!("Already on '{branch_name}' branch, rebase not applicable"),
1560 });
1561 }
1562
1563 let output = executor.execute("git", &["rebase", upstream_branch], &[], None)?;
1565
1566 if output.status.success() {
1567 Ok(RebaseResult::Success)
1568 } else {
1569 let stderr = &output.stderr;
1570 let stdout = &output.stdout;
1571
1572 let error_kind = classify_rebase_error(stderr, stdout);
1574
1575 match error_kind {
1576 RebaseErrorKind::ContentConflict { .. } => {
1577 match get_conflicted_files() {
1579 Ok(files) if files.is_empty() => {
1580 if let RebaseErrorKind::ContentConflict { files } = error_kind {
1583 Ok(RebaseResult::Conflicts(files))
1584 } else {
1585 Ok(RebaseResult::Conflicts(vec![]))
1586 }
1587 }
1588 Ok(files) => Ok(RebaseResult::Conflicts(files)),
1589 Err(_) => Ok(RebaseResult::Conflicts(vec![])),
1590 }
1591 }
1592 RebaseErrorKind::Unknown { .. } => {
1593 if stderr.contains("up to date") {
1595 Ok(RebaseResult::NoOp {
1596 reason: "Branch is already up-to-date with upstream".to_string(),
1597 })
1598 } else {
1599 Ok(RebaseResult::Failed(error_kind))
1600 }
1601 }
1602 _ => Ok(RebaseResult::Failed(error_kind)),
1603 }
1604 }
1605}
1606
1607pub fn abort_rebase(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<()> {
1623 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1624 abort_rebase_impl(&repo, executor)
1625}
1626
1627fn abort_rebase_impl(
1629 repo: &git2::Repository,
1630 executor: &dyn crate::executor::ProcessExecutor,
1631) -> io::Result<()> {
1632 let state = repo.state();
1634 if state != git2::RepositoryState::Rebase
1635 && state != git2::RepositoryState::RebaseMerge
1636 && state != git2::RepositoryState::RebaseInteractive
1637 {
1638 return Err(io::Error::new(
1639 io::ErrorKind::InvalidInput,
1640 "No rebase in progress",
1641 ));
1642 }
1643
1644 let output = executor.execute("git", &["rebase", "--abort"], &[], None)?;
1646
1647 if output.status.success() {
1648 Ok(())
1649 } else {
1650 Err(io::Error::other(format!(
1651 "Failed to abort rebase: {}",
1652 output.stderr
1653 )))
1654 }
1655}
1656
1657pub fn get_conflicted_files() -> io::Result<Vec<String>> {
1668 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1669 get_conflicted_files_impl(&repo)
1670}
1671
1672fn get_conflicted_files_impl(repo: &git2::Repository) -> io::Result<Vec<String>> {
1674 let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1675
1676 let mut conflicted_files = Vec::new();
1677
1678 if !index.has_conflicts() {
1680 return Ok(conflicted_files);
1681 }
1682
1683 let conflicts = index.conflicts().map_err(|e| git2_to_io_error(&e))?;
1685
1686 for conflict in conflicts {
1687 let conflict = conflict.map_err(|e| git2_to_io_error(&e))?;
1688 if let Some(our_entry) = conflict.our {
1690 if let Ok(path) = std::str::from_utf8(&our_entry.path) {
1691 let path_str = path.to_string();
1692 if !conflicted_files.contains(&path_str) {
1693 conflicted_files.push(path_str);
1694 }
1695 }
1696 }
1697 }
1698
1699 Ok(conflicted_files)
1700}
1701
1702pub fn get_conflict_markers_for_file(path: &Path) -> io::Result<String> {
1716 use std::fs;
1717 use std::io::Read;
1718
1719 let mut file = fs::File::open(path)?;
1720 let mut content = String::new();
1721 file.read_to_string(&mut content)?;
1722
1723 let mut conflict_sections = Vec::new();
1725 let lines: Vec<&str> = content.lines().collect();
1726 let mut i = 0;
1727
1728 while i < lines.len() {
1729 if lines[i].trim_start().starts_with("<<<<<<<") {
1730 let mut section = Vec::new();
1732 section.push(lines[i]);
1733
1734 i += 1;
1735 while i < lines.len() && !lines[i].trim_start().starts_with("=======") {
1737 section.push(lines[i]);
1738 i += 1;
1739 }
1740
1741 if i < lines.len() {
1742 section.push(lines[i]); i += 1;
1744 }
1745
1746 while i < lines.len() && !lines[i].trim_start().starts_with(">>>>>>>") {
1748 section.push(lines[i]);
1749 i += 1;
1750 }
1751
1752 if i < lines.len() {
1753 section.push(lines[i]); i += 1;
1755 }
1756
1757 conflict_sections.push(section.join("\n"));
1758 } else {
1759 i += 1;
1760 }
1761 }
1762
1763 if conflict_sections.is_empty() {
1764 Ok(String::new())
1766 } else {
1767 Ok(conflict_sections.join("\n\n"))
1768 }
1769}
1770
1771#[cfg(any(test, feature = "test-utils"))]
1791pub fn verify_rebase_completed(upstream_branch: &str) -> io::Result<bool> {
1792 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1793
1794 let state = repo.state();
1796 if state == git2::RepositoryState::Rebase
1797 || state == git2::RepositoryState::RebaseMerge
1798 || state == git2::RepositoryState::RebaseInteractive
1799 {
1800 return Ok(false);
1802 }
1803
1804 let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1806 if index.has_conflicts() {
1807 return Ok(false);
1809 }
1810
1811 let head = repo.head().map_err(|e| {
1813 io::Error::new(
1814 io::ErrorKind::InvalidData,
1815 format!("Repository HEAD is invalid: {e}"),
1816 )
1817 })?;
1818
1819 if let Ok(head_commit) = head.peel_to_commit() {
1822 if let Ok(upstream_object) = repo.revparse_single(upstream_branch) {
1823 if let Ok(upstream_commit) = upstream_object.peel_to_commit() {
1824 match repo.graph_descendant_of(head_commit.id(), upstream_commit.id()) {
1827 Ok(is_descendant) => {
1828 if is_descendant {
1829 return Ok(true);
1831 } else {
1832 return Ok(false);
1835 }
1836 }
1837 Err(e) => {
1838 let _ = e; }
1841 }
1842 }
1843 }
1844 }
1845
1846 Ok(!index.has_conflicts())
1849}
1850
1851pub fn continue_rebase(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<()> {
1866 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1867 continue_rebase_impl(&repo, executor)
1868}
1869
1870fn continue_rebase_impl(
1872 repo: &git2::Repository,
1873 executor: &dyn crate::executor::ProcessExecutor,
1874) -> io::Result<()> {
1875 let state = repo.state();
1877 if state != git2::RepositoryState::Rebase
1878 && state != git2::RepositoryState::RebaseMerge
1879 && state != git2::RepositoryState::RebaseInteractive
1880 {
1881 return Err(io::Error::new(
1882 io::ErrorKind::InvalidInput,
1883 "No rebase in progress",
1884 ));
1885 }
1886
1887 let conflicted = get_conflicted_files()?;
1889 if !conflicted.is_empty() {
1890 return Err(io::Error::new(
1891 io::ErrorKind::InvalidInput,
1892 format!(
1893 "Cannot continue rebase: {} file(s) still have conflicts",
1894 conflicted.len()
1895 ),
1896 ));
1897 }
1898
1899 let output = executor.execute("git", &["rebase", "--continue"], &[], None)?;
1901
1902 if output.status.success() {
1903 Ok(())
1904 } else {
1905 Err(io::Error::other(format!(
1906 "Failed to continue rebase: {}",
1907 output.stderr
1908 )))
1909 }
1910}
1911
1912pub fn rebase_in_progress() -> io::Result<bool> {
1924 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1925 rebase_in_progress_impl(&repo)
1926}
1927
1928fn rebase_in_progress_impl(repo: &git2::Repository) -> io::Result<bool> {
1930 let state = repo.state();
1931 Ok(state == git2::RepositoryState::Rebase
1932 || state == git2::RepositoryState::RebaseMerge
1933 || state == git2::RepositoryState::RebaseInteractive)
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938 use super::*;
1939 use crate::executor::MockProcessExecutor;
1940 use std::sync::Arc;
1941
1942 #[test]
1943 fn test_rebase_result_variants_exist() {
1944 let _ = RebaseResult::Success;
1946 let _ = RebaseResult::NoOp {
1947 reason: "test".to_string(),
1948 };
1949 let _ = RebaseResult::Conflicts(vec![]);
1950 let _ = RebaseResult::Failed(RebaseErrorKind::Unknown {
1951 details: "test".to_string(),
1952 });
1953 }
1954
1955 #[test]
1956 fn test_rebase_result_is_noop() {
1957 assert!(RebaseResult::NoOp {
1959 reason: "test".to_string()
1960 }
1961 .is_noop());
1962 assert!(!RebaseResult::Success.is_noop());
1963 assert!(!RebaseResult::Conflicts(vec![]).is_noop());
1964 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
1965 details: "test".to_string(),
1966 })
1967 .is_noop());
1968 }
1969
1970 #[test]
1971 fn test_rebase_result_is_success() {
1972 assert!(RebaseResult::Success.is_success());
1974 assert!(!RebaseResult::NoOp {
1975 reason: "test".to_string()
1976 }
1977 .is_success());
1978 assert!(!RebaseResult::Conflicts(vec![]).is_success());
1979 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
1980 details: "test".to_string(),
1981 })
1982 .is_success());
1983 }
1984
1985 #[test]
1986 fn test_rebase_result_has_conflicts() {
1987 assert!(RebaseResult::Conflicts(vec!["file.txt".to_string()]).has_conflicts());
1989 assert!(!RebaseResult::Success.has_conflicts());
1990 assert!(!RebaseResult::NoOp {
1991 reason: "test".to_string()
1992 }
1993 .has_conflicts());
1994 }
1995
1996 #[test]
1997 fn test_rebase_result_is_failed() {
1998 assert!(RebaseResult::Failed(RebaseErrorKind::Unknown {
2000 details: "test".to_string(),
2001 })
2002 .is_failed());
2003 assert!(!RebaseResult::Success.is_failed());
2004 assert!(!RebaseResult::NoOp {
2005 reason: "test".to_string()
2006 }
2007 .is_failed());
2008 assert!(!RebaseResult::Conflicts(vec![]).is_failed());
2009 }
2010
2011 #[test]
2012 fn test_rebase_error_kind_description() {
2013 let err = RebaseErrorKind::InvalidRevision {
2015 revision: "main".to_string(),
2016 };
2017 assert!(err.description().contains("main"));
2018
2019 let err = RebaseErrorKind::DirtyWorkingTree;
2020 assert!(err.description().contains("Working tree"));
2021 }
2022
2023 #[test]
2024 fn test_rebase_error_kind_category() {
2025 assert_eq!(
2027 RebaseErrorKind::InvalidRevision {
2028 revision: "test".to_string()
2029 }
2030 .category(),
2031 1
2032 );
2033 assert_eq!(
2034 RebaseErrorKind::ContentConflict { files: vec![] }.category(),
2035 2
2036 );
2037 assert_eq!(
2038 RebaseErrorKind::ValidationFailed {
2039 reason: "test".to_string()
2040 }
2041 .category(),
2042 3
2043 );
2044 assert_eq!(
2045 RebaseErrorKind::ProcessTerminated {
2046 reason: "test".to_string()
2047 }
2048 .category(),
2049 4
2050 );
2051 assert_eq!(
2052 RebaseErrorKind::Unknown {
2053 details: "test".to_string()
2054 }
2055 .category(),
2056 5
2057 );
2058 }
2059
2060 #[test]
2061 fn test_rebase_error_kind_is_recoverable() {
2062 assert!(RebaseErrorKind::ConcurrentOperation {
2064 operation: "rebase".to_string()
2065 }
2066 .is_recoverable());
2067 assert!(RebaseErrorKind::ContentConflict { files: vec![] }.is_recoverable());
2068 assert!(!RebaseErrorKind::InvalidRevision {
2069 revision: "test".to_string()
2070 }
2071 .is_recoverable());
2072 assert!(!RebaseErrorKind::DirtyWorkingTree.is_recoverable());
2073 }
2074
2075 #[test]
2076 fn test_classify_rebase_error_invalid_revision() {
2077 let stderr = "error: invalid revision 'nonexistent'";
2079 let error = classify_rebase_error(stderr, "");
2080 assert!(matches!(error, RebaseErrorKind::InvalidRevision { .. }));
2081 }
2082
2083 #[test]
2084 fn test_classify_rebase_error_conflict() {
2085 let stderr = "CONFLICT (content): Merge conflict in src/main.rs";
2087 let error = classify_rebase_error(stderr, "");
2088 assert!(matches!(error, RebaseErrorKind::ContentConflict { .. }));
2089 }
2090
2091 #[test]
2092 fn test_classify_rebase_error_dirty_tree() {
2093 let stderr = "Cannot rebase: Your index contains uncommitted changes";
2095 let error = classify_rebase_error(stderr, "");
2096 assert!(matches!(error, RebaseErrorKind::DirtyWorkingTree));
2097 }
2098
2099 #[test]
2100 fn test_classify_rebase_error_concurrent_operation() {
2101 let stderr = "Cannot rebase: There is a rebase in progress already";
2103 let error = classify_rebase_error(stderr, "");
2104 assert!(matches!(error, RebaseErrorKind::ConcurrentOperation { .. }));
2105 }
2106
2107 #[test]
2108 fn test_classify_rebase_error_unknown() {
2109 let stderr = "Some completely unexpected error message";
2111 let error = classify_rebase_error(stderr, "");
2112 assert!(matches!(error, RebaseErrorKind::Unknown { .. }));
2113 }
2114
2115 #[test]
2116 fn test_rebase_onto_returns_result() {
2117 use test_helpers::{commit_all, init_git_repo, with_temp_cwd, write_file};
2118
2119 with_temp_cwd(|dir| {
2121 let repo = init_git_repo(dir);
2123 write_file(dir.path().join("initial.txt"), "initial content");
2124 let _ = commit_all(&repo, "initial commit");
2125
2126 let executor =
2129 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2130 let result = rebase_onto("nonexistent_branch_that_does_not_exist", executor.as_ref());
2131 assert!(result.is_ok());
2133 });
2134 }
2135
2136 #[test]
2137 fn test_get_conflicted_files_returns_result() {
2138 use test_helpers::{init_git_repo, with_temp_cwd};
2139
2140 with_temp_cwd(|dir| {
2142 let _repo = init_git_repo(dir);
2144
2145 let result = get_conflicted_files();
2146 assert!(result.is_ok());
2148 });
2149 }
2150
2151 #[test]
2152 fn test_rebase_in_progress_cli_returns_result() {
2153 use test_helpers::{init_git_repo, with_temp_cwd};
2154
2155 with_temp_cwd(|dir| {
2157 let _repo = init_git_repo(dir);
2159
2160 let executor =
2162 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2163 let result = rebase_in_progress_cli(executor.as_ref());
2164 assert!(result.is_ok());
2166 });
2167 }
2168
2169 #[test]
2170 fn test_is_dirty_tree_cli_returns_result() {
2171 use test_helpers::{init_git_repo, with_temp_cwd};
2172
2173 with_temp_cwd(|dir| {
2175 let _repo = init_git_repo(dir);
2177
2178 let executor =
2180 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2181 let result = is_dirty_tree_cli(executor.as_ref());
2182 assert!(result.is_ok());
2184 });
2185 }
2186
2187 #[test]
2188 fn test_cleanup_stale_rebase_state_returns_result() {
2189 use test_helpers::{init_git_repo, with_temp_cwd};
2190
2191 with_temp_cwd(|dir| {
2192 let _repo = init_git_repo(dir);
2194
2195 let result = cleanup_stale_rebase_state();
2197 assert!(result.is_ok());
2199 });
2200 }
2201}