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