use anyhow::{bail, Result};
use gix::bstr::BStr;
use gix::hash::ObjectId;
use gix::objs::tree::EntryKind;
use gix::refs::transaction::PreviousValue;
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const GITIGNORE: &str = r#"# Oxios
*.tmp
*.lock
.env
api-keys.json
"#;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CommitInfo {
pub hash: String,
pub short_hash: String,
pub message: String,
pub timestamp: String,
pub author: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LogEntry {
pub hash: String,
pub short_hash: String,
pub message: String,
pub timestamp: String,
pub author: String,
}
#[derive(Default, Debug, Clone)]
pub struct CommitContext {
pub agent_id: Option<uuid::Uuid>,
pub seed_id: Option<uuid::Uuid>,
pub tag: Option<&'static str>,
}
impl CommitContext {
pub fn system() -> Self {
Self::default()
}
pub fn agent(agent_id: uuid::Uuid, seed_id: Option<uuid::Uuid>) -> Self {
Self {
agent_id: Some(agent_id),
seed_id,
tag: None,
}
}
pub fn tagged(tag: &'static str) -> Self {
Self {
tag: Some(tag),
..Default::default()
}
}
fn author_name(&self) -> String {
match &self.agent_id {
Some(id) => {
let hex = id.to_string();
format!("agent-{}", &hex[..8])
}
None => "oxios".to_string(),
}
}
fn message_prefix(&self) -> String {
let mut parts = Vec::new();
if let Some(tag) = self.tag {
parts.push(format!("[{tag}]"));
}
if let Some(ref seed) = self.seed_id {
let hex = seed.to_string();
parts.push(format!("[seed-{}]", &hex[..8]));
}
if parts.is_empty() {
String::new()
} else {
format!("{} ", parts.join(" "))
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub enum DiffKind {
Added,
Deleted,
Modified,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FileDiff {
pub path: String,
pub old_hash: Option<String>,
pub new_hash: Option<String>,
pub kind: DiffKind,
pub patch: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DiffStats {
pub files_changed: usize,
pub additions: usize,
pub deletions: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CommitDiff {
pub from_hash: String,
pub to_hash: String,
pub files: Vec<FileDiff>,
pub stats: DiffStats,
}
const DEFAULT_EMAIL: &str = "oxios@oxios";
struct Signature {
name: String,
email: String,
time: String,
}
impl Signature {
fn new(name: impl Into<String>, email: impl Into<String>) -> Self {
Self {
name: name.into(),
email: email.into(),
time: gix::date::Time::now_local_or_utc().to_string(),
}
}
fn as_ref(&self) -> gix::actor::SignatureRef<'_> {
gix::actor::SignatureRef {
name: self.name.as_str().into(),
email: self.email.as_str().into(),
time: &self.time,
}
}
}
pub struct GitLayer {
repo: Arc<Mutex<gix::Repository>>,
root: PathBuf,
#[allow(dead_code)]
committer_email: String,
enabled: bool,
}
impl GitLayer {
pub fn new(root: PathBuf, enabled: bool) -> Result<Self> {
let repo = if root.join(".git").exists() {
gix::open(&root)?
} else {
std::fs::create_dir_all(&root)?;
gix::init(&root)?
};
let gitignore = root.join(".gitignore");
if !gitignore.exists() {
std::fs::write(&gitignore, GITIGNORE)?;
}
let repo_ref = Arc::new(Mutex::new(repo));
if Self::head_id_detached(&repo_ref).is_none() {
Self::create_initial_commit(&repo_ref, &root)?;
}
Ok(Self {
repo: repo_ref,
root,
committer_email: DEFAULT_EMAIL.into(),
enabled,
})
}
fn head_id_detached(repo_arc: &Arc<Mutex<gix::Repository>>) -> Option<ObjectId> {
let repo = repo_arc.lock();
repo.head_id().ok().map(|id| id.detach())
}
fn head_id_detached_raw(repo: &gix::Repository) -> Option<ObjectId> {
repo.head_id().ok().map(|id| id.detach())
}
fn create_initial_commit(repo: &Arc<Mutex<gix::Repository>>, root: &Path) -> Result<()> {
let repo_lock = repo.lock();
let gitignore = root.join(".gitignore");
let content = std::fs::read(&gitignore)?;
let blob_id = repo_lock.write_blob(&content)?;
let empty_tree = ObjectId::empty_tree(repo_lock.object_hash());
let mut editor = repo_lock.edit_tree(empty_tree)?;
editor.upsert(".gitignore", EntryKind::Blob, blob_id)?;
let tree_id = editor.write()?;
let sig = Signature::new("oxios", DEFAULT_EMAIL);
repo_lock.commit_as(
sig.as_ref(),
sig.as_ref(),
"refs/heads/main",
"Initial commit",
tree_id.detach(),
Vec::<ObjectId>::new(),
)?;
Ok(())
}
fn head_tree_oid(repo: &gix::Repository) -> Result<ObjectId> {
match Self::head_id_detached_raw(repo) {
Some(id) => {
let commit = repo.find_commit(id)?;
let decoded = commit.decode()?;
Ok(decoded.tree())
}
None => Ok(ObjectId::empty_tree(repo.object_hash())),
}
}
fn commit_tree_id(repo: &gix::Repository, commit_id: ObjectId) -> Result<ObjectId> {
let commit = repo.find_commit(commit_id)?;
let decoded = commit.decode()?;
Ok(decoded.tree())
}
fn find_blob_in_tree(
repo: &gix::Repository,
tree_id: ObjectId,
rel_path: &str,
) -> Result<ObjectId> {
let components: Vec<&str> = Path::new(rel_path)
.iter()
.filter_map(|c| c.to_str())
.collect();
anyhow::ensure!(!components.is_empty(), "empty path: {rel_path}");
let mut current_tree_id = tree_id;
for (i, component) in components.iter().enumerate() {
let tree = repo.find_tree(current_tree_id)?;
let decoded = tree.decode()?;
let comp_bytes = BStr::new(component);
let entry = decoded
.entries
.iter()
.find(|e| e.filename == comp_bytes)
.ok_or_else(|| {
anyhow::anyhow!("path component '{component}' not found in '{rel_path}'")
})?;
if i == components.len() - 1 {
return Ok(entry.oid.to_owned());
}
current_tree_id = entry.oid.to_owned();
}
unreachable!()
}
pub fn commit_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
self.commit_file_with(rel_path, message, CommitContext::default())
}
pub fn commit_file_with(
&self,
rel_path: &str,
message: &str,
ctx: CommitContext,
) -> Result<CommitInfo> {
if !self.enabled {
return self.noop_commit(&ctx, message);
}
let repo = self.repo.lock();
let abs = self.root.join(rel_path);
if !abs.exists() {
bail!("File not found: {rel_path}");
}
let content = std::fs::read(&abs)?;
let blob_id = repo.write_blob(&content)?;
let head_tree = Self::head_tree_oid(&repo)?;
let mut editor = repo.edit_tree(head_tree)?;
editor.upsert(rel_path, EntryKind::Blob, blob_id)?;
let tree_id = editor.write()?;
let parent = repo.head_id().ok().map(|id| id.detach());
let author_name = ctx.author_name();
let full_message = format!("{}{}", ctx.message_prefix(), message);
let sig = Signature::new(&author_name, &self.committer_email);
let commit_id = repo.commit_as(
sig.as_ref(),
sig.as_ref(),
"refs/heads/main",
&full_message,
tree_id.detach(),
parent.into_iter().collect::<Vec<_>>(),
)?;
Ok(self.make_info(&commit_id, &full_message, &author_name))
}
pub fn commit_files(&self, rel_paths: &[&str], message: &str) -> Result<CommitInfo> {
self.commit_files_with(rel_paths, message, CommitContext::default())
}
pub fn commit_files_with(
&self,
rel_paths: &[&str],
message: &str,
ctx: CommitContext,
) -> Result<CommitInfo> {
if !self.enabled {
return self.noop_commit(&ctx, message);
}
let repo = self.repo.lock();
let head_tree = Self::head_tree_oid(&repo)?;
let mut editor = repo.edit_tree(head_tree)?;
for path in rel_paths {
let abs = self.root.join(path);
if abs.exists() {
let content = std::fs::read(&abs)?;
let blob_id = repo.write_blob(&content)?;
editor.upsert(*path, EntryKind::Blob, blob_id)?;
}
}
let tree_id = editor.write()?;
let parent = repo.head_id().ok().map(|id| id.detach());
let author_name = ctx.author_name();
let full_message = format!("{}{}", ctx.message_prefix(), message);
let sig = Signature::new(&author_name, &self.committer_email);
let commit_id = repo.commit_as(
sig.as_ref(),
sig.as_ref(),
"refs/heads/main",
&full_message,
tree_id.detach(),
parent.into_iter().collect::<Vec<_>>(),
)?;
Ok(self.make_info(&commit_id, &full_message, &author_name))
}
pub fn remove_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
if !self.enabled {
return self.noop_commit(&CommitContext::default(), message);
}
let repo = self.repo.lock();
let head_tree = Self::head_tree_oid(&repo)?;
let mut editor = repo.edit_tree(head_tree)?;
editor.remove(rel_path)?;
let tree_id = editor.write()?;
let parent = repo.head_id().ok().map(|id| id.detach());
let sig = Signature::new("oxios", &self.committer_email);
let commit_id = repo.commit_as(
sig.as_ref(),
sig.as_ref(),
"refs/heads/main",
message,
tree_id.detach(),
parent.into_iter().collect::<Vec<_>>(),
)?;
Ok(self.make_info(&commit_id, message, "oxios"))
}
pub fn log_action(
&self,
agent: &str,
action: &str,
target: &str,
allowed: bool,
detail: Option<&str>,
) -> Result<()> {
let now = chrono::Utc::now();
let filename = format!("audit/{}.audit", now.format("%Y-%m"));
let entry = format!(
"{} | {} | {} | {} | {} | {}\n",
now.to_rfc3339(),
agent,
action,
target,
if allowed { "ALLOW" } else { "DENY" },
detail.unwrap_or("-")
);
let dir = self.root.join("audit");
std::fs::create_dir_all(&dir)?;
use std::io::Write;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.root.join(&filename))?
.write_all(entry.as_bytes())?;
self.commit_file(&filename, &format!("audit: {agent} {action} {target}"))?;
Ok(())
}
pub fn tag(&self, name: &str, message: &str) -> Result<()> {
if !self.enabled {
return Ok(());
}
let repo = self.repo.lock();
let head_id = repo
.head_id()
.ok()
.map(|id| id.detach())
.ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
let sig = Signature::new("oxios", &self.committer_email);
repo.tag(
name,
head_id,
gix::objs::Kind::Commit,
Some(sig.as_ref()),
message,
PreviousValue::MustNotExist,
)?;
Ok(())
}
pub fn list_tags(&self) -> Result<Vec<String>> {
let repo = self.repo.lock();
let mut tags = Vec::new();
for reference in repo.references()?.all()? {
let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
if reference
.name()
.category()
.is_some_and(|c| matches!(c, gix::refs::Category::Tag))
{
tags.push(reference.name().shorten().to_string());
}
}
Ok(tags)
}
pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
let repo = self.repo.lock();
let head_id = repo.head_id()?.detach();
let mut entries = Vec::new();
let mut current_id: Option<ObjectId> = Some(head_id);
while let Some(id) = current_id {
if entries.len() >= max_count {
break;
}
let commit = repo.find_commit(id)?;
let decoded = commit.decode()?;
let msg_ref = decoded.message();
let msg = if let Some(body) = msg_ref.body {
format!("{}\n\n{}", msg_ref.title, body)
} else {
msg_ref.title.to_string()
};
let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
let author = decoded
.author()
.map(|a| a.name.to_string())
.unwrap_or_default();
let hex = id.to_hex().to_string();
entries.push(LogEntry {
hash: hex.clone(),
short_hash: hex[..7].into(),
message: msg,
timestamp,
author,
});
current_id = decoded.parents().next();
}
Ok(entries)
}
pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
if partial.len() < 4 {
bail!("Partial hash too short (minimum 4 characters)");
}
if partial.len() >= 40 {
return Ok(ObjectId::from_hex(partial.as_bytes())?);
}
let repo = self.repo.lock();
let id = repo.rev_parse_single(BStr::new(partial))?;
Ok(id.detach())
}
fn resolve_hash_inner(&self, repo: &gix::Repository, partial: &str) -> Result<ObjectId> {
if partial.len() >= 40 {
return Ok(ObjectId::from_hex(partial.as_bytes())?);
}
if partial.len() < 4 {
bail!("Hash too short (minimum 4 characters)");
}
let id = repo.rev_parse_single(BStr::new(partial))?;
Ok(id.detach())
}
pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
let commit_id = self.resolve_partial_hash(hash)?;
let repo = self.repo.lock();
let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
let blob = repo.find_blob(blob_id)?;
std::fs::write(self.root.join(rel_path), &blob.data)?;
Ok(())
}
pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<CommitDiff> {
let repo = self.repo.lock();
let from_id = self.resolve_hash_inner(&repo, from_hash)?;
let to_id = self.resolve_hash_inner(&repo, to_hash)?;
let from_tree_id = Self::commit_tree_id(&repo, from_id)?;
let to_tree_id = Self::commit_tree_id(&repo, to_id)?;
let mut files = Vec::new();
Self::diff_trees(&repo, from_tree_id, to_tree_id, "", &mut files)?;
for fd in &mut files {
let old_data = fd
.old_hash
.as_ref()
.and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
.and_then(|id| repo.find_blob(id).ok())
.map(|b| b.data.to_vec());
let new_data = fd
.new_hash
.as_ref()
.and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
.and_then(|id| repo.find_blob(id).ok())
.map(|b| b.data.to_vec());
match (&old_data, &new_data) {
(Some(old), Some(new)) => {
fd.patch = compute_unified_diff(old, new, &fd.path);
}
(None, Some(new)) => {
fd.patch = compute_unified_diff(&[], new, &fd.path);
}
_ => {}
}
}
let stats = DiffStats {
files_changed: files.len(),
additions: files
.iter()
.filter_map(|f| f.patch.as_ref())
.map(|p| {
p.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count()
})
.sum(),
deletions: files
.iter()
.filter_map(|f| f.patch.as_ref())
.map(|p| {
p.lines()
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
.count()
})
.sum(),
};
Ok(CommitDiff {
from_hash: from_id.to_hex().to_string(),
to_hash: to_id.to_hex().to_string(),
files,
stats,
})
}
pub fn file_at_commit(&self, rel_path: &str, hash: &str) -> Result<Vec<u8>> {
let repo = self.repo.lock();
let commit_id = self.resolve_hash_inner(&repo, hash)?;
let tree_id = Self::commit_tree_id(&repo, commit_id)?;
let blob_id = Self::find_blob_in_tree(&repo, tree_id, rel_path)?;
let blob = repo.find_blob(blob_id)?;
Ok(blob.data.to_vec())
}
fn diff_trees(
repo: &gix::Repository,
old_tree: ObjectId,
new_tree: ObjectId,
prefix: &str,
changes: &mut Vec<FileDiff>,
) -> Result<()> {
let old_tree_obj = repo.find_tree(old_tree)?;
let old_decoded = old_tree_obj.decode()?;
let new_tree_obj = repo.find_tree(new_tree)?;
let new_decoded = new_tree_obj.decode()?;
let old_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
old_decoded
.entries
.iter()
.map(|e| (e.filename, e))
.collect();
let new_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
new_decoded
.entries
.iter()
.map(|e| (e.filename, e))
.collect();
for (name, new_entry) in &new_entries {
let path = format!("{prefix}{name}");
match old_entries.get(name) {
None => {
if new_entry.mode.is_tree() {
let empty = ObjectId::empty_tree(repo.object_hash());
Self::diff_trees(
repo,
empty,
new_entry.oid.to_owned(),
&format!("{path}/"),
changes,
)?;
} else {
changes.push(FileDiff {
path,
old_hash: None,
new_hash: Some(new_entry.oid.to_hex().to_string()),
kind: DiffKind::Added,
patch: None,
});
}
}
Some(old_entry) => {
if old_entry.oid == new_entry.oid {
continue;
}
if new_entry.mode.is_tree() && old_entry.mode.is_tree() {
Self::diff_trees(
repo,
old_entry.oid.to_owned(),
new_entry.oid.to_owned(),
&format!("{path}/"),
changes,
)?;
} else {
changes.push(FileDiff {
path,
old_hash: Some(old_entry.oid.to_hex().to_string()),
new_hash: Some(new_entry.oid.to_hex().to_string()),
kind: DiffKind::Modified,
patch: None,
});
}
}
}
}
for (name, old_entry) in &old_entries {
if new_entries.contains_key(name) {
continue;
}
let path = format!("{prefix}{name}");
changes.push(FileDiff {
path,
old_hash: Some(old_entry.oid.to_hex().to_string()),
new_hash: None,
kind: DiffKind::Deleted,
patch: None,
});
}
Ok(())
}
pub fn verify(&self) -> Result<bool> {
let repo = self.repo.lock();
let refs = repo.references()?;
for reference in refs.all()? {
let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
}
if repo.head_id().is_err() {
tracing::debug!("verify: no HEAD yet (empty repository)");
}
Ok(true)
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn root(&self) -> &Path {
&self.root
}
fn noop_commit(&self, ctx: &CommitContext, message: &str) -> Result<CommitInfo> {
Ok(CommitInfo {
hash: "(disabled)".into(),
short_hash: "(dis)".into(),
message: message.into(),
timestamp: chrono::Utc::now().to_rfc3339(),
author: ctx.author_name(),
})
}
fn make_info(&self, id: &gix::Id, message: &str, author: &str) -> CommitInfo {
let hex = id.to_hex().to_string();
CommitInfo {
short_hash: hex[..7].into(),
hash: hex,
message: message.into(),
timestamp: chrono::Utc::now().to_rfc3339(),
author: author.into(),
}
}
}
fn compute_unified_diff(old: &[u8], new: &[u8], path: &str) -> Option<String> {
let old_str = std::str::from_utf8(old).ok()?;
let new_str = std::str::from_utf8(new).ok()?;
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::from_lines(old_str, new_str);
let mut output = format!("--- a/{path}\n+++ b/{path}\n");
for change in diff.iter_all_changes() {
let prefix = match change.tag() {
ChangeTag::Delete => '-',
ChangeTag::Insert => '+',
ChangeTag::Equal => ' ',
};
output.push_str(&format!("{prefix}{change}"));
}
Some(output)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> (TempDir, GitLayer) {
let dir = tempfile::tempdir().unwrap();
let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
(dir, layer)
}
#[test]
fn test_init_creates_repo() {
let (dir, _) = setup();
assert!(dir.path().join(".git").exists());
}
#[test]
fn test_commit_file() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
let info = layer.commit_file("test.json", "test commit").unwrap();
assert!(!info.hash.is_empty());
assert_eq!(info.short_hash.len(), 7);
assert_eq!(info.message, "test commit");
assert!(info.hash.starts_with(&info.short_hash));
}
#[test]
fn test_log_query() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("a.json"), b"1").unwrap();
layer.commit_file("a.json", "first").unwrap();
std::fs::write(dir.path().join("a.json"), b"2").unwrap();
layer.commit_file("a.json", "second").unwrap();
let log = layer.log(10).unwrap();
assert!(log.len() >= 2);
assert!(log[0].message.contains("second"));
}
#[test]
fn test_tag_create_list() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("x.json"), b"1").unwrap();
layer.commit_file("x.json", "tag test").unwrap();
layer.tag("v1", "first tag").unwrap();
let tags = layer.list_tags().unwrap();
assert!(tags.iter().any(|t| t == "v1"));
}
#[test]
fn test_disabled_noop() {
let dir = tempfile::tempdir().unwrap();
let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
std::fs::write(dir.path().join("test.json"), b"1").unwrap();
let info = layer.commit_file("test.json", "noop").unwrap();
assert_eq!(info.hash, "(disabled)");
assert_eq!(info.short_hash, "(dis)");
}
#[test]
fn test_log_action() {
let (dir, layer) = setup();
layer
.log_action("agent-A", "read", "file.txt", true, None)
.unwrap();
let audit_file = dir
.path()
.join("audit")
.join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
assert!(audit_file.exists());
let content = std::fs::read_to_string(&audit_file).unwrap();
assert!(content.contains("agent-A"));
assert!(content.contains("ALLOW"));
}
#[test]
fn test_verify() {
let (_, layer) = setup();
assert!(layer.verify().unwrap());
}
#[test]
fn test_remove_file() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
layer.commit_file("todelete.json", "add file").unwrap();
std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
let info = layer.remove_file("todelete.json", "remove file").unwrap();
assert!(!info.hash.is_empty());
assert!(info.hash != "(disabled)");
}
#[test]
fn test_commit_files_batch() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("a.json"), b"1").unwrap();
std::fs::write(dir.path().join("b.json"), b"2").unwrap();
let info = layer
.commit_files(&["a.json", "b.json"], "batch commit")
.unwrap();
assert!(!info.hash.is_empty());
assert_eq!(info.message, "batch commit");
}
#[test]
fn test_restore_file() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
let first = layer.commit_file("state.json", "v1").unwrap();
std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
layer.commit_file("state.json", "v2").unwrap();
layer.restore_file("state.json", &first.short_hash).unwrap();
let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
assert_eq!(content, "v1");
}
#[test]
fn test_gitignore_created() {
let (dir, _) = setup();
assert!(dir.path().join(".gitignore").exists());
let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(content.contains("Oxios"));
}
#[test]
fn test_signature_timestamps_are_fresh() {
let sig1 = Signature::new("a", "a@a");
assert!(!sig1.time.is_empty());
std::thread::sleep(std::time::Duration::from_millis(1100));
let sig3 = Signature::new("c", "c@c");
assert_ne!(
sig1.time, sig3.time,
"Signature created 1s later must have a different timestamp"
);
}
#[test]
fn test_commit_file_with_agent_context() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("agent_work.json"), b"{\"result\":42}").unwrap();
let agent_id = uuid::Uuid::new_v4();
let ctx = CommitContext::agent(agent_id, None);
layer
.commit_file_with("agent_work.json", "agent did work", ctx)
.unwrap();
let log = layer.log(10).unwrap();
let agent_commit = log
.iter()
.find(|e| e.message.contains("agent did work"))
.expect("should find agent commit");
let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
assert_eq!(agent_commit.author, expected_author);
}
#[test]
fn test_commit_file_with_tag() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("audit.json"), b"{\"event\":\"test\"}").unwrap();
let ctx = CommitContext::tagged("audit");
let info = layer
.commit_file_with("audit.json", "flush audit trail", ctx)
.unwrap();
assert!(info.message.contains("[audit]"));
assert!(info.message.contains("flush audit trail"));
}
#[test]
fn test_default_context_is_oxios() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("sys.json"), b"1").unwrap();
let info = layer
.commit_file_with("sys.json", "system commit", CommitContext::default())
.unwrap();
assert_eq!(info.author, "oxios");
}
#[test]
fn test_commit_context_author_name() {
assert_eq!(CommitContext::default().author_name(), "oxios");
assert_eq!(CommitContext::system().author_name(), "oxios");
let id = uuid::Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
assert_eq!(
CommitContext::agent(id, None).author_name(),
"agent-aaaaaaaa"
);
assert_eq!(CommitContext::tagged("memory").author_name(), "oxios");
}
#[test]
fn test_commit_context_message_prefix() {
assert!(CommitContext::default().message_prefix().is_empty());
assert_eq!(CommitContext::tagged("audit").message_prefix(), "[audit] ");
let seed_id = uuid::Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
let ctx = CommitContext {
tag: Some("memory"),
seed_id: Some(seed_id),
..Default::default()
};
assert_eq!(ctx.message_prefix(), "[memory] [seed-11111111] ");
}
#[test]
fn test_commit_files_with_context() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("a.json"), b"1").unwrap();
std::fs::write(dir.path().join("b.json"), b"2").unwrap();
let agent_id = uuid::Uuid::new_v4();
let ctx = CommitContext::agent(agent_id, None);
let info = layer
.commit_files_with(&["a.json", "b.json"], "batch agent work", ctx)
.unwrap();
let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
assert_eq!(info.author, expected_author);
}
#[test]
fn test_backward_compat_commit_file_is_oxios() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("compat.json"), b"1").unwrap();
let info = layer.commit_file("compat.json", "compat check").unwrap();
assert_eq!(info.author, "oxios");
}
#[test]
fn test_restore_nested_file() {
let (dir, layer) = setup();
layer
.log_action("agent-X", "write", "secret.txt", true, None)
.unwrap();
let audit_rel = format!("audit/{}.audit", chrono::Utc::now().format("%Y-%m"));
let audit_path = dir.path().join(&audit_rel);
assert!(audit_path.exists(), "audit file should exist");
let original = std::fs::read_to_string(&audit_path).unwrap();
std::fs::write(&audit_path, "CORRUPTED").unwrap();
layer.commit_file(&audit_rel, "corrupt").unwrap();
let log = layer.log(10).unwrap();
let audit_commit = log
.iter()
.find(|e| e.message.contains("audit: agent-X"))
.expect("should find audit commit");
layer
.restore_file(&audit_rel, &audit_commit.short_hash)
.unwrap();
let restored = std::fs::read_to_string(&audit_path).unwrap();
assert!(restored.contains("agent-X"));
assert!(!restored.contains("CORRUPTED"));
}
#[test]
fn test_list_tags_excludes_non_tags() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("t.json"), b"1").unwrap();
layer.commit_file("t.json", "for tag").unwrap();
layer.tag("release-v1", "first release").unwrap();
let tags = layer.list_tags().unwrap();
assert!(tags.iter().any(|t| t == "release-v1"));
assert!(tags.iter().all(|t| t != "main" && t != "HEAD"));
}
#[test]
fn test_diff_added_file() {
let (dir, layer) = setup();
let first = layer.log(1).unwrap()[0].hash.clone();
std::fs::write(dir.path().join("new.txt"), b"hello\n").unwrap();
let info = layer.commit_file("new.txt", "add file").unwrap();
let diff = layer.diff_commits(&first, &info.hash).unwrap();
assert!(diff
.files
.iter()
.any(|f| f.path == "new.txt" && f.kind == DiffKind::Added));
}
#[test]
fn test_diff_modified_file() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("data.txt"), b"v1\n").unwrap();
let first = layer.commit_file("data.txt", "v1").unwrap();
std::fs::write(dir.path().join("data.txt"), b"v2\n").unwrap();
let second = layer.commit_file("data.txt", "v2").unwrap();
let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
assert!(diff
.files
.iter()
.any(|f| f.path == "data.txt" && f.kind == DiffKind::Modified));
let patch = diff
.files
.iter()
.find(|f| f.path == "data.txt")
.unwrap()
.patch
.as_ref()
.expect("should have patch");
assert!(patch.contains("-v1"));
assert!(patch.contains("+v2"));
}
#[test]
fn test_diff_deleted_file() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("temp.txt"), b"bye\n").unwrap();
let first = layer.commit_file("temp.txt", "add temp").unwrap();
std::fs::remove_file(dir.path().join("temp.txt")).unwrap();
let second = layer.remove_file("temp.txt", "remove temp").unwrap();
let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
assert!(diff
.files
.iter()
.any(|f| f.path == "temp.txt" && f.kind == DiffKind::Deleted));
}
#[test]
fn test_file_at_commit() {
let (dir, layer) = setup();
std::fs::write(dir.path().join("state.json"), b"{\"v\":1}").unwrap();
let first = layer.commit_file("state.json", "v1").unwrap();
std::fs::write(dir.path().join("state.json"), b"{\"v\":2}").unwrap();
layer.commit_file("state.json", "v2").unwrap();
let content = layer
.file_at_commit("state.json", &first.short_hash)
.unwrap();
assert_eq!(content, b"{\"v\":1}");
}
}