Skip to main content

opensession_git_native/
store.rs

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
13/// Git-native session storage using gix.
14///
15/// Stores session data (HAIL JSONL + metadata JSON) as blobs on an explicit
16/// hidden ledger ref without touching the working directory.
17pub struct NativeGitStorage;
18
19/// Result of a git-native retention prune run.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct PruneStats {
22    /// Number of unique sessions observed while scanning history.
23    pub scanned_sessions: usize,
24    /// Number of sessions considered expired by retention policy.
25    pub expired_sessions: usize,
26    /// Whether the sessions ref was rewritten.
27    pub rewritten: bool,
28}
29
30/// Result of storing a session at an explicit ref.
31#[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/// Result of storing a semantic summary at an explicit ref.
40#[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/// Session semantic summary payload persisted in hidden refs.
49#[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    /// Compute the storage path prefix for a session ID.
71    /// e.g. session_id "abcdef-1234" → "v1/ab/abcdef-1234"
72    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
144/// Store arbitrary blob content under a specific ref/path without touching the working tree.
145///
146/// This powers `opensession share --git`, which needs explicit ref/path control.
147pub 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    /// Store a session at an explicit ref name.
175    ///
176    /// Stores body and metadata blobs plus per-commit index entries:
177    /// `v1/index/commits/<sha>/<session_id>.json`.
178    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        // Write blobs
191        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        // Determine base tree: existing branch tree or empty tree
206        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        // Build new tree using editor
213        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    /// Store a session semantic summary at an explicit ref.
259    ///
260    /// Paths:
261    /// - `v1/summaries/<prefix>/<session_id>.summary.json`
262    /// - `v1/summaries/<prefix>/<session_id>.summary.meta.json`
263    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    /// Load a session semantic summary from an explicit ref.
320    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    /// Delete a session semantic summary from an explicit ref.
394    ///
395    /// Returns true when the ref was rewritten, false when no summary existed.
396    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    /// Prune expired semantic summaries from a specific summary ref by age (days).
444    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    /// Prune expired sessions from a specific ledger ref by age (days).
530    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        // First-parent walk from tip to capture latest-seen timestamp per session.
547        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        // Don't init git repo
674        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}