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