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
51pub 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 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 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 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 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 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 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 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 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 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}