#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(any(test, feature = "test-utils"))]
pub enum ConcurrentOperation {
Rebase,
Merge,
CherryPick,
Revert,
Bisect,
OtherGitProcess,
Unknown(String),
}
#[cfg(any(test, feature = "test-utils"))]
impl ConcurrentOperation {
#[must_use]
pub fn description(&self) -> String {
match self {
Self::Rebase => "rebase".to_string(),
Self::Merge => "merge".to_string(),
Self::CherryPick => "cherry-pick".to_string(),
Self::Revert => "revert".to_string(),
Self::Bisect => "bisect".to_string(),
Self::OtherGitProcess => "another Git process".to_string(),
Self::Unknown(s) => format!("unknown operation: {s}"),
}
}
}
#[cfg(any(test, feature = "test-utils"))]
pub fn detect_concurrent_git_operations() -> io::Result<Option<ConcurrentOperation>> {
use std::fs;
let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
let git_dir = repo.path();
let rebase_merge = git_dir.join(REBASE_MERGE_DIR);
let rebase_apply = git_dir.join(REBASE_APPLY_DIR);
if rebase_merge.exists() || rebase_apply.exists() {
return Ok(Some(ConcurrentOperation::Rebase));
}
let merge_head = git_dir.join("MERGE_HEAD");
if merge_head.exists() {
return Ok(Some(ConcurrentOperation::Merge));
}
let cherry_pick_head = git_dir.join("CHERRY_PICK_HEAD");
if cherry_pick_head.exists() {
return Ok(Some(ConcurrentOperation::CherryPick));
}
let revert_head = git_dir.join("REVERT_HEAD");
if revert_head.exists() {
return Ok(Some(ConcurrentOperation::Revert));
}
let bisect_log = git_dir.join("BISECT_LOG");
let bisect_start = git_dir.join("BISECT_START");
let bisect_names = git_dir.join("BISECT_NAMES");
if bisect_log.exists() || bisect_start.exists() || bisect_names.exists() {
return Ok(Some(ConcurrentOperation::Bisect));
}
let index_lock = git_dir.join("index.lock");
let packed_refs_lock = git_dir.join("packed-refs.lock");
let head_lock = git_dir.join("HEAD.lock");
if index_lock.exists() || packed_refs_lock.exists() || head_lock.exists() {
return Ok(Some(ConcurrentOperation::OtherGitProcess));
}
let result: io::Result<Option<ConcurrentOperation>> = fs::read_dir(git_dir)
.map_err(io::Error::other)?
.flatten()
.try_fold(None, |acc, entry| {
if acc.is_some() {
return Ok(acc);
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.contains("REBASE")
|| name_str.contains("MERGE")
|| name_str.contains("CHERRY")
{
return Ok(Some(ConcurrentOperation::Unknown(name_str.to_string())));
}
Ok(acc)
});
result
}
#[cfg(any(test, feature = "test-utils"))]
pub fn rebase_in_progress_cli(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<bool> {
let output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
Ok(output.stdout.contains("rebasing"))
}
#[derive(Debug, Clone, Default)]
#[cfg(any(test, feature = "test-utils"))]
pub struct CleanupResult {
pub cleaned_paths: Vec<String>,
pub locks_removed: bool,
}
#[cfg(any(test, feature = "test-utils"))]
impl CleanupResult {
#[must_use]
pub const fn has_cleanup(&self) -> bool {
!self.cleaned_paths.is_empty() || self.locks_removed
}
#[must_use]
pub const fn count(&self) -> usize {
self.cleaned_paths.len() + if self.locks_removed { 1 } else { 0 }
}
}
#[cfg(any(test, feature = "test-utils"))]
pub fn cleanup_stale_rebase_state() -> io::Result<CleanupResult> {
use std::fs;
let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
let git_dir = repo.path();
let stale_paths = [
(REBASE_APPLY_DIR, "rebase-apply directory"),
(REBASE_MERGE_DIR, "rebase-merge directory"),
("MERGE_HEAD", "merge state"),
("MERGE_MSG", "merge message"),
("CHERRY_PICK_HEAD", "cherry-pick state"),
("REVERT_HEAD", "revert state"),
("COMMIT_EDITMSG", "commit message"),
];
let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
let cleaned_paths: Vec<String> = stale_paths
.iter()
.filter_map(|(path_name, description)| {
let full_path = git_dir.join(path_name);
if full_path.exists() {
let is_valid = validate_state_file(&full_path);
if !is_valid.unwrap_or(true) {
let removed = if full_path.is_dir() {
fs::remove_dir_all(&full_path)
.map(|()| true)
.unwrap_or(false)
} else {
fs::remove_file(&full_path).map(|()| true).unwrap_or(false)
};
if removed {
return Some(format!("{path_name} ({description})"));
}
}
}
None
})
.chain(lock_files.iter().filter_map(|lock_file| {
let lock_path = git_dir.join(lock_file);
if lock_path.exists() && fs::remove_file(&lock_path).is_ok() {
Some(format!("{lock_file} (lock file)"))
} else {
None
}
}))
.collect();
let locks_removed = !lock_files
.iter()
.filter(|lock_file| {
let lock_path = git_dir.join(lock_file);
lock_path.exists() && fs::remove_file(&lock_path).is_ok()
})
.count()
== 0;
Ok(CleanupResult {
cleaned_paths,
locks_removed,
})
}
#[cfg(any(test, feature = "test-utils"))]
fn validate_state_file(path: &Path) -> io::Result<bool> {
use std::fs;
if !path.exists() {
return Ok(false);
}
if path.is_dir() {
let entries = fs::read_dir(path)?;
let has_content = entries.count() > 0;
return Ok(has_content);
}
if path.is_file() {
let metadata = fs::metadata(path)?;
if metadata.len() == 0 {
return Ok(false);
}
let _ = fs::read(path)?;
return Ok(true);
}
Ok(false)
}
#[cfg(any(test, feature = "test-utils"))]
pub fn attempt_automatic_recovery(
executor: &dyn crate::executor::ProcessExecutor,
error_kind: &RebaseErrorKind,
phase: &crate::git_helpers::rebase_checkpoint::RebasePhase,
phase_error_count: u32,
) -> io::Result<bool> {
match error_kind {
RebaseErrorKind::InvalidRevision { .. }
| RebaseErrorKind::DirtyWorkingTree
| RebaseErrorKind::RepositoryCorrupt { .. }
| RebaseErrorKind::EnvironmentFailure { .. }
| RebaseErrorKind::HookRejection { .. }
| RebaseErrorKind::InteractiveStop { .. }
| RebaseErrorKind::Unknown { .. } => {
return Ok(false);
}
_ => {}
}
let max_attempts = phase.max_recovery_attempts();
if phase_error_count >= max_attempts {
return Ok(false);
}
if cleanup_stale_rebase_state().is_ok() {
if validate_git_state().is_ok() {
return Ok(true);
}
}
let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
let git_dir = repo.path();
let lock_files = ["index.lock", "packed-refs.lock", "HEAD.lock"];
let removed_any = lock_files.iter().any(|lock_file| {
let lock_path = git_dir.join(lock_file);
lock_path.exists() && std::fs::remove_file(&lock_path).is_ok()
});
if removed_any && validate_git_state().is_ok() {
return Ok(true);
}
if let RebaseErrorKind::ConcurrentOperation { .. } = error_kind {
let abort_result = executor.execute("git", &["rebase", "--abort"], &[], None);
if abort_result.is_ok() {
if validate_git_state().is_ok() {
return Ok(true);
}
}
}
Ok(false)
}
#[cfg(any(test, feature = "test-utils"))]
pub fn validate_git_state() -> io::Result<()> {
let repo = git2::Repository::discover(".").map_err(|e| git2_to_io_error(&e))?;
let _ = repo.head().map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Repository HEAD is invalid: {e}"),
)
})?;
let _ = repo.index().map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Repository index is corrupted: {e}"),
)
})?;
if let Ok(head) = repo.head() {
if let Ok(commit) = head.peel_to_commit() {
let _ = commit.tree().map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Object database corruption: {e}"),
)
})?;
}
}
Ok(())
}
#[cfg(any(test, feature = "test-utils"))]
pub fn is_dirty_tree_cli(executor: &dyn crate::executor::ProcessExecutor) -> io::Result<bool> {
let output = executor.execute("git", &["status", "--porcelain"], &[], None)?;
if output.status.success() {
let stdout = output.stdout.trim();
Ok(!stdout.is_empty())
} else {
Err(io::Error::other(format!(
"Failed to check working tree status: {}",
output.stderr
)))
}
}