Skip to main content

turbovault_git/
repo.rs

1//! Repository handle + detection (GWS.1).
2//!
3//! [`VaultRepo`] wraps a `git2::Repository` opened at the vault root. The git
4//! write substrate is **opt-in per vault and git-gated**: a vault that is not a
5//! git repo is detected here and the substrate is a no-op for it (the caller
6//! falls back / refuses with a clear error).
7//!
8//! Resolves the current branch and HEAD across the three states the substrate
9//! must handle: a normal born branch, an **unborn** branch (fresh repo, no
10//! commits — the initial-commit case), and a **detached** HEAD.
11
12use 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
20/// Callback fired by [`VaultRepo::commit_changeset`] after a successful
21/// commit + materialize, **inside** the commit lock. Arguments are the
22/// commit's first-parent oid (or `None` for the initial commit on an
23/// unborn branch) and the new commit oid.
24///
25/// The hook is the substrate's GWS.14 plumbing: downstream consumers
26/// (the reindex queue) push the new commit onto a pending-reindex queue;
27/// the actual diff + graph/search update runs out of band (lazy GSU,
28/// see `git-write-substrate-architecture.md` §8.1 as refined by GWS.14).
29///
30/// The substrate itself does NOT touch graph or search — that would
31/// smear write-substrate logic outward. The hook is the only contract.
32pub type CommitHook = Arc<dyn Fn(Option<Oid>, Oid) + Send + Sync>;
33
34/// A handle to the git repository backing a vault.
35pub struct VaultRepo {
36    repo: Repository,
37    /// Shared per-worktree commit-lock registry (GWS.6). All handles to the same
38    /// worktree must share one registry to serialize the commit critical section.
39    commit_locks: Arc<CommitLocks>,
40    /// Optional post-commit hook fired inside the commit lock after the
41    /// changeset is materialized. Plumbed for GWS.14 lazy GSU.
42    pub(crate) commit_hook: Option<CommitHook>,
43}
44
45impl VaultRepo {
46    /// Open the git repository whose working tree root is `vault_root`, with a
47    /// **private** commit-lock registry. Use [`Self::open_with_locks`] when
48    /// multiple handles (e.g. a server managing several worktrees) must share
49    /// one registry.
50    ///
51    /// Strict: `vault_root` must be the repository root (we do not walk parent
52    /// directories). Returns [`Error::NotARepo`] if there is no repo there.
53    pub fn open(vault_root: &Path) -> Result<Self> {
54        Self::open_with_locks(vault_root, Arc::new(CommitLocks::new()))
55    }
56
57    /// Open at `vault_root` sharing the given commit-lock registry, so handles to
58    /// the same worktree serialize their commit critical sections.
59    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    /// Open the repo with both a shared commit-lock registry AND a post-commit
74    /// hook. The hook fires once per successful `commit_changeset`, inside
75    /// the commit lock, after materialization (GWS.14 plumbing).
76    ///
77    /// Multiple `VaultRepo` handles to the same worktree may install
78    /// different hooks; each handle's hook fires only for changesets
79    /// applied through THAT handle. The server-side pattern is to install
80    /// the same hook on every cached handle for a given vault.
81    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    /// Clone the shared commit-lock registry — handed to a scratch worktree's
92    /// `VaultRepo` (GWS.9) so all handles to the same repo's worktrees keep
93    /// using one registry.
94    pub fn commit_locks(&self) -> Arc<CommitLocks> {
95        Arc::clone(&self.commit_locks)
96    }
97
98    /// Run `f` while holding both the in-process mutex and a cross-process
99    /// advisory lock for this worktree. The lock spans ref CAS and working-tree
100    /// materialization, preventing two TurboVault processes from interleaving
101    /// checkout writes after independently successful commits.
102    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    /// Identity of this worktree for commit-lock keying: the working directory,
120    /// falling back to the git directory for a bare repo.
121    fn worktree_key(&self) -> PathBuf {
122        self.repo
123            .workdir()
124            .unwrap_or_else(|| self.repo.path())
125            .to_path_buf()
126    }
127
128    /// Whether `vault_root` is the root of a git repository.
129    pub fn is_git_repo(vault_root: &Path) -> bool {
130        Repository::open(vault_root).is_ok()
131    }
132
133    /// The current branch's short name (e.g. `main`).
134    ///
135    /// Returns `None` when HEAD is **detached** (points directly at a commit, no
136    /// branch). Works for an **unborn** branch too — the name exists before the
137    /// first commit.
138    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()??; // e.g. "refs/heads/main"
144        target.strip_prefix("refs/heads/").map(str::to_string)
145    }
146
147    /// The full ref name HEAD points at (e.g. `refs/heads/main`), even when the
148    /// branch is **unborn**. Errors if HEAD is detached (no branch ref).
149    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    /// The HEAD commit oid, or `None` when the branch is **unborn** (no commits).
158    pub fn head_oid(&self) -> Option<Oid> {
159        self.repo.head().ok()?.target()
160    }
161
162    /// Whether the current branch is unborn (a fresh repo with no commits).
163    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    /// First-parent oid of `commit`, or `None` for a root commit (the
171    /// initial commit on an unborn branch). Thin libgit2 wrapper exposed
172    /// so downstream consumers (the GWS.14 reindex drainer) can resolve a
173    /// commit's parent without taking a direct `git2` dep.
174    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    /// First-parent commits in `(stop_exclusive, tip]`, oldest-first.
180    ///
181    /// tlx.5: the out-of-band ref listener uses this to enqueue EVERY commit a
182    /// multi-commit jump introduced (e.g. a `git pull` of N commits) instead of
183    /// only the new tip — otherwise the drainer diffs the tip against its first
184    /// parent and silently skips the intermediate commits' changes.
185    ///
186    /// Returns `Ok(None)` when `stop_exclusive` is set but is NOT on `tip`'s
187    /// FIRST-PARENT chain — a non-ff move (force-push, branch switch) OR a stop
188    /// reachable only through a merge's second parent has no clean range, so the
189    /// caller falls back to best-effort (full coherence needs a restart; the
190    /// §8.4 limitation). With `stop_exclusive == None`, walks the whole
191    /// first-parent chain from `tip` to root.
192    pub fn first_parent_range(
193        &self,
194        stop_exclusive: Option<Oid>,
195        tip: Oid,
196    ) -> Result<Option<Vec<Oid>>> {
197        // Walk ONLY first parents. `graph_descendant_of` is the wrong test: it
198        // is true when `stop` is reachable through a merge's SECOND parent, but
199        // the drainer diffs each commit against its first parent, so a `stop` on
200        // a side branch is not a clean range — the walk would run past it to
201        // root and re-enqueue all of history. Reaching root without hitting
202        // `stop` => fall back (None); hitting it => the bounded range.
203        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            // `stop` is not on tip's first-parent chain — no clean range.
219            Some(_) => Ok(None),
220        }
221    }
222
223    /// turbovault-lri: whether `path` (repo-root-relative) is excluded by
224    /// any active `.gitignore`. Thin wrapper over libgit2's
225    /// `is_path_ignored`. Used by the substrate's `include_ignored`
226    /// policy enforcement.
227    pub fn is_path_ignored(&self, path: &str) -> Result<bool> {
228        Ok(self.repo.is_path_ignored(Path::new(path))?)
229    }
230
231    /// Borrow the underlying repository (for the plumbing layers).
232    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    /// Init a repo with its default branch named `main` (deterministic across
244    /// host git config) and no commits yet.
245    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    /// hq8: `commit_locks()` must hand back THE shared registry (scratch
298    /// worktrees share it, GWS.9), not a fresh default. Kills the
299    /// `Arc::new(Default::default())` mutation survivor.
300    #[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    /// hq8: `worktree_key()` must be the real workdir, not `PathBuf::default()`
313    /// (empty) — else every worktree keys to the same lock. Kills the
314    /// `Default::default()` mutation survivor.
315    #[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}