turbovault_git/locks.rs
1//! Per-worktree commit lock (GWS.6).
2//!
3//! Git's index and HEAD are **shared mutable state per worktree**: two
4//! concurrent commit+checkout sequences on the same worktree race on that
5//! worktree's `index.lock`. This is the *only* lock the git substrate needs —
6//! it replaces the legacy per-path lock registry, because the lost-update
7//! problem the per-path locks solved is now caught by the ref CAS (GWS.3),
8//! cross-process. What remains is purely intra-process serialization of the
9//! commit critical section, and that is coarse (one mutex per worktree), not
10//! per-path.
11//!
12//! Different worktrees (the main vault and fan-out scratch worktrees, GWS.9)
13//! have independent index/HEAD, so they never contend — they get distinct
14//! locks. A single shared [`CommitLocks`] registry, keyed by worktree, lets all
15//! [`VaultRepo`](crate::VaultRepo) handles for the same worktree serialize on
16//! one mutex.
17
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex, MutexGuard};
21
22/// A process-wide registry of per-worktree commit mutexes. Share one `Arc`
23/// across every `VaultRepo` so handles to the same worktree serialize.
24#[derive(Default)]
25pub struct CommitLocks {
26 locks: Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>,
27}
28
29impl CommitLocks {
30 pub fn new() -> Self {
31 Self::default()
32 }
33
34 /// The commit mutex for the worktree identified by `key` (its canonical
35 /// workdir). Returns the same `Arc<Mutex>` for repeated calls with the same
36 /// key, and distinct mutexes for distinct keys.
37 pub(crate) fn mutex_for(&self, key: &Path) -> Arc<Mutex<()>> {
38 // Canonicalize so different spellings of the same worktree share a lock;
39 // fall back to the raw path if the dir can't be canonicalized.
40 let key = key.canonicalize().unwrap_or_else(|_| key.to_path_buf());
41 let mut map = self
42 .locks
43 .lock()
44 .unwrap_or_else(|poisoned| poisoned.into_inner());
45 map.entry(key)
46 .or_insert_with(|| Arc::new(Mutex::new(())))
47 .clone()
48 }
49}
50
51/// Lock `mutex`, recovering from poisoning (a panic in a prior holder shouldn't
52/// permanently wedge a worktree's commit path).
53pub(crate) fn lock_recover(mutex: &Mutex<()>) -> MutexGuard<'_, ()> {
54 mutex
55 .lock()
56 .unwrap_or_else(|poisoned| poisoned.into_inner())
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use tempfile::TempDir;
63
64 #[test]
65 fn same_key_shares_mutex_distinct_keys_differ() {
66 let a = TempDir::new().unwrap();
67 let b = TempDir::new().unwrap();
68 let locks = CommitLocks::new();
69
70 let a1 = locks.mutex_for(a.path());
71 let a2 = locks.mutex_for(a.path());
72 let b1 = locks.mutex_for(b.path());
73
74 assert!(Arc::ptr_eq(&a1, &a2), "same worktree -> same mutex");
75 assert!(
76 !Arc::ptr_eq(&a1, &b1),
77 "distinct worktrees -> distinct mutexes"
78 );
79 }
80
81 #[test]
82 fn mutex_provides_mutual_exclusion() {
83 let dir = TempDir::new().unwrap();
84 let locks = CommitLocks::new();
85 let m = locks.mutex_for(dir.path());
86
87 let held = lock_recover(&m);
88 assert!(m.try_lock().is_err(), "a held commit lock blocks re-entry");
89 drop(held);
90 assert!(m.try_lock().is_ok(), "released lock is re-acquirable");
91 }
92
93 #[test]
94 fn lock_recover_survives_poison() {
95 let dir = TempDir::new().unwrap();
96 let locks = CommitLocks::new();
97 let m = locks.mutex_for(dir.path());
98
99 // Poison the mutex by panicking while holding it.
100 let m2 = Arc::clone(&m);
101 let _ = std::thread::spawn(move || {
102 let _g = m2.lock().unwrap();
103 panic!("poison");
104 })
105 .join();
106
107 // lock_recover still yields the guard despite poisoning.
108 let _g = lock_recover(&m);
109 }
110}