use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::{fsync, GitError, Result};
pub fn lock_path_for(path: &Path) -> Result<PathBuf> {
let Some(file_name) = path.file_name() else {
return Err(GitError::InvalidPath(format!(
"path has no filename: {}",
path.display()
)));
};
let mut lock_name = file_name.to_os_string();
lock_name.push(".lock");
Ok(path.with_file_name(lock_name))
}
#[derive(Debug)]
pub struct LockFile {
path: PathBuf,
target: Option<PathBuf>,
file: Option<fs::File>,
armed: bool,
}
impl LockFile {
pub fn create(path: PathBuf) -> Result<Self> {
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
Ok(Self {
path,
target: None,
file: Some(file),
armed: true,
})
}
pub fn acquire(target: &Path) -> Result<Self> {
let mut lock = Self::create(lock_path_for(target)?)?;
lock.target = Some(target.to_path_buf());
Ok(lock)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn target(&self) -> Option<&Path> {
self.target.as_deref()
}
pub fn file_mut(&mut self) -> Option<&mut fs::File> {
self.file.as_mut()
}
pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
let Some(file) = self.file.as_mut() else {
return Err(GitError::Io("lock file is already closed".into()));
};
file.write_all(bytes)?;
Ok(())
}
pub fn sync(&mut self, policy: &fsync::Policy, component: fsync::FsyncComponents) -> Result<()> {
let Some(file) = self.file.as_mut() else {
return Err(GitError::Io("lock file is already closed".into()));
};
policy.apply(file, component)?;
Ok(())
}
pub fn persist(self) -> Result<()> {
let Some(target) = self.target.clone() else {
return Err(GitError::Io(format!(
"lock file {} has no publication target",
self.path.display()
)));
};
self.persist_into(&target)
}
pub fn persist_into(mut self, target: &Path) -> Result<()> {
self.armed = false;
let _ = self.file.take();
match fs::rename(&self.path, target) {
Ok(()) => Ok(()),
Err(err) => {
let _ = fs::remove_file(&self.path);
Err(GitError::Io(err.to_string()))
}
}
}
pub fn persist_racy(mut self, target: &Path) -> Result<()> {
self.armed = false;
let _ = self.file.take();
match fs::rename(&self.path, target) {
Ok(()) => Ok(()),
Err(_) if target.exists() => {
let _ = fs::remove_file(&self.path);
Ok(())
}
Err(err) => {
let _ = fs::remove_file(&self.path);
Err(GitError::Io(err.to_string()))
}
}
}
pub fn keep(mut self) -> (PathBuf, Option<fs::File>) {
self.armed = false;
(std::mem::take(&mut self.path), self.file.take())
}
}
impl Drop for LockFile {
fn drop(&mut self) {
if self.armed {
self.armed = false;
let _ = self.file.take();
let _ = fs::remove_file(&self.path);
}
}
}
pub fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
atomic_write_with(path, |file| {
file.write_all(contents)?;
Ok(())
})
}
pub fn atomic_write_with(
path: &Path,
write: impl FnOnce(&mut fs::File) -> Result<()>,
) -> Result<()> {
let mut lock = LockFile::acquire(path)?;
match lock.file_mut() {
Some(file) => write(file)?,
None => return Err(GitError::Io("lock file is already closed".into())),
}
lock.persist()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn scratch_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"sley-core-atomic-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&dir).expect("create scratch dir");
dir
}
#[test]
fn lock_path_for_appends_lock_suffix() {
assert_eq!(
lock_path_for(Path::new("refs/heads/main")).expect("lock path"),
PathBuf::from("refs/heads/main.lock")
);
assert!(lock_path_for(Path::new("")).is_err());
}
#[test]
fn acquire_write_persist_replaces_target_atomically() {
let dir = scratch_dir("persist");
let target = dir.join("packed-refs");
fs::write(&target, b"old").expect("seed target");
let mut lock = LockFile::acquire(&target).expect("acquire");
assert_eq!(lock.path(), target.with_file_name("packed-refs.lock"));
lock.write_all(b"new").expect("write");
lock.persist().expect("persist");
assert_eq!(fs::read(&target).expect("read target"), b"new");
assert!(!target.with_file_name("packed-refs.lock").exists());
let mut again = LockFile::acquire(&target).expect("re-acquire");
again.write_all(b"newer").expect("write");
again.persist().expect("persist");
assert_eq!(fs::read(&target).expect("read target"), b"newer");
fs::remove_dir_all(dir).expect("cleanup");
}
#[test]
fn dropped_guard_removes_lock_and_atomic_write_fails_when_locked() {
let dir = scratch_dir("drop");
let target = dir.join("HEAD");
let guard = LockFile::acquire(&target).expect("acquire");
let lock_path = guard.path().to_path_buf();
drop(guard);
assert!(!lock_path.exists(), "dropped guard must remove its lock");
let held = LockFile::acquire(&target).expect("hold");
let err = atomic_write(&target, b"x").expect_err("second writer must fail");
assert_eq!(err.io_kind(), Some(std::io::ErrorKind::AlreadyExists));
drop(held);
atomic_write(&target, b"payload").expect("atomic write after release");
assert_eq!(fs::read(&target).expect("read"), b"payload");
fs::remove_dir_all(dir).expect("cleanup");
}
#[test]
fn create_keeps_exact_temp_names_for_mkstemp_style_callers() {
let dir = scratch_dir("keep");
let temp = dir.join("tmp_obj_42_7");
let lock = LockFile::create(temp.clone()).expect("create");
assert!(temp.exists());
let (kept_path, _) = lock.keep();
assert_eq!(kept_path, temp);
assert!(temp.exists(), "keep() must not delete the file");
let err = LockFile::create(temp).expect_err("exclusive create");
assert_eq!(err.io_kind(), Some(std::io::ErrorKind::AlreadyExists));
fs::remove_dir_all(dir).expect("cleanup");
}
#[test]
fn persist_racy_succeeds_when_another_writer_landed_first() {
let dir = scratch_dir("racy");
let target = dir.join("objects/ab/cdef");
fs::create_dir_all(target.parent().expect("parent")).expect("fanout");
fs::write(&target, b"theirs").expect("concurrent winner");
let temp = dir.join("tmp_obj_racy");
let mut lock = LockFile::create(temp).expect("create temp");
lock.write_all(b"ours").expect("write");
lock.persist_racy(&target)
.expect("racy publish must tolerate the concurrent winner");
let contents = fs::read(&target).expect("read");
assert!(contents == b"theirs" || contents == b"ours");
fs::remove_dir_all(dir).expect("cleanup");
}
}