Skip to main content

opensession_git_native/
store.rs

1use std::path::Path;
2
3use gix::object::tree::EntryKind;
4use gix::ObjectId;
5use serde_json::json;
6use tracing::{debug, info};
7
8use crate::error::Result;
9use crate::ops::{self, gix_err};
10
11/// Git-native session storage using gix.
12///
13/// Stores session data (HAIL JSONL + metadata JSON) as blobs on an explicit
14/// hidden ledger ref 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
28/// Result of storing a session at an explicit ref.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct StoredSessionRecord {
31    pub ref_name: String,
32    pub commit_id: String,
33    pub hail_path: String,
34    pub meta_path: String,
35}
36
37impl NativeGitStorage {
38    /// Compute the storage path prefix for a session ID.
39    /// e.g. session_id "abcdef-1234" → "v1/ab/abcdef-1234"
40    fn session_prefix(session_id: &str) -> String {
41        let prefix = if session_id.len() >= 2 {
42            &session_id[..2]
43        } else {
44            session_id
45        };
46        format!("v1/{prefix}/{session_id}")
47    }
48
49    fn session_id_from_commit_message(message: &str) -> Option<&str> {
50        let first = message.lines().next()?.trim();
51        let id = first.strip_prefix("session: ")?.trim();
52        if id.is_empty() {
53            None
54        } else {
55            Some(id)
56        }
57    }
58
59    fn commit_index_path(commit_sha: &str, session_id: &str) -> String {
60        format!(
61            "v1/index/commits/{}/{}.json",
62            sanitize_path_component(commit_sha),
63            sanitize_path_component(session_id)
64        )
65    }
66
67    fn commit_index_payload(
68        session_id: &str,
69        hail_path: &str,
70        meta_path: &str,
71    ) -> serde_json::Value {
72        json!({
73            "session_id": session_id,
74            "hail_path": hail_path,
75            "meta_path": meta_path,
76            "stored_at": chrono::Utc::now().to_rfc3339(),
77        })
78    }
79}
80
81fn sanitize_path_component(raw: &str) -> String {
82    raw.chars()
83        .map(|c| {
84            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
85                c
86            } else {
87                '_'
88            }
89        })
90        .collect()
91}
92
93/// Store arbitrary blob content under a specific ref/path without touching the working tree.
94///
95/// This powers `opensession share --git`, which needs explicit ref/path control.
96pub fn store_blob_at_ref(
97    repo_path: &Path,
98    ref_name: &str,
99    rel_path: &str,
100    body: &[u8],
101    message: &str,
102) -> Result<ObjectId> {
103    let repo = ops::open_repo(repo_path)?;
104    let hash_kind = repo.object_hash();
105
106    let blob = repo.write_blob(body).map_err(gix_err)?.detach();
107    let tip = ops::find_ref_tip(&repo, ref_name)?;
108    let base_tree_id = match &tip {
109        Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
110        None => ObjectId::empty_tree(hash_kind),
111    };
112
113    let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
114    editor
115        .upsert(rel_path, EntryKind::Blob, blob)
116        .map_err(gix_err)?;
117    let new_tree_id = editor.write().map_err(gix_err)?.detach();
118    let parent = tip.map(|id| id.detach());
119    ops::create_commit(&repo, ref_name, new_tree_id, parent, message)
120}
121
122impl NativeGitStorage {
123    /// Store a session at an explicit ref name.
124    ///
125    /// Stores body and metadata blobs plus per-commit index entries:
126    /// `v1/index/commits/<sha>/<session_id>.json`.
127    pub fn store_session_at_ref(
128        &self,
129        repo_path: &Path,
130        ref_name: &str,
131        session_id: &str,
132        hail_jsonl: &[u8],
133        meta_json: &[u8],
134        commit_shas: &[String],
135    ) -> Result<StoredSessionRecord> {
136        let repo = ops::open_repo(repo_path)?;
137        let hash_kind = repo.object_hash();
138
139        // Write blobs
140        let hail_blob = repo.write_blob(hail_jsonl).map_err(gix_err)?.detach();
141        let meta_blob = repo.write_blob(meta_json).map_err(gix_err)?.detach();
142
143        debug!(
144            session_id,
145            hail_blob = %hail_blob,
146            meta_blob = %meta_blob,
147            "Wrote session blobs"
148        );
149
150        let prefix = Self::session_prefix(session_id);
151        let hail_path = format!("{prefix}.hail.jsonl");
152        let meta_path = format!("{prefix}.meta.json");
153
154        // Determine base tree: existing branch tree or empty tree
155        let tip = ops::find_ref_tip(&repo, ref_name)?;
156        let base_tree_id = match &tip {
157            Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
158            None => ObjectId::empty_tree(hash_kind),
159        };
160
161        // Build new tree using editor
162        let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
163        editor
164            .upsert(&hail_path, EntryKind::Blob, hail_blob)
165            .map_err(gix_err)?;
166        editor
167            .upsert(&meta_path, EntryKind::Blob, meta_blob)
168            .map_err(gix_err)?;
169
170        for sha in commit_shas {
171            let trimmed = sha.trim();
172            if trimmed.is_empty() {
173                continue;
174            }
175            let index_path = Self::commit_index_path(trimmed, session_id);
176            let payload = Self::commit_index_payload(session_id, &hail_path, &meta_path);
177            let payload_bytes = serde_json::to_vec(&payload)?;
178            let payload_blob = repo.write_blob(&payload_bytes).map_err(gix_err)?.detach();
179            editor
180                .upsert(&index_path, EntryKind::Blob, payload_blob)
181                .map_err(gix_err)?;
182        }
183
184        let new_tree_id = editor.write().map_err(gix_err)?.detach();
185
186        debug!(tree = %new_tree_id, "Built new tree");
187
188        let parent = tip.map(|id| id.detach());
189        let message = format!("session: {session_id}");
190        let commit_id = ops::create_commit(&repo, ref_name, new_tree_id, parent, &message)?;
191
192        info!(
193            session_id,
194            ref_name,
195            commit = %commit_id,
196            "Stored session on ref"
197        );
198
199        Ok(StoredSessionRecord {
200            ref_name: ref_name.to_string(),
201            commit_id: commit_id.to_string(),
202            hail_path,
203            meta_path,
204        })
205    }
206
207    /// Prune expired sessions from a specific ledger ref by age (days).
208    pub fn prune_by_age_at_ref(
209        &self,
210        repo_path: &Path,
211        ref_name: &str,
212        keep_days: u32,
213    ) -> Result<PruneStats> {
214        let repo = ops::open_repo(repo_path)?;
215        let tip = match ops::find_ref_tip(&repo, ref_name)? {
216            Some(tip) => tip.detach(),
217            None => return Ok(PruneStats::default()),
218        };
219
220        let cutoff = chrono::Utc::now()
221            .timestamp()
222            .saturating_sub((keep_days as i64).saturating_mul(24 * 60 * 60));
223
224        // First-parent walk from tip to capture latest-seen timestamp per session.
225        let mut latest_seen: std::collections::HashMap<String, i64> =
226            std::collections::HashMap::new();
227        let mut current = Some(tip);
228        while let Some(commit_id) = current {
229            let commit = repo.find_commit(commit_id).map_err(gix_err)?;
230
231            let message = String::from_utf8_lossy(commit.message_raw_sloppy().as_ref());
232            if let Some(session_id) = Self::session_id_from_commit_message(&message) {
233                latest_seen
234                    .entry(session_id.to_string())
235                    .or_insert(commit.time().map_err(gix_err)?.seconds);
236            }
237
238            current = commit.parent_ids().next().map(|id| id.detach());
239        }
240
241        let mut expired: Vec<String> = latest_seen
242            .iter()
243            .filter_map(|(id, ts)| {
244                if *ts <= cutoff {
245                    Some(id.clone())
246                } else {
247                    None
248                }
249            })
250            .collect();
251        expired.sort();
252
253        if expired.is_empty() {
254            return Ok(PruneStats {
255                scanned_sessions: latest_seen.len(),
256                expired_sessions: 0,
257                rewritten: false,
258            });
259        }
260
261        let base_tree_id = ops::commit_tree_id(&repo, tip)?;
262        let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
263        for session_id in &expired {
264            let prefix = Self::session_prefix(session_id);
265            let hail_path = format!("{prefix}.hail.jsonl");
266            let meta_path = format!("{prefix}.meta.json");
267            editor.remove(&hail_path).map_err(gix_err)?;
268            editor.remove(&meta_path).map_err(gix_err)?;
269        }
270
271        let new_tree_id = editor.write().map_err(gix_err)?.detach();
272        let message = format!(
273            "retention-prune: keep_days={keep_days} expired={}",
274            expired.len()
275        );
276        let sig = ops::make_signature();
277        let commit = gix::objs::Commit {
278            message: message.clone().into(),
279            tree: new_tree_id,
280            author: sig.clone(),
281            committer: sig,
282            encoding: None,
283            parents: Vec::<ObjectId>::new().into(),
284            extra_headers: Default::default(),
285        };
286        let new_tip = repo.write_object(&commit).map_err(gix_err)?.detach();
287        ops::replace_ref_tip(&repo, ref_name, tip, new_tip, &message)?;
288
289        info!(
290            ref_name,
291            keep_days,
292            expired_sessions = expired.len(),
293            old_tip = %tip,
294            new_tip = %new_tip,
295            "Pruned expired sessions on ref"
296        );
297
298        Ok(PruneStats {
299            scanned_sessions: latest_seen.len(),
300            expired_sessions: expired.len(),
301            rewritten: true,
302        })
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::error::GitStorageError;
310    use crate::test_utils::{init_test_repo, run_git};
311    use crate::{branch_ledger_ref, ops};
312
313    #[test]
314    fn test_session_prefix() {
315        assert_eq!(
316            NativeGitStorage::session_prefix("abcdef-1234"),
317            "v1/ab/abcdef-1234"
318        );
319        assert_eq!(NativeGitStorage::session_prefix("x"), "v1/x/x");
320        assert_eq!(NativeGitStorage::session_prefix("ab"), "v1/ab/ab");
321    }
322
323    #[test]
324    fn test_not_a_repo() {
325        let tmp = tempfile::tempdir().unwrap();
326        // Don't init git repo
327        let storage = NativeGitStorage;
328        let ref_name = branch_ledger_ref("main");
329        let err = storage
330            .store_session_at_ref(tmp.path(), &ref_name, "test", b"data", b"meta", &[])
331            .unwrap_err();
332        assert!(
333            matches!(err, GitStorageError::NotARepo(_)),
334            "expected NotARepo, got: {err}"
335        );
336    }
337
338    #[test]
339    fn store_blob_at_ref_writes_requested_path() {
340        let tmp = tempfile::tempdir().expect("tempdir");
341        init_test_repo(tmp.path());
342
343        let ref_name = "refs/heads/opensession/custom-share";
344        let rel_path = "sessions/hash.jsonl";
345        store_blob_at_ref(
346            tmp.path(),
347            ref_name,
348            rel_path,
349            b"hello",
350            "custom share write",
351        )
352        .expect("store blob at ref");
353
354        let output = run_git(tmp.path(), &["show", &format!("{ref_name}:{rel_path}")]);
355        assert_eq!(String::from_utf8_lossy(&output.stdout), "hello");
356    }
357
358    #[test]
359    fn test_store_session_at_ref_writes_commit_indexes() {
360        let tmp = tempfile::tempdir().expect("tempdir");
361        init_test_repo(tmp.path());
362
363        let storage = NativeGitStorage;
364        let ref_name = branch_ledger_ref("feature/ledger");
365        let result = storage
366            .store_session_at_ref(
367                tmp.path(),
368                &ref_name,
369                "session-1",
370                b"{\"event\":\"one\"}\n",
371                b"{\"meta\":1}",
372                &["abcd1234".to_string(), "beef5678".to_string()],
373            )
374            .expect("store at ref");
375
376        assert_eq!(result.ref_name, ref_name);
377        assert_eq!(result.hail_path, "v1/se/session-1.hail.jsonl");
378        assert_eq!(result.meta_path, "v1/se/session-1.meta.json");
379        assert!(!result.commit_id.is_empty());
380        run_git(tmp.path(), &["show-ref", "--verify", "--quiet", &ref_name]);
381
382        let first_index = "v1/index/commits/abcd1234/session-1.json";
383        let first_output = run_git(tmp.path(), &["show", &format!("{ref_name}:{first_index}")]);
384        let parsed: serde_json::Value =
385            serde_json::from_slice(&first_output.stdout).expect("valid index payload");
386        assert_eq!(parsed["session_id"], "session-1");
387        assert_eq!(parsed["hail_path"], "v1/se/session-1.hail.jsonl");
388    }
389
390    #[test]
391    fn test_prune_by_age_no_branch() {
392        let tmp = tempfile::tempdir().unwrap();
393        init_test_repo(tmp.path());
394
395        let storage = NativeGitStorage;
396        let ref_name = branch_ledger_ref("feature/no-branch");
397        let stats = storage
398            .prune_by_age_at_ref(tmp.path(), &ref_name, 30)
399            .expect("prune should work");
400        assert_eq!(stats, PruneStats::default());
401    }
402
403    #[test]
404    fn test_prune_by_age_rewrites_and_removes_expired_sessions() {
405        let tmp = tempfile::tempdir().unwrap();
406        init_test_repo(tmp.path());
407
408        let storage = NativeGitStorage;
409        let ref_name = branch_ledger_ref("feature/prune-expired");
410        storage
411            .store_session_at_ref(
412                tmp.path(),
413                &ref_name,
414                "abc123-def456",
415                b"{\"event\":\"one\"}\n",
416                b"{}",
417                &[],
418            )
419            .expect("store should succeed");
420        storage
421            .store_session_at_ref(
422                tmp.path(),
423                &ref_name,
424                "ff0011-xyz",
425                b"{\"event\":\"two\"}\n",
426                b"{}",
427                &[],
428            )
429            .expect("store should succeed");
430
431        let repo = gix::open(tmp.path()).unwrap();
432        let before_tip = ops::find_ref_tip(&repo, &ref_name)
433            .unwrap()
434            .expect("ledger ref should exist")
435            .detach();
436
437        let stats = storage
438            .prune_by_age_at_ref(tmp.path(), &ref_name, 0)
439            .expect("prune should work");
440        assert!(stats.rewritten);
441        assert_eq!(stats.expired_sessions, 2);
442
443        let repo = gix::open(tmp.path()).unwrap();
444        let after_tip = ops::find_ref_tip(&repo, &ref_name)
445            .unwrap()
446            .expect("ledger ref should exist")
447            .detach();
448        assert_ne!(before_tip, after_tip, "tip should be rewritten");
449
450        let commit = repo.find_commit(after_tip).unwrap();
451        assert_eq!(
452            commit.parent_ids().count(),
453            0,
454            "retention rewrite should produce orphan commit"
455        );
456
457        let output = run_git(tmp.path(), &["ls-tree", "-r", &ref_name]);
458        let listing = String::from_utf8_lossy(&output.stdout);
459        assert!(
460            !listing.contains(".hail.jsonl"),
461            "expected no retained session blobs after prune: {listing}"
462        );
463    }
464
465    #[test]
466    fn test_prune_by_age_keeps_recent_sessions() {
467        let tmp = tempfile::tempdir().unwrap();
468        init_test_repo(tmp.path());
469
470        let storage = NativeGitStorage;
471        let ref_name = branch_ledger_ref("feature/prune-keep");
472        storage
473            .store_session_at_ref(
474                tmp.path(),
475                &ref_name,
476                "abc123-def456",
477                b"{\"event\":\"one\"}\n",
478                b"{}",
479                &[],
480            )
481            .expect("store should succeed");
482
483        let repo = gix::open(tmp.path()).unwrap();
484        let before_tip = ops::find_ref_tip(&repo, &ref_name)
485            .unwrap()
486            .expect("ledger ref should exist")
487            .detach();
488
489        let stats = storage
490            .prune_by_age_at_ref(tmp.path(), &ref_name, 36500)
491            .expect("prune should work");
492        assert!(
493            !stats.rewritten,
494            "no prune should occur for very long retention"
495        );
496        assert_eq!(stats.expired_sessions, 0);
497        assert_eq!(stats.scanned_sessions, 1);
498
499        let repo = gix::open(tmp.path()).unwrap();
500        let after_tip = ops::find_ref_tip(&repo, &ref_name)
501            .unwrap()
502            .expect("ledger ref should exist")
503            .detach();
504        assert_eq!(before_tip, after_tip);
505    }
506}