1use std::path::Path;
2use std::process::Command;
3
4use gix::object::tree::EntryKind;
5use gix::ObjectId;
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8use tracing::{debug, info};
9
10use crate::error::Result;
11use crate::ops::{self, gix_err};
12
13pub struct NativeGitStorage;
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct PruneStats {
22 pub scanned_sessions: usize,
24 pub expired_sessions: usize,
26 pub rewritten: bool,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct StoredSessionRecord {
33 pub ref_name: String,
34 pub commit_id: String,
35 pub hail_path: String,
36 pub meta_path: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct StoredSummaryRecord {
42 pub ref_name: String,
43 pub commit_id: String,
44 pub summary_path: String,
45 pub meta_path: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct SessionSummaryLedgerRecord {
51 pub session_id: String,
52 pub generated_at: String,
53 pub provider: String,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub model: Option<String>,
56 pub source_kind: String,
57 pub generation_kind: String,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub prompt_fingerprint: Option<String>,
60 pub summary: serde_json::Value,
61 #[serde(default)]
62 pub source_details: serde_json::Value,
63 #[serde(default)]
64 pub diff_tree: Vec<serde_json::Value>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub error: Option<String>,
67}
68
69impl NativeGitStorage {
70 fn session_prefix(session_id: &str) -> String {
73 let prefix = if session_id.len() >= 2 {
74 &session_id[..2]
75 } else {
76 session_id
77 };
78 format!("v1/{prefix}/{session_id}")
79 }
80
81 fn session_id_from_commit_message(message: &str) -> Option<&str> {
82 let first = message.lines().next()?.trim();
83 let id = first.strip_prefix("session: ")?.trim();
84 if id.is_empty() {
85 None
86 } else {
87 Some(id)
88 }
89 }
90
91 fn commit_index_path(commit_sha: &str, session_id: &str) -> String {
92 format!(
93 "v1/index/commits/{}/{}.json",
94 sanitize_path_component(commit_sha),
95 sanitize_path_component(session_id)
96 )
97 }
98
99 fn commit_index_payload(
100 session_id: &str,
101 hail_path: &str,
102 meta_path: &str,
103 ) -> serde_json::Value {
104 json!({
105 "session_id": session_id,
106 "hail_path": hail_path,
107 "meta_path": meta_path,
108 "stored_at": chrono::Utc::now().to_rfc3339(),
109 })
110 }
111
112 fn summary_prefix(session_id: &str) -> String {
113 let prefix = if session_id.len() >= 2 {
114 &session_id[..2]
115 } else {
116 session_id
117 };
118 format!("v1/summaries/{prefix}/{session_id}")
119 }
120
121 fn summary_session_id_from_commit_message(message: &str) -> Option<&str> {
122 let first = message.lines().next()?.trim();
123 let id = first.strip_prefix("summary: ")?.trim();
124 if id.is_empty() {
125 None
126 } else {
127 Some(id)
128 }
129 }
130}
131
132fn sanitize_path_component(raw: &str) -> String {
133 raw.chars()
134 .map(|c| {
135 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
136 c
137 } else {
138 '_'
139 }
140 })
141 .collect()
142}
143
144pub fn store_blob_at_ref(
148 repo_path: &Path,
149 ref_name: &str,
150 rel_path: &str,
151 body: &[u8],
152 message: &str,
153) -> Result<ObjectId> {
154 let repo = ops::open_repo(repo_path)?;
155 let hash_kind = repo.object_hash();
156
157 let blob = repo.write_blob(body).map_err(gix_err)?.detach();
158 let tip = ops::find_ref_tip(&repo, ref_name)?;
159 let base_tree_id = match &tip {
160 Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
161 None => ObjectId::empty_tree(hash_kind),
162 };
163
164 let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
165 editor
166 .upsert(rel_path, EntryKind::Blob, blob)
167 .map_err(gix_err)?;
168 let new_tree_id = editor.write().map_err(gix_err)?.detach();
169 let parent = tip.map(|id| id.detach());
170 ops::create_commit(&repo, ref_name, new_tree_id, parent, message)
171}
172
173impl NativeGitStorage {
174 pub fn store_session_at_ref(
179 &self,
180 repo_path: &Path,
181 ref_name: &str,
182 session_id: &str,
183 hail_jsonl: &[u8],
184 meta_json: &[u8],
185 commit_shas: &[String],
186 ) -> Result<StoredSessionRecord> {
187 let repo = ops::open_repo(repo_path)?;
188 let hash_kind = repo.object_hash();
189
190 let hail_blob = repo.write_blob(hail_jsonl).map_err(gix_err)?.detach();
192 let meta_blob = repo.write_blob(meta_json).map_err(gix_err)?.detach();
193
194 debug!(
195 session_id,
196 hail_blob = %hail_blob,
197 meta_blob = %meta_blob,
198 "Wrote session blobs"
199 );
200
201 let prefix = Self::session_prefix(session_id);
202 let hail_path = format!("{prefix}.hail.jsonl");
203 let meta_path = format!("{prefix}.meta.json");
204
205 let tip = ops::find_ref_tip(&repo, ref_name)?;
207 let base_tree_id = match &tip {
208 Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
209 None => ObjectId::empty_tree(hash_kind),
210 };
211
212 let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
214 editor
215 .upsert(&hail_path, EntryKind::Blob, hail_blob)
216 .map_err(gix_err)?;
217 editor
218 .upsert(&meta_path, EntryKind::Blob, meta_blob)
219 .map_err(gix_err)?;
220
221 for sha in commit_shas {
222 let trimmed = sha.trim();
223 if trimmed.is_empty() {
224 continue;
225 }
226 let index_path = Self::commit_index_path(trimmed, session_id);
227 let payload = Self::commit_index_payload(session_id, &hail_path, &meta_path);
228 let payload_bytes = serde_json::to_vec(&payload)?;
229 let payload_blob = repo.write_blob(&payload_bytes).map_err(gix_err)?.detach();
230 editor
231 .upsert(&index_path, EntryKind::Blob, payload_blob)
232 .map_err(gix_err)?;
233 }
234
235 let new_tree_id = editor.write().map_err(gix_err)?.detach();
236
237 debug!(tree = %new_tree_id, "Built new tree");
238
239 let parent = tip.map(|id| id.detach());
240 let message = format!("session: {session_id}");
241 let commit_id = ops::create_commit(&repo, ref_name, new_tree_id, parent, &message)?;
242
243 info!(
244 session_id,
245 ref_name,
246 commit = %commit_id,
247 "Stored session on ref"
248 );
249
250 Ok(StoredSessionRecord {
251 ref_name: ref_name.to_string(),
252 commit_id: commit_id.to_string(),
253 hail_path,
254 meta_path,
255 })
256 }
257
258 pub fn store_summary_at_ref(
264 &self,
265 repo_path: &Path,
266 ref_name: &str,
267 record: &SessionSummaryLedgerRecord,
268 ) -> Result<StoredSummaryRecord> {
269 let repo = ops::open_repo(repo_path)?;
270 let hash_kind = repo.object_hash();
271 let prefix = Self::summary_prefix(&record.session_id);
272 let summary_path = format!("{prefix}.summary.json");
273 let meta_path = format!("{prefix}.summary.meta.json");
274
275 let summary_bytes = serde_json::to_vec(&record.summary)?;
276 let summary_blob = repo.write_blob(&summary_bytes).map_err(gix_err)?.detach();
277
278 let meta_payload = json!({
279 "session_id": record.session_id,
280 "generated_at": record.generated_at,
281 "provider": record.provider,
282 "model": record.model,
283 "source_kind": record.source_kind,
284 "generation_kind": record.generation_kind,
285 "prompt_fingerprint": record.prompt_fingerprint,
286 "source_details": record.source_details,
287 "diff_tree": record.diff_tree,
288 "error": record.error,
289 "updated_at": chrono::Utc::now().to_rfc3339(),
290 });
291 let meta_bytes = serde_json::to_vec(&meta_payload)?;
292 let meta_blob = repo.write_blob(&meta_bytes).map_err(gix_err)?.detach();
293
294 let tip = ops::find_ref_tip(&repo, ref_name)?;
295 let base_tree_id = match &tip {
296 Some(commit_id) => ops::commit_tree_id(&repo, commit_id.detach())?,
297 None => ObjectId::empty_tree(hash_kind),
298 };
299 let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
300 editor
301 .upsert(&summary_path, EntryKind::Blob, summary_blob)
302 .map_err(gix_err)?;
303 editor
304 .upsert(&meta_path, EntryKind::Blob, meta_blob)
305 .map_err(gix_err)?;
306 let new_tree_id = editor.write().map_err(gix_err)?.detach();
307 let parent = tip.map(|id| id.detach());
308 let message = format!("summary: {}", record.session_id);
309 let commit_id = ops::create_commit(&repo, ref_name, new_tree_id, parent, &message)?;
310
311 Ok(StoredSummaryRecord {
312 ref_name: ref_name.to_string(),
313 commit_id: commit_id.to_string(),
314 summary_path,
315 meta_path,
316 })
317 }
318
319 pub fn load_summary_at_ref(
321 &self,
322 repo_path: &Path,
323 ref_name: &str,
324 session_id: &str,
325 ) -> Result<Option<SessionSummaryLedgerRecord>> {
326 let prefix = Self::summary_prefix(session_id);
327 let summary_path = format!("{prefix}.summary.json");
328 let meta_path = format!("{prefix}.summary.meta.json");
329
330 let summary_raw = match read_path_from_ref(repo_path, ref_name, &summary_path)? {
331 Some(value) => value,
332 None => return Ok(None),
333 };
334 let meta_raw = match read_path_from_ref(repo_path, ref_name, &meta_path)? {
335 Some(value) => value,
336 None => return Ok(None),
337 };
338
339 let summary_value: serde_json::Value = serde_json::from_slice(&summary_raw)?;
340 let meta_value: serde_json::Value = serde_json::from_slice(&meta_raw)?;
341
342 Ok(Some(SessionSummaryLedgerRecord {
343 session_id: meta_value
344 .get("session_id")
345 .and_then(serde_json::Value::as_str)
346 .unwrap_or(session_id)
347 .to_string(),
348 generated_at: meta_value
349 .get("generated_at")
350 .and_then(serde_json::Value::as_str)
351 .unwrap_or_default()
352 .to_string(),
353 provider: meta_value
354 .get("provider")
355 .and_then(serde_json::Value::as_str)
356 .unwrap_or("unknown")
357 .to_string(),
358 model: meta_value
359 .get("model")
360 .and_then(serde_json::Value::as_str)
361 .map(str::to_string),
362 source_kind: meta_value
363 .get("source_kind")
364 .and_then(serde_json::Value::as_str)
365 .unwrap_or("unknown")
366 .to_string(),
367 generation_kind: meta_value
368 .get("generation_kind")
369 .and_then(serde_json::Value::as_str)
370 .unwrap_or("unknown")
371 .to_string(),
372 prompt_fingerprint: meta_value
373 .get("prompt_fingerprint")
374 .and_then(serde_json::Value::as_str)
375 .map(str::to_string),
376 summary: summary_value,
377 source_details: meta_value
378 .get("source_details")
379 .cloned()
380 .unwrap_or(serde_json::Value::Object(Default::default())),
381 diff_tree: meta_value
382 .get("diff_tree")
383 .and_then(serde_json::Value::as_array)
384 .cloned()
385 .unwrap_or_default(),
386 error: meta_value
387 .get("error")
388 .and_then(serde_json::Value::as_str)
389 .map(str::to_string),
390 }))
391 }
392
393 pub fn delete_summary_at_ref(
397 &self,
398 repo_path: &Path,
399 ref_name: &str,
400 session_id: &str,
401 ) -> Result<bool> {
402 let repo = ops::open_repo(repo_path)?;
403 let tip = match ops::find_ref_tip(&repo, ref_name)? {
404 Some(tip) => tip.detach(),
405 None => return Ok(false),
406 };
407
408 let prefix = Self::summary_prefix(session_id);
409 let summary_path = format!("{prefix}.summary.json");
410 let meta_path = format!("{prefix}.summary.meta.json");
411 let has_summary = read_path_from_ref(repo_path, ref_name, &summary_path)?.is_some();
412 let has_meta = read_path_from_ref(repo_path, ref_name, &meta_path)?.is_some();
413 if !has_summary && !has_meta {
414 return Ok(false);
415 }
416
417 let base_tree_id = ops::commit_tree_id(&repo, tip)?;
418 let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
419 if has_summary {
420 editor.remove(&summary_path).map_err(gix_err)?;
421 }
422 if has_meta {
423 editor.remove(&meta_path).map_err(gix_err)?;
424 }
425
426 let new_tree_id = editor.write().map_err(gix_err)?.detach();
427 let message = format!("summary-delete: {session_id}");
428 let sig = ops::make_signature();
429 let commit = gix::objs::Commit {
430 message: message.clone().into(),
431 tree: new_tree_id,
432 author: sig.clone(),
433 committer: sig,
434 encoding: None,
435 parents: Vec::<ObjectId>::new().into(),
436 extra_headers: Default::default(),
437 };
438 let new_tip = repo.write_object(&commit).map_err(gix_err)?.detach();
439 ops::replace_ref_tip(&repo, ref_name, tip, new_tip, &message)?;
440 Ok(true)
441 }
442
443 pub fn prune_summaries_by_age_at_ref(
445 &self,
446 repo_path: &Path,
447 ref_name: &str,
448 keep_days: u32,
449 ) -> Result<PruneStats> {
450 let repo = ops::open_repo(repo_path)?;
451 let tip = match ops::find_ref_tip(&repo, ref_name)? {
452 Some(tip) => tip.detach(),
453 None => return Ok(PruneStats::default()),
454 };
455
456 let cutoff = chrono::Utc::now()
457 .timestamp()
458 .saturating_sub((keep_days as i64).saturating_mul(24 * 60 * 60));
459
460 let mut latest_seen: std::collections::HashMap<String, i64> =
461 std::collections::HashMap::new();
462 let mut current = Some(tip);
463 while let Some(commit_id) = current {
464 let commit = repo.find_commit(commit_id).map_err(gix_err)?;
465 let message = String::from_utf8_lossy(commit.message_raw_sloppy().as_ref());
466 if let Some(session_id) = Self::summary_session_id_from_commit_message(&message) {
467 latest_seen
468 .entry(session_id.to_string())
469 .or_insert(commit.time().map_err(gix_err)?.seconds);
470 }
471 current = commit.parent_ids().next().map(|id| id.detach());
472 }
473
474 let mut expired: Vec<String> = latest_seen
475 .iter()
476 .filter_map(|(id, ts)| {
477 if *ts <= cutoff {
478 Some(id.clone())
479 } else {
480 None
481 }
482 })
483 .collect();
484 expired.sort();
485
486 if expired.is_empty() {
487 return Ok(PruneStats {
488 scanned_sessions: latest_seen.len(),
489 expired_sessions: 0,
490 rewritten: false,
491 });
492 }
493
494 let base_tree_id = ops::commit_tree_id(&repo, tip)?;
495 let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
496 for session_id in &expired {
497 let prefix = Self::summary_prefix(session_id);
498 let summary_path = format!("{prefix}.summary.json");
499 let meta_path = format!("{prefix}.summary.meta.json");
500 editor.remove(&summary_path).map_err(gix_err)?;
501 editor.remove(&meta_path).map_err(gix_err)?;
502 }
503
504 let new_tree_id = editor.write().map_err(gix_err)?.detach();
505 let message = format!(
506 "summary-retention-prune: keep_days={keep_days} expired={}",
507 expired.len()
508 );
509 let sig = ops::make_signature();
510 let commit = gix::objs::Commit {
511 message: message.clone().into(),
512 tree: new_tree_id,
513 author: sig.clone(),
514 committer: sig,
515 encoding: None,
516 parents: Vec::<ObjectId>::new().into(),
517 extra_headers: Default::default(),
518 };
519 let new_tip = repo.write_object(&commit).map_err(gix_err)?.detach();
520 ops::replace_ref_tip(&repo, ref_name, tip, new_tip, &message)?;
521
522 Ok(PruneStats {
523 scanned_sessions: latest_seen.len(),
524 expired_sessions: expired.len(),
525 rewritten: true,
526 })
527 }
528
529 pub fn prune_by_age_at_ref(
531 &self,
532 repo_path: &Path,
533 ref_name: &str,
534 keep_days: u32,
535 ) -> Result<PruneStats> {
536 let repo = ops::open_repo(repo_path)?;
537 let tip = match ops::find_ref_tip(&repo, ref_name)? {
538 Some(tip) => tip.detach(),
539 None => return Ok(PruneStats::default()),
540 };
541
542 let cutoff = chrono::Utc::now()
543 .timestamp()
544 .saturating_sub((keep_days as i64).saturating_mul(24 * 60 * 60));
545
546 let mut latest_seen: std::collections::HashMap<String, i64> =
548 std::collections::HashMap::new();
549 let mut current = Some(tip);
550 while let Some(commit_id) = current {
551 let commit = repo.find_commit(commit_id).map_err(gix_err)?;
552
553 let message = String::from_utf8_lossy(commit.message_raw_sloppy().as_ref());
554 if let Some(session_id) = Self::session_id_from_commit_message(&message) {
555 latest_seen
556 .entry(session_id.to_string())
557 .or_insert(commit.time().map_err(gix_err)?.seconds);
558 }
559
560 current = commit.parent_ids().next().map(|id| id.detach());
561 }
562
563 let mut expired: Vec<String> = latest_seen
564 .iter()
565 .filter_map(|(id, ts)| {
566 if *ts <= cutoff {
567 Some(id.clone())
568 } else {
569 None
570 }
571 })
572 .collect();
573 expired.sort();
574
575 if expired.is_empty() {
576 return Ok(PruneStats {
577 scanned_sessions: latest_seen.len(),
578 expired_sessions: 0,
579 rewritten: false,
580 });
581 }
582
583 let base_tree_id = ops::commit_tree_id(&repo, tip)?;
584 let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
585 for session_id in &expired {
586 let prefix = Self::session_prefix(session_id);
587 let hail_path = format!("{prefix}.hail.jsonl");
588 let meta_path = format!("{prefix}.meta.json");
589 editor.remove(&hail_path).map_err(gix_err)?;
590 editor.remove(&meta_path).map_err(gix_err)?;
591 }
592
593 let new_tree_id = editor.write().map_err(gix_err)?.detach();
594 let message = format!(
595 "retention-prune: keep_days={keep_days} expired={}",
596 expired.len()
597 );
598 let sig = ops::make_signature();
599 let commit = gix::objs::Commit {
600 message: message.clone().into(),
601 tree: new_tree_id,
602 author: sig.clone(),
603 committer: sig,
604 encoding: None,
605 parents: Vec::<ObjectId>::new().into(),
606 extra_headers: Default::default(),
607 };
608 let new_tip = repo.write_object(&commit).map_err(gix_err)?.detach();
609 ops::replace_ref_tip(&repo, ref_name, tip, new_tip, &message)?;
610
611 info!(
612 ref_name,
613 keep_days,
614 expired_sessions = expired.len(),
615 old_tip = %tip,
616 new_tip = %new_tip,
617 "Pruned expired sessions on ref"
618 );
619
620 Ok(PruneStats {
621 scanned_sessions: latest_seen.len(),
622 expired_sessions: expired.len(),
623 rewritten: true,
624 })
625 }
626}
627
628fn read_path_from_ref(repo_path: &Path, ref_name: &str, rel_path: &str) -> Result<Option<Vec<u8>>> {
629 let output = Command::new("git")
630 .arg("-C")
631 .arg(repo_path)
632 .arg("show")
633 .arg(format!("{ref_name}:{rel_path}"))
634 .output()?;
635 if output.status.success() {
636 return Ok(Some(output.stdout));
637 }
638 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
639 if stderr.contains("does not exist")
640 || stderr.contains("not in")
641 || stderr.contains("unknown revision")
642 || stderr.contains("invalid object name")
643 {
644 return Ok(None);
645 }
646 Err(crate::error::GitStorageError::Other(format!(
647 "failed to read {rel_path} from {ref_name}: {}",
648 String::from_utf8_lossy(&output.stderr).trim()
649 )))
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655 use crate::error::GitStorageError;
656 use crate::test_utils::{init_test_repo, run_git};
657 use crate::{branch_ledger_ref, ops};
658 use serde_json::json;
659
660 #[test]
661 fn test_session_prefix() {
662 assert_eq!(
663 NativeGitStorage::session_prefix("abcdef-1234"),
664 "v1/ab/abcdef-1234"
665 );
666 assert_eq!(NativeGitStorage::session_prefix("x"), "v1/x/x");
667 assert_eq!(NativeGitStorage::session_prefix("ab"), "v1/ab/ab");
668 }
669
670 #[test]
671 fn test_not_a_repo() {
672 let tmp = tempfile::tempdir().unwrap();
673 let storage = NativeGitStorage;
675 let ref_name = branch_ledger_ref("main");
676 let err = storage
677 .store_session_at_ref(tmp.path(), &ref_name, "test", b"data", b"meta", &[])
678 .unwrap_err();
679 assert!(
680 matches!(err, GitStorageError::NotARepo(_)),
681 "expected NotARepo, got: {err}"
682 );
683 }
684
685 #[test]
686 fn store_blob_at_ref_writes_requested_path() {
687 let tmp = tempfile::tempdir().expect("tempdir");
688 init_test_repo(tmp.path());
689
690 let ref_name = "refs/heads/opensession/custom-share";
691 let rel_path = "sessions/hash.jsonl";
692 store_blob_at_ref(
693 tmp.path(),
694 ref_name,
695 rel_path,
696 b"hello",
697 "custom share write",
698 )
699 .expect("store blob at ref");
700
701 let output = run_git(tmp.path(), &["show", &format!("{ref_name}:{rel_path}")]);
702 assert_eq!(String::from_utf8_lossy(&output.stdout), "hello");
703 }
704
705 #[test]
706 fn test_store_session_at_ref_writes_commit_indexes() {
707 let tmp = tempfile::tempdir().expect("tempdir");
708 init_test_repo(tmp.path());
709
710 let storage = NativeGitStorage;
711 let ref_name = branch_ledger_ref("feature/ledger");
712 let result = storage
713 .store_session_at_ref(
714 tmp.path(),
715 &ref_name,
716 "session-1",
717 b"{\"event\":\"one\"}\n",
718 b"{\"meta\":1}",
719 &["abcd1234".to_string(), "beef5678".to_string()],
720 )
721 .expect("store at ref");
722
723 assert_eq!(result.ref_name, ref_name);
724 assert_eq!(result.hail_path, "v1/se/session-1.hail.jsonl");
725 assert_eq!(result.meta_path, "v1/se/session-1.meta.json");
726 assert!(!result.commit_id.is_empty());
727 run_git(tmp.path(), &["show-ref", "--verify", "--quiet", &ref_name]);
728
729 let first_index = "v1/index/commits/abcd1234/session-1.json";
730 let first_output = run_git(tmp.path(), &["show", &format!("{ref_name}:{first_index}")]);
731 let parsed: serde_json::Value =
732 serde_json::from_slice(&first_output.stdout).expect("valid index payload");
733 assert_eq!(parsed["session_id"], "session-1");
734 assert_eq!(parsed["hail_path"], "v1/se/session-1.hail.jsonl");
735 }
736
737 #[test]
738 fn test_store_and_load_summary_at_ref() {
739 let tmp = tempfile::tempdir().expect("tempdir");
740 init_test_repo(tmp.path());
741
742 let storage = NativeGitStorage;
743 let ref_name = "refs/opensession/summaries";
744 let record = SessionSummaryLedgerRecord {
745 session_id: "session-9".to_string(),
746 generated_at: "2026-03-05T00:00:00Z".to_string(),
747 provider: "codex_exec".to_string(),
748 model: Some("gpt-5".to_string()),
749 source_kind: "session_signals".to_string(),
750 generation_kind: "provider".to_string(),
751 prompt_fingerprint: Some("abc123".to_string()),
752 summary: json!({ "changes": "updated", "auth_security": "none detected", "layer_file_changes": [] }),
753 source_details: json!({ "repo_root": "/tmp/repo" }),
754 diff_tree: vec![json!({"layer":"application","files":[]})],
755 error: None,
756 };
757
758 let stored = storage
759 .store_summary_at_ref(tmp.path(), ref_name, &record)
760 .expect("store summary");
761 assert_eq!(
762 stored.summary_path,
763 "v1/summaries/se/session-9.summary.json"
764 );
765 assert_eq!(
766 stored.meta_path,
767 "v1/summaries/se/session-9.summary.meta.json"
768 );
769
770 let loaded = storage
771 .load_summary_at_ref(tmp.path(), ref_name, "session-9")
772 .expect("load summary")
773 .expect("summary should exist");
774 assert_eq!(loaded.session_id, "session-9");
775 assert_eq!(loaded.provider, "codex_exec");
776 assert_eq!(loaded.model.as_deref(), Some("gpt-5"));
777 assert_eq!(loaded.summary["changes"], "updated");
778 }
779
780 #[test]
781 fn test_load_summary_missing_returns_none() {
782 let tmp = tempfile::tempdir().expect("tempdir");
783 init_test_repo(tmp.path());
784 let storage = NativeGitStorage;
785
786 let loaded = storage
787 .load_summary_at_ref(tmp.path(), "refs/opensession/summaries", "missing-session")
788 .expect("load summary");
789 assert!(loaded.is_none());
790 }
791
792 #[test]
793 fn test_delete_summary_at_ref_removes_paths() {
794 let tmp = tempfile::tempdir().expect("tempdir");
795 init_test_repo(tmp.path());
796
797 let storage = NativeGitStorage;
798 let ref_name = "refs/opensession/summaries";
799 let record = SessionSummaryLedgerRecord {
800 session_id: "session-delete".to_string(),
801 generated_at: "2026-03-05T00:00:00Z".to_string(),
802 provider: "codex_exec".to_string(),
803 model: None,
804 source_kind: "session_signals".to_string(),
805 generation_kind: "provider".to_string(),
806 prompt_fingerprint: None,
807 summary: json!({ "changes": "x" }),
808 source_details: json!({}),
809 diff_tree: vec![],
810 error: None,
811 };
812 storage
813 .store_summary_at_ref(tmp.path(), ref_name, &record)
814 .expect("store summary");
815
816 let rewritten = storage
817 .delete_summary_at_ref(tmp.path(), ref_name, "session-delete")
818 .expect("delete summary");
819 assert!(rewritten);
820 assert!(storage
821 .load_summary_at_ref(tmp.path(), ref_name, "session-delete")
822 .expect("load after delete")
823 .is_none());
824 }
825
826 #[test]
827 fn test_prune_summaries_by_age_rewrites_and_removes_expired_summaries() {
828 let tmp = tempfile::tempdir().expect("tempdir");
829 init_test_repo(tmp.path());
830
831 let storage = NativeGitStorage;
832 let ref_name = "refs/opensession/summaries";
833 let record_a = SessionSummaryLedgerRecord {
834 session_id: "summary-a".to_string(),
835 generated_at: "2026-03-05T00:00:00Z".to_string(),
836 provider: "codex_exec".to_string(),
837 model: None,
838 source_kind: "session_signals".to_string(),
839 generation_kind: "provider".to_string(),
840 prompt_fingerprint: None,
841 summary: json!({ "changes": "a" }),
842 source_details: json!({}),
843 diff_tree: vec![],
844 error: None,
845 };
846 let record_b = SessionSummaryLedgerRecord {
847 session_id: "summary-b".to_string(),
848 generated_at: "2026-03-05T00:00:01Z".to_string(),
849 provider: "codex_exec".to_string(),
850 model: None,
851 source_kind: "session_signals".to_string(),
852 generation_kind: "provider".to_string(),
853 prompt_fingerprint: None,
854 summary: json!({ "changes": "b" }),
855 source_details: json!({}),
856 diff_tree: vec![],
857 error: None,
858 };
859 storage
860 .store_summary_at_ref(tmp.path(), ref_name, &record_a)
861 .expect("store summary a");
862 storage
863 .store_summary_at_ref(tmp.path(), ref_name, &record_b)
864 .expect("store summary b");
865
866 let stats = storage
867 .prune_summaries_by_age_at_ref(tmp.path(), ref_name, 0)
868 .expect("prune summaries");
869 assert!(stats.rewritten);
870 assert_eq!(stats.expired_sessions, 2);
871 assert!(storage
872 .load_summary_at_ref(tmp.path(), ref_name, "summary-a")
873 .expect("load summary a")
874 .is_none());
875 assert!(storage
876 .load_summary_at_ref(tmp.path(), ref_name, "summary-b")
877 .expect("load summary b")
878 .is_none());
879 }
880
881 #[test]
882 fn test_prune_by_age_no_branch() {
883 let tmp = tempfile::tempdir().unwrap();
884 init_test_repo(tmp.path());
885
886 let storage = NativeGitStorage;
887 let ref_name = branch_ledger_ref("feature/no-branch");
888 let stats = storage
889 .prune_by_age_at_ref(tmp.path(), &ref_name, 30)
890 .expect("prune should work");
891 assert_eq!(stats, PruneStats::default());
892 }
893
894 #[test]
895 fn test_prune_by_age_rewrites_and_removes_expired_sessions() {
896 let tmp = tempfile::tempdir().unwrap();
897 init_test_repo(tmp.path());
898
899 let storage = NativeGitStorage;
900 let ref_name = branch_ledger_ref("feature/prune-expired");
901 storage
902 .store_session_at_ref(
903 tmp.path(),
904 &ref_name,
905 "abc123-def456",
906 b"{\"event\":\"one\"}\n",
907 b"{}",
908 &[],
909 )
910 .expect("store should succeed");
911 storage
912 .store_session_at_ref(
913 tmp.path(),
914 &ref_name,
915 "ff0011-xyz",
916 b"{\"event\":\"two\"}\n",
917 b"{}",
918 &[],
919 )
920 .expect("store should succeed");
921
922 let repo = gix::open(tmp.path()).unwrap();
923 let before_tip = ops::find_ref_tip(&repo, &ref_name)
924 .unwrap()
925 .expect("ledger ref should exist")
926 .detach();
927
928 let stats = storage
929 .prune_by_age_at_ref(tmp.path(), &ref_name, 0)
930 .expect("prune should work");
931 assert!(stats.rewritten);
932 assert_eq!(stats.expired_sessions, 2);
933
934 let repo = gix::open(tmp.path()).unwrap();
935 let after_tip = ops::find_ref_tip(&repo, &ref_name)
936 .unwrap()
937 .expect("ledger ref should exist")
938 .detach();
939 assert_ne!(before_tip, after_tip, "tip should be rewritten");
940
941 let commit = repo.find_commit(after_tip).unwrap();
942 assert_eq!(
943 commit.parent_ids().count(),
944 0,
945 "retention rewrite should produce orphan commit"
946 );
947
948 let output = run_git(tmp.path(), &["ls-tree", "-r", &ref_name]);
949 let listing = String::from_utf8_lossy(&output.stdout);
950 assert!(
951 !listing.contains(".hail.jsonl"),
952 "expected no retained session blobs after prune: {listing}"
953 );
954 }
955
956 #[test]
957 fn test_prune_by_age_keeps_recent_sessions() {
958 let tmp = tempfile::tempdir().unwrap();
959 init_test_repo(tmp.path());
960
961 let storage = NativeGitStorage;
962 let ref_name = branch_ledger_ref("feature/prune-keep");
963 storage
964 .store_session_at_ref(
965 tmp.path(),
966 &ref_name,
967 "abc123-def456",
968 b"{\"event\":\"one\"}\n",
969 b"{}",
970 &[],
971 )
972 .expect("store should succeed");
973
974 let repo = gix::open(tmp.path()).unwrap();
975 let before_tip = ops::find_ref_tip(&repo, &ref_name)
976 .unwrap()
977 .expect("ledger ref should exist")
978 .detach();
979
980 let stats = storage
981 .prune_by_age_at_ref(tmp.path(), &ref_name, 36500)
982 .expect("prune should work");
983 assert!(
984 !stats.rewritten,
985 "no prune should occur for very long retention"
986 );
987 assert_eq!(stats.expired_sessions, 0);
988 assert_eq!(stats.scanned_sessions, 1);
989
990 let repo = gix::open(tmp.path()).unwrap();
991 let after_tip = ops::find_ref_tip(&repo, &ref_name)
992 .unwrap()
993 .expect("ledger ref should exist")
994 .detach();
995 assert_eq!(before_tip, after_tip);
996 }
997}