Skip to main content

oxios_kernel/
git_layer.rs

1//! Git-based version control layer using gix.
2//! Provides in-process commits, logs, tags, and restore.
3
4use 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/// Commit information returned after a successful commit.
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct CommitInfo {
23    /// Full commit hash (hex).
24    pub hash: String,
25    /// Short hash (7 chars).
26    pub short_hash: String,
27    /// Commit message.
28    pub message: String,
29    /// ISO-8601 timestamp.
30    pub timestamp: String,
31    /// Author name.
32    pub author: String,
33}
34
35/// A single commit log entry.
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct LogEntry {
38    /// Full commit hash (hex).
39    pub hash: String,
40    /// Short hash (7 chars).
41    pub short_hash: String,
42    /// Commit message.
43    pub message: String,
44    /// Timestamp string.
45    pub timestamp: String,
46    /// Author name.
47    pub author: String,
48}
49
50/// Git-based version control layer.
51///
52/// Uses `gix` for in-process git operations — no subprocess spawning,
53/// no performance overhead of forking `git` CLI commands.
54pub 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    /// Create a new GitLayer, initializing a repo if needed.
65    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        // Write .gitignore
74        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        // Create initial commit if repo is empty
82        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    /// Get head commit as ObjectId (detached, no repo reference).
96    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    /// Commit a single file with a message.
123    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    /// Commit multiple files in a single commit.
155    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    /// Remove a file from the repo and commit.
188    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    /// Append an audit entry to a monthly audit log file and commit it.
213    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    /// Create an annotated tag at the current HEAD.
248    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    /// List all tags in the repository.
268    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    /// Return commit log entries, most recent first.
283    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            // First parent via iterator
307            current_id = decoded.parents().next();
308        }
309
310        Ok(entries)
311    }
312
313    /// Restore a file to its state in a specific commit.
314    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        // Find entry by filename (as bytes)
324        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    /// Verify repository integrity.
337    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    /// Whether auto-commit is enabled.
348    pub fn is_enabled(&self) -> bool {
349        self.enabled
350    }
351
352    // ── Private helpers ───────────────────────────────────────────────────────
353
354    /// Get the current HEAD tree's ObjectId.
355    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    /// Get head commit as ObjectId (raw, borrowed repo).
368    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
394/// Create a signature ref for committer/author identity.
395fn 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// ── Tests ────────────────────────────────────────────────────────────────────
404
405#[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}