Skip to main content

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}