1use 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#[derive(Debug, Clone)]
19pub enum TreeChange {
20 Upsert { path: String, content: Vec<u8> },
22 Remove { path: String },
24}
25
26impl TreeChange {
27 pub fn path(&self) -> &str {
29 match self {
30 TreeChange::Upsert { path, .. } | TreeChange::Remove { path } => path,
31 }
32 }
33}
34
35impl VaultRepo {
36 #[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 #[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 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 pub fn read_blob(&self, oid: Oid) -> Result<Vec<u8>> {
113 Ok(self.git().find_blob(oid)?.content().to_vec())
114 }
115
116 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 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 assert!(vr.is_unborn(), "commit_tree must NOT advance any ref");
215 assert_eq!(vr.head_oid(), None);
216
217 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}