Skip to main content

turbovault_git/
plumbing.rs

1//! Object-DB plumbing (GWS.2): build trees in an **isolated index** and create
2//! commit objects, with **no working-tree interaction**.
3//!
4//! The substrate stages from the batch's own bytes (not the working tree) into
5//! an ephemeral `git2::Index` that is never bound to `.git/index`, seeds it from
6//! a parent tree, applies the changeset's changes, and writes the tree +
7//! commit to the object DB. Advancing a ref (CAS) and materializing the working
8//! tree are separate, later steps (GWS.3, GWS.5).
9
10use crate::error::{Error, Result};
11use crate::repo::VaultRepo;
12use git2::{Commit, Index, IndexEntry, IndexTime, Oid, Signature};
13use std::path::Path;
14use tracing::instrument;
15
16/// A single change to apply to a tree. Moves are modeled at the op-mapping layer
17/// (GWS.8) as `Remove(old)` + `Upsert(new)`.
18#[derive(Debug, Clone)]
19pub enum TreeChange {
20    /// Add a new file or overwrite an existing one with `content`.
21    Upsert { path: String, content: Vec<u8> },
22    /// Remove a file from the tree.
23    Remove { path: String },
24}
25
26impl TreeChange {
27    /// The vault-relative path this change targets.
28    pub fn path(&self) -> &str {
29        match self {
30            TreeChange::Upsert { path, .. } | TreeChange::Remove { path } => path,
31        }
32    }
33}
34
35impl VaultRepo {
36    /// Build a tree from `base` (a parent commit's tree oid, or `None` for an
37    /// empty base) applying `changes` in an **isolated in-memory index**. Blobs
38    /// and the resulting tree are written to the object DB. The shared
39    /// `.git/index` is never touched. Returns the new tree oid.
40    #[instrument(
41        skip(self, changes),
42        fields(base = ?base, n_changes = changes.len()),
43        name = "git_build_tree"
44    )]
45    pub fn build_tree(&self, base: Option<Oid>, changes: &[TreeChange]) -> Result<Oid> {
46        let repo = self.git();
47        let mut index = Index::new()?;
48        if let Some(base_oid) = base {
49            let tree = repo.find_tree(base_oid)?;
50            index.read_tree(&tree)?;
51        }
52        for change in changes {
53            match change {
54                TreeChange::Upsert { path, content } => {
55                    let blob = repo.blob(content)?;
56                    index.add(&IndexEntry {
57                        ctime: IndexTime::new(0, 0),
58                        mtime: IndexTime::new(0, 0),
59                        dev: 0,
60                        ino: 0,
61                        mode: 0o100_644,
62                        uid: 0,
63                        gid: 0,
64                        file_size: content.len() as u32,
65                        id: blob,
66                        flags: 0,
67                        flags_extended: 0,
68                        path: path.as_bytes().to_vec(),
69                    })?;
70                }
71                TreeChange::Remove { path } => {
72                    index.remove_path(Path::new(path))?;
73                }
74            }
75        }
76        Ok(index.write_tree_to(repo)?)
77    }
78
79    /// Create a commit object from `tree` and `parents` **without moving any
80    /// ref** (this is `commit-tree`, not `commit`). The ref advance is a separate
81    /// CAS step (GWS.3). Returns the new commit oid.
82    #[instrument(
83        skip(self),
84        fields(tree = %tree, n_parents = parents.len(), message = %message),
85        name = "git_commit_tree"
86    )]
87    pub fn commit_tree(&self, tree: Oid, parents: &[Oid], message: &str) -> Result<Oid> {
88        let repo = self.git();
89        let sig = self.author_signature()?;
90        let tree = repo.find_tree(tree)?;
91        let parent_commits: Vec<Commit> = parents
92            .iter()
93            .map(|oid| repo.find_commit(*oid))
94            .collect::<std::result::Result<_, _>>()?;
95        let parent_refs: Vec<&Commit> = parent_commits.iter().collect();
96        Ok(repo.commit(None, &sig, &sig, message, &tree, &parent_refs)?)
97    }
98
99    /// The blob oid at `path` in `tree`, or `None` if absent. This is the value
100    /// a changeset reads as its CAS pre-image (GWS.4) and what materialization
101    /// resolves to working-tree bytes (GWS.5).
102    pub fn blob_oid_at(&self, tree: Oid, path: &str) -> Result<Option<Oid>> {
103        let tree = self.git().find_tree(tree)?;
104        match tree.get_path(Path::new(path)) {
105            Ok(entry) => Ok(Some(entry.id())),
106            Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
107            Err(e) => Err(Error::Git(e)),
108        }
109    }
110
111    /// Read a blob's bytes by oid.
112    pub fn read_blob(&self, oid: Oid) -> Result<Vec<u8>> {
113        Ok(self.git().find_blob(oid)?.content().to_vec())
114    }
115
116    /// Author/committer signature.
117    ///
118    /// turbovault-ov7 / TV-004: defaults to the built-in
119    /// `TurboVault <turbovault@localhost>` identity so machine-authored
120    /// commits are visibly distinguishable from human commits in
121    /// `git log` / `git blame`. The previous behavior pulled the
122    /// operator's global `user.name` / `user.email` first, muddying
123    /// the audit trail and blocking "act only on bot commits"
124    /// automation.
125    ///
126    /// Per-vault override via `VaultGitConfig::author` is the
127    /// documented upgrade path (architecture §13.5); plumbing that
128    /// override into the substrate is a follow-up — until then this
129    /// is the single default.
130    fn author_signature(&self) -> Result<Signature<'static>> {
131        Ok(Signature::now("TurboVault", "turbovault@localhost")?)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use git2::Repository;
139    use tempfile::TempDir;
140
141    fn open_unborn() -> (TempDir, VaultRepo) {
142        let tmp = TempDir::new().unwrap();
143        let mut opts = git2::RepositoryInitOptions::new();
144        opts.initial_head("main");
145        Repository::init_opts(tmp.path(), &opts).unwrap();
146        let vr = VaultRepo::open(tmp.path()).unwrap();
147        (tmp, vr)
148    }
149
150    fn upsert(path: &str, content: &str) -> TreeChange {
151        TreeChange::Upsert {
152            path: path.to_string(),
153            content: content.as_bytes().to_vec(),
154        }
155    }
156
157    #[test]
158    fn build_tree_from_empty_base() {
159        let (_tmp, vr) = open_unborn();
160        let t = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
161        let oid = vr.blob_oid_at(t, "a.md").unwrap().expect("a.md present");
162        assert_eq!(vr.read_blob(oid).unwrap(), b"alpha");
163        assert!(vr.blob_oid_at(t, "missing.md").unwrap().is_none());
164    }
165
166    #[test]
167    fn build_tree_seeds_from_base_and_adds() {
168        let (_tmp, vr) = open_unborn();
169        let t1 = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
170        let t2 = vr.build_tree(Some(t1), &[upsert("b.md", "beta")]).unwrap();
171        // a.md preserved, b.md added.
172        assert!(vr.blob_oid_at(t2, "a.md").unwrap().is_some());
173        let b = vr.blob_oid_at(t2, "b.md").unwrap().unwrap();
174        assert_eq!(vr.read_blob(b).unwrap(), b"beta");
175    }
176
177    #[test]
178    fn upsert_overwrites_existing() {
179        let (_tmp, vr) = open_unborn();
180        let t1 = vr.build_tree(None, &[upsert("a.md", "v1")]).unwrap();
181        let t2 = vr.build_tree(Some(t1), &[upsert("a.md", "v2")]).unwrap();
182        let oid = vr.blob_oid_at(t2, "a.md").unwrap().unwrap();
183        assert_eq!(vr.read_blob(oid).unwrap(), b"v2");
184    }
185
186    #[test]
187    fn remove_drops_path() {
188        let (_tmp, vr) = open_unborn();
189        let t1 = vr
190            .build_tree(None, &[upsert("a.md", "alpha"), upsert("b.md", "beta")])
191            .unwrap();
192        let t2 = vr
193            .build_tree(
194                Some(t1),
195                &[TreeChange::Remove {
196                    path: "a.md".to_string(),
197                }],
198            )
199            .unwrap();
200        assert!(
201            vr.blob_oid_at(t2, "a.md").unwrap().is_none(),
202            "a.md removed"
203        );
204        assert!(vr.blob_oid_at(t2, "b.md").unwrap().is_some(), "b.md kept");
205    }
206
207    #[test]
208    fn commit_tree_creates_object_without_moving_ref() {
209        let (_tmp, vr) = open_unborn();
210        let t1 = vr.build_tree(None, &[upsert("a.md", "alpha")]).unwrap();
211        let c1 = vr.commit_tree(t1, &[], "init").unwrap();
212
213        // The branch is still unborn: commit_tree built an object but moved no ref.
214        assert!(vr.is_unborn(), "commit_tree must NOT advance any ref");
215        assert_eq!(vr.head_oid(), None);
216
217        // Parent linkage + tree content round-trip.
218        let t2 = vr.build_tree(Some(t1), &[upsert("b.md", "beta")]).unwrap();
219        let c2 = vr.commit_tree(t2, &[c1], "add b").unwrap();
220        let commit2 = vr.git().find_commit(c2).unwrap();
221        assert_eq!(commit2.parent_count(), 1);
222        assert_eq!(commit2.parent_id(0).unwrap(), c1);
223        let b = vr.blob_oid_at(commit2.tree_id(), "b.md").unwrap().unwrap();
224        assert_eq!(vr.read_blob(b).unwrap(), b"beta");
225    }
226}