Skip to main content

turbovault_git/
occ.rs

1//! Per-file optimistic-concurrency precondition (GWS.4) — the multi-file CAS /
2//! "reconsideration domino".
3//!
4//! A changeset reads each target path and remembers the **blob oid** it saw
5//! (the version token: `blob_oid_of(bytes)` for working-tree bytes the agent
6//! read). Before committing, [`VaultRepo::check_preconditions`] re-resolves each
7//! path against the base tree it is building on and confirms the blob oid still
8//! matches. If **any** path changed underneath the changeset, the whole batch
9//! aborts with [`Error::PreconditionFailed`] and nothing is applied — so the
10//! agent re-reads the affected paths and re-decides rather than silently
11//! overwriting a concurrent change.
12//!
13//! This is the WS-B.2 OCC validate phase re-expressed against git blob oids:
14//! content-addressing makes the comparison exact and cheap.
15
16use crate::error::{Error, Result};
17use crate::repo::VaultRepo;
18use git2::{ObjectType, Oid};
19use tracing::instrument;
20
21/// A precondition on one path: the blob oid the caller expects to find in the
22/// base tree. `expected == None` asserts the path is **absent** (a create).
23#[derive(Debug, Clone)]
24pub struct Precondition {
25    pub path: String,
26    pub expected: Option<Oid>,
27}
28
29impl Precondition {
30    /// The path must currently hold exactly this blob (an update of known content).
31    pub fn expect_blob(path: impl Into<String>, blob: Oid) -> Self {
32        Self {
33            path: path.into(),
34            expected: Some(blob),
35        }
36    }
37
38    /// The path must currently be absent (a create).
39    pub fn expect_absent(path: impl Into<String>) -> Self {
40        Self {
41            path: path.into(),
42            expected: None,
43        }
44    }
45}
46
47impl VaultRepo {
48    /// The blob oid of `content` **without writing it** to the object DB — the
49    /// version token for bytes an agent read from the working tree. Equals the
50    /// blob oid that [`Self::build_tree`] would store for the same bytes, so a
51    /// token computed at read time can be compared directly against a base
52    /// tree's entry at commit time.
53    pub fn blob_oid_of(content: &[u8]) -> Result<Oid> {
54        Ok(Oid::hash_object(ObjectType::Blob, content)?)
55    }
56
57    /// Validate every precondition against `base_tree` (the tree the changeset
58    /// is building on; `None` = an empty/unborn base where nothing exists).
59    /// Returns `Ok(())` only if **all** match; the first mismatch aborts with
60    /// [`Error::PreconditionFailed`] (the whole changeset fails, nothing
61    /// applied).
62    #[instrument(
63        skip(self, preconditions),
64        fields(base = ?base_tree, n = preconditions.len()),
65        name = "git_check_preconditions"
66    )]
67    pub fn check_preconditions(
68        &self,
69        base_tree: Option<Oid>,
70        preconditions: &[Precondition],
71    ) -> Result<()> {
72        for pc in preconditions {
73            let found = match base_tree {
74                Some(tree) => self.blob_oid_at(tree, &pc.path)?,
75                None => None, // empty base: every path is absent
76            };
77            if found != pc.expected {
78                return Err(Error::PreconditionFailed {
79                    path: pc.path.clone(),
80                    expected: pc.expected,
81                    found,
82                });
83            }
84        }
85        Ok(())
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::plumbing::TreeChange;
93    use git2::Repository;
94    use tempfile::TempDir;
95
96    fn open_unborn() -> (TempDir, VaultRepo) {
97        let tmp = TempDir::new().unwrap();
98        let mut opts = git2::RepositoryInitOptions::new();
99        opts.initial_head("main");
100        Repository::init_opts(tmp.path(), &opts).unwrap();
101        let vr = VaultRepo::open(tmp.path()).unwrap();
102        (tmp, vr)
103    }
104
105    fn upsert(path: &str, content: &str) -> TreeChange {
106        TreeChange::Upsert {
107            path: path.to_string(),
108            content: content.as_bytes().to_vec(),
109        }
110    }
111
112    #[test]
113    fn version_token_matches_stored_blob() {
114        // The read-path contract: the oid an agent computes from the bytes it read
115        // equals the blob oid stored in the tree for those bytes.
116        let (_tmp, vr) = open_unborn();
117        let t = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
118        let stored = vr.blob_oid_at(t, "a.md").unwrap().unwrap();
119        let token = VaultRepo::blob_oid_of(b"alpha").unwrap();
120        assert_eq!(
121            token, stored,
122            "version token must equal the stored blob oid"
123        );
124    }
125
126    #[test]
127    fn matching_preconditions_pass() {
128        let (_tmp, vr) = open_unborn();
129        let t = vr
130            .build_tree(None, &[upsert("a.md", "alpha"), upsert("b.md", "beta")])
131            .unwrap();
132        let a = VaultRepo::blob_oid_of(b"alpha").unwrap();
133        let b = VaultRepo::blob_oid_of(b"beta").unwrap();
134        vr.check_preconditions(
135            Some(t),
136            &[
137                Precondition::expect_blob("a.md", a),
138                Precondition::expect_blob("b.md", b),
139                Precondition::expect_absent("c.md"),
140            ],
141        )
142        .expect("all preconditions match");
143    }
144
145    #[test]
146    fn changed_blob_fails() {
147        let (_tmp, vr) = open_unborn();
148        let t = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
149        // Caller thinks a.md holds "stale" but it actually holds "alpha".
150        let stale = VaultRepo::blob_oid_of(b"stale").unwrap();
151        match vr.check_preconditions(Some(t), &[Precondition::expect_blob("a.md", stale)]) {
152            Err(Error::PreconditionFailed { path, .. }) => assert_eq!(path, "a.md"),
153            other => panic!("expected PreconditionFailed, got {other:?}"),
154        }
155    }
156
157    #[test]
158    fn expect_absent_but_present_fails() {
159        let (_tmp, vr) = open_unborn();
160        let t = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
161        assert!(matches!(
162            vr.check_preconditions(Some(t), &[Precondition::expect_absent("a.md")]),
163            Err(Error::PreconditionFailed { .. })
164        ));
165    }
166
167    #[test]
168    fn expect_blob_but_absent_fails() {
169        let (_tmp, vr) = open_unborn();
170        let t = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
171        let phantom = VaultRepo::blob_oid_of(b"x").unwrap();
172        assert!(matches!(
173            vr.check_preconditions(Some(t), &[Precondition::expect_blob("missing.md", phantom)]),
174            Err(Error::PreconditionFailed { .. })
175        ));
176    }
177
178    #[test]
179    fn one_stale_among_many_aborts_all() {
180        // The domino: a single stale path fails the whole multi-file check.
181        let (_tmp, vr) = open_unborn();
182        let t = vr
183            .build_tree(None, &[upsert("a.md", "alpha"), upsert("b.md", "beta")])
184            .unwrap();
185        let a = VaultRepo::blob_oid_of(b"alpha").unwrap();
186        let b_stale = VaultRepo::blob_oid_of(b"OLD-beta").unwrap();
187        match vr.check_preconditions(
188            Some(t),
189            &[
190                Precondition::expect_blob("a.md", a),
191                Precondition::expect_blob("b.md", b_stale),
192            ],
193        ) {
194            Err(Error::PreconditionFailed { path, .. }) => assert_eq!(path, "b.md"),
195            other => panic!("expected PreconditionFailed on b.md, got {other:?}"),
196        }
197    }
198
199    #[test]
200    fn empty_base_treats_all_as_absent() {
201        let (_tmp, vr) = open_unborn();
202        // Against an unborn/empty base, expect_absent passes and expect_blob fails.
203        vr.check_preconditions(None, &[Precondition::expect_absent("a.md")])
204            .expect("absent on empty base");
205        let phantom = VaultRepo::blob_oid_of(b"x").unwrap();
206        assert!(matches!(
207            vr.check_preconditions(None, &[Precondition::expect_blob("a.md", phantom)]),
208            Err(Error::PreconditionFailed { .. })
209        ));
210    }
211}