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
11pub struct NativeGitStorage;
16
17impl NativeGitStorage {
18 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 storage
325 .store(tmp.path(), "exists-001", b"data", b"meta")
326 .unwrap();
327
328 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 let result = storage.load(tmp.path(), "any-id").unwrap();
341 assert!(result.is_none());
342 }
343}