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 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 fn ensure_within_root(&self, rel_path: &str) -> Result<std::path::PathBuf> {
283 use std::path::Component;
284 let p = Path::new(rel_path);
285 if p.is_absolute() {
286 bail!("path must be relative to git root: {rel_path}");
287 }
288 for comp in p.components() {
289 match comp {
290 Component::ParentDir => {
291 bail!("parent-dir traversal not allowed: {rel_path}")
292 }
293 Component::RootDir => bail!("root-dir traversal not allowed: {rel_path}"),
294 Component::Prefix(_) => bail!("path prefix not allowed: {rel_path}"),
295 _ => {}
296 }
297 }
298 Ok(self.root.join(rel_path))
299 }
300
301 fn create_initial_commit(repo: &Arc<Mutex<gix::Repository>>, root: &Path) -> Result<()> {
302 let repo_lock = repo.lock();
303 let gitignore = root.join(".gitignore");
304 let content = std::fs::read(&gitignore)?;
305 let blob_id = repo_lock.write_blob(&content)?;
306 let empty_tree = ObjectId::empty_tree(repo_lock.object_hash());
307 let mut editor = repo_lock.edit_tree(empty_tree)?;
308 editor.upsert(".gitignore", EntryKind::Blob, blob_id)?;
309 let tree_id = editor.write()?;
310 let sig = Signature::new("oxios", DEFAULT_EMAIL);
311 repo_lock.commit_as(
312 sig.as_ref(),
313 sig.as_ref(),
314 "refs/heads/main",
315 "Initial commit",
316 tree_id.detach(),
317 Vec::<ObjectId>::new(),
318 )?;
319 Ok(())
320 }
321
322 fn head_tree_oid(repo: &gix::Repository) -> Result<ObjectId> {
324 match Self::head_id_detached_raw(repo) {
325 Some(id) => {
326 let commit = repo.find_commit(id)?;
327 let decoded = commit.decode()?;
328 Ok(decoded.tree())
329 }
330 None => Ok(ObjectId::empty_tree(repo.object_hash())),
331 }
332 }
333
334 fn commit_tree_id(repo: &gix::Repository, commit_id: ObjectId) -> Result<ObjectId> {
336 let commit = repo.find_commit(commit_id)?;
337 let decoded = commit.decode()?;
338 Ok(decoded.tree())
339 }
340
341 fn find_blob_in_tree(
345 repo: &gix::Repository,
346 tree_id: ObjectId,
347 rel_path: &str,
348 ) -> Result<ObjectId> {
349 let components: Vec<&str> = Path::new(rel_path)
350 .iter()
351 .filter_map(|c| c.to_str())
352 .collect();
353 anyhow::ensure!(!components.is_empty(), "empty path: {rel_path}");
354
355 let mut current_tree_id = tree_id;
356
357 for (i, component) in components.iter().enumerate() {
358 let tree = repo.find_tree(current_tree_id)?;
359 let decoded = tree.decode()?;
360 let comp_bytes = BStr::new(component);
361 let entry = decoded
362 .entries
363 .iter()
364 .find(|e| e.filename == comp_bytes)
365 .ok_or_else(|| {
366 anyhow::anyhow!("path component '{component}' not found in '{rel_path}'")
367 })?;
368
369 if i == components.len() - 1 {
370 return Ok(entry.oid.to_owned());
371 }
372 current_tree_id = entry.oid.to_owned();
373 }
374
375 unreachable!()
376 }
377
378 pub fn commit_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
382 self.commit_file_with(rel_path, message, CommitContext::default())
383 }
384
385 pub fn commit_file_with(
387 &self,
388 rel_path: &str,
389 message: &str,
390 ctx: CommitContext,
391 ) -> Result<CommitInfo> {
392 if !self.enabled {
393 return self.noop_commit(&ctx, message);
394 }
395 let repo = self.repo.lock();
396 let abs = self.ensure_within_root(rel_path)?;
397 if !abs.exists() {
398 bail!("File not found: {rel_path}");
399 }
400
401 let content = std::fs::read(&abs)?;
402 let blob_id = repo.write_blob(&content)?;
403 let head_tree = Self::head_tree_oid(&repo)?;
404 let mut editor = repo.edit_tree(head_tree)?;
405 editor.upsert(rel_path, EntryKind::Blob, blob_id)?;
406 let tree_id = editor.write()?;
407
408 let parent = repo.head_id().ok().map(|id| id.detach());
409 let author_name = ctx.author_name();
410 let full_message = format!("{}{}", ctx.message_prefix(), message);
411 let sig = Signature::new(&author_name, &self.committer_email);
412 let commit_id = repo.commit_as(
413 sig.as_ref(),
414 sig.as_ref(),
415 "refs/heads/main",
416 &full_message,
417 tree_id.detach(),
418 parent.into_iter().collect::<Vec<_>>(),
419 )?;
420
421 Ok(self.make_info(&commit_id, &full_message, &author_name))
422 }
423
424 pub fn commit_files(&self, rel_paths: &[&str], message: &str) -> Result<CommitInfo> {
426 self.commit_files_with(rel_paths, message, CommitContext::default())
427 }
428
429 pub fn commit_files_with(
431 &self,
432 rel_paths: &[&str],
433 message: &str,
434 ctx: CommitContext,
435 ) -> Result<CommitInfo> {
436 if !self.enabled {
437 return self.noop_commit(&ctx, message);
438 }
439 let repo = self.repo.lock();
440 let head_tree = Self::head_tree_oid(&repo)?;
441 let mut editor = repo.edit_tree(head_tree)?;
442
443 for path in rel_paths {
444 let abs = self.ensure_within_root(path)?;
445 if abs.exists() {
446 let content = std::fs::read(&abs)?;
447 let blob_id = repo.write_blob(&content)?;
448 editor.upsert(*path, EntryKind::Blob, blob_id)?;
449 }
450 }
451 let tree_id = editor.write()?;
452
453 let parent = repo.head_id().ok().map(|id| id.detach());
454 let author_name = ctx.author_name();
455 let full_message = format!("{}{}", ctx.message_prefix(), message);
456 let sig = Signature::new(&author_name, &self.committer_email);
457 let commit_id = repo.commit_as(
458 sig.as_ref(),
459 sig.as_ref(),
460 "refs/heads/main",
461 &full_message,
462 tree_id.detach(),
463 parent.into_iter().collect::<Vec<_>>(),
464 )?;
465
466 Ok(self.make_info(&commit_id, &full_message, &author_name))
467 }
468
469 pub fn remove_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
471 if !self.enabled {
472 return self.noop_commit(&CommitContext::default(), message);
473 }
474 self.ensure_within_root(rel_path)?;
477 let repo = self.repo.lock();
478 let head_tree = Self::head_tree_oid(&repo)?;
479 let mut editor = repo.edit_tree(head_tree)?;
480 editor.remove(rel_path)?;
481 let tree_id = editor.write()?;
482
483 let parent = repo.head_id().ok().map(|id| id.detach());
484 let sig = Signature::new("oxios", &self.committer_email);
485 let commit_id = repo.commit_as(
486 sig.as_ref(),
487 sig.as_ref(),
488 "refs/heads/main",
489 message,
490 tree_id.detach(),
491 parent.into_iter().collect::<Vec<_>>(),
492 )?;
493
494 Ok(self.make_info(&commit_id, message, "oxios"))
495 }
496
497 pub fn log_action(
499 &self,
500 agent: &str,
501 action: &str,
502 target: &str,
503 allowed: bool,
504 detail: Option<&str>,
505 ) -> Result<()> {
506 let now = chrono::Utc::now();
507 let filename = format!("audit/{}.audit", now.format("%Y-%m"));
508 let entry = format!(
509 "{} | {} | {} | {} | {} | {}\n",
510 now.to_rfc3339(),
511 agent,
512 action,
513 target,
514 if allowed { "ALLOW" } else { "DENY" },
515 detail.unwrap_or("-")
516 );
517 let dir = self.root.join("audit");
518 std::fs::create_dir_all(&dir)?;
519 use std::io::Write;
520 std::fs::OpenOptions::new()
521 .create(true)
522 .append(true)
523 .open(self.root.join(&filename))?
524 .write_all(entry.as_bytes())?;
525 self.commit_file(&filename, &format!("audit: {agent} {action} {target}"))?;
526 Ok(())
527 }
528
529 pub fn tag(&self, name: &str, message: &str) -> Result<()> {
533 if !self.enabled {
534 return Ok(());
535 }
536 let repo = self.repo.lock();
537 let head_id = repo
538 .head_id()
539 .ok()
540 .map(|id| id.detach())
541 .ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
542 let sig = Signature::new("oxios", &self.committer_email);
543 repo.tag(
544 name,
545 head_id,
546 gix::objs::Kind::Commit,
547 Some(sig.as_ref()),
548 message,
549 PreviousValue::MustNotExist,
550 )?;
551 Ok(())
552 }
553
554 pub fn list_tags(&self) -> Result<Vec<String>> {
558 let repo = self.repo.lock();
559 let mut tags = Vec::new();
560 for reference in repo.references()?.all()? {
561 let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
562 if reference
563 .name()
564 .category()
565 .is_some_and(|c| matches!(c, gix::refs::Category::Tag))
566 {
567 tags.push(reference.name().shorten().to_string());
568 }
569 }
570 Ok(tags)
571 }
572
573 pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
577 let repo = self.repo.lock();
578 let head_id = repo.head_id()?.detach();
579 let mut entries = Vec::new();
580 let mut current_id: Option<ObjectId> = Some(head_id);
581
582 while let Some(id) = current_id {
583 if entries.len() >= max_count {
584 break;
585 }
586 let commit = repo.find_commit(id)?;
587 let decoded = commit.decode()?;
588 let msg_ref = decoded.message();
589 let msg = if let Some(body) = msg_ref.body {
590 format!("{}\n\n{}", msg_ref.title, body)
591 } else {
592 msg_ref.title.to_string()
593 };
594 let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
595 let author = decoded
596 .author()
597 .map(|a| a.name.to_string())
598 .unwrap_or_default();
599 let hex = id.to_hex().to_string();
600 entries.push(LogEntry {
601 hash: hex.clone(),
602 short_hash: hex[..7].into(),
603 message: msg,
604 timestamp,
605 author,
606 });
607 current_id = decoded.parents().next();
608 }
609
610 Ok(entries)
611 }
612
613 pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
615 if partial.len() < 4 {
616 bail!("Partial hash too short (minimum 4 characters)");
617 }
618 if partial.len() >= 40 {
619 return Ok(ObjectId::from_hex(partial.as_bytes())?);
620 }
621 let repo = self.repo.lock();
622 let id = repo.rev_parse_single(BStr::new(partial))?;
623 Ok(id.detach())
624 }
625
626 fn resolve_hash_inner(&self, repo: &gix::Repository, partial: &str) -> Result<ObjectId> {
628 if partial.len() >= 40 {
629 return Ok(ObjectId::from_hex(partial.as_bytes())?);
630 }
631 if partial.len() < 4 {
632 bail!("Hash too short (minimum 4 characters)");
633 }
634 let id = repo.rev_parse_single(BStr::new(partial))?;
635 Ok(id.detach())
636 }
637
638 pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
645 let dest = self.ensure_within_root(rel_path)?;
648 let commit_id = self.resolve_partial_hash(hash)?;
649 let repo = self.repo.lock();
650 let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
651 let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
652 let blob = repo.find_blob(blob_id)?;
653 std::fs::write(dest, &blob.data)?;
654 Ok(())
655 }
656
657 pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<CommitDiff> {
661 let repo = self.repo.lock();
662 let from_id = self.resolve_hash_inner(&repo, from_hash)?;
663 let to_id = self.resolve_hash_inner(&repo, to_hash)?;
664
665 let from_tree_id = Self::commit_tree_id(&repo, from_id)?;
666 let to_tree_id = Self::commit_tree_id(&repo, to_id)?;
667
668 let mut files = Vec::new();
669 Self::diff_trees(&repo, from_tree_id, to_tree_id, "", &mut files)?;
670
671 for fd in &mut files {
673 let old_data = fd
674 .old_hash
675 .as_ref()
676 .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
677 .and_then(|id| repo.find_blob(id).ok())
678 .map(|b| b.data.to_vec());
679 let new_data = fd
680 .new_hash
681 .as_ref()
682 .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
683 .and_then(|id| repo.find_blob(id).ok())
684 .map(|b| b.data.to_vec());
685
686 match (&old_data, &new_data) {
687 (Some(old), Some(new)) => {
688 fd.patch = compute_unified_diff(old, new, &fd.path);
689 }
690 (None, Some(new)) => {
691 fd.patch = compute_unified_diff(&[], new, &fd.path);
692 }
693 _ => {}
694 }
695 }
696
697 let stats = DiffStats {
698 files_changed: files.len(),
699 additions: files
700 .iter()
701 .filter_map(|f| f.patch.as_ref())
702 .map(|p| {
703 p.lines()
704 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
705 .count()
706 })
707 .sum(),
708 deletions: files
709 .iter()
710 .filter_map(|f| f.patch.as_ref())
711 .map(|p| {
712 p.lines()
713 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
714 .count()
715 })
716 .sum(),
717 };
718
719 Ok(CommitDiff {
720 from_hash: from_id.to_hex().to_string(),
721 to_hash: to_id.to_hex().to_string(),
722 files,
723 stats,
724 })
725 }
726
727 pub fn file_at_commit(&self, rel_path: &str, hash: &str) -> Result<Vec<u8>> {
729 self.ensure_within_root(rel_path)?;
732 let repo = self.repo.lock();
733 let commit_id = self.resolve_hash_inner(&repo, hash)?;
734 let tree_id = Self::commit_tree_id(&repo, commit_id)?;
735 let blob_id = Self::find_blob_in_tree(&repo, tree_id, rel_path)?;
736 let blob = repo.find_blob(blob_id)?;
737 Ok(blob.data.to_vec())
738 }
739
740 fn diff_trees(
744 repo: &gix::Repository,
745 old_tree: ObjectId,
746 new_tree: ObjectId,
747 prefix: &str,
748 changes: &mut Vec<FileDiff>,
749 ) -> Result<()> {
750 let old_tree_obj = repo.find_tree(old_tree)?;
751 let old_decoded = old_tree_obj.decode()?;
752 let new_tree_obj = repo.find_tree(new_tree)?;
753 let new_decoded = new_tree_obj.decode()?;
754
755 let old_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
756 old_decoded
757 .entries
758 .iter()
759 .map(|e| (e.filename, e))
760 .collect();
761 let new_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
762 new_decoded
763 .entries
764 .iter()
765 .map(|e| (e.filename, e))
766 .collect();
767
768 for (name, new_entry) in &new_entries {
770 let path = format!("{prefix}{name}");
771 match old_entries.get(name) {
772 None => {
773 if new_entry.mode.is_tree() {
774 let empty = ObjectId::empty_tree(repo.object_hash());
775 Self::diff_trees(
776 repo,
777 empty,
778 new_entry.oid.to_owned(),
779 &format!("{path}/"),
780 changes,
781 )?;
782 } else {
783 changes.push(FileDiff {
784 path,
785 old_hash: None,
786 new_hash: Some(new_entry.oid.to_hex().to_string()),
787 kind: DiffKind::Added,
788 patch: None,
789 });
790 }
791 }
792 Some(old_entry) => {
793 if old_entry.oid == new_entry.oid {
794 continue;
795 }
796 if new_entry.mode.is_tree() && old_entry.mode.is_tree() {
797 Self::diff_trees(
798 repo,
799 old_entry.oid.to_owned(),
800 new_entry.oid.to_owned(),
801 &format!("{path}/"),
802 changes,
803 )?;
804 } else {
805 changes.push(FileDiff {
806 path,
807 old_hash: Some(old_entry.oid.to_hex().to_string()),
808 new_hash: Some(new_entry.oid.to_hex().to_string()),
809 kind: DiffKind::Modified,
810 patch: None,
811 });
812 }
813 }
814 }
815 }
816
817 for (name, old_entry) in &old_entries {
819 if new_entries.contains_key(name) {
820 continue;
821 }
822 let path = format!("{prefix}{name}");
823 changes.push(FileDiff {
824 path,
825 old_hash: Some(old_entry.oid.to_hex().to_string()),
826 new_hash: None,
827 kind: DiffKind::Deleted,
828 patch: None,
829 });
830 }
831
832 Ok(())
833 }
834
835 pub fn verify(&self) -> Result<bool> {
839 let repo = self.repo.lock();
840 let refs = repo.references()?;
841 for reference in refs.all()? {
842 let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
843 }
844 if repo.head_id().is_err() {
845 tracing::debug!("verify: no HEAD yet (empty repository)");
846 }
847 Ok(true)
848 }
849
850 pub fn is_enabled(&self) -> bool {
852 self.enabled
853 }
854
855 pub fn root(&self) -> &Path {
857 &self.root
858 }
859
860 fn noop_commit(&self, ctx: &CommitContext, message: &str) -> Result<CommitInfo> {
863 Ok(CommitInfo {
864 hash: "(disabled)".into(),
865 short_hash: "(dis)".into(),
866 message: message.into(),
867 timestamp: chrono::Utc::now().to_rfc3339(),
868 author: ctx.author_name(),
869 })
870 }
871
872 fn make_info(&self, id: &gix::Id, message: &str, author: &str) -> CommitInfo {
873 let hex = id.to_hex().to_string();
874 CommitInfo {
875 short_hash: hex[..7].into(),
876 hash: hex,
877 message: message.into(),
878 timestamp: chrono::Utc::now().to_rfc3339(),
879 author: author.into(),
880 }
881 }
882}
883
884fn compute_unified_diff(old: &[u8], new: &[u8], path: &str) -> Option<String> {
888 let old_str = std::str::from_utf8(old).ok()?;
889 let new_str = std::str::from_utf8(new).ok()?;
890
891 use similar::{ChangeTag, TextDiff};
892 let diff = TextDiff::from_lines(old_str, new_str);
893
894 let mut output = format!("--- a/{path}\n+++ b/{path}\n");
895 for change in diff.iter_all_changes() {
896 let prefix = match change.tag() {
897 ChangeTag::Delete => '-',
898 ChangeTag::Insert => '+',
899 ChangeTag::Equal => ' ',
900 };
901 output.push_str(&format!("{prefix}{change}"));
902 }
903
904 Some(output)
905}
906
907#[cfg(test)]
910mod tests {
911 use super::*;
912 use tempfile::TempDir;
913
914 fn setup() -> (TempDir, GitLayer) {
915 let dir = tempfile::tempdir().unwrap();
916 let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
917 (dir, layer)
918 }
919
920 #[test]
921 fn test_init_creates_repo() {
922 let (dir, _) = setup();
923 assert!(dir.path().join(".git").exists());
924 }
925
926 #[test]
927 fn test_commit_file() {
928 let (dir, layer) = setup();
929 std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
930 let info = layer.commit_file("test.json", "test commit").unwrap();
931 assert!(!info.hash.is_empty());
932 assert_eq!(info.short_hash.len(), 7);
933 assert_eq!(info.message, "test commit");
934 assert!(info.hash.starts_with(&info.short_hash));
935 }
936
937 #[test]
938 fn test_log_query() {
939 let (dir, layer) = setup();
940 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
941 layer.commit_file("a.json", "first").unwrap();
942 std::fs::write(dir.path().join("a.json"), b"2").unwrap();
943 layer.commit_file("a.json", "second").unwrap();
944 let log = layer.log(10).unwrap();
945 assert!(log.len() >= 2);
946 assert!(log[0].message.contains("second"));
947 }
948
949 #[test]
950 fn test_tag_create_list() {
951 let (dir, layer) = setup();
952 std::fs::write(dir.path().join("x.json"), b"1").unwrap();
953 layer.commit_file("x.json", "tag test").unwrap();
954 layer.tag("v1", "first tag").unwrap();
955 let tags = layer.list_tags().unwrap();
956 assert!(tags.iter().any(|t| t == "v1"));
957 }
958
959 #[test]
960 fn test_disabled_noop() {
961 let dir = tempfile::tempdir().unwrap();
962 let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
963 std::fs::write(dir.path().join("test.json"), b"1").unwrap();
964 let info = layer.commit_file("test.json", "noop").unwrap();
965 assert_eq!(info.hash, "(disabled)");
966 assert_eq!(info.short_hash, "(dis)");
967 }
968
969 #[test]
970 fn test_log_action() {
971 let (dir, layer) = setup();
972 layer
973 .log_action("agent-A", "read", "file.txt", true, None)
974 .unwrap();
975 let audit_file = dir
976 .path()
977 .join("audit")
978 .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
979 assert!(audit_file.exists());
980 let content = std::fs::read_to_string(&audit_file).unwrap();
981 assert!(content.contains("agent-A"));
982 assert!(content.contains("ALLOW"));
983 }
984
985 #[test]
986 fn test_verify() {
987 let (_, layer) = setup();
988 assert!(layer.verify().unwrap());
989 }
990
991 #[test]
992 fn test_remove_file() {
993 let (dir, layer) = setup();
994 std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
995 layer.commit_file("todelete.json", "add file").unwrap();
996 std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
997 let info = layer.remove_file("todelete.json", "remove file").unwrap();
998 assert!(!info.hash.is_empty());
999 assert!(info.hash != "(disabled)");
1000 }
1001
1002 #[test]
1003 fn test_commit_files_batch() {
1004 let (dir, layer) = setup();
1005 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1006 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1007 let info = layer
1008 .commit_files(&["a.json", "b.json"], "batch commit")
1009 .unwrap();
1010 assert!(!info.hash.is_empty());
1011 assert_eq!(info.message, "batch commit");
1012 }
1013
1014 #[test]
1015 fn test_restore_file() {
1016 let (dir, layer) = setup();
1017 std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
1018 let first = layer.commit_file("state.json", "v1").unwrap();
1019 std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
1020 layer.commit_file("state.json", "v2").unwrap();
1021 layer.restore_file("state.json", &first.short_hash).unwrap();
1022 let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
1023 assert_eq!(content, "v1");
1024 }
1025
1026 #[test]
1027 fn test_gitignore_created() {
1028 let (dir, _) = setup();
1029 assert!(dir.path().join(".gitignore").exists());
1030 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
1031 assert!(content.contains("Oxios"));
1032 }
1033
1034 #[test]
1037 fn test_signature_timestamps_are_fresh() {
1038 let sig1 = Signature::new("a", "a@a");
1042 assert!(!sig1.time.is_empty());
1043
1044 std::thread::sleep(std::time::Duration::from_millis(1100));
1045 let sig3 = Signature::new("c", "c@c");
1046 assert_ne!(
1047 sig1.time, sig3.time,
1048 "Signature created 1s later must have a different timestamp"
1049 );
1050 }
1051
1052 #[test]
1055 fn test_commit_file_with_agent_context() {
1056 let (dir, layer) = setup();
1057 std::fs::write(dir.path().join("agent_work.json"), b"{\"result\":42}").unwrap();
1058
1059 let agent_id = uuid::Uuid::new_v4();
1060 let ctx = CommitContext::agent(agent_id, None);
1061 layer
1062 .commit_file_with("agent_work.json", "agent did work", ctx)
1063 .unwrap();
1064
1065 let log = layer.log(10).unwrap();
1066 let agent_commit = log
1067 .iter()
1068 .find(|e| e.message.contains("agent did work"))
1069 .expect("should find agent commit");
1070
1071 let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1072 assert_eq!(agent_commit.author, expected_author);
1073 }
1074
1075 #[test]
1076 fn test_commit_file_with_tag() {
1077 let (dir, layer) = setup();
1078 std::fs::write(dir.path().join("audit.json"), b"{\"event\":\"test\"}").unwrap();
1079
1080 let ctx = CommitContext::tagged("audit");
1081 let info = layer
1082 .commit_file_with("audit.json", "flush audit trail", ctx)
1083 .unwrap();
1084
1085 assert!(info.message.contains("[audit]"));
1086 assert!(info.message.contains("flush audit trail"));
1087 }
1088
1089 #[test]
1090 fn test_default_context_is_oxios() {
1091 let (dir, layer) = setup();
1092 std::fs::write(dir.path().join("sys.json"), b"1").unwrap();
1093
1094 let info = layer
1095 .commit_file_with("sys.json", "system commit", CommitContext::default())
1096 .unwrap();
1097
1098 assert_eq!(info.author, "oxios");
1099 }
1100
1101 #[test]
1102 fn test_commit_context_author_name() {
1103 assert_eq!(CommitContext::default().author_name(), "oxios");
1104 assert_eq!(CommitContext::system().author_name(), "oxios");
1105
1106 let id = uuid::Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
1107 assert_eq!(
1108 CommitContext::agent(id, None).author_name(),
1109 "agent-aaaaaaaa"
1110 );
1111
1112 assert_eq!(CommitContext::tagged("memory").author_name(), "oxios");
1113 }
1114
1115 #[test]
1116 fn test_commit_context_message_prefix() {
1117 assert!(CommitContext::default().message_prefix().is_empty());
1118 assert_eq!(CommitContext::tagged("audit").message_prefix(), "[audit] ");
1119
1120 let seed_id = uuid::Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
1121 let ctx = CommitContext {
1122 tag: Some("memory"),
1123 seed_id: Some(seed_id),
1124 ..Default::default()
1125 };
1126 assert_eq!(ctx.message_prefix(), "[memory] [seed-11111111] ");
1127 }
1128
1129 #[test]
1130 fn test_commit_files_with_context() {
1131 let (dir, layer) = setup();
1132 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1133 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1134
1135 let agent_id = uuid::Uuid::new_v4();
1136 let ctx = CommitContext::agent(agent_id, None);
1137 let info = layer
1138 .commit_files_with(&["a.json", "b.json"], "batch agent work", ctx)
1139 .unwrap();
1140
1141 let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1142 assert_eq!(info.author, expected_author);
1143 }
1144
1145 #[test]
1146 fn test_backward_compat_commit_file_is_oxios() {
1147 let (dir, layer) = setup();
1148 std::fs::write(dir.path().join("compat.json"), b"1").unwrap();
1149 let info = layer.commit_file("compat.json", "compat check").unwrap();
1150 assert_eq!(info.author, "oxios");
1151 }
1152
1153 #[test]
1156 fn test_restore_nested_file() {
1157 let (dir, layer) = setup();
1158
1159 layer
1161 .log_action("agent-X", "write", "secret.txt", true, None)
1162 .unwrap();
1163
1164 let audit_rel = format!("audit/{}.audit", chrono::Utc::now().format("%Y-%m"));
1165 let audit_path = dir.path().join(&audit_rel);
1166 assert!(audit_path.exists(), "audit file should exist");
1167
1168 let _original = std::fs::read_to_string(&audit_path).unwrap();
1170 std::fs::write(&audit_path, "CORRUPTED").unwrap();
1171 layer.commit_file(&audit_rel, "corrupt").unwrap();
1172
1173 let log = layer.log(10).unwrap();
1175 let audit_commit = log
1176 .iter()
1177 .find(|e| e.message.contains("audit: agent-X"))
1178 .expect("should find audit commit");
1179
1180 layer
1181 .restore_file(&audit_rel, &audit_commit.short_hash)
1182 .unwrap();
1183
1184 let restored = std::fs::read_to_string(&audit_path).unwrap();
1185 assert!(restored.contains("agent-X"));
1186 assert!(!restored.contains("CORRUPTED"));
1187 }
1188
1189 #[test]
1192 fn test_list_tags_excludes_non_tags() {
1193 let (dir, layer) = setup();
1194 std::fs::write(dir.path().join("t.json"), b"1").unwrap();
1195 layer.commit_file("t.json", "for tag").unwrap();
1196 layer.tag("release-v1", "first release").unwrap();
1197 let tags = layer.list_tags().unwrap();
1198 assert!(tags.iter().any(|t| t == "release-v1"));
1199 assert!(tags.iter().all(|t| t != "main" && t != "HEAD"));
1200 }
1201
1202 #[test]
1205 fn test_diff_added_file() {
1206 let (dir, layer) = setup();
1207 let first = layer.log(1).unwrap()[0].hash.clone();
1208
1209 std::fs::write(dir.path().join("new.txt"), b"hello\n").unwrap();
1210 let info = layer.commit_file("new.txt", "add file").unwrap();
1211
1212 let diff = layer.diff_commits(&first, &info.hash).unwrap();
1213 assert!(
1214 diff.files
1215 .iter()
1216 .any(|f| f.path == "new.txt" && f.kind == DiffKind::Added)
1217 );
1218 }
1219
1220 #[test]
1221 fn test_diff_modified_file() {
1222 let (dir, layer) = setup();
1223
1224 std::fs::write(dir.path().join("data.txt"), b"v1\n").unwrap();
1225 let first = layer.commit_file("data.txt", "v1").unwrap();
1226
1227 std::fs::write(dir.path().join("data.txt"), b"v2\n").unwrap();
1228 let second = layer.commit_file("data.txt", "v2").unwrap();
1229
1230 let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1231 assert!(
1232 diff.files
1233 .iter()
1234 .any(|f| f.path == "data.txt" && f.kind == DiffKind::Modified)
1235 );
1236
1237 let patch = diff
1238 .files
1239 .iter()
1240 .find(|f| f.path == "data.txt")
1241 .unwrap()
1242 .patch
1243 .as_ref()
1244 .expect("should have patch");
1245 assert!(patch.contains("-v1"));
1246 assert!(patch.contains("+v2"));
1247 }
1248
1249 #[test]
1250 fn test_diff_deleted_file() {
1251 let (dir, layer) = setup();
1252
1253 std::fs::write(dir.path().join("temp.txt"), b"bye\n").unwrap();
1254 let first = layer.commit_file("temp.txt", "add temp").unwrap();
1255
1256 std::fs::remove_file(dir.path().join("temp.txt")).unwrap();
1257 let second = layer.remove_file("temp.txt", "remove temp").unwrap();
1258
1259 let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1260 assert!(
1261 diff.files
1262 .iter()
1263 .any(|f| f.path == "temp.txt" && f.kind == DiffKind::Deleted)
1264 );
1265 }
1266
1267 #[test]
1268 fn test_file_at_commit() {
1269 let (dir, layer) = setup();
1270
1271 std::fs::write(dir.path().join("state.json"), b"{\"v\":1}").unwrap();
1272 let first = layer.commit_file("state.json", "v1").unwrap();
1273
1274 std::fs::write(dir.path().join("state.json"), b"{\"v\":2}").unwrap();
1275 layer.commit_file("state.json", "v2").unwrap();
1276
1277 let content = layer
1278 .file_at_commit("state.json", &first.short_hash)
1279 .unwrap();
1280 assert_eq!(content, b"{\"v\":1}");
1281 }
1282}