nexo-core 0.1.1

Agent runtime: event bus, sessions, plugin trait, heartbeat, A2A delegation.
Documentation
//! Phase 10.9 — local git repo around the agent workspace.
//!
//! Provides forensics (`git log`), rollback (`git revert` on disk), and
//! blame without any remote. The caller commits at natural boundaries:
//! dreaming sweeps, explicit `forge_memory_checkpoint` tool invocations.
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";
/// Files bigger than this are excluded from commits (logged, not fatal).
pub const MAX_COMMIT_FILE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, Serialize)]
pub struct CommitSummary {
    /// Full 40-char hex oid.
    pub oid: String,
    /// 7-char short form for display.
    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,
    /// libgit2 `Repository` is not `Sync`. Serialize access with a mutex so
    /// this struct is safe behind `Arc<..>` across threads.
    inner: Mutex<Repository>,
}
impl MemoryGitRepo {
    /// Open an existing `.git` at `root`, or init a fresh repo plus
    /// `.gitignore` / `.gitattributes` + an initial commit.
    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
    }
    /// Stage every non-ignored change (skipping blobs > `MAX_COMMIT_FILE_BYTES`)
    /// and commit. Returns `Ok(None)` if the worktree was clean.
    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()?;
        // First pass — add all tracked + untracked (respects .gitignore).
        index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
        index.write()?;
        // Skip oversize files. Iterate current entries, drop any whose blob
        // exceeds the limit. Log which ones.
        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)?;
        // Detect clean tree (matches HEAD).
        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))
    }
    /// Last `limit` commits reachable from HEAD, newest first.
    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()?;
        // Topological order from HEAD — children (newer) come before parents.
        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)
    }
    /// Unified patch text from `from_oid`..HEAD. When `None`, compares
    /// HEAD~1..HEAD. Empty string when the repo has only one commit.
    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 => {
                // HEAD~1 — if HEAD exists but has no target (the
                // symbolic-ref-without-oid corner case), there's
                // nothing to diff against.
                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,
        &[],
    )?;
    // Ensure HEAD points at a main branch for predictability.
    if repo
        .head()
        .map(|r| r.shorthand().is_none())
        .unwrap_or(false)
    {
        // No-op; some libgit2 versions accept the HEAD pointed at refs/heads/master by default.
    }
    // Work around detached-HEAD on some libgit2 versions when the initial commit
    // is made before any branch exists: attach the commit to `refs/heads/main`.
    let _ = repo.reference("refs/heads/main", oid, true, "bootstrap");
    let _ = repo.set_head("refs/heads/main");
    let _ = ObjectType::Commit; // quiet unused-import warning when refactoring
    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();
        // Commit should succeed (MEMORY.md changed), big.bin excluded.
        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}"
        );
    }
}