1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Output;
4use std::time::Duration;
5
6use tokio::task::spawn_blocking;
7
8use super::error::GitError;
9use super::repo::{
10 command_output_detail, resolve_git_dir, run_git_command_output_sync,
11 run_git_command_output_with_env_sync, run_git_command_sync,
12};
13use crate::{Sleeper, ThreadSleeper};
14
15const GIT_INDEX_LOCK_RETRY_ATTEMPTS: usize = 5;
16const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
17
18#[cfg_attr(test, mockall::automock)]
20trait GitCommandRunner: Send + Sync {
21 fn run_git_command_output_with_env(
23 &self,
24 repo_path: &Path,
25 args: &[String],
26 environment: &[(String, String)],
27 ) -> Result<Output, GitError>;
28}
29
30struct ProcessGitCommandRunner;
32
33impl GitCommandRunner for ProcessGitCommandRunner {
34 fn run_git_command_output_with_env(
35 &self,
36 repo_path: &Path,
37 args: &[String],
38 environment: &[(String, String)],
39 ) -> Result<Output, GitError> {
40 let args = args.iter().map(String::as_str).collect::<Vec<_>>();
41 let environment = environment
42 .iter()
43 .map(|(key, value)| (key.as_str(), value.as_str()))
44 .collect::<Vec<_>>();
45
46 run_git_command_output_with_env_sync(repo_path, &args, &environment)
47 }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
52pub enum RebaseStepResult {
53 Completed,
55 Conflict { detail: String },
57}
58
59pub async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
75 match rebase_start(repo_path.clone(), target_branch.clone()).await? {
76 RebaseStepResult::Completed => Ok(()),
77 RebaseStepResult::Conflict { detail } => {
78 let abort_suffix = match abort_rebase(repo_path).await {
79 Ok(()) => String::new(),
80 Err(error) => format!(" {error}"),
81 };
82
83 Err(GitError::CommandFailed {
84 command: "git rebase".to_string(),
85 stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
86 })
87 }
88 }
89}
90
91pub async fn rebase_start(
106 repo_path: PathBuf,
107 target_branch: String,
108) -> Result<RebaseStepResult, GitError> {
109 spawn_blocking(move || {
110 let rebase_args = ["rebase", target_branch.as_str()];
111 run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
112 format!("Failed to rebase onto {target_branch}: {detail}.")
113 })
114 })
115 .await?
116}
117
118pub async fn rebase_onto_start(
135 repo_path: PathBuf,
136 new_base: String,
137 old_base: String,
138) -> Result<RebaseStepResult, GitError> {
139 spawn_blocking(move || {
140 let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
141 run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
142 format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
143 })
144 })
145 .await?
146}
147
148pub async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
160 spawn_blocking(move || {
161 let output = run_git_command_with_index_lock_retry(
162 &repo_path,
163 &["rebase", "--continue"],
164 &[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
165 )?;
166
167 if output.status.success() {
168 return Ok(RebaseStepResult::Completed);
169 }
170
171 let detail = command_output_detail(&output.stdout, &output.stderr);
172 if is_rebase_conflict(&detail) {
173 return Ok(RebaseStepResult::Conflict { detail });
174 }
175
176 Err(GitError::CommandFailed {
177 command: "git rebase --continue".to_string(),
178 stderr: format!("Failed to continue rebase: {detail}."),
179 })
180 })
181 .await?
182}
183
184pub async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
199 spawn_blocking(move || {
200 let output =
201 run_git_command_with_index_lock_retry(&repo_path, &["rebase", "--abort"], &[])?;
202
203 if !output.status.success() {
204 let detail = command_output_detail(&output.stdout, &output.stderr);
205 if !is_stale_or_inactive_rebase_error(&detail) {
206 return Err(GitError::CommandFailed {
207 command: "git rebase --abort".to_string(),
208 stderr: format!("Failed to abort rebase: {detail}."),
209 });
210 }
211
212 let cleaned_stale_metadata = clean_stale_rebase_metadata(&repo_path)?;
213 if cleaned_stale_metadata {
214 return Ok(());
215 }
216
217 return Err(GitError::CommandFailed {
218 command: "git rebase --abort".to_string(),
219 stderr: format!("Failed to abort rebase: {detail}."),
220 });
221 }
222
223 Ok(())
224 })
225 .await?
226}
227
228pub async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
241 spawn_blocking(move || -> Result<bool, GitError> {
242 let git_dir = resolve_git_dir(&repo_path)
243 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
244 let rebase_merge = git_dir.join("rebase-merge");
245 let rebase_apply = git_dir.join("rebase-apply");
246
247 Ok(rebase_merge.exists() || rebase_apply.exists())
248 })
249 .await?
250}
251
252pub async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
263 let conflicted_files = list_conflicted_files(repo_path).await?;
264
265 Ok(!conflicted_files.is_empty())
266}
267
268pub async fn list_staged_conflict_marker_files(
292 repo_path: PathBuf,
293 paths: Vec<String>,
294) -> Result<Vec<String>, GitError> {
295 if paths.is_empty() {
296 return Ok(vec![]);
297 }
298
299 spawn_blocking(move || -> Result<Vec<String>, GitError> {
300 let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
301 let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
302 grep_arguments.extend(path_arguments);
303 let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
304
305 let exit_code = output.status.code().unwrap_or(2);
307 if !output.status.success() && exit_code != 1 {
308 let detail = command_output_detail(&output.stdout, &output.stderr);
309
310 return Err(GitError::CommandFailed {
311 command: "git grep".to_string(),
312 stderr: format!("Failed to check for staged conflict markers: {detail}"),
313 });
314 }
315
316 let files = String::from_utf8_lossy(&output.stdout)
317 .lines()
318 .map(str::trim)
319 .filter(|line| !line.is_empty())
320 .map(ToString::to_string)
321 .collect();
322
323 Ok(files)
324 })
325 .await?
326}
327
328pub async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
340 spawn_blocking(move || -> Result<Vec<String>, GitError> {
341 let output = run_git_command_sync(
342 &repo_path,
343 &["diff", "--name-only", "--diff-filter=U"],
344 "Failed to read conflicted files",
345 )?;
346 let files = output
347 .lines()
348 .map(str::trim)
349 .filter(|line| !line.is_empty())
350 .map(ToString::to_string)
351 .collect();
352
353 Ok(files)
354 })
355 .await?
356}
357
358fn run_rebase_step(
360 repo_path: &Path,
361 args: &[&str],
362 command: &str,
363 failure_message: impl FnOnce(&str) -> String,
364) -> Result<RebaseStepResult, GitError> {
365 let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
366
367 if output.status.success() {
368 return Ok(RebaseStepResult::Completed);
369 }
370
371 let detail = command_output_detail(&output.stdout, &output.stderr);
372 if is_rebase_conflict(&detail) {
373 return Ok(RebaseStepResult::Conflict { detail });
374 }
375
376 Err(GitError::CommandFailed {
377 command: command.to_string(),
378 stderr: failure_message(&detail),
379 })
380}
381
382pub(super) fn run_git_command_with_index_lock_retry(
384 repo_path: &Path,
385 args: &[&str],
386 environment: &[(&str, &str)],
387) -> Result<Output, GitError> {
388 let command_runner = ProcessGitCommandRunner;
389 let sleeper = ThreadSleeper;
390
391 run_git_command_with_index_lock_retry_with_dependencies(
392 repo_path,
393 args,
394 environment,
395 &command_runner,
396 &sleeper,
397 )
398}
399
400fn run_git_command_with_index_lock_retry_with_dependencies(
403 repo_path: &Path,
404 args: &[&str],
405 environment: &[(&str, &str)],
406 command_runner: &dyn GitCommandRunner,
407 sleeper: &dyn Sleeper,
408) -> Result<Output, GitError> {
409 let args = args
410 .iter()
411 .map(|arg| String::from(*arg))
412 .collect::<Vec<_>>();
413 let environment = environment
414 .iter()
415 .map(|(key, value)| (String::from(*key), String::from(*value)))
416 .collect::<Vec<_>>();
417
418 for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
419 let output =
420 command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
421 if output.status.success() {
422 return Ok(output);
423 }
424
425 let detail = command_output_detail(&output.stdout, &output.stderr);
426 let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
427 if !is_git_index_lock_error(&detail) || is_last_attempt {
428 return Ok(output);
429 }
430
431 sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
432 }
433
434 unreachable!("index lock retry loop should always return an output")
435}
436
437pub(super) fn is_rebase_conflict(detail: &str) -> bool {
443 detail.contains("CONFLICT")
444 || detail.contains("Resolve all conflicts manually")
445 || detail.contains("could not apply")
446 || detail.contains("mark them as resolved")
447 || detail.contains("unresolved conflict")
448 || detail.contains("Committing is not possible")
449}
450
451fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
453 let normalized_detail = detail.to_ascii_lowercase();
454
455 normalized_detail.contains("already a rebase-merge directory")
456 || normalized_detail.contains("already a rebase-apply directory")
457 || normalized_detail.contains("middle of another rebase")
458 || normalized_detail.contains("no rebase in progress")
459 || normalized_detail.contains("rebase-merge")
460 || normalized_detail.contains("rebase-apply")
461}
462
463fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
471 let git_dir = resolve_git_dir(repo_path)
472 .ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
473 let rebase_merge = git_dir.join("rebase-merge");
474 let rebase_apply = git_dir.join("rebase-apply");
475 let removed_rebase_merge = remove_stale_rebase_metadata_path(&rebase_merge)?;
476 let removed_rebase_apply = remove_stale_rebase_metadata_path(&rebase_apply)?;
477
478 Ok(removed_rebase_merge || removed_rebase_apply)
479}
480
481fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
487 if !path.exists() {
488 return Ok(false);
489 }
490
491 if path.is_dir() {
492 fs::remove_dir_all(path)?;
493
494 return Ok(true);
495 }
496
497 fs::remove_file(path)?;
498
499 Ok(true)
500}
501
502fn is_git_index_lock_error(detail: &str) -> bool {
504 let normalized_detail = detail.to_ascii_lowercase();
505
506 normalized_detail.contains("index.lock")
507 && (normalized_detail.contains("file exists")
508 || normalized_detail.contains("unable to create")
509 || normalized_detail.contains("another git process"))
510}
511
512#[cfg(test)]
513mod tests {
514 use std::fs;
515 use std::process::{Command, Output};
516
517 use mockall::predicate::eq;
518 use tempfile::tempdir;
519
520 use super::*;
521 use crate::MockSleeper;
522
523 #[test]
524 fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
525 let mut command_runner = MockGitCommandRunner::new();
527 let mut sleeper = MockSleeper::new();
528 let repo_path = Path::new(".");
529 let args = ["rebase", "main"];
530 let environment: [(&str, &str); 0] = [];
531
532 command_runner
533 .expect_run_git_command_output_with_env()
534 .times(1)
535 .returning(|_, _, _| Ok(git_index_lock_output()));
536 command_runner
537 .expect_run_git_command_output_with_env()
538 .times(1)
539 .returning(|_, _, _| Ok(success_output()));
540
541 sleeper
542 .expect_sleep()
543 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
544 .times(1)
545 .return_once(|_| {});
546
547 let output = run_git_command_with_index_lock_retry_with_dependencies(
549 repo_path,
550 &args,
551 &environment,
552 &command_runner,
553 &sleeper,
554 )
555 .expect("retry helper should return command output");
556
557 assert!(output.status.success());
559 }
560
561 #[test]
562 fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
563 let mut command_runner = MockGitCommandRunner::new();
565 let mut sleeper = MockSleeper::new();
566 let repo_path = Path::new(".");
567 let args = ["-c", "core.editor=true", "rebase", "main"];
568 let environment = [("GIT_EDITOR", "true")];
569
570 command_runner
571 .expect_run_git_command_output_with_env()
572 .withf(|repo_path, args, environment| {
573 repo_path == Path::new(".")
574 && args.iter().map(String::as_str).eq([
575 "-c",
576 "core.editor=true",
577 "rebase",
578 "main",
579 ])
580 && environment
581 .iter()
582 .map(|(key, value)| (key.as_str(), value.as_str()))
583 .eq([("GIT_EDITOR", "true")])
584 })
585 .times(1)
586 .returning(|_, _, _| Ok(success_output()));
587 sleeper.expect_sleep().times(0);
588
589 let output = run_git_command_with_index_lock_retry_with_dependencies(
591 repo_path,
592 &args,
593 &environment,
594 &command_runner,
595 &sleeper,
596 )
597 .expect("retry helper should return command output");
598
599 assert!(output.status.success());
601 }
602
603 #[test]
604 fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
605 let mut command_runner = MockGitCommandRunner::new();
607 let mut sleeper = MockSleeper::new();
608 let repo_path = Path::new(".");
609 let args = ["rebase", "main"];
610 let environment: [(&str, &str); 0] = [];
611
612 command_runner
613 .expect_run_git_command_output_with_env()
614 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
615 .returning(|_, _, _| Ok(git_index_lock_output()));
616 sleeper
617 .expect_sleep()
618 .with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
619 .times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
620 .returning(|_| {});
621
622 let output = run_git_command_with_index_lock_retry_with_dependencies(
624 repo_path,
625 &args,
626 &environment,
627 &command_runner,
628 &sleeper,
629 )
630 .expect("retry helper should return command output");
631
632 assert!(!output.status.success());
634 assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
635 }
636
637 #[test]
638 fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
639 let mut command_runner = MockGitCommandRunner::new();
641 let mut sleeper = MockSleeper::new();
642 let repo_path = Path::new(".");
643 let args = ["rebase", "main"];
644 let environment: [(&str, &str); 0] = [];
645
646 command_runner
647 .expect_run_git_command_output_with_env()
648 .times(1)
649 .return_once(|_, _, _| {
650 Err(GitError::CommandFailed {
651 command: "git".to_string(),
652 stderr: "git execution failed".to_string(),
653 })
654 });
655 sleeper.expect_sleep().times(0);
656
657 let error = run_git_command_with_index_lock_retry_with_dependencies(
659 repo_path,
660 &args,
661 &environment,
662 &command_runner,
663 &sleeper,
664 )
665 .expect_err("retry helper should surface command execution errors");
666
667 assert_eq!(error.to_string(), "git: git execution failed");
669 }
670
671 #[test]
672 fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
673 let mut command_runner = MockGitCommandRunner::new();
675 let mut sleeper = MockSleeper::new();
676 let repo_path = Path::new(".");
677 let args = ["rebase", "main"];
678 let environment: [(&str, &str); 0] = [];
679
680 command_runner
681 .expect_run_git_command_output_with_env()
682 .times(1)
683 .returning(|_, _, _| Ok(non_lock_failure_output()));
684 sleeper.expect_sleep().times(0);
685
686 let output = run_git_command_with_index_lock_retry_with_dependencies(
688 repo_path,
689 &args,
690 &environment,
691 &command_runner,
692 &sleeper,
693 )
694 .expect("retry helper should return command output");
695
696 assert!(!output.status.success());
698 }
699
700 #[test]
701 fn test_is_rebase_conflict_matches_unmerged_files_message() {
702 let detail = "Committing is not possible because you have unmerged files.";
704
705 let is_conflict = is_rebase_conflict(detail);
707
708 assert!(is_conflict);
710 }
711
712 #[test]
713 fn test_is_stale_or_inactive_rebase_error_matches_no_rebase_message() {
714 let detail = "fatal: No rebase in progress?";
716
717 let is_stale_metadata_error = is_stale_or_inactive_rebase_error(detail);
719
720 assert!(is_stale_metadata_error);
722 }
723
724 #[test]
725 fn test_clean_stale_rebase_metadata_removes_existing_paths() {
726 let temp_dir = tempdir().expect("tempdir should be created");
728 let git_dir = temp_dir.path().join(".git");
729 let rebase_merge = git_dir.join("rebase-merge");
730 let rebase_apply = git_dir.join("rebase-apply");
731
732 fs::create_dir(&git_dir).expect("git dir should be created");
733 fs::create_dir(&rebase_merge).expect("rebase-merge dir should be created");
734 fs::write(&rebase_apply, "apply state").expect("rebase-apply file should be created");
735
736 let cleaned = clean_stale_rebase_metadata(temp_dir.path())
738 .expect("stale metadata cleanup should succeed");
739
740 assert!(cleaned);
742 assert!(!rebase_merge.exists());
743 assert!(!rebase_apply.exists());
744 }
745
746 fn success_output() -> Output {
748 Command::new("git")
749 .arg("--version")
750 .output()
751 .expect("failed to run git --version")
752 }
753
754 fn git_index_lock_output() -> Output {
756 let mut output = Command::new("git")
757 .arg("definitely-invalid-subcommand")
758 .output()
759 .expect("failed to run git invalid command");
760 output.stdout = vec![];
761 output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
762
763 output
764 }
765
766 fn non_lock_failure_output() -> Output {
768 let mut output = Command::new("git")
769 .arg("definitely-invalid-subcommand")
770 .output()
771 .expect("failed to run git invalid command");
772 output.stdout = vec![];
773 output.stderr = b"fatal: not a git repository".to_vec();
774
775 output
776 }
777}