1#![deny(unsafe_code)]
25
26#[cfg(any(test, feature = "test-utils"))]
31const REBASE_APPLY_DIR: &str = "rebase-apply";
32
33#[cfg(any(test, feature = "test-utils"))]
38const REBASE_MERGE_DIR: &str = "rebase-merge";
39
40use std::io;
41use std::path::Path;
42
43use super::git2_to_io_error;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum RebaseErrorKind {
52 InvalidRevision { revision: String },
55
56 DirtyWorkingTree,
58
59 ConcurrentOperation { operation: String },
61
62 RepositoryCorrupt { details: String },
64
65 EnvironmentFailure { reason: String },
67
68 HookRejection { hook_name: String },
70
71 ContentConflict { files: Vec<String> },
74
75 PatchApplicationFailed { reason: String },
77
78 InteractiveStop { command: String },
80
81 EmptyCommit,
83
84 AutostashFailed { reason: String },
86
87 CommitCreationFailed { reason: String },
89
90 ReferenceUpdateFailed { reason: String },
92
93 #[cfg(any(test, feature = "test-utils"))]
96 ValidationFailed { reason: String },
97
98 #[cfg(any(test, feature = "test-utils"))]
101 ProcessTerminated { reason: String },
102
103 #[cfg(any(test, feature = "test-utils"))]
105 InconsistentState { details: String },
106
107 Unknown { details: String },
110}
111
112impl RebaseErrorKind {
113 pub fn description(&self) -> String {
115 match self {
116 RebaseErrorKind::InvalidRevision { revision } => {
117 format!("Invalid or unresolvable revision: '{revision}'")
118 }
119 RebaseErrorKind::DirtyWorkingTree => "Working tree has uncommitted changes".to_string(),
120 RebaseErrorKind::ConcurrentOperation { operation } => {
121 format!("Concurrent Git operation in progress: {operation}")
122 }
123 RebaseErrorKind::RepositoryCorrupt { details } => {
124 format!("Repository integrity issue: {details}")
125 }
126 RebaseErrorKind::EnvironmentFailure { reason } => {
127 format!("Environment or configuration failure: {reason}")
128 }
129 RebaseErrorKind::HookRejection { hook_name } => {
130 format!("Hook '{hook_name}' rejected the operation")
131 }
132 RebaseErrorKind::ContentConflict { files } => {
133 format!("Merge conflicts in {} file(s)", files.len())
134 }
135 RebaseErrorKind::PatchApplicationFailed { reason } => {
136 format!("Patch application failed: {reason}")
137 }
138 RebaseErrorKind::InteractiveStop { command } => {
139 format!("Interactive rebase stopped at command: {command}")
140 }
141 RebaseErrorKind::EmptyCommit => "Empty or redundant commit".to_string(),
142 RebaseErrorKind::AutostashFailed { reason } => {
143 format!("Autostash failed: {reason}")
144 }
145 RebaseErrorKind::CommitCreationFailed { reason } => {
146 format!("Commit creation failed: {reason}")
147 }
148 RebaseErrorKind::ReferenceUpdateFailed { reason } => {
149 format!("Reference update failed: {reason}")
150 }
151 #[cfg(any(test, feature = "test-utils"))]
152 RebaseErrorKind::ValidationFailed { reason } => {
153 format!("Post-rebase validation failed: {reason}")
154 }
155 #[cfg(any(test, feature = "test-utils"))]
156 RebaseErrorKind::ProcessTerminated { reason } => {
157 format!("Rebase process terminated: {reason}")
158 }
159 #[cfg(any(test, feature = "test-utils"))]
160 RebaseErrorKind::InconsistentState { details } => {
161 format!("Inconsistent rebase state: {details}")
162 }
163 RebaseErrorKind::Unknown { details } => {
164 format!("Unknown rebase error: {details}")
165 }
166 }
167 }
168
169 #[cfg(any(test, feature = "test-utils"))]
171 pub fn is_recoverable(&self) -> bool {
172 match self {
173 RebaseErrorKind::ConcurrentOperation { .. } => true,
175 #[cfg(any(test, feature = "test-utils"))]
176 RebaseErrorKind::ProcessTerminated { .. }
177 | RebaseErrorKind::InconsistentState { .. } => true,
178
179 RebaseErrorKind::ContentConflict { .. } => true,
181
182 RebaseErrorKind::InvalidRevision { .. }
184 | RebaseErrorKind::DirtyWorkingTree
185 | RebaseErrorKind::RepositoryCorrupt { .. }
186 | RebaseErrorKind::EnvironmentFailure { .. }
187 | RebaseErrorKind::HookRejection { .. }
188 | RebaseErrorKind::PatchApplicationFailed { .. }
189 | RebaseErrorKind::InteractiveStop { .. }
190 | RebaseErrorKind::EmptyCommit
191 | RebaseErrorKind::AutostashFailed { .. }
192 | RebaseErrorKind::CommitCreationFailed { .. }
193 | RebaseErrorKind::ReferenceUpdateFailed { .. } => false,
194 #[cfg(any(test, feature = "test-utils"))]
195 RebaseErrorKind::ValidationFailed { .. } => false,
196 RebaseErrorKind::Unknown { .. } => false,
197 }
198 }
199
200 #[cfg(any(test, feature = "test-utils"))]
202 pub fn category(&self) -> u8 {
203 match self {
204 RebaseErrorKind::InvalidRevision { .. }
205 | RebaseErrorKind::DirtyWorkingTree
206 | RebaseErrorKind::ConcurrentOperation { .. }
207 | RebaseErrorKind::RepositoryCorrupt { .. }
208 | RebaseErrorKind::EnvironmentFailure { .. }
209 | RebaseErrorKind::HookRejection { .. } => 1,
210
211 RebaseErrorKind::ContentConflict { .. }
212 | RebaseErrorKind::PatchApplicationFailed { .. }
213 | RebaseErrorKind::InteractiveStop { .. }
214 | RebaseErrorKind::EmptyCommit
215 | RebaseErrorKind::AutostashFailed { .. }
216 | RebaseErrorKind::CommitCreationFailed { .. }
217 | RebaseErrorKind::ReferenceUpdateFailed { .. } => 2,
218
219 #[cfg(any(test, feature = "test-utils"))]
220 RebaseErrorKind::ValidationFailed { .. } => 3,
221
222 #[cfg(any(test, feature = "test-utils"))]
223 RebaseErrorKind::ProcessTerminated { .. }
224 | RebaseErrorKind::InconsistentState { .. } => 4,
225
226 RebaseErrorKind::Unknown { .. } => 5,
227 }
228 }
229}
230
231impl std::fmt::Display for RebaseErrorKind {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 write!(f, "{}", self.description())
234 }
235}
236
237impl std::error::Error for RebaseErrorKind {}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum RebaseResult {
246 Success,
248
249 Conflicts(Vec<String>),
251
252 NoOp { reason: String },
254
255 Failed(RebaseErrorKind),
257}
258
259impl RebaseResult {
260 #[cfg(any(test, feature = "test-utils"))]
262 pub fn is_success(&self) -> bool {
263 matches!(self, RebaseResult::Success)
264 }
265
266 #[cfg(any(test, feature = "test-utils"))]
268 pub fn has_conflicts(&self) -> bool {
269 matches!(self, RebaseResult::Conflicts(_))
270 }
271
272 #[cfg(any(test, feature = "test-utils"))]
274 pub fn is_noop(&self) -> bool {
275 matches!(self, RebaseResult::NoOp { .. })
276 }
277
278 #[cfg(any(test, feature = "test-utils"))]
280 pub fn is_failed(&self) -> bool {
281 matches!(self, RebaseResult::Failed(_))
282 }
283
284 #[cfg(any(test, feature = "test-utils"))]
286 pub fn conflict_files(&self) -> Option<&[String]> {
287 match self {
288 RebaseResult::Conflicts(files) => Some(files),
289 RebaseResult::Failed(RebaseErrorKind::ContentConflict { files }) => Some(files),
290 _ => None,
291 }
292 }
293
294 #[cfg(any(test, feature = "test-utils"))]
296 pub fn error_kind(&self) -> Option<&RebaseErrorKind> {
297 match self {
298 RebaseResult::Failed(kind) => Some(kind),
299 _ => None,
300 }
301 }
302
303 #[cfg(any(test, feature = "test-utils"))]
305 pub fn noop_reason(&self) -> Option<&str> {
306 match self {
307 RebaseResult::NoOp { reason } => Some(reason),
308 _ => None,
309 }
310 }
311}
312
313pub fn classify_rebase_error(stderr: &str, stdout: &str) -> RebaseErrorKind {
318 let combined = format!("{stderr}\n{stdout}");
319
320 if combined.contains("invalid revision")
324 || combined.contains("unknown revision")
325 || combined.contains("bad revision")
326 || combined.contains("ambiguous revision")
327 || combined.contains("not found")
328 || combined.contains("does not exist")
329 || combined.contains("bad revision")
330 || combined.contains("no such ref")
331 {
332 let revision = extract_revision(&combined);
334 return RebaseErrorKind::InvalidRevision {
335 revision: revision.unwrap_or_else(|| "unknown".to_string()),
336 };
337 }
338
339 if combined.contains("shallow")
341 || combined.contains("depth")
342 || combined.contains("unreachable")
343 || combined.contains("needed single revision")
344 || combined.contains("does not have")
345 {
346 return RebaseErrorKind::RepositoryCorrupt {
347 details: format!(
348 "Shallow clone or missing history: {}",
349 extract_error_line(&combined)
350 ),
351 };
352 }
353
354 if combined.contains("worktree")
356 || combined.contains("checked out")
357 || combined.contains("another branch")
358 || combined.contains("already checked out")
359 {
360 return RebaseErrorKind::ConcurrentOperation {
361 operation: "branch checked out in another worktree".to_string(),
362 };
363 }
364
365 if combined.contains("submodule") || combined.contains(".gitmodules") {
367 return RebaseErrorKind::ContentConflict {
368 files: extract_conflict_files(&combined),
369 };
370 }
371
372 if combined.contains("dirty")
374 || combined.contains("uncommitted changes")
375 || combined.contains("local changes")
376 || combined.contains("cannot rebase")
377 {
378 return RebaseErrorKind::DirtyWorkingTree;
379 }
380
381 if combined.contains("rebase in progress")
383 || combined.contains("merge in progress")
384 || combined.contains("cherry-pick in progress")
385 || combined.contains("revert in progress")
386 || combined.contains("bisect in progress")
387 || combined.contains("Another git process")
388 || combined.contains("Locked")
389 {
390 let operation = extract_operation(&combined);
391 return RebaseErrorKind::ConcurrentOperation {
392 operation: operation.unwrap_or_else(|| "unknown".to_string()),
393 };
394 }
395
396 if combined.contains("corrupt")
398 || combined.contains("object not found")
399 || combined.contains("missing object")
400 || combined.contains("invalid object")
401 || combined.contains("bad object")
402 || combined.contains("disk full")
403 || combined.contains("filesystem")
404 {
405 return RebaseErrorKind::RepositoryCorrupt {
406 details: extract_error_line(&combined),
407 };
408 }
409
410 if combined.contains("user.name")
412 || combined.contains("user.email")
413 || combined.contains("author")
414 || combined.contains("committer")
415 || combined.contains("terminal")
416 || combined.contains("editor")
417 {
418 return RebaseErrorKind::EnvironmentFailure {
419 reason: extract_error_line(&combined),
420 };
421 }
422
423 if combined.contains("pre-rebase")
425 || combined.contains("hook")
426 || combined.contains("rejected by")
427 {
428 return RebaseErrorKind::HookRejection {
429 hook_name: extract_hook_name(&combined),
430 };
431 }
432
433 if combined.contains("Conflict")
437 || combined.contains("conflict")
438 || combined.contains("Resolve")
439 || combined.contains("Merge conflict")
440 {
441 return RebaseErrorKind::ContentConflict {
442 files: extract_conflict_files(&combined),
443 };
444 }
445
446 if combined.contains("patch does not apply")
448 || combined.contains("patch failed")
449 || combined.contains("hunk failed")
450 || combined.contains("context mismatch")
451 || combined.contains("fuzz")
452 {
453 return RebaseErrorKind::PatchApplicationFailed {
454 reason: extract_error_line(&combined),
455 };
456 }
457
458 if combined.contains("Stopped at")
460 || combined.contains("paused")
461 || combined.contains("edit command")
462 {
463 return RebaseErrorKind::InteractiveStop {
464 command: extract_command(&combined),
465 };
466 }
467
468 if combined.contains("empty")
470 || combined.contains("no changes")
471 || combined.contains("already applied")
472 {
473 return RebaseErrorKind::EmptyCommit;
474 }
475
476 if combined.contains("autostash") || combined.contains("stash") {
478 return RebaseErrorKind::AutostashFailed {
479 reason: extract_error_line(&combined),
480 };
481 }
482
483 if combined.contains("pre-commit")
485 || combined.contains("commit-msg")
486 || combined.contains("prepare-commit-msg")
487 || combined.contains("post-commit")
488 || combined.contains("signing")
489 || combined.contains("GPG")
490 {
491 return RebaseErrorKind::CommitCreationFailed {
492 reason: extract_error_line(&combined),
493 };
494 }
495
496 if combined.contains("cannot lock")
498 || combined.contains("ref update")
499 || combined.contains("packed-refs")
500 || combined.contains("reflog")
501 {
502 return RebaseErrorKind::ReferenceUpdateFailed {
503 reason: extract_error_line(&combined),
504 };
505 }
506
507 RebaseErrorKind::Unknown {
509 details: extract_error_line(&combined),
510 }
511}
512
513fn extract_revision(output: &str) -> Option<String> {
515 let patterns = [
518 ("invalid revision '", "'"),
519 ("unknown revision '", "'"),
520 ("bad revision '", "'"),
521 ("branch '", "' not found"),
522 ("upstream branch '", "' not found"),
523 ("revision ", " not found"),
524 ("'", "'"),
525 ];
526
527 for (start, end) in patterns {
528 if let Some(start_idx) = output.find(start) {
529 let after_start = &output[start_idx + start.len()..];
530 if let Some(end_idx) = after_start.find(end) {
531 let revision = &after_start[..end_idx];
532 if !revision.is_empty() {
533 return Some(revision.to_string());
534 }
535 }
536 }
537 }
538
539 for line in output.lines() {
541 if line.contains("not found") || line.contains("does not exist") {
542 let words: Vec<&str> = line.split_whitespace().collect();
544 for (i, word) in words.iter().enumerate() {
545 if *word == "'"
546 || *word == "\""
547 && i + 2 < words.len()
548 && (words[i + 2] == "'" || words[i + 2] == "\"")
549 {
550 return Some(words[i + 1].to_string());
551 }
552 }
553 }
554 }
555
556 None
557}
558
559fn extract_operation(output: &str) -> Option<String> {
561 if output.contains("rebase in progress") {
562 Some("rebase".to_string())
563 } else if output.contains("merge in progress") {
564 Some("merge".to_string())
565 } else if output.contains("cherry-pick in progress") {
566 Some("cherry-pick".to_string())
567 } else if output.contains("revert in progress") {
568 Some("revert".to_string())
569 } else if output.contains("bisect in progress") {
570 Some("bisect".to_string())
571 } else {
572 None
573 }
574}
575
576fn extract_hook_name(output: &str) -> String {
578 if output.contains("pre-rebase") {
579 "pre-rebase".to_string()
580 } else if output.contains("pre-commit") {
581 "pre-commit".to_string()
582 } else if output.contains("commit-msg") {
583 "commit-msg".to_string()
584 } else if output.contains("post-commit") {
585 "post-commit".to_string()
586 } else {
587 "hook".to_string()
588 }
589}
590
591fn extract_command(output: &str) -> String {
593 if output.contains("edit") {
594 "edit".to_string()
595 } else if output.contains("reword") {
596 "reword".to_string()
597 } else if output.contains("break") {
598 "break".to_string()
599 } else if output.contains("exec") {
600 "exec".to_string()
601 } else {
602 "unknown".to_string()
603 }
604}
605
606fn extract_error_line(output: &str) -> String {
608 output
609 .lines()
610 .find(|line| {
611 !line.is_empty()
612 && !line.starts_with("hint:")
613 && !line.starts_with("Hint:")
614 && !line.starts_with("note:")
615 && !line.starts_with("Note:")
616 })
617 .map(|s| s.trim().to_string())
618 .unwrap_or_else(|| output.trim().to_string())
619}
620
621fn extract_conflict_files(output: &str) -> Vec<String> {
623 let mut files = Vec::new();
624
625 for line in output.lines() {
626 if line.contains("CONFLICT") || line.contains("Conflict") || line.contains("Merge conflict")
627 {
628 if let Some(start) = line.find("in ") {
632 let path = line[start + 3..].trim();
633 if !path.is_empty() {
634 files.push(path.to_string());
635 }
636 }
637 }
638 }
639
640 files
641}
642
643#[derive(Debug, Clone, PartialEq, Eq)]
648#[cfg(any(test, feature = "test-utils"))]
649pub enum ConcurrentOperation {
650 Rebase,
652 Merge,
654 CherryPick,
656 Revert,
658 Bisect,
660 OtherGitProcess,
662 Unknown(String),
664}
665
666#[cfg(any(test, feature = "test-utils"))]
667impl ConcurrentOperation {
668 pub fn description(&self) -> String {
670 match self {
671 ConcurrentOperation::Rebase => "rebase".to_string(),
672 ConcurrentOperation::Merge => "merge".to_string(),
673 ConcurrentOperation::CherryPick => "cherry-pick".to_string(),
674 ConcurrentOperation::Revert => "revert".to_string(),
675 ConcurrentOperation::Bisect => "bisect".to_string(),
676 ConcurrentOperation::OtherGitProcess => "another Git process".to_string(),
677 ConcurrentOperation::Unknown(s) => format!("unknown operation: {s}"),
678 }
679 }
680}
681
682#[cfg(any(test, feature = "test-utils"))]
711pub fn detect_concurrent_git_operations() -> io::Result<Option<ConcurrentOperation>> {
712 use std::fs;
713
714 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
715 let git_dir = repo.path();
716
717 let rebase_merge = git_dir.join(REBASE_MERGE_DIR);
719 let rebase_apply = git_dir.join(REBASE_APPLY_DIR);
720 if rebase_merge.exists() || rebase_apply.exists() {
721 return Ok(Some(ConcurrentOperation::Rebase));
722 }
723
724 let merge_head = git_dir.join("MERGE_HEAD");
726 if merge_head.exists() {
727 return Ok(Some(ConcurrentOperation::Merge));
728 }
729
730 let cherry_pick_head = git_dir.join("CHERRY_PICK_HEAD");
732 if cherry_pick_head.exists() {
733 return Ok(Some(ConcurrentOperation::CherryPick));
734 }
735
736 let revert_head = git_dir.join("REVERT_HEAD");
738 if revert_head.exists() {
739 return Ok(Some(ConcurrentOperation::Revert));
740 }
741
742 let bisect_log = git_dir.join("BISECT_LOG");
744 let bisect_start = git_dir.join("BISECT_START");
745 let bisect_names = git_dir.join("BISECT_NAMES");
746 if bisect_log.exists() || bisect_start.exists() || bisect_names.exists() {
747 return Ok(Some(ConcurrentOperation::Bisect));
748 }
749
750 let index_lock = git_dir.join("index.lock");
752 let packed_refs_lock = git_dir.join("packed-refs.lock");
753 let head_lock = git_dir.join("HEAD.lock");
754 if index_lock.exists() || packed_refs_lock.exists() || head_lock.exists() {
755 return Ok(Some(ConcurrentOperation::OtherGitProcess));
758 }
759
760 if let Ok(entries) = fs::read_dir(git_dir) {
762 for entry in entries.flatten() {
763 let name = entry.file_name();
764 let name_str = name.to_string_lossy();
765 if name_str.contains("REBASE")
767 || name_str.contains("MERGE")
768 || name_str.contains("CHERRY")
769 {
770 return Ok(Some(ConcurrentOperation::Unknown(name_str.to_string())));
771 }
772 }
773 }
774
775 Ok(None)
776}
777
778#[cfg(any(test, feature = "test-utils"))]
787pub fn rebase_in_progress_cli(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<bool> {
788 let output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
789 Ok(output.stdout.contains("rebasing"))
790}
791
792#[derive(Debug, Clone, Default)]
796#[cfg(any(test, feature = "test-utils"))]
797pub struct CleanupResult {
798 pub cleaned_paths: Vec<String>,
800 pub locks_removed: bool,
802}
803
804#[cfg(any(test, feature = "test-utils"))]
805impl CleanupResult {
806 pub fn has_cleanup(&self) -> bool {
808 !self.cleaned_paths.is_empty() || self.locks_removed
809 }
810
811 pub fn count(&self) -> usize {
813 self.cleaned_paths.len() + if self.locks_removed { 1 } else { 0 }
814 }
815}
816
817#[cfg(any(test, feature = "test-utils"))]
831pub fn cleanup_stale_rebase_state() -> io::Result<CleanupResult> {
832 use std::fs;
833
834 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
835 let git_dir = repo.path();
836
837 let mut result = CleanupResult::default();
838
839 let stale_paths = [
841 (REBASE_APPLY_DIR, "rebase-apply directory"),
842 (REBASE_MERGE_DIR, "rebase-merge directory"),
843 ("MERGE_HEAD", "merge state"),
844 ("MERGE_MSG", "merge message"),
845 ("CHERRY_PICK_HEAD", "cherry-pick state"),
846 ("REVERT_HEAD", "revert state"),
847 ("COMMIT_EDITMSG", "commit message"),
848 ];
849
850 for (path_name, description) in &stale_paths {
851 let full_path = git_dir.join(path_name);
852 if full_path.exists() {
853 let is_valid = validate_state_file(&full_path);
855 if !is_valid.unwrap_or(true) {
856 let removed = if full_path.is_dir() {
858 fs::remove_dir_all(&full_path)
859 .map(|_| true)
860 .unwrap_or(false)
861 } else {
862 fs::remove_file(&full_path).map(|_| true).unwrap_or(false)
863 };
864
865 if removed {
866 result
867 .cleaned_paths
868 .push(format!("{path_name} ({description})"));
869 }
870 }
871 }
872 }
873
874 let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
876 for lock_file in &lock_files {
877 let lock_path = git_dir.join(lock_file);
878 if lock_path.exists() {
879 if fs::remove_file(&lock_path).is_ok() {
881 result.locks_removed = true;
882 result
883 .cleaned_paths
884 .push(format!("{lock_file} (lock file)"));
885 }
886 }
887 }
888
889 Ok(result)
890}
891
892#[cfg(any(test, feature = "test-utils"))]
906fn validate_state_file(path: &Path) -> io::Result<bool> {
907 use std::fs;
908
909 if !path.exists() {
910 return Ok(false);
911 }
912
913 if path.is_dir() {
915 let entries = fs::read_dir(path)?;
917 let has_content = entries.count() > 0;
918 return Ok(has_content);
919 }
920
921 if path.is_file() {
923 let metadata = fs::metadata(path)?;
924 if metadata.len() == 0 {
925 return Ok(false);
927 }
928 let _ = fs::read(path)?;
930 return Ok(true);
931 }
932
933 Ok(false)
934}
935
936#[cfg(any(test, feature = "test-utils"))]
970pub fn attempt_automatic_recovery(
971 executor: &dyn crate::executor::ProcessExecutor,
972 error_kind: &RebaseErrorKind,
973 phase: &crate::git_helpers::rebase_checkpoint::RebasePhase,
974 phase_error_count: u32,
975) -> io::Result<bool> {
976 match error_kind {
978 RebaseErrorKind::InvalidRevision { .. }
979 | RebaseErrorKind::DirtyWorkingTree
980 | RebaseErrorKind::RepositoryCorrupt { .. }
981 | RebaseErrorKind::EnvironmentFailure { .. }
982 | RebaseErrorKind::HookRejection { .. }
983 | RebaseErrorKind::InteractiveStop { .. }
984 | RebaseErrorKind::Unknown { .. } => {
985 return Ok(false);
986 }
987 _ => {}
988 }
989
990 let max_attempts = phase.max_recovery_attempts();
991 if phase_error_count >= max_attempts {
992 return Ok(false);
993 }
994
995 if cleanup_stale_rebase_state().is_ok() {
997 if validate_git_state().is_ok() {
999 return Ok(true);
1000 }
1001 }
1002
1003 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1005 let git_dir = repo.path();
1006 let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
1007 let mut removed_any = false;
1008
1009 for lock_file in &lock_files {
1010 let lock_path = git_dir.join(lock_file);
1011 if lock_path.exists() && std::fs::remove_file(&lock_path).is_ok() {
1013 removed_any = true;
1014 }
1015 }
1016
1017 if removed_any && validate_git_state().is_ok() {
1018 return Ok(true);
1019 }
1020
1021 if let RebaseErrorKind::ConcurrentOperation { .. } = error_kind {
1023 let abort_result = executor.execute("git", &["rebase", "--abort"], &[], None);
1025
1026 if abort_result.is_ok() {
1027 if validate_git_state().is_ok() {
1029 return Ok(true);
1030 }
1031 }
1032 }
1033
1034 Ok(false)
1036}
1037
1038#[cfg(any(test, feature = "test-utils"))]
1048pub fn validate_git_state() -> io::Result<()> {
1049 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1050
1051 let _ = repo.head().map_err(|e| {
1053 io::Error::new(
1054 io::ErrorKind::InvalidData,
1055 format!("Repository HEAD is invalid: {e}"),
1056 )
1057 })?;
1058
1059 let _ = repo.index().map_err(|e| {
1061 io::Error::new(
1062 io::ErrorKind::InvalidData,
1063 format!("Repository index is corrupted: {e}"),
1064 )
1065 })?;
1066
1067 if let Ok(head) = repo.head() {
1069 if let Ok(commit) = head.peel_to_commit() {
1070 let _ = commit.tree().map_err(|e| {
1072 io::Error::new(
1073 io::ErrorKind::InvalidData,
1074 format!("Object database corruption: {e}"),
1075 )
1076 })?;
1077 }
1078 }
1079
1080 Ok(())
1081}
1082
1083#[cfg(any(test, feature = "test-utils"))]
1096pub fn is_dirty_tree_cli(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<bool> {
1097 let output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
1098
1099 if output.status.success() {
1100 let stdout = output.stdout.trim();
1101 Ok(!stdout.is_empty())
1102 } else {
1103 Err(io::Error::other(format!(
1104 "Failed to check working tree status: {}",
1105 output.stderr
1106 )))
1107 }
1108}
1109
1110#[cfg(any(test, feature = "test-utils"))]
1147pub fn validate_rebase_preconditions(
1148 executor: &dyn crate::executor::ProcessExecutor,
1149) -> io::Result<()> {
1150 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1151
1152 validate_git_state()?;
1154
1155 if let Some(concurrent_op) = detect_concurrent_git_operations()? {
1157 return Err(io::Error::new(
1158 io::ErrorKind::InvalidInput,
1159 format!(
1160 "Cannot start rebase: {} already in progress. \
1161 Please complete or abort the current operation first.",
1162 concurrent_op.description()
1163 ),
1164 ));
1165 }
1166
1167 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1169
1170 let user_name = config.get_string("user.name");
1171 let user_email = config.get_string("user.email");
1172
1173 if user_name.is_err() && user_email.is_err() {
1174 return Err(io::Error::new(
1175 io::ErrorKind::InvalidInput,
1176 "Git identity is not configured. Please set user.name and user.email:\n \
1177 git config --global user.name \"Your Name\"\n \
1178 git config --global user.email \"you@example.com\"",
1179 ));
1180 }
1181
1182 let status_output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
1184
1185 if status_output.status.success() {
1186 let stdout = status_output.stdout.trim();
1187 if !stdout.is_empty() {
1188 return Err(io::Error::new(
1189 io::ErrorKind::InvalidInput,
1190 "Working tree is not clean. Please commit or stash changes before rebasing.",
1191 ));
1192 }
1193 } else {
1194 let statuses = repo.statuses(None).map_err(|e| {
1196 io::Error::new(
1197 io::ErrorKind::InvalidData,
1198 format!("Failed to check working tree status: {e}"),
1199 )
1200 })?;
1201
1202 if !statuses.is_empty() {
1203 return Err(io::Error::new(
1204 io::ErrorKind::InvalidInput,
1205 "Working tree is not clean. Please commit or stash changes before rebasing.",
1206 ));
1207 }
1208 }
1209
1210 check_shallow_clone()?;
1212
1213 check_worktree_conflicts()?;
1215
1216 check_submodule_state()?;
1218
1219 check_sparse_checkout_state()?;
1221
1222 Ok(())
1223}
1224
1225#[cfg(any(test, feature = "test-utils"))]
1235fn check_shallow_clone() -> io::Result<()> {
1236 use std::fs;
1237
1238 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1239 let git_dir = repo.path();
1240
1241 let shallow_file = git_dir.join("shallow");
1243 if shallow_file.exists() {
1244 let content = fs::read_to_string(&shallow_file).unwrap_or_default();
1246 let line_count = content.lines().count();
1247
1248 return Err(io::Error::new(
1249 io::ErrorKind::InvalidInput,
1250 format!(
1251 "Repository is a shallow clone with {} commits. \
1252 Rebasing may fail due to missing history. \
1253 Consider running: git fetch --unshallow",
1254 line_count
1255 ),
1256 ));
1257 }
1258
1259 Ok(())
1260}
1261
1262#[cfg(any(test, feature = "test-utils"))]
1272fn check_worktree_conflicts() -> io::Result<()> {
1273 use std::fs;
1274
1275 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1276
1277 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1279 let branch_name = match head.shorthand() {
1280 Some(name) if head.is_branch() => name,
1281 _ => return Ok(()), };
1283
1284 let git_dir = repo.path();
1285 let worktrees_dir = git_dir.join("worktrees");
1286
1287 if !worktrees_dir.exists() {
1288 return Ok(());
1289 }
1290
1291 let entries = fs::read_dir(&worktrees_dir).map_err(|e| {
1293 io::Error::new(
1294 io::ErrorKind::InvalidData,
1295 format!("Failed to read worktrees directory: {e}"),
1296 )
1297 })?;
1298
1299 for entry in entries.flatten() {
1300 let worktree_path = entry.path();
1301 let worktree_head = worktree_path.join("HEAD");
1302
1303 if worktree_head.exists() {
1304 if let Ok(content) = fs::read_to_string(&worktree_head) {
1305 if content.contains(&format!("refs/heads/{branch_name}")) {
1307 let worktree_name = worktree_path
1309 .file_name()
1310 .and_then(|n| n.to_str())
1311 .unwrap_or("unknown");
1312
1313 return Err(io::Error::new(
1314 io::ErrorKind::InvalidInput,
1315 format!(
1316 "Branch '{branch_name}' is already checked out in worktree '{worktree_name}'. \
1317 Use 'git worktree add' to create a new worktree for this branch."
1318 ),
1319 ));
1320 }
1321 }
1322 }
1323 }
1324
1325 Ok(())
1326}
1327
1328#[cfg(any(test, feature = "test-utils"))]
1338fn check_submodule_state() -> io::Result<()> {
1339 use std::fs;
1340
1341 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1342 let git_dir = repo.path();
1343
1344 let workdir = repo.workdir().unwrap_or(git_dir);
1346 let gitmodules_path = workdir.join(".gitmodules");
1347
1348 if !gitmodules_path.exists() {
1349 return Ok(()); }
1351
1352 let modules_dir = git_dir.join("modules");
1354 if !modules_dir.exists() {
1355 return Err(io::Error::new(
1357 io::ErrorKind::InvalidInput,
1358 "Submodules are not initialized. Run: git submodule update --init --recursive",
1359 ));
1360 }
1361
1362 let gitmodules_content = fs::read_to_string(&gitmodules_path).unwrap_or_default();
1364 let submodule_count = gitmodules_content.matches("path = ").count();
1365
1366 if submodule_count > 0 {
1367 for line in gitmodules_content.lines() {
1369 if line.contains("path = ") {
1370 if let Some(path) = line.split("path = ").nth(1) {
1371 let submodule_path = workdir.join(path.trim());
1372 if !submodule_path.exists() {
1373 return Err(io::Error::new(
1374 io::ErrorKind::InvalidInput,
1375 format!(
1376 "Submodule '{}' is not initialized. Run: git submodule update --init --recursive",
1377 path.trim()
1378 ),
1379 ));
1380 }
1381 }
1382 }
1383 }
1384 }
1385
1386 Ok(())
1387}
1388
1389#[cfg(any(test, feature = "test-utils"))]
1399fn check_sparse_checkout_state() -> io::Result<()> {
1400 use std::fs;
1401
1402 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1403 let git_dir = repo.path();
1404
1405 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1407
1408 let sparse_checkout = config.get_bool("core.sparseCheckout");
1409 let sparse_checkout_cone = config.get_bool("extensions.sparseCheckoutCone");
1410
1411 match (sparse_checkout, sparse_checkout_cone) {
1412 (Ok(true), _) | (_, Ok(true)) => {
1413 let info_sparse_dir = git_dir.join("info").join("sparse-checkout");
1415
1416 if !info_sparse_dir.exists() {
1417 return Err(io::Error::new(
1419 io::ErrorKind::InvalidInput,
1420 "Sparse checkout is enabled but not configured. \
1421 Run: git sparse-checkout init",
1422 ));
1423 }
1424
1425 if let Ok(content) = fs::read_to_string(&info_sparse_dir) {
1427 if content.trim().is_empty() {
1428 return Err(io::Error::new(
1429 io::ErrorKind::InvalidInput,
1430 "Sparse checkout configuration is empty. \
1431 Run: git sparse-checkout set <patterns>",
1432 ));
1433 }
1434 }
1435
1436 }
1441 (Err(_), _) | (_, Err(_)) => {
1442 }
1444 _ => {}
1445 }
1446
1447 Ok(())
1448}
1449
1450pub fn rebase_onto(
1484 upstream_branch: &str,
1485 executor: &dyn crate::executor::ProcessExecutor,
1486) -> io::Result<RebaseResult> {
1487 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1488 rebase_onto_impl(&repo, upstream_branch, executor)
1489}
1490
1491fn rebase_onto_impl(
1493 repo: &git2::Repository,
1494 upstream_branch: &str,
1495 executor: &dyn crate::executor::ProcessExecutor,
1496) -> io::Result<RebaseResult> {
1497 match repo.head() {
1500 Ok(_) => {}
1501 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
1502 return Ok(RebaseResult::NoOp {
1504 reason: "Repository has no commits yet (unborn branch)".to_string(),
1505 });
1506 }
1507 Err(e) => return Err(git2_to_io_error(&e)),
1508 }
1509
1510 let upstream_object = match repo.revparse_single(upstream_branch) {
1512 Ok(obj) => obj,
1513 Err(_) => {
1514 return Ok(RebaseResult::Failed(RebaseErrorKind::InvalidRevision {
1515 revision: upstream_branch.to_string(),
1516 }))
1517 }
1518 };
1519
1520 let upstream_commit = upstream_object
1521 .peel_to_commit()
1522 .map_err(|e| git2_to_io_error(&e))?;
1523
1524 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1526 let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
1527
1528 if repo
1530 .graph_descendant_of(head_commit.id(), upstream_commit.id())
1531 .map_err(|e| git2_to_io_error(&e))?
1532 {
1533 return Ok(RebaseResult::NoOp {
1535 reason: "Branch is already up-to-date with upstream".to_string(),
1536 });
1537 }
1538
1539 match repo.merge_base(head_commit.id(), upstream_commit.id()) {
1542 Err(e)
1543 if e.class() == git2::ErrorClass::Reference
1544 && e.code() == git2::ErrorCode::NotFound =>
1545 {
1546 return Ok(RebaseResult::NoOp {
1548 reason: format!(
1549 "No common ancestor between current branch and '{upstream_branch}' (unrelated branches)"
1550 ),
1551 });
1552 }
1553 Err(e) => return Err(git2_to_io_error(&e)),
1554 Ok(_) => {}
1555 }
1556
1557 let branch_name = match head.shorthand() {
1559 Some(name) => name,
1560 None => {
1561 return Ok(RebaseResult::NoOp {
1563 reason: "HEAD is detached (not on any branch), rebase not applicable".to_string(),
1564 });
1565 }
1566 };
1567
1568 if branch_name == "main" || branch_name == "master" {
1569 return Ok(RebaseResult::NoOp {
1570 reason: format!("Already on '{branch_name}' branch, rebase not applicable"),
1571 });
1572 }
1573
1574 let output = executor.execute("git", &["rebase", upstream_branch], &[], None)?;
1576
1577 if output.status.success() {
1578 Ok(RebaseResult::Success)
1579 } else {
1580 let stderr = &output.stderr;
1581 let stdout = &output.stdout;
1582
1583 let error_kind = classify_rebase_error(stderr, stdout);
1585
1586 match error_kind {
1587 RebaseErrorKind::ContentConflict { .. } => {
1588 match get_conflicted_files() {
1590 Ok(files) if files.is_empty() => {
1591 if let RebaseErrorKind::ContentConflict { files } = error_kind {
1594 Ok(RebaseResult::Conflicts(files))
1595 } else {
1596 Ok(RebaseResult::Conflicts(vec![]))
1597 }
1598 }
1599 Ok(files) => Ok(RebaseResult::Conflicts(files)),
1600 Err(_) => Ok(RebaseResult::Conflicts(vec![])),
1601 }
1602 }
1603 RebaseErrorKind::Unknown { .. } => {
1604 if stderr.contains("up to date") {
1606 Ok(RebaseResult::NoOp {
1607 reason: "Branch is already up-to-date with upstream".to_string(),
1608 })
1609 } else {
1610 Ok(RebaseResult::Failed(error_kind))
1611 }
1612 }
1613 _ => Ok(RebaseResult::Failed(error_kind)),
1614 }
1615 }
1616}
1617
1618pub fn abort_rebase(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<()> {
1634 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1635 abort_rebase_impl(&repo, executor)
1636}
1637
1638fn abort_rebase_impl(
1640 repo: &git2::Repository,
1641 executor: &dyn crate::executor::ProcessExecutor,
1642) -> io::Result<()> {
1643 let state = repo.state();
1645 if state != git2::RepositoryState::Rebase
1646 && state != git2::RepositoryState::RebaseMerge
1647 && state != git2::RepositoryState::RebaseInteractive
1648 {
1649 return Err(io::Error::new(
1650 io::ErrorKind::InvalidInput,
1651 "No rebase in progress",
1652 ));
1653 }
1654
1655 let output = executor.execute("git", &["rebase", "--abort"], &[], None)?;
1657
1658 if output.status.success() {
1659 Ok(())
1660 } else {
1661 Err(io::Error::other(format!(
1662 "Failed to abort rebase: {}",
1663 output.stderr
1664 )))
1665 }
1666}
1667
1668pub fn get_conflicted_files() -> io::Result<Vec<String>> {
1679 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1680 get_conflicted_files_impl(&repo)
1681}
1682
1683fn get_conflicted_files_impl(repo: &git2::Repository) -> io::Result<Vec<String>> {
1685 let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1686
1687 let mut conflicted_files = Vec::new();
1688
1689 if !index.has_conflicts() {
1691 return Ok(conflicted_files);
1692 }
1693
1694 let conflicts = index.conflicts().map_err(|e| git2_to_io_error(&e))?;
1696
1697 for conflict in conflicts {
1698 let conflict = conflict.map_err(|e| git2_to_io_error(&e))?;
1699 if let Some(our_entry) = conflict.our {
1701 if let Ok(path) = std::str::from_utf8(&our_entry.path) {
1702 let path_str = path.to_string();
1703 if !conflicted_files.contains(&path_str) {
1704 conflicted_files.push(path_str);
1705 }
1706 }
1707 }
1708 }
1709
1710 Ok(conflicted_files)
1711}
1712
1713pub fn get_conflict_markers_for_file(path: &Path) -> io::Result<String> {
1727 use std::fs;
1728 use std::io::Read;
1729
1730 let mut file = fs::File::open(path)?;
1731 let mut content = String::new();
1732 file.read_to_string(&mut content)?;
1733
1734 let mut conflict_sections = Vec::new();
1736 let lines: Vec<&str> = content.lines().collect();
1737 let mut i = 0;
1738
1739 while i < lines.len() {
1740 if lines[i].trim_start().starts_with("<<<<<<<") {
1741 let mut section = Vec::new();
1743 section.push(lines[i]);
1744
1745 i += 1;
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 while i < lines.len() && !lines[i].trim_start().starts_with(">>>>>>>") {
1759 section.push(lines[i]);
1760 i += 1;
1761 }
1762
1763 if i < lines.len() {
1764 section.push(lines[i]); i += 1;
1766 }
1767
1768 conflict_sections.push(section.join("\n"));
1769 } else {
1770 i += 1;
1771 }
1772 }
1773
1774 if conflict_sections.is_empty() {
1775 Ok(String::new())
1777 } else {
1778 Ok(conflict_sections.join("\n\n"))
1779 }
1780}
1781
1782#[cfg(any(test, feature = "test-utils"))]
1802pub fn verify_rebase_completed(upstream_branch: &str) -> io::Result<bool> {
1803 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1804
1805 let state = repo.state();
1807 if state == git2::RepositoryState::Rebase
1808 || state == git2::RepositoryState::RebaseMerge
1809 || state == git2::RepositoryState::RebaseInteractive
1810 {
1811 return Ok(false);
1813 }
1814
1815 let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1817 if index.has_conflicts() {
1818 return Ok(false);
1820 }
1821
1822 let head = repo.head().map_err(|e| {
1824 io::Error::new(
1825 io::ErrorKind::InvalidData,
1826 format!("Repository HEAD is invalid: {e}"),
1827 )
1828 })?;
1829
1830 if let Ok(head_commit) = head.peel_to_commit() {
1833 if let Ok(upstream_object) = repo.revparse_single(upstream_branch) {
1834 if let Ok(upstream_commit) = upstream_object.peel_to_commit() {
1835 match repo.graph_descendant_of(head_commit.id(), upstream_commit.id()) {
1838 Ok(is_descendant) => {
1839 if is_descendant {
1840 return Ok(true);
1842 } else {
1843 return Ok(false);
1846 }
1847 }
1848 Err(e) => {
1849 let _ = e; }
1852 }
1853 }
1854 }
1855 }
1856
1857 Ok(!index.has_conflicts())
1860}
1861
1862pub fn continue_rebase(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<()> {
1877 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1878 continue_rebase_impl(&repo, executor)
1879}
1880
1881fn continue_rebase_impl(
1883 repo: &git2::Repository,
1884 executor: &dyn crate::executor::ProcessExecutor,
1885) -> io::Result<()> {
1886 let state = repo.state();
1888 if state != git2::RepositoryState::Rebase
1889 && state != git2::RepositoryState::RebaseMerge
1890 && state != git2::RepositoryState::RebaseInteractive
1891 {
1892 return Err(io::Error::new(
1893 io::ErrorKind::InvalidInput,
1894 "No rebase in progress",
1895 ));
1896 }
1897
1898 let conflicted = get_conflicted_files()?;
1900 if !conflicted.is_empty() {
1901 return Err(io::Error::new(
1902 io::ErrorKind::InvalidInput,
1903 format!(
1904 "Cannot continue rebase: {} file(s) still have conflicts",
1905 conflicted.len()
1906 ),
1907 ));
1908 }
1909
1910 let output = executor.execute("git", &["rebase", "--continue"], &[], None)?;
1912
1913 if output.status.success() {
1914 Ok(())
1915 } else {
1916 Err(io::Error::other(format!(
1917 "Failed to continue rebase: {}",
1918 output.stderr
1919 )))
1920 }
1921}
1922
1923pub fn rebase_in_progress() -> io::Result<bool> {
1935 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1936 rebase_in_progress_impl(&repo)
1937}
1938
1939fn rebase_in_progress_impl(repo: &git2::Repository) -> io::Result<bool> {
1941 let state = repo.state();
1942 Ok(state == git2::RepositoryState::Rebase
1943 || state == git2::RepositoryState::RebaseMerge
1944 || state == git2::RepositoryState::RebaseInteractive)
1945}
1946
1947#[cfg(test)]
1948mod tests {
1949 use super::*;
1950 use crate::executor::MockProcessExecutor;
1951 use std::sync::Arc;
1952
1953 #[test]
1954 fn test_rebase_result_variants_exist() {
1955 let _ = RebaseResult::Success;
1957 let _ = RebaseResult::NoOp {
1958 reason: "test".to_string(),
1959 };
1960 let _ = RebaseResult::Conflicts(vec![]);
1961 let _ = RebaseResult::Failed(RebaseErrorKind::Unknown {
1962 details: "test".to_string(),
1963 });
1964 }
1965
1966 #[test]
1967 fn test_rebase_result_is_noop() {
1968 assert!(RebaseResult::NoOp {
1970 reason: "test".to_string()
1971 }
1972 .is_noop());
1973 assert!(!RebaseResult::Success.is_noop());
1974 assert!(!RebaseResult::Conflicts(vec![]).is_noop());
1975 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
1976 details: "test".to_string(),
1977 })
1978 .is_noop());
1979 }
1980
1981 #[test]
1982 fn test_rebase_result_is_success() {
1983 assert!(RebaseResult::Success.is_success());
1985 assert!(!RebaseResult::NoOp {
1986 reason: "test".to_string()
1987 }
1988 .is_success());
1989 assert!(!RebaseResult::Conflicts(vec![]).is_success());
1990 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
1991 details: "test".to_string(),
1992 })
1993 .is_success());
1994 }
1995
1996 #[test]
1997 fn test_rebase_result_has_conflicts() {
1998 assert!(RebaseResult::Conflicts(vec!["file.txt".to_string()]).has_conflicts());
2000 assert!(!RebaseResult::Success.has_conflicts());
2001 assert!(!RebaseResult::NoOp {
2002 reason: "test".to_string()
2003 }
2004 .has_conflicts());
2005 }
2006
2007 #[test]
2008 fn test_rebase_result_is_failed() {
2009 assert!(RebaseResult::Failed(RebaseErrorKind::Unknown {
2011 details: "test".to_string(),
2012 })
2013 .is_failed());
2014 assert!(!RebaseResult::Success.is_failed());
2015 assert!(!RebaseResult::NoOp {
2016 reason: "test".to_string()
2017 }
2018 .is_failed());
2019 assert!(!RebaseResult::Conflicts(vec![]).is_failed());
2020 }
2021
2022 #[test]
2023 fn test_rebase_error_kind_description() {
2024 let err = RebaseErrorKind::InvalidRevision {
2026 revision: "main".to_string(),
2027 };
2028 assert!(err.description().contains("main"));
2029
2030 let err = RebaseErrorKind::DirtyWorkingTree;
2031 assert!(err.description().contains("Working tree"));
2032 }
2033
2034 #[test]
2035 fn test_rebase_error_kind_category() {
2036 assert_eq!(
2038 RebaseErrorKind::InvalidRevision {
2039 revision: "test".to_string()
2040 }
2041 .category(),
2042 1
2043 );
2044 assert_eq!(
2045 RebaseErrorKind::ContentConflict { files: vec![] }.category(),
2046 2
2047 );
2048 assert_eq!(
2049 RebaseErrorKind::ValidationFailed {
2050 reason: "test".to_string()
2051 }
2052 .category(),
2053 3
2054 );
2055 assert_eq!(
2056 RebaseErrorKind::ProcessTerminated {
2057 reason: "test".to_string()
2058 }
2059 .category(),
2060 4
2061 );
2062 assert_eq!(
2063 RebaseErrorKind::Unknown {
2064 details: "test".to_string()
2065 }
2066 .category(),
2067 5
2068 );
2069 }
2070
2071 #[test]
2072 fn test_rebase_error_kind_is_recoverable() {
2073 assert!(RebaseErrorKind::ConcurrentOperation {
2075 operation: "rebase".to_string()
2076 }
2077 .is_recoverable());
2078 assert!(RebaseErrorKind::ContentConflict { files: vec![] }.is_recoverable());
2079 assert!(!RebaseErrorKind::InvalidRevision {
2080 revision: "test".to_string()
2081 }
2082 .is_recoverable());
2083 assert!(!RebaseErrorKind::DirtyWorkingTree.is_recoverable());
2084 }
2085
2086 #[test]
2087 fn test_classify_rebase_error_invalid_revision() {
2088 let stderr = "error: invalid revision 'nonexistent'";
2090 let error = classify_rebase_error(stderr, "");
2091 assert!(matches!(error, RebaseErrorKind::InvalidRevision { .. }));
2092 }
2093
2094 #[test]
2095 fn test_classify_rebase_error_conflict() {
2096 let stderr = "CONFLICT (content): Merge conflict in src/main.rs";
2098 let error = classify_rebase_error(stderr, "");
2099 assert!(matches!(error, RebaseErrorKind::ContentConflict { .. }));
2100 }
2101
2102 #[test]
2103 fn test_classify_rebase_error_dirty_tree() {
2104 let stderr = "Cannot rebase: Your index contains uncommitted changes";
2106 let error = classify_rebase_error(stderr, "");
2107 assert!(matches!(error, RebaseErrorKind::DirtyWorkingTree));
2108 }
2109
2110 #[test]
2111 fn test_classify_rebase_error_concurrent_operation() {
2112 let stderr = "Cannot rebase: There is a rebase in progress already";
2114 let error = classify_rebase_error(stderr, "");
2115 assert!(matches!(error, RebaseErrorKind::ConcurrentOperation { .. }));
2116 }
2117
2118 #[test]
2119 fn test_classify_rebase_error_unknown() {
2120 let stderr = "Some completely unexpected error message";
2122 let error = classify_rebase_error(stderr, "");
2123 assert!(matches!(error, RebaseErrorKind::Unknown { .. }));
2124 }
2125
2126 #[test]
2127 fn test_rebase_onto_returns_result() {
2128 use test_helpers::{commit_all, init_git_repo, with_temp_cwd, write_file};
2129
2130 with_temp_cwd(|dir| {
2132 let repo = init_git_repo(dir);
2134 write_file(dir.path().join("initial.txt"), "initial content");
2135 let _ = commit_all(&repo, "initial commit");
2136
2137 let executor =
2140 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2141 let result = rebase_onto("nonexistent_branch_that_does_not_exist", executor.as_ref());
2142 assert!(result.is_ok());
2144 });
2145 }
2146
2147 #[test]
2148 fn test_get_conflicted_files_returns_result() {
2149 use test_helpers::{init_git_repo, with_temp_cwd};
2150
2151 with_temp_cwd(|dir| {
2153 let _repo = init_git_repo(dir);
2155
2156 let result = get_conflicted_files();
2157 assert!(result.is_ok());
2159 });
2160 }
2161
2162 #[test]
2163 fn test_rebase_in_progress_cli_returns_result() {
2164 use test_helpers::{init_git_repo, with_temp_cwd};
2165
2166 with_temp_cwd(|dir| {
2168 let _repo = init_git_repo(dir);
2170
2171 let executor =
2173 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2174 let result = rebase_in_progress_cli(executor.as_ref());
2175 assert!(result.is_ok());
2177 });
2178 }
2179
2180 #[test]
2181 fn test_is_dirty_tree_cli_returns_result() {
2182 use test_helpers::{init_git_repo, with_temp_cwd};
2183
2184 with_temp_cwd(|dir| {
2186 let _repo = init_git_repo(dir);
2188
2189 let executor =
2191 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2192 let result = is_dirty_tree_cli(executor.as_ref());
2193 assert!(result.is_ok());
2195 });
2196 }
2197
2198 #[test]
2199 fn test_cleanup_stale_rebase_state_returns_result() {
2200 use test_helpers::{init_git_repo, with_temp_cwd};
2201
2202 with_temp_cwd(|dir| {
2203 let _repo = init_git_repo(dir);
2205
2206 let result = cleanup_stale_rebase_state();
2208 assert!(result.is_ok());
2210 });
2211 }
2212}