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    // ── Log / resolve ─────────────────────────────────────────────────────
567
568    /// Return commit log entries, most recent first.
569    pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
570        let repo = self.repo.lock();
571        let head_id = repo.head_id()?.detach();
572        let mut entries = Vec::new();
573        let mut current_id: Option<ObjectId> = Some(head_id);
574
575        while let Some(id) = current_id {
576            if entries.len() >= max_count {
577                break;
578            }
579            let commit = repo.find_commit(id)?;
580            let decoded = commit.decode()?;
581            let msg_ref = decoded.message();
582            let msg = if let Some(body) = msg_ref.body {
583                format!("{}\n\n{}", msg_ref.title, body)
584            } else {
585                msg_ref.title.to_string()
586            };
587            let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
588            let author = decoded
589                .author()
590                .map(|a| a.name.to_string())
591                .unwrap_or_default();
592            let hex = id.to_hex().to_string();
593            entries.push(LogEntry {
594                hash: hex.clone(),
595                short_hash: hex[..7].into(),
596                message: msg,
597                timestamp,
598                author,
599            });
600            current_id = decoded.parents().next();
601        }
602
603        Ok(entries)
604    }
605
606    /// Resolve a partial commit hash to full ObjectId.
607    pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
608        if partial.len() < 4 {
609            bail!("Partial hash too short (minimum 4 characters)");
610        }
611        if partial.len() >= 40 {
612            return Ok(ObjectId::from_hex(partial.as_bytes())?);
613        }
614        let repo = self.repo.lock();
615        let id = repo.rev_parse_single(BStr::new(partial))?;
616        Ok(id.detach())
617    }
618
619    /// Resolve a hash string using a pre-locked repo.
620    fn resolve_hash_inner(&self, repo: &gix::Repository, partial: &str) -> Result<ObjectId> {
621        if partial.len() >= 40 {
622            return Ok(ObjectId::from_hex(partial.as_bytes())?);
623        }
624        if partial.len() < 4 {
625            bail!("Hash too short (minimum 4 characters)");
626        }
627        let id = repo.rev_parse_single(BStr::new(partial))?;
628        Ok(id.detach())
629    }
630
631    // ── Restore ───────────────────────────────────────────────────────────
632
633    /// Restore a file to its state in a specific commit.
634    ///
635    /// Supports nested paths like `audit/2024-05.audit` by traversing
636    /// each path component through sub-trees.
637    pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
638        // Validate the destination before resolving the blob — defense-in-depth
639        // against writing attacker-controlled content to an arbitrary path.
640        let dest = self.ensure_within_root(rel_path)?;
641        let commit_id = self.resolve_partial_hash(hash)?;
642        let repo = self.repo.lock();
643        let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
644        let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
645        let blob = repo.find_blob(blob_id)?;
646        std::fs::write(dest, &blob.data)?;
647        Ok(())
648    }
649
650    // ── Diff API (Phase 3) ────────────────────────────────────────────────
651
652    /// Compute the diff between two commits.
653    pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<CommitDiff> {
654        let repo = self.repo.lock();
655        let from_id = self.resolve_hash_inner(&repo, from_hash)?;
656        let to_id = self.resolve_hash_inner(&repo, to_hash)?;
657
658        let from_tree_id = Self::commit_tree_id(&repo, from_id)?;
659        let to_tree_id = Self::commit_tree_id(&repo, to_id)?;
660
661        let mut files = Vec::new();
662        Self::diff_trees(&repo, from_tree_id, to_tree_id, "", &mut files)?;
663
664        // Compute patches for modified/added files.
665        for fd in &mut files {
666            let old_data = fd
667                .old_hash
668                .as_ref()
669                .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
670                .and_then(|id| repo.find_blob(id).ok())
671                .map(|b| b.data.to_vec());
672            let new_data = fd
673                .new_hash
674                .as_ref()
675                .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
676                .and_then(|id| repo.find_blob(id).ok())
677                .map(|b| b.data.to_vec());
678
679            match (&old_data, &new_data) {
680                (Some(old), Some(new)) => {
681                    fd.patch = compute_unified_diff(old, new, &fd.path);
682                }
683                (None, Some(new)) => {
684                    fd.patch = compute_unified_diff(&[], new, &fd.path);
685                }
686                _ => {}
687            }
688        }
689
690        let stats = DiffStats {
691            files_changed: files.len(),
692            additions: files
693                .iter()
694                .filter_map(|f| f.patch.as_ref())
695                .map(|p| {
696                    p.lines()
697                        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
698                        .count()
699                })
700                .sum(),
701            deletions: files
702                .iter()
703                .filter_map(|f| f.patch.as_ref())
704                .map(|p| {
705                    p.lines()
706                        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
707                        .count()
708                })
709                .sum(),
710        };
711
712        Ok(CommitDiff {
713            from_hash: from_id.to_hex().to_string(),
714            to_hash: to_id.to_hex().to_string(),
715            files,
716            stats,
717        })
718    }
719
720    /// Retrieve file content as it was at a specific commit.
721    pub fn file_at_commit(&self, rel_path: &str, hash: &str) -> Result<Vec<u8>> {
722        // Defense-in-depth: the path is only used as a tree key here, but
723        // rejecting traversal early keeps the contract uniform with restore_file.
724        self.ensure_within_root(rel_path)?;
725        let repo = self.repo.lock();
726        let commit_id = self.resolve_hash_inner(&repo, hash)?;
727        let tree_id = Self::commit_tree_id(&repo, commit_id)?;
728        let blob_id = Self::find_blob_in_tree(&repo, tree_id, rel_path)?;
729        let blob = repo.find_blob(blob_id)?;
730        Ok(blob.data.to_vec())
731    }
732
733    // ── Diff helpers ──────────────────────────────────────────────────────
734
735    /// Recursively compare two trees and collect changed files.
736    fn diff_trees(
737        repo: &gix::Repository,
738        old_tree: ObjectId,
739        new_tree: ObjectId,
740        prefix: &str,
741        changes: &mut Vec<FileDiff>,
742    ) -> Result<()> {
743        let old_tree_obj = repo.find_tree(old_tree)?;
744        let old_decoded = old_tree_obj.decode()?;
745        let new_tree_obj = repo.find_tree(new_tree)?;
746        let new_decoded = new_tree_obj.decode()?;
747
748        let old_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
749            old_decoded
750                .entries
751                .iter()
752                .map(|e| (e.filename, e))
753                .collect();
754        let new_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
755            new_decoded
756                .entries
757                .iter()
758                .map(|e| (e.filename, e))
759                .collect();
760
761        // Detect additions and modifications.
762        for (name, new_entry) in &new_entries {
763            let path = format!("{prefix}{name}");
764            match old_entries.get(name) {
765                None => {
766                    if new_entry.mode.is_tree() {
767                        let empty = ObjectId::empty_tree(repo.object_hash());
768                        Self::diff_trees(
769                            repo,
770                            empty,
771                            new_entry.oid.to_owned(),
772                            &format!("{path}/"),
773                            changes,
774                        )?;
775                    } else {
776                        changes.push(FileDiff {
777                            path,
778                            old_hash: None,
779                            new_hash: Some(new_entry.oid.to_hex().to_string()),
780                            kind: DiffKind::Added,
781                            patch: None,
782                        });
783                    }
784                }
785                Some(old_entry) => {
786                    if old_entry.oid == new_entry.oid {
787                        continue;
788                    }
789                    if new_entry.mode.is_tree() && old_entry.mode.is_tree() {
790                        Self::diff_trees(
791                            repo,
792                            old_entry.oid.to_owned(),
793                            new_entry.oid.to_owned(),
794                            &format!("{path}/"),
795                            changes,
796                        )?;
797                    } else {
798                        changes.push(FileDiff {
799                            path,
800                            old_hash: Some(old_entry.oid.to_hex().to_string()),
801                            new_hash: Some(new_entry.oid.to_hex().to_string()),
802                            kind: DiffKind::Modified,
803                            patch: None,
804                        });
805                    }
806                }
807            }
808        }
809
810        // Detect deletions.
811        for (name, old_entry) in &old_entries {
812            if new_entries.contains_key(name) {
813                continue;
814            }
815            let path = format!("{prefix}{name}");
816            changes.push(FileDiff {
817                path,
818                old_hash: Some(old_entry.oid.to_hex().to_string()),
819                new_hash: None,
820                kind: DiffKind::Deleted,
821                patch: None,
822            });
823        }
824
825        Ok(())
826    }
827
828    // ── Verify / accessors ────────────────────────────────────────────────
829
830    /// Verify repository integrity.
831    pub fn verify(&self) -> Result<bool> {
832        let repo = self.repo.lock();
833        let refs = repo.references()?;
834        for reference in refs.all()? {
835            let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
836        }
837        if repo.head_id().is_err() {
838            tracing::debug!("verify: no HEAD yet (empty repository)");
839        }
840        Ok(true)
841    }
842
843    /// Whether auto-commit is enabled.
844    pub fn is_enabled(&self) -> bool {
845        self.enabled
846    }
847
848    /// Get the root path of this git repository.
849    pub fn root(&self) -> &Path {
850        &self.root
851    }
852
853    // ── Private info builders ─────────────────────────────────────────────
854
855    fn noop_commit(&self, ctx: &CommitContext, message: &str) -> Result<CommitInfo> {
856        Ok(CommitInfo {
857            hash: "(disabled)".into(),
858            short_hash: "(dis)".into(),
859            message: message.into(),
860            timestamp: chrono::Utc::now().to_rfc3339(),
861            author: ctx.author_name(),
862        })
863    }
864
865    fn make_info(&self, id: &gix::Id, message: &str, author: &str) -> CommitInfo {
866        let hex = id.to_hex().to_string();
867        CommitInfo {
868            short_hash: hex[..7].into(),
869            hash: hex,
870            message: message.into(),
871            timestamp: chrono::Utc::now().to_rfc3339(),
872            author: author.into(),
873        }
874    }
875}
876
877// ── Free functions ──────────────────────────────────────────────────────────
878
879/// Produce a simple unified-style diff between two byte sequences.
880fn compute_unified_diff(old: &[u8], new: &[u8], path: &str) -> Option<String> {
881    let old_str = std::str::from_utf8(old).ok()?;
882    let new_str = std::str::from_utf8(new).ok()?;
883
884    use similar::{ChangeTag, TextDiff};
885    let diff = TextDiff::from_lines(old_str, new_str);
886
887    let mut output = format!("--- a/{path}\n+++ b/{path}\n");
888    for change in diff.iter_all_changes() {
889        let prefix = match change.tag() {
890            ChangeTag::Delete => '-',
891            ChangeTag::Insert => '+',
892            ChangeTag::Equal => ' ',
893        };
894        output.push_str(&format!("{prefix}{change}"));
895    }
896
897    Some(output)
898}
899
900// ── Tests ───────────────────────────────────────────────────────────────────
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905    use tempfile::TempDir;
906
907    fn setup() -> (TempDir, GitLayer) {
908        let dir = tempfile::tempdir().unwrap();
909        let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
910        (dir, layer)
911    }
912
913    #[test]
914    fn test_init_creates_repo() {
915        let (dir, _) = setup();
916        assert!(dir.path().join(".git").exists());
917    }
918
919    #[test]
920    fn test_commit_file() {
921        let (dir, layer) = setup();
922        std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
923        let info = layer.commit_file("test.json", "test commit").unwrap();
924        assert!(!info.hash.is_empty());
925        assert_eq!(info.short_hash.len(), 7);
926        assert_eq!(info.message, "test commit");
927        assert!(info.hash.starts_with(&info.short_hash));
928    }
929
930    #[test]
931    fn test_log_query() {
932        let (dir, layer) = setup();
933        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
934        layer.commit_file("a.json", "first").unwrap();
935        std::fs::write(dir.path().join("a.json"), b"2").unwrap();
936        layer.commit_file("a.json", "second").unwrap();
937        let log = layer.log(10).unwrap();
938        assert!(log.len() >= 2);
939        assert!(log[0].message.contains("second"));
940    }
941
942    #[test]
943    fn test_tag_create_list() {
944        let (dir, layer) = setup();
945        std::fs::write(dir.path().join("x.json"), b"1").unwrap();
946        layer.commit_file("x.json", "tag test").unwrap();
947        layer.tag("v1", "first tag").unwrap();
948        let tags = layer.list_tags().unwrap();
949        assert!(tags.iter().any(|t| t == "v1"));
950    }
951
952    #[test]
953    fn test_disabled_noop() {
954        let dir = tempfile::tempdir().unwrap();
955        let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
956        std::fs::write(dir.path().join("test.json"), b"1").unwrap();
957        let info = layer.commit_file("test.json", "noop").unwrap();
958        assert_eq!(info.hash, "(disabled)");
959        assert_eq!(info.short_hash, "(dis)");
960    }
961
962    #[test]
963    fn test_log_action() {
964        let (dir, layer) = setup();
965        layer
966            .log_action("agent-A", "read", "file.txt", true, None)
967            .unwrap();
968        let audit_file = dir
969            .path()
970            .join("audit")
971            .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
972        assert!(audit_file.exists());
973        let content = std::fs::read_to_string(&audit_file).unwrap();
974        assert!(content.contains("agent-A"));
975        assert!(content.contains("ALLOW"));
976    }
977
978    #[test]
979    fn test_verify() {
980        let (_, layer) = setup();
981        assert!(layer.verify().unwrap());
982    }
983
984    #[test]
985    fn test_remove_file() {
986        let (dir, layer) = setup();
987        std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
988        layer.commit_file("todelete.json", "add file").unwrap();
989        std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
990        let info = layer.remove_file("todelete.json", "remove file").unwrap();
991        assert!(!info.hash.is_empty());
992        assert!(info.hash != "(disabled)");
993    }
994
995    #[test]
996    fn test_commit_files_batch() {
997        let (dir, layer) = setup();
998        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
999        std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1000        let info = layer
1001            .commit_files(&["a.json", "b.json"], "batch commit")
1002            .unwrap();
1003        assert!(!info.hash.is_empty());
1004        assert_eq!(info.message, "batch commit");
1005    }
1006
1007    #[test]
1008    fn test_restore_file() {
1009        let (dir, layer) = setup();
1010        std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
1011        let first = layer.commit_file("state.json", "v1").unwrap();
1012        std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
1013        layer.commit_file("state.json", "v2").unwrap();
1014        layer.restore_file("state.json", &first.short_hash).unwrap();
1015        let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
1016        assert_eq!(content, "v1");
1017    }
1018
1019    #[test]
1020    fn test_gitignore_created() {
1021        let (dir, _) = setup();
1022        assert!(dir.path().join(".gitignore").exists());
1023        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
1024        assert!(content.contains("Oxios"));
1025    }
1026
1027    // ── B1: Signature timestamps ──────────────────────────────────────────
1028
1029    #[test]
1030    fn test_signature_timestamps_are_fresh() {
1031        // B1 fix: each Signature captures its own timestamp at creation time,
1032        // not a process-wide cached value. Verify that signatures created 1s
1033        // apart produce different timestamps.
1034        let sig1 = Signature::new("a", "a@a");
1035        assert!(!sig1.time.is_empty());
1036
1037        std::thread::sleep(std::time::Duration::from_millis(1100));
1038        let sig3 = Signature::new("c", "c@c");
1039        assert_ne!(
1040            sig1.time, sig3.time,
1041            "Signature created 1s later must have a different timestamp"
1042        );
1043    }
1044
1045    // ── D1: Agent identification ──────────────────────────────────────────
1046
1047    #[test]
1048    fn test_commit_file_with_agent_context() {
1049        let (dir, layer) = setup();
1050        std::fs::write(dir.path().join("agent_work.json"), b"{\"result\":42}").unwrap();
1051
1052        let agent_id = uuid::Uuid::new_v4();
1053        let ctx = CommitContext::agent(agent_id);
1054        layer
1055            .commit_file_with("agent_work.json", "agent did work", ctx)
1056            .unwrap();
1057
1058        let log = layer.log(10).unwrap();
1059        let agent_commit = log
1060            .iter()
1061            .find(|e| e.message.contains("agent did work"))
1062            .expect("should find agent commit");
1063
1064        let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1065        assert_eq!(agent_commit.author, expected_author);
1066    }
1067
1068    #[test]
1069    fn test_commit_file_with_tag() {
1070        let (dir, layer) = setup();
1071        std::fs::write(dir.path().join("audit.json"), b"{\"event\":\"test\"}").unwrap();
1072
1073        let ctx = CommitContext::tagged("audit");
1074        let info = layer
1075            .commit_file_with("audit.json", "flush audit trail", ctx)
1076            .unwrap();
1077
1078        assert!(info.message.contains("[audit]"));
1079        assert!(info.message.contains("flush audit trail"));
1080    }
1081
1082    #[test]
1083    fn test_default_context_is_oxios() {
1084        let (dir, layer) = setup();
1085        std::fs::write(dir.path().join("sys.json"), b"1").unwrap();
1086
1087        let info = layer
1088            .commit_file_with("sys.json", "system commit", CommitContext::default())
1089            .unwrap();
1090
1091        assert_eq!(info.author, "oxios");
1092    }
1093
1094    #[test]
1095    fn test_commit_context_author_name() {
1096        assert_eq!(CommitContext::default().author_name(), "oxios");
1097        assert_eq!(CommitContext::system().author_name(), "oxios");
1098
1099        let id = uuid::Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
1100        assert_eq!(CommitContext::agent(id).author_name(), "agent-aaaaaaaa");
1101
1102        assert_eq!(CommitContext::tagged("memory").author_name(), "oxios");
1103    }
1104
1105    #[test]
1106    fn test_commit_context_message_prefix() {
1107        assert!(CommitContext::default().message_prefix().is_empty());
1108        assert_eq!(CommitContext::tagged("audit").message_prefix(), "[audit] ");
1109
1110        assert_eq!(
1111            CommitContext::tagged("memory").message_prefix(),
1112            "[memory] "
1113        );
1114    }
1115
1116    #[test]
1117    fn test_commit_files_with_context() {
1118        let (dir, layer) = setup();
1119        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1120        std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1121
1122        let agent_id = uuid::Uuid::new_v4();
1123        let ctx = CommitContext::agent(agent_id);
1124        let info = layer
1125            .commit_files_with(&["a.json", "b.json"], "batch agent work", ctx)
1126            .unwrap();
1127
1128        let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1129        assert_eq!(info.author, expected_author);
1130    }
1131
1132    #[test]
1133    fn test_backward_compat_commit_file_is_oxios() {
1134        let (dir, layer) = setup();
1135        std::fs::write(dir.path().join("compat.json"), b"1").unwrap();
1136        let info = layer.commit_file("compat.json", "compat check").unwrap();
1137        assert_eq!(info.author, "oxios");
1138    }
1139
1140    // ── B2: Nested path restore ───────────────────────────────────────────
1141
1142    #[test]
1143    fn test_restore_nested_file() {
1144        let (dir, layer) = setup();
1145
1146        // Create a nested file via log_action.
1147        layer
1148            .log_action("agent-X", "write", "secret.txt", true, None)
1149            .unwrap();
1150
1151        let audit_rel = format!("audit/{}.audit", chrono::Utc::now().format("%Y-%m"));
1152        let audit_path = dir.path().join(&audit_rel);
1153        assert!(audit_path.exists(), "audit file should exist");
1154
1155        // Overwrite it.
1156        let _original = std::fs::read_to_string(&audit_path).unwrap();
1157        std::fs::write(&audit_path, "CORRUPTED").unwrap();
1158        layer.commit_file(&audit_rel, "corrupt").unwrap();
1159
1160        // Find the audit commit and restore.
1161        let log = layer.log(10).unwrap();
1162        let audit_commit = log
1163            .iter()
1164            .find(|e| e.message.contains("audit: agent-X"))
1165            .expect("should find audit commit");
1166
1167        layer
1168            .restore_file(&audit_rel, &audit_commit.short_hash)
1169            .unwrap();
1170
1171        let restored = std::fs::read_to_string(&audit_path).unwrap();
1172        assert!(restored.contains("agent-X"));
1173        assert!(!restored.contains("CORRUPTED"));
1174    }
1175
1176    // ── D3b: list_tags filter ─────────────────────────────────────────────
1177
1178    #[test]
1179    fn test_list_tags_excludes_non_tags() {
1180        let (dir, layer) = setup();
1181        std::fs::write(dir.path().join("t.json"), b"1").unwrap();
1182        layer.commit_file("t.json", "for tag").unwrap();
1183        layer.tag("release-v1", "first release").unwrap();
1184        let tags = layer.list_tags().unwrap();
1185        assert!(tags.iter().any(|t| t == "release-v1"));
1186        assert!(tags.iter().all(|t| t != "main" && t != "HEAD"));
1187    }
1188
1189    // ── Phase 3: Diff ─────────────────────────────────────────────────────
1190
1191    #[test]
1192    fn test_diff_added_file() {
1193        let (dir, layer) = setup();
1194        let first = layer.log(1).unwrap()[0].hash.clone();
1195
1196        std::fs::write(dir.path().join("new.txt"), b"hello\n").unwrap();
1197        let info = layer.commit_file("new.txt", "add file").unwrap();
1198
1199        let diff = layer.diff_commits(&first, &info.hash).unwrap();
1200        assert!(
1201            diff.files
1202                .iter()
1203                .any(|f| f.path == "new.txt" && f.kind == DiffKind::Added)
1204        );
1205    }
1206
1207    #[test]
1208    fn test_diff_modified_file() {
1209        let (dir, layer) = setup();
1210
1211        std::fs::write(dir.path().join("data.txt"), b"v1\n").unwrap();
1212        let first = layer.commit_file("data.txt", "v1").unwrap();
1213
1214        std::fs::write(dir.path().join("data.txt"), b"v2\n").unwrap();
1215        let second = layer.commit_file("data.txt", "v2").unwrap();
1216
1217        let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1218        assert!(
1219            diff.files
1220                .iter()
1221                .any(|f| f.path == "data.txt" && f.kind == DiffKind::Modified)
1222        );
1223
1224        let patch = diff
1225            .files
1226            .iter()
1227            .find(|f| f.path == "data.txt")
1228            .unwrap()
1229            .patch
1230            .as_ref()
1231            .expect("should have patch");
1232        assert!(patch.contains("-v1"));
1233        assert!(patch.contains("+v2"));
1234    }
1235
1236    #[test]
1237    fn test_diff_deleted_file() {
1238        let (dir, layer) = setup();
1239
1240        std::fs::write(dir.path().join("temp.txt"), b"bye\n").unwrap();
1241        let first = layer.commit_file("temp.txt", "add temp").unwrap();
1242
1243        std::fs::remove_file(dir.path().join("temp.txt")).unwrap();
1244        let second = layer.remove_file("temp.txt", "remove temp").unwrap();
1245
1246        let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1247        assert!(
1248            diff.files
1249                .iter()
1250                .any(|f| f.path == "temp.txt" && f.kind == DiffKind::Deleted)
1251        );
1252    }
1253
1254    #[test]
1255    fn test_file_at_commit() {
1256        let (dir, layer) = setup();
1257
1258        std::fs::write(dir.path().join("state.json"), b"{\"v\":1}").unwrap();
1259        let first = layer.commit_file("state.json", "v1").unwrap();
1260
1261        std::fs::write(dir.path().join("state.json"), b"{\"v\":2}").unwrap();
1262        layer.commit_file("state.json", "v2").unwrap();
1263
1264        let content = layer
1265            .file_at_commit("state.json", &first.short_hash)
1266            .unwrap();
1267        assert_eq!(content, b"{\"v\":1}");
1268    }
1269}