1use anyhow::{bail, Result};
5use gix::bstr::BStr;
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 = Self::head_id_detached(&self.repo);
141 let _sig = self_signature_ref();
142 let commit_id = repo.commit_as(
143 self_signature_ref(),
144 self_signature_ref(),
145 "refs/heads/main",
146 message,
147 tree_id.detach(),
148 parent.into_iter().collect::<Vec<_>>(),
149 )?;
150
151 Ok(self.make_info(&commit_id, message))
152 }
153
154 pub fn commit_files(&self, rel_paths: &[&str], message: &str) -> Result<CommitInfo> {
156 if !self.enabled {
157 return self.noop_commit(message);
158 }
159 let repo = self.repo.lock();
160 let head_tree = Self::head_tree_oid(&repo)?;
161 let mut editor = repo.edit_tree(head_tree)?;
162
163 for path in rel_paths {
164 let abs = self.root.join(path);
165 if abs.exists() {
166 let content = std::fs::read(&abs)?;
167 let blob_id = repo.write_blob(&content)?;
168 editor.upsert(*path, EntryKind::Blob, blob_id)?;
169 }
170 }
171 let tree_id = editor.write()?;
172
173 let parent = Self::head_id_detached(&self.repo);
174 let _sig = self_signature_ref();
175 let commit_id = repo.commit_as(
176 self_signature_ref(),
177 self_signature_ref(),
178 "refs/heads/main",
179 message,
180 tree_id.detach(),
181 parent.into_iter().collect::<Vec<_>>(),
182 )?;
183
184 Ok(self.make_info(&commit_id, message))
185 }
186
187 pub fn remove_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
189 if !self.enabled {
190 return self.noop_commit(message);
191 }
192 let repo = self.repo.lock();
193 let head_tree = Self::head_tree_oid(&repo)?;
194 let mut editor = repo.edit_tree(head_tree)?;
195 editor.remove(rel_path)?;
196 let tree_id = editor.write()?;
197
198 let parent = Self::head_id_detached(&self.repo);
199 let _sig = self_signature_ref();
200 let commit_id = repo.commit_as(
201 self_signature_ref(),
202 self_signature_ref(),
203 "refs/heads/main",
204 message,
205 tree_id.detach(),
206 parent.into_iter().collect::<Vec<_>>(),
207 )?;
208
209 Ok(self.make_info(&commit_id, message))
210 }
211
212 pub fn log_action(
214 &self,
215 agent: &str,
216 action: &str,
217 target: &str,
218 allowed: bool,
219 detail: Option<&str>,
220 ) -> Result<()> {
221 let now = chrono::Utc::now();
222 let filename = format!("audit/{}.audit", now.format("%Y-%m"));
223 let entry = format!(
224 "{} | {} | {} | {} | {} | {}\n",
225 now.to_rfc3339(),
226 agent,
227 action,
228 target,
229 if allowed { "ALLOW" } else { "DENY" },
230 detail.unwrap_or("-")
231 );
232 let dir = self.root.join("audit");
233 std::fs::create_dir_all(&dir)?;
234 use std::io::Write;
235 std::fs::OpenOptions::new()
236 .create(true)
237 .append(true)
238 .open(self.root.join(&filename))?
239 .write_all(entry.as_bytes())?;
240 self.commit_file(
241 &filename,
242 &format!("audit: {} {} {}", agent, action, target),
243 )?;
244 Ok(())
245 }
246
247 pub fn tag(&self, name: &str, message: &str) -> Result<()> {
249 if !self.enabled {
250 return Ok(());
251 }
252 let repo = self.repo.lock();
253 let head_id = Self::head_id_detached(&self.repo)
254 .ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
255 let _sig = self_signature_ref();
256 repo.tag(
257 name,
258 head_id,
259 gix::objs::Kind::Commit,
260 Some(_sig),
261 message,
262 PreviousValue::MustNotExist,
263 )?;
264 Ok(())
265 }
266
267 pub fn list_tags(&self) -> Result<Vec<String>> {
269 let repo = self.repo.lock();
270 let mut tags = Vec::new();
271 for reference in repo.references()?.all()? {
272 let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
273 let name = reference.name().shorten().to_string();
274 if name.starts_with("tags/") || (!name.contains('/') && !name.is_empty()) {
275 let tag_name = name.strip_prefix("tags/").unwrap_or(&name);
276 tags.push(tag_name.to_string());
277 }
278 }
279 Ok(tags)
280 }
281
282 pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
284 let repo = self.repo.lock();
285 let head_id = repo.head_id()?.detach();
286 let mut entries = Vec::new();
287 let mut current_id: Option<ObjectId> = Some(head_id);
288
289 while let Some(id) = current_id {
290 if entries.len() >= max_count {
291 break;
292 }
293 let commit = repo.find_commit(id)?;
294 let decoded = commit.decode()?;
295 let msg = decoded.message.to_string();
296 let timestamp = format!("{:?}", decoded.committer.time);
297 let author = decoded.author.name.to_string();
298 let hex = id.to_hex().to_string();
299 entries.push(LogEntry {
300 hash: hex.clone(),
301 short_hash: hex[..7].into(),
302 message: msg,
303 timestamp,
304 author,
305 });
306 current_id = decoded.parents().next();
308 }
309
310 Ok(entries)
311 }
312
313 pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
315 let repo = self.repo.lock();
316 let commit_id = ObjectId::from_hex(hash.as_bytes())?;
317 let commit = repo.find_commit(commit_id)?;
318 let decoded = commit.decode()?;
319 let tree_id = ObjectId::from_hex(decoded.tree)?;
320 let tree = repo.find_tree(tree_id)?;
321 let decoded_tree = tree.decode()?;
322
323 let rel_bytes = BStr::new(rel_path);
325 let entry = decoded_tree
326 .entries
327 .iter()
328 .find(|e| e.filename == rel_bytes)
329 .ok_or_else(|| anyhow::anyhow!("Path {} not found in commit {}", rel_path, hash))?;
330
331 let blob = repo.find_blob(entry.oid.to_owned())?;
332 std::fs::write(self.root.join(rel_path), &blob.data)?;
333 Ok(())
334 }
335
336 pub fn verify(&self) -> Result<bool> {
338 let repo = self.repo.lock();
339 let refs = repo.references()?;
340 for reference in refs.all()? {
341 let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
342 }
343 repo.head_id()?;
344 Ok(true)
345 }
346
347 pub fn is_enabled(&self) -> bool {
349 self.enabled
350 }
351
352 fn head_tree_oid(repo: &gix::Repository) -> Result<ObjectId> {
356 match Self::head_id_detached_raw(repo) {
357 Some(id) => {
358 let commit = repo.find_commit(id)?;
359 let decoded = commit.decode()?;
360 let oid = ObjectId::from_hex(decoded.tree)?;
361 Ok(oid)
362 }
363 None => Ok(ObjectId::empty_tree(repo.object_hash())),
364 }
365 }
366
367 fn head_id_detached_raw(repo: &gix::Repository) -> Option<ObjectId> {
369 repo.head_id().ok().map(|id| id.detach())
370 }
371
372 fn noop_commit(&self, message: &str) -> Result<CommitInfo> {
373 Ok(CommitInfo {
374 hash: "(disabled)".into(),
375 short_hash: "(dis)".into(),
376 message: message.into(),
377 timestamp: chrono::Utc::now().to_rfc3339(),
378 author: "oxios".into(),
379 })
380 }
381
382 fn make_info(&self, id: &gix::Id, message: &str) -> CommitInfo {
383 let hex = id.to_hex().to_string();
384 CommitInfo {
385 short_hash: hex[..7].into(),
386 hash: hex,
387 message: message.into(),
388 timestamp: chrono::Utc::now().to_rfc3339(),
389 author: self.committer_name.clone(),
390 }
391 }
392}
393
394fn self_signature_ref() -> gix::actor::SignatureRef<'static> {
396 gix::actor::SignatureRef {
397 name: "oxios".into(),
398 email: "oxios@oxios".into(),
399 time: gix::date::Time::now_local_or_utc(),
400 }
401}
402
403#[cfg(test)]
406mod tests {
407 use super::*;
408 use tempfile::TempDir;
409
410 fn setup() -> (TempDir, GitLayer) {
411 let dir = tempfile::tempdir().unwrap();
412 let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
413 (dir, layer)
414 }
415
416 #[test]
417 fn test_init_creates_repo() {
418 let (dir, _) = setup();
419 assert!(dir.path().join(".git").exists());
420 }
421
422 #[test]
423 fn test_commit_file() {
424 let (dir, layer) = setup();
425 std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
426 let info = layer.commit_file("test.json", "test commit").unwrap();
427 assert!(!info.hash.is_empty());
428 assert_eq!(info.short_hash.len(), 7);
429 assert_eq!(info.message, "test commit");
430 assert!(info.hash.starts_with(&info.short_hash));
431 }
432
433 #[test]
434 fn test_log_query() {
435 let (dir, layer) = setup();
436 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
437 layer.commit_file("a.json", "first").unwrap();
438 std::fs::write(dir.path().join("a.json"), b"2").unwrap();
439 layer.commit_file("a.json", "second").unwrap();
440 let log = layer.log(10).unwrap();
441 assert!(log.len() >= 2);
442 assert!(log[0].message.contains("second"));
443 }
444
445 #[test]
446 fn test_tag_create_list() {
447 let (dir, layer) = setup();
448 std::fs::write(dir.path().join("x.json"), b"1").unwrap();
449 layer.commit_file("x.json", "tag test").unwrap();
450 layer.tag("v1", "first tag").unwrap();
451 let tags = layer.list_tags().unwrap();
452 assert!(tags.iter().any(|t| t.contains("v1")));
453 }
454
455 #[test]
456 fn test_disabled_noop() {
457 let dir = tempfile::tempdir().unwrap();
458 let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
459 std::fs::write(dir.path().join("test.json"), b"1").unwrap();
460 let info = layer.commit_file("test.json", "noop").unwrap();
461 assert_eq!(info.hash, "(disabled)");
462 assert_eq!(info.short_hash, "(dis)");
463 }
464
465 #[test]
466 fn test_log_action() {
467 let (dir, layer) = setup();
468 layer
469 .log_action("agent-A", "read", "file.txt", true, None)
470 .unwrap();
471 let audit_file = dir
472 .path()
473 .join("audit")
474 .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
475 assert!(audit_file.exists());
476 let content = std::fs::read_to_string(&audit_file).unwrap();
477 assert!(content.contains("agent-A"));
478 assert!(content.contains("ALLOW"));
479 }
480
481 #[test]
482 fn test_verify() {
483 let (_, layer) = setup();
484 assert!(layer.verify().unwrap());
485 }
486
487 #[test]
488 fn test_remove_file() {
489 let (dir, layer) = setup();
490 std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
491 layer.commit_file("todelete.json", "add file").unwrap();
492 std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
493 let info = layer.remove_file("todelete.json", "remove file").unwrap();
494 assert!(!info.hash.is_empty());
495 assert!(info.hash != "(disabled)");
496 }
497
498 #[test]
499 fn test_commit_files_batch() {
500 let (dir, layer) = setup();
501 std::fs::write(dir.path().join("a.json"), b"1").unwrap();
502 std::fs::write(dir.path().join("b.json"), b"2").unwrap();
503 let info = layer
504 .commit_files(&["a.json", "b.json"], "batch commit")
505 .unwrap();
506 assert!(!info.hash.is_empty());
507 assert_eq!(info.message, "batch commit");
508 }
509
510 #[test]
511 fn test_restore_file() {
512 let (dir, layer) = setup();
513 std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
514 let first = layer.commit_file("state.json", "v1").unwrap();
515 std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
516 layer.commit_file("state.json", "v2").unwrap();
517 layer.restore_file("state.json", &first.short_hash).unwrap();
518 let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
519 assert_eq!(content, "v1");
520 }
521
522 #[test]
523 fn test_gitignore_created() {
524 let (dir, _) = setup();
525 assert!(dir.path().join(".gitignore").exists());
526 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
527 assert!(content.contains("Oxios"));
528 }
529}