use anyhow::{Context, Result, anyhow};
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::error_code::{self, ErrorCodeExt};
const STALE_LOCK_TTL: Duration = Duration::from_secs(30 * 60);
#[derive(Debug)]
pub struct ReleaseLock {
path: PathBuf,
_handle: File,
}
impl ReleaseLock {
pub fn acquire(repo_root: &Path) -> Result<Self> {
let git_dir = repo_root.join(".git");
if !git_dir.is_dir() {
return Err(anyhow!(
"release lock cannot acquire — {} is not a regular .git directory \
(worktrees and submodules currently unsupported by the lock)",
git_dir.display()
))
.error_code(error_code::GIT_NOT_A_REPO);
}
let path = git_dir.join("ferrflow.lock");
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut file) => {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = writeln!(
file,
"{pid}\n{now}\n{host}",
pid = std::process::id(),
host = hostname_or_unknown()
);
let _ = file.flush();
Ok(Self {
path,
_handle: file,
})
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if take_over_if_stale(&path)? {
eprintln!(
"Warning: previous release lock at {} appeared stale; took it over.",
path.display()
);
return Self::acquire(repo_root);
}
let existing = read_lock_info(&path).unwrap_or_else(|| "<unreadable>".to_string());
Err(anyhow!(
"another `ferrflow release` is already running on this repo (lockfile: {})\n \
lock content:\n {}\n \
If you're sure no other release is in progress, delete the lockfile manually \
and retry (or run with --force-unlock).",
path.display(),
existing.replace('\n', "\n ")
))
.error_code(error_code::GIT_LOCKED)
}
Err(e) => Err(e)
.with_context(|| format!("could not create release lock at {}", path.display()))
.error_code(error_code::GIT_LOCKED),
}
}
pub fn acquire_force(repo_root: &Path) -> Result<Self> {
let path = repo_root.join(".git").join("ferrflow.lock");
if path.exists() {
let _ = std::fs::remove_file(&path);
eprintln!(
"Warning: --force-unlock removed existing lockfile at {}",
path.display()
);
}
Self::acquire(repo_root)
}
}
impl Drop for ReleaseLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn read_lock_info(path: &Path) -> Option<String> {
let mut buf = String::new();
File::open(path).ok()?.read_to_string(&mut buf).ok()?;
Some(buf)
}
fn take_over_if_stale(path: &Path) -> Result<bool> {
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => return Ok(false),
};
let modified = metadata
.modified()
.ok()
.and_then(|t| t.elapsed().ok())
.unwrap_or(Duration::ZERO);
if modified < STALE_LOCK_TTL {
return Ok(false);
}
let _ = std::fs::remove_file(path);
Ok(true)
}
fn hostname_or_unknown() -> String {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn init_test_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
dir
}
#[test]
fn acquire_in_clean_repo_succeeds() {
let dir = init_test_repo();
let _lock = ReleaseLock::acquire(dir.path()).expect("first acquire");
assert!(dir.path().join(".git/ferrflow.lock").exists());
}
#[test]
fn drop_removes_the_lockfile() {
let dir = init_test_repo();
{
let _lock = ReleaseLock::acquire(dir.path()).unwrap();
}
assert!(!dir.path().join(".git/ferrflow.lock").exists());
}
#[test]
fn second_acquire_fails_while_first_held() {
let dir = init_test_repo();
let _first = ReleaseLock::acquire(dir.path()).unwrap();
let err = ReleaseLock::acquire(dir.path()).expect_err("second acquire should fail");
let msg = format!("{err:?}");
assert!(
msg.contains("already running"),
"expected lock-busy error, got: {msg}"
);
}
#[test]
fn force_unlock_takes_over_active_lock() {
let dir = init_test_repo();
let first = ReleaseLock::acquire(dir.path()).unwrap();
let _second = ReleaseLock::acquire_force(dir.path())
.expect("force-unlock should succeed even if held");
drop(first);
}
#[test]
fn errors_when_git_dir_missing() {
let dir = tempfile::tempdir().unwrap();
let err = ReleaseLock::acquire(dir.path()).expect_err("no .git → should error");
assert!(format!("{err:?}").contains(".git directory"));
}
#[test]
fn lockfile_content_includes_pid() {
let dir = init_test_repo();
let _lock = ReleaseLock::acquire(dir.path()).unwrap();
let content = std::fs::read_to_string(dir.path().join(".git/ferrflow.lock")).unwrap();
let expected = std::process::id().to_string();
assert!(
content.starts_with(&expected),
"expected lock content to start with PID {expected}, got: {content:?}"
);
}
}