use std::fs;
use std::path::{Path, PathBuf};
use std::process::Output;
use std::time::Duration;
use tokio::task::spawn_blocking;
use super::error::GitError;
use super::repo::{
command_output_detail, resolve_git_dir, run_git_command_output_sync,
run_git_command_output_with_env_sync, run_git_command_sync,
};
use crate::{Sleeper, ThreadSleeper};
const GIT_INDEX_LOCK_RETRY_ATTEMPTS: usize = 5;
const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
#[cfg_attr(test, mockall::automock)]
trait GitCommandRunner: Send + Sync {
fn run_git_command_output_with_env(
&self,
repo_path: &Path,
args: &[String],
environment: &[(String, String)],
) -> Result<Output, GitError>;
}
struct ProcessGitCommandRunner;
impl GitCommandRunner for ProcessGitCommandRunner {
fn run_git_command_output_with_env(
&self,
repo_path: &Path,
args: &[String],
environment: &[(String, String)],
) -> Result<Output, GitError> {
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
let environment = environment
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect::<Vec<_>>();
run_git_command_output_with_env_sync(repo_path, &args, &environment)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RebaseStepResult {
Completed,
Conflict { detail: String },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InProgressGitOperation {
CherryPick,
Merge,
Rebase,
Revert,
}
impl InProgressGitOperation {
pub fn article_name(self) -> &'static str {
match self {
Self::CherryPick => "a cherry-pick",
Self::Merge => "a merge",
Self::Rebase => "a rebase",
Self::Revert => "a revert",
}
}
pub fn name(self) -> &'static str {
match self {
Self::CherryPick => "cherry-pick",
Self::Merge => "merge",
Self::Rebase => "rebase",
Self::Revert => "revert",
}
}
}
pub(crate) async fn rebase(repo_path: PathBuf, target_branch: String) -> Result<(), GitError> {
match rebase_start(repo_path.clone(), target_branch.clone()).await? {
RebaseStepResult::Completed => Ok(()),
RebaseStepResult::Conflict { detail } => {
let abort_suffix = match abort_rebase(repo_path).await {
Ok(()) => String::new(),
Err(error) => format!(" {error}"),
};
Err(GitError::CommandFailed {
command: "git rebase".to_string(),
stderr: format!("Failed to rebase onto {target_branch}: {detail}.{abort_suffix}"),
})
}
}
}
pub(crate) async fn rebase_start(
repo_path: PathBuf,
target_branch: String,
) -> Result<RebaseStepResult, GitError> {
spawn_blocking(move || {
let rebase_args = ["rebase", target_branch.as_str()];
run_rebase_step(&repo_path, &rebase_args, "git rebase", |detail| {
format!("Failed to rebase onto {target_branch}: {detail}.")
})
})
.await?
}
pub(crate) async fn rebase_onto_start(
repo_path: PathBuf,
new_base: String,
old_base: String,
) -> Result<RebaseStepResult, GitError> {
spawn_blocking(move || {
let rebase_args = ["rebase", "--onto", new_base.as_str(), old_base.as_str()];
run_rebase_step(&repo_path, &rebase_args, "git rebase --onto", |detail| {
format!("Failed to rebase onto {new_base} after {old_base}: {detail}.")
})
})
.await?
}
pub(crate) async fn rebase_continue(repo_path: PathBuf) -> Result<RebaseStepResult, GitError> {
spawn_blocking(move || {
let output = run_git_command_with_index_lock_retry(
&repo_path,
&["rebase", "--continue"],
&[("GIT_EDITOR", ":"), ("GIT_SEQUENCE_EDITOR", ":")],
)?;
if output.status.success() {
return Ok(RebaseStepResult::Completed);
}
let detail = command_output_detail(&output.stdout, &output.stderr);
if is_rebase_conflict(&detail) {
return Ok(RebaseStepResult::Conflict { detail });
}
Err(GitError::CommandFailed {
command: "git rebase --continue".to_string(),
stderr: format!("Failed to continue rebase: {detail}."),
})
})
.await?
}
pub(crate) async fn abort_rebase(repo_path: PathBuf) -> Result<(), GitError> {
spawn_blocking(move || {
let output =
run_git_command_with_index_lock_retry(&repo_path, &["rebase", "--abort"], &[])?;
if !output.status.success() {
let detail = command_output_detail(&output.stdout, &output.stderr);
if !is_stale_or_inactive_rebase_error(&detail) {
return Err(GitError::CommandFailed {
command: "git rebase --abort".to_string(),
stderr: format!("Failed to abort rebase: {detail}."),
});
}
let cleaned_stale_metadata = clean_stale_rebase_metadata(&repo_path)?;
if cleaned_stale_metadata {
return Ok(());
}
return Err(GitError::CommandFailed {
command: "git rebase --abort".to_string(),
stderr: format!("Failed to abort rebase: {detail}."),
});
}
Ok(())
})
.await?
}
pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
spawn_blocking(move || -> Result<bool, GitError> {
let git_dir = resolve_git_dir(&repo_path)
.ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
Ok(has_rebase_metadata(&git_dir))
})
.await?
}
pub(crate) async fn in_progress_operation(
repo_path: PathBuf,
) -> Result<Option<InProgressGitOperation>, GitError> {
spawn_blocking(move || in_progress_operation_sync(&repo_path)).await?
}
fn in_progress_operation_sync(
repo_path: &Path,
) -> Result<Option<InProgressGitOperation>, GitError> {
let git_dir = resolve_git_dir(repo_path)
.ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
if has_rebase_metadata(&git_dir) {
return Ok(Some(InProgressGitOperation::Rebase));
}
if git_dir.join("MERGE_HEAD").exists() {
return Ok(Some(InProgressGitOperation::Merge));
}
if git_dir.join("CHERRY_PICK_HEAD").exists() {
return Ok(Some(InProgressGitOperation::CherryPick));
}
if git_dir.join("REVERT_HEAD").exists() {
return Ok(Some(InProgressGitOperation::Revert));
}
Ok(None)
}
fn has_rebase_metadata(git_dir: &Path) -> bool {
let rebase_merge = git_dir.join("rebase-merge");
let rebase_apply = git_dir.join("rebase-apply");
rebase_merge.exists() || rebase_apply.exists()
}
pub(crate) async fn has_unmerged_paths(repo_path: PathBuf) -> Result<bool, GitError> {
let conflicted_files = list_conflicted_files(repo_path).await?;
Ok(!conflicted_files.is_empty())
}
pub(crate) async fn list_staged_conflict_marker_files(
repo_path: PathBuf,
paths: Vec<String>,
) -> Result<Vec<String>, GitError> {
if paths.is_empty() {
return Ok(vec![]);
}
spawn_blocking(move || -> Result<Vec<String>, GitError> {
let mut grep_arguments = vec!["grep", "--cached", "-l", "^<<<<<<<", "--"];
let path_arguments: Vec<&str> = paths.iter().map(String::as_str).collect();
grep_arguments.extend(path_arguments);
let output = run_git_command_output_sync(&repo_path, &grep_arguments)?;
let exit_code = output.status.code().unwrap_or(2);
if !output.status.success() && exit_code != 1 {
let detail = command_output_detail(&output.stdout, &output.stderr);
return Err(GitError::CommandFailed {
command: "git grep".to_string(),
stderr: format!("Failed to check for staged conflict markers: {detail}"),
});
}
let files = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect();
Ok(files)
})
.await?
}
pub(crate) async fn list_conflicted_files(repo_path: PathBuf) -> Result<Vec<String>, GitError> {
spawn_blocking(move || -> Result<Vec<String>, GitError> {
let output = run_git_command_sync(
&repo_path,
&["diff", "--name-only", "--diff-filter=U"],
"Failed to read conflicted files",
)?;
let files = output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect();
Ok(files)
})
.await?
}
fn run_rebase_step(
repo_path: &Path,
args: &[&str],
command: &str,
failure_message: impl FnOnce(&str) -> String,
) -> Result<RebaseStepResult, GitError> {
let output = run_git_command_with_index_lock_retry(repo_path, args, &[])?;
if output.status.success() {
return Ok(RebaseStepResult::Completed);
}
let detail = command_output_detail(&output.stdout, &output.stderr);
if is_rebase_conflict(&detail) {
return Ok(RebaseStepResult::Conflict { detail });
}
Err(GitError::CommandFailed {
command: command.to_string(),
stderr: failure_message(&detail),
})
}
pub(super) fn run_git_command_with_index_lock_retry(
repo_path: &Path,
args: &[&str],
environment: &[(&str, &str)],
) -> Result<Output, GitError> {
let command_runner = ProcessGitCommandRunner;
let sleeper = ThreadSleeper;
run_git_command_with_index_lock_retry_with_dependencies(
repo_path,
args,
environment,
&command_runner,
&sleeper,
)
}
fn run_git_command_with_index_lock_retry_with_dependencies(
repo_path: &Path,
args: &[&str],
environment: &[(&str, &str)],
command_runner: &dyn GitCommandRunner,
sleeper: &dyn Sleeper,
) -> Result<Output, GitError> {
let args = args
.iter()
.map(|arg| String::from(*arg))
.collect::<Vec<_>>();
let environment = environment
.iter()
.map(|(key, value)| (String::from(*key), String::from(*value)))
.collect::<Vec<_>>();
for attempt in 0..GIT_INDEX_LOCK_RETRY_ATTEMPTS {
let output =
command_runner.run_git_command_output_with_env(repo_path, &args, &environment)?;
if output.status.success() {
return Ok(output);
}
let detail = command_output_detail(&output.stdout, &output.stderr);
let is_last_attempt = attempt + 1 == GIT_INDEX_LOCK_RETRY_ATTEMPTS;
if !is_git_index_lock_error(&detail) || is_last_attempt {
return Ok(output);
}
sleeper.sleep(GIT_INDEX_LOCK_RETRY_DELAY);
}
unreachable!("index lock retry loop should always return an output")
}
pub(super) fn is_rebase_conflict(detail: &str) -> bool {
detail.contains("CONFLICT")
|| detail.contains("Resolve all conflicts manually")
|| detail.contains("could not apply")
|| detail.contains("mark them as resolved")
|| detail.contains("unresolved conflict")
|| detail.contains("Committing is not possible")
}
fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
let normalized_detail = detail.to_ascii_lowercase();
normalized_detail.contains("already a rebase-merge directory")
|| normalized_detail.contains("already a rebase-apply directory")
|| normalized_detail.contains("middle of another rebase")
|| normalized_detail.contains("no rebase in progress")
|| normalized_detail.contains("rebase-merge")
|| normalized_detail.contains("rebase-apply")
}
fn clean_stale_rebase_metadata(repo_path: &Path) -> Result<bool, GitError> {
let git_dir = resolve_git_dir(repo_path)
.ok_or_else(|| GitError::OutputParse("Failed to resolve git directory".to_string()))?;
let rebase_merge = git_dir.join("rebase-merge");
let rebase_apply = git_dir.join("rebase-apply");
let removed_rebase_merge = remove_stale_rebase_metadata_path(&rebase_merge)?;
let removed_rebase_apply = remove_stale_rebase_metadata_path(&rebase_apply)?;
Ok(removed_rebase_merge || removed_rebase_apply)
}
fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
if !path.exists() {
return Ok(false);
}
if path.is_dir() {
fs::remove_dir_all(path)?;
return Ok(true);
}
fs::remove_file(path)?;
Ok(true)
}
fn is_git_index_lock_error(detail: &str) -> bool {
let normalized_detail = detail.to_ascii_lowercase();
normalized_detail.contains("index.lock")
&& (normalized_detail.contains("file exists")
|| normalized_detail.contains("unable to create")
|| normalized_detail.contains("another git process"))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::process::{Command, Output};
use mockall::predicate::eq;
use tempfile::tempdir;
use super::*;
use crate::MockSleeper;
#[test]
fn test_run_git_command_with_index_lock_retry_retries_and_sleeps_before_success() {
let mut command_runner = MockGitCommandRunner::new();
let mut sleeper = MockSleeper::new();
let repo_path = Path::new(".");
let args = ["rebase", "main"];
let environment: [(&str, &str); 0] = [];
command_runner
.expect_run_git_command_output_with_env()
.times(1)
.returning(|_, _, _| Ok(git_index_lock_output()));
command_runner
.expect_run_git_command_output_with_env()
.times(1)
.returning(|_, _, _| Ok(success_output()));
sleeper
.expect_sleep()
.with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
.times(1)
.return_once(|_| {});
let output = run_git_command_with_index_lock_retry_with_dependencies(
repo_path,
&args,
&environment,
&command_runner,
&sleeper,
)
.expect("retry helper should return command output");
assert!(output.status.success());
}
#[test]
fn test_run_git_command_with_index_lock_retry_passes_owned_args_and_environment() {
let mut command_runner = MockGitCommandRunner::new();
let mut sleeper = MockSleeper::new();
let repo_path = Path::new(".");
let args = ["-c", "core.editor=true", "rebase", "main"];
let environment = [("GIT_EDITOR", "true")];
command_runner
.expect_run_git_command_output_with_env()
.withf(|repo_path, args, environment| {
repo_path == Path::new(".")
&& args.iter().map(String::as_str).eq([
"-c",
"core.editor=true",
"rebase",
"main",
])
&& environment
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.eq([("GIT_EDITOR", "true")])
})
.times(1)
.returning(|_, _, _| Ok(success_output()));
sleeper.expect_sleep().times(0);
let output = run_git_command_with_index_lock_retry_with_dependencies(
repo_path,
&args,
&environment,
&command_runner,
&sleeper,
)
.expect("retry helper should return command output");
assert!(output.status.success());
}
#[test]
fn test_run_git_command_with_index_lock_retry_returns_last_lock_failure() {
let mut command_runner = MockGitCommandRunner::new();
let mut sleeper = MockSleeper::new();
let repo_path = Path::new(".");
let args = ["rebase", "main"];
let environment: [(&str, &str); 0] = [];
command_runner
.expect_run_git_command_output_with_env()
.times(GIT_INDEX_LOCK_RETRY_ATTEMPTS)
.returning(|_, _, _| Ok(git_index_lock_output()));
sleeper
.expect_sleep()
.with(eq(GIT_INDEX_LOCK_RETRY_DELAY))
.times(GIT_INDEX_LOCK_RETRY_ATTEMPTS - 1)
.returning(|_| {});
let output = run_git_command_with_index_lock_retry_with_dependencies(
repo_path,
&args,
&environment,
&command_runner,
&sleeper,
)
.expect("retry helper should return command output");
assert!(!output.status.success());
assert!(command_output_detail(&output.stdout, &output.stderr).contains("index.lock"));
}
#[test]
fn test_run_git_command_with_index_lock_retry_returns_command_error_without_sleeping() {
let mut command_runner = MockGitCommandRunner::new();
let mut sleeper = MockSleeper::new();
let repo_path = Path::new(".");
let args = ["rebase", "main"];
let environment: [(&str, &str); 0] = [];
command_runner
.expect_run_git_command_output_with_env()
.times(1)
.return_once(|_, _, _| {
Err(GitError::CommandFailed {
command: "git".to_string(),
stderr: "git execution failed".to_string(),
})
});
sleeper.expect_sleep().times(0);
let error = run_git_command_with_index_lock_retry_with_dependencies(
repo_path,
&args,
&environment,
&command_runner,
&sleeper,
)
.expect_err("retry helper should surface command execution errors");
assert_eq!(error.to_string(), "git: git execution failed");
}
#[test]
fn test_run_git_command_with_index_lock_retry_does_not_sleep_for_non_lock_errors() {
let mut command_runner = MockGitCommandRunner::new();
let mut sleeper = MockSleeper::new();
let repo_path = Path::new(".");
let args = ["rebase", "main"];
let environment: [(&str, &str); 0] = [];
command_runner
.expect_run_git_command_output_with_env()
.times(1)
.returning(|_, _, _| Ok(non_lock_failure_output()));
sleeper.expect_sleep().times(0);
let output = run_git_command_with_index_lock_retry_with_dependencies(
repo_path,
&args,
&environment,
&command_runner,
&sleeper,
)
.expect("retry helper should return command output");
assert!(!output.status.success());
}
#[test]
fn test_is_rebase_conflict_matches_unmerged_files_message() {
let detail = "Committing is not possible because you have unmerged files.";
let is_conflict = is_rebase_conflict(detail);
assert!(is_conflict);
}
#[test]
fn test_is_stale_or_inactive_rebase_error_matches_no_rebase_message() {
let detail = "fatal: No rebase in progress?";
let is_stale_metadata_error = is_stale_or_inactive_rebase_error(detail);
assert!(is_stale_metadata_error);
}
#[test]
fn test_in_progress_operation_detects_rebase_metadata() {
let temp_dir = tempdir().expect("tempdir should be created");
let git_dir = temp_dir.path().join(".git");
fs::create_dir(&git_dir).expect("git dir should be created");
fs::create_dir(git_dir.join("rebase-merge")).expect("rebase metadata should be created");
let operation =
in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
assert_eq!(operation, Some(InProgressGitOperation::Rebase));
}
#[test]
fn test_in_progress_operation_detects_merge_metadata() {
let temp_dir = tempdir().expect("tempdir should be created");
let git_dir = temp_dir.path().join(".git");
fs::create_dir(&git_dir).expect("git dir should be created");
fs::write(git_dir.join("MERGE_HEAD"), "merge").expect("merge metadata should be created");
let operation =
in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
assert_eq!(operation, Some(InProgressGitOperation::Merge));
}
#[test]
fn test_in_progress_operation_detects_cherry_pick_metadata() {
let temp_dir = tempdir().expect("tempdir should be created");
let git_dir = temp_dir.path().join(".git");
fs::create_dir(&git_dir).expect("git dir should be created");
fs::write(git_dir.join("CHERRY_PICK_HEAD"), "cherry-pick")
.expect("cherry-pick metadata should be created");
let operation =
in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
assert_eq!(operation, Some(InProgressGitOperation::CherryPick));
}
#[test]
fn test_in_progress_operation_detects_revert_metadata() {
let temp_dir = tempdir().expect("tempdir should be created");
let git_dir = temp_dir.path().join(".git");
fs::create_dir(&git_dir).expect("git dir should be created");
fs::write(git_dir.join("REVERT_HEAD"), "revert")
.expect("revert metadata should be created");
let operation =
in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
assert_eq!(operation, Some(InProgressGitOperation::Revert));
}
#[test]
fn test_in_progress_operation_returns_none_for_clean_git_dir() {
let temp_dir = tempdir().expect("tempdir should be created");
fs::create_dir(temp_dir.path().join(".git")).expect("git dir should be created");
let operation =
in_progress_operation_sync(temp_dir.path()).expect("operation should be detected");
assert_eq!(operation, None);
}
#[test]
fn test_clean_stale_rebase_metadata_removes_existing_paths() {
let temp_dir = tempdir().expect("tempdir should be created");
let git_dir = temp_dir.path().join(".git");
let rebase_merge = git_dir.join("rebase-merge");
let rebase_apply = git_dir.join("rebase-apply");
fs::create_dir(&git_dir).expect("git dir should be created");
fs::create_dir(&rebase_merge).expect("rebase-merge dir should be created");
fs::write(&rebase_apply, "apply state").expect("rebase-apply file should be created");
let cleaned = clean_stale_rebase_metadata(temp_dir.path())
.expect("stale metadata cleanup should succeed");
assert!(cleaned);
assert!(!rebase_merge.exists());
assert!(!rebase_apply.exists());
}
fn success_output() -> Output {
Command::new("git")
.arg("--version")
.output()
.expect("failed to run git --version")
}
fn git_index_lock_output() -> Output {
let mut output = Command::new("git")
.arg("definitely-invalid-subcommand")
.output()
.expect("failed to run git invalid command");
output.stdout = vec![];
output.stderr = b"fatal: Unable to create '.git/index.lock': File exists.".to_vec();
output
}
fn non_lock_failure_output() -> Output {
let mut output = Command::new("git")
.arg("definitely-invalid-subcommand")
.output()
.expect("failed to run git invalid command");
output.stdout = vec![];
output.stderr = b"fatal: not a git repository".to_vec();
output
}
}