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 delete_tag(&self, name: &str) -> Result<()> {
568 if !self.enabled {
569 return Ok(());
570 }
571 let repo = self.repo.lock();
572 let target = repo
575 .references()?
576 .all()?
577 .filter_map(|r| r.ok())
578 .find(|r| {
579 r.name()
580 .category()
581 .is_some_and(|c| matches!(c, gix::refs::Category::Tag))
582 && r.name().shorten() == name
583 })
584 .ok_or_else(|| anyhow::anyhow!("tag not found: {name}"))?;
585 target
586 .delete()
587 .map_err(|e| anyhow::anyhow!("delete tag: {e:#}"))?;
588 Ok(())
589 }
590
591 pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
595 let repo = self.repo.lock();
596 let head_id = repo.head_id()?.detach();
597 let mut entries = Vec::new();
598 let mut current_id: Option<ObjectId> = Some(head_id);
599
600 while let Some(id) = current_id {
601 if entries.len() >= max_count {
602 break;
603 }
604 let commit = repo.find_commit(id)?;
605 let decoded = commit.decode()?;
606 let msg_ref = decoded.message();
607 let msg = if let Some(body) = msg_ref.body {
608 format!("{}\n\n{}", msg_ref.title, body)
609 } else {
610 msg_ref.title.to_string()
611 };
612 let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
613 let author = decoded
614 .author()
615 .map(|a| a.name.to_string())
616 .unwrap_or_default();
617 let hex = id.to_hex().to_string();
618 entries.push(LogEntry {
619 hash: hex.clone(),
620 short_hash: hex[..7].into(),
621 message: msg,
622 timestamp,
623 author,
624 });
625 current_id = decoded.parents().next();
626 }
627
628 Ok(entries)
629 }
630
631 pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
633 if partial.len() < 4 {
634 bail!("Partial hash too short (minimum 4 characters)");
635 }
636 if partial.len() >= 40 {
637 return Ok(ObjectId::from_hex(partial.as_bytes())?);
638 }
639 let repo = self.repo.lock();
640 let id = repo.rev_parse_single(BStr::new(partial))?;
641 Ok(id.detach())
642 }
643
644 fn resolve_hash_inner(&self, repo: &gix::Repository, partial: &str) -> Result<ObjectId> {
646 if partial.len() >= 40 {
647 return Ok(ObjectId::from_hex(partial.as_bytes())?);
648 }
649 if partial.len() < 4 {
650 bail!("Hash too short (minimum 4 characters)");
651 }
652 let id = repo.rev_parse_single(BStr::new(partial))?;
653 Ok(id.detach())
654 }
655
656 pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
663 let dest = self.ensure_within_root(rel_path)?;
666 let commit_id = self.resolve_partial_hash(hash)?;
667 let repo = self.repo.lock();
668 let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
669 let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
670 let blob = repo.find_blob(blob_id)?;
671 std::fs::write(dest, &blob.data)?;
672 Ok(())
673 }
674
675 pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<CommitDiff> {
679 let repo = self.repo.lock();
680 let from_id = self.resolve_hash_inner(&repo, from_hash)?;
681 let to_id = self.resolve_hash_inner(&repo, to_hash)?;
682
683 let from_tree_id = Self::commit_tree_id(&repo, from_id)?;
684 let to_tree_id = Self::commit_tree_id(&repo, to_id)?;
685
686 let mut files = Vec::new();
687 Self::diff_trees(&repo, from_tree_id, to_tree_id, "", &mut files)?;
688
689 for fd in &mut files {
691 let old_data = fd
692 .old_hash
693 .as_ref()
694 .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
695 .and_then(|id| repo.find_blob(id).ok())
696 .map(|b| b.data.to_vec());
697 let new_data = fd
698 .new_hash
699 .as_ref()
700 .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
701 .and_then(|id| repo.find_blob(id).ok())
702 .map(|b| b.data.to_vec());
703
704 match (&old_data, &new_data) {
705 (Some(old), Some(new)) => {
706 fd.patch = compute_unified_diff(old, new, &fd.path);
707 }
708 (None, Some(new)) => {
709 fd.patch = compute_unified_diff(&[], new, &fd.path);
710 }
711 _ => {}
712 }
713 }
714
715 let stats = DiffStats {
716 files_changed: files.len(),
717 additions: files
718 .iter()
719 .filter_map(|f| f.patch.as_ref())
720 .map(|p| {
721 p.lines()
722 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
723 .count()
724 })
725 .sum(),
726 deletions: files
727 .iter()
728 .filter_map(|f| f.patch.as_ref())
729 .map(|p| {
730 p.lines()
731 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
732 .count()
733 })
734 .sum(),
735 };
736
737 Ok(CommitDiff {
738 from_hash: from_id.to_hex().to_string(),
739 to_hash: to_id.to_hex().to_string(),
740 files,
741 stats,
742 })
743 }
744
745 pub fn file_at_commit(&self, rel_path: &str, hash: &str) -> Result<Vec<u8>> {
747 self.ensure_within_root(rel_path)?;
750 let repo = self.repo.lock();
751 let commit_id = self.resolve_hash_inner(&repo, hash)?;
752 let tree_id = Self::commit_tree_id(&repo, commit_id)?;
753 let blob_id = Self::find_blob_in_tree(&repo, tree_id, rel_path)?;
754 let blob = repo.find_blob(blob_id)?;
755 Ok(blob.data.to_vec())
756 }
757
758 fn diff_trees(
762 repo: &gix::Repository,
763 old_tree: ObjectId,
764 new_tree: ObjectId,
765 prefix: &str,
766 changes: &mut Vec<FileDiff>,
767 ) -> Result<()> {
768 let old_tree_obj = repo.find_tree(old_tree)?;
769 let old_decoded = old_tree_obj.decode()?;
770 let new_tree_obj = repo.find_tree(new_tree)?;
771 let new_decoded = new_tree_obj.decode()?;
772
773 let old_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
774 old_decoded
775 .entries
776 .iter()
777 .map(|e| (e.filename, e))
778 .collect();
779 let new_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
780 new_decoded
781 .entries
782 .iter()
783 .map(|e| (e.filename, e))
784 .collect();
785
786 for (name, new_entry) in &new_entries {
788 let path = format!("{prefix}{name}");
789 match old_entries.get(name) {
790 None => {
791 if new_entry.mode.is_tree() {
792 let empty = ObjectId::empty_tree(repo.object_hash());
793 Self::diff_trees(
794 repo,
795 empty,
796 new_entry.oid.to_owned(),
797 &format!("{path}/"),
798 changes,
799 )?;
800 } else {
801 changes.push(FileDiff {
802 path,
803 old_hash: None,
804 new_hash: Some(new_entry.oid.to_hex().to_string()),
805 kind: DiffKind::Added,
806 patch: None,
807 });
808 }
809 }
810 Some(old_entry) => {
811 if old_entry.oid == new_entry.oid {
812 continue;
813 }
814 if new_entry.mode.is_tree() && old_entry.mode.is_tree() {
815 Self::diff_trees(
816 repo,
817 old_entry.oid.to_owned(),
818 new_entry.oid.to_owned(),
819 &format!("{path}/"),
820 changes,
821 )?;
822 } else {
823 changes.push(FileDiff {
824 path,
825 old_hash: Some(old_entry.oid.to_hex().to_string()),
826 new_hash: Some(new_entry.oid.to_hex().to_string()),
827 kind: DiffKind::Modified,
828 patch: None,
829 });
830 }
831 }
832 }
833 }
834
835 for (name, old_entry) in &old_entries {
837 if new_entries.contains_key(name) {
838 continue;
839 }
840 let path = format!("{prefix}{name}");
841 changes.push(FileDiff {
842 path,
843 old_hash: Some(old_entry.oid.to_hex().to_string()),
844 new_hash: None,
845 kind: DiffKind::Deleted,
846 patch: None,
847 });
848 }
849
850 Ok(())
851 }
852
853 pub fn verify(&self) -> Result<bool> {
857 let repo = self.repo.lock();
858 let refs = repo.references()?;
859 for reference in refs.all()? {
860 let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
861 }
862 if repo.head_id().is_err() {
863 tracing::debug!("verify: no HEAD yet (empty repository)");
864 }
865 Ok(true)
866 }
867
868 pub fn is_enabled(&self) -> bool {
870 self.enabled
871 }
872
873 pub fn root(&self) -> &Path {
875 &self.root
876 }
877
878 fn noop_commit(&self, ctx: &CommitContext, message: &str) -> Result<CommitInfo> {
881 Ok(CommitInfo {
882 hash: "(disabled)".into(),
883 short_hash: "(dis)".into(),
884 message: message.into(),
885 timestamp: chrono::Utc::now().to_rfc3339(),
886 author: ctx.author_name(),
887 })
888 }
889
890 fn make_info(&self, id: &gix::Id, message: &str, author: &str) -> CommitInfo {
891 let hex = id.to_hex().to_string();
892 CommitInfo {
893 short_hash: hex[..7].into(),
894 hash: hex,
895 message: message.into(),
896 timestamp: chrono::Utc::now().to_rfc3339(),
897 author: author.into(),
898 }
899 }
900}
901
902fn compute_unified_diff(old: &[u8], new: &[u8], path: &str) -> Option<String> {
906 let old_str = std::str::from_utf8(old).ok()?;
907 let new_str = std::str::from_utf8(new).ok()?;
908
909 use similar::{ChangeTag, TextDiff};
910 let diff = TextDiff::from_lines(old_str, new_str);
911
912 let mut output = format!("--- a/{path}\n+++ b/{path}\n");
913 for change in diff.iter_all_changes() {
914 let prefix = match change.tag() {
915 ChangeTag::Delete => '-',
916 ChangeTag::Insert => '+',
917 ChangeTag::Equal => ' ',
918 };
919 output.push_str(&format!("{prefix}{change}"));
920 }
921
922 Some(output)
923}
924
925#[cfg(test)]
928mod tests {
929 use super::*;
930 use tempfile::TempDir;
931
932 fn setup() -> (TempDir, GitLayer) {
933 let dir = tempfile::tempdir().unwrap();
934 let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
935 (dir, layer)
936 }
937
938 #[test]
939 fn test_init_creates_repo() {
940 let (dir, _) = setup();
941 assert!(dir.path().join(".git").exists());
942 }
943
944 #[test]
945 fn test_commit_file() {
946 let (dir, layer) = setup();
947 std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
948 let info = layer.commit_file("test.json", "test commit").unwrap();
949 assert!(!info.hash.is_empty());
950 assert_eq!(info.short_hash.len(), 7);
951 assert_eq!(info.message, "test commit");
952 assert!(info.hash.starts_with(&info.short_hash));
953 }
954
955 #[test]
956 fn test_log_query() {
957 let (dir, layer) = setup();
958 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
959 layer.commit_file("a.json", "first").unwrap();
960 std::fs::write(dir.path().join("a.json"), b"2").unwrap();
961 layer.commit_file("a.json", "second").unwrap();
962 let log = layer.log(10).unwrap();
963 assert!(log.len() >= 2);
964 assert!(log[0].message.contains("second"));
965 }
966
967 #[test]
968 fn test_tag_create_list() {
969 let (dir, layer) = setup();
970 std::fs::write(dir.path().join("x.json"), b"1").unwrap();
971 layer.commit_file("x.json", "tag test").unwrap();
972 layer.tag("v1", "first tag").unwrap();
973 let tags = layer.list_tags().unwrap();
974 assert!(tags.iter().any(|t| t == "v1"));
975 }
976
977 #[test]
978 fn test_disabled_noop() {
979 let dir = tempfile::tempdir().unwrap();
980 let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
981 std::fs::write(dir.path().join("test.json"), b"1").unwrap();
982 let info = layer.commit_file("test.json", "noop").unwrap();
983 assert_eq!(info.hash, "(disabled)");
984 assert_eq!(info.short_hash, "(dis)");
985 }
986
987 #[test]
988 fn test_log_action() {
989 let (dir, layer) = setup();
990 layer
991 .log_action("agent-A", "read", "file.txt", true, None)
992 .unwrap();
993 let audit_file = dir
994 .path()
995 .join("audit")
996 .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
997 assert!(audit_file.exists());
998 let content = std::fs::read_to_string(&audit_file).unwrap();
999 assert!(content.contains("agent-A"));
1000 assert!(content.contains("ALLOW"));
1001 }
1002
1003 #[test]
1004 fn test_verify() {
1005 let (_, layer) = setup();
1006 assert!(layer.verify().unwrap());
1007 }
1008
1009 #[test]
1010 fn test_remove_file() {
1011 let (dir, layer) = setup();
1012 std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
1013 layer.commit_file("todelete.json", "add file").unwrap();
1014 std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
1015 let info = layer.remove_file("todelete.json", "remove file").unwrap();
1016 assert!(!info.hash.is_empty());
1017 assert!(info.hash != "(disabled)");
1018 }
1019
1020 #[test]
1021 fn test_commit_files_batch() {
1022 let (dir, layer) = setup();
1023 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1024 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1025 let info = layer
1026 .commit_files(&["a.json", "b.json"], "batch commit")
1027 .unwrap();
1028 assert!(!info.hash.is_empty());
1029 assert_eq!(info.message, "batch commit");
1030 }
1031
1032 #[test]
1033 fn test_restore_file() {
1034 let (dir, layer) = setup();
1035 std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
1036 let first = layer.commit_file("state.json", "v1").unwrap();
1037 std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
1038 layer.commit_file("state.json", "v2").unwrap();
1039 layer.restore_file("state.json", &first.short_hash).unwrap();
1040 let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
1041 assert_eq!(content, "v1");
1042 }
1043
1044 #[test]
1045 fn test_gitignore_created() {
1046 let (dir, _) = setup();
1047 assert!(dir.path().join(".gitignore").exists());
1048 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
1049 assert!(content.contains("Oxios"));
1050 }
1051
1052 #[test]
1055 fn test_signature_timestamps_are_fresh() {
1056 let sig1 = Signature::new("a", "a@a");
1060 assert!(!sig1.time.is_empty());
1061
1062 std::thread::sleep(std::time::Duration::from_millis(1100));
1063 let sig3 = Signature::new("c", "c@c");
1064 assert_ne!(
1065 sig1.time, sig3.time,
1066 "Signature created 1s later must have a different timestamp"
1067 );
1068 }
1069
1070 #[test]
1073 fn test_commit_file_with_agent_context() {
1074 let (dir, layer) = setup();
1075 std::fs::write(dir.path().join("agent_work.json"), b"{\"result\":42}").unwrap();
1076
1077 let agent_id = uuid::Uuid::new_v4();
1078 let ctx = CommitContext::agent(agent_id);
1079 layer
1080 .commit_file_with("agent_work.json", "agent did work", ctx)
1081 .unwrap();
1082
1083 let log = layer.log(10).unwrap();
1084 let agent_commit = log
1085 .iter()
1086 .find(|e| e.message.contains("agent did work"))
1087 .expect("should find agent commit");
1088
1089 let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1090 assert_eq!(agent_commit.author, expected_author);
1091 }
1092
1093 #[test]
1094 fn test_commit_file_with_tag() {
1095 let (dir, layer) = setup();
1096 std::fs::write(dir.path().join("audit.json"), b"{\"event\":\"test\"}").unwrap();
1097
1098 let ctx = CommitContext::tagged("audit");
1099 let info = layer
1100 .commit_file_with("audit.json", "flush audit trail", ctx)
1101 .unwrap();
1102
1103 assert!(info.message.contains("[audit]"));
1104 assert!(info.message.contains("flush audit trail"));
1105 }
1106
1107 #[test]
1108 fn test_default_context_is_oxios() {
1109 let (dir, layer) = setup();
1110 std::fs::write(dir.path().join("sys.json"), b"1").unwrap();
1111
1112 let info = layer
1113 .commit_file_with("sys.json", "system commit", CommitContext::default())
1114 .unwrap();
1115
1116 assert_eq!(info.author, "oxios");
1117 }
1118
1119 #[test]
1120 fn test_commit_context_author_name() {
1121 assert_eq!(CommitContext::default().author_name(), "oxios");
1122 assert_eq!(CommitContext::system().author_name(), "oxios");
1123
1124 let id = uuid::Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
1125 assert_eq!(CommitContext::agent(id).author_name(), "agent-aaaaaaaa");
1126
1127 assert_eq!(CommitContext::tagged("memory").author_name(), "oxios");
1128 }
1129
1130 #[test]
1131 fn test_commit_context_message_prefix() {
1132 assert!(CommitContext::default().message_prefix().is_empty());
1133 assert_eq!(CommitContext::tagged("audit").message_prefix(), "[audit] ");
1134
1135 assert_eq!(
1136 CommitContext::tagged("memory").message_prefix(),
1137 "[memory] "
1138 );
1139 }
1140
1141 #[test]
1142 fn test_commit_files_with_context() {
1143 let (dir, layer) = setup();
1144 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1145 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1146
1147 let agent_id = uuid::Uuid::new_v4();
1148 let ctx = CommitContext::agent(agent_id);
1149 let info = layer
1150 .commit_files_with(&["a.json", "b.json"], "batch agent work", ctx)
1151 .unwrap();
1152
1153 let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1154 assert_eq!(info.author, expected_author);
1155 }
1156
1157 #[test]
1158 fn test_backward_compat_commit_file_is_oxios() {
1159 let (dir, layer) = setup();
1160 std::fs::write(dir.path().join("compat.json"), b"1").unwrap();
1161 let info = layer.commit_file("compat.json", "compat check").unwrap();
1162 assert_eq!(info.author, "oxios");
1163 }
1164
1165 #[test]
1168 fn test_restore_nested_file() {
1169 let (dir, layer) = setup();
1170
1171 layer
1173 .log_action("agent-X", "write", "secret.txt", true, None)
1174 .unwrap();
1175
1176 let audit_rel = format!("audit/{}.audit", chrono::Utc::now().format("%Y-%m"));
1177 let audit_path = dir.path().join(&audit_rel);
1178 assert!(audit_path.exists(), "audit file should exist");
1179
1180 let _original = std::fs::read_to_string(&audit_path).unwrap();
1182 std::fs::write(&audit_path, "CORRUPTED").unwrap();
1183 layer.commit_file(&audit_rel, "corrupt").unwrap();
1184
1185 let log = layer.log(10).unwrap();
1187 let audit_commit = log
1188 .iter()
1189 .find(|e| e.message.contains("audit: agent-X"))
1190 .expect("should find audit commit");
1191
1192 layer
1193 .restore_file(&audit_rel, &audit_commit.short_hash)
1194 .unwrap();
1195
1196 let restored = std::fs::read_to_string(&audit_path).unwrap();
1197 assert!(restored.contains("agent-X"));
1198 assert!(!restored.contains("CORRUPTED"));
1199 }
1200
1201 #[test]
1204 fn test_list_tags_excludes_non_tags() {
1205 let (dir, layer) = setup();
1206 std::fs::write(dir.path().join("t.json"), b"1").unwrap();
1207 layer.commit_file("t.json", "for tag").unwrap();
1208 layer.tag("release-v1", "first release").unwrap();
1209 let tags = layer.list_tags().unwrap();
1210 assert!(tags.iter().any(|t| t == "release-v1"));
1211 assert!(tags.iter().all(|t| t != "main" && t != "HEAD"));
1212 }
1213
1214 #[test]
1217 fn test_diff_added_file() {
1218 let (dir, layer) = setup();
1219 let first = layer.log(1).unwrap()[0].hash.clone();
1220
1221 std::fs::write(dir.path().join("new.txt"), b"hello\n").unwrap();
1222 let info = layer.commit_file("new.txt", "add file").unwrap();
1223
1224 let diff = layer.diff_commits(&first, &info.hash).unwrap();
1225 assert!(
1226 diff.files
1227 .iter()
1228 .any(|f| f.path == "new.txt" && f.kind == DiffKind::Added)
1229 );
1230 }
1231
1232 #[test]
1233 fn test_diff_modified_file() {
1234 let (dir, layer) = setup();
1235
1236 std::fs::write(dir.path().join("data.txt"), b"v1\n").unwrap();
1237 let first = layer.commit_file("data.txt", "v1").unwrap();
1238
1239 std::fs::write(dir.path().join("data.txt"), b"v2\n").unwrap();
1240 let second = layer.commit_file("data.txt", "v2").unwrap();
1241
1242 let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1243 assert!(
1244 diff.files
1245 .iter()
1246 .any(|f| f.path == "data.txt" && f.kind == DiffKind::Modified)
1247 );
1248
1249 let patch = diff
1250 .files
1251 .iter()
1252 .find(|f| f.path == "data.txt")
1253 .unwrap()
1254 .patch
1255 .as_ref()
1256 .expect("should have patch");
1257 assert!(patch.contains("-v1"));
1258 assert!(patch.contains("+v2"));
1259 }
1260
1261 #[test]
1262 fn test_diff_deleted_file() {
1263 let (dir, layer) = setup();
1264
1265 std::fs::write(dir.path().join("temp.txt"), b"bye\n").unwrap();
1266 let first = layer.commit_file("temp.txt", "add temp").unwrap();
1267
1268 std::fs::remove_file(dir.path().join("temp.txt")).unwrap();
1269 let second = layer.remove_file("temp.txt", "remove temp").unwrap();
1270
1271 let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1272 assert!(
1273 diff.files
1274 .iter()
1275 .any(|f| f.path == "temp.txt" && f.kind == DiffKind::Deleted)
1276 );
1277 }
1278
1279 #[test]
1280 fn test_file_at_commit() {
1281 let (dir, layer) = setup();
1282
1283 std::fs::write(dir.path().join("state.json"), b"{\"v\":1}").unwrap();
1284 let first = layer.commit_file("state.json", "v1").unwrap();
1285
1286 std::fs::write(dir.path().join("state.json"), b"{\"v\":2}").unwrap();
1287 layer.commit_file("state.json", "v2").unwrap();
1288
1289 let content = layer
1290 .file_at_commit("state.json", &first.short_hash)
1291 .unwrap();
1292 assert_eq!(content, b"{\"v\":1}");
1293 }
1294}