Skip to main content

opensession_git_native/
store.rs

1use std::path::Path;
2
3use gix::object::tree::EntryKind;
4use gix::ObjectId;
5use tracing::{debug, info};
6
7use crate::error::{GitStorageError, Result};
8use crate::ops::{self, gix_err};
9use crate::{GitStorage, SESSIONS_BRANCH, SESSIONS_REF};
10
11/// Git-native session storage using gix.
12///
13/// Stores session data (HAIL JSONL + metadata JSON) as blobs on an orphan
14/// branch (`opensession/sessions`) without touching the working directory.
15pub struct NativeGitStorage;
16
17impl NativeGitStorage {
18    /// Compute the storage path prefix for a session ID.
19    /// e.g. session_id "abcdef-1234" → "v1/ab/abcdef-1234"
20    fn session_prefix(session_id: &str) -> String {
21        let prefix = if session_id.len() >= 2 {
22            &session_id[..2]
23        } else {
24            session_id
25        };
26        format!("v1/{prefix}/{session_id}")
27    }
28}
29
30impl GitStorage for NativeGitStorage {
31    fn store(
32        &self,
33        repo_path: &Path,
34        session_id: &str,
35        hail_jsonl: &[u8],
36        meta_json: &[u8],
37    ) -> Result<String> {
38        let repo = ops::open_repo(repo_path)?;
39        let hash_kind = repo.object_hash();
40
41        // Write blobs
42        let hail_blob = repo.write_blob(hail_jsonl).map_err(gix_err)?.detach();
43        let meta_blob = repo.write_blob(meta_json).map_err(gix_err)?.detach();
44
45        debug!(
46            session_id,
47            hail_blob = %hail_blob,
48            meta_blob = %meta_blob,
49            "Wrote session blobs"
50        );
51
52        let prefix = Self::session_prefix(session_id);
53        let hail_path = format!("{prefix}.hail.jsonl");
54        let meta_path = format!("{prefix}.meta.json");
55
56        // Determine base tree: existing branch tree or empty tree
57        let tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)?;
58        let base_tree_id = match &tip {
59            Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
60            None => ObjectId::empty_tree(hash_kind),
61        };
62
63        // Build new tree using editor
64        let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
65        editor
66            .upsert(&hail_path, EntryKind::Blob, hail_blob)
67            .map_err(gix_err)?;
68        editor
69            .upsert(&meta_path, EntryKind::Blob, meta_blob)
70            .map_err(gix_err)?;
71
72        let new_tree_id = editor.write().map_err(gix_err)?.detach();
73
74        debug!(tree = %new_tree_id, "Built new tree");
75
76        let parent = tip.map(|id| id.detach());
77        let message = format!("session: {session_id}");
78        let commit_id = ops::create_commit(&repo, SESSIONS_REF, new_tree_id, parent, &message)?;
79
80        info!(
81            session_id,
82            commit = %commit_id,
83            "Stored session on {SESSIONS_BRANCH}"
84        );
85
86        Ok(hail_path)
87    }
88
89    fn load(&self, repo_path: &Path, session_id: &str) -> Result<Option<Vec<u8>>> {
90        let repo = ops::open_repo(repo_path)?;
91
92        let tip = match ops::find_ref_tip(&repo, SESSIONS_BRANCH)? {
93            Some(id) => id,
94            None => return Ok(None),
95        };
96
97        let tree_id = ops::commit_tree_id(&repo, tip.detach())?;
98        let tree = repo.find_tree(tree_id).map_err(gix_err)?;
99
100        let prefix = Self::session_prefix(session_id);
101        let hail_path = format!("{prefix}.hail.jsonl");
102
103        match tree.lookup_entry_by_path(hail_path).map_err(gix_err)? {
104            Some(entry) => {
105                let object = entry.object().map_err(gix_err)?;
106                Ok(Some(object.data.to_vec()))
107            }
108            None => Ok(None),
109        }
110    }
111
112    fn list(&self, repo_path: &Path) -> Result<Vec<String>> {
113        let repo = ops::open_repo(repo_path)?;
114
115        let tip = match ops::find_ref_tip(&repo, SESSIONS_BRANCH)? {
116            Some(id) => id,
117            None => return Ok(Vec::new()),
118        };
119
120        let tree_id = ops::commit_tree_id(&repo, tip.detach())?;
121        let tree = repo.find_tree(tree_id).map_err(gix_err)?;
122
123        // Navigate to v1/ subtree
124        let v1_entry = match tree.lookup_entry_by_path("v1").map_err(gix_err)? {
125            Some(e) => e,
126            None => return Ok(Vec::new()),
127        };
128
129        let v1_tree = v1_entry
130            .object()
131            .map_err(gix_err)?
132            .try_into_tree()
133            .map_err(gix_err)?;
134
135        let mut session_ids = Vec::new();
136
137        // Iterate prefix directories (2-char hex prefixes)
138        for entry in v1_tree.iter() {
139            let entry = entry.map_err(|e| GitStorageError::Gix(Box::new(e)))?;
140            if !entry.mode().is_tree() {
141                continue;
142            }
143
144            let prefix_tree = repo
145                .find_object(entry.oid())
146                .map_err(gix_err)?
147                .try_into_tree()
148                .map_err(gix_err)?;
149
150            for file_entry in prefix_tree.iter() {
151                let file_entry = file_entry.map_err(|e| GitStorageError::Gix(Box::new(e)))?;
152                let filename = file_entry.filename().to_string();
153                if let Some(id) = filename.strip_suffix(".hail.jsonl") {
154                    session_ids.push(id.to_string());
155                }
156            }
157        }
158
159        Ok(session_ids)
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::test_utils::init_test_repo;
167    use crate::SESSIONS_BRANCH;
168
169    #[test]
170    fn test_session_prefix() {
171        assert_eq!(
172            NativeGitStorage::session_prefix("abcdef-1234"),
173            "v1/ab/abcdef-1234"
174        );
175        assert_eq!(NativeGitStorage::session_prefix("x"), "v1/x/x");
176        assert_eq!(NativeGitStorage::session_prefix("ab"), "v1/ab/ab");
177    }
178
179    #[test]
180    fn test_store_and_load() {
181        let tmp = tempfile::tempdir().unwrap();
182        init_test_repo(tmp.path());
183
184        let storage = NativeGitStorage;
185        let hail = b"{\"event\":\"test\"}\n";
186        let meta = b"{\"title\":\"Test Session\"}";
187
188        // Store
189        let rel_path = storage
190            .store(tmp.path(), "abc123-def456", hail, meta)
191            .expect("store failed");
192        assert_eq!(rel_path, "v1/ab/abc123-def456.hail.jsonl");
193
194        // Verify branch exists
195        let output = std::process::Command::new("git")
196            .args(["branch", "--list", SESSIONS_BRANCH])
197            .current_dir(tmp.path())
198            .output()
199            .unwrap();
200        let branches = String::from_utf8_lossy(&output.stdout);
201        assert!(
202            branches.contains("opensession/sessions"),
203            "branch not found: {branches}"
204        );
205
206        // Load
207        let loaded = storage
208            .load(tmp.path(), "abc123-def456")
209            .expect("load failed");
210        assert_eq!(loaded.as_deref(), Some(hail.as_slice()));
211
212        // Load non-existent
213        let missing = storage
214            .load(tmp.path(), "nonexistent")
215            .expect("load failed");
216        assert!(missing.is_none());
217    }
218
219    #[test]
220    fn test_store_multiple_and_list() {
221        let tmp = tempfile::tempdir().unwrap();
222        init_test_repo(tmp.path());
223
224        let storage = NativeGitStorage;
225
226        // Store 3 sessions with different prefixes
227        storage
228            .store(tmp.path(), "aaa111", b"hail-1", b"meta-1")
229            .unwrap();
230        storage
231            .store(tmp.path(), "bbb222", b"hail-2", b"meta-2")
232            .unwrap();
233        storage
234            .store(tmp.path(), "aaa333", b"hail-3", b"meta-3")
235            .unwrap();
236
237        // List
238        let mut ids = storage.list(tmp.path()).expect("list failed");
239        ids.sort();
240        assert_eq!(ids, vec!["aaa111", "aaa333", "bbb222"]);
241    }
242
243    #[test]
244    fn test_list_empty_repo() {
245        let tmp = tempfile::tempdir().unwrap();
246        init_test_repo(tmp.path());
247
248        let storage = NativeGitStorage;
249        let ids = storage.list(tmp.path()).expect("list failed");
250        assert!(ids.is_empty());
251    }
252
253    #[test]
254    fn test_store_overwrite() {
255        let tmp = tempfile::tempdir().unwrap();
256        init_test_repo(tmp.path());
257
258        let storage = NativeGitStorage;
259
260        // Store initial version
261        storage
262            .store(tmp.path(), "sess001", b"version-1", b"meta-1")
263            .unwrap();
264        let v1 = storage.load(tmp.path(), "sess001").unwrap().unwrap();
265        assert_eq!(v1, b"version-1");
266
267        // Overwrite with new version
268        storage
269            .store(tmp.path(), "sess001", b"version-2", b"meta-2")
270            .unwrap();
271        let v2 = storage.load(tmp.path(), "sess001").unwrap().unwrap();
272        assert_eq!(v2, b"version-2");
273
274        // List should still have only 1 session
275        let ids = storage.list(tmp.path()).unwrap();
276        assert_eq!(ids, vec!["sess001"]);
277    }
278
279    #[test]
280    fn test_not_a_repo() {
281        let tmp = tempfile::tempdir().unwrap();
282        // Don't init git repo
283        let storage = NativeGitStorage;
284        let err = storage
285            .store(tmp.path(), "test", b"data", b"meta")
286            .unwrap_err();
287        assert!(
288            matches!(err, GitStorageError::NotARepo(_)),
289            "expected NotARepo, got: {err}"
290        );
291    }
292
293    #[test]
294    fn test_load_invalid_repo() {
295        let tmp = tempfile::tempdir().unwrap();
296        // Don't init git repo
297        let storage = NativeGitStorage;
298        let err = storage.load(tmp.path(), "test").unwrap_err();
299        assert!(
300            matches!(err, GitStorageError::NotARepo(_)),
301            "expected NotARepo, got: {err}"
302        );
303    }
304
305    #[test]
306    fn test_list_invalid_repo() {
307        let tmp = tempfile::tempdir().unwrap();
308        // Don't init git repo
309        let storage = NativeGitStorage;
310        let err = storage.list(tmp.path()).unwrap_err();
311        assert!(
312            matches!(err, GitStorageError::NotARepo(_)),
313            "expected NotARepo, got: {err}"
314        );
315    }
316
317    #[test]
318    fn test_load_nonexistent_session() {
319        let tmp = tempfile::tempdir().unwrap();
320        init_test_repo(tmp.path());
321
322        let storage = NativeGitStorage;
323        // Store one session so the branch exists
324        storage
325            .store(tmp.path(), "exists-001", b"data", b"meta")
326            .unwrap();
327
328        // Load a completely different session that was never stored
329        let result = storage.load(tmp.path(), "does-not-exist").unwrap();
330        assert!(result.is_none());
331    }
332
333    #[test]
334    fn test_load_before_any_store() {
335        let tmp = tempfile::tempdir().unwrap();
336        init_test_repo(tmp.path());
337
338        let storage = NativeGitStorage;
339        // Branch doesn't exist yet — load should return None
340        let result = storage.load(tmp.path(), "any-id").unwrap();
341        assert!(result.is_none());
342    }
343}