Skip to main content

oxios_kernel/
git_layer.rs

1//! Git-based version control layer using gix.
2//! Provides in-process commits, logs, tags, restore, and diffs.
3//!
4//! # RFC-013 Improvements
5//!
6//! - **B1**: `Signature` captures fresh timestamp per commit (not `OnceLock` cached).
7//! - **B2**: `restore_file` traverses nested paths (e.g. `audit/2024-05.audit`).
8//! - **D1**: `CommitContext` enables per-agent author tracking.
9//! - **D2**: `diff_commits` / `file_at_commit` for Ouroboros evaluate.
10//! - **D3**: Removed hex round-trips; `list_tags` uses `Category::Tag`.
11
12use anyhow::{bail, Result};
13use gix::bstr::BStr;
14use gix::hash::ObjectId;
15use gix::objs::tree::EntryKind;
16use gix::refs::transaction::PreviousValue;
17use parking_lot::Mutex;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21const GITIGNORE: &str = r#"# Oxios
22*.tmp
23*.lock
24.env
25api-keys.json
26"#;
27
28// ── Public types ────────────────────────────────────────────────────────────
29
30/// Commit information returned after a successful commit.
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32pub struct CommitInfo {
33    /// Full commit hash (hex).
34    pub hash: String,
35    /// Short hash (7 chars).
36    pub short_hash: String,
37    /// Commit message.
38    pub message: String,
39    /// ISO-8601 timestamp.
40    pub timestamp: String,
41    /// Author name.
42    pub author: String,
43}
44
45/// A single commit log entry.
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct LogEntry {
48    /// Full commit hash (hex).
49    pub hash: String,
50    /// Short hash (7 chars).
51    pub short_hash: String,
52    /// Commit message.
53    pub message: String,
54    /// Timestamp string.
55    pub timestamp: String,
56    /// Author name.
57    pub author: String,
58}
59
60/// Commit metadata supplied by the caller to identify who is committing.
61///
62/// Enables per-agent author tracking while keeping the existing
63/// `commit_file(path, msg)` API fully backward-compatible.
64#[derive(Default, Debug, Clone)]
65pub struct CommitContext {
66    /// Agent ID — if present the author becomes `agent-{short_id}`,
67    /// otherwise `"oxios"`.
68    pub agent_id: Option<uuid::Uuid>,
69    /// Seed ID — if present, included in the commit message prefix.
70    pub seed_id: Option<uuid::Uuid>,
71    /// Extra tag such as `"memory"`, `"audit"`, `"cron"`.
72    pub tag: Option<&'static str>,
73}
74
75impl CommitContext {
76    /// Default system commit (no agent context).
77    pub fn system() -> Self {
78        Self::default()
79    }
80
81    /// Agent commit.
82    pub fn agent(agent_id: uuid::Uuid, seed_id: Option<uuid::Uuid>) -> Self {
83        Self {
84            agent_id: Some(agent_id),
85            seed_id,
86            tag: None,
87        }
88    }
89
90    /// Tagged commit (no agent).
91    pub fn tagged(tag: &'static str) -> Self {
92        Self {
93            tag: Some(tag),
94            ..Default::default()
95        }
96    }
97
98    /// Derive the author name for this context.
99    fn author_name(&self) -> String {
100        match &self.agent_id {
101            Some(id) => {
102                let hex = id.to_string();
103                format!("agent-{}", &hex[..8])
104            }
105            None => "oxios".to_string(),
106        }
107    }
108
109    /// Build a prefix for the commit message (e.g. `[audit] [seed-abc123] `).
110    fn message_prefix(&self) -> String {
111        let mut parts = Vec::new();
112        if let Some(tag) = self.tag {
113            parts.push(format!("[{tag}]"));
114        }
115        if let Some(ref seed) = self.seed_id {
116            let hex = seed.to_string();
117            parts.push(format!("[seed-{}]", &hex[..8]));
118        }
119        if parts.is_empty() {
120            String::new()
121        } else {
122            format!("{} ", parts.join(" "))
123        }
124    }
125}
126
127// ── Diff types (Phase 3) ────────────────────────────────────────────────────
128
129/// Change kind for a single file.
130#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
131pub enum DiffKind {
132    /// New file added.
133    Added,
134    /// File deleted.
135    Deleted,
136    /// File content changed.
137    Modified,
138}
139
140/// Change record for a single file between two commits.
141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct FileDiff {
143    /// File path (relative to repo root).
144    pub path: String,
145    /// Hex hash in the "from" commit (None for added files).
146    pub old_hash: Option<String>,
147    /// Hex hash in the "to" commit (None for deleted files).
148    pub new_hash: Option<String>,
149    /// Kind of change.
150    pub kind: DiffKind,
151    /// Unified diff text (None for binary files).
152    pub patch: Option<String>,
153}
154
155/// Aggregate diff statistics.
156#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
157pub struct DiffStats {
158    /// Number of files changed.
159    pub files_changed: usize,
160    /// Total lines added.
161    pub additions: usize,
162    /// Total lines removed.
163    pub deletions: usize,
164}
165
166/// Diff result between two commits.
167#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
168pub struct CommitDiff {
169    /// Hex hash of the "from" commit.
170    pub from_hash: String,
171    /// Hex hash of the "to" commit.
172    pub to_hash: String,
173    /// Per-file changes.
174    pub files: Vec<FileDiff>,
175    /// Aggregate statistics.
176    pub stats: DiffStats,
177}
178
179// ── Internal types ──────────────────────────────────────────────────────────
180
181/// Default committer email used across all commits.
182const DEFAULT_EMAIL: &str = "oxios@oxios";
183
184/// Owned signature that captures the timestamp at creation time.
185///
186/// Fixes B1: the old `self_signature_ref()` used `OnceLock` and cached the
187/// timestamp for the entire process lifetime, causing all commits to share
188/// the same timestamp.
189struct Signature {
190    name: String,
191    email: String,
192    time: String,
193}
194
195impl Signature {
196    /// Create a new signature with the current timestamp.
197    fn new(name: impl Into<String>, email: impl Into<String>) -> Self {
198        Self {
199            name: name.into(),
200            email: email.into(),
201            time: gix::date::Time::now_local_or_utc().to_string(),
202        }
203    }
204
205    /// Produce a `SignatureRef` valid for as long as `self` lives.
206    fn as_ref(&self) -> gix::actor::SignatureRef<'_> {
207        gix::actor::SignatureRef {
208            name: self.name.as_str().into(),
209            email: self.email.as_str().into(),
210            time: &self.time,
211        }
212    }
213}
214
215// ── GitLayer ────────────────────────────────────────────────────────────────
216
217/// Git-based version control layer.
218///
219/// Uses `gix` for in-process git operations — no subprocess spawning,
220/// no performance overhead of forking `git` CLI commands.
221pub struct GitLayer {
222    repo: Arc<Mutex<gix::Repository>>,
223    root: PathBuf,
224    #[allow(dead_code)]
225    committer_email: String,
226    enabled: bool,
227}
228
229impl GitLayer {
230    /// Create a new GitLayer, initializing a repo if needed.
231    pub fn new(root: PathBuf, enabled: bool) -> Result<Self> {
232        let repo = if root.join(".git").exists() {
233            gix::open(&root)?
234        } else {
235            std::fs::create_dir_all(&root)?;
236            gix::init(&root)?
237        };
238
239        // Write .gitignore
240        let gitignore = root.join(".gitignore");
241        if !gitignore.exists() {
242            std::fs::write(&gitignore, GITIGNORE)?;
243        }
244
245        let repo_ref = Arc::new(Mutex::new(repo));
246
247        // Create initial commit if repo is empty
248        if Self::head_id_detached(&repo_ref).is_none() {
249            Self::create_initial_commit(&repo_ref, &root)?;
250        }
251
252        Ok(Self {
253            repo: repo_ref,
254            root,
255            committer_email: DEFAULT_EMAIL.into(),
256            enabled,
257        })
258    }
259
260    // ── Private helpers (repo-level) ──────────────────────────────────────
261
262    fn head_id_detached(repo_arc: &Arc<Mutex<gix::Repository>>) -> Option<ObjectId> {
263        let repo = repo_arc.lock();
264        repo.head_id().ok().map(|id| id.detach())
265    }
266
267    fn head_id_detached_raw(repo: &gix::Repository) -> Option<ObjectId> {
268        repo.head_id().ok().map(|id| id.detach())
269    }
270
271    fn create_initial_commit(repo: &Arc<Mutex<gix::Repository>>, root: &Path) -> Result<()> {
272        let repo_lock = repo.lock();
273        let gitignore = root.join(".gitignore");
274        let content = std::fs::read(&gitignore)?;
275        let blob_id = repo_lock.write_blob(&content)?;
276        let empty_tree = ObjectId::empty_tree(repo_lock.object_hash());
277        let mut editor = repo_lock.edit_tree(empty_tree)?;
278        editor.upsert(".gitignore", EntryKind::Blob, blob_id)?;
279        let tree_id = editor.write()?;
280        let sig = Signature::new("oxios", DEFAULT_EMAIL);
281        repo_lock.commit_as(
282            sig.as_ref(),
283            sig.as_ref(),
284            "refs/heads/main",
285            "Initial commit",
286            tree_id.detach(),
287            Vec::<ObjectId>::new(),
288        )?;
289        Ok(())
290    }
291
292    /// Get the current HEAD tree's ObjectId (no hex round-trip).
293    fn head_tree_oid(repo: &gix::Repository) -> Result<ObjectId> {
294        match Self::head_id_detached_raw(repo) {
295            Some(id) => {
296                let commit = repo.find_commit(id)?;
297                let decoded = commit.decode()?;
298                Ok(decoded.tree())
299            }
300            None => Ok(ObjectId::empty_tree(repo.object_hash())),
301        }
302    }
303
304    /// Get tree ObjectId for a commit (no hex round-trip).
305    fn commit_tree_id(repo: &gix::Repository, commit_id: ObjectId) -> Result<ObjectId> {
306        let commit = repo.find_commit(commit_id)?;
307        let decoded = commit.decode()?;
308        Ok(decoded.tree())
309    }
310
311    /// Traverse path components through sub-trees to locate a blob.
312    ///
313    /// Supports nested paths like `audit/2024-05.audit`.
314    fn find_blob_in_tree(
315        repo: &gix::Repository,
316        tree_id: ObjectId,
317        rel_path: &str,
318    ) -> Result<ObjectId> {
319        let components: Vec<&str> = Path::new(rel_path)
320            .iter()
321            .filter_map(|c| c.to_str())
322            .collect();
323        anyhow::ensure!(!components.is_empty(), "empty path: {rel_path}");
324
325        let mut current_tree_id = tree_id;
326
327        for (i, component) in components.iter().enumerate() {
328            let tree = repo.find_tree(current_tree_id)?;
329            let decoded = tree.decode()?;
330            let comp_bytes = BStr::new(component);
331            let entry = decoded
332                .entries
333                .iter()
334                .find(|e| e.filename == comp_bytes)
335                .ok_or_else(|| {
336                    anyhow::anyhow!("path component '{component}' not found in '{rel_path}'")
337                })?;
338
339            if i == components.len() - 1 {
340                return Ok(entry.oid.to_owned());
341            }
342            current_tree_id = entry.oid.to_owned();
343        }
344
345        unreachable!()
346    }
347
348    // ── Public commit API ─────────────────────────────────────────────────
349
350    /// Commit a single file with a message (backward-compatible).
351    pub fn commit_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
352        self.commit_file_with(rel_path, message, CommitContext::default())
353    }
354
355    /// Commit a single file with a message and explicit commit context.
356    pub fn commit_file_with(
357        &self,
358        rel_path: &str,
359        message: &str,
360        ctx: CommitContext,
361    ) -> Result<CommitInfo> {
362        if !self.enabled {
363            return self.noop_commit(&ctx, message);
364        }
365        let repo = self.repo.lock();
366        let abs = self.root.join(rel_path);
367        if !abs.exists() {
368            bail!("File not found: {rel_path}");
369        }
370
371        let content = std::fs::read(&abs)?;
372        let blob_id = repo.write_blob(&content)?;
373        let head_tree = Self::head_tree_oid(&repo)?;
374        let mut editor = repo.edit_tree(head_tree)?;
375        editor.upsert(rel_path, EntryKind::Blob, blob_id)?;
376        let tree_id = editor.write()?;
377
378        let parent = repo.head_id().ok().map(|id| id.detach());
379        let author_name = ctx.author_name();
380        let full_message = format!("{}{}", ctx.message_prefix(), message);
381        let sig = Signature::new(&author_name, &self.committer_email);
382        let commit_id = repo.commit_as(
383            sig.as_ref(),
384            sig.as_ref(),
385            "refs/heads/main",
386            &full_message,
387            tree_id.detach(),
388            parent.into_iter().collect::<Vec<_>>(),
389        )?;
390
391        Ok(self.make_info(&commit_id, &full_message, &author_name))
392    }
393
394    /// Commit multiple files in a single commit (backward-compatible).
395    pub fn commit_files(&self, rel_paths: &[&str], message: &str) -> Result<CommitInfo> {
396        self.commit_files_with(rel_paths, message, CommitContext::default())
397    }
398
399    /// Commit multiple files with a message and explicit commit context.
400    pub fn commit_files_with(
401        &self,
402        rel_paths: &[&str],
403        message: &str,
404        ctx: CommitContext,
405    ) -> Result<CommitInfo> {
406        if !self.enabled {
407            return self.noop_commit(&ctx, message);
408        }
409        let repo = self.repo.lock();
410        let head_tree = Self::head_tree_oid(&repo)?;
411        let mut editor = repo.edit_tree(head_tree)?;
412
413        for path in rel_paths {
414            let abs = self.root.join(path);
415            if abs.exists() {
416                let content = std::fs::read(&abs)?;
417                let blob_id = repo.write_blob(&content)?;
418                editor.upsert(*path, EntryKind::Blob, blob_id)?;
419            }
420        }
421        let tree_id = editor.write()?;
422
423        let parent = repo.head_id().ok().map(|id| id.detach());
424        let author_name = ctx.author_name();
425        let full_message = format!("{}{}", ctx.message_prefix(), message);
426        let sig = Signature::new(&author_name, &self.committer_email);
427        let commit_id = repo.commit_as(
428            sig.as_ref(),
429            sig.as_ref(),
430            "refs/heads/main",
431            &full_message,
432            tree_id.detach(),
433            parent.into_iter().collect::<Vec<_>>(),
434        )?;
435
436        Ok(self.make_info(&commit_id, &full_message, &author_name))
437    }
438
439    /// Remove a file from the repo and commit.
440    pub fn remove_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
441        if !self.enabled {
442            return self.noop_commit(&CommitContext::default(), message);
443        }
444        let repo = self.repo.lock();
445        let head_tree = Self::head_tree_oid(&repo)?;
446        let mut editor = repo.edit_tree(head_tree)?;
447        editor.remove(rel_path)?;
448        let tree_id = editor.write()?;
449
450        let parent = repo.head_id().ok().map(|id| id.detach());
451        let sig = Signature::new("oxios", &self.committer_email);
452        let commit_id = repo.commit_as(
453            sig.as_ref(),
454            sig.as_ref(),
455            "refs/heads/main",
456            message,
457            tree_id.detach(),
458            parent.into_iter().collect::<Vec<_>>(),
459        )?;
460
461        Ok(self.make_info(&commit_id, message, "oxios"))
462    }
463
464    /// Append an audit entry to a monthly audit log file and commit it.
465    pub fn log_action(
466        &self,
467        agent: &str,
468        action: &str,
469        target: &str,
470        allowed: bool,
471        detail: Option<&str>,
472    ) -> Result<()> {
473        let now = chrono::Utc::now();
474        let filename = format!("audit/{}.audit", now.format("%Y-%m"));
475        let entry = format!(
476            "{} | {} | {} | {} | {} | {}\n",
477            now.to_rfc3339(),
478            agent,
479            action,
480            target,
481            if allowed { "ALLOW" } else { "DENY" },
482            detail.unwrap_or("-")
483        );
484        let dir = self.root.join("audit");
485        std::fs::create_dir_all(&dir)?;
486        use std::io::Write;
487        std::fs::OpenOptions::new()
488            .create(true)
489            .append(true)
490            .open(self.root.join(&filename))?
491            .write_all(entry.as_bytes())?;
492        self.commit_file(&filename, &format!("audit: {agent} {action} {target}"))?;
493        Ok(())
494    }
495
496    // ── Tags ──────────────────────────────────────────────────────────────
497
498    /// Create an annotated tag at the current HEAD.
499    pub fn tag(&self, name: &str, message: &str) -> Result<()> {
500        if !self.enabled {
501            return Ok(());
502        }
503        let repo = self.repo.lock();
504        let head_id = repo
505            .head_id()
506            .ok()
507            .map(|id| id.detach())
508            .ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
509        let sig = Signature::new("oxios", &self.committer_email);
510        repo.tag(
511            name,
512            head_id,
513            gix::objs::Kind::Commit,
514            Some(sig.as_ref()),
515            message,
516            PreviousValue::MustNotExist,
517        )?;
518        Ok(())
519    }
520
521    /// List all tags in the repository.
522    ///
523    /// Uses `Category::Tag` to correctly filter only tag refs.
524    pub fn list_tags(&self) -> Result<Vec<String>> {
525        let repo = self.repo.lock();
526        let mut tags = Vec::new();
527        for reference in repo.references()?.all()? {
528            let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
529            if reference
530                .name()
531                .category()
532                .is_some_and(|c| matches!(c, gix::refs::Category::Tag))
533            {
534                tags.push(reference.name().shorten().to_string());
535            }
536        }
537        Ok(tags)
538    }
539
540    // ── Log / resolve ─────────────────────────────────────────────────────
541
542    /// Return commit log entries, most recent first.
543    pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
544        let repo = self.repo.lock();
545        let head_id = repo.head_id()?.detach();
546        let mut entries = Vec::new();
547        let mut current_id: Option<ObjectId> = Some(head_id);
548
549        while let Some(id) = current_id {
550            if entries.len() >= max_count {
551                break;
552            }
553            let commit = repo.find_commit(id)?;
554            let decoded = commit.decode()?;
555            let msg_ref = decoded.message();
556            let msg = if let Some(body) = msg_ref.body {
557                format!("{}\n\n{}", msg_ref.title, body)
558            } else {
559                msg_ref.title.to_string()
560            };
561            let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
562            let author = decoded
563                .author()
564                .map(|a| a.name.to_string())
565                .unwrap_or_default();
566            let hex = id.to_hex().to_string();
567            entries.push(LogEntry {
568                hash: hex.clone(),
569                short_hash: hex[..7].into(),
570                message: msg,
571                timestamp,
572                author,
573            });
574            current_id = decoded.parents().next();
575        }
576
577        Ok(entries)
578    }
579
580    /// Resolve a partial commit hash to full ObjectId.
581    pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
582        if partial.len() < 4 {
583            bail!("Partial hash too short (minimum 4 characters)");
584        }
585        if partial.len() >= 40 {
586            return Ok(ObjectId::from_hex(partial.as_bytes())?);
587        }
588        let repo = self.repo.lock();
589        let id = repo.rev_parse_single(BStr::new(partial))?;
590        Ok(id.detach())
591    }
592
593    /// Resolve a hash string using a pre-locked repo.
594    fn resolve_hash_inner(&self, repo: &gix::Repository, partial: &str) -> Result<ObjectId> {
595        if partial.len() >= 40 {
596            return Ok(ObjectId::from_hex(partial.as_bytes())?);
597        }
598        if partial.len() < 4 {
599            bail!("Hash too short (minimum 4 characters)");
600        }
601        let id = repo.rev_parse_single(BStr::new(partial))?;
602        Ok(id.detach())
603    }
604
605    // ── Restore ───────────────────────────────────────────────────────────
606
607    /// Restore a file to its state in a specific commit.
608    ///
609    /// Supports nested paths like `audit/2024-05.audit` by traversing
610    /// each path component through sub-trees.
611    pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
612        let commit_id = self.resolve_partial_hash(hash)?;
613        let repo = self.repo.lock();
614        let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
615        let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
616        let blob = repo.find_blob(blob_id)?;
617        std::fs::write(self.root.join(rel_path), &blob.data)?;
618        Ok(())
619    }
620
621    // ── Diff API (Phase 3) ────────────────────────────────────────────────
622
623    /// Compute the diff between two commits.
624    pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<CommitDiff> {
625        let repo = self.repo.lock();
626        let from_id = self.resolve_hash_inner(&repo, from_hash)?;
627        let to_id = self.resolve_hash_inner(&repo, to_hash)?;
628
629        let from_tree_id = Self::commit_tree_id(&repo, from_id)?;
630        let to_tree_id = Self::commit_tree_id(&repo, to_id)?;
631
632        let mut files = Vec::new();
633        Self::diff_trees(&repo, from_tree_id, to_tree_id, "", &mut files)?;
634
635        // Compute patches for modified/added files.
636        for fd in &mut files {
637            let old_data = fd
638                .old_hash
639                .as_ref()
640                .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
641                .and_then(|id| repo.find_blob(id).ok())
642                .map(|b| b.data.to_vec());
643            let new_data = fd
644                .new_hash
645                .as_ref()
646                .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
647                .and_then(|id| repo.find_blob(id).ok())
648                .map(|b| b.data.to_vec());
649
650            match (&old_data, &new_data) {
651                (Some(old), Some(new)) => {
652                    fd.patch = compute_unified_diff(old, new, &fd.path);
653                }
654                (None, Some(new)) => {
655                    fd.patch = compute_unified_diff(&[], new, &fd.path);
656                }
657                _ => {}
658            }
659        }
660
661        let stats = DiffStats {
662            files_changed: files.len(),
663            additions: files
664                .iter()
665                .filter_map(|f| f.patch.as_ref())
666                .map(|p| {
667                    p.lines()
668                        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
669                        .count()
670                })
671                .sum(),
672            deletions: files
673                .iter()
674                .filter_map(|f| f.patch.as_ref())
675                .map(|p| {
676                    p.lines()
677                        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
678                        .count()
679                })
680                .sum(),
681        };
682
683        Ok(CommitDiff {
684            from_hash: from_id.to_hex().to_string(),
685            to_hash: to_id.to_hex().to_string(),
686            files,
687            stats,
688        })
689    }
690
691    /// Retrieve file content as it was at a specific commit.
692    pub fn file_at_commit(&self, rel_path: &str, hash: &str) -> Result<Vec<u8>> {
693        let repo = self.repo.lock();
694        let commit_id = self.resolve_hash_inner(&repo, hash)?;
695        let tree_id = Self::commit_tree_id(&repo, commit_id)?;
696        let blob_id = Self::find_blob_in_tree(&repo, tree_id, rel_path)?;
697        let blob = repo.find_blob(blob_id)?;
698        Ok(blob.data.to_vec())
699    }
700
701    // ── Diff helpers ──────────────────────────────────────────────────────
702
703    /// Recursively compare two trees and collect changed files.
704    fn diff_trees(
705        repo: &gix::Repository,
706        old_tree: ObjectId,
707        new_tree: ObjectId,
708        prefix: &str,
709        changes: &mut Vec<FileDiff>,
710    ) -> Result<()> {
711        let old_tree_obj = repo.find_tree(old_tree)?;
712        let old_decoded = old_tree_obj.decode()?;
713        let new_tree_obj = repo.find_tree(new_tree)?;
714        let new_decoded = new_tree_obj.decode()?;
715
716        let old_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
717            old_decoded
718                .entries
719                .iter()
720                .map(|e| (e.filename, e))
721                .collect();
722        let new_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
723            new_decoded
724                .entries
725                .iter()
726                .map(|e| (e.filename, e))
727                .collect();
728
729        // Detect additions and modifications.
730        for (name, new_entry) in &new_entries {
731            let path = format!("{prefix}{name}");
732            match old_entries.get(name) {
733                None => {
734                    if new_entry.mode.is_tree() {
735                        let empty = ObjectId::empty_tree(repo.object_hash());
736                        Self::diff_trees(
737                            repo,
738                            empty,
739                            new_entry.oid.to_owned(),
740                            &format!("{path}/"),
741                            changes,
742                        )?;
743                    } else {
744                        changes.push(FileDiff {
745                            path,
746                            old_hash: None,
747                            new_hash: Some(new_entry.oid.to_hex().to_string()),
748                            kind: DiffKind::Added,
749                            patch: None,
750                        });
751                    }
752                }
753                Some(old_entry) => {
754                    if old_entry.oid == new_entry.oid {
755                        continue;
756                    }
757                    if new_entry.mode.is_tree() && old_entry.mode.is_tree() {
758                        Self::diff_trees(
759                            repo,
760                            old_entry.oid.to_owned(),
761                            new_entry.oid.to_owned(),
762                            &format!("{path}/"),
763                            changes,
764                        )?;
765                    } else {
766                        changes.push(FileDiff {
767                            path,
768                            old_hash: Some(old_entry.oid.to_hex().to_string()),
769                            new_hash: Some(new_entry.oid.to_hex().to_string()),
770                            kind: DiffKind::Modified,
771                            patch: None,
772                        });
773                    }
774                }
775            }
776        }
777
778        // Detect deletions.
779        for (name, old_entry) in &old_entries {
780            if new_entries.contains_key(name) {
781                continue;
782            }
783            let path = format!("{prefix}{name}");
784            changes.push(FileDiff {
785                path,
786                old_hash: Some(old_entry.oid.to_hex().to_string()),
787                new_hash: None,
788                kind: DiffKind::Deleted,
789                patch: None,
790            });
791        }
792
793        Ok(())
794    }
795
796    // ── Verify / accessors ────────────────────────────────────────────────
797
798    /// Verify repository integrity.
799    pub fn verify(&self) -> Result<bool> {
800        let repo = self.repo.lock();
801        let refs = repo.references()?;
802        for reference in refs.all()? {
803            let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
804        }
805        if repo.head_id().is_err() {
806            tracing::debug!("verify: no HEAD yet (empty repository)");
807        }
808        Ok(true)
809    }
810
811    /// Whether auto-commit is enabled.
812    pub fn is_enabled(&self) -> bool {
813        self.enabled
814    }
815
816    /// Get the root path of this git repository.
817    pub fn root(&self) -> &Path {
818        &self.root
819    }
820
821    // ── Private info builders ─────────────────────────────────────────────
822
823    fn noop_commit(&self, ctx: &CommitContext, message: &str) -> Result<CommitInfo> {
824        Ok(CommitInfo {
825            hash: "(disabled)".into(),
826            short_hash: "(dis)".into(),
827            message: message.into(),
828            timestamp: chrono::Utc::now().to_rfc3339(),
829            author: ctx.author_name(),
830        })
831    }
832
833    fn make_info(&self, id: &gix::Id, message: &str, author: &str) -> CommitInfo {
834        let hex = id.to_hex().to_string();
835        CommitInfo {
836            short_hash: hex[..7].into(),
837            hash: hex,
838            message: message.into(),
839            timestamp: chrono::Utc::now().to_rfc3339(),
840            author: author.into(),
841        }
842    }
843}
844
845// ── Free functions ──────────────────────────────────────────────────────────
846
847/// Produce a simple unified-style diff between two byte sequences.
848fn compute_unified_diff(old: &[u8], new: &[u8], path: &str) -> Option<String> {
849    let old_str = std::str::from_utf8(old).ok()?;
850    let new_str = std::str::from_utf8(new).ok()?;
851
852    use similar::{ChangeTag, TextDiff};
853    let diff = TextDiff::from_lines(old_str, new_str);
854
855    let mut output = format!("--- a/{path}\n+++ b/{path}\n");
856    for change in diff.iter_all_changes() {
857        let prefix = match change.tag() {
858            ChangeTag::Delete => '-',
859            ChangeTag::Insert => '+',
860            ChangeTag::Equal => ' ',
861        };
862        output.push_str(&format!("{prefix}{change}"));
863    }
864
865    Some(output)
866}
867
868// ── Tests ───────────────────────────────────────────────────────────────────
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use tempfile::TempDir;
874
875    fn setup() -> (TempDir, GitLayer) {
876        let dir = tempfile::tempdir().unwrap();
877        let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
878        (dir, layer)
879    }
880
881    #[test]
882    fn test_init_creates_repo() {
883        let (dir, _) = setup();
884        assert!(dir.path().join(".git").exists());
885    }
886
887    #[test]
888    fn test_commit_file() {
889        let (dir, layer) = setup();
890        std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
891        let info = layer.commit_file("test.json", "test commit").unwrap();
892        assert!(!info.hash.is_empty());
893        assert_eq!(info.short_hash.len(), 7);
894        assert_eq!(info.message, "test commit");
895        assert!(info.hash.starts_with(&info.short_hash));
896    }
897
898    #[test]
899    fn test_log_query() {
900        let (dir, layer) = setup();
901        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
902        layer.commit_file("a.json", "first").unwrap();
903        std::fs::write(dir.path().join("a.json"), b"2").unwrap();
904        layer.commit_file("a.json", "second").unwrap();
905        let log = layer.log(10).unwrap();
906        assert!(log.len() >= 2);
907        assert!(log[0].message.contains("second"));
908    }
909
910    #[test]
911    fn test_tag_create_list() {
912        let (dir, layer) = setup();
913        std::fs::write(dir.path().join("x.json"), b"1").unwrap();
914        layer.commit_file("x.json", "tag test").unwrap();
915        layer.tag("v1", "first tag").unwrap();
916        let tags = layer.list_tags().unwrap();
917        assert!(tags.iter().any(|t| t == "v1"));
918    }
919
920    #[test]
921    fn test_disabled_noop() {
922        let dir = tempfile::tempdir().unwrap();
923        let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
924        std::fs::write(dir.path().join("test.json"), b"1").unwrap();
925        let info = layer.commit_file("test.json", "noop").unwrap();
926        assert_eq!(info.hash, "(disabled)");
927        assert_eq!(info.short_hash, "(dis)");
928    }
929
930    #[test]
931    fn test_log_action() {
932        let (dir, layer) = setup();
933        layer
934            .log_action("agent-A", "read", "file.txt", true, None)
935            .unwrap();
936        let audit_file = dir
937            .path()
938            .join("audit")
939            .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
940        assert!(audit_file.exists());
941        let content = std::fs::read_to_string(&audit_file).unwrap();
942        assert!(content.contains("agent-A"));
943        assert!(content.contains("ALLOW"));
944    }
945
946    #[test]
947    fn test_verify() {
948        let (_, layer) = setup();
949        assert!(layer.verify().unwrap());
950    }
951
952    #[test]
953    fn test_remove_file() {
954        let (dir, layer) = setup();
955        std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
956        layer.commit_file("todelete.json", "add file").unwrap();
957        std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
958        let info = layer.remove_file("todelete.json", "remove file").unwrap();
959        assert!(!info.hash.is_empty());
960        assert!(info.hash != "(disabled)");
961    }
962
963    #[test]
964    fn test_commit_files_batch() {
965        let (dir, layer) = setup();
966        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
967        std::fs::write(dir.path().join("b.json"), b"2").unwrap();
968        let info = layer
969            .commit_files(&["a.json", "b.json"], "batch commit")
970            .unwrap();
971        assert!(!info.hash.is_empty());
972        assert_eq!(info.message, "batch commit");
973    }
974
975    #[test]
976    fn test_restore_file() {
977        let (dir, layer) = setup();
978        std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
979        let first = layer.commit_file("state.json", "v1").unwrap();
980        std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
981        layer.commit_file("state.json", "v2").unwrap();
982        layer.restore_file("state.json", &first.short_hash).unwrap();
983        let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
984        assert_eq!(content, "v1");
985    }
986
987    #[test]
988    fn test_gitignore_created() {
989        let (dir, _) = setup();
990        assert!(dir.path().join(".gitignore").exists());
991        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
992        assert!(content.contains("Oxios"));
993    }
994
995    // ── B1: Signature timestamps ──────────────────────────────────────────
996
997    #[test]
998    fn test_signature_timestamps_are_fresh() {
999        // B1 fix: each Signature captures its own timestamp at creation time,
1000        // not a process-wide cached value. Verify that signatures created 1s
1001        // apart produce different timestamps.
1002        let sig1 = Signature::new("a", "a@a");
1003        assert!(!sig1.time.is_empty());
1004
1005        std::thread::sleep(std::time::Duration::from_millis(1100));
1006        let sig3 = Signature::new("c", "c@c");
1007        assert_ne!(
1008            sig1.time, sig3.time,
1009            "Signature created 1s later must have a different timestamp"
1010        );
1011    }
1012
1013    // ── D1: Agent identification ──────────────────────────────────────────
1014
1015    #[test]
1016    fn test_commit_file_with_agent_context() {
1017        let (dir, layer) = setup();
1018        std::fs::write(dir.path().join("agent_work.json"), b"{\"result\":42}").unwrap();
1019
1020        let agent_id = uuid::Uuid::new_v4();
1021        let ctx = CommitContext::agent(agent_id, None);
1022        layer
1023            .commit_file_with("agent_work.json", "agent did work", ctx)
1024            .unwrap();
1025
1026        let log = layer.log(10).unwrap();
1027        let agent_commit = log
1028            .iter()
1029            .find(|e| e.message.contains("agent did work"))
1030            .expect("should find agent commit");
1031
1032        let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1033        assert_eq!(agent_commit.author, expected_author);
1034    }
1035
1036    #[test]
1037    fn test_commit_file_with_tag() {
1038        let (dir, layer) = setup();
1039        std::fs::write(dir.path().join("audit.json"), b"{\"event\":\"test\"}").unwrap();
1040
1041        let ctx = CommitContext::tagged("audit");
1042        let info = layer
1043            .commit_file_with("audit.json", "flush audit trail", ctx)
1044            .unwrap();
1045
1046        assert!(info.message.contains("[audit]"));
1047        assert!(info.message.contains("flush audit trail"));
1048    }
1049
1050    #[test]
1051    fn test_default_context_is_oxios() {
1052        let (dir, layer) = setup();
1053        std::fs::write(dir.path().join("sys.json"), b"1").unwrap();
1054
1055        let info = layer
1056            .commit_file_with("sys.json", "system commit", CommitContext::default())
1057            .unwrap();
1058
1059        assert_eq!(info.author, "oxios");
1060    }
1061
1062    #[test]
1063    fn test_commit_context_author_name() {
1064        assert_eq!(CommitContext::default().author_name(), "oxios");
1065        assert_eq!(CommitContext::system().author_name(), "oxios");
1066
1067        let id = uuid::Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
1068        assert_eq!(
1069            CommitContext::agent(id, None).author_name(),
1070            "agent-aaaaaaaa"
1071        );
1072
1073        assert_eq!(CommitContext::tagged("memory").author_name(), "oxios");
1074    }
1075
1076    #[test]
1077    fn test_commit_context_message_prefix() {
1078        assert!(CommitContext::default().message_prefix().is_empty());
1079        assert_eq!(CommitContext::tagged("audit").message_prefix(), "[audit] ");
1080
1081        let seed_id = uuid::Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
1082        let ctx = CommitContext {
1083            tag: Some("memory"),
1084            seed_id: Some(seed_id),
1085            ..Default::default()
1086        };
1087        assert_eq!(ctx.message_prefix(), "[memory] [seed-11111111] ");
1088    }
1089
1090    #[test]
1091    fn test_commit_files_with_context() {
1092        let (dir, layer) = setup();
1093        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1094        std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1095
1096        let agent_id = uuid::Uuid::new_v4();
1097        let ctx = CommitContext::agent(agent_id, None);
1098        let info = layer
1099            .commit_files_with(&["a.json", "b.json"], "batch agent work", ctx)
1100            .unwrap();
1101
1102        let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1103        assert_eq!(info.author, expected_author);
1104    }
1105
1106    #[test]
1107    fn test_backward_compat_commit_file_is_oxios() {
1108        let (dir, layer) = setup();
1109        std::fs::write(dir.path().join("compat.json"), b"1").unwrap();
1110        let info = layer.commit_file("compat.json", "compat check").unwrap();
1111        assert_eq!(info.author, "oxios");
1112    }
1113
1114    // ── B2: Nested path restore ───────────────────────────────────────────
1115
1116    #[test]
1117    fn test_restore_nested_file() {
1118        let (dir, layer) = setup();
1119
1120        // Create a nested file via log_action.
1121        layer
1122            .log_action("agent-X", "write", "secret.txt", true, None)
1123            .unwrap();
1124
1125        let audit_rel = format!("audit/{}.audit", chrono::Utc::now().format("%Y-%m"));
1126        let audit_path = dir.path().join(&audit_rel);
1127        assert!(audit_path.exists(), "audit file should exist");
1128
1129        // Overwrite it.
1130        let _original = std::fs::read_to_string(&audit_path).unwrap();
1131        std::fs::write(&audit_path, "CORRUPTED").unwrap();
1132        layer.commit_file(&audit_rel, "corrupt").unwrap();
1133
1134        // Find the audit commit and restore.
1135        let log = layer.log(10).unwrap();
1136        let audit_commit = log
1137            .iter()
1138            .find(|e| e.message.contains("audit: agent-X"))
1139            .expect("should find audit commit");
1140
1141        layer
1142            .restore_file(&audit_rel, &audit_commit.short_hash)
1143            .unwrap();
1144
1145        let restored = std::fs::read_to_string(&audit_path).unwrap();
1146        assert!(restored.contains("agent-X"));
1147        assert!(!restored.contains("CORRUPTED"));
1148    }
1149
1150    // ── D3b: list_tags filter ─────────────────────────────────────────────
1151
1152    #[test]
1153    fn test_list_tags_excludes_non_tags() {
1154        let (dir, layer) = setup();
1155        std::fs::write(dir.path().join("t.json"), b"1").unwrap();
1156        layer.commit_file("t.json", "for tag").unwrap();
1157        layer.tag("release-v1", "first release").unwrap();
1158        let tags = layer.list_tags().unwrap();
1159        assert!(tags.iter().any(|t| t == "release-v1"));
1160        assert!(tags.iter().all(|t| t != "main" && t != "HEAD"));
1161    }
1162
1163    // ── Phase 3: Diff ─────────────────────────────────────────────────────
1164
1165    #[test]
1166    fn test_diff_added_file() {
1167        let (dir, layer) = setup();
1168        let first = layer.log(1).unwrap()[0].hash.clone();
1169
1170        std::fs::write(dir.path().join("new.txt"), b"hello\n").unwrap();
1171        let info = layer.commit_file("new.txt", "add file").unwrap();
1172
1173        let diff = layer.diff_commits(&first, &info.hash).unwrap();
1174        assert!(diff
1175            .files
1176            .iter()
1177            .any(|f| f.path == "new.txt" && f.kind == DiffKind::Added));
1178    }
1179
1180    #[test]
1181    fn test_diff_modified_file() {
1182        let (dir, layer) = setup();
1183
1184        std::fs::write(dir.path().join("data.txt"), b"v1\n").unwrap();
1185        let first = layer.commit_file("data.txt", "v1").unwrap();
1186
1187        std::fs::write(dir.path().join("data.txt"), b"v2\n").unwrap();
1188        let second = layer.commit_file("data.txt", "v2").unwrap();
1189
1190        let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1191        assert!(diff
1192            .files
1193            .iter()
1194            .any(|f| f.path == "data.txt" && f.kind == DiffKind::Modified));
1195
1196        let patch = diff
1197            .files
1198            .iter()
1199            .find(|f| f.path == "data.txt")
1200            .unwrap()
1201            .patch
1202            .as_ref()
1203            .expect("should have patch");
1204        assert!(patch.contains("-v1"));
1205        assert!(patch.contains("+v2"));
1206    }
1207
1208    #[test]
1209    fn test_diff_deleted_file() {
1210        let (dir, layer) = setup();
1211
1212        std::fs::write(dir.path().join("temp.txt"), b"bye\n").unwrap();
1213        let first = layer.commit_file("temp.txt", "add temp").unwrap();
1214
1215        std::fs::remove_file(dir.path().join("temp.txt")).unwrap();
1216        let second = layer.remove_file("temp.txt", "remove temp").unwrap();
1217
1218        let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1219        assert!(diff
1220            .files
1221            .iter()
1222            .any(|f| f.path == "temp.txt" && f.kind == DiffKind::Deleted));
1223    }
1224
1225    #[test]
1226    fn test_file_at_commit() {
1227        let (dir, layer) = setup();
1228
1229        std::fs::write(dir.path().join("state.json"), b"{\"v\":1}").unwrap();
1230        let first = layer.commit_file("state.json", "v1").unwrap();
1231
1232        std::fs::write(dir.path().join("state.json"), b"{\"v\":2}").unwrap();
1233        layer.commit_file("state.json", "v2").unwrap();
1234
1235        let content = layer
1236            .file_at_commit("state.json", &first.short_hash)
1237            .unwrap();
1238        assert_eq!(content, b"{\"v\":1}");
1239    }
1240}