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