1use anyhow::{bail, Result};
13use gix::bstr::BStr;
14use gix::hash::ObjectId;
15use gix::objs::tree::EntryKind;
16use gix::refs::transaction::PreviousValue;
17use parking_lot::Mutex;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21const GITIGNORE: &str = r#"# Oxios
22*.tmp
23*.lock
24.env
25api-keys.json
26"#;
27
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32pub struct CommitInfo {
33 pub hash: String,
35 pub short_hash: String,
37 pub message: String,
39 pub timestamp: String,
41 pub author: String,
43}
44
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct LogEntry {
48 pub hash: String,
50 pub short_hash: String,
52 pub message: String,
54 pub timestamp: String,
56 pub author: String,
58}
59
60#[derive(Default, Debug, Clone)]
65pub struct CommitContext {
66 pub agent_id: Option<uuid::Uuid>,
69 pub seed_id: Option<uuid::Uuid>,
71 pub tag: Option<&'static str>,
73}
74
75impl CommitContext {
76 pub fn system() -> Self {
78 Self::default()
79 }
80
81 pub fn agent(agent_id: uuid::Uuid, seed_id: Option<uuid::Uuid>) -> Self {
83 Self {
84 agent_id: Some(agent_id),
85 seed_id,
86 tag: None,
87 }
88 }
89
90 pub fn tagged(tag: &'static str) -> Self {
92 Self {
93 tag: Some(tag),
94 ..Default::default()
95 }
96 }
97
98 fn author_name(&self) -> String {
100 match &self.agent_id {
101 Some(id) => {
102 let hex = id.to_string();
103 format!("agent-{}", &hex[..8])
104 }
105 None => "oxios".to_string(),
106 }
107 }
108
109 fn message_prefix(&self) -> String {
111 let mut parts = Vec::new();
112 if let Some(tag) = self.tag {
113 parts.push(format!("[{tag}]"));
114 }
115 if let Some(ref seed) = self.seed_id {
116 let hex = seed.to_string();
117 parts.push(format!("[seed-{}]", &hex[..8]));
118 }
119 if parts.is_empty() {
120 String::new()
121 } else {
122 format!("{} ", parts.join(" "))
123 }
124 }
125}
126
127#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
131pub enum DiffKind {
132 Added,
134 Deleted,
136 Modified,
138}
139
140#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct FileDiff {
143 pub path: String,
145 pub old_hash: Option<String>,
147 pub new_hash: Option<String>,
149 pub kind: DiffKind,
151 pub patch: Option<String>,
153}
154
155#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
157pub struct DiffStats {
158 pub files_changed: usize,
160 pub additions: usize,
162 pub deletions: usize,
164}
165
166#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
168pub struct CommitDiff {
169 pub from_hash: String,
171 pub to_hash: String,
173 pub files: Vec<FileDiff>,
175 pub stats: DiffStats,
177}
178
179const DEFAULT_EMAIL: &str = "oxios@oxios";
183
184struct Signature {
190 name: String,
191 email: String,
192 time: String,
193}
194
195impl Signature {
196 fn new(name: impl Into<String>, email: impl Into<String>) -> Self {
198 Self {
199 name: name.into(),
200 email: email.into(),
201 time: gix::date::Time::now_local_or_utc().to_string(),
202 }
203 }
204
205 fn as_ref(&self) -> gix::actor::SignatureRef<'_> {
207 gix::actor::SignatureRef {
208 name: self.name.as_str().into(),
209 email: self.email.as_str().into(),
210 time: &self.time,
211 }
212 }
213}
214
215pub struct GitLayer {
222 repo: Arc<Mutex<gix::Repository>>,
223 root: PathBuf,
224 #[allow(dead_code)]
225 committer_email: String,
226 enabled: bool,
227}
228
229impl GitLayer {
230 pub fn new(root: PathBuf, enabled: bool) -> Result<Self> {
232 let repo = if root.join(".git").exists() {
233 gix::open(&root)?
234 } else {
235 std::fs::create_dir_all(&root)?;
236 gix::init(&root)?
237 };
238
239 let gitignore = root.join(".gitignore");
241 if !gitignore.exists() {
242 std::fs::write(&gitignore, GITIGNORE)?;
243 }
244
245 let repo_ref = Arc::new(Mutex::new(repo));
246
247 if Self::head_id_detached(&repo_ref).is_none() {
249 Self::create_initial_commit(&repo_ref, &root)?;
250 }
251
252 Ok(Self {
253 repo: repo_ref,
254 root,
255 committer_email: DEFAULT_EMAIL.into(),
256 enabled,
257 })
258 }
259
260 fn head_id_detached(repo_arc: &Arc<Mutex<gix::Repository>>) -> Option<ObjectId> {
263 let repo = repo_arc.lock();
264 repo.head_id().ok().map(|id| id.detach())
265 }
266
267 fn head_id_detached_raw(repo: &gix::Repository) -> Option<ObjectId> {
268 repo.head_id().ok().map(|id| id.detach())
269 }
270
271 fn create_initial_commit(repo: &Arc<Mutex<gix::Repository>>, root: &Path) -> Result<()> {
272 let repo_lock = repo.lock();
273 let gitignore = root.join(".gitignore");
274 let content = std::fs::read(&gitignore)?;
275 let blob_id = repo_lock.write_blob(&content)?;
276 let empty_tree = ObjectId::empty_tree(repo_lock.object_hash());
277 let mut editor = repo_lock.edit_tree(empty_tree)?;
278 editor.upsert(".gitignore", EntryKind::Blob, blob_id)?;
279 let tree_id = editor.write()?;
280 let sig = Signature::new("oxios", DEFAULT_EMAIL);
281 repo_lock.commit_as(
282 sig.as_ref(),
283 sig.as_ref(),
284 "refs/heads/main",
285 "Initial commit",
286 tree_id.detach(),
287 Vec::<ObjectId>::new(),
288 )?;
289 Ok(())
290 }
291
292 fn head_tree_oid(repo: &gix::Repository) -> Result<ObjectId> {
294 match Self::head_id_detached_raw(repo) {
295 Some(id) => {
296 let commit = repo.find_commit(id)?;
297 let decoded = commit.decode()?;
298 Ok(decoded.tree())
299 }
300 None => Ok(ObjectId::empty_tree(repo.object_hash())),
301 }
302 }
303
304 fn commit_tree_id(repo: &gix::Repository, commit_id: ObjectId) -> Result<ObjectId> {
306 let commit = repo.find_commit(commit_id)?;
307 let decoded = commit.decode()?;
308 Ok(decoded.tree())
309 }
310
311 fn find_blob_in_tree(
315 repo: &gix::Repository,
316 tree_id: ObjectId,
317 rel_path: &str,
318 ) -> Result<ObjectId> {
319 let components: Vec<&str> = Path::new(rel_path)
320 .iter()
321 .filter_map(|c| c.to_str())
322 .collect();
323 anyhow::ensure!(!components.is_empty(), "empty path: {rel_path}");
324
325 let mut current_tree_id = tree_id;
326
327 for (i, component) in components.iter().enumerate() {
328 let tree = repo.find_tree(current_tree_id)?;
329 let decoded = tree.decode()?;
330 let comp_bytes = BStr::new(component);
331 let entry = decoded
332 .entries
333 .iter()
334 .find(|e| e.filename == comp_bytes)
335 .ok_or_else(|| {
336 anyhow::anyhow!("path component '{component}' not found in '{rel_path}'")
337 })?;
338
339 if i == components.len() - 1 {
340 return Ok(entry.oid.to_owned());
341 }
342 current_tree_id = entry.oid.to_owned();
343 }
344
345 unreachable!()
346 }
347
348 pub fn commit_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
352 self.commit_file_with(rel_path, message, CommitContext::default())
353 }
354
355 pub fn commit_file_with(
357 &self,
358 rel_path: &str,
359 message: &str,
360 ctx: CommitContext,
361 ) -> Result<CommitInfo> {
362 if !self.enabled {
363 return self.noop_commit(&ctx, message);
364 }
365 let repo = self.repo.lock();
366 let abs = self.root.join(rel_path);
367 if !abs.exists() {
368 bail!("File not found: {rel_path}");
369 }
370
371 let content = std::fs::read(&abs)?;
372 let blob_id = repo.write_blob(&content)?;
373 let head_tree = Self::head_tree_oid(&repo)?;
374 let mut editor = repo.edit_tree(head_tree)?;
375 editor.upsert(rel_path, EntryKind::Blob, blob_id)?;
376 let tree_id = editor.write()?;
377
378 let parent = repo.head_id().ok().map(|id| id.detach());
379 let author_name = ctx.author_name();
380 let full_message = format!("{}{}", ctx.message_prefix(), message);
381 let sig = Signature::new(&author_name, &self.committer_email);
382 let commit_id = repo.commit_as(
383 sig.as_ref(),
384 sig.as_ref(),
385 "refs/heads/main",
386 &full_message,
387 tree_id.detach(),
388 parent.into_iter().collect::<Vec<_>>(),
389 )?;
390
391 Ok(self.make_info(&commit_id, &full_message, &author_name))
392 }
393
394 pub fn commit_files(&self, rel_paths: &[&str], message: &str) -> Result<CommitInfo> {
396 self.commit_files_with(rel_paths, message, CommitContext::default())
397 }
398
399 pub fn commit_files_with(
401 &self,
402 rel_paths: &[&str],
403 message: &str,
404 ctx: CommitContext,
405 ) -> Result<CommitInfo> {
406 if !self.enabled {
407 return self.noop_commit(&ctx, message);
408 }
409 let repo = self.repo.lock();
410 let head_tree = Self::head_tree_oid(&repo)?;
411 let mut editor = repo.edit_tree(head_tree)?;
412
413 for path in rel_paths {
414 let abs = self.root.join(path);
415 if abs.exists() {
416 let content = std::fs::read(&abs)?;
417 let blob_id = repo.write_blob(&content)?;
418 editor.upsert(*path, EntryKind::Blob, blob_id)?;
419 }
420 }
421 let tree_id = editor.write()?;
422
423 let parent = repo.head_id().ok().map(|id| id.detach());
424 let author_name = ctx.author_name();
425 let full_message = format!("{}{}", ctx.message_prefix(), message);
426 let sig = Signature::new(&author_name, &self.committer_email);
427 let commit_id = repo.commit_as(
428 sig.as_ref(),
429 sig.as_ref(),
430 "refs/heads/main",
431 &full_message,
432 tree_id.detach(),
433 parent.into_iter().collect::<Vec<_>>(),
434 )?;
435
436 Ok(self.make_info(&commit_id, &full_message, &author_name))
437 }
438
439 pub fn remove_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
441 if !self.enabled {
442 return self.noop_commit(&CommitContext::default(), message);
443 }
444 let repo = self.repo.lock();
445 let head_tree = Self::head_tree_oid(&repo)?;
446 let mut editor = repo.edit_tree(head_tree)?;
447 editor.remove(rel_path)?;
448 let tree_id = editor.write()?;
449
450 let parent = repo.head_id().ok().map(|id| id.detach());
451 let sig = Signature::new("oxios", &self.committer_email);
452 let commit_id = repo.commit_as(
453 sig.as_ref(),
454 sig.as_ref(),
455 "refs/heads/main",
456 message,
457 tree_id.detach(),
458 parent.into_iter().collect::<Vec<_>>(),
459 )?;
460
461 Ok(self.make_info(&commit_id, message, "oxios"))
462 }
463
464 pub fn log_action(
466 &self,
467 agent: &str,
468 action: &str,
469 target: &str,
470 allowed: bool,
471 detail: Option<&str>,
472 ) -> Result<()> {
473 let now = chrono::Utc::now();
474 let filename = format!("audit/{}.audit", now.format("%Y-%m"));
475 let entry = format!(
476 "{} | {} | {} | {} | {} | {}\n",
477 now.to_rfc3339(),
478 agent,
479 action,
480 target,
481 if allowed { "ALLOW" } else { "DENY" },
482 detail.unwrap_or("-")
483 );
484 let dir = self.root.join("audit");
485 std::fs::create_dir_all(&dir)?;
486 use std::io::Write;
487 std::fs::OpenOptions::new()
488 .create(true)
489 .append(true)
490 .open(self.root.join(&filename))?
491 .write_all(entry.as_bytes())?;
492 self.commit_file(&filename, &format!("audit: {agent} {action} {target}"))?;
493 Ok(())
494 }
495
496 pub fn tag(&self, name: &str, message: &str) -> Result<()> {
500 if !self.enabled {
501 return Ok(());
502 }
503 let repo = self.repo.lock();
504 let head_id = repo
505 .head_id()
506 .ok()
507 .map(|id| id.detach())
508 .ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
509 let sig = Signature::new("oxios", &self.committer_email);
510 repo.tag(
511 name,
512 head_id,
513 gix::objs::Kind::Commit,
514 Some(sig.as_ref()),
515 message,
516 PreviousValue::MustNotExist,
517 )?;
518 Ok(())
519 }
520
521 pub fn list_tags(&self) -> Result<Vec<String>> {
525 let repo = self.repo.lock();
526 let mut tags = Vec::new();
527 for reference in repo.references()?.all()? {
528 let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
529 if reference
530 .name()
531 .category()
532 .is_some_and(|c| matches!(c, gix::refs::Category::Tag))
533 {
534 tags.push(reference.name().shorten().to_string());
535 }
536 }
537 Ok(tags)
538 }
539
540 pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
544 let repo = self.repo.lock();
545 let head_id = repo.head_id()?.detach();
546 let mut entries = Vec::new();
547 let mut current_id: Option<ObjectId> = Some(head_id);
548
549 while let Some(id) = current_id {
550 if entries.len() >= max_count {
551 break;
552 }
553 let commit = repo.find_commit(id)?;
554 let decoded = commit.decode()?;
555 let msg_ref = decoded.message();
556 let msg = if let Some(body) = msg_ref.body {
557 format!("{}\n\n{}", msg_ref.title, body)
558 } else {
559 msg_ref.title.to_string()
560 };
561 let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
562 let author = decoded
563 .author()
564 .map(|a| a.name.to_string())
565 .unwrap_or_default();
566 let hex = id.to_hex().to_string();
567 entries.push(LogEntry {
568 hash: hex.clone(),
569 short_hash: hex[..7].into(),
570 message: msg,
571 timestamp,
572 author,
573 });
574 current_id = decoded.parents().next();
575 }
576
577 Ok(entries)
578 }
579
580 pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
582 if partial.len() < 4 {
583 bail!("Partial hash too short (minimum 4 characters)");
584 }
585 if partial.len() >= 40 {
586 return Ok(ObjectId::from_hex(partial.as_bytes())?);
587 }
588 let repo = self.repo.lock();
589 let id = repo.rev_parse_single(BStr::new(partial))?;
590 Ok(id.detach())
591 }
592
593 fn resolve_hash_inner(&self, repo: &gix::Repository, partial: &str) -> Result<ObjectId> {
595 if partial.len() >= 40 {
596 return Ok(ObjectId::from_hex(partial.as_bytes())?);
597 }
598 if partial.len() < 4 {
599 bail!("Hash too short (minimum 4 characters)");
600 }
601 let id = repo.rev_parse_single(BStr::new(partial))?;
602 Ok(id.detach())
603 }
604
605 pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
612 let commit_id = self.resolve_partial_hash(hash)?;
613 let repo = self.repo.lock();
614 let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
615 let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
616 let blob = repo.find_blob(blob_id)?;
617 std::fs::write(self.root.join(rel_path), &blob.data)?;
618 Ok(())
619 }
620
621 pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<CommitDiff> {
625 let repo = self.repo.lock();
626 let from_id = self.resolve_hash_inner(&repo, from_hash)?;
627 let to_id = self.resolve_hash_inner(&repo, to_hash)?;
628
629 let from_tree_id = Self::commit_tree_id(&repo, from_id)?;
630 let to_tree_id = Self::commit_tree_id(&repo, to_id)?;
631
632 let mut files = Vec::new();
633 Self::diff_trees(&repo, from_tree_id, to_tree_id, "", &mut files)?;
634
635 for fd in &mut files {
637 let old_data = fd
638 .old_hash
639 .as_ref()
640 .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
641 .and_then(|id| repo.find_blob(id).ok())
642 .map(|b| b.data.to_vec());
643 let new_data = fd
644 .new_hash
645 .as_ref()
646 .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
647 .and_then(|id| repo.find_blob(id).ok())
648 .map(|b| b.data.to_vec());
649
650 match (&old_data, &new_data) {
651 (Some(old), Some(new)) => {
652 fd.patch = compute_unified_diff(old, new, &fd.path);
653 }
654 (None, Some(new)) => {
655 fd.patch = compute_unified_diff(&[], new, &fd.path);
656 }
657 _ => {}
658 }
659 }
660
661 let stats = DiffStats {
662 files_changed: files.len(),
663 additions: files
664 .iter()
665 .filter_map(|f| f.patch.as_ref())
666 .map(|p| {
667 p.lines()
668 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
669 .count()
670 })
671 .sum(),
672 deletions: files
673 .iter()
674 .filter_map(|f| f.patch.as_ref())
675 .map(|p| {
676 p.lines()
677 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
678 .count()
679 })
680 .sum(),
681 };
682
683 Ok(CommitDiff {
684 from_hash: from_id.to_hex().to_string(),
685 to_hash: to_id.to_hex().to_string(),
686 files,
687 stats,
688 })
689 }
690
691 pub fn file_at_commit(&self, rel_path: &str, hash: &str) -> Result<Vec<u8>> {
693 let repo = self.repo.lock();
694 let commit_id = self.resolve_hash_inner(&repo, hash)?;
695 let tree_id = Self::commit_tree_id(&repo, commit_id)?;
696 let blob_id = Self::find_blob_in_tree(&repo, tree_id, rel_path)?;
697 let blob = repo.find_blob(blob_id)?;
698 Ok(blob.data.to_vec())
699 }
700
701 fn diff_trees(
705 repo: &gix::Repository,
706 old_tree: ObjectId,
707 new_tree: ObjectId,
708 prefix: &str,
709 changes: &mut Vec<FileDiff>,
710 ) -> Result<()> {
711 let old_tree_obj = repo.find_tree(old_tree)?;
712 let old_decoded = old_tree_obj.decode()?;
713 let new_tree_obj = repo.find_tree(new_tree)?;
714 let new_decoded = new_tree_obj.decode()?;
715
716 let old_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
717 old_decoded
718 .entries
719 .iter()
720 .map(|e| (e.filename, e))
721 .collect();
722 let new_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
723 new_decoded
724 .entries
725 .iter()
726 .map(|e| (e.filename, e))
727 .collect();
728
729 for (name, new_entry) in &new_entries {
731 let path = format!("{prefix}{name}");
732 match old_entries.get(name) {
733 None => {
734 if new_entry.mode.is_tree() {
735 let empty = ObjectId::empty_tree(repo.object_hash());
736 Self::diff_trees(
737 repo,
738 empty,
739 new_entry.oid.to_owned(),
740 &format!("{path}/"),
741 changes,
742 )?;
743 } else {
744 changes.push(FileDiff {
745 path,
746 old_hash: None,
747 new_hash: Some(new_entry.oid.to_hex().to_string()),
748 kind: DiffKind::Added,
749 patch: None,
750 });
751 }
752 }
753 Some(old_entry) => {
754 if old_entry.oid == new_entry.oid {
755 continue;
756 }
757 if new_entry.mode.is_tree() && old_entry.mode.is_tree() {
758 Self::diff_trees(
759 repo,
760 old_entry.oid.to_owned(),
761 new_entry.oid.to_owned(),
762 &format!("{path}/"),
763 changes,
764 )?;
765 } else {
766 changes.push(FileDiff {
767 path,
768 old_hash: Some(old_entry.oid.to_hex().to_string()),
769 new_hash: Some(new_entry.oid.to_hex().to_string()),
770 kind: DiffKind::Modified,
771 patch: None,
772 });
773 }
774 }
775 }
776 }
777
778 for (name, old_entry) in &old_entries {
780 if new_entries.contains_key(name) {
781 continue;
782 }
783 let path = format!("{prefix}{name}");
784 changes.push(FileDiff {
785 path,
786 old_hash: Some(old_entry.oid.to_hex().to_string()),
787 new_hash: None,
788 kind: DiffKind::Deleted,
789 patch: None,
790 });
791 }
792
793 Ok(())
794 }
795
796 pub fn verify(&self) -> Result<bool> {
800 let repo = self.repo.lock();
801 let refs = repo.references()?;
802 for reference in refs.all()? {
803 let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
804 }
805 if repo.head_id().is_err() {
806 tracing::debug!("verify: no HEAD yet (empty repository)");
807 }
808 Ok(true)
809 }
810
811 pub fn is_enabled(&self) -> bool {
813 self.enabled
814 }
815
816 pub fn root(&self) -> &Path {
818 &self.root
819 }
820
821 fn noop_commit(&self, ctx: &CommitContext, message: &str) -> Result<CommitInfo> {
824 Ok(CommitInfo {
825 hash: "(disabled)".into(),
826 short_hash: "(dis)".into(),
827 message: message.into(),
828 timestamp: chrono::Utc::now().to_rfc3339(),
829 author: ctx.author_name(),
830 })
831 }
832
833 fn make_info(&self, id: &gix::Id, message: &str, author: &str) -> CommitInfo {
834 let hex = id.to_hex().to_string();
835 CommitInfo {
836 short_hash: hex[..7].into(),
837 hash: hex,
838 message: message.into(),
839 timestamp: chrono::Utc::now().to_rfc3339(),
840 author: author.into(),
841 }
842 }
843}
844
845fn compute_unified_diff(old: &[u8], new: &[u8], path: &str) -> Option<String> {
849 let old_str = std::str::from_utf8(old).ok()?;
850 let new_str = std::str::from_utf8(new).ok()?;
851
852 use similar::{ChangeTag, TextDiff};
853 let diff = TextDiff::from_lines(old_str, new_str);
854
855 let mut output = format!("--- a/{path}\n+++ b/{path}\n");
856 for change in diff.iter_all_changes() {
857 let prefix = match change.tag() {
858 ChangeTag::Delete => '-',
859 ChangeTag::Insert => '+',
860 ChangeTag::Equal => ' ',
861 };
862 output.push_str(&format!("{prefix}{change}"));
863 }
864
865 Some(output)
866}
867
868#[cfg(test)]
871mod tests {
872 use super::*;
873 use tempfile::TempDir;
874
875 fn setup() -> (TempDir, GitLayer) {
876 let dir = tempfile::tempdir().unwrap();
877 let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
878 (dir, layer)
879 }
880
881 #[test]
882 fn test_init_creates_repo() {
883 let (dir, _) = setup();
884 assert!(dir.path().join(".git").exists());
885 }
886
887 #[test]
888 fn test_commit_file() {
889 let (dir, layer) = setup();
890 std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
891 let info = layer.commit_file("test.json", "test commit").unwrap();
892 assert!(!info.hash.is_empty());
893 assert_eq!(info.short_hash.len(), 7);
894 assert_eq!(info.message, "test commit");
895 assert!(info.hash.starts_with(&info.short_hash));
896 }
897
898 #[test]
899 fn test_log_query() {
900 let (dir, layer) = setup();
901 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
902 layer.commit_file("a.json", "first").unwrap();
903 std::fs::write(dir.path().join("a.json"), b"2").unwrap();
904 layer.commit_file("a.json", "second").unwrap();
905 let log = layer.log(10).unwrap();
906 assert!(log.len() >= 2);
907 assert!(log[0].message.contains("second"));
908 }
909
910 #[test]
911 fn test_tag_create_list() {
912 let (dir, layer) = setup();
913 std::fs::write(dir.path().join("x.json"), b"1").unwrap();
914 layer.commit_file("x.json", "tag test").unwrap();
915 layer.tag("v1", "first tag").unwrap();
916 let tags = layer.list_tags().unwrap();
917 assert!(tags.iter().any(|t| t == "v1"));
918 }
919
920 #[test]
921 fn test_disabled_noop() {
922 let dir = tempfile::tempdir().unwrap();
923 let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
924 std::fs::write(dir.path().join("test.json"), b"1").unwrap();
925 let info = layer.commit_file("test.json", "noop").unwrap();
926 assert_eq!(info.hash, "(disabled)");
927 assert_eq!(info.short_hash, "(dis)");
928 }
929
930 #[test]
931 fn test_log_action() {
932 let (dir, layer) = setup();
933 layer
934 .log_action("agent-A", "read", "file.txt", true, None)
935 .unwrap();
936 let audit_file = dir
937 .path()
938 .join("audit")
939 .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
940 assert!(audit_file.exists());
941 let content = std::fs::read_to_string(&audit_file).unwrap();
942 assert!(content.contains("agent-A"));
943 assert!(content.contains("ALLOW"));
944 }
945
946 #[test]
947 fn test_verify() {
948 let (_, layer) = setup();
949 assert!(layer.verify().unwrap());
950 }
951
952 #[test]
953 fn test_remove_file() {
954 let (dir, layer) = setup();
955 std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
956 layer.commit_file("todelete.json", "add file").unwrap();
957 std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
958 let info = layer.remove_file("todelete.json", "remove file").unwrap();
959 assert!(!info.hash.is_empty());
960 assert!(info.hash != "(disabled)");
961 }
962
963 #[test]
964 fn test_commit_files_batch() {
965 let (dir, layer) = setup();
966 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
967 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
968 let info = layer
969 .commit_files(&["a.json", "b.json"], "batch commit")
970 .unwrap();
971 assert!(!info.hash.is_empty());
972 assert_eq!(info.message, "batch commit");
973 }
974
975 #[test]
976 fn test_restore_file() {
977 let (dir, layer) = setup();
978 std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
979 let first = layer.commit_file("state.json", "v1").unwrap();
980 std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
981 layer.commit_file("state.json", "v2").unwrap();
982 layer.restore_file("state.json", &first.short_hash).unwrap();
983 let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
984 assert_eq!(content, "v1");
985 }
986
987 #[test]
988 fn test_gitignore_created() {
989 let (dir, _) = setup();
990 assert!(dir.path().join(".gitignore").exists());
991 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
992 assert!(content.contains("Oxios"));
993 }
994
995 #[test]
998 fn test_signature_timestamps_are_fresh() {
999 let sig1 = Signature::new("a", "a@a");
1003 assert!(!sig1.time.is_empty());
1004
1005 std::thread::sleep(std::time::Duration::from_millis(1100));
1006 let sig3 = Signature::new("c", "c@c");
1007 assert_ne!(
1008 sig1.time, sig3.time,
1009 "Signature created 1s later must have a different timestamp"
1010 );
1011 }
1012
1013 #[test]
1016 fn test_commit_file_with_agent_context() {
1017 let (dir, layer) = setup();
1018 std::fs::write(dir.path().join("agent_work.json"), b"{\"result\":42}").unwrap();
1019
1020 let agent_id = uuid::Uuid::new_v4();
1021 let ctx = CommitContext::agent(agent_id, None);
1022 layer
1023 .commit_file_with("agent_work.json", "agent did work", ctx)
1024 .unwrap();
1025
1026 let log = layer.log(10).unwrap();
1027 let agent_commit = log
1028 .iter()
1029 .find(|e| e.message.contains("agent did work"))
1030 .expect("should find agent commit");
1031
1032 let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1033 assert_eq!(agent_commit.author, expected_author);
1034 }
1035
1036 #[test]
1037 fn test_commit_file_with_tag() {
1038 let (dir, layer) = setup();
1039 std::fs::write(dir.path().join("audit.json"), b"{\"event\":\"test\"}").unwrap();
1040
1041 let ctx = CommitContext::tagged("audit");
1042 let info = layer
1043 .commit_file_with("audit.json", "flush audit trail", ctx)
1044 .unwrap();
1045
1046 assert!(info.message.contains("[audit]"));
1047 assert!(info.message.contains("flush audit trail"));
1048 }
1049
1050 #[test]
1051 fn test_default_context_is_oxios() {
1052 let (dir, layer) = setup();
1053 std::fs::write(dir.path().join("sys.json"), b"1").unwrap();
1054
1055 let info = layer
1056 .commit_file_with("sys.json", "system commit", CommitContext::default())
1057 .unwrap();
1058
1059 assert_eq!(info.author, "oxios");
1060 }
1061
1062 #[test]
1063 fn test_commit_context_author_name() {
1064 assert_eq!(CommitContext::default().author_name(), "oxios");
1065 assert_eq!(CommitContext::system().author_name(), "oxios");
1066
1067 let id = uuid::Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
1068 assert_eq!(
1069 CommitContext::agent(id, None).author_name(),
1070 "agent-aaaaaaaa"
1071 );
1072
1073 assert_eq!(CommitContext::tagged("memory").author_name(), "oxios");
1074 }
1075
1076 #[test]
1077 fn test_commit_context_message_prefix() {
1078 assert!(CommitContext::default().message_prefix().is_empty());
1079 assert_eq!(CommitContext::tagged("audit").message_prefix(), "[audit] ");
1080
1081 let seed_id = uuid::Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
1082 let ctx = CommitContext {
1083 tag: Some("memory"),
1084 seed_id: Some(seed_id),
1085 ..Default::default()
1086 };
1087 assert_eq!(ctx.message_prefix(), "[memory] [seed-11111111] ");
1088 }
1089
1090 #[test]
1091 fn test_commit_files_with_context() {
1092 let (dir, layer) = setup();
1093 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1094 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1095
1096 let agent_id = uuid::Uuid::new_v4();
1097 let ctx = CommitContext::agent(agent_id, None);
1098 let info = layer
1099 .commit_files_with(&["a.json", "b.json"], "batch agent work", ctx)
1100 .unwrap();
1101
1102 let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1103 assert_eq!(info.author, expected_author);
1104 }
1105
1106 #[test]
1107 fn test_backward_compat_commit_file_is_oxios() {
1108 let (dir, layer) = setup();
1109 std::fs::write(dir.path().join("compat.json"), b"1").unwrap();
1110 let info = layer.commit_file("compat.json", "compat check").unwrap();
1111 assert_eq!(info.author, "oxios");
1112 }
1113
1114 #[test]
1117 fn test_restore_nested_file() {
1118 let (dir, layer) = setup();
1119
1120 layer
1122 .log_action("agent-X", "write", "secret.txt", true, None)
1123 .unwrap();
1124
1125 let audit_rel = format!("audit/{}.audit", chrono::Utc::now().format("%Y-%m"));
1126 let audit_path = dir.path().join(&audit_rel);
1127 assert!(audit_path.exists(), "audit file should exist");
1128
1129 let _original = std::fs::read_to_string(&audit_path).unwrap();
1131 std::fs::write(&audit_path, "CORRUPTED").unwrap();
1132 layer.commit_file(&audit_rel, "corrupt").unwrap();
1133
1134 let log = layer.log(10).unwrap();
1136 let audit_commit = log
1137 .iter()
1138 .find(|e| e.message.contains("audit: agent-X"))
1139 .expect("should find audit commit");
1140
1141 layer
1142 .restore_file(&audit_rel, &audit_commit.short_hash)
1143 .unwrap();
1144
1145 let restored = std::fs::read_to_string(&audit_path).unwrap();
1146 assert!(restored.contains("agent-X"));
1147 assert!(!restored.contains("CORRUPTED"));
1148 }
1149
1150 #[test]
1153 fn test_list_tags_excludes_non_tags() {
1154 let (dir, layer) = setup();
1155 std::fs::write(dir.path().join("t.json"), b"1").unwrap();
1156 layer.commit_file("t.json", "for tag").unwrap();
1157 layer.tag("release-v1", "first release").unwrap();
1158 let tags = layer.list_tags().unwrap();
1159 assert!(tags.iter().any(|t| t == "release-v1"));
1160 assert!(tags.iter().all(|t| t != "main" && t != "HEAD"));
1161 }
1162
1163 #[test]
1166 fn test_diff_added_file() {
1167 let (dir, layer) = setup();
1168 let first = layer.log(1).unwrap()[0].hash.clone();
1169
1170 std::fs::write(dir.path().join("new.txt"), b"hello\n").unwrap();
1171 let info = layer.commit_file("new.txt", "add file").unwrap();
1172
1173 let diff = layer.diff_commits(&first, &info.hash).unwrap();
1174 assert!(diff
1175 .files
1176 .iter()
1177 .any(|f| f.path == "new.txt" && f.kind == DiffKind::Added));
1178 }
1179
1180 #[test]
1181 fn test_diff_modified_file() {
1182 let (dir, layer) = setup();
1183
1184 std::fs::write(dir.path().join("data.txt"), b"v1\n").unwrap();
1185 let first = layer.commit_file("data.txt", "v1").unwrap();
1186
1187 std::fs::write(dir.path().join("data.txt"), b"v2\n").unwrap();
1188 let second = layer.commit_file("data.txt", "v2").unwrap();
1189
1190 let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1191 assert!(diff
1192 .files
1193 .iter()
1194 .any(|f| f.path == "data.txt" && f.kind == DiffKind::Modified));
1195
1196 let patch = diff
1197 .files
1198 .iter()
1199 .find(|f| f.path == "data.txt")
1200 .unwrap()
1201 .patch
1202 .as_ref()
1203 .expect("should have patch");
1204 assert!(patch.contains("-v1"));
1205 assert!(patch.contains("+v2"));
1206 }
1207
1208 #[test]
1209 fn test_diff_deleted_file() {
1210 let (dir, layer) = setup();
1211
1212 std::fs::write(dir.path().join("temp.txt"), b"bye\n").unwrap();
1213 let first = layer.commit_file("temp.txt", "add temp").unwrap();
1214
1215 std::fs::remove_file(dir.path().join("temp.txt")).unwrap();
1216 let second = layer.remove_file("temp.txt", "remove temp").unwrap();
1217
1218 let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1219 assert!(diff
1220 .files
1221 .iter()
1222 .any(|f| f.path == "temp.txt" && f.kind == DiffKind::Deleted));
1223 }
1224
1225 #[test]
1226 fn test_file_at_commit() {
1227 let (dir, layer) = setup();
1228
1229 std::fs::write(dir.path().join("state.json"), b"{\"v\":1}").unwrap();
1230 let first = layer.commit_file("state.json", "v1").unwrap();
1231
1232 std::fs::write(dir.path().join("state.json"), b"{\"v\":2}").unwrap();
1233 layer.commit_file("state.json", "v2").unwrap();
1234
1235 let content = layer
1236 .file_at_commit("state.json", &first.short_hash)
1237 .unwrap();
1238 assert_eq!(content, b"{\"v\":1}");
1239 }
1240}