use std::fs::{self, File, OpenOptions};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use crate::error::PawError;
pub const LOCK_FILE_NAME: &str = ".add-remove.lock";
#[must_use]
pub fn lock_path(repo_root: &Path) -> PathBuf {
repo_root.join(".git-paw").join(LOCK_FILE_NAME)
}
#[derive(Debug)]
pub struct SessionLock {
path: PathBuf,
_file: File,
}
impl SessionLock {
pub fn acquire(repo_root: &Path) -> Result<Self, PawError> {
let path = lock_path(repo_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
PawError::SessionError(format!(
"failed to create lock directory {}: {e}",
parent.display()
))
})?;
}
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => Ok(Self { path, _file: file }),
Err(e) if e.kind() == ErrorKind::AlreadyExists => Err(PawError::SessionError(format!(
"another `git paw add` / `git paw remove` operation is in progress for this \
repository.\n\
\n\
Wait for it to finish, then retry. If no such command is running, a previous \
invocation crashed mid-operation — remove the stale lock and retry:\n \
rm {}",
path.display()
))),
Err(e) => Err(PawError::SessionError(format!(
"failed to acquire session lock {}: {e}",
path.display()
))),
}
}
}
impl Drop for SessionLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn lock_path_is_under_git_paw_dir() {
let repo = TempDir::new().unwrap();
let p = lock_path(repo.path());
assert_eq!(p, repo.path().join(".git-paw").join(".add-remove.lock"));
}
#[test]
fn acquire_creates_the_lock_file() {
let repo = TempDir::new().unwrap();
let _guard = SessionLock::acquire(repo.path()).expect("first acquire should succeed");
assert!(
lock_path(repo.path()).exists(),
"lock file should exist while the guard is held"
);
}
#[test]
fn second_concurrent_acquire_errors_with_in_progress_message() {
let repo = TempDir::new().unwrap();
let _guard = SessionLock::acquire(repo.path()).expect("first acquire should succeed");
let err = SessionLock::acquire(repo.path())
.expect_err("second concurrent acquire must fail while the first is held");
let msg = err.to_string();
assert!(
msg.contains("in progress"),
"second acquire should report an operation in progress; got: {msg}"
);
assert!(
msg.contains(".add-remove.lock"),
"error should name the lock file so a stale lock can be removed; got: {msg}"
);
}
#[test]
fn lock_is_released_on_drop_allowing_reacquire() {
let repo = TempDir::new().unwrap();
{
let _guard = SessionLock::acquire(repo.path()).expect("acquire");
}
assert!(
!lock_path(repo.path()).exists(),
"lock file should be removed when the guard drops"
);
let _again = SessionLock::acquire(repo.path())
.expect("re-acquire after the previous guard dropped should succeed");
}
#[test]
fn acquire_creates_git_paw_dir_when_absent() {
let repo = TempDir::new().unwrap();
assert!(!repo.path().join(".git-paw").exists());
let _guard = SessionLock::acquire(repo.path()).expect("acquire should create .git-paw/");
assert!(repo.path().join(".git-paw").is_dir());
}
}