1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::process::Output;
5use std::time::Duration;
6
7use tokio::task::spawn_blocking;
8
9use super::error::GitError;
10use super::repo::{
11 command_output_detail, resolve_git_dir, run_git_command_output_sync,
12 run_git_command_output_with_env_sync, run_git_command_sync,
13};
14use crate::{Sleeper, ThreadSleeper};
15
16pub(super) const GIT_INDEX_LOCK_RETRY_ATTEMPTS: usize = 21;
18pub(super) const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
19
20#[cfg_attr(test, mockall::automock)]
22trait GitCommandRunner: Send + Sync {
23 fn run_git_command_output_with_env(
25 &self,
26 repo_path: &Path,
27 args: &[String],
28 environment: &[(String, String)],
29 ) -> Result<Output, GitError>;
30}
31
32#[cfg_attr(test, mockall::automock)]
34trait RebaseMetadataCleaner: Send + Sync {
35 fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError>;
37}
38
39struct FilesystemRebaseMetadataCleaner;
41
42impl RebaseMetadataCleaner for FilesystemRebaseMetadataCleaner {
43 fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError> {
44 clean_stale_rebase_metadata(repo_path)
45 }
46}
47
48struct ProcessGitCommandRunner;
50
51impl GitCommandRunner for ProcessGitCommandRunner {
52 fn run_git_command_output_with_env(
53 &self,
54 repo_path: &Path,
55 args: &[String],
56 environment: &[(String, String)],
57 ) -> Result<Output, GitError> {
58 let args = args.iter().map(String::as_str).collect::<Vec<_>>();
59 let environment = environment
60 .iter()
61 .map(|(key, value)| (key.as_str(), value.as_str()))
62 .collect::<Vec<_>>();
63
64 run_git_command_output_with_env_sync(repo_path, &args, &environment)
65 }
66}
67
68#[derive(Clone, Debug, Eq, PartialEq)]
70pub enum RebaseStepResult {
71 Completed,
73 Conflict {
75 detail: String,
77 },
78}
79
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum InProgressGitOperation {
83 CherryPick,
85 Merge,
87 Rebase,
89 Revert,
91}
92
93impl InProgressGitOperation {
94 pub fn article_name(self) -> &'static str {
97 match self {
98 Self::CherryPick => "a cherry-pick",
99 Self::Merge => "a merge",
100 Self::Rebase => "a rebase",
101 Self::Revert => "a revert",
102 }
103 }
104
105 pub fn name(self) -> &'static str {
107 match self {
108 Self::CherryPick => "cherry-pick",
109 Self::Merge => "merge",
110 Self::Rebase => "rebase",
111 Self::Revert => "revert",
112 }
113 }
114}
115
116pub(crate) async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
132 match rebase_start(repo_path.clone(), target_branch.clone()).await? {
133 RebaseStepResult::Completed => Ok(()),
134 RebaseStepResult::Conflict { detail } => {
135 let abort_suffix = match abort_rebase(repo_path).await {
136 Ok(()) => String::new(),
137 Err(error) => format!(" {error}"),
138 };
139
140 Err(GitError::CommandFailed {
141 command: "git rebase".to_string(),
142 stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
143 })
144 }
145 }
146}
147
148pub(crate) async fn rebase_start(
163 repo_path: PathBuf,
164 target_branch: String,
165) -> Result<RebaseStepResult, GitError> {
166 spawn_blocking(move || {
167 let rebase_args = ["rebase", target_branch.as_str()];
168 run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
169 format!("Failed to rebase onto {target_branch}: {detail}.")
170 })
171 })
172 .await?
173}
174
175pub(crate) async fn rebase_onto_start(
192 repo_path: PathBuf,
193 new_base: String,
194 old_base: String,
195) -> Result<RebaseStepResult, GitError> {
196 spawn_blocking(move || {
197 let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
198 run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
199 format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
200 })
201 })
202 .await?
203}
204
205pub(crate) async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
217 spawn_blocking(move || {
218 let output = run_git_command_with_index_lock_retry(
219 &repo_path,
220 &["rebase", "--continue"],
221 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
222 )?;
223
224 if output.status.success() {
225 return Ok(RebaseStepResult::Completed);
226 }
227
228 let detail = command_output_detail(&output.stdout, &output.stderr);
229 if is_rebase_conflict(&detail) {
230 return Ok(RebaseStepResult::Conflict { detail });
231 }
232
233 Err(GitError::CommandFailed {
234 command: "git rebase --continue".to_string(),
235 stderr: format!("Failed to continue rebase: {detail}."),
236 })
237 })
238 .await?
239}
240
241pub(crate) async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
256 spawn_blocking(move || {
257 let command_runner = ProcessGitCommandRunner;
258 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
259 let sleeper = ThreadSleeper;
260
261 abort_rebase_with_dependencies(&repo_path, &command_runner, &sleeper, &metadata_cleaner)
262 })
263 .await?
264}
265
266pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
280 spawn_blocking(move || -> Result<bool, GitError> {
281 match fs::metadata(&repo_path) {
282 Ok(_) => {}
283 Err(error) if error.kind() == ErrorKind::NotFound => {
284 return Err(GitError::RepositoryUnavailable {
285 detail: format!("Repository folder is missing: {}", repo_path.display()),
286 });
287 }
288 Err(error) => return Err(error.into()),
289 }
290 let git_dir = resolve_git_dir(&repo_path).ok_or_else(|| {
291 GitError::OutputParse(format!(
292 "Failed to resolve git directory for `{}`",
293 repo_path.display()
294 ))
295 })?;
296
297 Ok(has_rebase_metadata(&git_dir))
298 })
299 .await?
300}
301
302pub(crate) async fn in_progress_operation(
313 repo_path: PathBuf,
314) -> Result<Option<InProgressGitOperation>, GitError> {
315 spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
316}
317
318fn in_progress_operation_sync(
319 repo_path: &Path,
320) -> Result<Option<InProgressGitOperation>, GitError> {
321 let git_dir = resolve_git_dir(repo_path)
322 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
323 if has_rebase_metadata(&git_dir) {
324 return Ok(Some(InProgressGitOperation::Rebase));
325 }
326 if git_dir.join("MERGE_HEAD").exists() {
327 return Ok(Some(InProgressGitOperation::Merge));
328 }
329 if git_dir.join("CHERRY_PICK_HEAD").exists() {
330 return Ok(Some(InProgressGitOperation::CherryPick));
331 }
332 if git_dir.join("REVERT_HEAD").exists() {
333 return Ok(Some(InProgressGitOperation::Revert));
334 }
335
336 Ok(None)
337}
338
339fn has_rebase_metadata(git_dir: &Path) -> bool {
340 let rebase_merge = git_dir.join("rebase-merge");
341 let rebase_apply = git_dir.join("rebase-apply");
342
343 rebase_merge.exists() || rebase_apply.exists()
344}
345
346pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
357 let conflicted_files = list_conflicted_files(repo_path).await?;
358
359 Ok(!conflicted_files.is_empty())
360}
361
362pub(crate) async fn list_staged_conflict_marker_files(
386 repo_path: PathBuf,
387 paths: Vec<String>,
388) -> Result<Vec<String>, GitError> {
389 if paths.is_empty() {
390 return Ok(vec![]);
391 }
392
393 spawn_blocking(move || -> Result<Vec<String>, GitError> {
394 let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
395 let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
396 grep_arguments.extend(path_arguments);
397 let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
398
399 let exit_code = output.status.code().unwrap_or(2);
401 if !output.status.success() && exit_code != 1 {
402 let detail = command_output_detail(&output.stdout, &output.stderr);
403
404 return Err(GitError::CommandFailed {
405 command: "git grep".to_string(),
406 stderr: format!("Failed to check for staged conflict markers: {detail}"),
407 });
408 }
409
410 let files = String::from_utf8_lossy(&output.stdout)
411 .lines()
412 .map(str::trim)
413 .filter(|line| !line.is_empty())
414 .map(ToString::to_string)
415 .collect();
416
417 Ok(files)
418 })
419 .await?
420}
421
422pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
434 spawn_blocking(move || -> Result<Vec<String>, GitError> {
435 let output = run_git_command_sync(
436 &repo_path,
437 &["diff", "--name-only", "--diff-filter=U"],
438 "Failed to read conflicted files",
439 )?;
440 let files = output
441 .lines()
442 .map(str::trim)
443 .filter(|line| !line.is_empty())
444 .map(ToString::to_string)
445 .collect();
446
447 Ok(files)
448 })
449 .await?
450}
451
452fn run_rebase_step(
454 repo_path: &Path,
455 args: &[&str],
456 command: &str,
457 failure_message: impl FnOnce(&str) -> String,
458) -> Result<RebaseStepResult, GitError> {
459 let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
460
461 if output.status.success() {
462 return Ok(RebaseStepResult::Completed);
463 }
464
465 let detail = command_output_detail(&output.stdout, &output.stderr);
466 if is_rebase_conflict(&detail) {
467 return Ok(RebaseStepResult::Conflict { detail });
468 }
469
470 Err(GitError::CommandFailed {
471 command: command.to_string(),
472 stderr: failure_message(&detail),
473 })
474}
475
476fn abort_rebase_with_dependencies(
478 repo_path: &Path,
479 command_runner: &dyn GitCommandRunner,
480 sleeper: &dyn Sleeper,
481 metadata_cleaner: &dyn RebaseMetadataCleaner,
482) -> Result<(), GitError> {
483 let output = run_git_command_with_index_lock_retry_with_dependencies(
484 repo_path,
485 &["rebase", "--abort"],
486 &[],
487 command_runner,
488 sleeper,
489 )?;
490 if !output.status.success() {
491 let detail = command_output_detail(&output.stdout, &output.stderr);
492 if is_stale_or_inactive_rebase_error(&detail) {
493 match metadata_cleaner.clean_stale_metadata(repo_path) {
494 Ok(true) => return Ok(()),
495 Ok(false) => {}
496 Err(cleanup_error) => {
497 return Err(GitError::CommandFailed {
498 command: "git rebase --abort".to_string(),
499 stderr: format!(
500 "Failed to abort rebase: {detail}. Stale rebase metadata cleanup \
501 failed: {cleanup_error}."
502 ),
503 });
504 }
505 }
506 }
507
508 return Err(GitError::CommandFailed {
509 command: "git rebase --abort".to_string(),
510 stderr: format!("Failed to abort rebase: {detail}."),
511 });
512 }
513
514 Ok(())
515}
516
517fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
519 let normalized_detail = detail.to_ascii_lowercase();
520
521 normalized_detail.contains("no rebase in progress")
522 || normalized_detail.contains("already a rebase-merge directory")
523 || normalized_detail.contains("already a rebase-apply directory")
524 || normalized_detail.contains("middle of another rebase")
525}
526
527fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
529 let git_dir = resolve_git_dir(repo_path)
530 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
531 let removed_rebase_merge = remove_stale_rebase_metadata_path(&git_dir.join("rebase-merge"))?;
532 let removed_rebase_apply = remove_stale_rebase_metadata_path(&git_dir.join("rebase-apply"))?;
533
534 Ok(removed_rebase_merge || removed_rebase_apply)
535}
536
537fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
539 let metadata = match fs::symlink_metadata(path) {
540 Ok(metadata) => metadata,
541 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
542 Err(error) => return Err(error.into()),
543 };
544
545 if metadata.file_type().is_dir() {
546 fs::remove_dir_all(path)?;
547 } else {
548 fs::remove_file(path)?;
549 }
550
551 Ok(true)
552}
553
554pub(super) fn run_git_command_with_index_lock_retry(
556 repo_path: &Path,
557 args: &[&str],
558 environment: &[(&str, &str)],
559) -> Result<Output, GitError> {
560 let command_runner = ProcessGitCommandRunner;
561 let sleeper = ThreadSleeper;
562
563 run_git_command_with_index_lock_retry_with_dependencies(
564 repo_path,
565 args,
566 environment,
567 &command_runner,
568 &sleeper,
569 )
570}
571
572fn run_git_command_with_index_lock_retry_with_dependencies(
575 repo_path: &Path,
576 args: &[&str],
577 environment: &[(&str, &str)],
578 command_runner: &dyn GitCommandRunner,
579 sleeper: &dyn Sleeper,
580) -> Result<Output, GitError> {
581 let args = args
582 .iter()
583 .map(|arg| String::from(*arg))
584 .collect::<Vec<_>>();
585 let environment = environment
586 .iter()
587 .map(|(key, value)| (String::from(*key), String::from(*value)))
588 .collect::<Vec<_>>();
589
590 for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
591 let output =
592 command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
593 if output.status.success() {
594 return Ok(output);
595 }
596
597 let detail = command_output_detail(&output.stdout, &output.stderr);
598 let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
599 if !is_git_index_lock_error(&detail) || is_last_attempt {
600 return Ok(output);
601 }
602
603 sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
604 }
605
606 unreachable!("index lock retry loop should always return an output")
607}
608
609pub(super) fn is_rebase_conflict(detail: &str) -> bool {
615 detail.contains("CONFLICT")
616 || detail.contains("Resolve all conflicts manually")
617 || detail.contains("could not apply")
618 || detail.contains("mark them as resolved")
619 || detail.contains("unresolved conflict")
620 || detail.contains("Committing is not possible")
621}
622
623pub(super) fn is_git_index_lock_error(detail: &str) -> bool {
625 let normalized_detail = detail.to_ascii_lowercase();
626
627 normalized_detail.contains("index.lock")
628 && (normalized_detail.contains("file exists")
629 || normalized_detail.contains("another git process"))
630}
631
632#[cfg(test)]
633mod tests {
634 use std::fs;
635 #[cfg(unix)]
636 use std::os::unix::fs as unix_fs;
637 use std::process::{Command, Output};
638
639 use mockall::predicate::eq;
640 use tempfile::tempdir;
641
642 use super::*;
643 use crate::MockSleeper;
644
645 #[tokio::test]
646 async fn rebase_probe_reports_missing_repository() {
647 let directory = tempdir().expect("temporary directory should exist");
649 let missing = directory.path().join("removed-worktree");
650
651 let error = is_rebase_in_progress(missing.clone())
653 .await
654 .expect_err("missing worktree should be classified");
655
656 assert!(
658 matches!(error, GitError::RepositoryUnavailable { ref detail }
659 if detail.contains(&missing.display().to_string()))
660 );
661 }
662
663 #[tokio::test]
664 async fn rebase_probe_preserves_invalid_repository_and_io_errors() {
665 let directory = tempdir().expect("temporary directory should exist");
667 let parent_file = directory.path().join("file");
668 fs::write(&parent_file, "not a directory").expect("file should be written");
669
670 let invalid_repository = is_rebase_in_progress(directory.path().to_path_buf()).await;
672 let io_error = is_rebase_in_progress(parent_file.join("worktree")).await;
673
674 assert!(
676 matches!(invalid_repository, Err(GitError::OutputParse(message))
677 if message.contains(&directory.path().display().to_string()))
678 );
679 assert!(matches!(io_error, Err(GitError::Io(_))));
680 }
681
682 #[tokio::test]
683 async fn rebase_probe_detects_metadata_in_checkout_and_linked_worktree() {
684 let directory = tempdir().expect("temporary directory should exist");
686 let checkout = directory.path().join("checkout");
687 let linked = directory.path().join("linked");
688 let git_dir = checkout.join(".git");
689 fs::create_dir_all(&git_dir).expect("git directory should exist");
690 fs::create_dir(&linked).expect("worktree directory should exist");
691 fs::write(linked.join(".git"), "gitdir: ../checkout/.git\n")
692 .expect("gitdir file should be written");
693
694 assert!(
696 !is_rebase_in_progress(checkout.clone())
697 .await
698 .expect("probe should succeed")
699 );
700 for metadata in ["rebase-merge", "rebase-apply"] {
701 fs::create_dir(git_dir.join(metadata)).expect("rebase metadata should exist");
702 assert!(
703 is_rebase_in_progress(checkout.clone())
704 .await
705 .expect("probe should succeed")
706 );
707 assert!(
708 is_rebase_in_progress(linked.clone())
709 .await
710 .expect("probe should succeed")
711 );
712 fs::remove_dir(git_dir.join(metadata)).expect("rebase metadata should be removed");
713 }
714 }
715
716 #[test]
717 fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
718 let mut command_runner = MockGitCommandRunner::new();
720 let mut sleeper = MockSleeper::new();
721 let repo_path = Path::new(".");
722 let args = ["rebase", "main"];
723 let environment: [(&str, &str); 0] = [];
724
725 command_runner
726 .expect_run_git_command_output_with_env()
727 .times(1)
728 .returning(|_, _, _| Ok(git_index_lock_output()));
729 command_runner
730 .expect_run_git_command_output_with_env()
731 .times(1)
732 .returning(|_, _, _| Ok(success_output()));
733
734 sleeper
735 .expect_sleep()
736 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
737 .times(1)
738 .return_once(|_| {});
739
740 let output = run_git_command_with_index_lock_retry_with_dependencies(
742 repo_path,
743 &args,
744 &environment,
745 &command_runner,
746 &sleeper,
747 )
748 .expect("retry helper should return command output");
749
750 assert!(output.status.success());
752 }
753
754 #[test]
755 fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
756 let mut command_runner = MockGitCommandRunner::new();
758 let mut sleeper = MockSleeper::new();
759 let repo_path = Path::new(".");
760 let args = ["-c", "core.editor=true", "rebase", "main"];
761 let environment = [("GIT_EDITOR", "true")];
762
763 command_runner
764 .expect_run_git_command_output_with_env()
765 .withf(|repo_path, args, environment| {
766 repo_path == Path::new(".")
767 && args.iter().map(String::as_str).eq([
768 "-c",
769 "core.editor=true",
770 "rebase",
771 "main",
772 ])
773 && environment
774 .iter()
775 .map(|(key, value)| (key.as_str(), value.as_str()))
776 .eq([("GIT_EDITOR", "true")])
777 })
778 .times(1)
779 .returning(|_, _, _| Ok(success_output()));
780 sleeper.expect_sleep().times(0);
781
782 let output = run_git_command_with_index_lock_retry_with_dependencies(
784 repo_path,
785 &args,
786 &environment,
787 &command_runner,
788 &sleeper,
789 )
790 .expect("retry helper should return command output");
791
792 assert!(output.status.success());
794 }
795
796 #[test]
797 fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
798 let mut command_runner = MockGitCommandRunner::new();
800 let mut sleeper = MockSleeper::new();
801 let repo_path = Path::new(".");
802 let args = ["rebase", "main"];
803 let environment: [(&str, &str); 0] = [];
804
805 command_runner
806 .expect_run_git_command_output_with_env()
807 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
808 .returning(|_, _, _| Ok(git_index_lock_output()));
809 sleeper
810 .expect_sleep()
811 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
812 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
813 .returning(|_| {});
814
815 let output = run_git_command_with_index_lock_retry_with_dependencies(
817 repo_path,
818 &args,
819 &environment,
820 &command_runner,
821 &sleeper,
822 )
823 .expect("retry helper should return command output");
824
825 assert!(!output.status.success());
827 assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
828 }
829
830 #[test]
831 fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
832 let mut command_runner = MockGitCommandRunner::new();
834 let mut sleeper = MockSleeper::new();
835 let repo_path = Path::new(".");
836 let args = ["rebase", "main"];
837 let environment: [(&str, &str); 0] = [];
838
839 command_runner
840 .expect_run_git_command_output_with_env()
841 .times(1)
842 .return_once(|_, _, _| {
843 Err(GitError::CommandFailed {
844 command: "git".to_string(),
845 stderr: "git execution failed".to_string(),
846 })
847 });
848 sleeper.expect_sleep().times(0);
849
850 let error = run_git_command_with_index_lock_retry_with_dependencies(
852 repo_path,
853 &args,
854 &environment,
855 &command_runner,
856 &sleeper,
857 )
858 .expect_err("retry helper should surface command execution errors");
859
860 assert_eq!(error.to_string(), "git: git execution failed");
862 }
863
864 #[test]
865 fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
866 let mut command_runner = MockGitCommandRunner::new();
868 let mut sleeper = MockSleeper::new();
869 let repo_path = Path::new(".");
870 let args = ["rebase", "main"];
871 let environment: [(&str, &str); 0] = [];
872
873 command_runner
874 .expect_run_git_command_output_with_env()
875 .times(1)
876 .returning(|_, _, _| Ok(non_lock_failure_output()));
877 sleeper.expect_sleep().times(0);
878
879 let output = run_git_command_with_index_lock_retry_with_dependencies(
881 repo_path,
882 &args,
883 &environment,
884 &command_runner,
885 &sleeper,
886 )
887 .expect("retry helper should return command output");
888
889 assert!(!output.status.success());
891 }
892
893 #[test]
894 fn test_is_rebase_conflict_matches_unmerged_files_message() {
895 let detail = "Committing is not possible because you have unmerged files.";
897
898 let is_conflict = is_rebase_conflict(detail);
900
901 assert!(is_conflict);
903 }
904
905 #[test]
906 fn abort_rebase_succeeds_through_injected_boundaries() {
907 let mut command_runner = MockGitCommandRunner::new();
909 let metadata_cleaner = MockRebaseMetadataCleaner::new();
910 let mut sleeper = MockSleeper::new();
911 command_runner
912 .expect_run_git_command_output_with_env()
913 .withf(|repo_path, args, environment| {
914 repo_path == Path::new("session-worktree")
915 && args == ["rebase", "--abort"]
916 && environment.is_empty()
917 })
918 .once()
919 .returning(|_, _, _| Ok(success_output()));
920 sleeper.expect_sleep().times(0);
921
922 let result = abort_rebase_with_dependencies(
924 Path::new("session-worktree"),
925 &command_runner,
926 &sleeper,
927 &metadata_cleaner,
928 );
929
930 assert!(result.is_ok());
932 }
933
934 #[test]
935 fn abort_rebase_preserves_command_runner_error() {
936 let mut command_runner = MockGitCommandRunner::new();
938 let metadata_cleaner = MockRebaseMetadataCleaner::new();
939 let mut sleeper = MockSleeper::new();
940 command_runner
941 .expect_run_git_command_output_with_env()
942 .once()
943 .return_once(|_, _, _| {
944 Err(GitError::CommandFailed {
945 command: "git rebase --abort".to_string(),
946 stderr: "failed to spawn git".to_string(),
947 })
948 });
949 sleeper.expect_sleep().times(0);
950
951 let error = abort_rebase_with_dependencies(
953 Path::new("session-worktree"),
954 &command_runner,
955 &sleeper,
956 &metadata_cleaner,
957 )
958 .expect_err("command runner failure should be preserved");
959
960 assert!(matches!(
962 error,
963 GitError::CommandFailed { command, stderr }
964 if command == "git rebase --abort" && stderr == "failed to spawn git"
965 ));
966 }
967
968 #[test]
969 fn abort_rebase_preserves_actionable_command_failure() {
970 let mut command_runner = MockGitCommandRunner::new();
972 let metadata_cleaner = MockRebaseMetadataCleaner::new();
973 let mut sleeper = MockSleeper::new();
974 command_runner
975 .expect_run_git_command_output_with_env()
976 .once()
977 .returning(|_, _, _| {
978 let mut output = non_lock_failure_output();
979 output.stderr = b"fatal: cannot open .git/rebase-merge/head-name".to_vec();
980
981 Ok(output)
982 });
983 sleeper.expect_sleep().times(0);
984
985 let error = abort_rebase_with_dependencies(
987 Path::new("session-worktree"),
988 &command_runner,
989 &sleeper,
990 &metadata_cleaner,
991 )
992 .expect_err("failed abort should preserve the git error");
993
994 assert!(matches!(
996 error,
997 GitError::CommandFailed { command, stderr }
998 if command == "git rebase --abort"
999 && stderr.contains(".git/rebase-merge/head-name")
1000 ));
1001 }
1002
1003 #[test]
1004 fn abort_rebase_recovers_when_stale_metadata_is_removed() {
1005 let temp_dir = tempdir().expect("tempdir should be created");
1007 let git_dir = temp_dir.path().join(".git");
1008 let stale_metadata = git_dir.join("rebase-merge");
1009 fs::create_dir(&git_dir).expect("git dir should be created");
1010 fs::create_dir(&stale_metadata).expect("stale metadata should be created");
1011 let mut command_runner = MockGitCommandRunner::new();
1012 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1013 let mut sleeper = MockSleeper::new();
1014 command_runner
1015 .expect_run_git_command_output_with_env()
1016 .once()
1017 .returning(|_, _, _| Ok(stale_rebase_failure_output()));
1018 sleeper.expect_sleep().times(0);
1019
1020 let result = abort_rebase_with_dependencies(
1022 temp_dir.path(),
1023 &command_runner,
1024 &sleeper,
1025 &metadata_cleaner,
1026 );
1027
1028 assert!(result.is_ok());
1030 assert!(!stale_metadata.exists());
1031 }
1032
1033 #[test]
1034 fn abort_rebase_preserves_stale_error_when_no_metadata_is_removed() {
1035 let mut command_runner = MockGitCommandRunner::new();
1037 let mut metadata_cleaner = MockRebaseMetadataCleaner::new();
1038 let mut sleeper = MockSleeper::new();
1039 command_runner
1040 .expect_run_git_command_output_with_env()
1041 .once()
1042 .returning(|_, _, _| Ok(stale_rebase_failure_output()));
1043 metadata_cleaner
1044 .expect_clean_stale_metadata()
1045 .once()
1046 .returning(|_| Ok(false));
1047 sleeper.expect_sleep().times(0);
1048
1049 let error = abort_rebase_with_dependencies(
1051 Path::new("session-worktree"),
1052 &command_runner,
1053 &sleeper,
1054 &metadata_cleaner,
1055 )
1056 .expect_err("missing stale metadata should preserve the abort failure");
1057
1058 assert!(matches!(
1060 error,
1061 GitError::CommandFailed { command, stderr }
1062 if command == "git rebase --abort" && stderr.contains("No rebase in progress")
1063 ));
1064 }
1065
1066 #[test]
1067 fn abort_rebase_appends_stale_metadata_cleanup_failure() {
1068 let mut command_runner = MockGitCommandRunner::new();
1070 let mut metadata_cleaner = MockRebaseMetadataCleaner::new();
1071 let mut sleeper = MockSleeper::new();
1072 command_runner
1073 .expect_run_git_command_output_with_env()
1074 .once()
1075 .returning(|_, _, _| Ok(stale_rebase_failure_output()));
1076 metadata_cleaner
1077 .expect_clean_stale_metadata()
1078 .once()
1079 .returning(|_| {
1080 Err(GitError::Io(std::io::Error::new(
1081 ErrorKind::PermissionDenied,
1082 "metadata is read-only",
1083 )))
1084 });
1085 sleeper.expect_sleep().times(0);
1086
1087 let error = abort_rebase_with_dependencies(
1089 Path::new("session-worktree"),
1090 &command_runner,
1091 &sleeper,
1092 &metadata_cleaner,
1093 )
1094 .expect_err("cleanup failure should preserve both error contexts");
1095
1096 assert!(matches!(
1098 error,
1099 GitError::CommandFailed { command, stderr }
1100 if command == "git rebase --abort"
1101 && stderr.contains("No rebase in progress")
1102 && stderr.contains("metadata is read-only")
1103 ));
1104 }
1105
1106 #[test]
1107 fn stale_rebase_error_detection_matches_only_known_diagnostics() {
1108 let stale_diagnostics = [
1110 "fatal: No rebase in progress?",
1111 "fatal: It seems that there is already a rebase-merge directory",
1112 "fatal: It seems that there is already a rebase-apply directory",
1113 "fatal: It seems that I cannot tell whether you are in the middle of another rebase",
1114 ];
1115
1116 let stale_results = stale_diagnostics.map(is_stale_or_inactive_rebase_error);
1118 let unrelated_result =
1119 is_stale_or_inactive_rebase_error("fatal: cannot read rebase-merge/head-name");
1120
1121 assert!(stale_results.into_iter().all(|is_stale| is_stale));
1123 assert!(!unrelated_result);
1124 }
1125
1126 #[test]
1127 fn filesystem_metadata_cleaner_removes_exact_rebase_entries() {
1128 let temp_dir = tempdir().expect("tempdir should be created");
1130 let git_dir = temp_dir.path().join(".git");
1131 let rebase_merge = git_dir.join("rebase-merge");
1132 let rebase_apply = git_dir.join("rebase-apply");
1133 let unrelated_metadata = git_dir.join("MERGE_HEAD");
1134 fs::create_dir(&git_dir).expect("git dir should be created");
1135 fs::create_dir(&rebase_merge).expect("rebase-merge should be created");
1136 fs::write(rebase_merge.join("head-name"), "refs/heads/main")
1137 .expect("rebase-merge metadata should be written");
1138 fs::write(&rebase_apply, "apply state").expect("rebase-apply should be written");
1139 fs::write(&unrelated_metadata, "merge state").expect("merge metadata should be written");
1140 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1141
1142 let removed = metadata_cleaner
1144 .clean_stale_metadata(temp_dir.path())
1145 .expect("stale metadata cleanup should succeed");
1146
1147 assert!(removed);
1149 assert!(!rebase_merge.exists());
1150 assert!(!rebase_apply.exists());
1151 assert!(unrelated_metadata.exists());
1152 }
1153
1154 #[test]
1155 fn filesystem_metadata_cleaner_reports_no_change_without_rebase_entries() {
1156 let temp_dir = tempdir().expect("tempdir should be created");
1158 fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
1159 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1160
1161 let removed = metadata_cleaner
1163 .clean_stale_metadata(temp_dir.path())
1164 .expect("empty metadata cleanup should succeed");
1165
1166 assert!(!removed);
1168 }
1169
1170 #[test]
1171 fn filesystem_metadata_cleaner_rejects_missing_git_directory() {
1172 let temp_dir = tempdir().expect("tempdir should be created");
1174 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1175
1176 let error = metadata_cleaner
1178 .clean_stale_metadata(temp_dir.path())
1179 .expect_err("repository without git metadata should fail");
1180
1181 assert!(matches!(
1183 error,
1184 GitError::OutputParse(message) if message == "Failed to resolve git directory"
1185 ));
1186 }
1187
1188 #[test]
1189 fn remove_stale_metadata_preserves_non_not_found_io_error() {
1190 let temp_dir = tempdir().expect("tempdir should be created");
1192 let parent_file = temp_dir.path().join("parent-file");
1193 fs::write(&parent_file, "not a directory").expect("parent file should be written");
1194
1195 let error = remove_stale_rebase_metadata_path(&parent_file.join("rebase-merge"))
1197 .expect_err("non-directory parent should remain an I/O error");
1198
1199 assert!(matches!(error, GitError::Io(_)));
1201 }
1202
1203 #[cfg(unix)]
1204 #[test]
1205 fn filesystem_metadata_cleaner_does_not_follow_directory_symlink() {
1206 let temp_dir = tempdir().expect("tempdir should be created");
1208 let git_dir = temp_dir.path().join(".git");
1209 let external_dir = temp_dir.path().join("external-rebase-data");
1210 let external_marker = external_dir.join("marker");
1211 fs::create_dir(&git_dir).expect("git dir should be created");
1212 fs::create_dir(&external_dir).expect("external directory should be created");
1213 fs::write(&external_marker, "preserve").expect("external marker should be written");
1214 unix_fs::symlink(&external_dir, git_dir.join("rebase-merge"))
1215 .expect("metadata symlink should be created");
1216 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1217
1218 let removed = metadata_cleaner
1220 .clean_stale_metadata(temp_dir.path())
1221 .expect("symlink cleanup should succeed");
1222
1223 assert!(removed);
1225 assert!(!git_dir.join("rebase-merge").exists());
1226 assert!(external_marker.exists());
1227 }
1228
1229 #[test]
1230 fn test_in_progress_operation_detects_rebase_metadata() {
1231 let temp_dir = tempdir().expect("tempdir should be created");
1233 let git_dir = temp_dir.path().join(".git");
1234 fs::create_dir(&git_dir).expect("git dir should be created");
1235 fs::create_dir(git_dir.join("rebase-merge")).expect("rebase metadata should be created");
1236
1237 let operation =
1239 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1240
1241 assert_eq!(operation, Some(InProgressGitOperation::Rebase));
1243 }
1244
1245 #[test]
1246 fn test_in_progress_operation_detects_merge_metadata() {
1247 let temp_dir = tempdir().expect("tempdir should be created");
1249 let git_dir = temp_dir.path().join(".git");
1250 fs::create_dir(&git_dir).expect("git dir should be created");
1251 fs::write(git_dir.join("MERGE_HEAD"), "merge").expect("merge metadata should be created");
1252
1253 let operation =
1255 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1256
1257 assert_eq!(operation, Some(InProgressGitOperation::Merge));
1259 }
1260
1261 #[test]
1262 fn test_in_progress_operation_detects_cherry_pick_metadata() {
1263 let temp_dir = tempdir().expect("tempdir should be created");
1265 let git_dir = temp_dir.path().join(".git");
1266 fs::create_dir(&git_dir).expect("git dir should be created");
1267 fs::write(git_dir.join("CHERRY_PICK_HEAD"), "cherry-pick")
1268 .expect("cherry-pick metadata should be created");
1269
1270 let operation =
1272 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1273
1274 assert_eq!(operation, Some(InProgressGitOperation::CherryPick));
1276 }
1277
1278 #[test]
1279 fn test_in_progress_operation_detects_revert_metadata() {
1280 let temp_dir = tempdir().expect("tempdir should be created");
1282 let git_dir = temp_dir.path().join(".git");
1283 fs::create_dir(&git_dir).expect("git dir should be created");
1284 fs::write(git_dir.join("REVERT_HEAD"), "revert")
1285 .expect("revert metadata should be created");
1286
1287 let operation =
1289 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1290
1291 assert_eq!(operation, Some(InProgressGitOperation::Revert));
1293 }
1294
1295 #[test]
1296 fn test_in_progress_operation_returns_none_for_clean_git_dir() {
1297 let temp_dir = tempdir().expect("tempdir should be created");
1299 fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
1300
1301 let operation =
1303 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1304
1305 assert_eq!(operation, None);
1307 }
1308
1309 fn success_output() -> Output {
1311 Command::new("git")
1312 .arg("--version")
1313 .output()
1314 .expect("failed to run git --version")
1315 }
1316
1317 fn git_index_lock_output() -> Output {
1319 let mut output = Command::new("git")
1320 .arg("definitely-invalid-subcommand")
1321 .output()
1322 .expect("failed to run git invalid command");
1323 output.stdout = vec![];
1324 output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
1325
1326 output
1327 }
1328
1329 fn non_lock_failure_output() -> Output {
1331 let mut output = Command::new("git")
1332 .arg("definitely-invalid-subcommand")
1333 .output()
1334 .expect("failed to run git invalid command");
1335 output.stdout = vec![];
1336 output.stderr = b"fatal: not a git repository".to_vec();
1337
1338 output
1339 }
1340
1341 fn stale_rebase_failure_output() -> Output {
1343 let mut output = non_lock_failure_output();
1344 output.stderr = b"fatal: No rebase in progress?".to_vec();
1345
1346 output
1347 }
1348}