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.head_id().ok()
258 .map(|id| id.detach())
259 .ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
260 let _sig = self_signature_ref();
261 repo.tag(
262 name,
263 head_id,
264 gix::objs::Kind::Commit,
265 Some(_sig),
266 message,
267 PreviousValue::MustNotExist,
268 )?;
269 Ok(())
270 }
271
272 pub fn list_tags(&self) -> Result<Vec<String>> {
274 let repo = self.repo.lock();
275 let mut tags = Vec::new();
276 for reference in repo.references()?.all()? {
277 let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
278 let name = reference.name().shorten().to_string();
279 if name.starts_with("tags/") || (!name.contains('/') && !name.is_empty()) {
280 let tag_name = name.strip_prefix("tags/").unwrap_or(&name);
281 tags.push(tag_name.to_string());
282 }
283 }
284 Ok(tags)
285 }
286
287 pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
289 let repo = self.repo.lock();
290 let head_id = repo.head_id()?.detach();
291 let mut entries = Vec::new();
292 let mut current_id: Option<ObjectId> = Some(head_id);
293
294 while let Some(id) = current_id {
295 if entries.len() >= max_count {
296 break;
297 }
298 let commit = repo.find_commit(id)?;
299 let decoded = commit.decode()?;
300 let msg = decoded.message.to_string();
301 let timestamp = format!("{:?}", decoded.committer.time);
302 let author = decoded.author.name.to_string();
303 let hex = id.to_hex().to_string();
304 entries.push(LogEntry {
305 hash: hex.clone(),
306 short_hash: hex[..7].into(),
307 message: msg,
308 timestamp,
309 author,
310 });
311 current_id = decoded.parents().next();
313 }
314
315 Ok(entries)
316 }
317
318 pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
330 if partial.len() < 4 {
331 bail!("Partial hash too short (minimum 4 characters)");
332 }
333 if partial.len() >= 40 {
335 return Ok(ObjectId::from_hex(partial.as_bytes())?);
337 }
338 let repo = self.repo.lock();
340 let id = repo.rev_parse_single(BStr::new(partial))?;
342 Ok(id.detach())
343 }
344
345 pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
347 let commit_id = self.resolve_partial_hash(hash)?;
348 let repo = self.repo.lock();
349 let commit = repo.find_commit(commit_id)?;
350 let decoded = commit.decode()?;
351 let tree_id = ObjectId::from_hex(decoded.tree.as_bytes())?;
352 let tree = repo.find_tree(tree_id)?;
353 let decoded_tree = tree.decode()?;
354
355 let rel_bytes = BStr::new(rel_path);
357 let entry = decoded_tree
358 .entries
359 .iter()
360 .find(|e| e.filename == rel_bytes)
361 .ok_or_else(|| anyhow::anyhow!("Path {} not found in commit {}", rel_path, hash))?;
362
363 let blob = repo.find_blob(entry.oid.to_owned())?;
364 std::fs::write(self.root.join(rel_path), &blob.data)?;
365 Ok(())
366 }
367
368 pub fn verify(&self) -> Result<bool> {
370 let repo = self.repo.lock();
371 let refs = repo.references()?;
372 for reference in refs.all()? {
373 let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
374 }
375 if repo.head_id().is_err() {
377 tracing::debug!("verify: no HEAD yet (empty repository)");
378 }
379 Ok(true)
380 }
381
382 pub fn is_enabled(&self) -> bool {
384 self.enabled
385 }
386
387 fn head_tree_oid(repo: &gix::Repository) -> Result<ObjectId> {
391 match Self::head_id_detached_raw(repo) {
392 Some(id) => {
393 let commit = repo.find_commit(id)?;
394 let decoded = commit.decode()?;
395 let oid = ObjectId::from_hex(decoded.tree)?;
396 Ok(oid)
397 }
398 None => Ok(ObjectId::empty_tree(repo.object_hash())),
399 }
400 }
401
402 fn head_id_detached_raw(repo: &gix::Repository) -> Option<ObjectId> {
404 repo.head_id().ok().map(|id| id.detach())
405 }
406
407 fn noop_commit(&self, message: &str) -> Result<CommitInfo> {
408 Ok(CommitInfo {
409 hash: "(disabled)".into(),
410 short_hash: "(dis)".into(),
411 message: message.into(),
412 timestamp: chrono::Utc::now().to_rfc3339(),
413 author: "oxios".into(),
414 })
415 }
416
417 fn make_info(&self, id: &gix::Id, message: &str) -> CommitInfo {
418 let hex = id.to_hex().to_string();
419 CommitInfo {
420 short_hash: hex[..7].into(),
421 hash: hex,
422 message: message.into(),
423 timestamp: chrono::Utc::now().to_rfc3339(),
424 author: self.committer_name.clone(),
425 }
426 }
427}
428
429fn self_signature_ref() -> gix::actor::SignatureRef<'static> {
431 gix::actor::SignatureRef {
432 name: "oxios".into(),
433 email: "oxios@oxios".into(),
434 time: gix::date::Time::now_local_or_utc(),
435 }
436}
437
438#[cfg(test)]
441mod tests {
442 use super::*;
443 use tempfile::TempDir;
444
445 fn setup() -> (TempDir, GitLayer) {
446 let dir = tempfile::tempdir().unwrap();
447 let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
448 (dir, layer)
449 }
450
451 #[test]
452 fn test_init_creates_repo() {
453 let (dir, _) = setup();
454 assert!(dir.path().join(".git").exists());
455 }
456
457 #[test]
458 fn test_commit_file() {
459 let (dir, layer) = setup();
460 std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
461 let info = layer.commit_file("test.json", "test commit").unwrap();
462 assert!(!info.hash.is_empty());
463 assert_eq!(info.short_hash.len(), 7);
464 assert_eq!(info.message, "test commit");
465 assert!(info.hash.starts_with(&info.short_hash));
466 }
467
468 #[test]
469 fn test_log_query() {
470 let (dir, layer) = setup();
471 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
472 layer.commit_file("a.json", "first").unwrap();
473 std::fs::write(dir.path().join("a.json"), b"2").unwrap();
474 layer.commit_file("a.json", "second").unwrap();
475 let log = layer.log(10).unwrap();
476 assert!(log.len() >= 2);
477 assert!(log[0].message.contains("second"));
478 }
479
480 #[test]
481 fn test_tag_create_list() {
482 let (dir, layer) = setup();
483 std::fs::write(dir.path().join("x.json"), b"1").unwrap();
484 layer.commit_file("x.json", "tag test").unwrap();
485 layer.tag("v1", "first tag").unwrap();
486 let tags = layer.list_tags().unwrap();
487 assert!(tags.iter().any(|t| t.contains("v1")));
488 }
489
490 #[test]
491 fn test_disabled_noop() {
492 let dir = tempfile::tempdir().unwrap();
493 let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
494 std::fs::write(dir.path().join("test.json"), b"1").unwrap();
495 let info = layer.commit_file("test.json", "noop").unwrap();
496 assert_eq!(info.hash, "(disabled)");
497 assert_eq!(info.short_hash, "(dis)");
498 }
499
500 #[test]
501 fn test_log_action() {
502 let (dir, layer) = setup();
503 layer
504 .log_action("agent-A", "read", "file.txt", true, None)
505 .unwrap();
506 let audit_file = dir
507 .path()
508 .join("audit")
509 .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
510 assert!(audit_file.exists());
511 let content = std::fs::read_to_string(&audit_file).unwrap();
512 assert!(content.contains("agent-A"));
513 assert!(content.contains("ALLOW"));
514 }
515
516 #[test]
517 fn test_verify() {
518 let (_, layer) = setup();
519 assert!(layer.verify().unwrap());
520 }
521
522 #[test]
523 fn test_remove_file() {
524 let (dir, layer) = setup();
525 std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
526 layer.commit_file("todelete.json", "add file").unwrap();
527 std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
528 let info = layer.remove_file("todelete.json", "remove file").unwrap();
529 assert!(!info.hash.is_empty());
530 assert!(info.hash != "(disabled)");
531 }
532
533 #[test]
534 fn test_commit_files_batch() {
535 let (dir, layer) = setup();
536 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
537 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
538 let info = layer
539 .commit_files(&["a.json", "b.json"], "batch commit")
540 .unwrap();
541 assert!(!info.hash.is_empty());
542 assert_eq!(info.message, "batch commit");
543 }
544
545 #[test]
546 fn test_restore_file() {
547 let (dir, layer) = setup();
548 std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
549 let first = layer.commit_file("state.json", "v1").unwrap();
550 std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
551 layer.commit_file("state.json", "v2").unwrap();
552 layer.restore_file("state.json", &first.short_hash).unwrap();
553 let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
554 assert_eq!(content, "v1");
555 }
556
557 #[test]
558 fn test_gitignore_created() {
559 let (dir, _) = setup();
560 assert!(dir.path().join(".gitignore").exists());
561 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
562 assert!(content.contains("Oxios"));
563 }
564}