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
11pub struct NativeGitStorage;
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct PruneStats {
20 pub scanned_sessions: usize,
22 pub expired_sessions: usize,
24 pub rewritten: bool,
26}
27
28impl NativeGitStorage {
29 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 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 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 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 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 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 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 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 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 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}