1use anyhow::{bail, Result};
5use gix::bstr::{BStr, ByteSlice};
6use gix::hash::ObjectId;
7use gix::objs::tree::EntryKind;
8use gix::refs::transaction::PreviousValue;
9use parking_lot::Mutex;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13const GITIGNORE: &str = r#"# Oxios
14*.tmp
15*.lock
16.env
17api-keys.json
18"#;
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct CommitInfo {
23 pub hash: String,
25 pub short_hash: String,
27 pub message: String,
29 pub timestamp: String,
31 pub author: String,
33}
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct LogEntry {
38 pub hash: String,
40 pub short_hash: String,
42 pub message: String,
44 pub timestamp: String,
46 pub author: String,
48}
49
50pub struct GitLayer {
55 repo: Arc<Mutex<gix::Repository>>,
56 root: PathBuf,
57 committer_name: String,
58 #[allow(dead_code)]
59 committer_email: String,
60 enabled: bool,
61}
62
63impl GitLayer {
64 pub fn new(root: PathBuf, enabled: bool) -> Result<Self> {
66 let repo = if root.join(".git").exists() {
67 gix::open(&root)?
68 } else {
69 std::fs::create_dir_all(&root)?;
70 gix::init(&root)?
71 };
72
73 let gitignore = root.join(".gitignore");
75 if !gitignore.exists() {
76 std::fs::write(&gitignore, GITIGNORE)?;
77 }
78
79 let repo_ref = Arc::new(Mutex::new(repo));
80
81 if Self::head_id_detached(&repo_ref).is_none() {
83 Self::create_initial_commit(&repo_ref, &root)?;
84 }
85
86 Ok(Self {
87 repo: repo_ref,
88 root,
89 committer_name: "oxios".into(),
90 committer_email: "oxios@oxios".into(),
91 enabled,
92 })
93 }
94
95 fn head_id_detached(repo_arc: &Arc<Mutex<gix::Repository>>) -> Option<ObjectId> {
97 let repo = repo_arc.lock();
98 repo.head_id().ok().map(|id| id.detach())
99 }
100
101 fn create_initial_commit(repo: &Arc<Mutex<gix::Repository>>, root: &Path) -> Result<()> {
102 let repo_lock = repo.lock();
103 let gitignore = root.join(".gitignore");
104 let content = std::fs::read(&gitignore)?;
105 let blob_id = repo_lock.write_blob(&content)?;
106 let empty_tree = ObjectId::empty_tree(repo_lock.object_hash());
107 let mut editor = repo_lock.edit_tree(empty_tree)?;
108 editor.upsert(".gitignore", EntryKind::Blob, blob_id)?;
109 let tree_id = editor.write()?;
110 let _sig = self_signature_ref();
111 repo_lock.commit_as(
112 self_signature_ref(),
113 self_signature_ref(),
114 "refs/heads/main",
115 "Initial commit",
116 tree_id.detach(),
117 Vec::<ObjectId>::new(),
118 )?;
119 Ok(())
120 }
121
122 pub fn commit_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
124 if !self.enabled {
125 return self.noop_commit(message);
126 }
127 let repo = self.repo.lock();
128 let abs = self.root.join(rel_path);
129 if !abs.exists() {
130 bail!("File not found: {}", rel_path);
131 }
132
133 let content = std::fs::read(&abs)?;
134 let blob_id = repo.write_blob(&content)?;
135 let head_tree = Self::head_tree_oid(&repo)?;
136 let mut editor = repo.edit_tree(head_tree)?;
137 editor.upsert(rel_path, EntryKind::Blob, blob_id)?;
138 let tree_id = editor.write()?;
139
140 let parent = repo.head_id().ok().map(|id| id.detach());
143 let _sig = self_signature_ref();
144 let commit_id = repo.commit_as(
145 self_signature_ref(),
146 self_signature_ref(),
147 "refs/heads/main",
148 message,
149 tree_id.detach(),
150 parent.into_iter().collect::<Vec<_>>(),
151 )?;
152
153 Ok(self.make_info(&commit_id, message))
154 }
155
156 pub fn commit_files(&self, rel_paths: &[&str], message: &str) -> Result<CommitInfo> {
158 if !self.enabled {
159 return self.noop_commit(message);
160 }
161 let repo = self.repo.lock();
162 let head_tree = Self::head_tree_oid(&repo)?;
163 let mut editor = repo.edit_tree(head_tree)?;
164
165 for path in rel_paths {
166 let abs = self.root.join(path);
167 if abs.exists() {
168 let content = std::fs::read(&abs)?;
169 let blob_id = repo.write_blob(&content)?;
170 editor.upsert(*path, EntryKind::Blob, blob_id)?;
171 }
172 }
173 let tree_id = editor.write()?;
174
175 let parent = repo.head_id().ok().map(|id| id.detach());
178 let _sig = self_signature_ref();
179 let commit_id = repo.commit_as(
180 self_signature_ref(),
181 self_signature_ref(),
182 "refs/heads/main",
183 message,
184 tree_id.detach(),
185 parent.into_iter().collect::<Vec<_>>(),
186 )?;
187
188 Ok(self.make_info(&commit_id, message))
189 }
190
191 pub fn remove_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
193 if !self.enabled {
194 return self.noop_commit(message);
195 }
196 let repo = self.repo.lock();
197 let head_tree = Self::head_tree_oid(&repo)?;
198 let mut editor = repo.edit_tree(head_tree)?;
199 editor.remove(rel_path)?;
200 let tree_id = editor.write()?;
201
202 let parent = repo.head_id().ok().map(|id| id.detach());
203 let _sig = self_signature_ref();
204 let commit_id = repo.commit_as(
205 self_signature_ref(),
206 self_signature_ref(),
207 "refs/heads/main",
208 message,
209 tree_id.detach(),
210 parent.into_iter().collect::<Vec<_>>(),
211 )?;
212
213 Ok(self.make_info(&commit_id, message))
214 }
215
216 pub fn log_action(
218 &self,
219 agent: &str,
220 action: &str,
221 target: &str,
222 allowed: bool,
223 detail: Option<&str>,
224 ) -> Result<()> {
225 let now = chrono::Utc::now();
226 let filename = format!("audit/{}.audit", now.format("%Y-%m"));
227 let entry = format!(
228 "{} | {} | {} | {} | {} | {}\n",
229 now.to_rfc3339(),
230 agent,
231 action,
232 target,
233 if allowed { "ALLOW" } else { "DENY" },
234 detail.unwrap_or("-")
235 );
236 let dir = self.root.join("audit");
237 std::fs::create_dir_all(&dir)?;
238 use std::io::Write;
239 std::fs::OpenOptions::new()
240 .create(true)
241 .append(true)
242 .open(self.root.join(&filename))?
243 .write_all(entry.as_bytes())?;
244 self.commit_file(
245 &filename,
246 &format!("audit: {} {} {}", agent, action, target),
247 )?;
248 Ok(())
249 }
250
251 pub fn tag(&self, name: &str, message: &str) -> Result<()> {
253 if !self.enabled {
254 return Ok(());
255 }
256 let repo = self.repo.lock();
257 let head_id = repo
258 .head_id()
259 .ok()
260 .map(|id| id.detach())
261 .ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
262 let _sig = self_signature_ref();
263 repo.tag(
264 name,
265 head_id,
266 gix::objs::Kind::Commit,
267 Some(_sig),
268 message,
269 PreviousValue::MustNotExist,
270 )?;
271 Ok(())
272 }
273
274 pub fn list_tags(&self) -> Result<Vec<String>> {
276 let repo = self.repo.lock();
277 let mut tags = Vec::new();
278 for reference in repo.references()?.all()? {
279 let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
280 let name = reference.name().shorten().to_string();
281 if name.starts_with("tags/") || (!name.contains('/') && !name.is_empty()) {
282 let tag_name = name.strip_prefix("tags/").unwrap_or(&name);
283 tags.push(tag_name.to_string());
284 }
285 }
286 Ok(tags)
287 }
288
289 pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
291 let repo = self.repo.lock();
292 let head_id = repo.head_id()?.detach();
293 let mut entries = Vec::new();
294 let mut current_id: Option<ObjectId> = Some(head_id);
295
296 while let Some(id) = current_id {
297 if entries.len() >= max_count {
298 break;
299 }
300 let commit = repo.find_commit(id)?;
301 let decoded = commit.decode()?;
302 let msg_ref = decoded.message();
303 let msg = if let Some(body) = msg_ref.body {
304 format!("{}\n\n{}", msg_ref.title, body)
305 } else {
306 msg_ref.title.to_string()
307 };
308 let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
309 let author = decoded
310 .author()
311 .map(|a| a.name.to_string())
312 .unwrap_or_default();
313 let hex = id.to_hex().to_string();
314 entries.push(LogEntry {
315 hash: hex.clone(),
316 short_hash: hex[..7].into(),
317 message: msg,
318 timestamp,
319 author,
320 });
321 current_id = decoded.parents().next();
323 }
324
325 Ok(entries)
326 }
327
328 pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
340 if partial.len() < 4 {
341 bail!("Partial hash too short (minimum 4 characters)");
342 }
343 if partial.len() >= 40 {
345 return Ok(ObjectId::from_hex(partial.as_bytes())?);
347 }
348 let repo = self.repo.lock();
350 let id = repo.rev_parse_single(BStr::new(partial))?;
352 Ok(id.detach())
353 }
354
355 pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
357 let commit_id = self.resolve_partial_hash(hash)?;
358 let repo = self.repo.lock();
359 let commit = repo.find_commit(commit_id)?;
360 let decoded = commit.decode()?;
361 let tree_id = ObjectId::from_hex(decoded.tree.as_bytes())?;
362 let tree = repo.find_tree(tree_id)?;
363 let decoded_tree = tree.decode()?;
364
365 let rel_bytes = BStr::new(rel_path);
367 let entry = decoded_tree
368 .entries
369 .iter()
370 .find(|e| e.filename == rel_bytes)
371 .ok_or_else(|| anyhow::anyhow!("Path {} not found in commit {}", rel_path, hash))?;
372
373 let blob = repo.find_blob(entry.oid.to_owned())?;
374 std::fs::write(self.root.join(rel_path), &blob.data)?;
375 Ok(())
376 }
377
378 pub fn verify(&self) -> Result<bool> {
380 let repo = self.repo.lock();
381 let refs = repo.references()?;
382 for reference in refs.all()? {
383 let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
384 }
385 if repo.head_id().is_err() {
387 tracing::debug!("verify: no HEAD yet (empty repository)");
388 }
389 Ok(true)
390 }
391
392 pub fn is_enabled(&self) -> bool {
394 self.enabled
395 }
396
397 fn head_tree_oid(repo: &gix::Repository) -> Result<ObjectId> {
401 match Self::head_id_detached_raw(repo) {
402 Some(id) => {
403 let commit = repo.find_commit(id)?;
404 let decoded = commit.decode()?;
405 let oid = ObjectId::from_hex(decoded.tree)?;
406 Ok(oid)
407 }
408 None => Ok(ObjectId::empty_tree(repo.object_hash())),
409 }
410 }
411
412 fn head_id_detached_raw(repo: &gix::Repository) -> Option<ObjectId> {
414 repo.head_id().ok().map(|id| id.detach())
415 }
416
417 fn noop_commit(&self, message: &str) -> Result<CommitInfo> {
418 Ok(CommitInfo {
419 hash: "(disabled)".into(),
420 short_hash: "(dis)".into(),
421 message: message.into(),
422 timestamp: chrono::Utc::now().to_rfc3339(),
423 author: "oxios".into(),
424 })
425 }
426
427 fn make_info(&self, id: &gix::Id, message: &str) -> CommitInfo {
428 let hex = id.to_hex().to_string();
429 CommitInfo {
430 short_hash: hex[..7].into(),
431 hash: hex,
432 message: message.into(),
433 timestamp: chrono::Utc::now().to_rfc3339(),
434 author: self.committer_name.clone(),
435 }
436 }
437}
438
439fn self_signature_ref() -> gix::actor::SignatureRef<'static> {
441 static TIME_BUF: std::sync::OnceLock<String> = std::sync::OnceLock::new();
442 let time_str = TIME_BUF.get_or_init(|| gix::date::Time::now_local_or_utc().to_string());
443 gix::actor::SignatureRef {
444 name: "oxios".into(),
445 email: "oxios@oxios".into(),
446 time: time_str.as_str(),
447 }
448}
449
450#[cfg(test)]
453mod tests {
454 use super::*;
455 use tempfile::TempDir;
456
457 fn setup() -> (TempDir, GitLayer) {
458 let dir = tempfile::tempdir().unwrap();
459 let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
460 (dir, layer)
461 }
462
463 #[test]
464 fn test_init_creates_repo() {
465 let (dir, _) = setup();
466 assert!(dir.path().join(".git").exists());
467 }
468
469 #[test]
470 fn test_commit_file() {
471 let (dir, layer) = setup();
472 std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
473 let info = layer.commit_file("test.json", "test commit").unwrap();
474 assert!(!info.hash.is_empty());
475 assert_eq!(info.short_hash.len(), 7);
476 assert_eq!(info.message, "test commit");
477 assert!(info.hash.starts_with(&info.short_hash));
478 }
479
480 #[test]
481 fn test_log_query() {
482 let (dir, layer) = setup();
483 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
484 layer.commit_file("a.json", "first").unwrap();
485 std::fs::write(dir.path().join("a.json"), b"2").unwrap();
486 layer.commit_file("a.json", "second").unwrap();
487 let log = layer.log(10).unwrap();
488 assert!(log.len() >= 2);
489 assert!(log[0].message.contains("second"));
490 }
491
492 #[test]
493 fn test_tag_create_list() {
494 let (dir, layer) = setup();
495 std::fs::write(dir.path().join("x.json"), b"1").unwrap();
496 layer.commit_file("x.json", "tag test").unwrap();
497 layer.tag("v1", "first tag").unwrap();
498 let tags = layer.list_tags().unwrap();
499 assert!(tags.iter().any(|t| t.contains("v1")));
500 }
501
502 #[test]
503 fn test_disabled_noop() {
504 let dir = tempfile::tempdir().unwrap();
505 let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
506 std::fs::write(dir.path().join("test.json"), b"1").unwrap();
507 let info = layer.commit_file("test.json", "noop").unwrap();
508 assert_eq!(info.hash, "(disabled)");
509 assert_eq!(info.short_hash, "(dis)");
510 }
511
512 #[test]
513 fn test_log_action() {
514 let (dir, layer) = setup();
515 layer
516 .log_action("agent-A", "read", "file.txt", true, None)
517 .unwrap();
518 let audit_file = dir
519 .path()
520 .join("audit")
521 .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
522 assert!(audit_file.exists());
523 let content = std::fs::read_to_string(&audit_file).unwrap();
524 assert!(content.contains("agent-A"));
525 assert!(content.contains("ALLOW"));
526 }
527
528 #[test]
529 fn test_verify() {
530 let (_, layer) = setup();
531 assert!(layer.verify().unwrap());
532 }
533
534 #[test]
535 fn test_remove_file() {
536 let (dir, layer) = setup();
537 std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
538 layer.commit_file("todelete.json", "add file").unwrap();
539 std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
540 let info = layer.remove_file("todelete.json", "remove file").unwrap();
541 assert!(!info.hash.is_empty());
542 assert!(info.hash != "(disabled)");
543 }
544
545 #[test]
546 fn test_commit_files_batch() {
547 let (dir, layer) = setup();
548 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
549 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
550 let info = layer
551 .commit_files(&["a.json", "b.json"], "batch commit")
552 .unwrap();
553 assert!(!info.hash.is_empty());
554 assert_eq!(info.message, "batch commit");
555 }
556
557 #[test]
558 fn test_restore_file() {
559 let (dir, layer) = setup();
560 std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
561 let first = layer.commit_file("state.json", "v1").unwrap();
562 std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
563 layer.commit_file("state.json", "v2").unwrap();
564 layer.restore_file("state.json", &first.short_hash).unwrap();
565 let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
566 assert_eq!(content, "v1");
567 }
568
569 #[test]
570 fn test_gitignore_created() {
571 let (dir, _) = setup();
572 assert!(dir.path().join(".gitignore").exists());
573 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
574 assert!(content.contains("Oxios"));
575 }
576}