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
17impl NativeGitStorage {
18    /// Compute the storage path prefix for a session ID.
19    /// e.g. session_id "abcdef-1234" → "v1/ab/abcdef-1234"
20    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 NativeGitStorage {
31    /// Store a session in the git repository at `repo_path`.
32    ///
33    /// Creates the orphan branch if it doesn't exist, then adds/updates blobs
34    /// for the HAIL JSONL and metadata JSON under `v1/<prefix>/<id>.*`.
35    ///
36    /// Returns the relative path within the branch (e.g. `v1/ab/abcdef.hail.jsonl`).
37    pub fn store(
38        &self,
39        repo_path: &Path,
40        session_id: &str,
41        hail_jsonl: &[u8],
42        meta_json: &[u8],
43    ) -> Result<String> {
44        let repo = ops::open_repo(repo_path)?;
45        let hash_kind = repo.object_hash();
46
47        // Write blobs
48        let hail_blob = repo.write_blob(hail_jsonl).map_err(gix_err)?.detach();
49        let meta_blob = repo.write_blob(meta_json).map_err(gix_err)?.detach();
50
51        debug!(
52            session_id,
53            hail_blob = %hail_blob,
54            meta_blob = %meta_blob,
55            "Wrote session blobs"
56        );
57
58        let prefix = Self::session_prefix(session_id);
59        let hail_path = format!("{prefix}.hail.jsonl");
60        let meta_path = format!("{prefix}.meta.json");
61
62        // Determine base tree: existing branch tree or empty tree
63        let tip = ops::find_ref_tip(&repo, SESSIONS_BRANCH)?;
64        let base_tree_id = match &tip {
65            Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
66            None => ObjectId::empty_tree(hash_kind),
67        };
68
69        // Build new tree using editor
70        let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
71        editor
72            .upsert(&hail_path, EntryKind::Blob, hail_blob)
73            .map_err(gix_err)?;
74        editor
75            .upsert(&meta_path, EntryKind::Blob, meta_blob)
76            .map_err(gix_err)?;
77
78        let new_tree_id = editor.write().map_err(gix_err)?.detach();
79
80        debug!(tree = %new_tree_id, "Built new tree");
81
82        let parent = tip.map(|id| id.detach());
83        let message = format!("session: {session_id}");
84        let commit_id = ops::create_commit(&repo, SESSIONS_REF, new_tree_id, parent, &message)?;
85
86        info!(
87            session_id,
88            commit = %commit_id,
89            "Stored session on {SESSIONS_BRANCH}"
90        );
91
92        Ok(hail_path)
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::error::GitStorageError;
100    use crate::test_utils::init_test_repo;
101    use crate::SESSIONS_BRANCH;
102
103    #[test]
104    fn test_session_prefix() {
105        assert_eq!(
106            NativeGitStorage::session_prefix("abcdef-1234"),
107            "v1/ab/abcdef-1234"
108        );
109        assert_eq!(NativeGitStorage::session_prefix("x"), "v1/x/x");
110        assert_eq!(NativeGitStorage::session_prefix("ab"), "v1/ab/ab");
111    }
112
113    #[test]
114    fn test_store() {
115        let tmp = tempfile::tempdir().unwrap();
116        init_test_repo(tmp.path());
117
118        let storage = NativeGitStorage;
119        let hail = b"{\"event\":\"test\"}\n";
120        let meta = b"{\"title\":\"Test Session\"}";
121
122        // Store
123        let rel_path = storage
124            .store(tmp.path(), "abc123-def456", hail, meta)
125            .expect("store failed");
126        assert_eq!(rel_path, "v1/ab/abc123-def456.hail.jsonl");
127
128        // Verify branch exists
129        let output = std::process::Command::new("git")
130            .args(["branch", "--list", SESSIONS_BRANCH])
131            .current_dir(tmp.path())
132            .output()
133            .unwrap();
134        let branches = String::from_utf8_lossy(&output.stdout);
135        assert!(
136            branches.contains("opensession/sessions"),
137            "branch not found: {branches}"
138        );
139    }
140
141    #[test]
142    fn test_not_a_repo() {
143        let tmp = tempfile::tempdir().unwrap();
144        // Don't init git repo
145        let storage = NativeGitStorage;
146        let err = storage
147            .store(tmp.path(), "test", b"data", b"meta")
148            .unwrap_err();
149        assert!(
150            matches!(err, GitStorageError::NotARepo(_)),
151            "expected NotARepo, got: {err}"
152        );
153    }
154}