1use 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#[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
31pub struct GitSessionStore {
33 repo: gix::Repository,
34}
35
36impl GitSessionStore {
37 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 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 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 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 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 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 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 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 pub fn fork(&self, parent_hash: &str, session: &Session) -> Result<String> {
150 self.save_commit(session, Some(parent_hash))
151 }
152
153 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 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 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 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 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 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}