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 libgit2 (git2-rs). Future: layer jj (Jujutsu) as porcelain on top.
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 git2::{Oid, Repository, Signature, Time};
13use serde::{Deserialize, Serialize};
14use std::collections::HashSet;
15use std::path::PathBuf;
16
17/// Summary of a git-backed session commit
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CommitInfo {
20    pub hash: String,
21    pub short_hash: String,
22    pub message: String,
23    pub timestamp: i64,
24    pub message_count: usize,
25    pub model: String,
26}
27
28/// Git-backed session store using a bare repo at ~/.pawan/repo/
29pub struct GitSessionStore {
30    repo: Repository,
31}
32
33impl GitSessionStore {
34    /// Initialize or open the git session store
35    pub fn init() -> Result<Self> {
36        let path = Self::default_path()?;
37        let repo = if path.join("HEAD").exists() {
38            Repository::open_bare(&path)
39                .map_err(|e| PawanError::Git(format!("Open repo: {}", e)))?
40        } else {
41            std::fs::create_dir_all(&path)
42                .map_err(|e| PawanError::Git(format!("Create dir: {}", e)))?;
43            Repository::init_bare(&path)
44                .map_err(|e| PawanError::Git(format!("Init repo: {}", e)))?
45        };
46        Ok(Self { repo })
47    }
48
49    /// Open with a custom path (for testing)
50    pub fn open(repo: Repository) -> Self {
51        Self { repo }
52    }
53
54    fn default_path() -> Result<PathBuf> {
55        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
56        Ok(PathBuf::from(home).join(".pawan").join("repo"))
57    }
58
59    fn sig(&self) -> Signature<'_> {
60        let now = chrono::Utc::now().timestamp();
61        Signature::new("pawan", "pawan@dirmacs.com", &Time::new(now, 0))
62            .expect("valid signature")
63    }
64
65    fn commit_message(session: &Session) -> String {
66        session
67            .messages
68            .iter()
69            .rev()
70            .find(|m| m.role == crate::agent::Role::User)
71            .map(|m| {
72                let trunc: String = m.content.chars().take(80).collect();
73                format!("[{}] {}", session.id, trunc)
74            })
75            .unwrap_or_else(|| format!("[{}] new session", session.id))
76    }
77
78    /// Save session as a git commit. Returns the commit hash.
79    pub fn save_commit(&self, session: &Session, parent_hash: Option<&str>) -> Result<String> {
80        let json = serde_json::to_string_pretty(session)
81            .map_err(|e| PawanError::Git(format!("Serialize: {}", e)))?;
82
83        let blob_oid = self.repo.blob(json.as_bytes())
84            .map_err(|e| PawanError::Git(format!("Blob: {}", e)))?;
85
86        let mut tb = self.repo.treebuilder(None)
87            .map_err(|e| PawanError::Git(format!("Treebuilder: {}", e)))?;
88        tb.insert("session.json", blob_oid, 0o100644)
89            .map_err(|e| PawanError::Git(format!("Insert: {}", e)))?;
90        let tree_oid = tb.write()
91            .map_err(|e| PawanError::Git(format!("Write tree: {}", e)))?;
92        let tree = self.repo.find_tree(tree_oid)
93            .map_err(|e| PawanError::Git(format!("Find tree: {}", e)))?;
94
95        let sig = self.sig();
96        let msg = Self::commit_message(session);
97
98        let parents: Vec<git2::Commit> = match parent_hash {
99            Some(h) => {
100                let oid = Oid::from_str(h)
101                    .map_err(|e| PawanError::Git(format!("Bad hash: {}", e)))?;
102                vec![self.repo.find_commit(oid)
103                    .map_err(|e| PawanError::Git(format!("Parent not found: {}", e)))?]
104            }
105            None => vec![],
106        };
107        let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
108
109        let oid = self.repo.commit(None, &sig, &sig, &msg, &tree, &parent_refs)
110            .map_err(|e| PawanError::Git(format!("Commit: {}", e)))?;
111
112        // Update session ref
113        let refname = format!("refs/sessions/{}", session.id);
114        self.repo.reference(&refname, oid, true, &msg)
115            .map_err(|e| PawanError::Git(format!("Ref: {}", e)))?;
116
117        Ok(oid.to_string())
118    }
119
120    /// Load session from a commit hash
121    pub fn load_commit(&self, hash: &str) -> Result<Session> {
122        let oid = Oid::from_str(hash)
123            .map_err(|e| PawanError::Git(format!("Bad hash: {}", e)))?;
124        let commit = self.repo.find_commit(oid)
125            .map_err(|e| PawanError::Git(format!("Not found: {}", e)))?;
126        self.session_from_commit(&commit)
127    }
128
129    /// Fork: create a new commit branching off parent
130    pub fn fork(&self, parent_hash: &str, session: &Session) -> Result<String> {
131        self.save_commit(session, Some(parent_hash))
132    }
133
134    /// List leaf commits (conversation tips with no children)
135    pub fn list_leaves(&self) -> Result<Vec<CommitInfo>> {
136        let all_oids = self.all_oids()?;
137        let mut parent_set = HashSet::new();
138
139        for &oid in &all_oids {
140            if let Ok(c) = self.repo.find_commit(oid) {
141                for p in c.parents() {
142                    parent_set.insert(p.id());
143                }
144            }
145        }
146
147        let mut leaves = Vec::new();
148        for &oid in &all_oids {
149            if !parent_set.contains(&oid) {
150                if let Ok(info) = self.info(oid) {
151                    leaves.push(info);
152                }
153            }
154        }
155        leaves.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
156        Ok(leaves)
157    }
158
159    /// Walk lineage from commit to root
160    pub fn lineage(&self, hash: &str) -> Result<Vec<CommitInfo>> {
161        let mut oid = Oid::from_str(hash)
162            .map_err(|e| PawanError::Git(format!("Bad hash: {}", e)))?;
163        let mut chain = Vec::new();
164
165        loop {
166            let commit = self.repo.find_commit(oid)
167                .map_err(|e| PawanError::Git(format!("Not found: {}", e)))?;
168            chain.push(self.info(oid)?);
169            if commit.parent_count() == 0 { break; }
170            oid = commit.parent_id(0)
171                .map_err(|e| PawanError::Git(format!("Parent: {}", e)))?;
172        }
173        Ok(chain)
174    }
175
176    /// Find all children of a commit
177    pub fn children(&self, hash: &str) -> Result<Vec<CommitInfo>> {
178        let target = Oid::from_str(hash)
179            .map_err(|e| PawanError::Git(format!("Bad hash: {}", e)))?;
180        let all = self.all_oids()?;
181        let mut result = Vec::new();
182
183        for &oid in &all {
184            if let Ok(c) = self.repo.find_commit(oid) {
185                for p in c.parents() {
186                    if p.id() == target {
187                        if let Ok(info) = self.info(oid) {
188                            result.push(info);
189                        }
190                    }
191                }
192            }
193        }
194        Ok(result)
195    }
196
197    /// List all session refs (latest commit per session)
198    pub fn list_sessions(&self) -> Result<Vec<CommitInfo>> {
199        let mut sessions = Vec::new();
200        if let Ok(refs) = self.repo.references_glob("refs/sessions/*") {
201            for r in refs.flatten() {
202                if let Some(oid) = r.target() {
203                    if let Ok(info) = self.info(oid) {
204                        sessions.push(info);
205                    }
206                }
207            }
208        }
209        sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
210        Ok(sessions)
211    }
212
213    // -- internals --
214
215    fn all_oids(&self) -> Result<Vec<Oid>> {
216        let mut oids = Vec::new();
217        let mut visited = HashSet::new();
218        let mut stack = Vec::new();
219
220        if let Ok(refs) = self.repo.references_glob("refs/sessions/*") {
221            for r in refs.flatten() {
222                if let Some(oid) = r.target() {
223                    stack.push(oid);
224                }
225            }
226        }
227
228        while let Some(oid) = stack.pop() {
229            if !visited.insert(oid) { continue; }
230            oids.push(oid);
231            if let Ok(c) = self.repo.find_commit(oid) {
232                for p in c.parents() {
233                    if !visited.contains(&p.id()) {
234                        stack.push(p.id());
235                    }
236                }
237            }
238        }
239        Ok(oids)
240    }
241
242    fn info(&self, oid: Oid) -> Result<CommitInfo> {
243        let commit = self.repo.find_commit(oid)
244            .map_err(|e| PawanError::Git(format!("Not found: {}", e)))?;
245        let hash = oid.to_string();
246        let (mc, model) = self.session_meta(&commit).unwrap_or((0, "unknown".into()));
247
248        Ok(CommitInfo {
249            short_hash: hash[..8].to_string(),
250            hash,
251            message: commit.message().unwrap_or("").to_string(),
252            timestamp: commit.time().seconds(),
253            message_count: mc,
254            model,
255        })
256    }
257
258    fn session_meta(&self, commit: &git2::Commit) -> Option<(usize, String)> {
259        let tree = commit.tree().ok()?;
260        let entry = tree.get_name("session.json")?;
261        let blob = self.repo.find_blob(entry.id()).ok()?;
262        let json = std::str::from_utf8(blob.content()).ok()?;
263        let s: Session = serde_json::from_str(json).ok()?;
264        Some((s.messages.len(), s.model))
265    }
266
267    fn session_from_commit(&self, commit: &git2::Commit) -> Result<Session> {
268        let tree = commit.tree()
269            .map_err(|e| PawanError::Git(format!("Tree: {}", e)))?;
270        let entry = tree.get_name("session.json")
271            .ok_or_else(|| PawanError::Git("No session.json".into()))?;
272        let blob = self.repo.find_blob(entry.id())
273            .map_err(|e| PawanError::Git(format!("Blob: {}", e)))?;
274        let json = std::str::from_utf8(blob.content())
275            .map_err(|e| PawanError::Git(format!("UTF-8: {}", e)))?;
276        serde_json::from_str(json)
277            .map_err(|e| PawanError::Git(format!("Parse: {}", e)))
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::agent::{Message, Role};
285
286    fn test_store() -> (GitSessionStore, tempfile::TempDir) {
287        let dir = tempfile::TempDir::new().unwrap();
288        let repo = Repository::init_bare(dir.path()).unwrap();
289        (GitSessionStore { repo }, dir)
290    }
291
292    fn session(id: &str, msg: &str) -> Session {
293        Session {
294            id: id.into(),
295            model: "test-model".into(),
296            created_at: chrono::Utc::now().to_rfc3339(),
297            updated_at: chrono::Utc::now().to_rfc3339(),
298            messages: vec![Message {
299                role: Role::User,
300                content: msg.into(),
301                tool_calls: vec![],
302                tool_result: None,
303            }],
304            total_tokens: 0,
305            iteration_count: 0,
306        }
307    }
308
309    #[test]
310    fn save_and_load() {
311        let (store, _dir) = test_store();
312        let s = session("s1", "hello world");
313        let hash = store.save_commit(&s, None).unwrap();
314        let loaded = store.load_commit(&hash).unwrap();
315        assert_eq!(loaded.id, "s1");
316        assert_eq!(loaded.messages[0].content, "hello world");
317    }
318
319    #[test]
320    fn fork_creates_branch() {
321        let (store, _dir) = test_store();
322        let s1 = session("s1", "root msg");
323        let root = store.save_commit(&s1, None).unwrap();
324
325        let s2 = session("s1-fork", "branch msg");
326        let fork = store.fork(&root, &s2).unwrap();
327
328        let lineage = store.lineage(&fork).unwrap();
329        assert_eq!(lineage.len(), 2);
330        assert_eq!(lineage[1].hash, root);
331    }
332
333    #[test]
334    fn leaves_finds_tips() {
335        let (store, _dir) = test_store();
336        let s = session("s1", "root");
337        let root = store.save_commit(&s, None).unwrap();
338
339        let a = session("a", "child a");
340        let ha = store.save_commit(&a, Some(&root)).unwrap();
341
342        let b = session("b", "child b");
343        let hb = store.save_commit(&b, Some(&root)).unwrap();
344
345        let leaves = store.list_leaves().unwrap();
346        let hashes: Vec<&str> = leaves.iter().map(|l| l.hash.as_str()).collect();
347        assert_eq!(leaves.len(), 2);
348        assert!(hashes.contains(&ha.as_str()));
349        assert!(hashes.contains(&hb.as_str()));
350    }
351
352    #[test]
353    fn children_finds_forks() {
354        let (store, _dir) = test_store();
355        let s = session("s1", "root");
356        let root = store.save_commit(&s, None).unwrap();
357
358        store.save_commit(&session("a", "fork1"), Some(&root)).unwrap();
359        store.save_commit(&session("b", "fork2"), Some(&root)).unwrap();
360
361        let children = store.children(&root).unwrap();
362        assert_eq!(children.len(), 2);
363    }
364}