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)]
637pub enum ConcurrentOperation {
638 Rebase,
640 Merge,
642 CherryPick,
644 Revert,
646 Bisect,
648 OtherGitProcess,
650 Unknown(String),
652}
653
654impl ConcurrentOperation {
655 pub fn description(&self) -> String {
657 match self {
658 ConcurrentOperation::Rebase => "rebase".to_string(),
659 ConcurrentOperation::Merge => "merge".to_string(),
660 ConcurrentOperation::CherryPick => "cherry-pick".to_string(),
661 ConcurrentOperation::Revert => "revert".to_string(),
662 ConcurrentOperation::Bisect => "bisect".to_string(),
663 ConcurrentOperation::OtherGitProcess => "another Git process".to_string(),
664 ConcurrentOperation::Unknown(s) => format!("unknown operation: {s}"),
665 }
666 }
667}
668
669pub fn detect_concurrent_git_operations() -> io::Result<Option<ConcurrentOperation>> {
698 use std::fs;
699
700 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
701 let git_dir = repo.path();
702
703 let rebase_merge = git_dir.join("rebase-merge");
705 let rebase_apply = git_dir.join("rebase-apply");
706 if rebase_merge.exists() || rebase_apply.exists() {
707 return Ok(Some(ConcurrentOperation::Rebase));
708 }
709
710 let merge_head = git_dir.join("MERGE_HEAD");
712 if merge_head.exists() {
713 return Ok(Some(ConcurrentOperation::Merge));
714 }
715
716 let cherry_pick_head = git_dir.join("CHERRY_PICK_HEAD");
718 if cherry_pick_head.exists() {
719 return Ok(Some(ConcurrentOperation::CherryPick));
720 }
721
722 let revert_head = git_dir.join("REVERT_HEAD");
724 if revert_head.exists() {
725 return Ok(Some(ConcurrentOperation::Revert));
726 }
727
728 let bisect_log = git_dir.join("BISECT_LOG");
730 let bisect_start = git_dir.join("BISECT_START");
731 let bisect_names = git_dir.join("BISECT_NAMES");
732 if bisect_log.exists() || bisect_start.exists() || bisect_names.exists() {
733 return Ok(Some(ConcurrentOperation::Bisect));
734 }
735
736 let index_lock = git_dir.join("index.lock");
738 let packed_refs_lock = git_dir.join("packed-refs.lock");
739 let head_lock = git_dir.join("HEAD.lock");
740 if index_lock.exists() || packed_refs_lock.exists() || head_lock.exists() {
741 return Ok(Some(ConcurrentOperation::OtherGitProcess));
744 }
745
746 if let Ok(entries) = fs::read_dir(git_dir) {
748 for entry in entries.flatten() {
749 let name = entry.file_name();
750 let name_str = name.to_string_lossy();
751 if name_str.contains("REBASE")
753 || name_str.contains("MERGE")
754 || name_str.contains("CHERRY")
755 {
756 return Ok(Some(ConcurrentOperation::Unknown(name_str.to_string())));
757 }
758 }
759 }
760
761 Ok(None)
762}
763
764#[cfg(any(test, feature = "test-utils"))]
773pub fn rebase_in_progress_cli() -> io::Result<bool> {
774 use std::process::Command;
775
776 let output = Command::new("git").args(["status", "--porcelain"]).output();
777
778 match output {
779 Ok(result) => {
780 let stdout = String::from_utf8_lossy(&result.stdout);
781 Ok(stdout.contains("rebasing"))
783 }
784 Err(e) => Err(io::Error::other(format!(
785 "Failed to check rebase status: {e}"
786 ))),
787 }
788}
789
790#[derive(Debug, Clone, Default)]
794pub struct CleanupResult {
795 pub cleaned_paths: Vec<String>,
797 pub locks_removed: bool,
799}
800
801impl CleanupResult {
802 #[cfg(any(test, feature = "test-utils"))]
804 pub fn has_cleanup(&self) -> bool {
805 !self.cleaned_paths.is_empty() || self.locks_removed
806 }
807
808 #[cfg(any(test, feature = "test-utils"))]
810 pub fn count(&self) -> usize {
811 self.cleaned_paths.len() + if self.locks_removed { 1 } else { 0 }
812 }
813}
814
815pub fn cleanup_stale_rebase_state() -> io::Result<CleanupResult> {
829 use std::fs;
830
831 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
832 let git_dir = repo.path();
833
834 let mut result = CleanupResult::default();
835
836 let stale_paths = [
838 ("rebase-apply", "rebase-apply directory"),
839 ("rebase-merge", "rebase-merge directory"),
840 ("MERGE_HEAD", "merge state"),
841 ("MERGE_MSG", "merge message"),
842 ("CHERRY_PICK_HEAD", "cherry-pick state"),
843 ("REVERT_HEAD", "revert state"),
844 ("COMMIT_EDITMSG", "commit message"),
845 ];
846
847 for (path_name, description) in &stale_paths {
848 let full_path = git_dir.join(path_name);
849 if full_path.exists() {
850 let is_valid = validate_state_file(&full_path);
852 if !is_valid.unwrap_or(true) {
853 let removed = if full_path.is_dir() {
855 fs::remove_dir_all(&full_path)
856 .map(|_| true)
857 .unwrap_or(false)
858 } else {
859 fs::remove_file(&full_path).map(|_| true).unwrap_or(false)
860 };
861
862 if removed {
863 result
864 .cleaned_paths
865 .push(format!("{path_name} ({description})"));
866 }
867 }
868 }
869 }
870
871 let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
873 for lock_file in &lock_files {
874 let lock_path = git_dir.join(lock_file);
875 if lock_path.exists() {
876 if fs::remove_file(&lock_path).is_ok() {
878 result.locks_removed = true;
879 result
880 .cleaned_paths
881 .push(format!("{lock_file} (lock file)"));
882 }
883 }
884 }
885
886 Ok(result)
887}
888
889fn validate_state_file(path: &Path) -> io::Result<bool> {
903 use std::fs;
904
905 if !path.exists() {
906 return Ok(false);
907 }
908
909 if path.is_dir() {
911 let entries = fs::read_dir(path)?;
913 let has_content = entries.count() > 0;
914 return Ok(has_content);
915 }
916
917 if path.is_file() {
919 let metadata = fs::metadata(path)?;
920 if metadata.len() == 0 {
921 return Ok(false);
923 }
924 let _ = fs::read(path)?;
926 return Ok(true);
927 }
928
929 Ok(false)
930}
931
932#[cfg(any(test, feature = "test-utils"))]
966pub fn attempt_automatic_recovery(
967 error_kind: &RebaseErrorKind,
968 phase: &crate::git_helpers::rebase_checkpoint::RebasePhase,
969 phase_error_count: u32,
970) -> io::Result<bool> {
971 use std::process::Command;
972
973 match error_kind {
975 RebaseErrorKind::InvalidRevision { .. }
976 | RebaseErrorKind::DirtyWorkingTree
977 | RebaseErrorKind::RepositoryCorrupt { .. }
978 | RebaseErrorKind::EnvironmentFailure { .. }
979 | RebaseErrorKind::HookRejection { .. }
980 | RebaseErrorKind::InteractiveStop { .. }
981 | RebaseErrorKind::Unknown { .. } => {
982 return Ok(false);
983 }
984 _ => {}
985 }
986
987 let max_attempts = phase.max_recovery_attempts();
988 if phase_error_count >= max_attempts {
989 return Ok(false);
990 }
991
992 if cleanup_stale_rebase_state().is_ok() {
994 if validate_git_state().is_ok() {
996 return Ok(true);
997 }
998 }
999
1000 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1002 let git_dir = repo.path();
1003 let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
1004 let mut removed_any = false;
1005
1006 for lock_file in &lock_files {
1007 let lock_path = git_dir.join(lock_file);
1008 if lock_path.exists() && std::fs::remove_file(&lock_path).is_ok() {
1009 removed_any = true;
1010 }
1011 }
1012
1013 if removed_any && validate_git_state().is_ok() {
1014 return Ok(true);
1015 }
1016
1017 if let RebaseErrorKind::ConcurrentOperation { .. } = error_kind {
1019 let abort_result = Command::new("git").args(["rebase", "--abort"]).output();
1021
1022 if abort_result.is_ok() {
1023 if validate_git_state().is_ok() {
1025 return Ok(true);
1026 }
1027 }
1028 }
1029
1030 Ok(false)
1032}
1033
1034pub fn validate_git_state() -> io::Result<()> {
1044 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1045
1046 let _ = repo.head().map_err(|e| {
1048 io::Error::new(
1049 io::ErrorKind::InvalidData,
1050 format!("Repository HEAD is invalid: {e}"),
1051 )
1052 })?;
1053
1054 let _ = repo.index().map_err(|e| {
1056 io::Error::new(
1057 io::ErrorKind::InvalidData,
1058 format!("Repository index is corrupted: {e}"),
1059 )
1060 })?;
1061
1062 if let Ok(head) = repo.head() {
1064 if let Ok(commit) = head.peel_to_commit() {
1065 let _ = commit.tree().map_err(|e| {
1067 io::Error::new(
1068 io::ErrorKind::InvalidData,
1069 format!("Object database corruption: {e}"),
1070 )
1071 })?;
1072 }
1073 }
1074
1075 Ok(())
1076}
1077
1078#[cfg(any(test, feature = "test-utils"))]
1092pub fn restore_from_reflog(ref_name: &str, steps_back: usize) -> io::Result<()> {
1093 use std::process::Command;
1094
1095 let refspec = format!("{ref_name}@{{{steps_back}}}");
1097 let output = Command::new("git")
1098 .args(["reset", "--hard", &refspec])
1099 .output();
1100
1101 match output {
1102 Ok(result) if result.status.success() => Ok(()),
1103 Ok(result) => {
1104 let stderr = String::from_utf8_lossy(&result.stderr);
1105 Err(io::Error::other(format!(
1106 "Failed to restore from reflog: {stderr}",
1107 )))
1108 }
1109 Err(e) => Err(io::Error::other(format!(
1110 "Failed to execute git reset: {e}"
1111 ))),
1112 }
1113}
1114
1115#[cfg(any(test, feature = "test-utils"))]
1124pub fn is_dirty_tree_cli() -> io::Result<bool> {
1125 use std::process::Command;
1126
1127 let output = Command::new("git").args(["status", "--porcelain"]).output();
1128
1129 match output {
1130 Ok(result) => {
1131 let stdout = String::from_utf8_lossy(&result.stdout);
1132 Ok(!stdout.trim().is_empty())
1133 }
1134 Err(e) => Err(io::Error::other(format!(
1135 "Failed to check working tree status: {e}"
1136 ))),
1137 }
1138}
1139
1140pub fn validate_rebase_preconditions() -> io::Result<()> {
1173 use std::process::Command;
1174
1175 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1176
1177 validate_git_state()?;
1179
1180 if let Some(concurrent_op) = detect_concurrent_git_operations()? {
1182 return Err(io::Error::new(
1183 io::ErrorKind::InvalidInput,
1184 format!(
1185 "Cannot start rebase: {} already in progress. \
1186 Please complete or abort the current operation first.",
1187 concurrent_op.description()
1188 ),
1189 ));
1190 }
1191
1192 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1194
1195 let user_name = config.get_string("user.name");
1196 let user_email = config.get_string("user.email");
1197
1198 if user_name.is_err() && user_email.is_err() {
1199 return Err(io::Error::new(
1200 io::ErrorKind::InvalidInput,
1201 "Git identity is not configured. Please set user.name and user.email:\n \
1202 git config --global user.name \"Your Name\"\n \
1203 git config --global user.email \"you@example.com\"",
1204 ));
1205 }
1206
1207 let status_output = Command::new("git").args(["status", "--porcelain"]).output();
1209
1210 match status_output {
1211 Ok(result) => {
1212 let stdout = String::from_utf8_lossy(&result.stdout);
1213 if !stdout.trim().is_empty() {
1214 return Err(io::Error::new(
1215 io::ErrorKind::InvalidInput,
1216 "Working tree is not clean. Please commit or stash changes before rebasing.",
1217 ));
1218 }
1219 }
1220 Err(_e) => {
1221 let statuses = repo.statuses(None).map_err(|e| {
1223 io::Error::new(
1224 io::ErrorKind::InvalidData,
1225 format!("Failed to check working tree status: {e}"),
1226 )
1227 })?;
1228
1229 if !statuses.is_empty() {
1230 return Err(io::Error::new(
1231 io::ErrorKind::InvalidInput,
1232 "Working tree is not clean. Please commit or stash changes before rebasing.",
1233 ));
1234 }
1235 }
1236 }
1237
1238 check_shallow_clone()?;
1240
1241 check_worktree_conflicts()?;
1243
1244 check_submodule_state()?;
1246
1247 check_sparse_checkout_state()?;
1249
1250 Ok(())
1251}
1252
1253fn check_shallow_clone() -> io::Result<()> {
1263 use std::fs;
1264
1265 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1266 let git_dir = repo.path();
1267
1268 let shallow_file = git_dir.join("shallow");
1270 if shallow_file.exists() {
1271 let content = fs::read_to_string(&shallow_file).unwrap_or_default();
1273 let line_count = content.lines().count();
1274
1275 return Err(io::Error::new(
1276 io::ErrorKind::InvalidInput,
1277 format!(
1278 "Repository is a shallow clone with {} commits. \
1279 Rebasing may fail due to missing history. \
1280 Consider running: git fetch --unshallow",
1281 line_count
1282 ),
1283 ));
1284 }
1285
1286 Ok(())
1287}
1288
1289fn check_worktree_conflicts() -> io::Result<()> {
1299 use std::fs;
1300
1301 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1302
1303 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1305 let branch_name = match head.shorthand() {
1306 Some(name) if head.is_branch() => name,
1307 _ => return Ok(()), };
1309
1310 let git_dir = repo.path();
1311 let worktrees_dir = git_dir.join("worktrees");
1312
1313 if !worktrees_dir.exists() {
1314 return Ok(());
1315 }
1316
1317 let entries = fs::read_dir(&worktrees_dir).map_err(|e| {
1319 io::Error::new(
1320 io::ErrorKind::InvalidData,
1321 format!("Failed to read worktrees directory: {e}"),
1322 )
1323 })?;
1324
1325 for entry in entries.flatten() {
1326 let worktree_path = entry.path();
1327 let worktree_head = worktree_path.join("HEAD");
1328
1329 if worktree_head.exists() {
1330 if let Ok(content) = fs::read_to_string(&worktree_head) {
1331 if content.contains(&format!("refs/heads/{branch_name}")) {
1333 let worktree_name = worktree_path
1335 .file_name()
1336 .and_then(|n| n.to_str())
1337 .unwrap_or("unknown");
1338
1339 return Err(io::Error::new(
1340 io::ErrorKind::InvalidInput,
1341 format!(
1342 "Branch '{branch_name}' is already checked out in worktree '{worktree_name}'. \
1343 Use 'git worktree add' to create a new worktree for this branch."
1344 ),
1345 ));
1346 }
1347 }
1348 }
1349 }
1350
1351 Ok(())
1352}
1353
1354fn check_submodule_state() -> io::Result<()> {
1364 use std::fs;
1365
1366 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1367 let git_dir = repo.path();
1368
1369 let workdir = repo.workdir().unwrap_or(git_dir);
1371 let gitmodules_path = workdir.join(".gitmodules");
1372
1373 if !gitmodules_path.exists() {
1374 return Ok(()); }
1376
1377 let modules_dir = git_dir.join("modules");
1379 if !modules_dir.exists() {
1380 return Err(io::Error::new(
1382 io::ErrorKind::InvalidInput,
1383 "Submodules are not initialized. Run: git submodule update --init --recursive",
1384 ));
1385 }
1386
1387 let gitmodules_content = fs::read_to_string(&gitmodules_path).unwrap_or_default();
1389 let submodule_count = gitmodules_content.matches("path = ").count();
1390
1391 if submodule_count > 0 {
1392 for line in gitmodules_content.lines() {
1394 if line.contains("path = ") {
1395 if let Some(path) = line.split("path = ").nth(1) {
1396 let submodule_path = workdir.join(path.trim());
1397 if !submodule_path.exists() {
1398 return Err(io::Error::new(
1399 io::ErrorKind::InvalidInput,
1400 format!(
1401 "Submodule '{}' is not initialized. Run: git submodule update --init --recursive",
1402 path.trim()
1403 ),
1404 ));
1405 }
1406 }
1407 }
1408 }
1409 }
1410
1411 Ok(())
1412}
1413
1414fn check_sparse_checkout_state() -> io::Result<()> {
1424 use std::fs;
1425
1426 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1427 let git_dir = repo.path();
1428
1429 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1431
1432 let sparse_checkout = config.get_bool("core.sparseCheckout");
1433 let sparse_checkout_cone = config.get_bool("extensions.sparseCheckoutCone");
1434
1435 match (sparse_checkout, sparse_checkout_cone) {
1436 (Ok(true), _) | (_, Ok(true)) => {
1437 let info_sparse_dir = git_dir.join("info").join("sparse-checkout");
1439
1440 if !info_sparse_dir.exists() {
1441 return Err(io::Error::new(
1443 io::ErrorKind::InvalidInput,
1444 "Sparse checkout is enabled but not configured. \
1445 Run: git sparse-checkout init",
1446 ));
1447 }
1448
1449 if let Ok(content) = fs::read_to_string(&info_sparse_dir) {
1451 if content.trim().is_empty() {
1452 return Err(io::Error::new(
1453 io::ErrorKind::InvalidInput,
1454 "Sparse checkout configuration is empty. \
1455 Run: git sparse-checkout set <patterns>",
1456 ));
1457 }
1458 }
1459
1460 }
1465 (Err(_), _) | (_, Err(_)) => {
1466 }
1468 _ => {}
1469 }
1470
1471 Ok(())
1472}
1473
1474pub fn rebase_onto(upstream_branch: &str) -> io::Result<RebaseResult> {
1506 use std::process::Command;
1507
1508 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1510
1511 match repo.head() {
1512 Ok(_) => {}
1513 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => {
1514 return Ok(RebaseResult::NoOp {
1516 reason: "Repository has no commits yet (unborn branch)".to_string(),
1517 });
1518 }
1519 Err(e) => return Err(git2_to_io_error(&e)),
1520 }
1521
1522 let upstream_object = match repo.revparse_single(upstream_branch) {
1524 Ok(obj) => obj,
1525 Err(_) => {
1526 return Ok(RebaseResult::Failed(RebaseErrorKind::InvalidRevision {
1527 revision: upstream_branch.to_string(),
1528 }))
1529 }
1530 };
1531
1532 let upstream_commit = upstream_object
1533 .peel_to_commit()
1534 .map_err(|e| git2_to_io_error(&e))?;
1535
1536 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1538 let head_commit = head.peel_to_commit().map_err(|e| git2_to_io_error(&e))?;
1539
1540 if repo
1542 .graph_descendant_of(head_commit.id(), upstream_commit.id())
1543 .map_err(|e| git2_to_io_error(&e))?
1544 {
1545 return Ok(RebaseResult::NoOp {
1547 reason: "Branch is already up-to-date with upstream".to_string(),
1548 });
1549 }
1550
1551 match repo.merge_base(head_commit.id(), upstream_commit.id()) {
1554 Err(e)
1555 if e.class() == git2::ErrorClass::Reference
1556 && e.code() == git2::ErrorCode::NotFound =>
1557 {
1558 return Ok(RebaseResult::NoOp {
1560 reason: format!(
1561 "No common ancestor between current branch and '{upstream_branch}' (unrelated branches)"
1562 ),
1563 });
1564 }
1565 Err(e) => return Err(git2_to_io_error(&e)),
1566 Ok(_) => {}
1567 }
1568
1569 let branch_name = match head.shorthand() {
1571 Some(name) => name,
1572 None => {
1573 return Ok(RebaseResult::NoOp {
1575 reason: "HEAD is detached (not on any branch), rebase not applicable".to_string(),
1576 });
1577 }
1578 };
1579
1580 if branch_name == "main" || branch_name == "master" {
1581 return Ok(RebaseResult::NoOp {
1582 reason: format!("Already on '{branch_name}' branch, rebase not applicable"),
1583 });
1584 }
1585
1586 let output = Command::new("git")
1588 .args(["rebase", upstream_branch])
1589 .output();
1590
1591 match output {
1592 Ok(result) => {
1593 if result.status.success() {
1594 Ok(RebaseResult::Success)
1595 } else {
1596 let stderr = String::from_utf8_lossy(&result.stderr);
1597 let stdout = String::from_utf8_lossy(&result.stdout);
1598
1599 let error_kind = classify_rebase_error(&stderr, &stdout);
1601
1602 match error_kind {
1603 RebaseErrorKind::ContentConflict { .. } => {
1604 match get_conflicted_files() {
1606 Ok(files) if files.is_empty() => {
1607 if let RebaseErrorKind::ContentConflict { files } = error_kind {
1610 Ok(RebaseResult::Conflicts(files))
1611 } else {
1612 Ok(RebaseResult::Conflicts(vec![]))
1613 }
1614 }
1615 Ok(files) => Ok(RebaseResult::Conflicts(files)),
1616 Err(_) => Ok(RebaseResult::Conflicts(vec![])),
1617 }
1618 }
1619 RebaseErrorKind::Unknown { .. } => {
1620 if stderr.contains("up to date") {
1622 Ok(RebaseResult::NoOp {
1623 reason: "Branch is already up-to-date with upstream".to_string(),
1624 })
1625 } else {
1626 Ok(RebaseResult::Failed(error_kind))
1627 }
1628 }
1629 _ => Ok(RebaseResult::Failed(error_kind)),
1630 }
1631 }
1632 }
1633 Err(e) => Err(io::Error::other(format!(
1634 "Failed to execute git rebase: {e}"
1635 ))),
1636 }
1637}
1638
1639pub fn abort_rebase() -> io::Result<()> {
1650 use std::process::Command;
1651
1652 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1653
1654 let state = repo.state();
1656 if state != git2::RepositoryState::Rebase
1657 && state != git2::RepositoryState::RebaseMerge
1658 && state != git2::RepositoryState::RebaseInteractive
1659 {
1660 return Err(io::Error::new(
1661 io::ErrorKind::InvalidInput,
1662 "No rebase in progress",
1663 ));
1664 }
1665
1666 let output = Command::new("git").args(["rebase", "--abort"]).output();
1668
1669 match output {
1670 Ok(result) => {
1671 if result.status.success() {
1672 Ok(())
1673 } else {
1674 let stderr = String::from_utf8_lossy(&result.stderr);
1675 Err(io::Error::other(format!(
1676 "Failed to abort rebase: {stderr}"
1677 )))
1678 }
1679 }
1680 Err(e) => Err(io::Error::other(format!(
1681 "Failed to execute git rebase --abort: {e}"
1682 ))),
1683 }
1684}
1685
1686pub fn get_conflicted_files() -> io::Result<Vec<String>> {
1696 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1697 let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1698
1699 let mut conflicted_files = Vec::new();
1700
1701 if !index.has_conflicts() {
1703 return Ok(conflicted_files);
1704 }
1705
1706 let conflicts = index.conflicts().map_err(|e| git2_to_io_error(&e))?;
1708
1709 for conflict in conflicts {
1710 let conflict = conflict.map_err(|e| git2_to_io_error(&e))?;
1711 if let Some(our_entry) = conflict.our {
1713 if let Ok(path) = std::str::from_utf8(&our_entry.path) {
1714 let path_str = path.to_string();
1715 if !conflicted_files.contains(&path_str) {
1716 conflicted_files.push(path_str);
1717 }
1718 }
1719 }
1720 }
1721
1722 Ok(conflicted_files)
1723}
1724
1725pub fn get_conflict_markers_for_file(path: &Path) -> io::Result<String> {
1739 use std::fs;
1740 use std::io::Read;
1741
1742 let mut file = fs::File::open(path)?;
1743 let mut content = String::new();
1744 file.read_to_string(&mut content)?;
1745
1746 let mut conflict_sections = Vec::new();
1748 let lines: Vec<&str> = content.lines().collect();
1749 let mut i = 0;
1750
1751 while i < lines.len() {
1752 if lines[i].trim_start().starts_with("<<<<<<<") {
1753 let mut section = Vec::new();
1755 section.push(lines[i]);
1756
1757 i += 1;
1758 while i < lines.len() && !lines[i].trim_start().starts_with("=======") {
1760 section.push(lines[i]);
1761 i += 1;
1762 }
1763
1764 if i < lines.len() {
1765 section.push(lines[i]); i += 1;
1767 }
1768
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 conflict_sections.push(section.join("\n"));
1781 } else {
1782 i += 1;
1783 }
1784 }
1785
1786 if conflict_sections.is_empty() {
1787 Ok(String::new())
1789 } else {
1790 Ok(conflict_sections.join("\n\n"))
1791 }
1792}
1793
1794#[cfg(any(test, feature = "test-utils"))]
1814pub fn verify_rebase_completed(upstream_branch: &str) -> io::Result<bool> {
1815 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1816
1817 let state = repo.state();
1819 if state == git2::RepositoryState::Rebase
1820 || state == git2::RepositoryState::RebaseMerge
1821 || state == git2::RepositoryState::RebaseInteractive
1822 {
1823 return Ok(false);
1825 }
1826
1827 let index = repo.index().map_err(|e| git2_to_io_error(&e))?;
1829 if index.has_conflicts() {
1830 return Ok(false);
1832 }
1833
1834 let head = repo.head().map_err(|e| {
1836 io::Error::new(
1837 io::ErrorKind::InvalidData,
1838 format!("Repository HEAD is invalid: {e}"),
1839 )
1840 })?;
1841
1842 if let Ok(head_commit) = head.peel_to_commit() {
1845 if let Ok(upstream_object) = repo.revparse_single(upstream_branch) {
1846 if let Ok(upstream_commit) = upstream_object.peel_to_commit() {
1847 match repo.graph_descendant_of(head_commit.id(), upstream_commit.id()) {
1850 Ok(is_descendant) => {
1851 if is_descendant {
1852 return Ok(true);
1854 } else {
1855 return Ok(false);
1858 }
1859 }
1860 Err(e) => {
1861 let _ = e; }
1864 }
1865 }
1866 }
1867 }
1868
1869 Ok(!index.has_conflicts())
1872}
1873
1874pub fn validate_post_rebase_state() -> io::Result<()> {
1891 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1892
1893 let head = repo.head().map_err(|e| {
1895 io::Error::new(
1896 io::ErrorKind::InvalidData,
1897 format!("Repository HEAD is invalid after rebase: {e}"),
1898 )
1899 })?;
1900
1901 let is_detached = head.shorthand().is_none();
1904 if is_detached {
1905 return Err(io::Error::new(
1906 io::ErrorKind::InvalidData,
1907 "HEAD is detached after rebase - this may indicate a problem",
1908 ));
1909 }
1910
1911 let _index = repo.index().map_err(|e| {
1913 io::Error::new(
1914 io::ErrorKind::InvalidData,
1915 format!("Repository index is corrupted after rebase: {e}"),
1916 )
1917 })?;
1918
1919 let head_commit = head.peel_to_commit().map_err(|e| {
1921 io::Error::new(
1922 io::ErrorKind::InvalidData,
1923 format!("Cannot access HEAD commit after rebase: {e}"),
1924 )
1925 })?;
1926
1927 let _tree = head_commit.tree().map_err(|e| {
1929 io::Error::new(
1930 io::ErrorKind::InvalidData,
1931 format!("Object database corruption after rebase: {e}"),
1932 )
1933 })?;
1934
1935 Ok(())
1936}
1937
1938#[cfg(any(test, feature = "test-utils"))]
1943#[derive(Debug, Clone, Default)]
1944pub struct PostRebaseValidationResult {
1945 pub git_state_valid: bool,
1947 pub build_valid: Option<bool>,
1949 pub tests_valid: Option<bool>,
1951 pub lint_valid: Option<bool>,
1953 pub messages: Vec<String>,
1955}
1956
1957#[cfg(any(test, feature = "test-utils"))]
1958impl PostRebaseValidationResult {
1959 pub fn is_valid(&self) -> bool {
1961 self.git_state_valid
1962 && self.build_valid.is_none_or(|v| v)
1963 && self.tests_valid.is_none_or(|v| v)
1964 && self.lint_valid.is_none_or(|v| v)
1965 }
1966
1967 pub fn summary(&self) -> String {
1969 if self.is_valid() {
1970 "All validations passed".to_string()
1971 } else {
1972 let mut failures = Vec::new();
1973 if !self.git_state_valid {
1974 failures.push("Git state validation failed".to_string());
1975 }
1976 if self.build_valid == Some(false) {
1977 failures.push("Build validation failed".to_string());
1978 }
1979 if self.tests_valid == Some(false) {
1980 failures.push("Test validation failed".to_string());
1981 }
1982 if self.lint_valid == Some(false) {
1983 failures.push("Lint validation failed".to_string());
1984 }
1985 failures.join("; ")
1986 }
1987 }
1988}
1989
1990#[cfg(any(test, feature = "test-utils"))]
2015pub fn validate_post_rebase_with_checks(
2016 run_build_checks: bool,
2017 run_test_checks: bool,
2018 run_lint_checks: bool,
2019) -> io::Result<PostRebaseValidationResult> {
2020 use std::path::Path;
2021 use std::process::Command;
2022
2023 let git_state_valid = validate_post_rebase_state().is_ok();
2024 let mut result = PostRebaseValidationResult {
2025 git_state_valid,
2026 ..Default::default()
2027 };
2028
2029 if !result.git_state_valid {
2030 result
2031 .messages
2032 .push("Git state validation failed".to_string());
2033 }
2034
2035 let is_rust_project = Path::new("Cargo.toml").exists();
2037
2038 if !is_rust_project {
2039 result
2040 .messages
2041 .push("Not a Rust project - skipping project-specific checks".to_string());
2042 return Ok(result);
2043 }
2044
2045 if run_build_checks {
2047 result
2048 .messages
2049 .push("Running build validation...".to_string());
2050 let build_output = Command::new("cargo").args(["build", "--release"]).output();
2051
2052 result.build_valid = Some(match build_output {
2053 Ok(output) => output.status.success(),
2054 Err(e) => {
2055 result.messages.push(format!("Failed to run build: {e}"));
2056 false
2057 }
2058 });
2059
2060 if result.build_valid == Some(false) {
2061 result
2062 .messages
2063 .push("Build validation failed - project may not compile".to_string());
2064 }
2065 }
2066
2067 if run_test_checks {
2069 result
2070 .messages
2071 .push("Running test validation...".to_string());
2072 let test_output = Command::new("cargo")
2073 .args(["test", "--lib", "--all-features"])
2074 .output();
2075
2076 result.tests_valid = Some(match test_output {
2077 Ok(output) => output.status.success(),
2078 Err(e) => {
2079 result.messages.push(format!("Failed to run tests: {e}"));
2080 false
2081 }
2082 });
2083
2084 if result.tests_valid == Some(false) {
2085 result
2086 .messages
2087 .push("Test validation failed - some tests may be broken".to_string());
2088 }
2089 }
2090
2091 if run_lint_checks {
2093 result
2094 .messages
2095 .push("Running lint validation...".to_string());
2096 let lint_output = Command::new("cargo")
2097 .args(["clippy", "--lib", "--all-features", "-D", "warnings"])
2098 .output();
2099
2100 result.lint_valid = Some(match lint_output {
2101 Ok(output) => output.status.success(),
2102 Err(e) => {
2103 result.messages.push(format!("Failed to run clippy: {e}"));
2104 false
2105 }
2106 });
2107
2108 if result.lint_valid == Some(false) {
2109 result
2110 .messages
2111 .push("Lint validation failed - code may have lint issues".to_string());
2112 }
2113 }
2114
2115 Ok(result)
2116}
2117
2118pub fn continue_rebase() -> io::Result<()> {
2131 use std::process::Command;
2132
2133 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
2134
2135 let state = repo.state();
2137 if state != git2::RepositoryState::Rebase
2138 && state != git2::RepositoryState::RebaseMerge
2139 && state != git2::RepositoryState::RebaseInteractive
2140 {
2141 return Err(io::Error::new(
2142 io::ErrorKind::InvalidInput,
2143 "No rebase in progress",
2144 ));
2145 }
2146
2147 let conflicted = get_conflicted_files()?;
2149 if !conflicted.is_empty() {
2150 return Err(io::Error::new(
2151 io::ErrorKind::InvalidInput,
2152 format!(
2153 "Cannot continue rebase: {} file(s) still have conflicts",
2154 conflicted.len()
2155 ),
2156 ));
2157 }
2158
2159 let output = Command::new("git").args(["rebase", "--continue"]).output();
2161
2162 match output {
2163 Ok(result) => {
2164 if result.status.success() {
2165 Ok(())
2166 } else {
2167 let stderr = String::from_utf8_lossy(&result.stderr);
2168 Err(io::Error::other(format!(
2169 "Failed to continue rebase: {stderr}"
2170 )))
2171 }
2172 }
2173 Err(e) => Err(io::Error::other(format!(
2174 "Failed to execute git rebase --continue: {e}"
2175 ))),
2176 }
2177}
2178
2179#[cfg(test)]
2180mod tests {
2181 use super::*;
2182
2183 #[test]
2184 fn test_rebase_result_variants_exist() {
2185 let _ = RebaseResult::Success;
2187 let _ = RebaseResult::NoOp {
2188 reason: "test".to_string(),
2189 };
2190 let _ = RebaseResult::Conflicts(vec![]);
2191 let _ = RebaseResult::Failed(RebaseErrorKind::Unknown {
2192 details: "test".to_string(),
2193 });
2194 }
2195
2196 #[test]
2197 fn test_rebase_result_is_noop() {
2198 assert!(RebaseResult::NoOp {
2200 reason: "test".to_string()
2201 }
2202 .is_noop());
2203 assert!(!RebaseResult::Success.is_noop());
2204 assert!(!RebaseResult::Conflicts(vec![]).is_noop());
2205 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
2206 details: "test".to_string(),
2207 })
2208 .is_noop());
2209 }
2210
2211 #[test]
2212 fn test_rebase_result_is_success() {
2213 assert!(RebaseResult::Success.is_success());
2215 assert!(!RebaseResult::NoOp {
2216 reason: "test".to_string()
2217 }
2218 .is_success());
2219 assert!(!RebaseResult::Conflicts(vec![]).is_success());
2220 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
2221 details: "test".to_string(),
2222 })
2223 .is_success());
2224 }
2225
2226 #[test]
2227 fn test_rebase_result_has_conflicts() {
2228 assert!(RebaseResult::Conflicts(vec!["file.txt".to_string()]).has_conflicts());
2230 assert!(!RebaseResult::Success.has_conflicts());
2231 assert!(!RebaseResult::NoOp {
2232 reason: "test".to_string()
2233 }
2234 .has_conflicts());
2235 }
2236
2237 #[test]
2238 fn test_rebase_result_is_failed() {
2239 assert!(RebaseResult::Failed(RebaseErrorKind::Unknown {
2241 details: "test".to_string(),
2242 })
2243 .is_failed());
2244 assert!(!RebaseResult::Success.is_failed());
2245 assert!(!RebaseResult::NoOp {
2246 reason: "test".to_string()
2247 }
2248 .is_failed());
2249 assert!(!RebaseResult::Conflicts(vec![]).is_failed());
2250 }
2251
2252 #[test]
2253 fn test_rebase_error_kind_description() {
2254 let err = RebaseErrorKind::InvalidRevision {
2256 revision: "main".to_string(),
2257 };
2258 assert!(err.description().contains("main"));
2259
2260 let err = RebaseErrorKind::DirtyWorkingTree;
2261 assert!(err.description().contains("Working tree"));
2262 }
2263
2264 #[test]
2265 fn test_rebase_error_kind_category() {
2266 assert_eq!(
2268 RebaseErrorKind::InvalidRevision {
2269 revision: "test".to_string()
2270 }
2271 .category(),
2272 1
2273 );
2274 assert_eq!(
2275 RebaseErrorKind::ContentConflict { files: vec![] }.category(),
2276 2
2277 );
2278 assert_eq!(
2279 RebaseErrorKind::ValidationFailed {
2280 reason: "test".to_string()
2281 }
2282 .category(),
2283 3
2284 );
2285 assert_eq!(
2286 RebaseErrorKind::ProcessTerminated {
2287 reason: "test".to_string()
2288 }
2289 .category(),
2290 4
2291 );
2292 assert_eq!(
2293 RebaseErrorKind::Unknown {
2294 details: "test".to_string()
2295 }
2296 .category(),
2297 5
2298 );
2299 }
2300
2301 #[test]
2302 fn test_rebase_error_kind_is_recoverable() {
2303 assert!(RebaseErrorKind::ConcurrentOperation {
2305 operation: "rebase".to_string()
2306 }
2307 .is_recoverable());
2308 assert!(RebaseErrorKind::ContentConflict { files: vec![] }.is_recoverable());
2309 assert!(!RebaseErrorKind::InvalidRevision {
2310 revision: "test".to_string()
2311 }
2312 .is_recoverable());
2313 assert!(!RebaseErrorKind::DirtyWorkingTree.is_recoverable());
2314 }
2315
2316 #[test]
2317 fn test_classify_rebase_error_invalid_revision() {
2318 let stderr = "error: invalid revision 'nonexistent'";
2320 let error = classify_rebase_error(stderr, "");
2321 assert!(matches!(error, RebaseErrorKind::InvalidRevision { .. }));
2322 }
2323
2324 #[test]
2325 fn test_classify_rebase_error_conflict() {
2326 let stderr = "CONFLICT (content): Merge conflict in src/main.rs";
2328 let error = classify_rebase_error(stderr, "");
2329 assert!(matches!(error, RebaseErrorKind::ContentConflict { .. }));
2330 }
2331
2332 #[test]
2333 fn test_classify_rebase_error_dirty_tree() {
2334 let stderr = "Cannot rebase: Your index contains uncommitted changes";
2336 let error = classify_rebase_error(stderr, "");
2337 assert!(matches!(error, RebaseErrorKind::DirtyWorkingTree));
2338 }
2339
2340 #[test]
2341 fn test_classify_rebase_error_concurrent_operation() {
2342 let stderr = "Cannot rebase: There is a rebase in progress already";
2344 let error = classify_rebase_error(stderr, "");
2345 assert!(matches!(error, RebaseErrorKind::ConcurrentOperation { .. }));
2346 }
2347
2348 #[test]
2349 fn test_classify_rebase_error_unknown() {
2350 let stderr = "Some completely unexpected error message";
2352 let error = classify_rebase_error(stderr, "");
2353 assert!(matches!(error, RebaseErrorKind::Unknown { .. }));
2354 }
2355
2356 #[test]
2357 fn test_rebase_onto_returns_result() {
2358 use test_helpers::{commit_all, init_git_repo, with_temp_cwd, write_file};
2359
2360 with_temp_cwd(|dir| {
2362 let repo = init_git_repo(dir);
2364 write_file(dir.path().join("initial.txt"), "initial content");
2365 let _ = commit_all(&repo, "initial commit");
2366
2367 let result = rebase_onto("nonexistent_branch_that_does_not_exist");
2369 assert!(result.is_ok());
2371 });
2372 }
2373
2374 #[test]
2375 fn test_get_conflicted_files_returns_result() {
2376 use test_helpers::{init_git_repo, with_temp_cwd};
2377
2378 with_temp_cwd(|dir| {
2380 let _repo = init_git_repo(dir);
2382
2383 let result = get_conflicted_files();
2384 assert!(result.is_ok());
2386 });
2387 }
2388
2389 #[test]
2390 fn test_rebase_in_progress_cli_returns_result() {
2391 use test_helpers::{init_git_repo, with_temp_cwd};
2392
2393 with_temp_cwd(|dir| {
2395 let _repo = init_git_repo(dir);
2397
2398 let result = rebase_in_progress_cli();
2399 assert!(result.is_ok());
2401 });
2402 }
2403
2404 #[test]
2405 fn test_is_dirty_tree_cli_returns_result() {
2406 use test_helpers::{init_git_repo, with_temp_cwd};
2407
2408 with_temp_cwd(|dir| {
2410 let _repo = init_git_repo(dir);
2412
2413 let result = is_dirty_tree_cli();
2414 assert!(result.is_ok());
2416 });
2417 }
2418
2419 #[test]
2420 fn test_cleanup_stale_rebase_state_returns_result() {
2421 use test_helpers::{init_git_repo, with_temp_cwd};
2422
2423 with_temp_cwd(|dir| {
2424 let _repo = init_git_repo(dir);
2426
2427 let result = cleanup_stale_rebase_state();
2429 assert!(result.is_ok());
2431 });
2432 }
2433}