use std::fs;
use std::io::ErrorKind;
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};
pub(super) const GIT_INDEX_LOCK_RETRY_ATTEMPTS: usize = 21;
pub(super) const GIT_INDEX_LOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
#[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>;
}
#[cfg_attr(test, mockall::automock)]
trait RebaseMetadataCleaner: Send + Sync {
fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError>;
}
struct FilesystemRebaseMetadataCleaner;
impl RebaseMetadataCleaner for FilesystemRebaseMetadataCleaner {
fn clean_stale_metadata(&self, repo_path: &Path) -> Result<bool, GitError> {
clean_stale_rebase_metadata(repo_path)
}
}
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 command_runner = ProcessGitCommandRunner;
let metadata_cleaner = FilesystemRebaseMetadataCleaner;
let sleeper = ThreadSleeper;
abort_rebase_with_dependencies(&repo_path, &command_runner, &sleeper, &metadata_cleaner)
})
.await?
}
pub(crate) async fn is_rebase_in_progress(repo_path: PathBuf) -> Result<bool, GitError> {
spawn_blocking(move || -> Result<bool, GitError> {
match fs::metadata(&repo_path) {
Ok(_) => {}
Err(error) if error.kind() == ErrorKind::NotFound => {
return Err(GitError::RepositoryUnavailable {
detail: format!("Repository folder is missing: {}", repo_path.display()),
});
}
Err(error) => return Err(error.into()),
}
let git_dir = resolve_git_dir(&repo_path).ok_or_else(|| {
GitError::OutputParse(format!(
"Failed to resolve git directory for `{}`",
repo_path.display()
))
})?;
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),
})
}
fn abort_rebase_with_dependencies(
repo_path: &Path,
command_runner: &dyn GitCommandRunner,
sleeper: &dyn Sleeper,
metadata_cleaner: &dyn RebaseMetadataCleaner,
) -> Result<(), GitError> {
let output = run_git_command_with_index_lock_retry_with_dependencies(
repo_path,
&["rebase", "--abort"],
&[],
command_runner,
sleeper,
)?;
if !output.status.success() {
let detail = command_output_detail(&output.stdout, &output.stderr);
if is_stale_or_inactive_rebase_error(&detail) {
match metadata_cleaner.clean_stale_metadata(repo_path) {
Ok(true) => return Ok(()),
Ok(false) => {}
Err(cleanup_error) => {
return Err(GitError::CommandFailed {
command: "git rebase --abort".to_string(),
stderr: format!(
"Failed to abort rebase: {detail}. Stale rebase metadata cleanup \
failed: {cleanup_error}."
),
});
}
}
}
return Err(GitError::CommandFailed {
command: "git rebase --abort".to_string(),
stderr: format!("Failed to abort rebase: {detail}."),
});
}
Ok(())
}
fn is_stale_or_inactive_rebase_error(detail: &str) -> bool {
let normalized_detail = detail.to_ascii_lowercase();
normalized_detail.contains("no rebase in progress")
|| normalized_detail.contains("already a rebase-merge directory")
|| normalized_detail.contains("already a rebase-apply directory")
|| normalized_detail.contains("middle of another rebase")
}
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 removed_rebase_merge = remove_stale_rebase_metadata_path(&git_dir.join("rebase-merge"))?;
let removed_rebase_apply = remove_stale_rebase_metadata_path(&git_dir.join("rebase-apply"))?;
Ok(removed_rebase_merge || removed_rebase_apply)
}
fn remove_stale_rebase_metadata_path(path: &Path) -> Result<bool, GitError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_dir() {
fs::remove_dir_all(path)?;
} else {
fs::remove_file(path)?;
}
Ok(true)
}
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")
}
pub(super) 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("another git process"))
}
#[cfg(test)]
#[path = "rebase_test.rs"]
mod tests;