use git2::{DiffFormat, IndexAddOption, ObjectType, Oid, Repository, Signature, Sort};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const GITIGNORE_BODY: &str = "# Auto-generated by agent workspace_git. Customize at will.\n\
transcripts/\n\
media/\n\
*.tmp\n\
*.swp\n\
.DS_Store\n";
const GITATTRIBUTES_BODY: &str = "*.md text eol=lf\n\
*.yaml text eol=lf\n\
*.json text eol=lf\n";
pub const MAX_COMMIT_FILE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, Serialize)]
pub struct CommitSummary {
pub oid: String,
pub short_oid: String,
pub subject: String,
pub body: String,
pub author: String,
pub timestamp_unix: i64,
}
pub struct MemoryGitRepo {
root: PathBuf,
author_name: String,
author_email: String,
inner: Mutex<Repository>,
}
impl MemoryGitRepo {
pub fn open_or_init(
root: &Path,
author_name: impl Into<String>,
author_email: impl Into<String>,
) -> anyhow::Result<Self> {
fs::create_dir_all(root).ok();
let author_name = author_name.into();
let author_email = author_email.into();
let repo = match Repository::open(root) {
Ok(r) => r,
Err(_) => {
let r = Repository::init(root)?;
write_bootstrap_files(root)?;
bootstrap_commit(&r, &author_name, &author_email)?;
r
}
};
Ok(Self {
root: root.to_path_buf(),
author_name,
author_email,
inner: Mutex::new(repo),
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn commit_all(&self, subject: &str, body: &str) -> anyhow::Result<Option<Oid>> {
let repo = self.inner.lock().unwrap_or_else(|p| p.into_inner());
let mut index = repo.index()?;
index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
index.write()?;
let oversized: Vec<String> = index
.iter()
.filter_map(|entry| {
if entry.file_size as u64 > MAX_COMMIT_FILE_BYTES {
Some(
std::str::from_utf8(&entry.path)
.unwrap_or("<non-utf8>")
.to_string(),
)
} else {
None
}
})
.collect();
for path in &oversized {
tracing::warn!(
path = %path,
limit = MAX_COMMIT_FILE_BYTES,
"workspace_git: skipping oversized file"
);
index.remove_path(Path::new(path))?;
}
if !oversized.is_empty() {
index.write()?;
}
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
if let Ok(head_ref) = repo.head() {
if let Some(head_oid) = head_ref.target() {
let head_commit = repo.find_commit(head_oid)?;
if head_commit.tree_id() == tree_id {
return Ok(None);
}
}
}
let sig = Signature::now(&self.author_name, &self.author_email)?;
let message = format_message(subject, body);
let parents: Vec<git2::Commit> = match repo.head() {
Ok(head_ref) => head_ref
.target()
.and_then(|oid| repo.find_commit(oid).ok())
.into_iter()
.collect(),
Err(_) => Vec::new(),
};
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
let oid = repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parent_refs)?;
Ok(Some(oid))
}
pub fn log(&self, limit: usize) -> anyhow::Result<Vec<CommitSummary>> {
let repo = self.inner.lock().unwrap_or_else(|p| p.into_inner());
let mut walk = repo.revwalk()?;
walk.set_sorting(Sort::NONE)?;
if walk.push_head().is_err() {
return Ok(Vec::new());
}
let mut out = Vec::with_capacity(limit.min(32));
for (i, oid_result) in walk.enumerate() {
if i >= limit {
break;
}
let oid = oid_result?;
let c = repo.find_commit(oid)?;
let full_msg = c.message().unwrap_or("").to_string();
let (subject, body) = split_subject_body(&full_msg);
let full_oid = format!("{oid}");
let short_oid: String = full_oid.chars().take(7).collect();
out.push(CommitSummary {
oid: full_oid,
short_oid,
subject,
body,
author: c.author().name().map(|s| s.to_string()).unwrap_or_default(),
timestamp_unix: c.time().seconds(),
});
}
Ok(out)
}
pub fn diff_since(&self, from_oid: Option<Oid>) -> anyhow::Result<String> {
let repo = self.inner.lock().unwrap_or_else(|p| p.into_inner());
let head_tree = match repo.head() {
Ok(head_ref) => head_ref
.target()
.and_then(|oid| repo.find_commit(oid).ok())
.map(|c| c.tree().ok())
.unwrap_or(None),
Err(_) => return Ok(String::new()),
};
let Some(head_tree) = head_tree else {
return Ok(String::new());
};
let from_tree = match from_oid {
Some(oid) => repo.find_commit(oid)?.tree()?,
None => {
let Some(head) = repo.head()?.target() else {
return Ok(String::new());
};
let head_commit = repo.find_commit(head)?;
match head_commit.parent(0) {
Ok(p) => p.tree()?,
Err(_) => return Ok(String::new()),
}
}
};
let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&head_tree), None)?;
let mut out = String::new();
diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
match line.origin() {
'+' | '-' | ' ' => out.push(line.origin()),
_ => {}
}
if let Ok(s) = std::str::from_utf8(line.content()) {
out.push_str(s);
}
true
})?;
Ok(out)
}
}
fn write_bootstrap_files(root: &Path) -> anyhow::Result<()> {
let gi = root.join(".gitignore");
if !gi.exists() {
fs::write(&gi, GITIGNORE_BODY)?;
}
let ga = root.join(".gitattributes");
if !ga.exists() {
fs::write(&ga, GITATTRIBUTES_BODY)?;
}
Ok(())
}
fn bootstrap_commit(
repo: &Repository,
author_name: &str,
author_email: &str,
) -> anyhow::Result<Oid> {
let mut index = repo.index()?;
index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
index.write()?;
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
let sig = Signature::now(author_name, author_email)?;
let oid = repo.commit(
Some("HEAD"),
&sig,
&sig,
"workspace init\n\nAuto-generated bootstrap commit.",
&tree,
&[],
)?;
if repo
.head()
.map(|r| r.shorthand().is_none())
.unwrap_or(false)
{
}
let _ = repo.reference("refs/heads/main", oid, true, "bootstrap");
let _ = repo.set_head("refs/heads/main");
let _ = ObjectType::Commit; Ok(oid)
}
fn format_message(subject: &str, body: &str) -> String {
let subject = subject.trim();
let body = body.trim();
if body.is_empty() {
format!("{subject}\n")
} else {
format!("{subject}\n\n{body}\n")
}
}
fn split_subject_body(message: &str) -> (String, String) {
match message.split_once("\n\n") {
Some((s, b)) => (s.trim().to_string(), b.trim().to_string()),
None => (message.trim().to_string(), String::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn repo_in(td: &TempDir) -> MemoryGitRepo {
MemoryGitRepo::open_or_init(td.path(), "kate", "kate@test").unwrap()
}
#[test]
fn init_creates_git_dir_and_bootstrap_commit() {
let td = TempDir::new().unwrap();
let repo = repo_in(&td);
assert!(td.path().join(".git").exists());
assert!(td.path().join(".gitignore").exists());
assert!(td.path().join(".gitattributes").exists());
let log = repo.log(10).unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].subject, "workspace init");
}
#[test]
fn commit_all_creates_new_commit() {
let td = TempDir::new().unwrap();
let repo = repo_in(&td);
std::fs::write(td.path().join("MEMORY.md"), "# hello\n").unwrap();
let oid = repo.commit_all("memory: note", "added MEMORY.md").unwrap();
assert!(oid.is_some());
let log = repo.log(10).unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0].subject, "memory: note");
}
#[test]
fn commit_all_on_clean_tree_returns_none() {
let td = TempDir::new().unwrap();
let repo = repo_in(&td);
let oid = repo.commit_all("noop", "").unwrap();
assert!(oid.is_none());
}
#[test]
fn commit_all_skips_oversized_files() {
let td = TempDir::new().unwrap();
let repo = repo_in(&td);
std::fs::write(td.path().join("MEMORY.md"), "# normal\n").unwrap();
let big = vec![b'x'; (MAX_COMMIT_FILE_BYTES + 1) as usize];
std::fs::write(td.path().join("big.bin"), big).unwrap();
let oid = repo.commit_all("memory: note + big", "").unwrap();
assert!(oid.is_some());
let log = repo.log(10).unwrap();
assert_eq!(log.len(), 2);
}
#[test]
fn log_returns_newest_first() {
let td = TempDir::new().unwrap();
let repo = repo_in(&td);
std::fs::write(td.path().join("a.md"), "a\n").unwrap();
repo.commit_all("a", "").unwrap();
std::fs::write(td.path().join("b.md"), "b\n").unwrap();
repo.commit_all("b", "").unwrap();
let log = repo.log(10).unwrap();
assert_eq!(log.len(), 3);
assert_eq!(log[0].subject, "b");
assert_eq!(log[1].subject, "a");
assert_eq!(log[2].subject, "workspace init");
}
#[test]
fn diff_since_includes_changes() {
let td = TempDir::new().unwrap();
let repo = repo_in(&td);
std::fs::write(td.path().join("MEMORY.md"), "first\n").unwrap();
let first = repo.commit_all("first", "").unwrap().unwrap();
std::fs::write(td.path().join("MEMORY.md"), "second\n").unwrap();
repo.commit_all("second", "").unwrap();
let diff = repo.diff_since(Some(first)).unwrap();
assert!(
diff.contains("+second"),
"diff should show additions: {diff}"
);
assert!(
diff.contains("-first"),
"diff should show deletions: {diff}"
);
}
}