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 = 5;
17pub(super) const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
18
19#[cfg_attr(test, mockall::automock)]
21trait GitCommandRunner: Send + Sync {
22 fn run_git_command_output_with_env(
24 &self,
25 repo_path: &Path,
26 args: &[String],
27 environment: &[(String, String)],
28 ) -> Result<Output, GitError>;
29}
30
31#[cfg_attr(test, mockall::automock)]
33trait RebaseMetadataCleaner: Send + Sync {
34 fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError>;
36}
37
38struct FilesystemRebaseMetadataCleaner;
40
41impl RebaseMetadataCleaner for FilesystemRebaseMetadataCleaner {
42 fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError> {
43 clean_stale_rebase_metadata(repo_path)
44 }
45}
46
47struct ProcessGitCommandRunner;
49
50impl GitCommandRunner for ProcessGitCommandRunner {
51 fn run_git_command_output_with_env(
52 &self,
53 repo_path: &Path,
54 args: &[String],
55 environment: &[(String, String)],
56 ) -> Result<Output, GitError> {
57 let args = args.iter().map(String::as_str).collect::<Vec<_>>();
58 let environment = environment
59 .iter()
60 .map(|(key, value)| (key.as_str(), value.as_str()))
61 .collect::<Vec<_>>();
62
63 run_git_command_output_with_env_sync(repo_path, &args, &environment)
64 }
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
69pub enum RebaseStepResult {
70 Completed,
72 Conflict {
74 detail: String,
76 },
77}
78
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum InProgressGitOperation {
82 CherryPick,
84 Merge,
86 Rebase,
88 Revert,
90}
91
92impl InProgressGitOperation {
93 pub fn article_name(self) -> &'static str {
96 match self {
97 Self::CherryPick => "a cherry-pick",
98 Self::Merge => "a merge",
99 Self::Rebase => "a rebase",
100 Self::Revert => "a revert",
101 }
102 }
103
104 pub fn name(self) -> &'static str {
106 match self {
107 Self::CherryPick => "cherry-pick",
108 Self::Merge => "merge",
109 Self::Rebase => "rebase",
110 Self::Revert => "revert",
111 }
112 }
113}
114
115pub(crate) async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
131 match rebase_start(repo_path.clone(), target_branch.clone()).await? {
132 RebaseStepResult::Completed => Ok(()),
133 RebaseStepResult::Conflict { detail } => {
134 let abort_suffix = match abort_rebase(repo_path).await {
135 Ok(()) => String::new(),
136 Err(error) => format!(" {error}"),
137 };
138
139 Err(GitError::CommandFailed {
140 command: "git rebase".to_string(),
141 stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
142 })
143 }
144 }
145}
146
147pub(crate) async fn rebase_start(
162 repo_path: PathBuf,
163 target_branch: String,
164) -> Result<RebaseStepResult, GitError> {
165 spawn_blocking(move || {
166 let rebase_args = ["rebase", target_branch.as_str()];
167 run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
168 format!("Failed to rebase onto {target_branch}: {detail}.")
169 })
170 })
171 .await?
172}
173
174pub(crate) async fn rebase_onto_start(
191 repo_path: PathBuf,
192 new_base: String,
193 old_base: String,
194) -> Result<RebaseStepResult, GitError> {
195 spawn_blocking(move || {
196 let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
197 run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
198 format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
199 })
200 })
201 .await?
202}
203
204pub(crate) async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
216 spawn_blocking(move || {
217 let output = run_git_command_with_index_lock_retry(
218 &repo_path,
219 &["rebase", "--continue"],
220 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
221 )?;
222
223 if output.status.success() {
224 return Ok(RebaseStepResult::Completed);
225 }
226
227 let detail = command_output_detail(&output.stdout, &output.stderr);
228 if is_rebase_conflict(&detail) {
229 return Ok(RebaseStepResult::Conflict { detail });
230 }
231
232 Err(GitError::CommandFailed {
233 command: "git rebase --continue".to_string(),
234 stderr: format!("Failed to continue rebase: {detail}."),
235 })
236 })
237 .await?
238}
239
240pub(crate) async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
255 spawn_blocking(move || {
256 let command_runner = ProcessGitCommandRunner;
257 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
258 let sleeper = ThreadSleeper;
259
260 abort_rebase_with_dependencies(&repo_path, &command_runner, &sleeper, &metadata_cleaner)
261 })
262 .await?
263}
264
265pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
278 spawn_blocking(move || -> Result<bool, GitError> {
279 let git_dir = resolve_git_dir(&repo_path)
280 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
281
282 Ok(has_rebase_metadata(&git_dir))
283 })
284 .await?
285}
286
287pub(crate) async fn in_progress_operation(
298 repo_path: PathBuf,
299) -> Result<Option<InProgressGitOperation>, GitError> {
300 spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
301}
302
303fn in_progress_operation_sync(
304 repo_path: &Path,
305) -> Result<Option<InProgressGitOperation>, GitError> {
306 let git_dir = resolve_git_dir(repo_path)
307 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
308 if has_rebase_metadata(&git_dir) {
309 return Ok(Some(InProgressGitOperation::Rebase));
310 }
311 if git_dir.join("MERGE_HEAD").exists() {
312 return Ok(Some(InProgressGitOperation::Merge));
313 }
314 if git_dir.join("CHERRY_PICK_HEAD").exists() {
315 return Ok(Some(InProgressGitOperation::CherryPick));
316 }
317 if git_dir.join("REVERT_HEAD").exists() {
318 return Ok(Some(InProgressGitOperation::Revert));
319 }
320
321 Ok(None)
322}
323
324fn has_rebase_metadata(git_dir: &Path) -> bool {
325 let rebase_merge = git_dir.join("rebase-merge");
326 let rebase_apply = git_dir.join("rebase-apply");
327
328 rebase_merge.exists() || rebase_apply.exists()
329}
330
331pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
342 let conflicted_files = list_conflicted_files(repo_path).await?;
343
344 Ok(!conflicted_files.is_empty())
345}
346
347pub(crate) async fn list_staged_conflict_marker_files(
371 repo_path: PathBuf,
372 paths: Vec<String>,
373) -> Result<Vec<String>, GitError> {
374 if paths.is_empty() {
375 return Ok(vec![]);
376 }
377
378 spawn_blocking(move || -> Result<Vec<String>, GitError> {
379 let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
380 let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
381 grep_arguments.extend(path_arguments);
382 let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
383
384 let exit_code = output.status.code().unwrap_or(2);
386 if !output.status.success() && exit_code != 1 {
387 let detail = command_output_detail(&output.stdout, &output.stderr);
388
389 return Err(GitError::CommandFailed {
390 command: "git grep".to_string(),
391 stderr: format!("Failed to check for staged conflict markers: {detail}"),
392 });
393 }
394
395 let files = String::from_utf8_lossy(&output.stdout)
396 .lines()
397 .map(str::trim)
398 .filter(|line| !line.is_empty())
399 .map(ToString::to_string)
400 .collect();
401
402 Ok(files)
403 })
404 .await?
405}
406
407pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
419 spawn_blocking(move || -> Result<Vec<String>, GitError> {
420 let output = run_git_command_sync(
421 &repo_path,
422 &["diff", "--name-only", "--diff-filter=U"],
423 "Failed to read conflicted files",
424 )?;
425 let files = output
426 .lines()
427 .map(str::trim)
428 .filter(|line| !line.is_empty())
429 .map(ToString::to_string)
430 .collect();
431
432 Ok(files)
433 })
434 .await?
435}
436
437fn run_rebase_step(
439 repo_path: &Path,
440 args: &[&str],
441 command: &str,
442 failure_message: impl FnOnce(&str) -> String,
443) -> Result<RebaseStepResult, GitError> {
444 let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
445
446 if output.status.success() {
447 return Ok(RebaseStepResult::Completed);
448 }
449
450 let detail = command_output_detail(&output.stdout, &output.stderr);
451 if is_rebase_conflict(&detail) {
452 return Ok(RebaseStepResult::Conflict { detail });
453 }
454
455 Err(GitError::CommandFailed {
456 command: command.to_string(),
457 stderr: failure_message(&detail),
458 })
459}
460
461fn abort_rebase_with_dependencies(
463 repo_path: &Path,
464 command_runner: &dyn GitCommandRunner,
465 sleeper: &dyn Sleeper,
466 metadata_cleaner: &dyn RebaseMetadataCleaner,
467) -> Result<(), GitError> {
468 let output = run_git_command_with_index_lock_retry_with_dependencies(
469 repo_path,
470 &["rebase", "--abort"],
471 &[],
472 command_runner,
473 sleeper,
474 )?;
475 if !output.status.success() {
476 let detail = command_output_detail(&output.stdout, &output.stderr);
477 if is_stale_or_inactive_rebase_error(&detail) {
478 match metadata_cleaner.clean_stale_metadata(repo_path) {
479 Ok(true) => return Ok(()),
480 Ok(false) => {}
481 Err(cleanup_error) => {
482 return Err(GitError::CommandFailed {
483 command: "git rebase --abort".to_string(),
484 stderr: format!(
485 "Failed to abort rebase: {detail}. Stale rebase metadata cleanup \
486 failed: {cleanup_error}."
487 ),
488 });
489 }
490 }
491 }
492
493 return Err(GitError::CommandFailed {
494 command: "git rebase --abort".to_string(),
495 stderr: format!("Failed to abort rebase: {detail}."),
496 });
497 }
498
499 Ok(())
500}
501
502fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
504 let normalized_detail = detail.to_ascii_lowercase();
505
506 normalized_detail.contains("no rebase in progress")
507 || normalized_detail.contains("already a rebase-merge directory")
508 || normalized_detail.contains("already a rebase-apply directory")
509 || normalized_detail.contains("middle of another rebase")
510}
511
512fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
514 let git_dir = resolve_git_dir(repo_path)
515 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
516 let removed_rebase_merge = remove_stale_rebase_metadata_path(&git_dir.join("rebase-merge"))?;
517 let removed_rebase_apply = remove_stale_rebase_metadata_path(&git_dir.join("rebase-apply"))?;
518
519 Ok(removed_rebase_merge || removed_rebase_apply)
520}
521
522fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
524 let metadata = match fs::symlink_metadata(path) {
525 Ok(metadata) => metadata,
526 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
527 Err(error) => return Err(error.into()),
528 };
529
530 if metadata.file_type().is_dir() {
531 fs::remove_dir_all(path)?;
532 } else {
533 fs::remove_file(path)?;
534 }
535
536 Ok(true)
537}
538
539pub(super) fn run_git_command_with_index_lock_retry(
541 repo_path: &Path,
542 args: &[&str],
543 environment: &[(&str, &str)],
544) -> Result<Output, GitError> {
545 let command_runner = ProcessGitCommandRunner;
546 let sleeper = ThreadSleeper;
547
548 run_git_command_with_index_lock_retry_with_dependencies(
549 repo_path,
550 args,
551 environment,
552 &command_runner,
553 &sleeper,
554 )
555}
556
557fn run_git_command_with_index_lock_retry_with_dependencies(
560 repo_path: &Path,
561 args: &[&str],
562 environment: &[(&str, &str)],
563 command_runner: &dyn GitCommandRunner,
564 sleeper: &dyn Sleeper,
565) -> Result<Output, GitError> {
566 let args = args
567 .iter()
568 .map(|arg| String::from(*arg))
569 .collect::<Vec<_>>();
570 let environment = environment
571 .iter()
572 .map(|(key, value)| (String::from(*key), String::from(*value)))
573 .collect::<Vec<_>>();
574
575 for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
576 let output =
577 command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
578 if output.status.success() {
579 return Ok(output);
580 }
581
582 let detail = command_output_detail(&output.stdout, &output.stderr);
583 let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
584 if !is_git_index_lock_error(&detail) || is_last_attempt {
585 return Ok(output);
586 }
587
588 sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
589 }
590
591 unreachable!("index lock retry loop should always return an output")
592}
593
594pub(super) fn is_rebase_conflict(detail: &str) -> bool {
600 detail.contains("CONFLICT")
601 || detail.contains("Resolve all conflicts manually")
602 || detail.contains("could not apply")
603 || detail.contains("mark them as resolved")
604 || detail.contains("unresolved conflict")
605 || detail.contains("Committing is not possible")
606}
607
608pub(super) fn is_git_index_lock_error(detail: &str) -> bool {
610 let normalized_detail = detail.to_ascii_lowercase();
611
612 normalized_detail.contains("index.lock")
613 && (normalized_detail.contains("file exists")
614 || normalized_detail.contains("unable to create")
615 || normalized_detail.contains("another git process"))
616}
617
618#[cfg(test)]
619mod tests {
620 use std::fs;
621 #[cfg(unix)]
622 use std::os::unix::fs as unix_fs;
623 use std::process::{Command, Output};
624
625 use mockall::predicate::eq;
626 use tempfile::tempdir;
627
628 use super::*;
629 use crate::MockSleeper;
630
631 #[test]
632 fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
633 let mut command_runner = MockGitCommandRunner::new();
635 let mut sleeper = MockSleeper::new();
636 let repo_path = Path::new(".");
637 let args = ["rebase", "main"];
638 let environment: [(&str, &str); 0] = [];
639
640 command_runner
641 .expect_run_git_command_output_with_env()
642 .times(1)
643 .returning(|_, _, _| Ok(git_index_lock_output()));
644 command_runner
645 .expect_run_git_command_output_with_env()
646 .times(1)
647 .returning(|_, _, _| Ok(success_output()));
648
649 sleeper
650 .expect_sleep()
651 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
652 .times(1)
653 .return_once(|_| {});
654
655 let output = run_git_command_with_index_lock_retry_with_dependencies(
657 repo_path,
658 &args,
659 &environment,
660 &command_runner,
661 &sleeper,
662 )
663 .expect("retry helper should return command output");
664
665 assert!(output.status.success());
667 }
668
669 #[test]
670 fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
671 let mut command_runner = MockGitCommandRunner::new();
673 let mut sleeper = MockSleeper::new();
674 let repo_path = Path::new(".");
675 let args = ["-c", "core.editor=true", "rebase", "main"];
676 let environment = [("GIT_EDITOR", "true")];
677
678 command_runner
679 .expect_run_git_command_output_with_env()
680 .withf(|repo_path, args, environment| {
681 repo_path == Path::new(".")
682 && args.iter().map(String::as_str).eq([
683 "-c",
684 "core.editor=true",
685 "rebase",
686 "main",
687 ])
688 && environment
689 .iter()
690 .map(|(key, value)| (key.as_str(), value.as_str()))
691 .eq([("GIT_EDITOR", "true")])
692 })
693 .times(1)
694 .returning(|_, _, _| Ok(success_output()));
695 sleeper.expect_sleep().times(0);
696
697 let output = run_git_command_with_index_lock_retry_with_dependencies(
699 repo_path,
700 &args,
701 &environment,
702 &command_runner,
703 &sleeper,
704 )
705 .expect("retry helper should return command output");
706
707 assert!(output.status.success());
709 }
710
711 #[test]
712 fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
713 let mut command_runner = MockGitCommandRunner::new();
715 let mut sleeper = MockSleeper::new();
716 let repo_path = Path::new(".");
717 let args = ["rebase", "main"];
718 let environment: [(&str, &str); 0] = [];
719
720 command_runner
721 .expect_run_git_command_output_with_env()
722 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
723 .returning(|_, _, _| Ok(git_index_lock_output()));
724 sleeper
725 .expect_sleep()
726 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
727 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
728 .returning(|_| {});
729
730 let output = run_git_command_with_index_lock_retry_with_dependencies(
732 repo_path,
733 &args,
734 &environment,
735 &command_runner,
736 &sleeper,
737 )
738 .expect("retry helper should return command output");
739
740 assert!(!output.status.success());
742 assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
743 }
744
745 #[test]
746 fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
747 let mut command_runner = MockGitCommandRunner::new();
749 let mut sleeper = MockSleeper::new();
750 let repo_path = Path::new(".");
751 let args = ["rebase", "main"];
752 let environment: [(&str, &str); 0] = [];
753
754 command_runner
755 .expect_run_git_command_output_with_env()
756 .times(1)
757 .return_once(|_, _, _| {
758 Err(GitError::CommandFailed {
759 command: "git".to_string(),
760 stderr: "git execution failed".to_string(),
761 })
762 });
763 sleeper.expect_sleep().times(0);
764
765 let error = run_git_command_with_index_lock_retry_with_dependencies(
767 repo_path,
768 &args,
769 &environment,
770 &command_runner,
771 &sleeper,
772 )
773 .expect_err("retry helper should surface command execution errors");
774
775 assert_eq!(error.to_string(), "git: git execution failed");
777 }
778
779 #[test]
780 fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
781 let mut command_runner = MockGitCommandRunner::new();
783 let mut sleeper = MockSleeper::new();
784 let repo_path = Path::new(".");
785 let args = ["rebase", "main"];
786 let environment: [(&str, &str); 0] = [];
787
788 command_runner
789 .expect_run_git_command_output_with_env()
790 .times(1)
791 .returning(|_, _, _| Ok(non_lock_failure_output()));
792 sleeper.expect_sleep().times(0);
793
794 let output = run_git_command_with_index_lock_retry_with_dependencies(
796 repo_path,
797 &args,
798 &environment,
799 &command_runner,
800 &sleeper,
801 )
802 .expect("retry helper should return command output");
803
804 assert!(!output.status.success());
806 }
807
808 #[test]
809 fn test_is_rebase_conflict_matches_unmerged_files_message() {
810 let detail = "Committing is not possible because you have unmerged files.";
812
813 let is_conflict = is_rebase_conflict(detail);
815
816 assert!(is_conflict);
818 }
819
820 #[test]
821 fn abort_rebase_succeeds_through_injected_boundaries() {
822 let mut command_runner = MockGitCommandRunner::new();
824 let metadata_cleaner = MockRebaseMetadataCleaner::new();
825 let mut sleeper = MockSleeper::new();
826 command_runner
827 .expect_run_git_command_output_with_env()
828 .withf(|repo_path, args, environment| {
829 repo_path == Path::new("session-worktree")
830 && args == ["rebase", "--abort"]
831 && environment.is_empty()
832 })
833 .once()
834 .returning(|_, _, _| Ok(success_output()));
835 sleeper.expect_sleep().times(0);
836
837 let result = abort_rebase_with_dependencies(
839 Path::new("session-worktree"),
840 &command_runner,
841 &sleeper,
842 &metadata_cleaner,
843 );
844
845 assert!(result.is_ok());
847 }
848
849 #[test]
850 fn abort_rebase_preserves_command_runner_error() {
851 let mut command_runner = MockGitCommandRunner::new();
853 let metadata_cleaner = MockRebaseMetadataCleaner::new();
854 let mut sleeper = MockSleeper::new();
855 command_runner
856 .expect_run_git_command_output_with_env()
857 .once()
858 .return_once(|_, _, _| {
859 Err(GitError::CommandFailed {
860 command: "git rebase --abort".to_string(),
861 stderr: "failed to spawn git".to_string(),
862 })
863 });
864 sleeper.expect_sleep().times(0);
865
866 let error = abort_rebase_with_dependencies(
868 Path::new("session-worktree"),
869 &command_runner,
870 &sleeper,
871 &metadata_cleaner,
872 )
873 .expect_err("command runner failure should be preserved");
874
875 assert!(matches!(
877 error,
878 GitError::CommandFailed { command, stderr }
879 if command == "git rebase --abort" && stderr == "failed to spawn git"
880 ));
881 }
882
883 #[test]
884 fn abort_rebase_preserves_actionable_command_failure() {
885 let mut command_runner = MockGitCommandRunner::new();
887 let metadata_cleaner = MockRebaseMetadataCleaner::new();
888 let mut sleeper = MockSleeper::new();
889 command_runner
890 .expect_run_git_command_output_with_env()
891 .once()
892 .returning(|_, _, _| {
893 let mut output = non_lock_failure_output();
894 output.stderr = b"fatal: cannot open .git/rebase-merge/head-name".to_vec();
895
896 Ok(output)
897 });
898 sleeper.expect_sleep().times(0);
899
900 let error = abort_rebase_with_dependencies(
902 Path::new("session-worktree"),
903 &command_runner,
904 &sleeper,
905 &metadata_cleaner,
906 )
907 .expect_err("failed abort should preserve the git error");
908
909 assert!(matches!(
911 error,
912 GitError::CommandFailed { command, stderr }
913 if command == "git rebase --abort"
914 && stderr.contains(".git/rebase-merge/head-name")
915 ));
916 }
917
918 #[test]
919 fn abort_rebase_recovers_when_stale_metadata_is_removed() {
920 let temp_dir = tempdir().expect("tempdir should be created");
922 let git_dir = temp_dir.path().join(".git");
923 let stale_metadata = git_dir.join("rebase-merge");
924 fs::create_dir(&git_dir).expect("git dir should be created");
925 fs::create_dir(&stale_metadata).expect("stale metadata should be created");
926 let mut command_runner = MockGitCommandRunner::new();
927 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
928 let mut sleeper = MockSleeper::new();
929 command_runner
930 .expect_run_git_command_output_with_env()
931 .once()
932 .returning(|_, _, _| Ok(stale_rebase_failure_output()));
933 sleeper.expect_sleep().times(0);
934
935 let result = abort_rebase_with_dependencies(
937 temp_dir.path(),
938 &command_runner,
939 &sleeper,
940 &metadata_cleaner,
941 );
942
943 assert!(result.is_ok());
945 assert!(!stale_metadata.exists());
946 }
947
948 #[test]
949 fn abort_rebase_preserves_stale_error_when_no_metadata_is_removed() {
950 let mut command_runner = MockGitCommandRunner::new();
952 let mut metadata_cleaner = MockRebaseMetadataCleaner::new();
953 let mut sleeper = MockSleeper::new();
954 command_runner
955 .expect_run_git_command_output_with_env()
956 .once()
957 .returning(|_, _, _| Ok(stale_rebase_failure_output()));
958 metadata_cleaner
959 .expect_clean_stale_metadata()
960 .once()
961 .returning(|_| Ok(false));
962 sleeper.expect_sleep().times(0);
963
964 let error = abort_rebase_with_dependencies(
966 Path::new("session-worktree"),
967 &command_runner,
968 &sleeper,
969 &metadata_cleaner,
970 )
971 .expect_err("missing stale metadata should preserve the abort failure");
972
973 assert!(matches!(
975 error,
976 GitError::CommandFailed { command, stderr }
977 if command == "git rebase --abort" && stderr.contains("No rebase in progress")
978 ));
979 }
980
981 #[test]
982 fn abort_rebase_appends_stale_metadata_cleanup_failure() {
983 let mut command_runner = MockGitCommandRunner::new();
985 let mut metadata_cleaner = MockRebaseMetadataCleaner::new();
986 let mut sleeper = MockSleeper::new();
987 command_runner
988 .expect_run_git_command_output_with_env()
989 .once()
990 .returning(|_, _, _| Ok(stale_rebase_failure_output()));
991 metadata_cleaner
992 .expect_clean_stale_metadata()
993 .once()
994 .returning(|_| {
995 Err(GitError::Io(std::io::Error::new(
996 ErrorKind::PermissionDenied,
997 "metadata is read-only",
998 )))
999 });
1000 sleeper.expect_sleep().times(0);
1001
1002 let error = abort_rebase_with_dependencies(
1004 Path::new("session-worktree"),
1005 &command_runner,
1006 &sleeper,
1007 &metadata_cleaner,
1008 )
1009 .expect_err("cleanup failure should preserve both error contexts");
1010
1011 assert!(matches!(
1013 error,
1014 GitError::CommandFailed { command, stderr }
1015 if command == "git rebase --abort"
1016 && stderr.contains("No rebase in progress")
1017 && stderr.contains("metadata is read-only")
1018 ));
1019 }
1020
1021 #[test]
1022 fn stale_rebase_error_detection_matches_only_known_diagnostics() {
1023 let stale_diagnostics = [
1025 "fatal: No rebase in progress?",
1026 "fatal: It seems that there is already a rebase-merge directory",
1027 "fatal: It seems that there is already a rebase-apply directory",
1028 "fatal: It seems that I cannot tell whether you are in the middle of another rebase",
1029 ];
1030
1031 let stale_results = stale_diagnostics.map(is_stale_or_inactive_rebase_error);
1033 let unrelated_result =
1034 is_stale_or_inactive_rebase_error("fatal: cannot read rebase-merge/head-name");
1035
1036 assert!(stale_results.into_iter().all(|is_stale| is_stale));
1038 assert!(!unrelated_result);
1039 }
1040
1041 #[test]
1042 fn filesystem_metadata_cleaner_removes_exact_rebase_entries() {
1043 let temp_dir = tempdir().expect("tempdir should be created");
1045 let git_dir = temp_dir.path().join(".git");
1046 let rebase_merge = git_dir.join("rebase-merge");
1047 let rebase_apply = git_dir.join("rebase-apply");
1048 let unrelated_metadata = git_dir.join("MERGE_HEAD");
1049 fs::create_dir(&git_dir).expect("git dir should be created");
1050 fs::create_dir(&rebase_merge).expect("rebase-merge should be created");
1051 fs::write(rebase_merge.join("head-name"), "refs/heads/main")
1052 .expect("rebase-merge metadata should be written");
1053 fs::write(&rebase_apply, "apply state").expect("rebase-apply should be written");
1054 fs::write(&unrelated_metadata, "merge state").expect("merge metadata should be written");
1055 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1056
1057 let removed = metadata_cleaner
1059 .clean_stale_metadata(temp_dir.path())
1060 .expect("stale metadata cleanup should succeed");
1061
1062 assert!(removed);
1064 assert!(!rebase_merge.exists());
1065 assert!(!rebase_apply.exists());
1066 assert!(unrelated_metadata.exists());
1067 }
1068
1069 #[test]
1070 fn filesystem_metadata_cleaner_reports_no_change_without_rebase_entries() {
1071 let temp_dir = tempdir().expect("tempdir should be created");
1073 fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
1074 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1075
1076 let removed = metadata_cleaner
1078 .clean_stale_metadata(temp_dir.path())
1079 .expect("empty metadata cleanup should succeed");
1080
1081 assert!(!removed);
1083 }
1084
1085 #[test]
1086 fn filesystem_metadata_cleaner_rejects_missing_git_directory() {
1087 let temp_dir = tempdir().expect("tempdir should be created");
1089 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1090
1091 let error = metadata_cleaner
1093 .clean_stale_metadata(temp_dir.path())
1094 .expect_err("repository without git metadata should fail");
1095
1096 assert!(matches!(
1098 error,
1099 GitError::OutputParse(message) if message == "Failed to resolve git directory"
1100 ));
1101 }
1102
1103 #[test]
1104 fn remove_stale_metadata_preserves_non_not_found_io_error() {
1105 let temp_dir = tempdir().expect("tempdir should be created");
1107 let parent_file = temp_dir.path().join("parent-file");
1108 fs::write(&parent_file, "not a directory").expect("parent file should be written");
1109
1110 let error = remove_stale_rebase_metadata_path(&parent_file.join("rebase-merge"))
1112 .expect_err("non-directory parent should remain an I/O error");
1113
1114 assert!(matches!(error, GitError::Io(_)));
1116 }
1117
1118 #[cfg(unix)]
1119 #[test]
1120 fn filesystem_metadata_cleaner_does_not_follow_directory_symlink() {
1121 let temp_dir = tempdir().expect("tempdir should be created");
1123 let git_dir = temp_dir.path().join(".git");
1124 let external_dir = temp_dir.path().join("external-rebase-data");
1125 let external_marker = external_dir.join("marker");
1126 fs::create_dir(&git_dir).expect("git dir should be created");
1127 fs::create_dir(&external_dir).expect("external directory should be created");
1128 fs::write(&external_marker, "preserve").expect("external marker should be written");
1129 unix_fs::symlink(&external_dir, git_dir.join("rebase-merge"))
1130 .expect("metadata symlink should be created");
1131 let metadata_cleaner = FilesystemRebaseMetadataCleaner;
1132
1133 let removed = metadata_cleaner
1135 .clean_stale_metadata(temp_dir.path())
1136 .expect("symlink cleanup should succeed");
1137
1138 assert!(removed);
1140 assert!(!git_dir.join("rebase-merge").exists());
1141 assert!(external_marker.exists());
1142 }
1143
1144 #[test]
1145 fn test_in_progress_operation_detects_rebase_metadata() {
1146 let temp_dir = tempdir().expect("tempdir should be created");
1148 let git_dir = temp_dir.path().join(".git");
1149 fs::create_dir(&git_dir).expect("git dir should be created");
1150 fs::create_dir(git_dir.join("rebase-merge")).expect("rebase metadata should be created");
1151
1152 let operation =
1154 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1155
1156 assert_eq!(operation, Some(InProgressGitOperation::Rebase));
1158 }
1159
1160 #[test]
1161 fn test_in_progress_operation_detects_merge_metadata() {
1162 let temp_dir = tempdir().expect("tempdir should be created");
1164 let git_dir = temp_dir.path().join(".git");
1165 fs::create_dir(&git_dir).expect("git dir should be created");
1166 fs::write(git_dir.join("MERGE_HEAD"), "merge").expect("merge metadata should be created");
1167
1168 let operation =
1170 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1171
1172 assert_eq!(operation, Some(InProgressGitOperation::Merge));
1174 }
1175
1176 #[test]
1177 fn test_in_progress_operation_detects_cherry_pick_metadata() {
1178 let temp_dir = tempdir().expect("tempdir should be created");
1180 let git_dir = temp_dir.path().join(".git");
1181 fs::create_dir(&git_dir).expect("git dir should be created");
1182 fs::write(git_dir.join("CHERRY_PICK_HEAD"), "cherry-pick")
1183 .expect("cherry-pick metadata should be created");
1184
1185 let operation =
1187 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1188
1189 assert_eq!(operation, Some(InProgressGitOperation::CherryPick));
1191 }
1192
1193 #[test]
1194 fn test_in_progress_operation_detects_revert_metadata() {
1195 let temp_dir = tempdir().expect("tempdir should be created");
1197 let git_dir = temp_dir.path().join(".git");
1198 fs::create_dir(&git_dir).expect("git dir should be created");
1199 fs::write(git_dir.join("REVERT_HEAD"), "revert")
1200 .expect("revert metadata should be created");
1201
1202 let operation =
1204 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1205
1206 assert_eq!(operation, Some(InProgressGitOperation::Revert));
1208 }
1209
1210 #[test]
1211 fn test_in_progress_operation_returns_none_for_clean_git_dir() {
1212 let temp_dir = tempdir().expect("tempdir should be created");
1214 fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
1215
1216 let operation =
1218 in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
1219
1220 assert_eq!(operation, None);
1222 }
1223
1224 fn success_output() -> Output {
1226 Command::new("git")
1227 .arg("--version")
1228 .output()
1229 .expect("failed to run git --version")
1230 }
1231
1232 fn git_index_lock_output() -> Output {
1234 let mut output = Command::new("git")
1235 .arg("definitely-invalid-subcommand")
1236 .output()
1237 .expect("failed to run git invalid command");
1238 output.stdout = vec![];
1239 output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
1240
1241 output
1242 }
1243
1244 fn non_lock_failure_output() -> Output {
1246 let mut output = Command::new("git")
1247 .arg("definitely-invalid-subcommand")
1248 .output()
1249 .expect("failed to run git invalid command");
1250 output.stdout = vec![];
1251 output.stderr = b"fatal: not a git repository".to_vec();
1252
1253 output
1254 }
1255
1256 fn stale_rebase_failure_output() -> Output {
1258 let mut output = non_lock_failure_output();
1259 output.stderr = b"fatal: No rebase in progress?".to_vec();
1260
1261 output
1262 }
1263}