1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Output;
4use std::time::Duration;
5
6use tokio::task::spawn_blocking;
7
8use super::error::GitError;
9use super::repo::{
10 command_output_detail, resolve_git_dir, run_git_command_output_sync,
11 run_git_command_output_with_env_sync, run_git_command_sync,
12};
13use crate::{Sleeper, ThreadSleeper};
14
15const GIT_INDEX_LOCK_RETRY_ATTEMPTS: usize = 5;
16const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
17
18#[cfg_attr(test, mockall::automock)]
20trait GitCommandRunner: Send + Sync {
21 fn run_git_command_output_with_env(
23 &self,
24 repo_path: &Path,
25 args: &[String],
26 environment: &[(String, String)],
27 ) -> Result<Output, GitError>;
28}
29
30struct ProcessGitCommandRunner;
32
33impl GitCommandRunner for ProcessGitCommandRunner {
34 fn run_git_command_output_with_env(
35 &self,
36 repo_path: &Path,
37 args: &[String],
38 environment: &[(String, String)],
39 ) -> Result<Output, GitError> {
40 let args = args.iter().map(String::as_str).collect::<Vec<_>>();
41 let environment = environment
42 .iter()
43 .map(|(key, value)| (key.as_str(), value.as_str()))
44 .collect::<Vec<_>>();
45
46 run_git_command_output_with_env_sync(repo_path, &args, &environment)
47 }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
52pub enum RebaseStepResult {
53 Completed,
55 Conflict { detail: String },
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum InProgressGitOperation {
62 CherryPick,
64 Merge,
66 Rebase,
68 Revert,
70}
71
72impl InProgressGitOperation {
73 pub fn article_name(self) -> &'static str {
76 match self {
77 Self::CherryPick => "a cherry-pick",
78 Self::Merge => "a merge",
79 Self::Rebase => "a rebase",
80 Self::Revert => "a revert",
81 }
82 }
83
84 pub fn name(self) -> &'static str {
86 match self {
87 Self::CherryPick => "cherry-pick",
88 Self::Merge => "merge",
89 Self::Rebase => "rebase",
90 Self::Revert => "revert",
91 }
92 }
93}
94
95pub(crate) async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
111 match rebase_start(repo_path.clone(), target_branch.clone()).await? {
112 RebaseStepResult::Completed => Ok(()),
113 RebaseStepResult::Conflict { detail } => {
114 let abort_suffix = match abort_rebase(repo_path).await {
115 Ok(()) => String::new(),
116 Err(error) => format!(" {error}"),
117 };
118
119 Err(GitError::CommandFailed {
120 command: "git rebase".to_string(),
121 stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
122 })
123 }
124 }
125}
126
127pub(crate) async fn rebase_start(
142 repo_path: PathBuf,
143 target_branch: String,
144) -> Result<RebaseStepResult, GitError> {
145 spawn_blocking(move || {
146 let rebase_args = ["rebase", target_branch.as_str()];
147 run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
148 format!("Failed to rebase onto {target_branch}: {detail}.")
149 })
150 })
151 .await?
152}
153
154pub(crate) async fn rebase_onto_start(
171 repo_path: PathBuf,
172 new_base: String,
173 old_base: String,
174) -> Result<RebaseStepResult, GitError> {
175 spawn_blocking(move || {
176 let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
177 run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
178 format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
179 })
180 })
181 .await?
182}
183
184pub(crate) async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
196 spawn_blocking(move || {
197 let output = run_git_command_with_index_lock_retry(
198 &repo_path,
199 &["rebase", "--continue"],
200 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
201 )?;
202
203 if output.status.success() {
204 return Ok(RebaseStepResult::Completed);
205 }
206
207 let detail = command_output_detail(&output.stdout, &output.stderr);
208 if is_rebase_conflict(&detail) {
209 return Ok(RebaseStepResult::Conflict { detail });
210 }
211
212 Err(GitError::CommandFailed {
213 command: "git rebase --continue".to_string(),
214 stderr: format!("Failed to continue rebase: {detail}."),
215 })
216 })
217 .await?
218}
219
220pub(crate) async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
235 spawn_blocking(move || {
236 let output =
237 run_git_command_with_index_lock_retry(&repo_path, &["rebase", "--abort"], &[])?;
238
239 if !output.status.success() {
240 let detail = command_output_detail(&output.stdout, &output.stderr);
241 if !is_stale_or_inactive_rebase_error(&detail) {
242 return Err(GitError::CommandFailed {
243 command: "git rebase --abort".to_string(),
244 stderr: format!("Failed to abort rebase: {detail}."),
245 });
246 }
247
248 let cleaned_stale_metadata = clean_stale_rebase_metadata(&repo_path)?;
249 if cleaned_stale_metadata {
250 return Ok(());
251 }
252
253 return Err(GitError::CommandFailed {
254 command: "git rebase --abort".to_string(),
255 stderr: format!("Failed to abort rebase: {detail}."),
256 });
257 }
258
259 Ok(())
260 })
261 .await?
262}
263
264pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
277 spawn_blocking(move || -> Result<bool, GitError> {
278 let git_dir = resolve_git_dir(&repo_path)
279 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
280
281 Ok(has_rebase_metadata(&git_dir))
282 })
283 .await?
284}
285
286pub(crate) async fn in_progress_operation(
297 repo_path: PathBuf,
298) -> Result<Option<InProgressGitOperation>, GitError> {
299 spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
300}
301
302fn in_progress_operation_sync(
303 repo_path: &Path,
304) -> Result<Option<InProgressGitOperation>, GitError> {
305 let git_dir = resolve_git_dir(repo_path)
306 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
307 if has_rebase_metadata(&git_dir) {
308 return Ok(Some(InProgressGitOperation::Rebase));
309 }
310 if git_dir.join("MERGE_HEAD").exists() {
311 return Ok(Some(InProgressGitOperation::Merge));
312 }
313 if git_dir.join("CHERRY_PICK_HEAD").exists() {
314 return Ok(Some(InProgressGitOperation::CherryPick));
315 }
316 if git_dir.join("REVERT_HEAD").exists() {
317 return Ok(Some(InProgressGitOperation::Revert));
318 }
319
320 Ok(None)
321}
322
323fn has_rebase_metadata(git_dir: &Path) -> bool {
324 let rebase_merge = git_dir.join("rebase-merge");
325 let rebase_apply = git_dir.join("rebase-apply");
326
327 rebase_merge.exists() || rebase_apply.exists()
328}
329
330pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
341 let conflicted_files = list_conflicted_files(repo_path).await?;
342
343 Ok(!conflicted_files.is_empty())
344}
345
346pub(crate) async fn list_staged_conflict_marker_files(
370 repo_path: PathBuf,
371 paths: Vec<String>,
372) -> Result<Vec<String>, GitError> {
373 if paths.is_empty() {
374 return Ok(vec![]);
375 }
376
377 spawn_blocking(move || -> Result<Vec<String>, GitError> {
378 let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
379 let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
380 grep_arguments.extend(path_arguments);
381 let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
382
383 let exit_code = output.status.code().unwrap_or(2);
385 if !output.status.success() && exit_code != 1 {
386 let detail = command_output_detail(&output.stdout, &output.stderr);
387
388 return Err(GitError::CommandFailed {
389 command: "git grep".to_string(),
390 stderr: format!("Failed to check for staged conflict markers: {detail}"),
391 });
392 }
393
394 let files = String::from_utf8_lossy(&output.stdout)
395 .lines()
396 .map(str::trim)
397 .filter(|line| !line.is_empty())
398 .map(ToString::to_string)
399 .collect();
400
401 Ok(files)
402 })
403 .await?
404}
405
406pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
418 spawn_blocking(move || -> Result<Vec<String>, GitError> {
419 let output = run_git_command_sync(
420 &repo_path,
421 &["diff", "--name-only", "--diff-filter=U"],
422 "Failed to read conflicted files",
423 )?;
424 let files = output
425 .lines()
426 .map(str::trim)
427 .filter(|line| !line.is_empty())
428 .map(ToString::to_string)
429 .collect();
430
431 Ok(files)
432 })
433 .await?
434}
435
436fn run_rebase_step(
438 repo_path: &Path,
439 args: &[&str],
440 command: &str,
441 failure_message: impl FnOnce(&str) -> String,
442) -> Result<RebaseStepResult, GitError> {
443 let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
444
445 if output.status.success() {
446 return Ok(RebaseStepResult::Completed);
447 }
448
449 let detail = command_output_detail(&output.stdout, &output.stderr);
450 if is_rebase_conflict(&detail) {
451 return Ok(RebaseStepResult::Conflict { detail });
452 }
453
454 Err(GitError::CommandFailed {
455 command: command.to_string(),
456 stderr: failure_message(&detail),
457 })
458}
459
460pub(super) fn run_git_command_with_index_lock_retry(
462 repo_path: &Path,
463 args: &[&str],
464 environment: &[(&str, &str)],
465) -> Result<Output, GitError> {
466 let command_runner = ProcessGitCommandRunner;
467 let sleeper = ThreadSleeper;
468
469 run_git_command_with_index_lock_retry_with_dependencies(
470 repo_path,
471 args,
472 environment,
473 &command_runner,
474 &sleeper,
475 )
476}
477
478fn run_git_command_with_index_lock_retry_with_dependencies(
481 repo_path: &Path,
482 args: &[&str],
483 environment: &[(&str, &str)],
484 command_runner: &dyn GitCommandRunner,
485 sleeper: &dyn Sleeper,
486) -> Result<Output, GitError> {
487 let args = args
488 .iter()
489 .map(|arg| String::from(*arg))
490 .collect::<Vec<_>>();
491 let environment = environment
492 .iter()
493 .map(|(key, value)| (String::from(*key), String::from(*value)))
494 .collect::<Vec<_>>();
495
496 for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
497 let output =
498 command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
499 if output.status.success() {
500 return Ok(output);
501 }
502
503 let detail = command_output_detail(&output.stdout, &output.stderr);
504 let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
505 if !is_git_index_lock_error(&detail) || is_last_attempt {
506 return Ok(output);
507 }
508
509 sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
510 }
511
512 unreachable!("index lock retry loop should always return an output")
513}
514
515pub(super) fn is_rebase_conflict(detail: &str) -> bool {
521 detail.contains("CONFLICT")
522 || detail.contains("Resolve all conflicts manually")
523 || detail.contains("could not apply")
524 || detail.contains("mark them as resolved")
525 || detail.contains("unresolved conflict")
526 || detail.contains("Committing is not possible")
527}
528
529fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
531 let normalized_detail = detail.to_ascii_lowercase();
532
533 normalized_detail.contains("already a rebase-merge directory")
534 || normalized_detail.contains("already a rebase-apply directory")
535 || normalized_detail.contains("middle of another rebase")
536 || normalized_detail.contains("no rebase in progress")
537 || normalized_detail.contains("rebase-merge")
538 || normalized_detail.contains("rebase-apply")
539}
540
541fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
549 let git_dir = resolve_git_dir(repo_path)
550 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
551 let rebase_merge = git_dir.join("rebase-merge");
552 let rebase_apply = git_dir.join("rebase-apply");
553 let removed_rebase_merge = remove_stale_rebase_metadata_path(&rebase_merge)?;
554 let removed_rebase_apply = remove_stale_rebase_metadata_path(&rebase_apply)?;
555
556 Ok(removed_rebase_merge || removed_rebase_apply)
557}
558
559fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
565 if !path.exists() {
566 return Ok(false);
567 }
568
569 if path.is_dir() {
570 fs::remove_dir_all(path)?;
571
572 return Ok(true);
573 }
574
575 fs::remove_file(path)?;
576
577 Ok(true)
578}
579
580fn is_git_index_lock_error(detail: &str) -> bool {
582 let normalized_detail = detail.to_ascii_lowercase();
583
584 normalized_detail.contains("index.lock")
585 && (normalized_detail.contains("file exists")
586 || normalized_detail.contains("unable to create")
587 || normalized_detail.contains("another git process"))
588}
589
590#[cfg(test)]
591mod tests {
592 use std::fs;
593 use std::process::{Command, Output};
594
595 use mockall::predicate::eq;
596 use tempfile::tempdir;
597
598 use super::*;
599 use crate::MockSleeper;
600
601 #[test]
602 fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
603 let mut command_runner = MockGitCommandRunner::new();
605 let mut sleeper = MockSleeper::new();
606 let repo_path = Path::new(".");
607 let args = ["rebase", "main"];
608 let environment: [(&str, &str); 0] = [];
609
610 command_runner
611 .expect_run_git_command_output_with_env()
612 .times(1)
613 .returning(|_, _, _| Ok(git_index_lock_output()));
614 command_runner
615 .expect_run_git_command_output_with_env()
616 .times(1)
617 .returning(|_, _, _| Ok(success_output()));
618
619 sleeper
620 .expect_sleep()
621 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
622 .times(1)
623 .return_once(|_| {});
624
625 let output = run_git_command_with_index_lock_retry_with_dependencies(
627 repo_path,
628 &args,
629 &environment,
630 &command_runner,
631 &sleeper,
632 )
633 .expect("retry helper should return command output");
634
635 assert!(output.status.success());
637 }
638
639 #[test]
640 fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
641 let mut command_runner = MockGitCommandRunner::new();
643 let mut sleeper = MockSleeper::new();
644 let repo_path = Path::new(".");
645 let args = ["-c", "core.editor=true", "rebase", "main"];
646 let environment = [("GIT_EDITOR", "true")];
647
648 command_runner
649 .expect_run_git_command_output_with_env()
650 .withf(|repo_path, args, environment| {
651 repo_path == Path::new(".")
652 && args.iter().map(String::as_str).eq([
653 "-c",
654 "core.editor=true",
655 "rebase",
656 "main",
657 ])
658 && environment
659 .iter()
660 .map(|(key, value)| (key.as_str(), value.as_str()))
661 .eq([("GIT_EDITOR", "true")])
662 })
663 .times(1)
664 .returning(|_, _, _| Ok(success_output()));
665 sleeper.expect_sleep().times(0);
666
667 let output = run_git_command_with_index_lock_retry_with_dependencies(
669 repo_path,
670 &args,
671 &environment,
672 &command_runner,
673 &sleeper,
674 )
675 .expect("retry helper should return command output");
676
677 assert!(output.status.success());
679 }
680
681 #[test]
682 fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
683 let mut command_runner = MockGitCommandRunner::new();
685 let mut sleeper = MockSleeper::new();
686 let repo_path = Path::new(".");
687 let args = ["rebase", "main"];
688 let environment: [(&str, &str); 0] = [];
689
690 command_runner
691 .expect_run_git_command_output_with_env()
692 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
693 .returning(|_, _, _| Ok(git_index_lock_output()));
694 sleeper
695 .expect_sleep()
696 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
697 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
698 .returning(|_| {});
699
700 let output = run_git_command_with_index_lock_retry_with_dependencies(
702 repo_path,
703 &args,
704 &environment,
705 &command_runner,
706 &sleeper,
707 )
708 .expect("retry helper should return command output");
709
710 assert!(!output.status.success());
712 assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
713 }
714
715 #[test]
716 fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
717 let mut command_runner = MockGitCommandRunner::new();
719 let mut sleeper = MockSleeper::new();
720 let repo_path = Path::new(".");
721 let args = ["rebase", "main"];
722 let environment: [(&str, &str); 0] = [];
723
724 command_runner
725 .expect_run_git_command_output_with_env()
726 .times(1)
727 .return_once(|_, _, _| {
728 Err(GitError::CommandFailed {
729 command: "git".to_string(),
730 stderr: "git execution failed".to_string(),
731 })
732 });
733 sleeper.expect_sleep().times(0);
734
735 let error = run_git_command_with_index_lock_retry_with_dependencies(
737 repo_path,
738 &args,
739 &environment,
740 &command_runner,
741 &sleeper,
742 )
743 .expect_err("retry helper should surface command execution errors");
744
745 assert_eq!(error.to_string(), "git: git execution failed");
747 }
748
749 #[test]
750 fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
751 let mut command_runner = MockGitCommandRunner::new();
753 let mut sleeper = MockSleeper::new();
754 let repo_path = Path::new(".");
755 let args = ["rebase", "main"];
756 let environment: [(&str, &str); 0] = [];
757
758 command_runner
759 .expect_run_git_command_output_with_env()
760 .times(1)
761 .returning(|_, _, _| Ok(non_lock_failure_output()));
762 sleeper.expect_sleep().times(0);
763
764 let output = run_git_command_with_index_lock_retry_with_dependencies(
766 repo_path,
767 &args,
768 &environment,
769 &command_runner,
770 &sleeper,
771 )
772 .expect("retry helper should return command output");
773
774 assert!(!output.status.success());
776 }
777
778 #[test]
779 fn test_is_rebase_conflict_matches_unmerged_files_message() {
780 let detail = "Committing is not possible because you have unmerged files.";
782
783 let is_conflict = is_rebase_conflict(detail);
785
786 assert!(is_conflict);
788 }
789
790 #[test]
791 fn test_is_stale_or_inactive_rebase_error_matches_no_rebase_message() {
792 let detail = "fatal: No rebase in progress?";
794
795 let is_stale_metadata_error = is_stale_or_inactive_rebase_error(detail);
797
798 assert!(is_stale_metadata_error);
800 }
801
802 #[test]
803 fn test_in_progress_operation_detects_rebase_metadata() {
804 let temp_dir = tempdir().expect("tempdir should be created");
806 let git_dir = temp_dir.path().join(".git");
807 fs::create_dir(&git_dir).expect("git dir should be created");
808 fs::create_dir(git_dir.join("rebase-merge")).expect("rebase metadata should be created");
809
810 let operation =
812 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
813
814 assert_eq!(operation, Some(InProgressGitOperation::Rebase));
816 }
817
818 #[test]
819 fn test_in_progress_operation_detects_merge_metadata() {
820 let temp_dir = tempdir().expect("tempdir should be created");
822 let git_dir = temp_dir.path().join(".git");
823 fs::create_dir(&git_dir).expect("git dir should be created");
824 fs::write(git_dir.join("MERGE_HEAD"), "merge").expect("merge metadata should be created");
825
826 let operation =
828 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
829
830 assert_eq!(operation, Some(InProgressGitOperation::Merge));
832 }
833
834 #[test]
835 fn test_in_progress_operation_detects_cherry_pick_metadata() {
836 let temp_dir = tempdir().expect("tempdir should be created");
838 let git_dir = temp_dir.path().join(".git");
839 fs::create_dir(&git_dir).expect("git dir should be created");
840 fs::write(git_dir.join("CHERRY_PICK_HEAD"), "cherry-pick")
841 .expect("cherry-pick metadata should be created");
842
843 let operation =
845 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
846
847 assert_eq!(operation, Some(InProgressGitOperation::CherryPick));
849 }
850
851 #[test]
852 fn test_in_progress_operation_detects_revert_metadata() {
853 let temp_dir = tempdir().expect("tempdir should be created");
855 let git_dir = temp_dir.path().join(".git");
856 fs::create_dir(&git_dir).expect("git dir should be created");
857 fs::write(git_dir.join("REVERT_HEAD"), "revert")
858 .expect("revert metadata should be created");
859
860 let operation =
862 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
863
864 assert_eq!(operation, Some(InProgressGitOperation::Revert));
866 }
867
868 #[test]
869 fn test_in_progress_operation_returns_none_for_clean_git_dir() {
870 let temp_dir = tempdir().expect("tempdir should be created");
872 fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
873
874 let operation =
876 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
877
878 assert_eq!(operation, None);
880 }
881
882 #[test]
883 fn test_clean_stale_rebase_metadata_removes_existing_paths() {
884 let temp_dir = tempdir().expect("tempdir should be created");
886 let git_dir = temp_dir.path().join(".git");
887 let rebase_merge = git_dir.join("rebase-merge");
888 let rebase_apply = git_dir.join("rebase-apply");
889
890 fs::create_dir(&git_dir).expect("git dir should be created");
891 fs::create_dir(&rebase_merge).expect("rebase-merge dir should be created");
892 fs::write(&rebase_apply, "apply state").expect("rebase-apply file should be created");
893
894 let cleaned = clean_stale_rebase_metadata(temp_dir.path())
896 .expect("stale metadata cleanup should succeed");
897
898 assert!(cleaned);
900 assert!(!rebase_merge.exists());
901 assert!(!rebase_apply.exists());
902 }
903
904 fn success_output() -> Output {
906 Command::new("git")
907 .arg("--version")
908 .output()
909 .expect("failed to run git --version")
910 }
911
912 fn git_index_lock_output() -> Output {
914 let mut output = Command::new("git")
915 .arg("definitely-invalid-subcommand")
916 .output()
917 .expect("failed to run git invalid command");
918 output.stdout = vec![];
919 output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
920
921 output
922 }
923
924 fn non_lock_failure_output() -> Output {
926 let mut output = Command::new("git")
927 .arg("definitely-invalid-subcommand")
928 .output()
929 .expect("failed to run git invalid command");
930 output.stdout = vec![];
931 output.stderr = b"fatal: not a git repository".to_vec();
932
933 output
934 }
935}