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"))]
1087pub fn restore_from_reflog(
1088 ref_name: &str,
1089 steps_back: usize,
1090 executor: &dyn crate::executor::ProcessExecutor,
1091) -> io::Result<()> {
1092 let refspec = format!("{ref_name}@{{{steps_back}}}");
1094 let output = executor.execute("git", &["reset", "--hard", &refspec], &[], None)?;
1095
1096 if output.status.success() {
1097 Ok(())
1098 } else {
1099 Err(io::Error::other(format!(
1100 "Failed to restore from reflog: {}",
1101 output.stderr
1102 )))
1103 }
1104}
1105
1106#[cfg(any(test, feature = "test-utils"))]
1119pub fn is_dirty_tree_cli(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<bool> {
1120 let output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
1121
1122 if output.status.success() {
1123 let stdout = output.stdout.trim();
1124 Ok(!stdout.is_empty())
1125 } else {
1126 Err(io::Error::other(format!(
1127 "Failed to check working tree status: {}",
1128 output.stderr
1129 )))
1130 }
1131}
1132
1133#[cfg(any(test, feature = "test-utils"))]
1170pub fn validate_rebase_preconditions(
1171 executor: &dyn crate::executor::ProcessExecutor,
1172) -> io::Result<()> {
1173 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1174
1175 validate_git_state()?;
1177
1178 if let Some(concurrent_op) = detect_concurrent_git_operations()? {
1180 return Err(io::Error::new(
1181 io::ErrorKind::InvalidInput,
1182 format!(
1183 "Cannot start rebase: {} already in progress. \
1184 Please complete or abort the current operation first.",
1185 concurrent_op.description()
1186 ),
1187 ));
1188 }
1189
1190 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1192
1193 let user_name = config.get_string("user.name");
1194 let user_email = config.get_string("user.email");
1195
1196 if user_name.is_err() && user_email.is_err() {
1197 return Err(io::Error::new(
1198 io::ErrorKind::InvalidInput,
1199 "Git identity is not configured. Please set user.name and user.email:\n \
1200 git config --global user.name \"Your Name\"\n \
1201 git config --global user.email \"you@example.com\"",
1202 ));
1203 }
1204
1205 let status_output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
1207
1208 if status_output.status.success() {
1209 let stdout = status_output.stdout.trim();
1210 if !stdout.is_empty() {
1211 return Err(io::Error::new(
1212 io::ErrorKind::InvalidInput,
1213 "Working tree is not clean. Please commit or stash changes before rebasing.",
1214 ));
1215 }
1216 } else {
1217 let statuses = repo.statuses(None).map_err(|e| {
1219 io::Error::new(
1220 io::ErrorKind::InvalidData,
1221 format!("Failed to check working tree status: {e}"),
1222 )
1223 })?;
1224
1225 if !statuses.is_empty() {
1226 return Err(io::Error::new(
1227 io::ErrorKind::InvalidInput,
1228 "Working tree is not clean. Please commit or stash changes before rebasing.",
1229 ));
1230 }
1231 }
1232
1233 check_shallow_clone()?;
1235
1236 check_worktree_conflicts()?;
1238
1239 check_submodule_state()?;
1241
1242 check_sparse_checkout_state()?;
1244
1245 Ok(())
1246}
1247
1248#[cfg(any(test, feature = "test-utils"))]
1258fn check_shallow_clone() -> io::Result<()> {
1259 use std::fs;
1260
1261 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1262 let git_dir = repo.path();
1263
1264 let shallow_file = git_dir.join("shallow");
1266 if shallow_file.exists() {
1267 let content = fs::read_to_string(&shallow_file).unwrap_or_default();
1269 let line_count = content.lines().count();
1270
1271 return Err(io::Error::new(
1272 io::ErrorKind::InvalidInput,
1273 format!(
1274 "Repository is a shallow clone with {} commits. \
1275 Rebasing may fail due to missing history. \
1276 Consider running: git fetch --unshallow",
1277 line_count
1278 ),
1279 ));
1280 }
1281
1282 Ok(())
1283}
1284
1285#[cfg(any(test, feature = "test-utils"))]
1295fn check_worktree_conflicts() -> io::Result<()> {
1296 use std::fs;
1297
1298 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1299
1300 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1302 let branch_name = match head.shorthand() {
1303 Some(name) if head.is_branch() => name,
1304 _ => return Ok(()), };
1306
1307 let git_dir = repo.path();
1308 let worktrees_dir = git_dir.join("worktrees");
1309
1310 if !worktrees_dir.exists() {
1311 return Ok(());
1312 }
1313
1314 let entries = fs::read_dir(&worktrees_dir).map_err(|e| {
1316 io::Error::new(
1317 io::ErrorKind::InvalidData,
1318 format!("Failed to read worktrees directory: {e}"),
1319 )
1320 })?;
1321
1322 for entry in entries.flatten() {
1323 let worktree_path = entry.path();
1324 let worktree_head = worktree_path.join("HEAD");
1325
1326 if worktree_head.exists() {
1327 if let Ok(content) = fs::read_to_string(&worktree_head) {
1328 if content.contains(&format!("refs/heads/{branch_name}")) {
1330 let worktree_name = worktree_path
1332 .file_name()
1333 .and_then(|n| n.to_str())
1334 .unwrap_or("unknown");
1335
1336 return Err(io::Error::new(
1337 io::ErrorKind::InvalidInput,
1338 format!(
1339 "Branch '{branch_name}' is already checked out in worktree '{worktree_name}'. \
1340 Use 'git worktree add' to create a new worktree for this branch."
1341 ),
1342 ));
1343 }
1344 }
1345 }
1346 }
1347
1348 Ok(())
1349}
1350
1351#[cfg(any(test, feature = "test-utils"))]
1361fn check_submodule_state() -> io::Result<()> {
1362 use std::fs;
1363
1364 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1365 let git_dir = repo.path();
1366
1367 let workdir = repo.workdir().unwrap_or(git_dir);
1369 let gitmodules_path = workdir.join(".gitmodules");
1370
1371 if !gitmodules_path.exists() {
1372 return Ok(()); }
1374
1375 let modules_dir = git_dir.join("modules");
1377 if !modules_dir.exists() {
1378 return Err(io::Error::new(
1380 io::ErrorKind::InvalidInput,
1381 "Submodules are not initialized. Run: git submodule update --init --recursive",
1382 ));
1383 }
1384
1385 let gitmodules_content = fs::read_to_string(&gitmodules_path).unwrap_or_default();
1387 let submodule_count = gitmodules_content.matches("path = ").count();
1388
1389 if submodule_count > 0 {
1390 for line in gitmodules_content.lines() {
1392 if line.contains("path = ") {
1393 if let Some(path) = line.split("path = ").nth(1) {
1394 let submodule_path = workdir.join(path.trim());
1395 if !submodule_path.exists() {
1396 return Err(io::Error::new(
1397 io::ErrorKind::InvalidInput,
1398 format!(
1399 "Submodule '{}' is not initialized. Run: git submodule update --init --recursive",
1400 path.trim()
1401 ),
1402 ));
1403 }
1404 }
1405 }
1406 }
1407 }
1408
1409 Ok(())
1410}
1411
1412#[cfg(any(test, feature = "test-utils"))]
1422fn check_sparse_checkout_state() -> io::Result<()> {
1423 use std::fs;
1424
1425 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1426 let git_dir = repo.path();
1427
1428 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1430
1431 let sparse_checkout = config.get_bool("core.sparseCheckout");
1432 let sparse_checkout_cone = config.get_bool("extensions.sparseCheckoutCone");
1433
1434 match (sparse_checkout, sparse_checkout_cone) {
1435 (Ok(true), _) | (_, Ok(true)) => {
1436 let info_sparse_dir = git_dir.join("info").join("sparse-checkout");
1438
1439 if !info_sparse_dir.exists() {
1440 return Err(io::Error::new(
1442 io::ErrorKind::InvalidInput,
1443 "Sparse checkout is enabled but not configured. \
1444 Run: git sparse-checkout init",
1445 ));
1446 }
1447
1448 if let Ok(content) = fs::read_to_string(&info_sparse_dir) {
1450 if content.trim().is_empty() {
1451 return Err(io::Error::new(
1452 io::ErrorKind::InvalidInput,
1453 "Sparse checkout configuration is empty. \
1454 Run: git sparse-checkout set <patterns>",
1455 ));
1456 }
1457 }
1458
1459 }
1464 (Err(_), _) | (_, Err(_)) => {
1465 }
1467 _ => {}
1468 }
1469
1470 Ok(())
1471}
1472
1473pub fn rebase_onto(
1507 upstream_branch: &str,
1508 executor: &dyn crate::executor::ProcessExecutor,
1509) -> io::Result<RebaseResult> {
1510 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1511 rebase_onto_impl(&repo, upstream_branch, executor)
1512}
1513
1514fn rebase_onto_impl(
1516 repo: &git2::Repository,
1517 upstream_branch: &str,
1518 executor: &dyn crate::executor::ProcessExecutor,
1519) -> io::Result<RebaseResult> {
1520 match repo.head() {
1523 Ok(_) => {}
1524 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
1525 return Ok(RebaseResult::NoOp {
1527 reason: "Repository has no commits yet (unborn branch)".to_string(),
1528 });
1529 }
1530 Err(e) => return Err(git2_to_io_error(&e)),
1531 }
1532
1533 let upstream_object = match repo.revparse_single(upstream_branch) {
1535 Ok(obj) => obj,
1536 Err(_) => {
1537 return Ok(RebaseResult::Failed(RebaseErrorKind::InvalidRevision {
1538 revision: upstream_branch.to_string(),
1539 }))
1540 }
1541 };
1542
1543 let upstream_commit = upstream_object
1544 .peel_to_commit()
1545 .map_err(|e| git2_to_io_error(&e))?;
1546
1547 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1549 let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
1550
1551 if repo
1553 .graph_descendant_of(head_commit.id(), upstream_commit.id())
1554 .map_err(|e| git2_to_io_error(&e))?
1555 {
1556 return Ok(RebaseResult::NoOp {
1558 reason: "Branch is already up-to-date with upstream".to_string(),
1559 });
1560 }
1561
1562 match repo.merge_base(head_commit.id(), upstream_commit.id()) {
1565 Err(e)
1566 if e.class() == git2::ErrorClass::Reference
1567 && e.code() == git2::ErrorCode::NotFound =>
1568 {
1569 return Ok(RebaseResult::NoOp {
1571 reason: format!(
1572 "No common ancestor between current branch and '{upstream_branch}' (unrelated branches)"
1573 ),
1574 });
1575 }
1576 Err(e) => return Err(git2_to_io_error(&e)),
1577 Ok(_) => {}
1578 }
1579
1580 let branch_name = match head.shorthand() {
1582 Some(name) => name,
1583 None => {
1584 return Ok(RebaseResult::NoOp {
1586 reason: "HEAD is detached (not on any branch), rebase not applicable".to_string(),
1587 });
1588 }
1589 };
1590
1591 if branch_name == "main" || branch_name == "master" {
1592 return Ok(RebaseResult::NoOp {
1593 reason: format!("Already on '{branch_name}' branch, rebase not applicable"),
1594 });
1595 }
1596
1597 let output = executor.execute("git", &["rebase", upstream_branch], &[], None)?;
1599
1600 if output.status.success() {
1601 Ok(RebaseResult::Success)
1602 } else {
1603 let stderr = &output.stderr;
1604 let stdout = &output.stdout;
1605
1606 let error_kind = classify_rebase_error(stderr, stdout);
1608
1609 match error_kind {
1610 RebaseErrorKind::ContentConflict { .. } => {
1611 match get_conflicted_files() {
1613 Ok(files) if files.is_empty() => {
1614 if let RebaseErrorKind::ContentConflict { files } = error_kind {
1617 Ok(RebaseResult::Conflicts(files))
1618 } else {
1619 Ok(RebaseResult::Conflicts(vec![]))
1620 }
1621 }
1622 Ok(files) => Ok(RebaseResult::Conflicts(files)),
1623 Err(_) => Ok(RebaseResult::Conflicts(vec![])),
1624 }
1625 }
1626 RebaseErrorKind::Unknown { .. } => {
1627 if stderr.contains("up to date") {
1629 Ok(RebaseResult::NoOp {
1630 reason: "Branch is already up-to-date with upstream".to_string(),
1631 })
1632 } else {
1633 Ok(RebaseResult::Failed(error_kind))
1634 }
1635 }
1636 _ => Ok(RebaseResult::Failed(error_kind)),
1637 }
1638 }
1639}
1640
1641pub fn abort_rebase(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<()> {
1657 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1658 abort_rebase_impl(&repo, executor)
1659}
1660
1661fn abort_rebase_impl(
1663 repo: &git2::Repository,
1664 executor: &dyn crate::executor::ProcessExecutor,
1665) -> io::Result<()> {
1666 let state = repo.state();
1668 if state != git2::RepositoryState::Rebase
1669 && state != git2::RepositoryState::RebaseMerge
1670 && state != git2::RepositoryState::RebaseInteractive
1671 {
1672 return Err(io::Error::new(
1673 io::ErrorKind::InvalidInput,
1674 "No rebase in progress",
1675 ));
1676 }
1677
1678 let output = executor.execute("git", &["rebase", "--abort"], &[], None)?;
1680
1681 if output.status.success() {
1682 Ok(())
1683 } else {
1684 Err(io::Error::other(format!(
1685 "Failed to abort rebase: {}",
1686 output.stderr
1687 )))
1688 }
1689}
1690
1691pub fn get_conflicted_files() -> io::Result<Vec<String>> {
1702 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1703 get_conflicted_files_impl(&repo)
1704}
1705
1706fn get_conflicted_files_impl(repo: &git2::Repository) -> io::Result<Vec<String>> {
1708 let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1709
1710 let mut conflicted_files = Vec::new();
1711
1712 if !index.has_conflicts() {
1714 return Ok(conflicted_files);
1715 }
1716
1717 let conflicts = index.conflicts().map_err(|e| git2_to_io_error(&e))?;
1719
1720 for conflict in conflicts {
1721 let conflict = conflict.map_err(|e| git2_to_io_error(&e))?;
1722 if let Some(our_entry) = conflict.our {
1724 if let Ok(path) = std::str::from_utf8(&our_entry.path) {
1725 let path_str = path.to_string();
1726 if !conflicted_files.contains(&path_str) {
1727 conflicted_files.push(path_str);
1728 }
1729 }
1730 }
1731 }
1732
1733 Ok(conflicted_files)
1734}
1735
1736pub fn get_conflict_markers_for_file(path: &Path) -> io::Result<String> {
1750 use std::fs;
1751 use std::io::Read;
1752
1753 let mut file = fs::File::open(path)?;
1754 let mut content = String::new();
1755 file.read_to_string(&mut content)?;
1756
1757 let mut conflict_sections = Vec::new();
1759 let lines: Vec<&str> = content.lines().collect();
1760 let mut i = 0;
1761
1762 while i < lines.len() {
1763 if lines[i].trim_start().starts_with("<<<<<<<") {
1764 let mut section = Vec::new();
1766 section.push(lines[i]);
1767
1768 i += 1;
1769 while i < lines.len() && !lines[i].trim_start().starts_with("=======") {
1771 section.push(lines[i]);
1772 i += 1;
1773 }
1774
1775 if i < lines.len() {
1776 section.push(lines[i]); i += 1;
1778 }
1779
1780 while i < lines.len() && !lines[i].trim_start().starts_with(">>>>>>>") {
1782 section.push(lines[i]);
1783 i += 1;
1784 }
1785
1786 if i < lines.len() {
1787 section.push(lines[i]); i += 1;
1789 }
1790
1791 conflict_sections.push(section.join("\n"));
1792 } else {
1793 i += 1;
1794 }
1795 }
1796
1797 if conflict_sections.is_empty() {
1798 Ok(String::new())
1800 } else {
1801 Ok(conflict_sections.join("\n\n"))
1802 }
1803}
1804
1805#[cfg(any(test, feature = "test-utils"))]
1825pub fn verify_rebase_completed(upstream_branch: &str) -> io::Result<bool> {
1826 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1827
1828 let state = repo.state();
1830 if state == git2::RepositoryState::Rebase
1831 || state == git2::RepositoryState::RebaseMerge
1832 || state == git2::RepositoryState::RebaseInteractive
1833 {
1834 return Ok(false);
1836 }
1837
1838 let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1840 if index.has_conflicts() {
1841 return Ok(false);
1843 }
1844
1845 let head = repo.head().map_err(|e| {
1847 io::Error::new(
1848 io::ErrorKind::InvalidData,
1849 format!("Repository HEAD is invalid: {e}"),
1850 )
1851 })?;
1852
1853 if let Ok(head_commit) = head.peel_to_commit() {
1856 if let Ok(upstream_object) = repo.revparse_single(upstream_branch) {
1857 if let Ok(upstream_commit) = upstream_object.peel_to_commit() {
1858 match repo.graph_descendant_of(head_commit.id(), upstream_commit.id()) {
1861 Ok(is_descendant) => {
1862 if is_descendant {
1863 return Ok(true);
1865 } else {
1866 return Ok(false);
1869 }
1870 }
1871 Err(e) => {
1872 let _ = e; }
1875 }
1876 }
1877 }
1878 }
1879
1880 Ok(!index.has_conflicts())
1883}
1884
1885#[cfg(any(test, feature = "test-utils"))]
1902pub fn validate_post_rebase_state() -> io::Result<()> {
1903 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1904
1905 let head = repo.head().map_err(|e| {
1907 io::Error::new(
1908 io::ErrorKind::InvalidData,
1909 format!("Repository HEAD is invalid after rebase: {e}"),
1910 )
1911 })?;
1912
1913 let is_detached = head.shorthand().is_none();
1916 if is_detached {
1917 return Err(io::Error::new(
1918 io::ErrorKind::InvalidData,
1919 "HEAD is detached after rebase - this may indicate a problem",
1920 ));
1921 }
1922
1923 let _index = repo.index().map_err(|e| {
1925 io::Error::new(
1926 io::ErrorKind::InvalidData,
1927 format!("Repository index is corrupted after rebase: {e}"),
1928 )
1929 })?;
1930
1931 let head_commit = head.peel_to_commit().map_err(|e| {
1933 io::Error::new(
1934 io::ErrorKind::InvalidData,
1935 format!("Cannot access HEAD commit after rebase: {e}"),
1936 )
1937 })?;
1938
1939 let _tree = head_commit.tree().map_err(|e| {
1941 io::Error::new(
1942 io::ErrorKind::InvalidData,
1943 format!("Object database corruption after rebase: {e}"),
1944 )
1945 })?;
1946
1947 Ok(())
1948}
1949
1950#[cfg(any(test, feature = "test-utils"))]
1955#[derive(Debug, Clone, Default)]
1956pub struct PostRebaseValidationResult {
1957 pub git_state_valid: bool,
1959 pub build_valid: Option<bool>,
1961 pub tests_valid: Option<bool>,
1963 pub lint_valid: Option<bool>,
1965 pub messages: Vec<String>,
1967}
1968
1969#[cfg(any(test, feature = "test-utils"))]
1970impl PostRebaseValidationResult {
1971 pub fn is_valid(&self) -> bool {
1973 self.git_state_valid
1974 && self.build_valid.is_none_or(|v| v)
1975 && self.tests_valid.is_none_or(|v| v)
1976 && self.lint_valid.is_none_or(|v| v)
1977 }
1978
1979 pub fn summary(&self) -> String {
1981 if self.is_valid() {
1982 "All validations passed".to_string()
1983 } else {
1984 let mut failures = Vec::new();
1985 if !self.git_state_valid {
1986 failures.push("Git state validation failed".to_string());
1987 }
1988 if self.build_valid == Some(false) {
1989 failures.push("Build validation failed".to_string());
1990 }
1991 if self.tests_valid == Some(false) {
1992 failures.push("Test validation failed".to_string());
1993 }
1994 if self.lint_valid == Some(false) {
1995 failures.push("Lint validation failed".to_string());
1996 }
1997 failures.join("; ")
1998 }
1999 }
2000}
2001
2002#[cfg(any(test, feature = "test-utils"))]
2028pub fn validate_post_rebase_with_checks(
2029 executor: &dyn crate::executor::ProcessExecutor,
2030 run_build_checks: bool,
2031 run_test_checks: bool,
2032 run_lint_checks: bool,
2033) -> io::Result<PostRebaseValidationResult> {
2034 use std::path::Path;
2035
2036 let git_state_valid = validate_post_rebase_state().is_ok();
2037 let mut result = PostRebaseValidationResult {
2038 git_state_valid,
2039 ..Default::default()
2040 };
2041
2042 if !result.git_state_valid {
2043 result
2044 .messages
2045 .push("Git state validation failed".to_string());
2046 }
2047
2048 let is_rust_project = Path::new("Cargo.toml").exists();
2050
2051 if !is_rust_project {
2052 result
2053 .messages
2054 .push("Not a Rust project - skipping project-specific checks".to_string());
2055 return Ok(result);
2056 }
2057
2058 if run_build_checks {
2060 result
2061 .messages
2062 .push("Running build validation...".to_string());
2063 let build_output = executor.execute("cargo", &["build", "--release"], &[], None);
2064
2065 result.build_valid = Some(match build_output {
2066 Ok(output) => output.status.success(),
2067 Err(e) => {
2068 result.messages.push(format!("Failed to run build: {e}"));
2069 false
2070 }
2071 });
2072
2073 if result.build_valid == Some(false) {
2074 result
2075 .messages
2076 .push("Build validation failed - project may not compile".to_string());
2077 }
2078 }
2079
2080 if run_test_checks {
2082 result
2083 .messages
2084 .push("Running test validation...".to_string());
2085 let test_output =
2086 executor.execute("cargo", &["test", "--lib", "--all-features"], &[], None);
2087
2088 result.tests_valid = Some(match test_output {
2089 Ok(output) => output.status.success(),
2090 Err(e) => {
2091 result.messages.push(format!("Failed to run tests: {e}"));
2092 false
2093 }
2094 });
2095
2096 if result.tests_valid == Some(false) {
2097 result
2098 .messages
2099 .push("Test validation failed - some tests may be broken".to_string());
2100 }
2101 }
2102
2103 if run_lint_checks {
2105 result
2106 .messages
2107 .push("Running lint validation...".to_string());
2108 let lint_output = executor.execute(
2109 "cargo",
2110 &["clippy", "--lib", "--all-features", "-D", "warnings"],
2111 &[],
2112 None,
2113 );
2114
2115 result.lint_valid = Some(match lint_output {
2116 Ok(output) => output.status.success(),
2117 Err(e) => {
2118 result.messages.push(format!("Failed to run clippy: {e}"));
2119 false
2120 }
2121 });
2122
2123 if result.lint_valid == Some(false) {
2124 result
2125 .messages
2126 .push("Lint validation failed - code may have lint issues".to_string());
2127 }
2128 }
2129
2130 Ok(result)
2131}
2132
2133pub fn continue_rebase(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<()> {
2148 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
2149 continue_rebase_impl(&repo, executor)
2150}
2151
2152fn continue_rebase_impl(
2154 repo: &git2::Repository,
2155 executor: &dyn crate::executor::ProcessExecutor,
2156) -> io::Result<()> {
2157 let state = repo.state();
2159 if state != git2::RepositoryState::Rebase
2160 && state != git2::RepositoryState::RebaseMerge
2161 && state != git2::RepositoryState::RebaseInteractive
2162 {
2163 return Err(io::Error::new(
2164 io::ErrorKind::InvalidInput,
2165 "No rebase in progress",
2166 ));
2167 }
2168
2169 let conflicted = get_conflicted_files()?;
2171 if !conflicted.is_empty() {
2172 return Err(io::Error::new(
2173 io::ErrorKind::InvalidInput,
2174 format!(
2175 "Cannot continue rebase: {} file(s) still have conflicts",
2176 conflicted.len()
2177 ),
2178 ));
2179 }
2180
2181 let output = executor.execute("git", &["rebase", "--continue"], &[], None)?;
2183
2184 if output.status.success() {
2185 Ok(())
2186 } else {
2187 Err(io::Error::other(format!(
2188 "Failed to continue rebase: {}",
2189 output.stderr
2190 )))
2191 }
2192}
2193
2194pub fn rebase_in_progress() -> io::Result<bool> {
2206 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
2207 rebase_in_progress_impl(&repo)
2208}
2209
2210fn rebase_in_progress_impl(repo: &git2::Repository) -> io::Result<bool> {
2212 let state = repo.state();
2213 Ok(state == git2::RepositoryState::Rebase
2214 || state == git2::RepositoryState::RebaseMerge
2215 || state == git2::RepositoryState::RebaseInteractive)
2216}
2217
2218#[cfg(test)]
2219mod tests {
2220 use super::*;
2221 use crate::executor::MockProcessExecutor;
2222 use std::sync::Arc;
2223
2224 #[test]
2225 fn test_rebase_result_variants_exist() {
2226 let _ = RebaseResult::Success;
2228 let _ = RebaseResult::NoOp {
2229 reason: "test".to_string(),
2230 };
2231 let _ = RebaseResult::Conflicts(vec![]);
2232 let _ = RebaseResult::Failed(RebaseErrorKind::Unknown {
2233 details: "test".to_string(),
2234 });
2235 }
2236
2237 #[test]
2238 fn test_rebase_result_is_noop() {
2239 assert!(RebaseResult::NoOp {
2241 reason: "test".to_string()
2242 }
2243 .is_noop());
2244 assert!(!RebaseResult::Success.is_noop());
2245 assert!(!RebaseResult::Conflicts(vec![]).is_noop());
2246 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
2247 details: "test".to_string(),
2248 })
2249 .is_noop());
2250 }
2251
2252 #[test]
2253 fn test_rebase_result_is_success() {
2254 assert!(RebaseResult::Success.is_success());
2256 assert!(!RebaseResult::NoOp {
2257 reason: "test".to_string()
2258 }
2259 .is_success());
2260 assert!(!RebaseResult::Conflicts(vec![]).is_success());
2261 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
2262 details: "test".to_string(),
2263 })
2264 .is_success());
2265 }
2266
2267 #[test]
2268 fn test_rebase_result_has_conflicts() {
2269 assert!(RebaseResult::Conflicts(vec!["file.txt".to_string()]).has_conflicts());
2271 assert!(!RebaseResult::Success.has_conflicts());
2272 assert!(!RebaseResult::NoOp {
2273 reason: "test".to_string()
2274 }
2275 .has_conflicts());
2276 }
2277
2278 #[test]
2279 fn test_rebase_result_is_failed() {
2280 assert!(RebaseResult::Failed(RebaseErrorKind::Unknown {
2282 details: "test".to_string(),
2283 })
2284 .is_failed());
2285 assert!(!RebaseResult::Success.is_failed());
2286 assert!(!RebaseResult::NoOp {
2287 reason: "test".to_string()
2288 }
2289 .is_failed());
2290 assert!(!RebaseResult::Conflicts(vec![]).is_failed());
2291 }
2292
2293 #[test]
2294 fn test_rebase_error_kind_description() {
2295 let err = RebaseErrorKind::InvalidRevision {
2297 revision: "main".to_string(),
2298 };
2299 assert!(err.description().contains("main"));
2300
2301 let err = RebaseErrorKind::DirtyWorkingTree;
2302 assert!(err.description().contains("Working tree"));
2303 }
2304
2305 #[test]
2306 fn test_rebase_error_kind_category() {
2307 assert_eq!(
2309 RebaseErrorKind::InvalidRevision {
2310 revision: "test".to_string()
2311 }
2312 .category(),
2313 1
2314 );
2315 assert_eq!(
2316 RebaseErrorKind::ContentConflict { files: vec![] }.category(),
2317 2
2318 );
2319 assert_eq!(
2320 RebaseErrorKind::ValidationFailed {
2321 reason: "test".to_string()
2322 }
2323 .category(),
2324 3
2325 );
2326 assert_eq!(
2327 RebaseErrorKind::ProcessTerminated {
2328 reason: "test".to_string()
2329 }
2330 .category(),
2331 4
2332 );
2333 assert_eq!(
2334 RebaseErrorKind::Unknown {
2335 details: "test".to_string()
2336 }
2337 .category(),
2338 5
2339 );
2340 }
2341
2342 #[test]
2343 fn test_rebase_error_kind_is_recoverable() {
2344 assert!(RebaseErrorKind::ConcurrentOperation {
2346 operation: "rebase".to_string()
2347 }
2348 .is_recoverable());
2349 assert!(RebaseErrorKind::ContentConflict { files: vec![] }.is_recoverable());
2350 assert!(!RebaseErrorKind::InvalidRevision {
2351 revision: "test".to_string()
2352 }
2353 .is_recoverable());
2354 assert!(!RebaseErrorKind::DirtyWorkingTree.is_recoverable());
2355 }
2356
2357 #[test]
2358 fn test_classify_rebase_error_invalid_revision() {
2359 let stderr = "error: invalid revision 'nonexistent'";
2361 let error = classify_rebase_error(stderr, "");
2362 assert!(matches!(error, RebaseErrorKind::InvalidRevision { .. }));
2363 }
2364
2365 #[test]
2366 fn test_classify_rebase_error_conflict() {
2367 let stderr = "CONFLICT (content): Merge conflict in src/main.rs";
2369 let error = classify_rebase_error(stderr, "");
2370 assert!(matches!(error, RebaseErrorKind::ContentConflict { .. }));
2371 }
2372
2373 #[test]
2374 fn test_classify_rebase_error_dirty_tree() {
2375 let stderr = "Cannot rebase: Your index contains uncommitted changes";
2377 let error = classify_rebase_error(stderr, "");
2378 assert!(matches!(error, RebaseErrorKind::DirtyWorkingTree));
2379 }
2380
2381 #[test]
2382 fn test_classify_rebase_error_concurrent_operation() {
2383 let stderr = "Cannot rebase: There is a rebase in progress already";
2385 let error = classify_rebase_error(stderr, "");
2386 assert!(matches!(error, RebaseErrorKind::ConcurrentOperation { .. }));
2387 }
2388
2389 #[test]
2390 fn test_classify_rebase_error_unknown() {
2391 let stderr = "Some completely unexpected error message";
2393 let error = classify_rebase_error(stderr, "");
2394 assert!(matches!(error, RebaseErrorKind::Unknown { .. }));
2395 }
2396
2397 #[test]
2398 fn test_rebase_onto_returns_result() {
2399 use test_helpers::{commit_all, init_git_repo, with_temp_cwd, write_file};
2400
2401 with_temp_cwd(|dir| {
2403 let repo = init_git_repo(dir);
2405 write_file(dir.path().join("initial.txt"), "initial content");
2406 let _ = commit_all(&repo, "initial commit");
2407
2408 let executor =
2411 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2412 let result = rebase_onto("nonexistent_branch_that_does_not_exist", executor.as_ref());
2413 assert!(result.is_ok());
2415 });
2416 }
2417
2418 #[test]
2419 fn test_get_conflicted_files_returns_result() {
2420 use test_helpers::{init_git_repo, with_temp_cwd};
2421
2422 with_temp_cwd(|dir| {
2424 let _repo = init_git_repo(dir);
2426
2427 let result = get_conflicted_files();
2428 assert!(result.is_ok());
2430 });
2431 }
2432
2433 #[test]
2434 fn test_rebase_in_progress_cli_returns_result() {
2435 use test_helpers::{init_git_repo, with_temp_cwd};
2436
2437 with_temp_cwd(|dir| {
2439 let _repo = init_git_repo(dir);
2441
2442 let executor =
2444 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2445 let result = rebase_in_progress_cli(executor.as_ref());
2446 assert!(result.is_ok());
2448 });
2449 }
2450
2451 #[test]
2452 fn test_is_dirty_tree_cli_returns_result() {
2453 use test_helpers::{init_git_repo, with_temp_cwd};
2454
2455 with_temp_cwd(|dir| {
2457 let _repo = init_git_repo(dir);
2459
2460 let executor =
2462 Arc::new(MockProcessExecutor::new()) as Arc<dyn crate::executor::ProcessExecutor>;
2463 let result = is_dirty_tree_cli(executor.as_ref());
2464 assert!(result.is_ok());
2466 });
2467 }
2468
2469 #[test]
2470 fn test_cleanup_stale_rebase_state_returns_result() {
2471 use test_helpers::{init_git_repo, with_temp_cwd};
2472
2473 with_temp_cwd(|dir| {
2474 let _repo = init_git_repo(dir);
2476
2477 let result = cleanup_stale_rebase_state();
2479 assert!(result.is_ok());
2481 });
2482 }
2483}