memstead_git_branch/entity/
git_tree_source.rs1use std::path::PathBuf;
10
11use memstead_base::entity::loader::{LoadError, LoadResult, parse_entries};
12use memstead_base::entity::source::{SourceEntry, SourceReadError};
13use memstead_schema::Schema;
14
15pub struct GitTreeSource {
22 pub repo: gix::Repository,
23 pub ref_name: String,
24}
25
26impl GitTreeSource {
27 pub fn read_all(&self) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), LoadError> {
28 read_git_tree(&self.repo, &self.ref_name)
29 }
30}
31
32fn read_git_tree(
42 repo: &gix::Repository,
43 ref_name: &str,
44) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), LoadError> {
45 let mut reference = repo
46 .try_find_reference(ref_name)
47 .map_err(|e| LoadError::GitTree(format!("resolve {ref_name}: {e}")))?
48 .ok_or_else(|| LoadError::RefNotFound(ref_name.to_string()))?;
49
50 let tree = reference
51 .peel_to_tree()
52 .map_err(|e| LoadError::GitTree(format!("peel {ref_name} to tree: {e}")))?;
53
54 let entries = tree
55 .traverse()
56 .breadthfirst
57 .files()
58 .map_err(|e| LoadError::GitTree(format!("traverse {ref_name}: {e}")))?;
59
60 let mut out = Vec::new();
61 let mut errors = Vec::new();
62 for entry in entries {
63 if !entry.mode.is_blob() {
64 continue;
65 }
66 let path = match std::str::from_utf8(entry.filepath.as_slice()) {
67 Ok(s) => s.to_string(),
68 Err(_) => continue,
69 };
70 if !path.ends_with(".md") {
71 continue;
72 }
73 if path.starts_with(".git/") || path.starts_with(".memstead/") {
77 continue;
78 }
79
80 let blob = match repo.find_object(entry.oid) {
81 Ok(o) => o,
82 Err(e) => {
83 errors.push(SourceReadError {
84 source_path: PathBuf::from(&path),
85 error: std::io::Error::other(format!("find blob {}: {e}", entry.oid)),
86 });
87 continue;
88 }
89 };
90 let content = match String::from_utf8(blob.data.clone()) {
91 Ok(s) => s,
92 Err(e) => {
93 errors.push(SourceReadError {
94 source_path: PathBuf::from(&path),
95 error: std::io::Error::new(
96 std::io::ErrorKind::InvalidData,
97 format!("blob {} not utf-8: {e}", entry.oid),
98 ),
99 });
100 continue;
101 }
102 };
103
104 out.push(SourceEntry {
105 relative_path: path.clone(),
106 source_path: PathBuf::from(path),
107 content,
108 });
109 }
110
111 out.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
113
114 Ok((out, errors))
115}
116
117pub fn load_mem_from_git_tree(
125 repo: gix::Repository,
126 ref_name: &str,
127 mem: &str,
128 mem_schema: &Schema,
129) -> Result<LoadResult, LoadError> {
130 let source = GitTreeSource {
131 repo,
132 ref_name: ref_name.to_string(),
133 };
134 let (entries, read_errors) = source.read_all()?;
135 Ok(parse_entries(entries, read_errors, mem, mem_schema))
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use memstead_base::entity::source::EntitySource;
142 use std::fs;
143 use std::path::Path;
144 use tempfile::TempDir;
145
146 use gix::objs::tree::EntryKind;
147
148 fn seed_git_tree(git_dir: &Path, ref_name: &str, entries: &[(&str, &str)]) -> gix::Repository {
152 gix::init_bare(git_dir).unwrap();
153 let repo = gix::open(git_dir).unwrap();
154
155 let mut editor = repo.empty_tree().edit().expect("editor init");
156 for (path, content) in entries {
157 let blob_id = repo.write_blob(content.as_bytes()).unwrap().detach();
158 editor
159 .upsert(*path, EntryKind::Blob, blob_id)
160 .expect("upsert");
161 }
162 let tree_id = editor.write().expect("tree write").detach();
163
164 let time = gix::date::Time {
165 seconds: 0,
166 offset: 0,
167 };
168 let sig = gix::actor::Signature {
169 name: "Test".into(),
170 email: "test@example.com".into(),
171 time,
172 };
173 let mut buf = gix::date::parse::TimeBuf::default();
174 let sig_ref = sig.to_ref(&mut buf);
175 repo.commit_as(
176 sig_ref,
177 sig_ref,
178 ref_name,
179 "seed",
180 tree_id,
181 Vec::<gix::ObjectId>::new(),
182 )
183 .expect("commit_as");
184 repo
185 }
186
187 #[test]
188 fn git_tree_read_matches_directory_read() {
189 let dir = TempDir::new().unwrap();
192 let entries = [
193 ("a.md", "alpha"),
194 ("b.md", "beta"),
195 ("nested/c.md", "gamma"),
196 ("nested/deeper/d.md", "delta"),
197 ("ignored.txt", "skip me"),
198 ];
199
200 let disk_root = dir.path().join("disk");
201 for (rel, content) in entries.iter() {
202 let p = disk_root.join(rel);
203 if let Some(parent) = p.parent() {
204 fs::create_dir_all(parent).unwrap();
205 }
206 fs::write(p, content).unwrap();
207 }
208 let (disk_entries, disk_errs) = EntitySource::Directory { root: disk_root }
209 .read_all()
210 .unwrap();
211 assert!(disk_errs.is_empty());
212
213 let git_dir = dir.path().join("git");
214 let repo = seed_git_tree(&git_dir, "refs/heads/main", &entries);
215 let (tree_entries, tree_errs) = GitTreeSource {
216 repo,
217 ref_name: "refs/heads/main".to_string(),
218 }
219 .read_all()
220 .unwrap();
221 assert!(tree_errs.is_empty());
222
223 let mut disk_pairs: Vec<(String, String)> = disk_entries
224 .iter()
225 .map(|e| (e.relative_path.clone(), e.content.clone()))
226 .collect();
227 let mut tree_pairs: Vec<(String, String)> = tree_entries
228 .iter()
229 .map(|e| (e.relative_path.clone(), e.content.clone()))
230 .collect();
231 disk_pairs.sort();
232 tree_pairs.sort();
233 assert_eq!(disk_pairs, tree_pairs);
234 assert_eq!(tree_pairs.len(), 4);
237 }
238
239 #[test]
240 fn git_tree_skips_engine_internal_dirs() {
241 let dir = TempDir::new().unwrap();
246 let git_dir = dir.path().join("git");
247 let entries = [
248 ("keep.md", "k"),
249 (".memstead/note.md", "n"),
250 (".other/note.md", "n"),
251 ("docs/deep.md", "d"),
252 ];
253 let repo = seed_git_tree(&git_dir, "refs/heads/main", &entries);
254 let (got, errs) = GitTreeSource {
255 repo,
256 ref_name: "refs/heads/main".to_string(),
257 }
258 .read_all()
259 .unwrap();
260 assert!(errs.is_empty());
261 let paths: Vec<_> = got.iter().map(|e| e.relative_path.as_str()).collect();
262 assert!(paths.contains(&"keep.md"), "got {paths:?}");
263 assert!(
264 paths.iter().any(|p| p.ends_with("docs/deep.md")),
265 "regular nested dirs must load: {paths:?}"
266 );
267 assert!(
268 !paths.iter().any(|p| p.starts_with(".memstead/")),
269 ".memstead/* must be skipped: {paths:?}"
270 );
271 assert!(
272 paths.iter().any(|p| p.starts_with(".other/")),
273 "an ordinary dot-dir must load, only `.memstead/` is skipped: {paths:?}"
274 );
275 }
276
277 #[test]
278 fn git_tree_missing_ref_yields_error() {
279 let dir = TempDir::new().unwrap();
280 let git_dir = dir.path().join("git");
281 let repo = seed_git_tree(&git_dir, "refs/heads/main", &[("a.md", "x")]);
283 let err = GitTreeSource {
284 repo,
285 ref_name: "refs/heads/does-not-exist".to_string(),
286 }
287 .read_all()
288 .unwrap_err();
289 match err {
290 LoadError::RefNotFound(name) => {
291 assert!(
292 name.contains("does-not-exist"),
293 "RefNotFound must echo the ref: {name}"
294 );
295 }
296 other => panic!("expected RefNotFound, got {other:?}"),
297 }
298 }
299
300 #[test]
301 fn load_mem_via_git_tree_source_round_trips_a_blob() {
302 let tmp = TempDir::new().unwrap();
303 let gitdir = tmp.path().join("mem-repo").join(".git");
304 std::fs::create_dir_all(&gitdir).unwrap();
305 gix::init_bare(&gitdir).unwrap();
306 let repo = gix::open(&gitdir).unwrap();
307
308 let blob = repo
309 .write_blob(
310 "---\ntype: spec\n---\n# Tree Entity\n\n## Identity\n\nFrom git.\n".as_bytes(),
311 )
312 .unwrap()
313 .detach();
314 let mut editor = repo.empty_tree().edit().unwrap();
315 editor
316 .upsert("tree-entity.md", gix::objs::tree::EntryKind::Blob, blob)
317 .unwrap();
318 let tree_id = editor.write().unwrap().detach();
319
320 let actor = gix::actor::Signature {
321 name: "test".into(),
322 email: "test@example.com".into(),
323 time: gix::date::Time {
324 seconds: 0,
325 offset: 0,
326 },
327 };
328 let mut buf = gix::date::parse::TimeBuf::default();
329 let actor_ref = actor.to_ref(&mut buf);
330 repo.commit_as(
331 actor_ref,
332 actor_ref,
333 "refs/heads/specs",
334 "seed",
335 tree_id,
336 Vec::<gix::ObjectId>::new(),
337 )
338 .unwrap();
339
340 let schema = Schema::builtin_default();
341 let result = load_mem_from_git_tree(repo, "refs/heads/specs", "specs", &schema).unwrap();
342
343 assert_eq!(result.entities.len(), 1);
344 assert!(result.errors.is_empty());
345 assert_eq!(result.entities[0].entity.title, "Tree Entity");
346 }
347}