Skip to main content

pawan/agent/
git_session.rs

1//! Git-backed session store — conversation trees with fork/lineage/leaves
2//!
3//! Each pawan session is a git commit in a bare repo. Branching conversations
4//! = forking from any commit in the DAG.
5//!
6//! Uses gitoxide (gix) — pure Rust, no C dependency.
7//!
8//! Inspired by Karpathy's AgentHub (bare git DAG) and Yegge's Beads (git-backed memory).
9
10use crate::agent::session::Session;
11use crate::{PawanError, Result};
12use gix::bstr::ByteSlice;
13use gix::object::tree::EntryKind;
14use gix::ObjectId;
15use serde::{Deserialize, Serialize};
16use std::cmp::Reverse;
17use std::collections::HashSet;
18use std::path::PathBuf;
19
20/// Summary of a git-backed session commit
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CommitInfo {
23    pub hash: String,
24    pub short_hash: String,
25    pub message: String,
26    pub timestamp: i64,
27    pub message_count: usize,
28    pub model: String,
29}
30
31/// Git-backed session store using a bare repo at ~/.pawan/repo/
32pub struct GitSessionStore {
33    repo: gix::Repository,
34}
35
36impl GitSessionStore {
37    /// Initialize or open the git session store
38    pub fn init() -> Result<Self> {
39        let path = Self::default_path()?;
40        let repo = if path.join("HEAD").exists() {
41            gix::open(&path).map_err(|e| PawanError::Git(format!("Open repo: {}", e)))?
42        } else {
43            std::fs::create_dir_all(&path)
44                .map_err(|e| PawanError::Git(format!("Create dir: {}", e)))?;
45            gix::create::into(
46                &path,
47                gix::create::Kind::Bare,
48                gix::create::Options::default(),
49            )
50            .map_err(|e| PawanError::Git(format!("Init repo: {}", e)))?;
51            gix::open(&path).map_err(|e| PawanError::Git(format!("Open after init: {}", e)))?
52        };
53        Ok(Self { repo })
54    }
55
56    /// Open with a custom path (for testing)
57    pub fn open(repo: gix::Repository) -> Self {
58        Self { repo }
59    }
60
61    fn default_path() -> Result<PathBuf> {
62        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
63        Ok(PathBuf::from(home).join(".pawan").join("repo"))
64    }
65
66    fn commit_message(session: &Session) -> String {
67        session
68            .messages
69            .iter()
70            .rev()
71            .find(|m| m.role == crate::agent::Role::User)
72            .map(|m| {
73                let trunc: String = m.content.chars().take(80).collect();
74                format!("[{}] {}", session.id, trunc)
75            })
76            .unwrap_or_else(|| format!("[{}] new session", session.id))
77    }
78
79    /// Save session as a git commit. Returns the commit hash.
80    pub fn save_commit(&self, session: &Session, parent_hash: Option<&str>) -> Result<String> {
81        let json = serde_json::to_string_pretty(session)
82            .map_err(|e| PawanError::Git(format!("Serialize: {}", e)))?;
83
84        // Write blob
85        let blob_id = self
86            .repo
87            .write_blob(json.as_bytes())
88            .map_err(|e| PawanError::Git(format!("Blob: {}", e)))?
89            .detach();
90
91        // Build tree with a single "session.json" entry
92        let empty_tree_id = self.repo.empty_tree().id;
93        let tree_id = self
94            .repo
95            .edit_tree(empty_tree_id)
96            .map_err(|e| PawanError::Git(format!("TreeEditor init: {}", e)))?
97            .upsert("session.json", EntryKind::Blob, blob_id)
98            .map_err(|e| PawanError::Git(format!("Upsert: {}", e)))?
99            .write()
100            .map_err(|e| PawanError::Git(format!("Write tree: {}", e)))?
101            .detach();
102
103        let msg = Self::commit_message(session);
104        let refname = format!("refs/sessions/{}", session.id);
105        let now = chrono::Utc::now().timestamp();
106        let time = gix::date::Time::new(now, 0);
107        let time_str = time.to_string();
108        let sig = gix::actor::SignatureRef {
109            name: "pawan".into(),
110            email: "pawan@localhost".into(),
111            time: time_str.as_str(),
112        };
113
114        // Resolve parent OIDs
115        let parents: Vec<ObjectId> = match parent_hash {
116            Some(h) => {
117                let oid = ObjectId::from_hex(h.as_bytes())
118                    .map_err(|e| PawanError::Git(format!("Bad hash: {}", e)))?;
119                // Verify commit exists
120                self.repo
121                    .find_commit(oid)
122                    .map_err(|e| PawanError::Git(format!("Parent not found: {}", e)))?;
123                vec![oid]
124            }
125            None => vec![],
126        };
127
128        let commit_id = self
129            .repo
130            .commit_as(sig, sig, refname.as_str(), msg.as_str(), tree_id, parents)
131            .map_err(|e| PawanError::Git(format!("Commit: {}", e)))?
132            .detach();
133
134        Ok(commit_id.to_hex().to_string())
135    }
136
137    /// Load session from a commit hash
138    pub fn load_commit(&self, hash: &str) -> Result<Session> {
139        let oid = ObjectId::from_hex(hash.as_bytes())
140            .map_err(|e| PawanError::Git(format!("Bad hash: {}", e)))?;
141        let commit = self
142            .repo
143            .find_commit(oid)
144            .map_err(|e| PawanError::Git(format!("Not found: {}", e)))?;
145        self.session_from_commit(&commit)
146    }
147
148    /// Fork: create a new commit branching off parent
149    pub fn fork(&self, parent_hash: &str, session: &Session) -> Result<String> {
150        self.save_commit(session, Some(parent_hash))
151    }
152
153    /// List leaf commits (conversation tips with no children)
154    pub fn list_leaves(&self) -> Result<Vec<CommitInfo>> {
155        let all_oids = self.all_oids()?;
156        let mut parent_set: HashSet<ObjectId> = HashSet::new();
157
158        for &oid in &all_oids {
159            if let Ok(c) = self.repo.find_commit(oid) {
160                for pid in c.parent_ids() {
161                    parent_set.insert(pid.detach());
162                }
163            }
164        }
165
166        let mut leaves = Vec::new();
167        for &oid in &all_oids {
168            if !parent_set.contains(&oid) {
169                if let Ok(info) = self.info(oid) {
170                    leaves.push(info);
171                }
172            }
173        }
174        leaves.sort_by_key(|info| Reverse(info.timestamp));
175        Ok(leaves)
176    }
177
178    /// Walk lineage from commit to root
179    pub fn lineage(&self, hash: &str) -> Result<Vec<CommitInfo>> {
180        let mut oid = ObjectId::from_hex(hash.as_bytes())
181            .map_err(|e| PawanError::Git(format!("Bad hash: {}", e)))?;
182        let mut chain = Vec::new();
183
184        loop {
185            let commit = self
186                .repo
187                .find_commit(oid)
188                .map_err(|e| PawanError::Git(format!("Not found: {}", e)))?;
189            chain.push(self.info(oid)?);
190            let mut parents = commit.parent_ids();
191            match parents.next() {
192                Some(pid) => oid = pid.detach(),
193                None => break,
194            }
195        }
196        Ok(chain)
197    }
198
199    /// Find all children of a commit
200    pub fn children(&self, hash: &str) -> Result<Vec<CommitInfo>> {
201        let target = ObjectId::from_hex(hash.as_bytes())
202            .map_err(|e| PawanError::Git(format!("Bad hash: {}", e)))?;
203        let all = self.all_oids()?;
204        let mut result = Vec::new();
205
206        for &oid in &all {
207            if let Ok(c) = self.repo.find_commit(oid) {
208                for pid in c.parent_ids() {
209                    if pid.detach() == target {
210                        if let Ok(info) = self.info(oid) {
211                            result.push(info);
212                        }
213                    }
214                }
215            }
216        }
217        Ok(result)
218    }
219
220    /// List all session refs (latest commit per session)
221    pub fn list_sessions(&self) -> Result<Vec<CommitInfo>> {
222        let mut sessions = Vec::new();
223        let refs = self
224            .repo
225            .references()
226            .map_err(|e| PawanError::Git(format!("References: {}", e)))?;
227        let session_refs = refs
228            .prefixed("refs/sessions/")
229            .map_err(|e| PawanError::Git(format!("Prefixed refs: {}", e)))?;
230        for r in session_refs.flatten() {
231            if let Some(id) = r.try_id() {
232                if let Ok(info) = self.info(id.detach()) {
233                    sessions.push(info);
234                }
235            }
236        }
237        sessions.sort_by_key(|info| Reverse(info.timestamp));
238        Ok(sessions)
239    }
240
241    // -- internals --
242
243    fn all_oids(&self) -> Result<Vec<ObjectId>> {
244        let mut oids: Vec<ObjectId> = Vec::new();
245        let mut visited: HashSet<ObjectId> = HashSet::new();
246        let mut stack: Vec<ObjectId> = Vec::new();
247
248        let refs = self
249            .repo
250            .references()
251            .map_err(|e| PawanError::Git(format!("References: {}", e)))?;
252        if let Ok(session_refs) = refs.prefixed("refs/sessions/") {
253            for r in session_refs.flatten() {
254                if let Some(id) = r.try_id() {
255                    stack.push(id.detach());
256                }
257            }
258        }
259
260        while let Some(oid) = stack.pop() {
261            if !visited.insert(oid) {
262                continue;
263            }
264            oids.push(oid);
265            if let Ok(c) = self.repo.find_commit(oid) {
266                for pid in c.parent_ids() {
267                    let pid = pid.detach();
268                    if !visited.contains(&pid) {
269                        stack.push(pid);
270                    }
271                }
272            }
273        }
274        Ok(oids)
275    }
276
277    fn info(&self, oid: ObjectId) -> Result<CommitInfo> {
278        let commit = self
279            .repo
280            .find_commit(oid)
281            .map_err(|e| PawanError::Git(format!("Not found: {}", e)))?;
282        let hash = oid.to_hex().to_string();
283        let (mc, model) = self.session_meta(&commit).unwrap_or((0, "unknown".into()));
284        let decoded = commit
285            .decode()
286            .map_err(|e| PawanError::Git(format!("Decode: {}", e)))?;
287        // decoded.author is &BStr (raw); parse it to get time
288        let author_sig = decoded
289            .author()
290            .map_err(|e| PawanError::Git(format!("Author parse: {}", e)))?;
291        let timestamp: i64 = author_sig
292            .time
293            .parse::<gix::date::Time>()
294            .map(|t| t.seconds)
295            .unwrap_or(0);
296
297        Ok(CommitInfo {
298            short_hash: hash[..8].to_string(),
299            hash,
300            message: decoded.message.trim().to_str_lossy().to_string(),
301            timestamp,
302            message_count: mc,
303            model,
304        })
305    }
306
307    fn session_meta(&self, commit: &gix::Commit<'_>) -> Option<(usize, String)> {
308        let decoded = commit.decode().ok()?;
309        let tree_oid = decoded.tree();
310        let tree = self.repo.find_tree(tree_oid).ok()?;
311        let tree_data = tree.decode().ok()?;
312        let entry = tree_data
313            .entries
314            .iter()
315            .find(|e| e.filename == b"session.json")?;
316        let entry_oid = entry.oid.to_owned();
317        let blob = self.repo.find_blob(entry_oid).ok()?;
318        let json = std::str::from_utf8(&blob.data).ok()?;
319        let s: Session = serde_json::from_str(json).ok()?;
320        Some((s.messages.len(), s.model))
321    }
322
323    fn session_from_commit(&self, commit: &gix::Commit<'_>) -> Result<Session> {
324        let decoded = commit
325            .decode()
326            .map_err(|e| PawanError::Git(format!("Decode: {}", e)))?;
327        let tree_oid = decoded.tree();
328        let tree = self
329            .repo
330            .find_tree(tree_oid)
331            .map_err(|e| PawanError::Git(format!("Tree: {}", e)))?;
332        let tree_data = tree
333            .decode()
334            .map_err(|e| PawanError::Git(format!("Decode tree: {}", e)))?;
335        let entry = tree_data
336            .entries
337            .iter()
338            .find(|e| e.filename == b"session.json")
339            .ok_or_else(|| PawanError::Git("No session.json".into()))?;
340        let entry_oid = entry.oid.to_owned();
341        let blob = self
342            .repo
343            .find_blob(entry_oid)
344            .map_err(|e| PawanError::Git(format!("Blob: {}", e)))?;
345        let json = std::str::from_utf8(&blob.data)
346            .map_err(|e| PawanError::Git(format!("UTF-8: {}", e)))?;
347        serde_json::from_str(json).map_err(|e| PawanError::Git(format!("Parse: {}", e)))
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::agent::{Message, Role};
355
356    fn test_store() -> (GitSessionStore, tempfile::TempDir) {
357        let dir = tempfile::TempDir::new().unwrap();
358        gix::create::into(
359            dir.path(),
360            gix::create::Kind::Bare,
361            gix::create::Options::default(),
362        )
363        .unwrap();
364        let repo = gix::open(dir.path()).unwrap();
365        (GitSessionStore { repo }, dir)
366    }
367
368    fn session(id: &str, msg: &str) -> Session {
369        Session {
370            notes: String::new(),
371            parent_id: None,
372            root_id: None,
373            branch_label: None,
374            branch_depth: 0,
375            labels: vec![],
376            id: id.into(),
377            model: "test-model".into(),
378            created_at: chrono::Utc::now().to_rfc3339(),
379            updated_at: chrono::Utc::now().to_rfc3339(),
380            messages: vec![Message {
381                role: Role::User,
382                content: msg.into(),
383                tool_calls: vec![],
384                tool_result: None,
385            }],
386            total_tokens: 0,
387            iteration_count: 0,
388            tags: Vec::new(),
389        }
390    }
391
392    #[test]
393    fn save_and_load() {
394        let (store, _dir) = test_store();
395        let s = session("s1", "hello world");
396        let hash = store.save_commit(&s, None).unwrap();
397        let loaded = store.load_commit(&hash).unwrap();
398        assert_eq!(loaded.id, "s1");
399        assert_eq!(loaded.messages[0].content, "hello world");
400    }
401
402    #[test]
403    fn fork_creates_branch() {
404        let (store, _dir) = test_store();
405        let s1 = session("s1", "root msg");
406        let root = store.save_commit(&s1, None).unwrap();
407
408        let s2 = session("s1-fork", "branch msg");
409        let fork = store.fork(&root, &s2).unwrap();
410
411        let lineage = store.lineage(&fork).unwrap();
412        assert_eq!(lineage.len(), 2);
413        assert_eq!(lineage[1].hash, root);
414    }
415
416    #[test]
417    fn leaves_finds_tips() {
418        let (store, _dir) = test_store();
419        let s = session("s1", "root");
420        let root = store.save_commit(&s, None).unwrap();
421
422        let a = session("a", "child a");
423        let ha = store.save_commit(&a, Some(&root)).unwrap();
424
425        let b = session("b", "child b");
426        let hb = store.save_commit(&b, Some(&root)).unwrap();
427
428        let leaves = store.list_leaves().unwrap();
429        let hashes: Vec<&str> = leaves.iter().map(|l| l.hash.as_str()).collect();
430        assert_eq!(leaves.len(), 2);
431        assert!(hashes.contains(&ha.as_str()));
432        assert!(hashes.contains(&hb.as_str()));
433    }
434
435    #[test]
436    fn children_finds_forks() {
437        let (store, _dir) = test_store();
438        let s = session("s1", "root");
439        let root = store.save_commit(&s, None).unwrap();
440
441        store
442            .save_commit(&session("a", "fork1"), Some(&root))
443            .unwrap();
444        store
445            .save_commit(&session("b", "fork2"), Some(&root))
446            .unwrap();
447
448        let children = store.children(&root).unwrap();
449        assert_eq!(children.len(), 2);
450    }
451
452    #[test]
453    fn test_list_sessions_after_save() {
454        let (store, _dir) = test_store();
455        let s = session("sess-list-1", "session list test");
456        store.save_commit(&s, None).unwrap();
457
458        let sessions = store.list_sessions().unwrap();
459        assert!(
460            !sessions.is_empty(),
461            "list_sessions must be non-empty after save"
462        );
463        let found = sessions.iter().any(|c| c.message.contains("sess-list-1"));
464        assert!(found, "saved session id must appear in list_sessions()");
465    }
466
467    #[test]
468    fn test_load_commit_bad_hash_returns_git_error() {
469        let (store, _dir) = test_store();
470        let err = store.load_commit("not_a_valid_hash_zzz").unwrap_err();
471        match err {
472            crate::PawanError::Git(msg) => {
473                assert!(!msg.is_empty(), "Git error message must not be empty")
474            }
475            other => panic!("expected PawanError::Git, got {:?}", other),
476        }
477    }
478
479    #[test]
480    fn test_list_leaves_empty_repo_returns_empty() {
481        let (store, _dir) = test_store();
482        let leaves = store.list_leaves().unwrap();
483        assert!(leaves.is_empty(), "empty repo must have no leaves");
484    }
485
486    #[test]
487    fn test_commit_message_no_user_messages_uses_fallback() {
488        let s = Session {
489            notes: String::new(),
490            parent_id: None,
491            root_id: None,
492            branch_label: None,
493            branch_depth: 0,
494            labels: vec![],
495            id: "no-msg".into(),
496            model: "m".into(),
497            created_at: chrono::Utc::now().to_rfc3339(),
498            updated_at: chrono::Utc::now().to_rfc3339(),
499            messages: vec![],
500            total_tokens: 0,
501            iteration_count: 0,
502            tags: Vec::new(),
503        };
504        let msg = GitSessionStore::commit_message(&s);
505        assert!(
506            msg.contains("new session"),
507            "commit message with no user messages must say 'new session', got: {msg}"
508        );
509        assert!(
510            msg.contains("no-msg"),
511            "must include session id, got: {msg}"
512        );
513    }
514
515    #[test]
516    fn test_lineage_root_has_single_entry() {
517        let (store, _dir) = test_store();
518        let s = session("root-only", "the root");
519        let root_hash = store.save_commit(&s, None).unwrap();
520
521        let lineage = store.lineage(&root_hash).unwrap();
522        assert_eq!(
523            lineage.len(),
524            1,
525            "root commit must have lineage of length 1"
526        );
527        assert_eq!(lineage[0].hash, root_hash);
528    }
529}