1use crate::error::{Error, Result};
13use crate::locks::{CommitLocks, lock_recover};
14use fs4::fs_std::FileExt;
15use git2::{Oid, Repository};
16use std::fs::OpenOptions;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20pub type CommitHook = Arc<dyn Fn(Option<Oid>, Oid) + Send + Sync>;
33
34pub struct VaultRepo {
36 repo: Repository,
37 commit_locks: Arc<CommitLocks>,
40 pub(crate) commit_hook: Option<CommitHook>,
43}
44
45impl VaultRepo {
46 pub fn open(vault_root: &Path) -> Result<Self> {
54 Self::open_with_locks(vault_root, Arc::new(CommitLocks::new()))
55 }
56
57 pub fn open_with_locks(vault_root: &Path, commit_locks: Arc<CommitLocks>) -> Result<Self> {
60 match Repository::open(vault_root) {
61 Ok(repo) => Ok(Self {
62 repo,
63 commit_locks,
64 commit_hook: None,
65 }),
66 Err(e) if e.code() == git2::ErrorCode::NotFound => {
67 Err(Error::NotARepo(vault_root.to_path_buf()))
68 }
69 Err(e) => Err(Error::Git(e)),
70 }
71 }
72
73 pub fn open_with_locks_and_hook(
82 vault_root: &Path,
83 commit_locks: Arc<CommitLocks>,
84 commit_hook: CommitHook,
85 ) -> Result<Self> {
86 let mut vr = Self::open_with_locks(vault_root, commit_locks)?;
87 vr.commit_hook = Some(commit_hook);
88 Ok(vr)
89 }
90
91 pub fn commit_locks(&self) -> Arc<CommitLocks> {
95 Arc::clone(&self.commit_locks)
96 }
97
98 pub fn with_commit_lock<R>(&self, f: impl FnOnce() -> Result<R>) -> Result<R> {
103 let key = self.worktree_key();
104 let mutex = self.commit_locks.mutex_for(&key);
105 let _guard = lock_recover(&mutex);
106 let lock_path = self.repo.path().join("turbovault-write.lock");
107 let lock_file = OpenOptions::new()
108 .read(true)
109 .write(true)
110 .create(true)
111 .truncate(false)
112 .open(lock_path)?;
113 lock_file.lock_exclusive()?;
114 let result = f();
115 lock_file.unlock()?;
116 result
117 }
118
119 fn worktree_key(&self) -> PathBuf {
122 self.repo
123 .workdir()
124 .unwrap_or_else(|| self.repo.path())
125 .to_path_buf()
126 }
127
128 pub fn is_git_repo(vault_root: &Path) -> bool {
130 Repository::open(vault_root).is_ok()
131 }
132
133 pub fn current_branch(&self) -> Option<String> {
139 if self.repo.head_detached().unwrap_or(false) {
140 return None;
141 }
142 let head = self.repo.find_reference("HEAD").ok()?;
143 let target = head.symbolic_target().ok()??; target.strip_prefix("refs/heads/").map(str::to_string)
145 }
146
147 pub fn head_ref(&self) -> Result<String> {
150 let head = self.repo.find_reference("HEAD")?;
151 head.symbolic_target()
152 .map_err(Error::Git)?
153 .map(str::to_string)
154 .ok_or_else(|| Error::Other("HEAD is detached; no branch ref".to_string()))
155 }
156
157 pub fn head_oid(&self) -> Option<Oid> {
159 self.repo.head().ok()?.target()
160 }
161
162 pub fn is_unborn(&self) -> bool {
164 matches!(
165 self.repo.head(),
166 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch
167 )
168 }
169
170 pub fn git_commit_first_parent(&self, commit: Oid) -> Result<Option<Oid>> {
175 let c = self.repo.find_commit(commit)?;
176 Ok(c.parent_ids().next())
177 }
178
179 pub fn first_parent_range(
193 &self,
194 stop_exclusive: Option<Oid>,
195 tip: Oid,
196 ) -> Result<Option<Vec<Oid>>> {
197 let mut chain = Vec::new();
204 let mut cur = Some(tip);
205 while let Some(c) = cur {
206 if Some(c) == stop_exclusive {
207 chain.reverse();
208 return Ok(Some(chain));
209 }
210 chain.push(c);
211 cur = self.git_commit_first_parent(c)?;
212 }
213 match stop_exclusive {
214 None => {
215 chain.reverse();
216 Ok(Some(chain))
217 }
218 Some(_) => Ok(None),
220 }
221 }
222
223 pub fn is_path_ignored(&self, path: &str) -> Result<bool> {
228 Ok(self.repo.is_path_ignored(Path::new(path))?)
229 }
230
231 pub(crate) fn git(&self) -> &Repository {
233 &self.repo
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use git2::{Repository, Signature};
241 use tempfile::TempDir;
242
243 fn init_unborn(dir: &Path) -> Repository {
246 let mut opts = git2::RepositoryInitOptions::new();
247 opts.initial_head("main");
248 Repository::init_opts(dir, &opts).unwrap()
249 }
250
251 fn commit_one(repo: &Repository) -> Oid {
252 let sig = Signature::now("TurboVault", "tv@localhost").unwrap();
253 let tree_oid = {
254 let mut idx = git2::Index::new().unwrap();
255 let blob = repo.blob(b"hello").unwrap();
256 idx.add(&git2::IndexEntry {
257 ctime: git2::IndexTime::new(0, 0),
258 mtime: git2::IndexTime::new(0, 0),
259 dev: 0,
260 ino: 0,
261 mode: 0o100_644,
262 uid: 0,
263 gid: 0,
264 file_size: 5,
265 id: blob,
266 flags: 0,
267 flags_extended: 0,
268 path: b"a.md".to_vec(),
269 })
270 .unwrap();
271 idx.write_tree_to(repo).unwrap()
272 };
273 let tree = repo.find_tree(tree_oid).unwrap();
274 repo.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
275 .unwrap()
276 }
277
278 #[test]
279 fn open_non_git_dir_errors() {
280 let tmp = TempDir::new().unwrap();
281 assert!(!VaultRepo::is_git_repo(tmp.path()));
282 match VaultRepo::open(tmp.path()) {
283 Err(Error::NotARepo(p)) => assert_eq!(p, tmp.path()),
284 Err(e) => panic!("expected NotARepo, got error {e:?}"),
285 Ok(_) => panic!("expected NotARepo, got Ok"),
286 }
287 }
288
289 #[test]
290 fn open_detects_repo() {
291 let tmp = TempDir::new().unwrap();
292 init_unborn(tmp.path());
293 assert!(VaultRepo::is_git_repo(tmp.path()));
294 assert!(VaultRepo::open(tmp.path()).is_ok());
295 }
296
297 #[test]
301 fn commit_locks_returns_the_shared_registry() {
302 let tmp = TempDir::new().unwrap();
303 init_unborn(tmp.path());
304 let locks = std::sync::Arc::new(CommitLocks::new());
305 let vr = VaultRepo::open_with_locks(tmp.path(), std::sync::Arc::clone(&locks)).unwrap();
306 assert!(
307 std::sync::Arc::ptr_eq(&vr.commit_locks(), &locks),
308 "commit_locks() must return the registry the repo was opened with"
309 );
310 }
311
312 #[test]
316 fn worktree_key_is_the_workdir_not_default() {
317 let tmp = TempDir::new().unwrap();
318 init_unborn(tmp.path());
319 let vr = VaultRepo::open(tmp.path()).unwrap();
320 let key = vr.worktree_key();
321 assert!(
322 !key.as_os_str().is_empty(),
323 "worktree_key must not be empty"
324 );
325 assert_eq!(
326 std::fs::canonicalize(&key).unwrap(),
327 std::fs::canonicalize(tmp.path()).unwrap(),
328 "worktree_key is the repo workdir"
329 );
330 }
331
332 #[test]
333 fn unborn_branch_resolution() {
334 let tmp = TempDir::new().unwrap();
335 init_unborn(tmp.path());
336 let vr = VaultRepo::open(tmp.path()).unwrap();
337
338 assert!(vr.is_unborn(), "fresh repo has an unborn branch");
339 assert_eq!(vr.head_oid(), None, "no commit yet -> no HEAD oid");
340 assert_eq!(
341 vr.current_branch().as_deref(),
342 Some("main"),
343 "branch name exists before the first commit"
344 );
345 assert_eq!(vr.head_ref().unwrap(), "refs/heads/main");
346 }
347
348 #[test]
349 fn born_branch_resolution() {
350 let tmp = TempDir::new().unwrap();
351 let repo = init_unborn(tmp.path());
352 let c1 = commit_one(&repo);
353 let vr = VaultRepo::open(tmp.path()).unwrap();
354
355 assert!(!vr.is_unborn());
356 assert_eq!(vr.head_oid(), Some(c1));
357 assert_eq!(vr.current_branch().as_deref(), Some("main"));
358 assert_eq!(vr.head_ref().unwrap(), "refs/heads/main");
359 }
360
361 #[test]
362 fn detached_head_has_no_branch() {
363 let tmp = TempDir::new().unwrap();
364 let repo = init_unborn(tmp.path());
365 let c1 = commit_one(&repo);
366 repo.set_head_detached(c1).unwrap();
367
368 let vr = VaultRepo::open(tmp.path()).unwrap();
369 assert_eq!(
370 vr.head_oid(),
371 Some(c1),
372 "detached HEAD still resolves a commit"
373 );
374 assert_eq!(vr.current_branch(), None, "detached HEAD has no branch");
375 assert!(vr.head_ref().is_err(), "no branch ref while detached");
376 }
377
378 #[test]
379 fn shared_registry_same_worktree_shares_one_mutex() {
380 let tmp = TempDir::new().unwrap();
381 init_unborn(tmp.path());
382 let locks = Arc::new(CommitLocks::new());
383 let r1 = VaultRepo::open_with_locks(tmp.path(), Arc::clone(&locks)).unwrap();
384 let r2 = VaultRepo::open_with_locks(tmp.path(), Arc::clone(&locks)).unwrap();
385 let m1 = r1.commit_locks.mutex_for(&r1.worktree_key());
386 let m2 = r2.commit_locks.mutex_for(&r2.worktree_key());
387 assert!(
388 Arc::ptr_eq(&m1, &m2),
389 "shared registry + same worktree -> one commit mutex"
390 );
391 }
392
393 #[test]
394 fn with_commit_lock_runs_closure() {
395 let tmp = TempDir::new().unwrap();
396 init_unborn(tmp.path());
397 let vr = VaultRepo::open(tmp.path()).unwrap();
398 assert_eq!(vr.with_commit_lock(|| Ok(42)).unwrap(), 42);
399 }
400
401 #[test]
402 fn commit_lock_serializes_independent_repo_handles() {
403 let tmp = TempDir::new().unwrap();
404 init_unborn(tmp.path());
405 let first = VaultRepo::open(tmp.path()).unwrap();
406 let second = VaultRepo::open(tmp.path()).unwrap();
407 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
408 let (release_tx, release_rx) = std::sync::mpsc::channel();
409
410 let holder = std::thread::spawn(move || {
411 first
412 .with_commit_lock(|| {
413 entered_tx.send("first").unwrap();
414 release_rx.recv().unwrap();
415 Ok(())
416 })
417 .unwrap();
418 });
419 assert_eq!(entered_rx.recv().unwrap(), "first");
420
421 let (second_tx, second_rx) = std::sync::mpsc::channel();
422 let waiter = std::thread::spawn(move || {
423 second
424 .with_commit_lock(|| {
425 second_tx.send(()).unwrap();
426 Ok(())
427 })
428 .unwrap();
429 });
430 assert!(
431 second_rx
432 .recv_timeout(std::time::Duration::from_millis(100))
433 .is_err(),
434 "independent handle entered while the cross-process lock was held"
435 );
436 release_tx.send(()).unwrap();
437 second_rx
438 .recv_timeout(std::time::Duration::from_secs(2))
439 .expect("waiter enters after release");
440 holder.join().unwrap();
441 waiter.join().unwrap();
442 }
443}