use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
pub(crate) struct MergeLock {
path: PathBuf,
}
impl MergeLock {
pub fn acquire(project_root: &Path) -> Result<Self> {
let path = project_root.join(".batty").join("merge.lock");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let start = std::time::Instant::now();
loop {
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(_) => return Ok(Self { path }),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if start.elapsed() > std::time::Duration::from_secs(60) {
bail!("merge lock timeout after 60s: {}", path.display());
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(error) => bail!("failed to acquire merge lock: {error}"),
}
}
}
}
impl Drop for MergeLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MergeMode {
DirectRoot,
IsolatedIntegration,
}
impl MergeMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::DirectRoot => "direct_root",
Self::IsolatedIntegration => "isolated_integration",
}
}
}
pub(crate) fn infer_merge_mode_from_failure(message: &str) -> Option<MergeMode> {
if message.starts_with("direct merge path failed") {
Some(MergeMode::DirectRoot)
} else if message.starts_with("isolated merge path failed") {
Some(MergeMode::IsolatedIntegration)
} else {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MergeSuccess {
pub(crate) mode: MergeMode,
pub(crate) reason: Option<String>,
}
#[derive(Debug)]
pub(crate) enum MergeOutcome {
Success(MergeSuccess),
RebaseConflict(String),
MergeFailure(String),
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
Arc, Barrier,
atomic::{AtomicBool, Ordering},
};
use std::thread;
use std::time::Duration;
#[test]
fn merge_lock_acquire_release() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
let lock_path = tmp.path().join(".batty").join("merge.lock");
{
let lock = MergeLock::acquire(tmp.path()).unwrap();
assert!(lock_path.exists());
drop(lock);
}
assert!(!lock_path.exists());
}
#[test]
fn merge_lock_second_acquire_waits_for_release() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".batty")).unwrap();
let first_lock = MergeLock::acquire(tmp.path()).unwrap();
let project_root = tmp.path().to_path_buf();
let barrier = Arc::new(Barrier::new(2));
let acquired = Arc::new(AtomicBool::new(false));
let thread_barrier = Arc::clone(&barrier);
let thread_acquired = Arc::clone(&acquired);
let handle = thread::spawn(move || {
thread_barrier.wait();
let second_lock = MergeLock::acquire(&project_root).unwrap();
thread_acquired.store(true, Ordering::SeqCst);
drop(second_lock);
});
barrier.wait();
thread::sleep(Duration::from_millis(600));
assert!(!acquired.load(Ordering::SeqCst));
drop(first_lock);
handle.join().unwrap();
assert!(acquired.load(Ordering::SeqCst));
}
#[test]
fn infer_merge_mode_from_failure_prefix() {
assert_eq!(
infer_merge_mode_from_failure("direct merge path failed: boom"),
Some(MergeMode::DirectRoot)
);
assert_eq!(
infer_merge_mode_from_failure("isolated merge path failed: boom"),
Some(MergeMode::IsolatedIntegration)
);
assert_eq!(
infer_merge_mode_from_failure("verification detected regressions"),
None
);
}
}