use std::path::Path;
use git2::{Oid, Repository};
use crate::error::{Error, Result};
use crate::models::checkpoint::Checkpoint;
pub const NOTES_REF: &str = "refs/notes/agent-checkpoints";
pub struct CheckpointStore {
repo: Repository,
}
impl CheckpointStore {
pub fn open(repo_path: &Path) -> Result<Self> {
let repo = Repository::discover(repo_path).map_err(|e| {
if e.code() == git2::ErrorCode::NotFound {
Error::NotAGitRepo {
path: repo_path.to_path_buf(),
}
} else {
Error::Git(e)
}
})?;
Ok(Self { repo })
}
pub fn repo(&self) -> &Repository {
&self.repo
}
pub fn save(&self, checkpoint: &Checkpoint) -> Result<()> {
let oid = self.resolve_oid(&checkpoint.commit_sha)?;
let json = serde_json::to_string_pretty(checkpoint)?;
let sig = self.default_signature()?;
self.repo
.note(&sig, &sig, Some(NOTES_REF), oid, &json, true)?;
Ok(())
}
pub fn load(&self, commit_sha: &str) -> Result<Checkpoint> {
let oid = self.resolve_oid(commit_sha)?;
let note = self.repo.find_note(Some(NOTES_REF), oid).map_err(|e| {
if e.code() == git2::ErrorCode::NotFound {
Error::CheckpointNotFound {
sha: commit_sha.to_string(),
}
} else {
Error::Git(e)
}
})?;
let message = note.message().ok_or_else(|| Error::CheckpointError {
reason: format!("Note for {commit_sha} contains non-UTF-8 data"),
})?;
let checkpoint: Checkpoint = serde_json::from_str(message)?;
Ok(checkpoint)
}
pub fn list(&self) -> Result<Vec<(String, Checkpoint)>> {
let mut results = Vec::new();
let notes = match self.repo.notes(Some(NOTES_REF)) {
Ok(notes) => notes,
Err(e) if e.code() == git2::ErrorCode::NotFound => return Ok(results),
Err(e) => return Err(Error::Git(e)),
};
for note_result in notes {
let (note_oid, commit_oid) = note_result?;
let note_blob = self.repo.find_blob(note_oid)?;
let content = std::str::from_utf8(note_blob.content()).map_err(|_| {
Error::CheckpointError {
reason: format!("Non-UTF-8 note blob for commit {commit_oid}"),
}
})?;
match serde_json::from_str::<Checkpoint>(content) {
Ok(checkpoint) => {
results.push((commit_oid.to_string(), checkpoint));
}
Err(e) => {
tracing::warn!(
commit = %commit_oid,
error = %e,
"Skipping unparseable checkpoint note"
);
}
}
}
Ok(results)
}
pub fn delete(&self, commit_sha: &str) -> Result<()> {
let oid = self.resolve_oid(commit_sha)?;
let sig = self.default_signature()?;
self.repo
.note_delete(oid, Some(NOTES_REF), &sig, &sig)
.map_err(|e| {
if e.code() == git2::ErrorCode::NotFound {
Error::CheckpointNotFound {
sha: commit_sha.to_string(),
}
} else {
Error::Git(e)
}
})?;
Ok(())
}
pub fn exists(&self, commit_sha: &str) -> Result<bool> {
let oid = self.resolve_oid(commit_sha)?;
match self.repo.find_note(Some(NOTES_REF), oid) {
Ok(_) => Ok(true),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(false),
Err(e) => Err(Error::Git(e)),
}
}
fn resolve_oid(&self, spec: &str) -> Result<Oid> {
let obj = self.repo.revparse_single(spec).map_err(|e| {
if e.code() == git2::ErrorCode::NotFound {
Error::CheckpointNotFound {
sha: spec.to_string(),
}
} else {
Error::Git(e)
}
})?;
let commit = obj.peel_to_commit().map_err(|_| Error::CheckpointError {
reason: format!("'{spec}' does not resolve to a commit"),
})?;
Ok(commit.id())
}
fn default_signature(&self) -> Result<git2::Signature<'_>> {
self.repo.signature().or_else(|_| {
git2::Signature::now("agent-teams", "agent-teams@checkpoint")
.map_err(|e| Error::Git(e))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::checkpoint::{CheckpointSession, CheckpointFile, FileRole};
use tempfile::TempDir;
fn setup_test_repo() -> (TempDir, Repository) {
let dir = TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
{
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
std::fs::write(dir.path().join("README.md"), "# Test").unwrap();
index.add_path(Path::new("README.md")).unwrap();
index.write().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
}
(dir, repo)
}
#[test]
fn save_and_load_checkpoint() {
let (dir, _repo) = setup_test_repo();
let store = CheckpointStore::open(dir.path()).unwrap();
let session = CheckpointSession::new("test-agent");
let mut checkpoint = Checkpoint::new("HEAD", "main", session);
checkpoint.files.push(CheckpointFile {
path: "README.md".into(),
role: FileRole::Modified,
content_hash: None,
});
let head_sha = {
let obj = store.repo.revparse_single("HEAD").unwrap();
obj.id().to_string()
};
checkpoint.commit_sha = head_sha.clone();
store.save(&checkpoint).unwrap();
let loaded = store.load(&head_sha).unwrap();
assert_eq!(loaded.session.agent_name, "test-agent");
assert_eq!(loaded.files.len(), 1);
assert_eq!(loaded.files[0].path, "README.md");
}
#[test]
fn list_checkpoints() {
let (dir, repo) = setup_test_repo();
let store = CheckpointStore::open(dir.path()).unwrap();
let list = store.list().unwrap();
assert!(list.is_empty());
let head_sha = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
let session = CheckpointSession::new("agent-1");
let mut ckpt = Checkpoint::new(&head_sha, "main", session);
ckpt.commit_sha = head_sha.clone();
store.save(&ckpt).unwrap();
let list = store.list().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].1.session.agent_name, "agent-1");
}
#[test]
fn delete_checkpoint() {
let (dir, repo) = setup_test_repo();
let store = CheckpointStore::open(dir.path()).unwrap();
let head_sha = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
let session = CheckpointSession::new("agent-1");
let mut ckpt = Checkpoint::new(&head_sha, "main", session);
ckpt.commit_sha = head_sha.clone();
store.save(&ckpt).unwrap();
assert!(store.exists(&head_sha).unwrap());
store.delete(&head_sha).unwrap();
assert!(!store.exists(&head_sha).unwrap());
}
#[test]
fn overwrite_checkpoint() {
let (dir, repo) = setup_test_repo();
let store = CheckpointStore::open(dir.path()).unwrap();
let head_sha = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
let session1 = CheckpointSession::new("agent-v1");
let mut ckpt1 = Checkpoint::new(&head_sha, "main", session1);
ckpt1.commit_sha = head_sha.clone();
store.save(&ckpt1).unwrap();
let session2 = CheckpointSession::new("agent-v2");
let mut ckpt2 = Checkpoint::new(&head_sha, "main", session2);
ckpt2.commit_sha = head_sha.clone();
store.save(&ckpt2).unwrap();
let loaded = store.load(&head_sha).unwrap();
assert_eq!(loaded.session.agent_name, "agent-v2");
}
#[test]
fn load_nonexistent_checkpoint() {
let (dir, repo) = setup_test_repo();
let store = CheckpointStore::open(dir.path()).unwrap();
let head_sha = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
let result = store.load(&head_sha);
assert!(result.is_err());
}
#[test]
fn not_a_git_repo() {
let dir = TempDir::new().unwrap();
let result = CheckpointStore::open(dir.path());
assert!(matches!(result, Err(Error::NotAGitRepo { .. })));
}
}