ralph_workflow/git_helpers/
rebase_state_machine.rs1#![deny(unsafe_code)]
8
9use std::fs;
10use std::io;
11use std::io::Write;
12use std::path::Path;
13
14use super::rebase_checkpoint::{
15 clear_rebase_checkpoint, load_rebase_checkpoint, rebase_checkpoint_exists,
16 save_rebase_checkpoint, RebaseCheckpoint, RebasePhase,
17};
18
19const DEFAULT_MAX_RECOVERY_ATTEMPTS: u32 = 3;
21
22const REBASE_LOCK_FILE: &str = "rebase.lock";
24
25const DEFAULT_LOCK_TIMEOUT_SECONDS: u64 = 1800;
27
28fn rebase_lock_path() -> String {
33 format!(".agent/{REBASE_LOCK_FILE}")
34}
35
36pub struct RebaseStateMachine {
44 checkpoint: RebaseCheckpoint,
46 max_recovery_attempts: u32,
48}
49
50impl RebaseStateMachine {
51 pub fn new(upstream_branch: String) -> Self {
57 Self {
58 checkpoint: RebaseCheckpoint::new(upstream_branch),
59 max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
60 }
61 }
62
63 pub fn load_or_create(upstream_branch: String) -> io::Result<Self> {
80 if rebase_checkpoint_exists() {
81 match load_rebase_checkpoint() {
83 Ok(Some(checkpoint)) => {
84 Ok(Self {
86 checkpoint,
87 max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
88 })
89 }
90 Ok(None) => {
91 Self::try_load_backup_or_create(upstream_branch)
93 }
94 Err(e) => {
95 eprintln!("Warning: Failed to load checkpoint: {e}. Attempting recovery...");
98
99 match Self::try_load_backup_or_create(upstream_branch.clone()) {
100 Ok(sm) => {
101 let _ = clear_rebase_checkpoint();
103 Ok(sm)
104 }
105 Err(backup_err) => {
106 Err(io::Error::new(
108 io::ErrorKind::InvalidData,
109 format!(
110 "Failed to load checkpoint ({e}) and backup ({backup_err}). \
111 Manual intervention may be required."
112 ),
113 ))
114 }
115 }
116 }
117 }
118 } else {
119 Ok(Self::new(upstream_branch))
120 }
121 }
122
123 fn try_load_backup_or_create(upstream_branch: String) -> io::Result<Self> {
135 use super::rebase_checkpoint::rebase_checkpoint_backup_path;
136
137 let backup_path = rebase_checkpoint_backup_path();
138
139 if Path::new(&backup_path).exists() {
141 match fs::read_to_string(&backup_path) {
143 Ok(content) => match serde_json::from_str::<RebaseCheckpoint>(&content) {
144 Ok(checkpoint) => {
145 eprintln!("Successfully recovered from backup checkpoint");
146 return Ok(Self {
147 checkpoint,
148 max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
149 });
150 }
151 Err(e) => {
152 eprintln!("Backup checkpoint is also corrupted: {e}");
153 }
154 },
155 Err(e) => {
156 eprintln!("Failed to read backup checkpoint file: {e}");
157 }
158 }
159 }
160
161 eprintln!("Creating fresh state machine (checkpoint data lost)");
163 Ok(Self::new(upstream_branch))
164 }
165
166 pub fn with_max_recovery_attempts(mut self, max: u32) -> Self {
168 self.max_recovery_attempts = max;
169 self
170 }
171
172 pub fn transition_to(&mut self, phase: RebasePhase) -> io::Result<()> {
182 self.checkpoint = self.checkpoint.clone().with_phase(phase);
183 save_rebase_checkpoint(&self.checkpoint)
184 }
185
186 pub fn record_conflict(&mut self, file: String) {
192 self.checkpoint = self.checkpoint.clone().with_conflicted_file(file);
193 }
194
195 pub fn record_resolution(&mut self, file: String) {
201 self.checkpoint = self.checkpoint.clone().with_resolved_file(file);
202 }
203
204 pub fn record_error(&mut self, error: String) {
210 self.checkpoint = self.checkpoint.clone().with_error(error);
211 }
212
213 #[cfg(any(test, feature = "test-utils"))]
218 pub fn can_recover(&self) -> bool {
219 let max_for_phase = self.checkpoint.phase.max_recovery_attempts();
220 self.checkpoint.phase_error_count < max_for_phase
221 }
222
223 #[cfg(any(test, feature = "test-utils"))]
228 pub fn should_abort(&self) -> bool {
229 let max_for_phase = self.checkpoint.phase.max_recovery_attempts();
230 self.checkpoint.phase_error_count >= max_for_phase
231 }
232
233 pub fn all_conflicts_resolved(&self) -> bool {
237 self.checkpoint.all_conflicts_resolved()
238 }
239
240 pub fn checkpoint(&self) -> &RebaseCheckpoint {
242 &self.checkpoint
243 }
244
245 pub fn phase(&self) -> &RebasePhase {
247 &self.checkpoint.phase
248 }
249
250 pub fn upstream_branch(&self) -> &str {
252 &self.checkpoint.upstream_branch
253 }
254
255 pub fn unresolved_conflict_count(&self) -> usize {
257 self.checkpoint.unresolved_conflict_count()
258 }
259
260 pub fn clear_checkpoint(self) -> io::Result<()> {
262 clear_rebase_checkpoint()
263 }
264
265 #[cfg(any(test, feature = "test-utils"))]
271 pub fn abort(mut self) -> io::Result<()> {
272 self.checkpoint = self
273 .checkpoint
274 .clone()
275 .with_phase(RebasePhase::RebaseAborted);
276 save_rebase_checkpoint(&self.checkpoint)
277 }
278}
279
280#[cfg(any(test, feature = "test-utils"))]
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum RecoveryAction {
284 Continue,
289 Retry,
294 Abort,
299 Skip,
304}
305
306#[cfg(any(test, feature = "test-utils"))]
307impl RecoveryAction {
308 pub fn decide(
320 error_kind: &crate::git_helpers::rebase::RebaseErrorKind,
321 error_count: u32,
322 max_attempts: u32,
323 ) -> Self {
324 if error_count >= max_attempts {
326 return RecoveryAction::Abort;
327 }
328
329 match error_kind {
330 crate::git_helpers::rebase::RebaseErrorKind::InvalidRevision { .. } => {
332 RecoveryAction::Abort
333 }
334 crate::git_helpers::rebase::RebaseErrorKind::DirtyWorkingTree => RecoveryAction::Abort,
335 crate::git_helpers::rebase::RebaseErrorKind::ConcurrentOperation { .. } => {
336 RecoveryAction::Retry
337 }
338 crate::git_helpers::rebase::RebaseErrorKind::RepositoryCorrupt { .. } => {
339 RecoveryAction::Abort
340 }
341 crate::git_helpers::rebase::RebaseErrorKind::EnvironmentFailure { .. } => {
342 RecoveryAction::Abort
343 }
344 crate::git_helpers::rebase::RebaseErrorKind::HookRejection { .. } => {
345 RecoveryAction::Abort
346 }
347
348 crate::git_helpers::rebase::RebaseErrorKind::ContentConflict { .. } => {
350 RecoveryAction::Continue
351 }
352 crate::git_helpers::rebase::RebaseErrorKind::PatchApplicationFailed { .. } => {
353 RecoveryAction::Retry
354 }
355 crate::git_helpers::rebase::RebaseErrorKind::InteractiveStop { .. } => {
356 RecoveryAction::Abort
357 }
358 crate::git_helpers::rebase::RebaseErrorKind::EmptyCommit => RecoveryAction::Skip,
359 crate::git_helpers::rebase::RebaseErrorKind::AutostashFailed { .. } => {
360 RecoveryAction::Retry
361 }
362 crate::git_helpers::rebase::RebaseErrorKind::CommitCreationFailed { .. } => {
363 RecoveryAction::Retry
364 }
365 crate::git_helpers::rebase::RebaseErrorKind::ReferenceUpdateFailed { .. } => {
366 RecoveryAction::Retry
367 }
368
369 #[cfg(any(test, feature = "test-utils"))]
371 crate::git_helpers::rebase::RebaseErrorKind::ValidationFailed { .. } => {
372 RecoveryAction::Abort
373 }
374
375 #[cfg(any(test, feature = "test-utils"))]
377 crate::git_helpers::rebase::RebaseErrorKind::ProcessTerminated { .. } => {
378 RecoveryAction::Continue
379 }
380 #[cfg(any(test, feature = "test-utils"))]
381 crate::git_helpers::rebase::RebaseErrorKind::InconsistentState { .. } => {
382 RecoveryAction::Retry
383 }
384
385 crate::git_helpers::rebase::RebaseErrorKind::Unknown { .. } => RecoveryAction::Abort,
387 }
388 }
389}
390
391pub struct RebaseLock {
395 owns_lock: bool,
397}
398
399impl Drop for RebaseLock {
400 fn drop(&mut self) {
401 if self.owns_lock {
402 let _ = release_rebase_lock();
403 }
404 }
405}
406
407impl RebaseLock {
408 pub fn new() -> io::Result<Self> {
410 acquire_rebase_lock()?;
411 Ok(Self { owns_lock: true })
412 }
413
414 #[must_use]
418 #[cfg(any(test, feature = "test-utils"))]
419 pub fn leak(mut self) -> bool {
420 let owned = self.owns_lock;
421 self.owns_lock = false;
422 owned
423 }
424}
425
426pub fn acquire_rebase_lock() -> io::Result<()> {
437 let lock_path = rebase_lock_path();
438 let path = Path::new(&lock_path);
439
440 if let Some(parent) = path.parent() {
442 fs::create_dir_all(parent)?;
443 }
444
445 if path.exists() {
447 if !is_lock_stale()? {
448 return Err(io::Error::new(
449 io::ErrorKind::PermissionDenied,
450 "Rebase is already in progress. If you believe this is incorrect, \
451 wait 30 minutes for the lock to expire or manually remove `.agent/rebase.lock`.",
452 ));
453 }
454 fs::remove_file(path)?;
456 }
457
458 let pid = std::process::id();
460 let timestamp = chrono::Utc::now().to_rfc3339();
461 let lock_content = format!("pid={pid}\ntimestamp={timestamp}\n");
462
463 let mut file = fs::File::create(path)?;
464 file.write_all(lock_content.as_bytes())?;
465 file.sync_all()?;
466
467 Ok(())
468}
469
470pub fn release_rebase_lock() -> io::Result<()> {
478 let lock_path = rebase_lock_path();
479 let path = Path::new(&lock_path);
480
481 if path.exists() {
482 fs::remove_file(path)?;
483 }
484
485 Ok(())
486}
487
488fn is_lock_stale() -> io::Result<bool> {
496 let lock_path = rebase_lock_path();
497 let path = Path::new(&lock_path);
498
499 if !path.exists() {
500 return Ok(false);
501 }
502
503 let content = fs::read_to_string(path)?;
505
506 let timestamp_line = content
508 .lines()
509 .find(|line| line.starts_with("timestamp="))
510 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Lock file missing timestamp"))?;
511
512 let timestamp_str = timestamp_line.strip_prefix("timestamp=").ok_or_else(|| {
513 io::Error::new(
514 io::ErrorKind::InvalidData,
515 "Invalid timestamp format in lock file",
516 )
517 })?;
518
519 let lock_time = chrono::DateTime::parse_from_rfc3339(timestamp_str).map_err(|_| {
520 io::Error::new(
521 io::ErrorKind::InvalidData,
522 "Invalid timestamp format in lock file",
523 )
524 })?;
525
526 let now = chrono::Utc::now();
527 let elapsed = now.signed_duration_since(lock_time);
528
529 Ok(elapsed.num_seconds() > DEFAULT_LOCK_TIMEOUT_SECONDS as i64)
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn test_state_machine_new() {
538 let machine = RebaseStateMachine::new("main".to_string());
539 assert_eq!(machine.phase(), &RebasePhase::NotStarted);
540 assert_eq!(machine.upstream_branch(), "main");
541 assert!(machine.can_recover());
542 assert!(!machine.should_abort());
543 }
544
545 #[test]
546 fn test_state_machine_transition() {
547 use test_helpers::with_temp_cwd;
548
549 with_temp_cwd(|_dir| {
550 let mut machine = RebaseStateMachine::new("main".to_string());
551 machine
552 .transition_to(RebasePhase::RebaseInProgress)
553 .unwrap();
554 assert_eq!(machine.phase(), &RebasePhase::RebaseInProgress);
555 });
556 }
557
558 #[test]
559 fn test_state_machine_record_conflict() {
560 let mut machine = RebaseStateMachine::new("main".to_string());
561 machine.record_conflict("file1.rs".to_string());
562 machine.record_conflict("file2.rs".to_string());
563 assert_eq!(machine.unresolved_conflict_count(), 2);
564 }
565
566 #[test]
567 fn test_state_machine_record_resolution() {
568 let mut machine = RebaseStateMachine::new("main".to_string());
569 machine.record_conflict("file1.rs".to_string());
570 machine.record_conflict("file2.rs".to_string());
571 assert_eq!(machine.unresolved_conflict_count(), 2);
572
573 machine.record_resolution("file1.rs".to_string());
574 assert_eq!(machine.unresolved_conflict_count(), 1);
575 assert!(!machine.all_conflicts_resolved());
576
577 machine.record_resolution("file2.rs".to_string());
578 assert_eq!(machine.unresolved_conflict_count(), 0);
579 assert!(machine.all_conflicts_resolved());
580 }
581
582 #[test]
583 fn test_state_machine_record_error() {
584 let mut machine = RebaseStateMachine::new("main".to_string());
585 assert!(machine.can_recover());
586 assert!(!machine.should_abort());
587
588 machine.record_error("First error".to_string());
589 assert!(machine.can_recover());
590
591 machine.record_error("Second error".to_string());
592 assert!(machine.can_recover());
593
594 machine.record_error("Third error".to_string());
595 assert!(!machine.can_recover());
596 assert!(machine.should_abort());
597 }
598
599 #[test]
600 fn test_state_machine_custom_max_attempts() {
601 let machine = RebaseStateMachine::new("main".to_string()).with_max_recovery_attempts(1);
602
603 assert!(machine.can_recover());
604 }
605
606 #[test]
607 fn test_state_machine_save_load() {
608 use test_helpers::with_temp_cwd;
609
610 with_temp_cwd(|_dir| {
611 let mut machine1 = RebaseStateMachine::new("feature-branch".to_string());
612 machine1
613 .transition_to(RebasePhase::ConflictDetected)
614 .unwrap();
615
616 use super::super::rebase_checkpoint::{
619 save_rebase_checkpoint, RebaseCheckpoint, RebasePhase,
620 };
621 let checkpoint = RebaseCheckpoint::new("feature-branch".to_string())
622 .with_phase(RebasePhase::ConflictDetected)
623 .with_conflicted_file("test.rs".to_string());
624 save_rebase_checkpoint(&checkpoint).unwrap();
625
626 let machine2 = RebaseStateMachine::load_or_create("main".to_string()).unwrap();
628 assert_eq!(machine2.phase(), &RebasePhase::ConflictDetected);
629 assert_eq!(machine2.upstream_branch(), "feature-branch");
630 assert_eq!(machine2.unresolved_conflict_count(), 1);
631 });
632 }
633
634 #[test]
635 fn test_state_machine_clear_checkpoint() {
636 use test_helpers::with_temp_cwd;
637
638 with_temp_cwd(|_dir| {
639 let mut machine = RebaseStateMachine::new("main".to_string());
640 machine
641 .transition_to(RebasePhase::RebaseInProgress)
642 .unwrap();
643 assert!(rebase_checkpoint_exists());
644
645 machine.clear_checkpoint().unwrap();
646 assert!(!rebase_checkpoint_exists());
647 });
648 }
649
650 #[test]
651 fn test_state_machine_abort() {
652 use test_helpers::with_temp_cwd;
653
654 with_temp_cwd(|_dir| {
655 let mut machine = RebaseStateMachine::new("main".to_string());
656 machine
657 .transition_to(RebasePhase::ConflictDetected)
658 .unwrap();
659 machine.abort().unwrap();
660
661 let loaded = RebaseStateMachine::load_or_create("main".to_string()).unwrap();
662 assert_eq!(loaded.phase(), &RebasePhase::RebaseAborted);
663 });
664 }
665
666 #[test]
667 fn test_recovery_action_variants_exist() {
668 let _ = RecoveryAction::Continue;
669 let _ = RecoveryAction::Retry;
670 let _ = RecoveryAction::Abort;
671 let _ = RecoveryAction::Skip;
672 }
673
674 #[test]
675 fn test_acquire_and_release_rebase_lock() {
676 use test_helpers::with_temp_cwd;
677
678 with_temp_cwd(|_dir| {
679 acquire_rebase_lock().unwrap();
681
682 let lock_path = rebase_lock_path();
684 assert!(Path::new(&lock_path).exists());
685
686 release_rebase_lock().unwrap();
688
689 assert!(!Path::new(&lock_path).exists());
691 });
692 }
693
694 #[test]
695 fn test_rebase_lock_prevents_duplicate() {
696 use test_helpers::with_temp_cwd;
697
698 with_temp_cwd(|_dir| {
699 acquire_rebase_lock().unwrap();
701
702 let result = acquire_rebase_lock();
704 assert!(result.is_err());
705 assert!(result
706 .unwrap_err()
707 .to_string()
708 .contains("already in progress"));
709 });
710 }
711
712 #[test]
713 fn test_rebase_lock_guard_auto_releases() {
714 use test_helpers::with_temp_cwd;
715
716 with_temp_cwd(|_dir| {
717 {
718 let _lock = RebaseLock::new().unwrap();
720 let lock_path = rebase_lock_path();
721 assert!(Path::new(&lock_path).exists());
722 }
723 let lock_path = rebase_lock_path();
726 assert!(!Path::new(&lock_path).exists());
727 });
728 }
729
730 #[test]
731 fn test_rebase_lock_guard_leak() {
732 use test_helpers::with_temp_cwd;
733
734 with_temp_cwd(|_dir| {
735 {
736 let lock = RebaseLock::new().unwrap();
737 let lock_path = rebase_lock_path();
738 assert!(Path::new(&lock_path).exists());
739
740 let _ = lock.leak();
742 }
743
744 let lock_path = rebase_lock_path();
746 assert!(Path::new(&lock_path).exists());
747
748 let _ = release_rebase_lock();
750 });
751 }
752
753 #[test]
754 fn test_stale_lock_is_replaced() {
755 use test_helpers::with_temp_cwd;
756
757 with_temp_cwd(|_dir| {
758 let lock_path = rebase_lock_path();
760 let old_timestamp = chrono::Utc::now()
761 - chrono::Duration::seconds(DEFAULT_LOCK_TIMEOUT_SECONDS as i64 + 60);
762 let lock_content = format!("pid=12345\ntimestamp={}\n", old_timestamp.to_rfc3339());
763
764 fs::create_dir_all(".agent").unwrap();
765 fs::write(&lock_path, lock_content).unwrap();
766
767 acquire_rebase_lock().unwrap();
769
770 assert!(Path::new(&lock_path).exists());
772
773 release_rebase_lock().unwrap();
775 });
776 }
777
778 #[test]
779 fn test_recovery_action_decide_content_conflict() {
780 use super::super::rebase::RebaseErrorKind;
781
782 let error = RebaseErrorKind::ContentConflict {
783 files: vec!["file1.rs".to_string()],
784 };
785
786 let action = RecoveryAction::decide(&error, 0, 3);
788 assert_eq!(action, RecoveryAction::Continue);
789
790 let action = RecoveryAction::decide(&error, 2, 3);
792 assert_eq!(action, RecoveryAction::Continue);
793
794 let action = RecoveryAction::decide(&error, 3, 3);
796 assert_eq!(action, RecoveryAction::Abort);
797 }
798
799 #[test]
800 fn test_recovery_action_decide_concurrent_operation() {
801 use super::super::rebase::RebaseErrorKind;
802
803 let error = RebaseErrorKind::ConcurrentOperation {
804 operation: "rebase".to_string(),
805 };
806
807 let action = RecoveryAction::decide(&error, 0, 3);
809 assert_eq!(action, RecoveryAction::Retry);
810
811 let action = RecoveryAction::decide(&error, 2, 3);
813 assert_eq!(action, RecoveryAction::Retry);
814
815 let action = RecoveryAction::decide(&error, 3, 3);
817 assert_eq!(action, RecoveryAction::Abort);
818 }
819
820 #[test]
821 fn test_recovery_action_decide_invalid_revision() {
822 use super::super::rebase::RebaseErrorKind;
823
824 let error = RebaseErrorKind::InvalidRevision {
825 revision: "nonexistent".to_string(),
826 };
827
828 let action = RecoveryAction::decide(&error, 0, 3);
830 assert_eq!(action, RecoveryAction::Abort);
831 }
832
833 #[test]
834 fn test_recovery_action_decide_dirty_working_tree() {
835 use super::super::rebase::RebaseErrorKind;
836
837 let error = RebaseErrorKind::DirtyWorkingTree;
838
839 let action = RecoveryAction::decide(&error, 0, 3);
841 assert_eq!(action, RecoveryAction::Abort);
842 }
843
844 #[test]
845 fn test_recovery_action_decide_empty_commit() {
846 use super::super::rebase::RebaseErrorKind;
847
848 let error = RebaseErrorKind::EmptyCommit;
849
850 let action = RecoveryAction::decide(&error, 0, 3);
852 assert_eq!(action, RecoveryAction::Skip);
853
854 let action = RecoveryAction::decide(&error, 5, 10);
856 assert_eq!(action, RecoveryAction::Skip);
857 }
858
859 #[test]
860 fn test_recovery_action_decide_process_terminated() {
861 use super::super::rebase::RebaseErrorKind;
862
863 let error = RebaseErrorKind::ProcessTerminated {
864 reason: "agent crashed".to_string(),
865 };
866
867 let action = RecoveryAction::decide(&error, 0, 3);
869 assert_eq!(action, RecoveryAction::Continue);
870 }
871
872 #[test]
873 fn test_recovery_action_decide_inconsistent_state() {
874 use super::super::rebase::RebaseErrorKind;
875
876 let error = RebaseErrorKind::InconsistentState {
877 details: "HEAD detached unexpectedly".to_string(),
878 };
879
880 let action = RecoveryAction::decide(&error, 0, 3);
882 assert_eq!(action, RecoveryAction::Retry);
883
884 let action = RecoveryAction::decide(&error, 2, 3);
886 assert_eq!(action, RecoveryAction::Retry);
887
888 let action = RecoveryAction::decide(&error, 3, 3);
890 assert_eq!(action, RecoveryAction::Abort);
891 }
892
893 #[test]
894 fn test_recovery_action_decide_patch_application_failed() {
895 use super::super::rebase::RebaseErrorKind;
896
897 let error = RebaseErrorKind::PatchApplicationFailed {
898 reason: "context mismatch".to_string(),
899 };
900
901 let action = RecoveryAction::decide(&error, 0, 3);
903 assert_eq!(action, RecoveryAction::Retry);
904 }
905
906 #[test]
907 fn test_recovery_action_decide_validation_failed() {
908 use super::super::rebase::RebaseErrorKind;
909
910 let error = RebaseErrorKind::ValidationFailed {
911 reason: "tests failed".to_string(),
912 };
913
914 let action = RecoveryAction::decide(&error, 0, 3);
916 assert_eq!(action, RecoveryAction::Abort);
917 }
918
919 #[test]
920 fn test_recovery_action_decide_unknown() {
921 use super::super::rebase::RebaseErrorKind;
922
923 let error = RebaseErrorKind::Unknown {
924 details: "something went wrong".to_string(),
925 };
926
927 let action = RecoveryAction::decide(&error, 0, 3);
929 assert_eq!(action, RecoveryAction::Abort);
930 }
931
932 #[test]
933 fn test_recovery_action_decide_max_attempts_exceeded() {
934 use super::super::rebase::RebaseErrorKind;
935
936 let retryable_errors = [
937 RebaseErrorKind::ConcurrentOperation {
938 operation: "merge".to_string(),
939 },
940 RebaseErrorKind::PatchApplicationFailed {
941 reason: "fuzz failure".to_string(),
942 },
943 RebaseErrorKind::AutostashFailed {
944 reason: "stash pop failed".to_string(),
945 },
946 ];
947
948 for error in retryable_errors {
950 let action = RecoveryAction::decide(&error, 5, 3);
951 assert_eq!(
952 action,
953 RecoveryAction::Abort,
954 "Expected Abort for error: {error:?}"
955 );
956 }
957 }
958
959 #[test]
960 fn test_recovery_action_decide_category_1_non_recoverable() {
961 use super::super::rebase::RebaseErrorKind;
962
963 let non_recoverable_errors = [
964 RebaseErrorKind::InvalidRevision {
965 revision: "bad-ref".to_string(),
966 },
967 RebaseErrorKind::RepositoryCorrupt {
968 details: "missing objects".to_string(),
969 },
970 RebaseErrorKind::EnvironmentFailure {
971 reason: "no editor configured".to_string(),
972 },
973 RebaseErrorKind::HookRejection {
974 hook_name: "pre-rebase".to_string(),
975 },
976 ];
977
978 for error in non_recoverable_errors {
980 let action = RecoveryAction::decide(&error, 0, 3);
981 assert_eq!(
982 action,
983 RecoveryAction::Abort,
984 "Expected Abort for error: {error:?}"
985 );
986 }
987 }
988
989 #[test]
990 fn test_recovery_action_decide_category_2_mixed() {
991 use super::super::rebase::RebaseErrorKind;
992
993 let interactive = RebaseErrorKind::InteractiveStop {
995 command: "edit".to_string(),
996 };
997 assert_eq!(
998 RecoveryAction::decide(&interactive, 0, 3),
999 RecoveryAction::Abort
1000 );
1001
1002 let ref_fail = RebaseErrorKind::ReferenceUpdateFailed {
1004 reason: "concurrent update".to_string(),
1005 };
1006 assert_eq!(
1007 RecoveryAction::decide(&ref_fail, 0, 3),
1008 RecoveryAction::Retry
1009 );
1010
1011 let commit_fail = RebaseErrorKind::CommitCreationFailed {
1013 reason: "hook failed".to_string(),
1014 };
1015 assert_eq!(
1016 RecoveryAction::decide(&commit_fail, 0, 3),
1017 RecoveryAction::Retry
1018 );
1019 }
1020}