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>,
guard: Option<nexo_memory::SecretGuard>,
mutation_hook: Option<std::sync::Arc<dyn nexo_driver_types::MemoryMutationHook>>,
agent_id: String,
tenant: String,
}
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),
guard: None,
mutation_hook: None,
agent_id: String::new(),
tenant: "default".into(),
})
}
pub fn with_guard(mut self, guard: nexo_memory::SecretGuard) -> Self {
self.guard = Some(guard);
self
}
pub fn with_mutation_hook(
mut self,
hook: std::sync::Arc<dyn nexo_driver_types::MemoryMutationHook>,
agent_id: impl Into<String>,
tenant: impl Into<String>,
) -> Self {
self.mutation_hook = Some(hook);
self.agent_id = agent_id.into();
self.tenant = tenant.into();
self
}
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()?;
}
if let Some(ref guard) = self.guard {
if guard.is_enabled() {
let mut blocked: Vec<String> = Vec::new();
for entry in index.iter() {
let rel = match std::str::from_utf8(&entry.path) {
Ok(p) => p,
Err(_) => continue,
};
let abs_path = self.root.join(rel);
let content = match std::fs::read_to_string(&abs_path) {
Ok(c) => c,
Err(_) => continue, };
if guard.has_secrets(&content) {
match guard.on_secret() {
nexo_memory::OnSecret::Warn => {
let matches = guard.scan_for_display(&content);
tracing::warn!(
target = "memory.secret.warned",
rule_ids = ?matches.iter().map(|m| m.rule_id).collect::<Vec<_>>(),
path = %rel,
workspace = %self.root.display(),
"workspace_git: secrets found in staged file (warn policy, proceeding)"
);
}
_ => {
let matches = guard.scan_for_display(&content);
let labels: Vec<&str> = matches.iter().map(|m| m.label).collect();
tracing::warn!(
target = "memory.secret.blocked",
rule_ids = ?matches.iter().map(|m| m.rule_id).collect::<Vec<_>>(),
path = %rel,
workspace = %self.root.display(),
"workspace_git: commit blocked by secret scanner"
);
blocked.push(format!("{} ({})", rel, labels.join(", ")));
}
}
}
}
if !blocked.is_empty() {
anyhow::bail!(
"secret scan blocked git commit — {} file(s): {}",
blocked.len(),
blocked.join("; ")
);
}
}
}
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)?;
if let (Some(hook), false) = (&self.mutation_hook, self.agent_id.is_empty()) {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let hook = hook.clone();
let agent_id = self.agent_id.clone();
let tenant = self.tenant.clone();
let oid_str = oid.to_string();
handle.spawn(async move {
hook.on_mutation(
&agent_id,
&tenant,
nexo_driver_types::MemoryMutationScope::Git,
nexo_driver_types::MemoryMutationOp::Update,
&oid_str,
)
.await;
});
}
}
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()),
}
}
pub struct MemoryGitCheckpointer {
repo: std::sync::Arc<MemoryGitRepo>,
}
impl MemoryGitCheckpointer {
pub fn new(repo: std::sync::Arc<MemoryGitRepo>) -> Self {
Self { repo }
}
}
#[async_trait::async_trait]
impl nexo_driver_types::MemoryCheckpointer for MemoryGitCheckpointer {
async fn checkpoint(&self, subject: String, body: String) -> Result<(), String> {
let repo = std::sync::Arc::clone(&self.repo);
tokio::task::spawn_blocking(move || {
repo.commit_all(&subject, &body)
.map(|_oid| ())
.map_err(|e| e.to_string())
})
.await
.map_err(|e| format!("spawn_blocking join: {e}"))?
}
}
#[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");
}
#[tokio::test]
async fn commit_all_fires_mutation_hook_on_success() {
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct Recorder {
events: Mutex<Vec<(String, String, String)>>,
}
#[async_trait]
impl nexo_driver_types::MemoryMutationHook for Recorder {
async fn on_mutation(
&self,
agent_id: &str,
tenant: &str,
_scope: nexo_driver_types::MemoryMutationScope,
_op: nexo_driver_types::MemoryMutationOp,
key: &str,
) {
self.events.lock().unwrap().push((
agent_id.to_string(),
tenant.to_string(),
key.to_string(),
));
}
}
let td = TempDir::new().unwrap();
let rec = Arc::new(Recorder::default());
let hook: Arc<dyn nexo_driver_types::MemoryMutationHook> = rec.clone();
let repo = MemoryGitRepo::open_or_init(td.path(), "kate", "kate@test")
.unwrap()
.with_mutation_hook(hook, "ana", "acme");
std::fs::write(td.path().join("MEMORY.md"), "# hello\n").unwrap();
let oid = repo.commit_all("memory: note", "").unwrap();
assert!(oid.is_some());
for _ in 0..20 {
if !rec.events.lock().unwrap().is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let evs = rec.events.lock().unwrap();
assert_eq!(evs.len(), 1, "exactly one Git mutation event must fire");
assert_eq!(evs[0].0, "ana");
assert_eq!(evs[0].1, "acme");
assert_eq!(evs[0].2, oid.unwrap().to_string());
}
#[tokio::test]
async fn commit_all_does_not_fire_hook_on_clean_tree() {
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
struct Counter(Mutex<u32>);
#[async_trait]
impl nexo_driver_types::MemoryMutationHook for Counter {
async fn on_mutation(
&self,
_: &str,
_: &str,
_: nexo_driver_types::MemoryMutationScope,
_: nexo_driver_types::MemoryMutationOp,
_: &str,
) {
*self.0.lock().unwrap() += 1;
}
}
let td = TempDir::new().unwrap();
let c = Arc::new(Counter(Mutex::new(0)));
let hook: Arc<dyn nexo_driver_types::MemoryMutationHook> = c.clone();
let repo = MemoryGitRepo::open_or_init(td.path(), "kate", "kate@test")
.unwrap()
.with_mutation_hook(hook, "ana", "default");
let oid = repo.commit_all("noop", "").unwrap();
assert!(oid.is_none());
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert_eq!(*c.0.lock().unwrap(), 0);
}
#[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}"
);
}
#[tokio::test]
async fn checkpointer_async_calls_commit_all() {
use nexo_driver_types::MemoryCheckpointer;
let td = TempDir::new().unwrap();
let repo = std::sync::Arc::new(repo_in(&td));
let ckpt = MemoryGitCheckpointer::new(repo.clone());
std::fs::write(td.path().join("MEMORY.md"), "hello\n").unwrap();
ckpt.checkpoint("auto_dream: 1 file(s) consolidated".into(), "body".into())
.await
.unwrap();
let log = repo.log(10).unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[0].subject, "auto_dream: 1 file(s) consolidated");
assert_eq!(log[0].body, "body");
}
#[tokio::test]
async fn checkpointer_returns_ok_on_clean_worktree() {
use nexo_driver_types::MemoryCheckpointer;
let td = TempDir::new().unwrap();
let repo = std::sync::Arc::new(repo_in(&td));
let ckpt = MemoryGitCheckpointer::new(repo.clone());
let log_before = repo.log(10).unwrap().len();
ckpt.checkpoint("noop".into(), "".into()).await.unwrap();
let log_after = repo.log(10).unwrap().len();
assert_eq!(
log_before, log_after,
"clean worktree should not add a commit"
);
}
}