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() -> io::Result<bool> {
777 use std::process::Command;
778
779 let output = Command::new("git").args(["status", "--porcelain"]).output();
780
781 match output {
782 Ok(result) => {
783 let stdout = String::from_utf8_lossy(&result.stdout);
784 Ok(stdout.contains("rebasing"))
786 }
787 Err(e) => Err(io::Error::other(format!(
788 "Failed to check rebase status: {e}"
789 ))),
790 }
791}
792
793#[derive(Debug, Clone, Default)]
797#[cfg(any(test, feature = "test-utils"))]
798pub struct CleanupResult {
799 pub cleaned_paths: Vec<String>,
801 pub locks_removed: bool,
803}
804
805#[cfg(any(test, feature = "test-utils"))]
806impl CleanupResult {
807 pub fn has_cleanup(&self) -> bool {
809 !self.cleaned_paths.is_empty() || self.locks_removed
810 }
811
812 pub fn count(&self) -> usize {
814 self.cleaned_paths.len() + if self.locks_removed { 1 } else { 0 }
815 }
816}
817
818#[cfg(any(test, feature = "test-utils"))]
832pub fn cleanup_stale_rebase_state() -> io::Result<CleanupResult> {
833 use std::fs;
834
835 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
836 let git_dir = repo.path();
837
838 let mut result = CleanupResult::default();
839
840 let stale_paths = [
842 ("rebase-apply", "rebase-apply directory"),
843 ("rebase-merge", "rebase-merge directory"),
844 ("MERGE_HEAD", "merge state"),
845 ("MERGE_MSG", "merge message"),
846 ("CHERRY_PICK_HEAD", "cherry-pick state"),
847 ("REVERT_HEAD", "revert state"),
848 ("COMMIT_EDITMSG", "commit message"),
849 ];
850
851 for (path_name, description) in &stale_paths {
852 let full_path = git_dir.join(path_name);
853 if full_path.exists() {
854 let is_valid = validate_state_file(&full_path);
856 if !is_valid.unwrap_or(true) {
857 let removed = if full_path.is_dir() {
859 fs::remove_dir_all(&full_path)
860 .map(|_| true)
861 .unwrap_or(false)
862 } else {
863 fs::remove_file(&full_path).map(|_| true).unwrap_or(false)
864 };
865
866 if removed {
867 result
868 .cleaned_paths
869 .push(format!("{path_name} ({description})"));
870 }
871 }
872 }
873 }
874
875 let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
877 for lock_file in &lock_files {
878 let lock_path = git_dir.join(lock_file);
879 if lock_path.exists() {
880 if fs::remove_file(&lock_path).is_ok() {
882 result.locks_removed = true;
883 result
884 .cleaned_paths
885 .push(format!("{lock_file} (lock file)"));
886 }
887 }
888 }
889
890 Ok(result)
891}
892
893#[cfg(any(test, feature = "test-utils"))]
907fn validate_state_file(path: &Path) -> io::Result<bool> {
908 use std::fs;
909
910 if !path.exists() {
911 return Ok(false);
912 }
913
914 if path.is_dir() {
916 let entries = fs::read_dir(path)?;
918 let has_content = entries.count() > 0;
919 return Ok(has_content);
920 }
921
922 if path.is_file() {
924 let metadata = fs::metadata(path)?;
925 if metadata.len() == 0 {
926 return Ok(false);
928 }
929 let _ = fs::read(path)?;
931 return Ok(true);
932 }
933
934 Ok(false)
935}
936
937#[cfg(any(test, feature = "test-utils"))]
971pub fn attempt_automatic_recovery(
972 error_kind: &RebaseErrorKind,
973 phase: &crate::git_helpers::rebase_checkpoint::RebasePhase,
974 phase_error_count: u32,
975) -> io::Result<bool> {
976 use std::process::Command;
977
978 match error_kind {
980 RebaseErrorKind::InvalidRevision { .. }
981 | RebaseErrorKind::DirtyWorkingTree
982 | RebaseErrorKind::RepositoryCorrupt { .. }
983 | RebaseErrorKind::EnvironmentFailure { .. }
984 | RebaseErrorKind::HookRejection { .. }
985 | RebaseErrorKind::InteractiveStop { .. }
986 | RebaseErrorKind::Unknown { .. } => {
987 return Ok(false);
988 }
989 _ => {}
990 }
991
992 let max_attempts = phase.max_recovery_attempts();
993 if phase_error_count >= max_attempts {
994 return Ok(false);
995 }
996
997 if cleanup_stale_rebase_state().is_ok() {
999 if validate_git_state().is_ok() {
1001 return Ok(true);
1002 }
1003 }
1004
1005 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1007 let git_dir = repo.path();
1008 let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
1009 let mut removed_any = false;
1010
1011 for lock_file in &lock_files {
1012 let lock_path = git_dir.join(lock_file);
1013 if lock_path.exists() && std::fs::remove_file(&lock_path).is_ok() {
1014 removed_any = true;
1015 }
1016 }
1017
1018 if removed_any && validate_git_state().is_ok() {
1019 return Ok(true);
1020 }
1021
1022 if let RebaseErrorKind::ConcurrentOperation { .. } = error_kind {
1024 let abort_result = Command::new("git").args(["rebase", "--abort"]).output();
1026
1027 if abort_result.is_ok() {
1028 if validate_git_state().is_ok() {
1030 return Ok(true);
1031 }
1032 }
1033 }
1034
1035 Ok(false)
1037}
1038
1039#[cfg(any(test, feature = "test-utils"))]
1049pub fn validate_git_state() -> io::Result<()> {
1050 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1051
1052 let _ = repo.head().map_err(|e| {
1054 io::Error::new(
1055 io::ErrorKind::InvalidData,
1056 format!("Repository HEAD is invalid: {e}"),
1057 )
1058 })?;
1059
1060 let _ = repo.index().map_err(|e| {
1062 io::Error::new(
1063 io::ErrorKind::InvalidData,
1064 format!("Repository index is corrupted: {e}"),
1065 )
1066 })?;
1067
1068 if let Ok(head) = repo.head() {
1070 if let Ok(commit) = head.peel_to_commit() {
1071 let _ = commit.tree().map_err(|e| {
1073 io::Error::new(
1074 io::ErrorKind::InvalidData,
1075 format!("Object database corruption: {e}"),
1076 )
1077 })?;
1078 }
1079 }
1080
1081 Ok(())
1082}
1083
1084#[cfg(any(test, feature = "test-utils"))]
1098pub fn restore_from_reflog(ref_name: &str, steps_back: usize) -> io::Result<()> {
1099 use std::process::Command;
1100
1101 let refspec = format!("{ref_name}@{{{steps_back}}}");
1103 let output = Command::new("git")
1104 .args(["reset", "--hard", &refspec])
1105 .output();
1106
1107 match output {
1108 Ok(result) if result.status.success() => Ok(()),
1109 Ok(result) => {
1110 let stderr = String::from_utf8_lossy(&result.stderr);
1111 Err(io::Error::other(format!(
1112 "Failed to restore from reflog: {stderr}",
1113 )))
1114 }
1115 Err(e) => Err(io::Error::other(format!(
1116 "Failed to execute git reset: {e}"
1117 ))),
1118 }
1119}
1120
1121#[cfg(any(test, feature = "test-utils"))]
1130pub fn is_dirty_tree_cli() -> io::Result<bool> {
1131 use std::process::Command;
1132
1133 let output = Command::new("git").args(["status", "--porcelain"]).output();
1134
1135 match output {
1136 Ok(result) => {
1137 let stdout = String::from_utf8_lossy(&result.stdout);
1138 Ok(!stdout.trim().is_empty())
1139 }
1140 Err(e) => Err(io::Error::other(format!(
1141 "Failed to check working tree status: {e}"
1142 ))),
1143 }
1144}
1145
1146#[cfg(any(test, feature = "test-utils"))]
1179pub fn validate_rebase_preconditions() -> io::Result<()> {
1180 use std::process::Command;
1181
1182 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1183
1184 validate_git_state()?;
1186
1187 if let Some(concurrent_op) = detect_concurrent_git_operations()? {
1189 return Err(io::Error::new(
1190 io::ErrorKind::InvalidInput,
1191 format!(
1192 "Cannot start rebase: {} already in progress. \
1193 Please complete or abort the current operation first.",
1194 concurrent_op.description()
1195 ),
1196 ));
1197 }
1198
1199 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1201
1202 let user_name = config.get_string("user.name");
1203 let user_email = config.get_string("user.email");
1204
1205 if user_name.is_err() && user_email.is_err() {
1206 return Err(io::Error::new(
1207 io::ErrorKind::InvalidInput,
1208 "Git identity is not configured. Please set user.name and user.email:\n \
1209 git config --global user.name \"Your Name\"\n \
1210 git config --global user.email \"you@example.com\"",
1211 ));
1212 }
1213
1214 let status_output = Command::new("git").args(["status", "--porcelain"]).output();
1216
1217 match status_output {
1218 Ok(result) => {
1219 let stdout = String::from_utf8_lossy(&result.stdout);
1220 if !stdout.trim().is_empty() {
1221 return Err(io::Error::new(
1222 io::ErrorKind::InvalidInput,
1223 "Working tree is not clean. Please commit or stash changes before rebasing.",
1224 ));
1225 }
1226 }
1227 Err(_e) => {
1228 let statuses = repo.statuses(None).map_err(|e| {
1230 io::Error::new(
1231 io::ErrorKind::InvalidData,
1232 format!("Failed to check working tree status: {e}"),
1233 )
1234 })?;
1235
1236 if !statuses.is_empty() {
1237 return Err(io::Error::new(
1238 io::ErrorKind::InvalidInput,
1239 "Working tree is not clean. Please commit or stash changes before rebasing.",
1240 ));
1241 }
1242 }
1243 }
1244
1245 check_shallow_clone()?;
1247
1248 check_worktree_conflicts()?;
1250
1251 check_submodule_state()?;
1253
1254 check_sparse_checkout_state()?;
1256
1257 Ok(())
1258}
1259
1260#[cfg(any(test, feature = "test-utils"))]
1270fn check_shallow_clone() -> io::Result<()> {
1271 use std::fs;
1272
1273 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1274 let git_dir = repo.path();
1275
1276 let shallow_file = git_dir.join("shallow");
1278 if shallow_file.exists() {
1279 let content = fs::read_to_string(&shallow_file).unwrap_or_default();
1281 let line_count = content.lines().count();
1282
1283 return Err(io::Error::new(
1284 io::ErrorKind::InvalidInput,
1285 format!(
1286 "Repository is a shallow clone with {} commits. \
1287 Rebasing may fail due to missing history. \
1288 Consider running: git fetch --unshallow",
1289 line_count
1290 ),
1291 ));
1292 }
1293
1294 Ok(())
1295}
1296
1297#[cfg(any(test, feature = "test-utils"))]
1307fn check_worktree_conflicts() -> io::Result<()> {
1308 use std::fs;
1309
1310 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1311
1312 let head = repo.head().map_err(|e| git2_to_io_error(&e))?;
1314 let branch_name = match head.shorthand() {
1315 Some(name) if head.is_branch() => name,
1316 _ => return Ok(()), };
1318
1319 let git_dir = repo.path();
1320 let worktrees_dir = git_dir.join("worktrees");
1321
1322 if !worktrees_dir.exists() {
1323 return Ok(());
1324 }
1325
1326 let entries = fs::read_dir(&worktrees_dir).map_err(|e| {
1328 io::Error::new(
1329 io::ErrorKind::InvalidData,
1330 format!("Failed to read worktrees directory: {e}"),
1331 )
1332 })?;
1333
1334 for entry in entries.flatten() {
1335 let worktree_path = entry.path();
1336 let worktree_head = worktree_path.join("HEAD");
1337
1338 if worktree_head.exists() {
1339 if let Ok(content) = fs::read_to_string(&worktree_head) {
1340 if content.contains(&format!("refs/heads/{branch_name}")) {
1342 let worktree_name = worktree_path
1344 .file_name()
1345 .and_then(|n| n.to_str())
1346 .unwrap_or("unknown");
1347
1348 return Err(io::Error::new(
1349 io::ErrorKind::InvalidInput,
1350 format!(
1351 "Branch '{branch_name}' is already checked out in worktree '{worktree_name}'. \
1352 Use 'git worktree add' to create a new worktree for this branch."
1353 ),
1354 ));
1355 }
1356 }
1357 }
1358 }
1359
1360 Ok(())
1361}
1362
1363#[cfg(any(test, feature = "test-utils"))]
1373fn check_submodule_state() -> io::Result<()> {
1374 use std::fs;
1375
1376 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1377 let git_dir = repo.path();
1378
1379 let workdir = repo.workdir().unwrap_or(git_dir);
1381 let gitmodules_path = workdir.join(".gitmodules");
1382
1383 if !gitmodules_path.exists() {
1384 return Ok(()); }
1386
1387 let modules_dir = git_dir.join("modules");
1389 if !modules_dir.exists() {
1390 return Err(io::Error::new(
1392 io::ErrorKind::InvalidInput,
1393 "Submodules are not initialized. Run: git submodule update --init --recursive",
1394 ));
1395 }
1396
1397 let gitmodules_content = fs::read_to_string(&gitmodules_path).unwrap_or_default();
1399 let submodule_count = gitmodules_content.matches("path = ").count();
1400
1401 if submodule_count > 0 {
1402 for line in gitmodules_content.lines() {
1404 if line.contains("path = ") {
1405 if let Some(path) = line.split("path = ").nth(1) {
1406 let submodule_path = workdir.join(path.trim());
1407 if !submodule_path.exists() {
1408 return Err(io::Error::new(
1409 io::ErrorKind::InvalidInput,
1410 format!(
1411 "Submodule '{}' is not initialized. Run: git submodule update --init --recursive",
1412 path.trim()
1413 ),
1414 ));
1415 }
1416 }
1417 }
1418 }
1419 }
1420
1421 Ok(())
1422}
1423
1424#[cfg(any(test, feature = "test-utils"))]
1434fn check_sparse_checkout_state() -> io::Result<()> {
1435 use std::fs;
1436
1437 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1438 let git_dir = repo.path();
1439
1440 let config = repo.config().map_err(|e| git2_to_io_error(&e))?;
1442
1443 let sparse_checkout = config.get_bool("core.sparseCheckout");
1444 let sparse_checkout_cone = config.get_bool("extensions.sparseCheckoutCone");
1445
1446 match (sparse_checkout, sparse_checkout_cone) {
1447 (Ok(true), _) | (_, Ok(true)) => {
1448 let info_sparse_dir = git_dir.join("info").join("sparse-checkout");
1450
1451 if !info_sparse_dir.exists() {
1452 return Err(io::Error::new(
1454 io::ErrorKind::InvalidInput,
1455 "Sparse checkout is enabled but not configured. \
1456 Run: git sparse-checkout init",
1457 ));
1458 }
1459
1460 if let Ok(content) = fs::read_to_string(&info_sparse_dir) {
1462 if content.trim().is_empty() {
1463 return Err(io::Error::new(
1464 io::ErrorKind::InvalidInput,
1465 "Sparse checkout configuration is empty. \
1466 Run: git sparse-checkout set <patterns>",
1467 ));
1468 }
1469 }
1470
1471 }
1476 (Err(_), _) | (_, Err(_)) => {
1477 }
1479 _ => {}
1480 }
1481
1482 Ok(())
1483}
1484
1485pub fn rebase_onto(upstream_branch: &str) -> io::Result<RebaseResult> {
1517 use std::process::Command;
1518
1519 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1521
1522 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 = Command::new("git")
1599 .args(["rebase", upstream_branch])
1600 .output();
1601
1602 match output {
1603 Ok(result) => {
1604 if result.status.success() {
1605 Ok(RebaseResult::Success)
1606 } else {
1607 let stderr = String::from_utf8_lossy(&result.stderr);
1608 let stdout = String::from_utf8_lossy(&result.stdout);
1609
1610 let error_kind = classify_rebase_error(&stderr, &stdout);
1612
1613 match error_kind {
1614 RebaseErrorKind::ContentConflict { .. } => {
1615 match get_conflicted_files() {
1617 Ok(files) if files.is_empty() => {
1618 if let RebaseErrorKind::ContentConflict { files } = error_kind {
1621 Ok(RebaseResult::Conflicts(files))
1622 } else {
1623 Ok(RebaseResult::Conflicts(vec![]))
1624 }
1625 }
1626 Ok(files) => Ok(RebaseResult::Conflicts(files)),
1627 Err(_) => Ok(RebaseResult::Conflicts(vec![])),
1628 }
1629 }
1630 RebaseErrorKind::Unknown { .. } => {
1631 if stderr.contains("up to date") {
1633 Ok(RebaseResult::NoOp {
1634 reason: "Branch is already up-to-date with upstream".to_string(),
1635 })
1636 } else {
1637 Ok(RebaseResult::Failed(error_kind))
1638 }
1639 }
1640 _ => Ok(RebaseResult::Failed(error_kind)),
1641 }
1642 }
1643 }
1644 Err(e) => Err(io::Error::other(format!(
1645 "Failed to execute git rebase: {e}"
1646 ))),
1647 }
1648}
1649
1650pub fn abort_rebase() -> io::Result<()> {
1661 use std::process::Command;
1662
1663 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
1664
1665 let state = repo.state();
1667 if state != git2::RepositoryState::Rebase
1668 && state != git2::RepositoryState::RebaseMerge
1669 && state != git2::RepositoryState::RebaseInteractive
1670 {
1671 return Err(io::Error::new(
1672 io::ErrorKind::InvalidInput,
1673 "No rebase in progress",
1674 ));
1675 }
1676
1677 let output = Command::new("git").args(["rebase", "--abort"]).output();
1679
1680 match output {
1681 Ok(result) => {
1682 if result.status.success() {
1683 Ok(())
1684 } else {
1685 let stderr = String::from_utf8_lossy(&result.stderr);
1686 Err(io::Error::other(format!(
1687 "Failed to abort rebase: {stderr}"
1688 )))
1689 }
1690 }
1691 Err(e) => Err(io::Error::other(format!(
1692 "Failed to execute git rebase --abort: {e}"
1693 ))),
1694 }
1695}
1696
1697pub fn get_conflicted_files() -> io::Result<Vec<String>> {
1707 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
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"))]
2027pub fn validate_post_rebase_with_checks(
2028 run_build_checks: bool,
2029 run_test_checks: bool,
2030 run_lint_checks: bool,
2031) -> io::Result<PostRebaseValidationResult> {
2032 use std::path::Path;
2033 use std::process::Command;
2034
2035 let git_state_valid = validate_post_rebase_state().is_ok();
2036 let mut result = PostRebaseValidationResult {
2037 git_state_valid,
2038 ..Default::default()
2039 };
2040
2041 if !result.git_state_valid {
2042 result
2043 .messages
2044 .push("Git state validation failed".to_string());
2045 }
2046
2047 let is_rust_project = Path::new("Cargo.toml").exists();
2049
2050 if !is_rust_project {
2051 result
2052 .messages
2053 .push("Not a Rust project - skipping project-specific checks".to_string());
2054 return Ok(result);
2055 }
2056
2057 if run_build_checks {
2059 result
2060 .messages
2061 .push("Running build validation...".to_string());
2062 let build_output = Command::new("cargo").args(["build", "--release"]).output();
2063
2064 result.build_valid = Some(match build_output {
2065 Ok(output) => output.status.success(),
2066 Err(e) => {
2067 result.messages.push(format!("Failed to run build: {e}"));
2068 false
2069 }
2070 });
2071
2072 if result.build_valid == Some(false) {
2073 result
2074 .messages
2075 .push("Build validation failed - project may not compile".to_string());
2076 }
2077 }
2078
2079 if run_test_checks {
2081 result
2082 .messages
2083 .push("Running test validation...".to_string());
2084 let test_output = Command::new("cargo")
2085 .args(["test", "--lib", "--all-features"])
2086 .output();
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 = Command::new("cargo")
2109 .args(["clippy", "--lib", "--all-features", "-D", "warnings"])
2110 .output();
2111
2112 result.lint_valid = Some(match lint_output {
2113 Ok(output) => output.status.success(),
2114 Err(e) => {
2115 result.messages.push(format!("Failed to run clippy: {e}"));
2116 false
2117 }
2118 });
2119
2120 if result.lint_valid == Some(false) {
2121 result
2122 .messages
2123 .push("Lint validation failed - code may have lint issues".to_string());
2124 }
2125 }
2126
2127 Ok(result)
2128}
2129
2130pub fn continue_rebase() -> io::Result<()> {
2143 use std::process::Command;
2144
2145 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
2146
2147 let state = repo.state();
2149 if state != git2::RepositoryState::Rebase
2150 && state != git2::RepositoryState::RebaseMerge
2151 && state != git2::RepositoryState::RebaseInteractive
2152 {
2153 return Err(io::Error::new(
2154 io::ErrorKind::InvalidInput,
2155 "No rebase in progress",
2156 ));
2157 }
2158
2159 let conflicted = get_conflicted_files()?;
2161 if !conflicted.is_empty() {
2162 return Err(io::Error::new(
2163 io::ErrorKind::InvalidInput,
2164 format!(
2165 "Cannot continue rebase: {} file(s) still have conflicts",
2166 conflicted.len()
2167 ),
2168 ));
2169 }
2170
2171 let output = Command::new("git").args(["rebase", "--continue"]).output();
2173
2174 match output {
2175 Ok(result) => {
2176 if result.status.success() {
2177 Ok(())
2178 } else {
2179 let stderr = String::from_utf8_lossy(&result.stderr);
2180 Err(io::Error::other(format!(
2181 "Failed to continue rebase: {stderr}"
2182 )))
2183 }
2184 }
2185 Err(e) => Err(io::Error::other(format!(
2186 "Failed to execute git rebase --continue: {e}"
2187 ))),
2188 }
2189}
2190
2191pub fn rebase_in_progress() -> io::Result<bool> {
2202 let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
2203 let state = repo.state();
2204 Ok(state == git2::RepositoryState::Rebase
2205 || state == git2::RepositoryState::RebaseMerge
2206 || state == git2::RepositoryState::RebaseInteractive)
2207}
2208
2209#[cfg(test)]
2210mod tests {
2211 use super::*;
2212
2213 #[test]
2214 fn test_rebase_result_variants_exist() {
2215 let _ = RebaseResult::Success;
2217 let _ = RebaseResult::NoOp {
2218 reason: "test".to_string(),
2219 };
2220 let _ = RebaseResult::Conflicts(vec![]);
2221 let _ = RebaseResult::Failed(RebaseErrorKind::Unknown {
2222 details: "test".to_string(),
2223 });
2224 }
2225
2226 #[test]
2227 fn test_rebase_result_is_noop() {
2228 assert!(RebaseResult::NoOp {
2230 reason: "test".to_string()
2231 }
2232 .is_noop());
2233 assert!(!RebaseResult::Success.is_noop());
2234 assert!(!RebaseResult::Conflicts(vec![]).is_noop());
2235 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
2236 details: "test".to_string(),
2237 })
2238 .is_noop());
2239 }
2240
2241 #[test]
2242 fn test_rebase_result_is_success() {
2243 assert!(RebaseResult::Success.is_success());
2245 assert!(!RebaseResult::NoOp {
2246 reason: "test".to_string()
2247 }
2248 .is_success());
2249 assert!(!RebaseResult::Conflicts(vec![]).is_success());
2250 assert!(!RebaseResult::Failed(RebaseErrorKind::Unknown {
2251 details: "test".to_string(),
2252 })
2253 .is_success());
2254 }
2255
2256 #[test]
2257 fn test_rebase_result_has_conflicts() {
2258 assert!(RebaseResult::Conflicts(vec!["file.txt".to_string()]).has_conflicts());
2260 assert!(!RebaseResult::Success.has_conflicts());
2261 assert!(!RebaseResult::NoOp {
2262 reason: "test".to_string()
2263 }
2264 .has_conflicts());
2265 }
2266
2267 #[test]
2268 fn test_rebase_result_is_failed() {
2269 assert!(RebaseResult::Failed(RebaseErrorKind::Unknown {
2271 details: "test".to_string(),
2272 })
2273 .is_failed());
2274 assert!(!RebaseResult::Success.is_failed());
2275 assert!(!RebaseResult::NoOp {
2276 reason: "test".to_string()
2277 }
2278 .is_failed());
2279 assert!(!RebaseResult::Conflicts(vec![]).is_failed());
2280 }
2281
2282 #[test]
2283 fn test_rebase_error_kind_description() {
2284 let err = RebaseErrorKind::InvalidRevision {
2286 revision: "main".to_string(),
2287 };
2288 assert!(err.description().contains("main"));
2289
2290 let err = RebaseErrorKind::DirtyWorkingTree;
2291 assert!(err.description().contains("Working tree"));
2292 }
2293
2294 #[test]
2295 fn test_rebase_error_kind_category() {
2296 assert_eq!(
2298 RebaseErrorKind::InvalidRevision {
2299 revision: "test".to_string()
2300 }
2301 .category(),
2302 1
2303 );
2304 assert_eq!(
2305 RebaseErrorKind::ContentConflict { files: vec![] }.category(),
2306 2
2307 );
2308 assert_eq!(
2309 RebaseErrorKind::ValidationFailed {
2310 reason: "test".to_string()
2311 }
2312 .category(),
2313 3
2314 );
2315 assert_eq!(
2316 RebaseErrorKind::ProcessTerminated {
2317 reason: "test".to_string()
2318 }
2319 .category(),
2320 4
2321 );
2322 assert_eq!(
2323 RebaseErrorKind::Unknown {
2324 details: "test".to_string()
2325 }
2326 .category(),
2327 5
2328 );
2329 }
2330
2331 #[test]
2332 fn test_rebase_error_kind_is_recoverable() {
2333 assert!(RebaseErrorKind::ConcurrentOperation {
2335 operation: "rebase".to_string()
2336 }
2337 .is_recoverable());
2338 assert!(RebaseErrorKind::ContentConflict { files: vec![] }.is_recoverable());
2339 assert!(!RebaseErrorKind::InvalidRevision {
2340 revision: "test".to_string()
2341 }
2342 .is_recoverable());
2343 assert!(!RebaseErrorKind::DirtyWorkingTree.is_recoverable());
2344 }
2345
2346 #[test]
2347 fn test_classify_rebase_error_invalid_revision() {
2348 let stderr = "error: invalid revision 'nonexistent'";
2350 let error = classify_rebase_error(stderr, "");
2351 assert!(matches!(error, RebaseErrorKind::InvalidRevision { .. }));
2352 }
2353
2354 #[test]
2355 fn test_classify_rebase_error_conflict() {
2356 let stderr = "CONFLICT (content): Merge conflict in src/main.rs";
2358 let error = classify_rebase_error(stderr, "");
2359 assert!(matches!(error, RebaseErrorKind::ContentConflict { .. }));
2360 }
2361
2362 #[test]
2363 fn test_classify_rebase_error_dirty_tree() {
2364 let stderr = "Cannot rebase: Your index contains uncommitted changes";
2366 let error = classify_rebase_error(stderr, "");
2367 assert!(matches!(error, RebaseErrorKind::DirtyWorkingTree));
2368 }
2369
2370 #[test]
2371 fn test_classify_rebase_error_concurrent_operation() {
2372 let stderr = "Cannot rebase: There is a rebase in progress already";
2374 let error = classify_rebase_error(stderr, "");
2375 assert!(matches!(error, RebaseErrorKind::ConcurrentOperation { .. }));
2376 }
2377
2378 #[test]
2379 fn test_classify_rebase_error_unknown() {
2380 let stderr = "Some completely unexpected error message";
2382 let error = classify_rebase_error(stderr, "");
2383 assert!(matches!(error, RebaseErrorKind::Unknown { .. }));
2384 }
2385
2386 #[test]
2387 fn test_rebase_onto_returns_result() {
2388 use test_helpers::{commit_all, init_git_repo, with_temp_cwd, write_file};
2389
2390 with_temp_cwd(|dir| {
2392 let repo = init_git_repo(dir);
2394 write_file(dir.path().join("initial.txt"), "initial content");
2395 let _ = commit_all(&repo, "initial commit");
2396
2397 let result = rebase_onto("nonexistent_branch_that_does_not_exist");
2399 assert!(result.is_ok());
2401 });
2402 }
2403
2404 #[test]
2405 fn test_get_conflicted_files_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 = get_conflicted_files();
2414 assert!(result.is_ok());
2416 });
2417 }
2418
2419 #[test]
2420 fn test_rebase_in_progress_cli_returns_result() {
2421 use test_helpers::{init_git_repo, with_temp_cwd};
2422
2423 with_temp_cwd(|dir| {
2425 let _repo = init_git_repo(dir);
2427
2428 let result = rebase_in_progress_cli();
2429 assert!(result.is_ok());
2431 });
2432 }
2433
2434 #[test]
2435 fn test_is_dirty_tree_cli_returns_result() {
2436 use test_helpers::{init_git_repo, with_temp_cwd};
2437
2438 with_temp_cwd(|dir| {
2440 let _repo = init_git_repo(dir);
2442
2443 let result = is_dirty_tree_cli();
2444 assert!(result.is_ok());
2446 });
2447 }
2448
2449 #[test]
2450 fn test_cleanup_stale_rebase_state_returns_result() {
2451 use test_helpers::{init_git_repo, with_temp_cwd};
2452
2453 with_temp_cwd(|dir| {
2454 let _repo = init_git_repo(dir);
2456
2457 let result = cleanup_stale_rebase_state();
2459 assert!(result.is_ok());
2461 });
2462 }
2463}