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 {
57 detail: String,
59 },
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum InProgressGitOperation {
65 CherryPick,
67 Merge,
69 Rebase,
71 Revert,
73}
74
75impl InProgressGitOperation {
76 pub fn article_name(self) -> &'static str {
79 match self {
80 Self::CherryPick => "a cherry-pick",
81 Self::Merge => "a merge",
82 Self::Rebase => "a rebase",
83 Self::Revert => "a revert",
84 }
85 }
86
87 pub fn name(self) -> &'static str {
89 match self {
90 Self::CherryPick => "cherry-pick",
91 Self::Merge => "merge",
92 Self::Rebase => "rebase",
93 Self::Revert => "revert",
94 }
95 }
96}
97
98pub(crate) async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
114 match rebase_start(repo_path.clone(), target_branch.clone()).await? {
115 RebaseStepResult::Completed => Ok(()),
116 RebaseStepResult::Conflict { detail } => {
117 let abort_suffix = match abort_rebase(repo_path).await {
118 Ok(()) => String::new(),
119 Err(error) => format!(" {error}"),
120 };
121
122 Err(GitError::CommandFailed {
123 command: "git rebase".to_string(),
124 stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
125 })
126 }
127 }
128}
129
130pub(crate) async fn rebase_start(
145 repo_path: PathBuf,
146 target_branch: String,
147) -> Result<RebaseStepResult, GitError> {
148 spawn_blocking(move || {
149 let rebase_args = ["rebase", target_branch.as_str()];
150 run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
151 format!("Failed to rebase onto {target_branch}: {detail}.")
152 })
153 })
154 .await?
155}
156
157pub(crate) async fn rebase_onto_start(
174 repo_path: PathBuf,
175 new_base: String,
176 old_base: String,
177) -> Result<RebaseStepResult, GitError> {
178 spawn_blocking(move || {
179 let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
180 run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
181 format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
182 })
183 })
184 .await?
185}
186
187pub(crate) async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
199 spawn_blocking(move || {
200 let output = run_git_command_with_index_lock_retry(
201 &repo_path,
202 &["rebase", "--continue"],
203 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
204 )?;
205
206 if output.status.success() {
207 return Ok(RebaseStepResult::Completed);
208 }
209
210 let detail = command_output_detail(&output.stdout, &output.stderr);
211 if is_rebase_conflict(&detail) {
212 return Ok(RebaseStepResult::Conflict { detail });
213 }
214
215 Err(GitError::CommandFailed {
216 command: "git rebase --continue".to_string(),
217 stderr: format!("Failed to continue rebase: {detail}."),
218 })
219 })
220 .await?
221}
222
223pub(crate) async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
238 spawn_blocking(move || {
239 let output =
240 run_git_command_with_index_lock_retry(&repo_path, &["rebase", "--abort"], &[])?;
241
242 if !output.status.success() {
243 let detail = command_output_detail(&output.stdout, &output.stderr);
244 if !is_stale_or_inactive_rebase_error(&detail) {
245 return Err(GitError::CommandFailed {
246 command: "git rebase --abort".to_string(),
247 stderr: format!("Failed to abort rebase: {detail}."),
248 });
249 }
250
251 let cleaned_stale_metadata = clean_stale_rebase_metadata(&repo_path)?;
252 if cleaned_stale_metadata {
253 return Ok(());
254 }
255
256 return Err(GitError::CommandFailed {
257 command: "git rebase --abort".to_string(),
258 stderr: format!("Failed to abort rebase: {detail}."),
259 });
260 }
261
262 Ok(())
263 })
264 .await?
265}
266
267pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
280 spawn_blocking(move || -> Result<bool, GitError> {
281 let git_dir = resolve_git_dir(&repo_path)
282 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
283
284 Ok(has_rebase_metadata(&git_dir))
285 })
286 .await?
287}
288
289pub(crate) async fn in_progress_operation(
300 repo_path: PathBuf,
301) -> Result<Option<InProgressGitOperation>, GitError> {
302 spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
303}
304
305fn in_progress_operation_sync(
306 repo_path: &Path,
307) -> Result<Option<InProgressGitOperation>, GitError> {
308 let git_dir = resolve_git_dir(repo_path)
309 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
310 if has_rebase_metadata(&git_dir) {
311 return Ok(Some(InProgressGitOperation::Rebase));
312 }
313 if git_dir.join("MERGE_HEAD").exists() {
314 return Ok(Some(InProgressGitOperation::Merge));
315 }
316 if git_dir.join("CHERRY_PICK_HEAD").exists() {
317 return Ok(Some(InProgressGitOperation::CherryPick));
318 }
319 if git_dir.join("REVERT_HEAD").exists() {
320 return Ok(Some(InProgressGitOperation::Revert));
321 }
322
323 Ok(None)
324}
325
326fn has_rebase_metadata(git_dir: &Path) -> bool {
327 let rebase_merge = git_dir.join("rebase-merge");
328 let rebase_apply = git_dir.join("rebase-apply");
329
330 rebase_merge.exists() || rebase_apply.exists()
331}
332
333pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
344 let conflicted_files = list_conflicted_files(repo_path).await?;
345
346 Ok(!conflicted_files.is_empty())
347}
348
349pub(crate) async fn list_staged_conflict_marker_files(
373 repo_path: PathBuf,
374 paths: Vec<String>,
375) -> Result<Vec<String>, GitError> {
376 if paths.is_empty() {
377 return Ok(vec![]);
378 }
379
380 spawn_blocking(move || -> Result<Vec<String>, GitError> {
381 let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
382 let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
383 grep_arguments.extend(path_arguments);
384 let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
385
386 let exit_code = output.status.code().unwrap_or(2);
388 if !output.status.success() && exit_code != 1 {
389 let detail = command_output_detail(&output.stdout, &output.stderr);
390
391 return Err(GitError::CommandFailed {
392 command: "git grep".to_string(),
393 stderr: format!("Failed to check for staged conflict markers: {detail}"),
394 });
395 }
396
397 let files = String::from_utf8_lossy(&output.stdout)
398 .lines()
399 .map(str::trim)
400 .filter(|line| !line.is_empty())
401 .map(ToString::to_string)
402 .collect();
403
404 Ok(files)
405 })
406 .await?
407}
408
409pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
421 spawn_blocking(move || -> Result<Vec<String>, GitError> {
422 let output = run_git_command_sync(
423 &repo_path,
424 &["diff", "--name-only", "--diff-filter=U"],
425 "Failed to read conflicted files",
426 )?;
427 let files = output
428 .lines()
429 .map(str::trim)
430 .filter(|line| !line.is_empty())
431 .map(ToString::to_string)
432 .collect();
433
434 Ok(files)
435 })
436 .await?
437}
438
439fn run_rebase_step(
441 repo_path: &Path,
442 args: &[&str],
443 command: &str,
444 failure_message: impl FnOnce(&str) -> String,
445) -> Result<RebaseStepResult, GitError> {
446 let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
447
448 if output.status.success() {
449 return Ok(RebaseStepResult::Completed);
450 }
451
452 let detail = command_output_detail(&output.stdout, &output.stderr);
453 if is_rebase_conflict(&detail) {
454 return Ok(RebaseStepResult::Conflict { detail });
455 }
456
457 Err(GitError::CommandFailed {
458 command: command.to_string(),
459 stderr: failure_message(&detail),
460 })
461}
462
463pub(super) fn run_git_command_with_index_lock_retry(
465 repo_path: &Path,
466 args: &[&str],
467 environment: &[(&str, &str)],
468) -> Result<Output, GitError> {
469 let command_runner = ProcessGitCommandRunner;
470 let sleeper = ThreadSleeper;
471
472 run_git_command_with_index_lock_retry_with_dependencies(
473 repo_path,
474 args,
475 environment,
476 &command_runner,
477 &sleeper,
478 )
479}
480
481fn run_git_command_with_index_lock_retry_with_dependencies(
484 repo_path: &Path,
485 args: &[&str],
486 environment: &[(&str, &str)],
487 command_runner: &dyn GitCommandRunner,
488 sleeper: &dyn Sleeper,
489) -> Result<Output, GitError> {
490 let args = args
491 .iter()
492 .map(|arg| String::from(*arg))
493 .collect::<Vec<_>>();
494 let environment = environment
495 .iter()
496 .map(|(key, value)| (String::from(*key), String::from(*value)))
497 .collect::<Vec<_>>();
498
499 for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
500 let output =
501 command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
502 if output.status.success() {
503 return Ok(output);
504 }
505
506 let detail = command_output_detail(&output.stdout, &output.stderr);
507 let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
508 if !is_git_index_lock_error(&detail) || is_last_attempt {
509 return Ok(output);
510 }
511
512 sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
513 }
514
515 unreachable!("index lock retry loop should always return an output")
516}
517
518pub(super) fn is_rebase_conflict(detail: &str) -> bool {
524 detail.contains("CONFLICT")
525 || detail.contains("Resolve all conflicts manually")
526 || detail.contains("could not apply")
527 || detail.contains("mark them as resolved")
528 || detail.contains("unresolved conflict")
529 || detail.contains("Committing is not possible")
530}
531
532fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
534 let normalized_detail = detail.to_ascii_lowercase();
535
536 normalized_detail.contains("already a rebase-merge directory")
537 || normalized_detail.contains("already a rebase-apply directory")
538 || normalized_detail.contains("middle of another rebase")
539 || normalized_detail.contains("no rebase in progress")
540 || normalized_detail.contains("rebase-merge")
541 || normalized_detail.contains("rebase-apply")
542}
543
544fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
552 let git_dir = resolve_git_dir(repo_path)
553 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
554 let rebase_merge = git_dir.join("rebase-merge");
555 let rebase_apply = git_dir.join("rebase-apply");
556 let removed_rebase_merge = remove_stale_rebase_metadata_path(&rebase_merge)?;
557 let removed_rebase_apply = remove_stale_rebase_metadata_path(&rebase_apply)?;
558
559 Ok(removed_rebase_merge || removed_rebase_apply)
560}
561
562fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
568 if !path.exists() {
569 return Ok(false);
570 }
571
572 if path.is_dir() {
573 fs::remove_dir_all(path)?;
574
575 return Ok(true);
576 }
577
578 fs::remove_file(path)?;
579
580 Ok(true)
581}
582
583fn is_git_index_lock_error(detail: &str) -> bool {
585 let normalized_detail = detail.to_ascii_lowercase();
586
587 normalized_detail.contains("index.lock")
588 && (normalized_detail.contains("file exists")
589 || normalized_detail.contains("unable to create")
590 || normalized_detail.contains("another git process"))
591}
592
593#[cfg(test)]
594mod tests {
595 use std::fs;
596 use std::process::{Command, Output};
597
598 use mockall::predicate::eq;
599 use tempfile::tempdir;
600
601 use super::*;
602 use crate::MockSleeper;
603
604 #[test]
605 fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
606 let mut command_runner = MockGitCommandRunner::new();
608 let mut sleeper = MockSleeper::new();
609 let repo_path = Path::new(".");
610 let args = ["rebase", "main"];
611 let environment: [(&str, &str); 0] = [];
612
613 command_runner
614 .expect_run_git_command_output_with_env()
615 .times(1)
616 .returning(|_, _, _| Ok(git_index_lock_output()));
617 command_runner
618 .expect_run_git_command_output_with_env()
619 .times(1)
620 .returning(|_, _, _| Ok(success_output()));
621
622 sleeper
623 .expect_sleep()
624 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
625 .times(1)
626 .return_once(|_| {});
627
628 let output = run_git_command_with_index_lock_retry_with_dependencies(
630 repo_path,
631 &args,
632 &environment,
633 &command_runner,
634 &sleeper,
635 )
636 .expect("retry helper should return command output");
637
638 assert!(output.status.success());
640 }
641
642 #[test]
643 fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
644 let mut command_runner = MockGitCommandRunner::new();
646 let mut sleeper = MockSleeper::new();
647 let repo_path = Path::new(".");
648 let args = ["-c", "core.editor=true", "rebase", "main"];
649 let environment = [("GIT_EDITOR", "true")];
650
651 command_runner
652 .expect_run_git_command_output_with_env()
653 .withf(|repo_path, args, environment| {
654 repo_path == Path::new(".")
655 && args.iter().map(String::as_str).eq([
656 "-c",
657 "core.editor=true",
658 "rebase",
659 "main",
660 ])
661 && environment
662 .iter()
663 .map(|(key, value)| (key.as_str(), value.as_str()))
664 .eq([("GIT_EDITOR", "true")])
665 })
666 .times(1)
667 .returning(|_, _, _| Ok(success_output()));
668 sleeper.expect_sleep().times(0);
669
670 let output = run_git_command_with_index_lock_retry_with_dependencies(
672 repo_path,
673 &args,
674 &environment,
675 &command_runner,
676 &sleeper,
677 )
678 .expect("retry helper should return command output");
679
680 assert!(output.status.success());
682 }
683
684 #[test]
685 fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
686 let mut command_runner = MockGitCommandRunner::new();
688 let mut sleeper = MockSleeper::new();
689 let repo_path = Path::new(".");
690 let args = ["rebase", "main"];
691 let environment: [(&str, &str); 0] = [];
692
693 command_runner
694 .expect_run_git_command_output_with_env()
695 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
696 .returning(|_, _, _| Ok(git_index_lock_output()));
697 sleeper
698 .expect_sleep()
699 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
700 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
701 .returning(|_| {});
702
703 let output = run_git_command_with_index_lock_retry_with_dependencies(
705 repo_path,
706 &args,
707 &environment,
708 &command_runner,
709 &sleeper,
710 )
711 .expect("retry helper should return command output");
712
713 assert!(!output.status.success());
715 assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
716 }
717
718 #[test]
719 fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
720 let mut command_runner = MockGitCommandRunner::new();
722 let mut sleeper = MockSleeper::new();
723 let repo_path = Path::new(".");
724 let args = ["rebase", "main"];
725 let environment: [(&str, &str); 0] = [];
726
727 command_runner
728 .expect_run_git_command_output_with_env()
729 .times(1)
730 .return_once(|_, _, _| {
731 Err(GitError::CommandFailed {
732 command: "git".to_string(),
733 stderr: "git execution failed".to_string(),
734 })
735 });
736 sleeper.expect_sleep().times(0);
737
738 let error = run_git_command_with_index_lock_retry_with_dependencies(
740 repo_path,
741 &args,
742 &environment,
743 &command_runner,
744 &sleeper,
745 )
746 .expect_err("retry helper should surface command execution errors");
747
748 assert_eq!(error.to_string(), "git: git execution failed");
750 }
751
752 #[test]
753 fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
754 let mut command_runner = MockGitCommandRunner::new();
756 let mut sleeper = MockSleeper::new();
757 let repo_path = Path::new(".");
758 let args = ["rebase", "main"];
759 let environment: [(&str, &str); 0] = [];
760
761 command_runner
762 .expect_run_git_command_output_with_env()
763 .times(1)
764 .returning(|_, _, _| Ok(non_lock_failure_output()));
765 sleeper.expect_sleep().times(0);
766
767 let output = run_git_command_with_index_lock_retry_with_dependencies(
769 repo_path,
770 &args,
771 &environment,
772 &command_runner,
773 &sleeper,
774 )
775 .expect("retry helper should return command output");
776
777 assert!(!output.status.success());
779 }
780
781 #[test]
782 fn test_is_rebase_conflict_matches_unmerged_files_message() {
783 let detail = "Committing is not possible because you have unmerged files.";
785
786 let is_conflict = is_rebase_conflict(detail);
788
789 assert!(is_conflict);
791 }
792
793 #[test]
794 fn test_is_stale_or_inactive_rebase_error_matches_no_rebase_message() {
795 let detail = "fatal: No rebase in progress?";
797
798 let is_stale_metadata_error = is_stale_or_inactive_rebase_error(detail);
800
801 assert!(is_stale_metadata_error);
803 }
804
805 #[test]
806 fn test_in_progress_operation_detects_rebase_metadata() {
807 let temp_dir = tempdir().expect("tempdir should be created");
809 let git_dir = temp_dir.path().join(".git");
810 fs::create_dir(&git_dir).expect("git dir should be created");
811 fs::create_dir(git_dir.join("rebase-merge")).expect("rebase metadata should be created");
812
813 let operation =
815 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
816
817 assert_eq!(operation, Some(InProgressGitOperation::Rebase));
819 }
820
821 #[test]
822 fn test_in_progress_operation_detects_merge_metadata() {
823 let temp_dir = tempdir().expect("tempdir should be created");
825 let git_dir = temp_dir.path().join(".git");
826 fs::create_dir(&git_dir).expect("git dir should be created");
827 fs::write(git_dir.join("MERGE_HEAD"), "merge").expect("merge metadata should be created");
828
829 let operation =
831 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
832
833 assert_eq!(operation, Some(InProgressGitOperation::Merge));
835 }
836
837 #[test]
838 fn test_in_progress_operation_detects_cherry_pick_metadata() {
839 let temp_dir = tempdir().expect("tempdir should be created");
841 let git_dir = temp_dir.path().join(".git");
842 fs::create_dir(&git_dir).expect("git dir should be created");
843 fs::write(git_dir.join("CHERRY_PICK_HEAD"), "cherry-pick")
844 .expect("cherry-pick metadata should be created");
845
846 let operation =
848 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
849
850 assert_eq!(operation, Some(InProgressGitOperation::CherryPick));
852 }
853
854 #[test]
855 fn test_in_progress_operation_detects_revert_metadata() {
856 let temp_dir = tempdir().expect("tempdir should be created");
858 let git_dir = temp_dir.path().join(".git");
859 fs::create_dir(&git_dir).expect("git dir should be created");
860 fs::write(git_dir.join("REVERT_HEAD"), "revert")
861 .expect("revert metadata should be created");
862
863 let operation =
865 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
866
867 assert_eq!(operation, Some(InProgressGitOperation::Revert));
869 }
870
871 #[test]
872 fn test_in_progress_operation_returns_none_for_clean_git_dir() {
873 let temp_dir = tempdir().expect("tempdir should be created");
875 fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
876
877 let operation =
879 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
880
881 assert_eq!(operation, None);
883 }
884
885 #[test]
886 fn test_clean_stale_rebase_metadata_removes_existing_paths() {
887 let temp_dir = tempdir().expect("tempdir should be created");
889 let git_dir = temp_dir.path().join(".git");
890 let rebase_merge = git_dir.join("rebase-merge");
891 let rebase_apply = git_dir.join("rebase-apply");
892
893 fs::create_dir(&git_dir).expect("git dir should be created");
894 fs::create_dir(&rebase_merge).expect("rebase-merge dir should be created");
895 fs::write(&rebase_apply, "apply state").expect("rebase-apply file should be created");
896
897 let cleaned = clean_stale_rebase_metadata(temp_dir.path())
899 .expect("stale metadata cleanup should succeed");
900
901 assert!(cleaned);
903 assert!(!rebase_merge.exists());
904 assert!(!rebase_apply.exists());
905 }
906
907 fn success_output() -> Output {
909 Command::new("git")
910 .arg("--version")
911 .output()
912 .expect("failed to run git --version")
913 }
914
915 fn git_index_lock_output() -> Output {
917 let mut output = Command::new("git")
918 .arg("definitely-invalid-subcommand")
919 .output()
920 .expect("failed to run git invalid command");
921 output.stdout = vec![];
922 output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
923
924 output
925 }
926
927 fn non_lock_failure_output() -> Output {
929 let mut output = Command::new("git")
930 .arg("definitely-invalid-subcommand")
931 .output()
932 .expect("failed to run git invalid command");
933 output.stdout = vec![];
934 output.stderr = b"fatal: not a git repository".to_vec();
935
936 output
937 }
938}