1use 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#[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
30pub struct GitSessionStore {
32 repo: gix::Repository,
33}
34
35impl GitSessionStore {
36 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 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 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 let blob_id = self.repo
83 .write_blob(json.as_bytes())
84 .map_err(|e| PawanError::Git(format!("Blob: {}", e)))?
85 .detach();
86
87 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 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 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 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 pub fn fork(&self, parent_hash: &str, session: &Session) -> Result<String> {
141 self.save_commit(session, Some(parent_hash))
142 }
143
144 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 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 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 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 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 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 id: id.into(),
334 model: "test-model".into(),
335 created_at: chrono::Utc::now().to_rfc3339(),
336 updated_at: chrono::Utc::now().to_rfc3339(),
337 messages: vec![Message {
338 role: Role::User,
339 content: msg.into(),
340 tool_calls: vec![],
341 tool_result: None,
342 }],
343 total_tokens: 0,
344 iteration_count: 0,
345 tags: Vec::new(),
346 }
347 }
348
349 #[test]
350 fn save_and_load() {
351 let (store, _dir) = test_store();
352 let s = session("s1", "hello world");
353 let hash = store.save_commit(&s, None).unwrap();
354 let loaded = store.load_commit(&hash).unwrap();
355 assert_eq!(loaded.id, "s1");
356 assert_eq!(loaded.messages[0].content, "hello world");
357 }
358
359 #[test]
360 fn fork_creates_branch() {
361 let (store, _dir) = test_store();
362 let s1 = session("s1", "root msg");
363 let root = store.save_commit(&s1, None).unwrap();
364
365 let s2 = session("s1-fork", "branch msg");
366 let fork = store.fork(&root, &s2).unwrap();
367
368 let lineage = store.lineage(&fork).unwrap();
369 assert_eq!(lineage.len(), 2);
370 assert_eq!(lineage[1].hash, root);
371 }
372
373 #[test]
374 fn leaves_finds_tips() {
375 let (store, _dir) = test_store();
376 let s = session("s1", "root");
377 let root = store.save_commit(&s, None).unwrap();
378
379 let a = session("a", "child a");
380 let ha = store.save_commit(&a, Some(&root)).unwrap();
381
382 let b = session("b", "child b");
383 let hb = store.save_commit(&b, Some(&root)).unwrap();
384
385 let leaves = store.list_leaves().unwrap();
386 let hashes: Vec<&str> = leaves.iter().map(|l| l.hash.as_str()).collect();
387 assert_eq!(leaves.len(), 2);
388 assert!(hashes.contains(&ha.as_str()));
389 assert!(hashes.contains(&hb.as_str()));
390 }
391
392 #[test]
393 fn children_finds_forks() {
394 let (store, _dir) = test_store();
395 let s = session("s1", "root");
396 let root = store.save_commit(&s, None).unwrap();
397
398 store.save_commit(&session("a", "fork1"), Some(&root)).unwrap();
399 store.save_commit(&session("b", "fork2"), Some(&root)).unwrap();
400
401 let children = store.children(&root).unwrap();
402 assert_eq!(children.len(), 2);
403 }
404
405 #[test]
406 fn test_list_sessions_after_save() {
407 let (store, _dir) = test_store();
408 let s = session("sess-list-1", "session list test");
409 store.save_commit(&s, None).unwrap();
410
411 let sessions = store.list_sessions().unwrap();
412 assert!(!sessions.is_empty(), "list_sessions must be non-empty after save");
413 let found = sessions.iter().any(|c| c.message.contains("sess-list-1"));
414 assert!(found, "saved session id must appear in list_sessions()");
415 }
416
417 #[test]
418 fn test_load_commit_bad_hash_returns_git_error() {
419 let (store, _dir) = test_store();
420 let err = store.load_commit("not_a_valid_hash_zzz").unwrap_err();
421 match err {
422 crate::PawanError::Git(msg) => {
423 assert!(!msg.is_empty(), "Git error message must not be empty")
424 }
425 other => panic!("expected PawanError::Git, got {:?}", other),
426 }
427 }
428
429 #[test]
430 fn test_list_leaves_empty_repo_returns_empty() {
431 let (store, _dir) = test_store();
432 let leaves = store.list_leaves().unwrap();
433 assert!(leaves.is_empty(), "empty repo must have no leaves");
434 }
435
436 #[test]
437 fn test_commit_message_no_user_messages_uses_fallback() {
438 let s = Session {
439 notes: String::new(),
440 id: "no-msg".into(),
441 model: "m".into(),
442 created_at: chrono::Utc::now().to_rfc3339(),
443 updated_at: chrono::Utc::now().to_rfc3339(),
444 messages: vec![],
445 total_tokens: 0,
446 iteration_count: 0,
447 tags: Vec::new(),
448 };
449 let msg = GitSessionStore::commit_message(&s);
450 assert!(
451 msg.contains("new session"),
452 "commit message with no user messages must say 'new session', got: {msg}"
453 );
454 assert!(msg.contains("no-msg"), "must include session id, got: {msg}");
455 }
456
457 #[test]
458 fn test_lineage_root_has_single_entry() {
459 let (store, _dir) = test_store();
460 let s = session("root-only", "the root");
461 let root_hash = store.save_commit(&s, None).unwrap();
462
463 let lineage = store.lineage(&root_hash).unwrap();
464 assert_eq!(lineage.len(), 1, "root commit must have lineage of length 1");
465 assert_eq!(lineage[0].hash, root_hash);
466 }
467}