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 if let Ok(existing) = Self::find_blob_in_tree(&repo, head_tree, rel_path)
401 && existing == blob_id
402 {
403 tracing::debug!(path = rel_path, "Skipping identical-content commit");
404 return self.noop_commit(&ctx, message);
405 }
406 let mut editor = repo.edit_tree(head_tree)?;
407 editor.upsert(rel_path, EntryKind::Blob, blob_id)?;
408 let tree_id = editor.write()?;
409
410 let parent = repo.head_id().ok().map(|id| id.detach());
411 let author_name = ctx.author_name();
412 let full_message = format!("{}{}", ctx.message_prefix(), message);
413 let sig = Signature::new(&author_name, &self.committer_email);
414 let commit_id = repo.commit_as(
415 sig.as_ref(),
416 sig.as_ref(),
417 "refs/heads/main",
418 &full_message,
419 tree_id.detach(),
420 parent.into_iter().collect::<Vec<_>>(),
421 )?;
422
423 Ok(self.make_info(&commit_id, &full_message, &author_name))
424 }
425
426 pub fn commit_files(&self, rel_paths: &[&str], message: &str) -> Result<CommitInfo> {
428 self.commit_files_with(rel_paths, message, CommitContext::default())
429 }
430
431 pub fn commit_files_with(
433 &self,
434 rel_paths: &[&str],
435 message: &str,
436 ctx: CommitContext,
437 ) -> Result<CommitInfo> {
438 if !self.enabled {
439 return self.noop_commit(&ctx, message);
440 }
441 let repo = self.repo.lock();
442 let head_tree = Self::head_tree_oid(&repo)?;
443 let mut editor = repo.edit_tree(head_tree)?;
444
445 for path in rel_paths {
446 let abs = self.ensure_within_root(path)?;
447 if abs.exists() {
448 let content = std::fs::read(&abs)?;
449 let blob_id = repo.write_blob(&content)?;
450 editor.upsert(*path, EntryKind::Blob, blob_id)?;
451 }
452 }
453 let tree_id = editor.write()?;
454
455 let parent = repo.head_id().ok().map(|id| id.detach());
456 let author_name = ctx.author_name();
457 let full_message = format!("{}{}", ctx.message_prefix(), message);
458 let sig = Signature::new(&author_name, &self.committer_email);
459 let commit_id = repo.commit_as(
460 sig.as_ref(),
461 sig.as_ref(),
462 "refs/heads/main",
463 &full_message,
464 tree_id.detach(),
465 parent.into_iter().collect::<Vec<_>>(),
466 )?;
467
468 Ok(self.make_info(&commit_id, &full_message, &author_name))
469 }
470
471 pub fn remove_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
473 if !self.enabled {
474 return self.noop_commit(&CommitContext::default(), message);
475 }
476 self.ensure_within_root(rel_path)?;
479 let repo = self.repo.lock();
480 let head_tree = Self::head_tree_oid(&repo)?;
481 let mut editor = repo.edit_tree(head_tree)?;
482 editor.remove(rel_path)?;
483 let tree_id = editor.write()?;
484
485 let parent = repo.head_id().ok().map(|id| id.detach());
486 let sig = Signature::new("oxios", &self.committer_email);
487 let commit_id = repo.commit_as(
488 sig.as_ref(),
489 sig.as_ref(),
490 "refs/heads/main",
491 message,
492 tree_id.detach(),
493 parent.into_iter().collect::<Vec<_>>(),
494 )?;
495
496 Ok(self.make_info(&commit_id, message, "oxios"))
497 }
498
499 pub fn log_action(
501 &self,
502 agent: &str,
503 action: &str,
504 target: &str,
505 allowed: bool,
506 detail: Option<&str>,
507 ) -> Result<()> {
508 let now = chrono::Utc::now();
509 let filename = format!("audit/{}.audit", now.format("%Y-%m"));
510 let entry = format!(
511 "{} | {} | {} | {} | {} | {}\n",
512 now.to_rfc3339(),
513 agent,
514 action,
515 target,
516 if allowed { "ALLOW" } else { "DENY" },
517 detail.unwrap_or("-")
518 );
519 let dir = self.root.join("audit");
520 std::fs::create_dir_all(&dir)?;
521 use std::io::Write;
522 std::fs::OpenOptions::new()
523 .create(true)
524 .append(true)
525 .open(self.root.join(&filename))?
526 .write_all(entry.as_bytes())?;
527 self.commit_file(&filename, &format!("audit: {agent} {action} {target}"))?;
528 Ok(())
529 }
530
531 pub fn tag(&self, name: &str, message: &str) -> Result<()> {
535 if !self.enabled {
536 return Ok(());
537 }
538 let repo = self.repo.lock();
539 let head_id = repo
540 .head_id()
541 .ok()
542 .map(|id| id.detach())
543 .ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
544 let sig = Signature::new("oxios", &self.committer_email);
545 repo.tag(
546 name,
547 head_id,
548 gix::objs::Kind::Commit,
549 Some(sig.as_ref()),
550 message,
551 PreviousValue::MustNotExist,
552 )?;
553 Ok(())
554 }
555
556 pub fn list_tags(&self) -> Result<Vec<String>> {
560 let repo = self.repo.lock();
561 let mut tags = Vec::new();
562 for reference in repo.references()?.all()? {
563 let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
564 if reference
565 .name()
566 .category()
567 .is_some_and(|c| matches!(c, gix::refs::Category::Tag))
568 {
569 tags.push(reference.name().shorten().to_string());
570 }
571 }
572 Ok(tags)
573 }
574
575 pub fn delete_tag(&self, name: &str) -> Result<()> {
577 if !self.enabled {
578 return Ok(());
579 }
580 let repo = self.repo.lock();
581 let target = repo
584 .references()?
585 .all()?
586 .filter_map(|r| r.ok())
587 .find(|r| {
588 r.name()
589 .category()
590 .is_some_and(|c| matches!(c, gix::refs::Category::Tag))
591 && r.name().shorten() == name
592 })
593 .ok_or_else(|| anyhow::anyhow!("tag not found: {name}"))?;
594 target
595 .delete()
596 .map_err(|e| anyhow::anyhow!("delete tag: {e:#}"))?;
597 Ok(())
598 }
599
600 pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
604 let repo = self.repo.lock();
605 let head_id = repo.head_id()?.detach();
606 let mut entries = Vec::new();
607 let mut current_id: Option<ObjectId> = Some(head_id);
608
609 while let Some(id) = current_id {
610 if entries.len() >= max_count {
611 break;
612 }
613 let commit = repo.find_commit(id)?;
614 let decoded = commit.decode()?;
615 let msg_ref = decoded.message();
616 let msg = if let Some(body) = msg_ref.body {
617 format!("{}\n\n{}", msg_ref.title, body)
618 } else {
619 msg_ref.title.to_string()
620 };
621 let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
622 let author = decoded
623 .author()
624 .map(|a| a.name.to_string())
625 .unwrap_or_default();
626 let hex = id.to_hex().to_string();
627 entries.push(LogEntry {
628 hash: hex.clone(),
629 short_hash: hex[..7].into(),
630 message: msg,
631 timestamp,
632 author,
633 });
634 current_id = decoded.parents().next();
635 }
636
637 Ok(entries)
638 }
639
640 pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
642 if partial.len() < 4 {
643 bail!("Partial hash too short (minimum 4 characters)");
644 }
645 if partial.len() >= 40 {
646 return Ok(ObjectId::from_hex(partial.as_bytes())?);
647 }
648 let repo = self.repo.lock();
649 let id = repo.rev_parse_single(BStr::new(partial))?;
650 Ok(id.detach())
651 }
652
653 fn resolve_hash_inner(&self, repo: &gix::Repository, partial: &str) -> Result<ObjectId> {
655 if partial.len() >= 40 {
656 return Ok(ObjectId::from_hex(partial.as_bytes())?);
657 }
658 if partial.len() < 4 {
659 bail!("Hash too short (minimum 4 characters)");
660 }
661 let id = repo.rev_parse_single(BStr::new(partial))?;
662 Ok(id.detach())
663 }
664
665 pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
672 let dest = self.ensure_within_root(rel_path)?;
675 let commit_id = self.resolve_partial_hash(hash)?;
676 let repo = self.repo.lock();
677 let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
678 let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
679 let blob = repo.find_blob(blob_id)?;
680 std::fs::write(dest, &blob.data)?;
681 Ok(())
682 }
683
684 pub fn file_at_commit(&self, rel_path: &str, hash: &str) -> Result<Vec<u8>> {
693 self.ensure_within_root(rel_path)?;
695 let commit_id = self.resolve_partial_hash(hash)?;
696 let repo = self.repo.lock();
697 let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
698 let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
699 let blob = repo.find_blob(blob_id)?;
700 Ok(blob.data.to_vec())
701 }
702
703 pub fn log_for_file(&self, rel_path: &str, max_count: usize) -> Result<Vec<LogEntry>> {
710 if !self.enabled {
711 return Ok(Vec::new());
712 }
713 let repo = self.repo.lock();
714 let head_id = repo.head_id()?.detach();
715 let mut entries = Vec::new();
716 let mut current_id: Option<ObjectId> = Some(head_id);
717 let mut prev_blob: Option<ObjectId> = None;
718
719 while let Some(id) = current_id {
720 if entries.len() >= max_count {
721 break;
722 }
723 let commit = repo.find_commit(id)?;
724 let decoded = commit.decode()?;
725 let tree_id = decoded.tree();
726 let current_blob = Self::find_blob_in_tree(&repo, tree_id, rel_path).ok();
727 if current_blob != prev_blob {
728 let msg_ref = decoded.message();
729 let msg = if let Some(body) = msg_ref.body {
730 format!("{}\n\n{}", msg_ref.title, body)
731 } else {
732 msg_ref.title.to_string()
733 };
734 let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
735 let author = decoded
736 .author()
737 .map(|a| a.name.to_string())
738 .unwrap_or_default();
739 let hex = id.to_hex().to_string();
740 entries.push(LogEntry {
741 hash: hex.clone(),
742 short_hash: hex[..7].into(),
743 message: msg,
744 timestamp,
745 author,
746 });
747 }
748 prev_blob = current_blob;
749 current_id = decoded.parents().next();
750 }
751 Ok(entries)
752 }
753
754 pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<CommitDiff> {
758 let repo = self.repo.lock();
759 let from_id = self.resolve_hash_inner(&repo, from_hash)?;
760 let to_id = self.resolve_hash_inner(&repo, to_hash)?;
761
762 let from_tree_id = Self::commit_tree_id(&repo, from_id)?;
763 let to_tree_id = Self::commit_tree_id(&repo, to_id)?;
764
765 let mut files = Vec::new();
766 Self::diff_trees(&repo, from_tree_id, to_tree_id, "", &mut files)?;
767
768 for fd in &mut files {
770 let old_data = fd
771 .old_hash
772 .as_ref()
773 .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
774 .and_then(|id| repo.find_blob(id).ok())
775 .map(|b| b.data.to_vec());
776 let new_data = fd
777 .new_hash
778 .as_ref()
779 .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
780 .and_then(|id| repo.find_blob(id).ok())
781 .map(|b| b.data.to_vec());
782
783 match (&old_data, &new_data) {
784 (Some(old), Some(new)) => {
785 fd.patch = compute_unified_diff(old, new, &fd.path);
786 }
787 (None, Some(new)) => {
788 fd.patch = compute_unified_diff(&[], new, &fd.path);
789 }
790 _ => {}
791 }
792 }
793
794 let stats = DiffStats {
795 files_changed: files.len(),
796 additions: files
797 .iter()
798 .filter_map(|f| f.patch.as_ref())
799 .map(|p| {
800 p.lines()
801 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
802 .count()
803 })
804 .sum(),
805 deletions: files
806 .iter()
807 .filter_map(|f| f.patch.as_ref())
808 .map(|p| {
809 p.lines()
810 .filter(|l| l.starts_with('-') && !l.starts_with("---"))
811 .count()
812 })
813 .sum(),
814 };
815
816 Ok(CommitDiff {
817 from_hash: from_id.to_hex().to_string(),
818 to_hash: to_id.to_hex().to_string(),
819 files,
820 stats,
821 })
822 }
823
824 fn diff_trees(
830 repo: &gix::Repository,
831 old_tree: ObjectId,
832 new_tree: ObjectId,
833 prefix: &str,
834 changes: &mut Vec<FileDiff>,
835 ) -> Result<()> {
836 let old_tree_obj = repo.find_tree(old_tree)?;
837 let old_decoded = old_tree_obj.decode()?;
838 let new_tree_obj = repo.find_tree(new_tree)?;
839 let new_decoded = new_tree_obj.decode()?;
840
841 let old_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
842 old_decoded
843 .entries
844 .iter()
845 .map(|e| (e.filename, e))
846 .collect();
847 let new_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
848 new_decoded
849 .entries
850 .iter()
851 .map(|e| (e.filename, e))
852 .collect();
853
854 for (name, new_entry) in &new_entries {
856 let path = format!("{prefix}{name}");
857 match old_entries.get(name) {
858 None => {
859 if new_entry.mode.is_tree() {
860 let empty = ObjectId::empty_tree(repo.object_hash());
861 Self::diff_trees(
862 repo,
863 empty,
864 new_entry.oid.to_owned(),
865 &format!("{path}/"),
866 changes,
867 )?;
868 } else {
869 changes.push(FileDiff {
870 path,
871 old_hash: None,
872 new_hash: Some(new_entry.oid.to_hex().to_string()),
873 kind: DiffKind::Added,
874 patch: None,
875 });
876 }
877 }
878 Some(old_entry) => {
879 if old_entry.oid == new_entry.oid {
880 continue;
881 }
882 if new_entry.mode.is_tree() && old_entry.mode.is_tree() {
883 Self::diff_trees(
884 repo,
885 old_entry.oid.to_owned(),
886 new_entry.oid.to_owned(),
887 &format!("{path}/"),
888 changes,
889 )?;
890 } else {
891 changes.push(FileDiff {
892 path,
893 old_hash: Some(old_entry.oid.to_hex().to_string()),
894 new_hash: Some(new_entry.oid.to_hex().to_string()),
895 kind: DiffKind::Modified,
896 patch: None,
897 });
898 }
899 }
900 }
901 }
902
903 for (name, old_entry) in &old_entries {
905 if new_entries.contains_key(name) {
906 continue;
907 }
908 let path = format!("{prefix}{name}");
909 changes.push(FileDiff {
910 path,
911 old_hash: Some(old_entry.oid.to_hex().to_string()),
912 new_hash: None,
913 kind: DiffKind::Deleted,
914 patch: None,
915 });
916 }
917
918 Ok(())
919 }
920
921 pub fn verify(&self) -> Result<bool> {
925 let repo = self.repo.lock();
926 let refs = repo.references()?;
927 for reference in refs.all()? {
928 let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
929 }
930 if repo.head_id().is_err() {
931 tracing::debug!("verify: no HEAD yet (empty repository)");
932 }
933 Ok(true)
934 }
935
936 pub fn is_enabled(&self) -> bool {
938 self.enabled
939 }
940
941 pub fn root(&self) -> &Path {
943 &self.root
944 }
945
946 fn noop_commit(&self, ctx: &CommitContext, message: &str) -> Result<CommitInfo> {
949 Ok(CommitInfo {
950 hash: "(disabled)".into(),
951 short_hash: "(dis)".into(),
952 message: message.into(),
953 timestamp: chrono::Utc::now().to_rfc3339(),
954 author: ctx.author_name(),
955 })
956 }
957
958 fn make_info(&self, id: &gix::Id, message: &str, author: &str) -> CommitInfo {
959 let hex = id.to_hex().to_string();
960 CommitInfo {
961 short_hash: hex[..7].into(),
962 hash: hex,
963 message: message.into(),
964 timestamp: chrono::Utc::now().to_rfc3339(),
965 author: author.into(),
966 }
967 }
968}
969
970fn compute_unified_diff(old: &[u8], new: &[u8], path: &str) -> Option<String> {
974 let old_str = std::str::from_utf8(old).ok()?;
975 let new_str = std::str::from_utf8(new).ok()?;
976
977 use similar::{ChangeTag, TextDiff};
978 let diff = TextDiff::from_lines(old_str, new_str);
979
980 let mut output = format!("--- a/{path}\n+++ b/{path}\n");
981 for change in diff.iter_all_changes() {
982 let prefix = match change.tag() {
983 ChangeTag::Delete => '-',
984 ChangeTag::Insert => '+',
985 ChangeTag::Equal => ' ',
986 };
987 output.push_str(&format!("{prefix}{change}"));
988 }
989
990 Some(output)
991}
992
993#[cfg(test)]
996mod tests {
997 use super::*;
998 use tempfile::TempDir;
999
1000 fn setup() -> (TempDir, GitLayer) {
1001 let dir = tempfile::tempdir().unwrap();
1002 let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
1003 (dir, layer)
1004 }
1005
1006 #[test]
1007 fn test_init_creates_repo() {
1008 let (dir, _) = setup();
1009 assert!(dir.path().join(".git").exists());
1010 }
1011
1012 #[test]
1013 fn test_commit_file() {
1014 let (dir, layer) = setup();
1015 std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
1016 let info = layer.commit_file("test.json", "test commit").unwrap();
1017 assert!(!info.hash.is_empty());
1018 assert_eq!(info.short_hash.len(), 7);
1019 assert_eq!(info.message, "test commit");
1020 assert!(info.hash.starts_with(&info.short_hash));
1021 }
1022
1023 #[test]
1024 fn test_log_query() {
1025 let (dir, layer) = setup();
1026 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1027 layer.commit_file("a.json", "first").unwrap();
1028 std::fs::write(dir.path().join("a.json"), b"2").unwrap();
1029 layer.commit_file("a.json", "second").unwrap();
1030 let log = layer.log(10).unwrap();
1031 assert!(log.len() >= 2);
1032 assert!(log[0].message.contains("second"));
1033 }
1034
1035 #[test]
1036 fn test_tag_create_list() {
1037 let (dir, layer) = setup();
1038 std::fs::write(dir.path().join("x.json"), b"1").unwrap();
1039 layer.commit_file("x.json", "tag test").unwrap();
1040 layer.tag("v1", "first tag").unwrap();
1041 let tags = layer.list_tags().unwrap();
1042 assert!(tags.iter().any(|t| t == "v1"));
1043 }
1044
1045 #[test]
1046 fn test_disabled_noop() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
1049 std::fs::write(dir.path().join("test.json"), b"1").unwrap();
1050 let info = layer.commit_file("test.json", "noop").unwrap();
1051 assert_eq!(info.hash, "(disabled)");
1052 assert_eq!(info.short_hash, "(dis)");
1053 }
1054
1055 #[test]
1056 fn test_log_action() {
1057 let (dir, layer) = setup();
1058 layer
1059 .log_action("agent-A", "read", "file.txt", true, None)
1060 .unwrap();
1061 let audit_file = dir
1062 .path()
1063 .join("audit")
1064 .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
1065 assert!(audit_file.exists());
1066 let content = std::fs::read_to_string(&audit_file).unwrap();
1067 assert!(content.contains("agent-A"));
1068 assert!(content.contains("ALLOW"));
1069 }
1070
1071 #[test]
1072 fn test_verify() {
1073 let (_, layer) = setup();
1074 assert!(layer.verify().unwrap());
1075 }
1076
1077 #[test]
1078 fn test_remove_file() {
1079 let (dir, layer) = setup();
1080 std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
1081 layer.commit_file("todelete.json", "add file").unwrap();
1082 std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
1083 let info = layer.remove_file("todelete.json", "remove file").unwrap();
1084 assert!(!info.hash.is_empty());
1085 assert!(info.hash != "(disabled)");
1086 }
1087
1088 #[test]
1089 fn test_commit_files_batch() {
1090 let (dir, layer) = setup();
1091 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1092 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1093 let info = layer
1094 .commit_files(&["a.json", "b.json"], "batch commit")
1095 .unwrap();
1096 assert!(!info.hash.is_empty());
1097 assert_eq!(info.message, "batch commit");
1098 }
1099
1100 #[test]
1101 fn test_restore_file() {
1102 let (dir, layer) = setup();
1103 std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
1104 let first = layer.commit_file("state.json", "v1").unwrap();
1105 std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
1106 layer.commit_file("state.json", "v2").unwrap();
1107 layer.restore_file("state.json", &first.short_hash).unwrap();
1108 let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
1109 assert_eq!(content, "v1");
1110 }
1111
1112 #[test]
1113 fn test_gitignore_created() {
1114 let (dir, _) = setup();
1115 assert!(dir.path().join(".gitignore").exists());
1116 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
1117 assert!(content.contains("Oxios"));
1118 }
1119
1120 #[test]
1123 fn test_signature_timestamps_are_fresh() {
1124 let sig1 = Signature::new("a", "a@a");
1128 assert!(!sig1.time.is_empty());
1129
1130 std::thread::sleep(std::time::Duration::from_millis(1100));
1131 let sig3 = Signature::new("c", "c@c");
1132 assert_ne!(
1133 sig1.time, sig3.time,
1134 "Signature created 1s later must have a different timestamp"
1135 );
1136 }
1137
1138 #[test]
1141 fn test_commit_file_with_agent_context() {
1142 let (dir, layer) = setup();
1143 std::fs::write(dir.path().join("agent_work.json"), b"{\"result\":42}").unwrap();
1144
1145 let agent_id = uuid::Uuid::new_v4();
1146 let ctx = CommitContext::agent(agent_id);
1147 layer
1148 .commit_file_with("agent_work.json", "agent did work", ctx)
1149 .unwrap();
1150
1151 let log = layer.log(10).unwrap();
1152 let agent_commit = log
1153 .iter()
1154 .find(|e| e.message.contains("agent did work"))
1155 .expect("should find agent commit");
1156
1157 let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1158 assert_eq!(agent_commit.author, expected_author);
1159 }
1160
1161 #[test]
1162 fn test_commit_file_with_tag() {
1163 let (dir, layer) = setup();
1164 std::fs::write(dir.path().join("audit.json"), b"{\"event\":\"test\"}").unwrap();
1165
1166 let ctx = CommitContext::tagged("audit");
1167 let info = layer
1168 .commit_file_with("audit.json", "flush audit trail", ctx)
1169 .unwrap();
1170
1171 assert!(info.message.contains("[audit]"));
1172 assert!(info.message.contains("flush audit trail"));
1173 }
1174
1175 #[test]
1176 fn test_default_context_is_oxios() {
1177 let (dir, layer) = setup();
1178 std::fs::write(dir.path().join("sys.json"), b"1").unwrap();
1179
1180 let info = layer
1181 .commit_file_with("sys.json", "system commit", CommitContext::default())
1182 .unwrap();
1183
1184 assert_eq!(info.author, "oxios");
1185 }
1186
1187 #[test]
1188 fn test_commit_context_author_name() {
1189 assert_eq!(CommitContext::default().author_name(), "oxios");
1190 assert_eq!(CommitContext::system().author_name(), "oxios");
1191
1192 let id = uuid::Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
1193 assert_eq!(CommitContext::agent(id).author_name(), "agent-aaaaaaaa");
1194
1195 assert_eq!(CommitContext::tagged("memory").author_name(), "oxios");
1196 }
1197
1198 #[test]
1199 fn test_commit_context_message_prefix() {
1200 assert!(CommitContext::default().message_prefix().is_empty());
1201 assert_eq!(CommitContext::tagged("audit").message_prefix(), "[audit] ");
1202
1203 assert_eq!(
1204 CommitContext::tagged("memory").message_prefix(),
1205 "[memory] "
1206 );
1207 }
1208
1209 #[test]
1210 fn test_commit_files_with_context() {
1211 let (dir, layer) = setup();
1212 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1213 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1214
1215 let agent_id = uuid::Uuid::new_v4();
1216 let ctx = CommitContext::agent(agent_id);
1217 let info = layer
1218 .commit_files_with(&["a.json", "b.json"], "batch agent work", ctx)
1219 .unwrap();
1220
1221 let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1222 assert_eq!(info.author, expected_author);
1223 }
1224
1225 #[test]
1226 fn test_backward_compat_commit_file_is_oxios() {
1227 let (dir, layer) = setup();
1228 std::fs::write(dir.path().join("compat.json"), b"1").unwrap();
1229 let info = layer.commit_file("compat.json", "compat check").unwrap();
1230 assert_eq!(info.author, "oxios");
1231 }
1232
1233 #[test]
1236 fn test_restore_nested_file() {
1237 let (dir, layer) = setup();
1238
1239 layer
1241 .log_action("agent-X", "write", "secret.txt", true, None)
1242 .unwrap();
1243
1244 let audit_rel = format!("audit/{}.audit", chrono::Utc::now().format("%Y-%m"));
1245 let audit_path = dir.path().join(&audit_rel);
1246 assert!(audit_path.exists(), "audit file should exist");
1247
1248 let _original = std::fs::read_to_string(&audit_path).unwrap();
1250 std::fs::write(&audit_path, "CORRUPTED").unwrap();
1251 layer.commit_file(&audit_rel, "corrupt").unwrap();
1252
1253 let log = layer.log(10).unwrap();
1255 let audit_commit = log
1256 .iter()
1257 .find(|e| e.message.contains("audit: agent-X"))
1258 .expect("should find audit commit");
1259
1260 layer
1261 .restore_file(&audit_rel, &audit_commit.short_hash)
1262 .unwrap();
1263
1264 let restored = std::fs::read_to_string(&audit_path).unwrap();
1265 assert!(restored.contains("agent-X"));
1266 assert!(!restored.contains("CORRUPTED"));
1267 }
1268
1269 #[test]
1272 fn test_list_tags_excludes_non_tags() {
1273 let (dir, layer) = setup();
1274 std::fs::write(dir.path().join("t.json"), b"1").unwrap();
1275 layer.commit_file("t.json", "for tag").unwrap();
1276 layer.tag("release-v1", "first release").unwrap();
1277 let tags = layer.list_tags().unwrap();
1278 assert!(tags.iter().any(|t| t == "release-v1"));
1279 assert!(tags.iter().all(|t| t != "main" && t != "HEAD"));
1280 }
1281
1282 #[test]
1285 fn test_diff_added_file() {
1286 let (dir, layer) = setup();
1287 let first = layer.log(1).unwrap()[0].hash.clone();
1288
1289 std::fs::write(dir.path().join("new.txt"), b"hello\n").unwrap();
1290 let info = layer.commit_file("new.txt", "add file").unwrap();
1291
1292 let diff = layer.diff_commits(&first, &info.hash).unwrap();
1293 assert!(
1294 diff.files
1295 .iter()
1296 .any(|f| f.path == "new.txt" && f.kind == DiffKind::Added)
1297 );
1298 }
1299
1300 #[test]
1301 fn test_diff_modified_file() {
1302 let (dir, layer) = setup();
1303
1304 std::fs::write(dir.path().join("data.txt"), b"v1\n").unwrap();
1305 let first = layer.commit_file("data.txt", "v1").unwrap();
1306
1307 std::fs::write(dir.path().join("data.txt"), b"v2\n").unwrap();
1308 let second = layer.commit_file("data.txt", "v2").unwrap();
1309
1310 let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1311 assert!(
1312 diff.files
1313 .iter()
1314 .any(|f| f.path == "data.txt" && f.kind == DiffKind::Modified)
1315 );
1316
1317 let patch = diff
1318 .files
1319 .iter()
1320 .find(|f| f.path == "data.txt")
1321 .unwrap()
1322 .patch
1323 .as_ref()
1324 .expect("should have patch");
1325 assert!(patch.contains("-v1"));
1326 assert!(patch.contains("+v2"));
1327 }
1328
1329 #[test]
1330 fn test_diff_deleted_file() {
1331 let (dir, layer) = setup();
1332
1333 std::fs::write(dir.path().join("temp.txt"), b"bye\n").unwrap();
1334 let first = layer.commit_file("temp.txt", "add temp").unwrap();
1335
1336 std::fs::remove_file(dir.path().join("temp.txt")).unwrap();
1337 let second = layer.remove_file("temp.txt", "remove temp").unwrap();
1338
1339 let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1340 assert!(
1341 diff.files
1342 .iter()
1343 .any(|f| f.path == "temp.txt" && f.kind == DiffKind::Deleted)
1344 );
1345 }
1346
1347 #[test]
1348 fn test_file_at_commit() {
1349 let (dir, layer) = setup();
1350
1351 std::fs::write(dir.path().join("state.json"), b"{\"v\":1}").unwrap();
1352 let first = layer.commit_file("state.json", "v1").unwrap();
1353
1354 std::fs::write(dir.path().join("state.json"), b"{\"v\":2}").unwrap();
1355 layer.commit_file("state.json", "v2").unwrap();
1356
1357 let content = layer
1358 .file_at_commit("state.json", &first.short_hash)
1359 .unwrap();
1360 assert_eq!(content, b"{\"v\":1}");
1361 }
1362}