Skip to main content

memstead_git_branch/entity/
git_tree_source.rs

1//! Git-tree-backed entity source — reads `.md` blobs out of a mem
2//! ref's tree without a working tree on disk.
3//!
4//! Mirrors `Directory`'s walk semantics from
5//! [`memstead_base::entity::source`]: skip `.git/` and `.memstead/`, keep
6//! only `*.md` blobs, return UTF-8 content. Feeds the same parse pipeline
7//! via [`memstead_base::entity::loader::parse_entries`].
8
9use 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
15/// A mem stored as a git ref's tree — no working tree on disk.
16///
17/// `repo` is opened once per call by the engine — fresh opens are
18/// cheap (no clone, no fetch). `ref_name` is fully-qualified
19/// (e.g. `refs/heads/specs`); a missing ref surfaces as
20/// [`LoadError::RefNotFound`].
21pub 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
32/// Resolve `ref_name` in `repo` to a tree, walk every blob entry, and
33/// hand back `(path, content)` pairs for the markdown files. Mirrors
34/// [`memstead_base::entity::source::EntitySource::Directory`]'s walk
35/// semantics (skip `.git/` and `.memstead/`, keep only `*.md` blobs,
36/// return UTF-8 content).
37///
38/// Path strings on tree entries are forward-slash separated regardless
39/// of host OS — matches what `Directory` produces on POSIX hosts and
40/// keeps dual-adapter parity honest.
41fn 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        // Skip engine-internal subtrees. Mirrors `find_markdown_files`
74        // on the disk side. `.git/` cannot exist inside a tree object
75        // (git itself rejects it), but we filter anyway for symmetry.
76        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    // Deterministic ordering, matching the disk walk's post-walk sort.
112    out.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
113
114    Ok((out, errors))
115}
116
117/// Load all entities from a mem stored as a branch in a
118/// `mem-repo-git` repository.
119///
120/// Reads blobs from the git object database and feeds the result
121/// through [`memstead_base::entity::loader::parse_entries`] so the entity
122/// pipeline matches the directory- and archive-backed loaders byte
123/// for byte.
124pub 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    /// Build a fresh bare repository in `git_dir`, write the given
149    /// `(path, content)` entries as blobs into a tree, commit that
150    /// tree to `ref_name`, and return the open repository handle.
151    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        // Parallel fixtures: same byte content under both adapters
190        // must produce the same path-set with byte-identical contents.
191        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        // Both adapters drop `ignored.txt` (non-`.md`) and keep the
235        // four markdown files.
236        assert_eq!(tree_pairs.len(), 4);
237    }
238
239    #[test]
240    fn git_tree_skips_engine_internal_dirs() {
241        // `.memstead/note.md` is always skipped (engine-internal). An
242        // ordinary dot-dir (`.other/note.md`) is not special — it walks
243        // like any ordinary nested dir. `.git/` cannot exist inside a
244        // git tree object so we don't seed it.
245        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        // Need a real repo with at least one ref so `gix::open` succeeds.
282        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}