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, run_git};
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 = run_git(tmp.path(), &["branch", "--list", SESSIONS_BRANCH]);
246        let branches = String::from_utf8_lossy(&output.stdout);
247        assert!(
248            branches.contains("opensession/sessions"),
249            "branch not found: {branches}"
250        );
251    }
252
253    #[test]
254    fn test_not_a_repo() {
255        let tmp = tempfile::tempdir().unwrap();
256        // Don't init git repo
257        let storage = NativeGitStorage;
258        let err = storage
259            .store(tmp.path(), "test", b"data", b"meta")
260            .unwrap_err();
261        assert!(
262            matches!(err, GitStorageError::NotARepo(_)),
263            "expected NotARepo, got: {err}"
264        );
265    }
266
267    #[test]
268    fn test_prune_by_age_no_branch() {
269        let tmp = tempfile::tempdir().unwrap();
270        init_test_repo(tmp.path());
271
272        let storage = NativeGitStorage;
273        let stats = storage
274            .prune_by_age(tmp.path(), 30)
275            .expect("prune should work");
276        assert_eq!(stats, PruneStats::default());
277    }
278
279    #[test]
280    fn test_prune_by_age_rewrites_and_removes_expired_sessions() {
281        let tmp = tempfile::tempdir().unwrap();
282        init_test_repo(tmp.path());
283
284        let storage = NativeGitStorage;
285        storage
286            .store(tmp.path(), "abc123-def456", b"{\"event\":\"one\"}\n", b"{}")
287            .expect("store should succeed");
288        storage
289            .store(tmp.path(), "ff0011-xyz", b"{\"event\":\"two\"}\n", b"{}")
290            .expect("store should succeed");
291
292        let repo = gix::open(tmp.path()).unwrap();
293        let before_tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)
294            .unwrap()
295            .expect("sessions branch should exist")
296            .detach();
297
298        let stats = storage
299            .prune_by_age(tmp.path(), 0)
300            .expect("prune should work");
301        assert!(stats.rewritten);
302        assert_eq!(stats.expired_sessions, 2);
303
304        let repo = gix::open(tmp.path()).unwrap();
305        let after_tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)
306            .unwrap()
307            .expect("sessions branch should exist")
308            .detach();
309        assert_ne!(before_tip, after_tip, "tip should be rewritten");
310
311        let commit = repo.find_commit(after_tip).unwrap();
312        assert_eq!(
313            commit.parent_ids().count(),
314            0,
315            "retention rewrite should produce orphan commit"
316        );
317
318        let output = run_git(tmp.path(), &["ls-tree", "-r", SESSIONS_BRANCH]);
319        let listing = String::from_utf8_lossy(&output.stdout);
320        assert!(
321            !listing.contains(".hail.jsonl"),
322            "expected no retained session blobs after prune: {listing}"
323        );
324    }
325
326    #[test]
327    fn test_prune_by_age_keeps_recent_sessions() {
328        let tmp = tempfile::tempdir().unwrap();
329        init_test_repo(tmp.path());
330
331        let storage = NativeGitStorage;
332        storage
333            .store(tmp.path(), "abc123-def456", b"{\"event\":\"one\"}\n", b"{}")
334            .expect("store should succeed");
335
336        let repo = gix::open(tmp.path()).unwrap();
337        let before_tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)
338            .unwrap()
339            .expect("sessions branch should exist")
340            .detach();
341
342        let stats = storage
343            .prune_by_age(tmp.path(), 36500)
344            .expect("prune should work");
345        assert!(
346            !stats.rewritten,
347            "no prune should occur for very long retention"
348        );
349        assert_eq!(stats.expired_sessions, 0);
350        assert_eq!(stats.scanned_sessions, 1);
351
352        let repo = gix::open(tmp.path()).unwrap();
353        let after_tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)
354            .unwrap()
355            .expect("sessions branch should exist")
356            .detach();
357        assert_eq!(before_tip, after_tip);
358    }
359}