use crate::error::{Error, Result};
use crate::locks::{CommitLocks, lock_recover};
use fs4::fs_std::FileExt;
use git2::{Oid, Repository};
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub type CommitHook = Arc<dyn Fn(Option<Oid>, Oid) + Send + Sync>;
pub struct VaultRepo {
repo: Repository,
commit_locks: Arc<CommitLocks>,
pub(crate) commit_hook: Option<CommitHook>,
}
impl VaultRepo {
pub fn open(vault_root: &Path) -> Result<Self> {
Self::open_with_locks(vault_root, Arc::new(CommitLocks::new()))
}
pub fn open_with_locks(vault_root: &Path, commit_locks: Arc<CommitLocks>) -> Result<Self> {
match Repository::open(vault_root) {
Ok(repo) => Ok(Self {
repo,
commit_locks,
commit_hook: None,
}),
Err(e) if e.code() == git2::ErrorCode::NotFound => {
Err(Error::NotARepo(vault_root.to_path_buf()))
}
Err(e) => Err(Error::Git(e)),
}
}
pub fn open_with_locks_and_hook(
vault_root: &Path,
commit_locks: Arc<CommitLocks>,
commit_hook: CommitHook,
) -> Result<Self> {
let mut vr = Self::open_with_locks(vault_root, commit_locks)?;
vr.commit_hook = Some(commit_hook);
Ok(vr)
}
pub fn commit_locks(&self) -> Arc<CommitLocks> {
Arc::clone(&self.commit_locks)
}
pub fn with_commit_lock<R>(&self, f: impl FnOnce() -> Result<R>) -> Result<R> {
let key = self.worktree_key();
let mutex = self.commit_locks.mutex_for(&key);
let _guard = lock_recover(&mutex);
let lock_path = self.repo.path().join("turbovault-write.lock");
let lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(lock_path)?;
lock_file.lock_exclusive()?;
let result = f();
lock_file.unlock()?;
result
}
fn worktree_key(&self) -> PathBuf {
self.repo
.workdir()
.unwrap_or_else(|| self.repo.path())
.to_path_buf()
}
pub fn is_git_repo(vault_root: &Path) -> bool {
Repository::open(vault_root).is_ok()
}
pub fn current_branch(&self) -> Option<String> {
if self.repo.head_detached().unwrap_or(false) {
return None;
}
let head = self.repo.find_reference("HEAD").ok()?;
let target = head.symbolic_target().ok()??; target.strip_prefix("refs/heads/").map(str::to_string)
}
pub fn head_ref(&self) -> Result<String> {
let head = self.repo.find_reference("HEAD")?;
head.symbolic_target()
.map_err(Error::Git)?
.map(str::to_string)
.ok_or_else(|| Error::Other("HEAD is detached; no branch ref".to_string()))
}
pub fn head_oid(&self) -> Option<Oid> {
self.repo.head().ok()?.target()
}
pub fn is_unborn(&self) -> bool {
matches!(
self.repo.head(),
Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch
)
}
pub fn git_commit_first_parent(&self, commit: Oid) -> Result<Option<Oid>> {
let c = self.repo.find_commit(commit)?;
Ok(c.parent_ids().next())
}
pub fn first_parent_range(
&self,
stop_exclusive: Option<Oid>,
tip: Oid,
) -> Result<Option<Vec<Oid>>> {
let mut chain = Vec::new();
let mut cur = Some(tip);
while let Some(c) = cur {
if Some(c) == stop_exclusive {
chain.reverse();
return Ok(Some(chain));
}
chain.push(c);
cur = self.git_commit_first_parent(c)?;
}
match stop_exclusive {
None => {
chain.reverse();
Ok(Some(chain))
}
Some(_) => Ok(None),
}
}
pub fn is_path_ignored(&self, path: &str) -> Result<bool> {
Ok(self.repo.is_path_ignored(Path::new(path))?)
}
pub(crate) fn git(&self) -> &Repository {
&self.repo
}
}
#[cfg(test)]
mod tests {
use super::*;
use git2::{Repository, Signature};
use tempfile::TempDir;
fn init_unborn(dir: &Path) -> Repository {
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head("main");
Repository::init_opts(dir, &opts).unwrap()
}
fn commit_one(repo: &Repository) -> Oid {
let sig = Signature::now("TurboVault", "tv@localhost").unwrap();
let tree_oid = {
let mut idx = git2::Index::new().unwrap();
let blob = repo.blob(b"hello").unwrap();
idx.add(&git2::IndexEntry {
ctime: git2::IndexTime::new(0, 0),
mtime: git2::IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o100_644,
uid: 0,
gid: 0,
file_size: 5,
id: blob,
flags: 0,
flags_extended: 0,
path: b"a.md".to_vec(),
})
.unwrap();
idx.write_tree_to(repo).unwrap()
};
let tree = repo.find_tree(tree_oid).unwrap();
repo.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
.unwrap()
}
#[test]
fn open_non_git_dir_errors() {
let tmp = TempDir::new().unwrap();
assert!(!VaultRepo::is_git_repo(tmp.path()));
match VaultRepo::open(tmp.path()) {
Err(Error::NotARepo(p)) => assert_eq!(p, tmp.path()),
Err(e) => panic!("expected NotARepo, got error {e:?}"),
Ok(_) => panic!("expected NotARepo, got Ok"),
}
}
#[test]
fn open_detects_repo() {
let tmp = TempDir::new().unwrap();
init_unborn(tmp.path());
assert!(VaultRepo::is_git_repo(tmp.path()));
assert!(VaultRepo::open(tmp.path()).is_ok());
}
#[test]
fn commit_locks_returns_the_shared_registry() {
let tmp = TempDir::new().unwrap();
init_unborn(tmp.path());
let locks = std::sync::Arc::new(CommitLocks::new());
let vr = VaultRepo::open_with_locks(tmp.path(), std::sync::Arc::clone(&locks)).unwrap();
assert!(
std::sync::Arc::ptr_eq(&vr.commit_locks(), &locks),
"commit_locks() must return the registry the repo was opened with"
);
}
#[test]
fn worktree_key_is_the_workdir_not_default() {
let tmp = TempDir::new().unwrap();
init_unborn(tmp.path());
let vr = VaultRepo::open(tmp.path()).unwrap();
let key = vr.worktree_key();
assert!(
!key.as_os_str().is_empty(),
"worktree_key must not be empty"
);
assert_eq!(
std::fs::canonicalize(&key).unwrap(),
std::fs::canonicalize(tmp.path()).unwrap(),
"worktree_key is the repo workdir"
);
}
#[test]
fn unborn_branch_resolution() {
let tmp = TempDir::new().unwrap();
init_unborn(tmp.path());
let vr = VaultRepo::open(tmp.path()).unwrap();
assert!(vr.is_unborn(), "fresh repo has an unborn branch");
assert_eq!(vr.head_oid(), None, "no commit yet -> no HEAD oid");
assert_eq!(
vr.current_branch().as_deref(),
Some("main"),
"branch name exists before the first commit"
);
assert_eq!(vr.head_ref().unwrap(), "refs/heads/main");
}
#[test]
fn born_branch_resolution() {
let tmp = TempDir::new().unwrap();
let repo = init_unborn(tmp.path());
let c1 = commit_one(&repo);
let vr = VaultRepo::open(tmp.path()).unwrap();
assert!(!vr.is_unborn());
assert_eq!(vr.head_oid(), Some(c1));
assert_eq!(vr.current_branch().as_deref(), Some("main"));
assert_eq!(vr.head_ref().unwrap(), "refs/heads/main");
}
#[test]
fn detached_head_has_no_branch() {
let tmp = TempDir::new().unwrap();
let repo = init_unborn(tmp.path());
let c1 = commit_one(&repo);
repo.set_head_detached(c1).unwrap();
let vr = VaultRepo::open(tmp.path()).unwrap();
assert_eq!(
vr.head_oid(),
Some(c1),
"detached HEAD still resolves a commit"
);
assert_eq!(vr.current_branch(), None, "detached HEAD has no branch");
assert!(vr.head_ref().is_err(), "no branch ref while detached");
}
#[test]
fn shared_registry_same_worktree_shares_one_mutex() {
let tmp = TempDir::new().unwrap();
init_unborn(tmp.path());
let locks = Arc::new(CommitLocks::new());
let r1 = VaultRepo::open_with_locks(tmp.path(), Arc::clone(&locks)).unwrap();
let r2 = VaultRepo::open_with_locks(tmp.path(), Arc::clone(&locks)).unwrap();
let m1 = r1.commit_locks.mutex_for(&r1.worktree_key());
let m2 = r2.commit_locks.mutex_for(&r2.worktree_key());
assert!(
Arc::ptr_eq(&m1, &m2),
"shared registry + same worktree -> one commit mutex"
);
}
#[test]
fn with_commit_lock_runs_closure() {
let tmp = TempDir::new().unwrap();
init_unborn(tmp.path());
let vr = VaultRepo::open(tmp.path()).unwrap();
assert_eq!(vr.with_commit_lock(|| Ok(42)).unwrap(), 42);
}
#[test]
fn commit_lock_serializes_independent_repo_handles() {
let tmp = TempDir::new().unwrap();
init_unborn(tmp.path());
let first = VaultRepo::open(tmp.path()).unwrap();
let second = VaultRepo::open(tmp.path()).unwrap();
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let holder = std::thread::spawn(move || {
first
.with_commit_lock(|| {
entered_tx.send("first").unwrap();
release_rx.recv().unwrap();
Ok(())
})
.unwrap();
});
assert_eq!(entered_rx.recv().unwrap(), "first");
let (second_tx, second_rx) = std::sync::mpsc::channel();
let waiter = std::thread::spawn(move || {
second
.with_commit_lock(|| {
second_tx.send(()).unwrap();
Ok(())
})
.unwrap();
});
assert!(
second_rx
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"independent handle entered while the cross-process lock was held"
);
release_tx.send(()).unwrap();
second_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("waiter enters after release");
holder.join().unwrap();
waiter.join().unwrap();
}
}