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> {
279 spawn_blocking(move || -> Result<bool, GitError> {
280 let git_dir = resolve_git_dir(&repo_path)
281 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
282
283 Ok(has_rebase_metadata(&git_dir))
284 })
285 .await?
286}
287
288pub(crate) async fn in_progress_operation(
299 repo_path: PathBuf,
300) -> Result<Option<InProgressGitOperation>, GitError> {
301 spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
302}
303
304fn in_progress_operation_sync(
305 repo_path: &Path,
306) -> Result<Option<InProgressGitOperation>, GitError> {
307 let git_dir = resolve_git_dir(repo_path)
308 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
309 if has_rebase_metadata(&git_dir) {
310 return Ok(Some(InProgressGitOperation::Rebase));
311 }
312 if git_dir.join("MERGE_HEAD").exists() {
313 return Ok(Some(InProgressGitOperation::Merge));
314 }
315 if git_dir.join("CHERRY_PICK_HEAD").exists() {
316 return Ok(Some(InProgressGitOperation::CherryPick));
317 }
318 if git_dir.join("REVERT_HEAD").exists() {
319 return Ok(Some(InProgressGitOperation::Revert));
320 }
321
322 Ok(None)
323}
324
325fn has_rebase_metadata(git_dir: &Path) -> bool {
326 let rebase_merge = git_dir.join("rebase-merge");
327 let rebase_apply = git_dir.join("rebase-apply");
328
329 rebase_merge.exists() || rebase_apply.exists()
330}
331
332pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
343 let conflicted_files = list_conflicted_files(repo_path).await?;
344
345 Ok(!conflicted_files.is_empty())
346}
347
348pub(crate) async fn list_staged_conflict_marker_files(
372 repo_path: PathBuf,
373 paths: Vec<String>,
374) -> Result<Vec<String>, GitError> {
375 if paths.is_empty() {
376 return Ok(vec![]);
377 }
378
379 spawn_blocking(move || -> Result<Vec<String>, GitError> {
380 let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
381 let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
382 grep_arguments.extend(path_arguments);
383 let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
384
385 let exit_code = output.status.code().unwrap_or(2);
387 if !output.status.success() && exit_code != 1 {
388 let detail = command_output_detail(&output.stdout, &output.stderr);
389
390 return Err(GitError::CommandFailed {
391 command: "git grep".to_string(),
392 stderr: format!("Failed to check for staged conflict markers: {detail}"),
393 });
394 }
395
396 let files = String::from_utf8_lossy(&output.stdout)
397 .lines()
398 .map(str::trim)
399 .filter(|line| !line.is_empty())
400 .map(ToString::to_string)
401 .collect();
402
403 Ok(files)
404 })
405 .await?
406}
407
408pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
420 spawn_blocking(move || -> Result<Vec<String>, GitError> {
421 let output = run_git_command_sync(
422 &repo_path,
423 &["diff", "--name-only", "--diff-filter=U"],
424 "Failed to read conflicted files",
425 )?;
426 let files = output
427 .lines()
428 .map(str::trim)
429 .filter(|line| !line.is_empty())
430 .map(ToString::to_string)
431 .collect();
432
433 Ok(files)
434 })
435 .await?
436}
437
438fn run_rebase_step(
440 repo_path: &Path,
441 args: &[&str],
442 command: &str,
443 failure_message: impl FnOnce(&str) -> String,
444) -> Result<RebaseStepResult, GitError> {
445 let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
446
447 if output.status.success() {
448 return Ok(RebaseStepResult::Completed);
449 }
450
451 let detail = command_output_detail(&output.stdout, &output.stderr);
452 if is_rebase_conflict(&detail) {
453 return Ok(RebaseStepResult::Conflict { detail });
454 }
455
456 Err(GitError::CommandFailed {
457 command: command.to_string(),
458 stderr: failure_message(&detail),
459 })
460}
461
462fn abort_rebase_with_dependencies(
464 repo_path: &Path,
465 command_runner: &dyn GitCommandRunner,
466 sleeper: &dyn Sleeper,
467 metadata_cleaner: &dyn RebaseMetadataCleaner,
468) -> Result<(), GitError> {
469 let output = run_git_command_with_index_lock_retry_with_dependencies(
470 repo_path,
471 &["rebase", "--abort"],
472 &[],
473 command_runner,
474 sleeper,
475 )?;
476 if !output.status.success() {
477 let detail = command_output_detail(&output.stdout, &output.stderr);
478 if is_stale_or_inactive_rebase_error(&detail) {
479 match metadata_cleaner.clean_stale_metadata(repo_path) {
480 Ok(true) => return Ok(()),
481 Ok(false) => {}
482 Err(cleanup_error) => {
483 return Err(GitError::CommandFailed {
484 command: "git rebase --abort".to_string(),
485 stderr: format!(
486 "Failed to abort rebase: {detail}. Stale rebase metadata cleanup \
487 failed: {cleanup_error}."
488 ),
489 });
490 }
491 }
492 }
493
494 return Err(GitError::CommandFailed {
495 command: "git rebase --abort".to_string(),
496 stderr: format!("Failed to abort rebase: {detail}."),
497 });
498 }
499
500 Ok(())
501}
502
503fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
505 let normalized_detail = detail.to_ascii_lowercase();
506
507 normalized_detail.contains("no rebase in progress")
508 || normalized_detail.contains("already a rebase-merge directory")
509 || normalized_detail.contains("already a rebase-apply directory")
510 || normalized_detail.contains("middle of another rebase")
511}
512
513fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
515 let git_dir = resolve_git_dir(repo_path)
516 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
517 let removed_rebase_merge = remove_stale_rebase_metadata_path(&git_dir.join("rebase-merge"))?;
518 let removed_rebase_apply = remove_stale_rebase_metadata_path(&git_dir.join("rebase-apply"))?;
519
520 Ok(removed_rebase_merge || removed_rebase_apply)
521}
522
523fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
525 let metadata = match fs::symlink_metadata(path) {
526 Ok(metadata) => metadata,
527 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
528 Err(error) => return Err(error.into()),
529 };
530
531 if metadata.file_type().is_dir() {
532 fs::remove_dir_all(path)?;
533 } else {
534 fs::remove_file(path)?;
535 }
536
537 Ok(true)
538}
539
540pub(super) fn run_git_command_with_index_lock_retry(
542 repo_path: &Path,
543 args: &[&str],
544 environment: &[(&str, &str)],
545) -> Result<Output, GitError> {
546 let command_runner = ProcessGitCommandRunner;
547 let sleeper = ThreadSleeper;
548
549 run_git_command_with_index_lock_retry_with_dependencies(
550 repo_path,
551 args,
552 environment,
553 &command_runner,
554 &sleeper,
555 )
556}
557
558fn run_git_command_with_index_lock_retry_with_dependencies(
561 repo_path: &Path,
562 args: &[&str],
563 environment: &[(&str, &str)],
564 command_runner: &dyn GitCommandRunner,
565 sleeper: &dyn Sleeper,
566) -> Result<Output, GitError> {
567 let args = args
568 .iter()
569 .map(|arg| String::from(*arg))
570 .collect::<Vec<_>>();
571 let environment = environment
572 .iter()
573 .map(|(key, value)| (String::from(*key), String::from(*value)))
574 .collect::<Vec<_>>();
575
576 for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
577 let output =
578 command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
579 if output.status.success() {
580 return Ok(output);
581 }
582
583 let detail = command_output_detail(&output.stdout, &output.stderr);
584 let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
585 if !is_git_index_lock_error(&detail) || is_last_attempt {
586 return Ok(output);
587 }
588
589 sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
590 }
591
592 unreachable!("index lock retry loop should always return an output")
593}
594
595pub(super) fn is_rebase_conflict(detail: &str) -> bool {
601 detail.contains("CONFLICT")
602 || detail.contains("Resolve all conflicts manually")
603 || detail.contains("could not apply")
604 || detail.contains("mark them as resolved")
605 || detail.contains("unresolved conflict")
606 || detail.contains("Committing is not possible")
607}
608
609pub(super) fn is_git_index_lock_error(detail: &str) -> bool {
611 let normalized_detail = detail.to_ascii_lowercase();
612
613 normalized_detail.contains("index.lock")
614 && (normalized_detail.contains("file exists")
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}