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
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
28#[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 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
93pub 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 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 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 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 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 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 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 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}