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