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::Result;
8use crate::ops::{self, gix_err};
9use crate::{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
17/// Result of a git-native retention prune run.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct PruneStats {
20    /// Number of unique sessions observed while scanning history.
21    pub scanned_sessions: usize,
22    /// Number of sessions considered expired by retention policy.
23    pub expired_sessions: usize,
24    /// Whether the sessions ref was rewritten.
25    pub rewritten: bool,
26}
27
28impl NativeGitStorage {
29    /// Compute the storage path prefix for a session ID.
30    /// e.g. session_id "abcdef-1234" → "v1/ab/abcdef-1234"
31    fn session_prefix(session_id: &str) -> String {
32        let prefix = if session_id.len() >= 2 {
33            &session_id[..2]
34        } else {
35            session_id
36        };
37        format!("v1/{prefix}/{session_id}")
38    }
39
40    fn session_id_from_commit_message(message: &str) -> Option<&str> {
41        let first = message.lines().next()?.trim();
42        let id = first.strip_prefix("session: ")?.trim();
43        if id.is_empty() {
44            None
45        } else {
46            Some(id)
47        }
48    }
49}
50
51impl NativeGitStorage {
52    /// Store a session in the git repository at `repo_path`.
53    ///
54    /// Creates the orphan branch if it doesn't exist, then adds/updates blobs
55    /// for the HAIL JSONL and metadata JSON under `v1/<prefix>/<id>.*`.
56    ///
57    /// Returns the relative path within the branch (e.g. `v1/ab/abcdef.hail.jsonl`).
58    pub fn store(
59        &self,
60        repo_path: &Path,
61        session_id: &str,
62        hail_jsonl: &[u8],
63        meta_json: &[u8],
64    ) -> Result<String> {
65        let repo = ops::open_repo(repo_path)?;
66        let hash_kind = repo.object_hash();
67
68        // Write blobs
69        let hail_blob = repo.write_blob(hail_jsonl).map_err(gix_err)?.detach();
70        let meta_blob = repo.write_blob(meta_json).map_err(gix_err)?.detach();
71
72        debug!(
73            session_id,
74            hail_blob = %hail_blob,
75            meta_blob = %meta_blob,
76            "Wrote session blobs"
77        );
78
79        let prefix = Self::session_prefix(session_id);
80        let hail_path = format!("{prefix}.hail.jsonl");
81        let meta_path = format!("{prefix}.meta.json");
82
83        // Determine base tree: existing branch tree or empty tree
84        let tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)?;
85        let base_tree_id = match &tip {
86            Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
87            None => ObjectId::empty_tree(hash_kind),
88        };
89
90        // Build new tree using editor
91        let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
92        editor
93            .upsert(&hail_path, EntryKind::Blob, hail_blob)
94            .map_err(gix_err)?;
95        editor
96            .upsert(&meta_path, EntryKind::Blob, meta_blob)
97            .map_err(gix_err)?;
98
99        let new_tree_id = editor.write().map_err(gix_err)?.detach();
100
101        debug!(tree = %new_tree_id, "Built new tree");
102
103        let parent = tip.map(|id| id.detach());
104        let message = format!("session: {session_id}");
105        let commit_id = ops::create_commit(&repo, SESSIONS_REF, new_tree_id, parent, &message)?;
106
107        info!(
108            session_id,
109            commit = %commit_id,
110            "Stored session on {SESSIONS_BRANCH}"
111        );
112
113        Ok(hail_path)
114    }
115
116    /// Prune expired sessions from the sessions branch by age (days).
117    ///
118    /// This rewrites `opensession/sessions` to a new orphan commit containing
119    /// only currently retained paths.
120    pub fn prune_by_age(&self, repo_path: &Path, keep_days: u32) -> Result<PruneStats> {
121        let repo = ops::open_repo(repo_path)?;
122        let tip = match ops::find_ref_tip(&repo, SESSIONS_BRANCH)? {
123            Some(tip) => tip.detach(),
124            None => return Ok(PruneStats::default()),
125        };
126
127        let cutoff = chrono::Utc::now()
128            .timestamp()
129            .saturating_sub((keep_days as i64).saturating_mul(24 * 60 * 60));
130
131        // First-parent walk from tip to capture latest-seen timestamp per session.
132        let mut latest_seen: std::collections::HashMap<String, i64> =
133            std::collections::HashMap::new();
134        let mut current = Some(tip);
135        while let Some(commit_id) = current {
136            let commit = repo.find_commit(commit_id).map_err(gix_err)?;
137
138            let message = String::from_utf8_lossy(commit.message_raw_sloppy().as_ref());
139            if let Some(session_id) = Self::session_id_from_commit_message(&message) {
140                latest_seen
141                    .entry(session_id.to_string())
142                    .or_insert(commit.time().map_err(gix_err)?.seconds);
143            }
144
145            current = commit.parent_ids().next().map(|id| id.detach());
146        }
147
148        let mut expired: Vec<String> = latest_seen
149            .iter()
150            .filter_map(|(id, ts)| {
151                if *ts <= cutoff {
152                    Some(id.clone())
153                } else {
154                    None
155                }
156            })
157            .collect();
158        expired.sort();
159
160        if expired.is_empty() {
161            return Ok(PruneStats {
162                scanned_sessions: latest_seen.len(),
163                expired_sessions: 0,
164                rewritten: false,
165            });
166        }
167
168        let base_tree_id = ops::commit_tree_id(&repo, tip)?;
169        let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
170        for session_id in &expired {
171            let prefix = Self::session_prefix(session_id);
172            let hail_path = format!("{prefix}.hail.jsonl");
173            let meta_path = format!("{prefix}.meta.json");
174            editor.remove(&hail_path).map_err(gix_err)?;
175            editor.remove(&meta_path).map_err(gix_err)?;
176        }
177
178        let new_tree_id = editor.write().map_err(gix_err)?.detach();
179        let message = format!(
180            "retention-prune: keep_days={keep_days} expired={}",
181            expired.len()
182        );
183        let sig = ops::make_signature();
184        let commit = gix::objs::Commit {
185            message: message.clone().into(),
186            tree: new_tree_id,
187            author: sig.clone(),
188            committer: sig,
189            encoding: None,
190            parents: Vec::<ObjectId>::new().into(),
191            extra_headers: Default::default(),
192        };
193        let new_tip = repo.write_object(&commit).map_err(gix_err)?.detach();
194        ops::replace_ref_tip(&repo, SESSIONS_REF, tip, new_tip, &message)?;
195
196        info!(
197            keep_days,
198            expired_sessions = expired.len(),
199            old_tip = %tip,
200            new_tip = %new_tip,
201            "Pruned expired sessions on {SESSIONS_BRANCH}"
202        );
203
204        Ok(PruneStats {
205            scanned_sessions: latest_seen.len(),
206            expired_sessions: expired.len(),
207            rewritten: true,
208        })
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::error::GitStorageError;
216    use crate::test_utils::init_test_repo;
217    use crate::{ops, SESSIONS_BRANCH};
218
219    #[test]
220    fn test_session_prefix() {
221        assert_eq!(
222            NativeGitStorage::session_prefix("abcdef-1234"),
223            "v1/ab/abcdef-1234"
224        );
225        assert_eq!(NativeGitStorage::session_prefix("x"), "v1/x/x");
226        assert_eq!(NativeGitStorage::session_prefix("ab"), "v1/ab/ab");
227    }
228
229    #[test]
230    fn test_store() {
231        let tmp = tempfile::tempdir().unwrap();
232        init_test_repo(tmp.path());
233
234        let storage = NativeGitStorage;
235        let hail = b"{\"event\":\"test\"}\n";
236        let meta = b"{\"title\":\"Test Session\"}";
237
238        // Store
239        let rel_path = storage
240            .store(tmp.path(), "abc123-def456", hail, meta)
241            .expect("store failed");
242        assert_eq!(rel_path, "v1/ab/abc123-def456.hail.jsonl");
243
244        // Verify branch exists
245        let output = std::process::Command::new("git")
246            .args(["branch", "--list", SESSIONS_BRANCH])
247            .current_dir(tmp.path())
248            .output()
249            .unwrap();
250        let branches = String::from_utf8_lossy(&output.stdout);
251        assert!(
252            branches.contains("opensession/sessions"),
253            "branch not found: {branches}"
254        );
255    }
256
257    #[test]
258    fn test_not_a_repo() {
259        let tmp = tempfile::tempdir().unwrap();
260        // Don't init git repo
261        let storage = NativeGitStorage;
262        let err = storage
263            .store(tmp.path(), "test", b"data", b"meta")
264            .unwrap_err();
265        assert!(
266            matches!(err, GitStorageError::NotARepo(_)),
267            "expected NotARepo, got: {err}"
268        );
269    }
270
271    #[test]
272    fn test_prune_by_age_no_branch() {
273        let tmp = tempfile::tempdir().unwrap();
274        init_test_repo(tmp.path());
275
276        let storage = NativeGitStorage;
277        let stats = storage
278            .prune_by_age(tmp.path(), 30)
279            .expect("prune should work");
280        assert_eq!(stats, PruneStats::default());
281    }
282
283    #[test]
284    fn test_prune_by_age_rewrites_and_removes_expired_sessions() {
285        let tmp = tempfile::tempdir().unwrap();
286        init_test_repo(tmp.path());
287
288        let storage = NativeGitStorage;
289        storage
290            .store(tmp.path(), "abc123-def456", b"{\"event\":\"one\"}\n", b"{}")
291            .expect("store should succeed");
292        storage
293            .store(tmp.path(), "ff0011-xyz", b"{\"event\":\"two\"}\n", b"{}")
294            .expect("store should succeed");
295
296        let repo = gix::open(tmp.path()).unwrap();
297        let before_tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)
298            .unwrap()
299            .expect("sessions branch should exist")
300            .detach();
301
302        let stats = storage
303            .prune_by_age(tmp.path(), 0)
304            .expect("prune should work");
305        assert!(stats.rewritten);
306        assert_eq!(stats.expired_sessions, 2);
307
308        let repo = gix::open(tmp.path()).unwrap();
309        let after_tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)
310            .unwrap()
311            .expect("sessions branch should exist")
312            .detach();
313        assert_ne!(before_tip, after_tip, "tip should be rewritten");
314
315        let commit = repo.find_commit(after_tip).unwrap();
316        assert_eq!(
317            commit.parent_ids().count(),
318            0,
319            "retention rewrite should produce orphan commit"
320        );
321
322        let output = std::process::Command::new("git")
323            .args(["ls-tree", "-r", SESSIONS_BRANCH])
324            .current_dir(tmp.path())
325            .output()
326            .unwrap();
327        let listing = String::from_utf8_lossy(&output.stdout);
328        assert!(
329            !listing.contains(".hail.jsonl"),
330            "expected no retained session blobs after prune: {listing}"
331        );
332    }
333
334    #[test]
335    fn test_prune_by_age_keeps_recent_sessions() {
336        let tmp = tempfile::tempdir().unwrap();
337        init_test_repo(tmp.path());
338
339        let storage = NativeGitStorage;
340        storage
341            .store(tmp.path(), "abc123-def456", b"{\"event\":\"one\"}\n", b"{}")
342            .expect("store should succeed");
343
344        let repo = gix::open(tmp.path()).unwrap();
345        let before_tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)
346            .unwrap()
347            .expect("sessions branch should exist")
348            .detach();
349
350        let stats = storage
351            .prune_by_age(tmp.path(), 36500)
352            .expect("prune should work");
353        assert!(
354            !stats.rewritten,
355            "no prune should occur for very long retention"
356        );
357        assert_eq!(stats.expired_sessions, 0);
358        assert_eq!(stats.scanned_sessions, 1);
359
360        let repo = gix::open(tmp.path()).unwrap();
361        let after_tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)
362            .unwrap()
363            .expect("sessions branch should exist")
364            .detach();
365        assert_eq!(before_tip, after_tip);
366    }
367}