use std::fs;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const CACHE_DIR_NAME: &str = "merge-ready";
const MAX_LOCK_AGE_SECS: u64 = 120;
#[derive(Serialize, Deserialize)]
struct LockFile {
pid: u32,
locked_at: u64,
}
pub fn try_acquire(repo_id: &str) -> bool {
let Some(path) = lock_path(repo_id) else {
return false;
};
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if create_with_pid(&path) {
return true;
}
if is_alive(&path) {
return false;
}
let _ = fs::remove_file(&path);
create_with_pid(&path)
}
pub fn update_pid(repo_id: &str, pid: u32) {
if let Some(path) = lock_path(repo_id) {
let lock = LockFile {
pid,
locked_at: now_secs(),
};
if let Ok(content) = serde_json::to_string(&lock) {
let _ = fs::write(path, content);
}
}
}
pub fn release(repo_id: &str) {
if let Some(path) = lock_path(repo_id) {
let _ = fs::remove_file(path);
}
}
fn create_with_pid(path: &std::path::Path) -> bool {
let Ok(mut f) = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
else {
return false;
};
let lock = LockFile {
pid: std::process::id(),
locked_at: now_secs(),
};
let Ok(content) = serde_json::to_string(&lock) else {
drop(f);
let _ = fs::remove_file(path);
return false;
};
if f.write_all(content.as_bytes()).is_err() {
drop(f);
let _ = fs::remove_file(path);
return false;
}
true
}
fn is_alive(path: &std::path::Path) -> bool {
let Ok(content) = fs::read_to_string(path) else {
return false;
};
let Ok(lock) = serde_json::from_str::<LockFile>(&content) else {
return false;
};
let age = now_secs().saturating_sub(lock.locked_at);
if age >= MAX_LOCK_AGE_SECS {
return false;
}
std::process::Command::new("kill")
.args(["-0", &lock.pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn lock_path(repo_id: &str) -> Option<std::path::PathBuf> {
let home = std::env::var_os("HOME")?;
Some(
std::path::Path::new(&home)
.join(".cache")
.join(CACHE_DIR_NAME)
.join(format!("{repo_id}.lock")),
)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tempfile::tempdir;
use super::*;
#[test]
fn create_with_pid_concurrent_exactly_one_succeeds() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.lock");
let success_count = Arc::new(AtomicUsize::new(0));
std::thread::scope(|s| {
let handles: Vec<_> = (0..16)
.map(|_| {
let count = Arc::clone(&success_count);
let p = path.clone();
s.spawn(move || {
if create_with_pid(&p) {
count.fetch_add(1, Ordering::SeqCst);
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
});
assert_eq!(
success_count.load(Ordering::SeqCst),
1,
"exactly 1 thread should win create_with_pid"
);
}
#[test]
fn create_with_pid_writes_valid_json_immediately() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.lock");
assert!(create_with_pid(&path));
let content = std::fs::read_to_string(&path).unwrap();
let lock: LockFile =
serde_json::from_str(&content).expect("lock file should contain valid JSON");
assert_eq!(
lock.pid,
std::process::id(),
"pid should match current process"
);
assert!(lock.locked_at > 0, "locked_at should be non-zero");
}
#[test]
fn create_with_pid_failure_leaves_no_orphan_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.lock");
assert!(create_with_pid(&path), "first acquire should succeed");
assert!(!create_with_pid(&path), "second acquire should fail");
assert!(path.exists());
std::fs::remove_file(&path).unwrap();
assert!(
create_with_pid(&path),
"should be re-acquirable after release — no orphan file"
);
}
#[test]
fn is_alive_returns_false_for_empty_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.lock");
std::fs::write(&path, b"").unwrap();
assert!(!is_alive(&path));
}
#[test]
fn is_alive_returns_true_when_age_is_below_max() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.lock");
let lock = LockFile {
pid: std::process::id(),
locked_at: now_secs() - (MAX_LOCK_AGE_SECS - 1),
};
std::fs::write(&path, serde_json::to_string(&lock).unwrap()).unwrap();
assert!(is_alive(&path), "age 119s should still be alive");
}
#[test]
fn is_alive_returns_false_when_age_equals_max() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.lock");
let lock = LockFile {
pid: std::process::id(),
locked_at: now_secs() - MAX_LOCK_AGE_SECS,
};
std::fs::write(&path, serde_json::to_string(&lock).unwrap()).unwrap();
assert!(!is_alive(&path), "age 120s should be expired");
}
}