1use 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#[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
28pub struct GitSessionStore {
30 repo: Repository,
31}
32
33impl GitSessionStore {
34 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 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 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 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 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 pub fn fork(&self, parent_hash: &str, session: &Session) -> Result<String> {
131 self.save_commit(session, Some(parent_hash))
132 }
133
134 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 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 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 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 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}