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, 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/// 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        // BUGFIX: Use the already-locked repo reference instead of self.repo
141        // which would deadlock (parking_lot::Mutex is not reentrant).
142        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    /// Commit multiple files in a single commit.
157    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        // BUGFIX: Use the already-locked repo reference instead of self.repo
176        // which would deadlock (parking_lot::Mutex is not reentrant).
177        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    /// Remove a file from the repo and commit.
192    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    /// Append an audit entry to a monthly audit log file and commit it.
217    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    /// Create an annotated tag at the current HEAD.
252    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    /// List all tags in the repository.
275    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    /// Return commit log entries, most recent first.
290    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            // First parent via iterator
322            current_id = decoded.parents().next();
323        }
324
325        Ok(entries)
326    }
327
328    /// Resolve a partial commit hash to full ObjectId.
329    ///
330    /// Git allows abbreviating commit hashes (e.g., "abc1234") as long as
331    /// they're unique within the repository. This method uses `rev_parse_single`
332    /// to resolve partial hashes to full commit IDs.
333    ///
334    /// # Arguments
335    /// * `partial` - A partial hash (4-40 hex characters)
336    ///
337    /// # Returns
338    /// The resolved `ObjectId` or error if the hash cannot be resolved.
339    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        // Check if it's already a full hash (40 hex chars for SHA-1)
344        if partial.len() >= 40 {
345            // Full hash - validate and return directly
346            return Ok(ObjectId::from_hex(partial.as_bytes())?);
347        }
348        // Partial hash - use rev_parse_single to resolve
349        let repo = self.repo.lock();
350        // rev_parse_single handles both full and partial hashes
351        let id = repo.rev_parse_single(BStr::new(partial))?;
352        Ok(id.detach())
353    }
354
355    /// Restore a file to its state in a specific commit.
356    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        // Find entry by filename (as bytes)
366        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    /// Verify repository integrity.
379    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        // head_id() fails on an empty repo (no commits yet) — that's fine.
386        if repo.head_id().is_err() {
387            tracing::debug!("verify: no HEAD yet (empty repository)");
388        }
389        Ok(true)
390    }
391
392    /// Whether auto-commit is enabled.
393    pub fn is_enabled(&self) -> bool {
394        self.enabled
395    }
396
397    // ── Private helpers ───────────────────────────────────────────────────────
398
399    /// Get the current HEAD tree's ObjectId.
400    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    /// Get head commit as ObjectId (raw, borrowed repo).
413    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
439/// Create a signature ref for committer/author identity.
440fn 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// ── Tests ────────────────────────────────────────────────────────────────────
451
452#[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}