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        // I-3: Skip commit if the file content is byte-identical to the
398        // existing HEAD tree entry. Avoids empty-diff commits from no-op
399        // writes (agent re-saves, debounce no-ops, dream curation, etc.).
400        if let Ok(existing) = Self::find_blob_in_tree(&repo, head_tree, rel_path)
401            && existing == blob_id
402        {
403            tracing::debug!(path = rel_path, "Skipping identical-content commit");
404            return self.noop_commit(&ctx, message);
405        }
406        let mut editor = repo.edit_tree(head_tree)?;
407        editor.upsert(rel_path, EntryKind::Blob, blob_id)?;
408        let tree_id = editor.write()?;
409
410        let parent = repo.head_id().ok().map(|id| id.detach());
411        let author_name = ctx.author_name();
412        let full_message = format!("{}{}", ctx.message_prefix(), message);
413        let sig = Signature::new(&author_name, &self.committer_email);
414        let commit_id = repo.commit_as(
415            sig.as_ref(),
416            sig.as_ref(),
417            "refs/heads/main",
418            &full_message,
419            tree_id.detach(),
420            parent.into_iter().collect::<Vec<_>>(),
421        )?;
422
423        Ok(self.make_info(&commit_id, &full_message, &author_name))
424    }
425
426    /// Commit multiple files in a single commit (backward-compatible).
427    pub fn commit_files(&self, rel_paths: &[&str], message: &str) -> Result<CommitInfo> {
428        self.commit_files_with(rel_paths, message, CommitContext::default())
429    }
430
431    /// Commit multiple files with a message and explicit commit context.
432    pub fn commit_files_with(
433        &self,
434        rel_paths: &[&str],
435        message: &str,
436        ctx: CommitContext,
437    ) -> Result<CommitInfo> {
438        if !self.enabled {
439            return self.noop_commit(&ctx, message);
440        }
441        let repo = self.repo.lock();
442        let head_tree = Self::head_tree_oid(&repo)?;
443        let mut editor = repo.edit_tree(head_tree)?;
444
445        for path in rel_paths {
446            let abs = self.ensure_within_root(path)?;
447            if abs.exists() {
448                let content = std::fs::read(&abs)?;
449                let blob_id = repo.write_blob(&content)?;
450                editor.upsert(*path, EntryKind::Blob, blob_id)?;
451            }
452        }
453        let tree_id = editor.write()?;
454
455        let parent = repo.head_id().ok().map(|id| id.detach());
456        let author_name = ctx.author_name();
457        let full_message = format!("{}{}", ctx.message_prefix(), message);
458        let sig = Signature::new(&author_name, &self.committer_email);
459        let commit_id = repo.commit_as(
460            sig.as_ref(),
461            sig.as_ref(),
462            "refs/heads/main",
463            &full_message,
464            tree_id.detach(),
465            parent.into_iter().collect::<Vec<_>>(),
466        )?;
467
468        Ok(self.make_info(&commit_id, &full_message, &author_name))
469    }
470
471    /// Remove a file from the repo and commit.
472    pub fn remove_file(&self, rel_path: &str, message: &str) -> Result<CommitInfo> {
473        if !self.enabled {
474            return self.noop_commit(&CommitContext::default(), message);
475        }
476        // Validate the tree key (defense-in-depth: remove() does not touch disk
477        // but the path is used to locate the blob in the commit tree).
478        self.ensure_within_root(rel_path)?;
479        let repo = self.repo.lock();
480        let head_tree = Self::head_tree_oid(&repo)?;
481        let mut editor = repo.edit_tree(head_tree)?;
482        editor.remove(rel_path)?;
483        let tree_id = editor.write()?;
484
485        let parent = repo.head_id().ok().map(|id| id.detach());
486        let sig = Signature::new("oxios", &self.committer_email);
487        let commit_id = repo.commit_as(
488            sig.as_ref(),
489            sig.as_ref(),
490            "refs/heads/main",
491            message,
492            tree_id.detach(),
493            parent.into_iter().collect::<Vec<_>>(),
494        )?;
495
496        Ok(self.make_info(&commit_id, message, "oxios"))
497    }
498
499    /// Append an audit entry to a monthly audit log file and commit it.
500    pub fn log_action(
501        &self,
502        agent: &str,
503        action: &str,
504        target: &str,
505        allowed: bool,
506        detail: Option<&str>,
507    ) -> Result<()> {
508        let now = chrono::Utc::now();
509        let filename = format!("audit/{}.audit", now.format("%Y-%m"));
510        let entry = format!(
511            "{} | {} | {} | {} | {} | {}\n",
512            now.to_rfc3339(),
513            agent,
514            action,
515            target,
516            if allowed { "ALLOW" } else { "DENY" },
517            detail.unwrap_or("-")
518        );
519        let dir = self.root.join("audit");
520        std::fs::create_dir_all(&dir)?;
521        use std::io::Write;
522        std::fs::OpenOptions::new()
523            .create(true)
524            .append(true)
525            .open(self.root.join(&filename))?
526            .write_all(entry.as_bytes())?;
527        self.commit_file(&filename, &format!("audit: {agent} {action} {target}"))?;
528        Ok(())
529    }
530
531    // ── Tags ──────────────────────────────────────────────────────────────
532
533    /// Create an annotated tag at the current HEAD.
534    pub fn tag(&self, name: &str, message: &str) -> Result<()> {
535        if !self.enabled {
536            return Ok(());
537        }
538        let repo = self.repo.lock();
539        let head_id = repo
540            .head_id()
541            .ok()
542            .map(|id| id.detach())
543            .ok_or_else(|| anyhow::anyhow!("No HEAD commit to tag"))?;
544        let sig = Signature::new("oxios", &self.committer_email);
545        repo.tag(
546            name,
547            head_id,
548            gix::objs::Kind::Commit,
549            Some(sig.as_ref()),
550            message,
551            PreviousValue::MustNotExist,
552        )?;
553        Ok(())
554    }
555
556    /// List all tags in the repository.
557    ///
558    /// Uses `Category::Tag` to correctly filter only tag refs.
559    pub fn list_tags(&self) -> Result<Vec<String>> {
560        let repo = self.repo.lock();
561        let mut tags = Vec::new();
562        for reference in repo.references()?.all()? {
563            let reference = reference.map_err(|e| anyhow::anyhow!("ref iter: {e:#}"))?;
564            if reference
565                .name()
566                .category()
567                .is_some_and(|c| matches!(c, gix::refs::Category::Tag))
568            {
569                tags.push(reference.name().shorten().to_string());
570            }
571        }
572        Ok(tags)
573    }
574
575    /// Delete a tag by name. Fails if the tag does not exist.
576    pub fn delete_tag(&self, name: &str) -> Result<()> {
577        if !self.enabled {
578            return Ok(());
579        }
580        let repo = self.repo.lock();
581        // Locate the tag reference first, then delete it after the search
582        // iterator is dropped so we don't hold a borrow across the edit.
583        let target = repo
584            .references()?
585            .all()?
586            .filter_map(|r| r.ok())
587            .find(|r| {
588                r.name()
589                    .category()
590                    .is_some_and(|c| matches!(c, gix::refs::Category::Tag))
591                    && r.name().shorten() == name
592            })
593            .ok_or_else(|| anyhow::anyhow!("tag not found: {name}"))?;
594        target
595            .delete()
596            .map_err(|e| anyhow::anyhow!("delete tag: {e:#}"))?;
597        Ok(())
598    }
599
600    // ── Log / resolve ─────────────────────────────────────────────────────
601
602    /// Return commit log entries, most recent first.
603    pub fn log(&self, max_count: usize) -> Result<Vec<LogEntry>> {
604        let repo = self.repo.lock();
605        let head_id = repo.head_id()?.detach();
606        let mut entries = Vec::new();
607        let mut current_id: Option<ObjectId> = Some(head_id);
608
609        while let Some(id) = current_id {
610            if entries.len() >= max_count {
611                break;
612            }
613            let commit = repo.find_commit(id)?;
614            let decoded = commit.decode()?;
615            let msg_ref = decoded.message();
616            let msg = if let Some(body) = msg_ref.body {
617                format!("{}\n\n{}", msg_ref.title, body)
618            } else {
619                msg_ref.title.to_string()
620            };
621            let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
622            let author = decoded
623                .author()
624                .map(|a| a.name.to_string())
625                .unwrap_or_default();
626            let hex = id.to_hex().to_string();
627            entries.push(LogEntry {
628                hash: hex.clone(),
629                short_hash: hex[..7].into(),
630                message: msg,
631                timestamp,
632                author,
633            });
634            current_id = decoded.parents().next();
635        }
636
637        Ok(entries)
638    }
639
640    /// Resolve a partial commit hash to full ObjectId.
641    pub fn resolve_partial_hash(&self, partial: &str) -> Result<ObjectId> {
642        if partial.len() < 4 {
643            bail!("Partial hash too short (minimum 4 characters)");
644        }
645        if partial.len() >= 40 {
646            return Ok(ObjectId::from_hex(partial.as_bytes())?);
647        }
648        let repo = self.repo.lock();
649        let id = repo.rev_parse_single(BStr::new(partial))?;
650        Ok(id.detach())
651    }
652
653    /// Resolve a hash string using a pre-locked repo.
654    fn resolve_hash_inner(&self, repo: &gix::Repository, partial: &str) -> Result<ObjectId> {
655        if partial.len() >= 40 {
656            return Ok(ObjectId::from_hex(partial.as_bytes())?);
657        }
658        if partial.len() < 4 {
659            bail!("Hash too short (minimum 4 characters)");
660        }
661        let id = repo.rev_parse_single(BStr::new(partial))?;
662        Ok(id.detach())
663    }
664
665    // ── Restore ───────────────────────────────────────────────────────────
666
667    /// Restore a file to its state in a specific commit.
668    ///
669    /// Supports nested paths like `audit/2024-05.audit` by traversing
670    /// each path component through sub-trees.
671    pub fn restore_file(&self, rel_path: &str, hash: &str) -> Result<()> {
672        // Validate the destination before resolving the blob — defense-in-depth
673        // against writing attacker-controlled content to an arbitrary path.
674        let dest = self.ensure_within_root(rel_path)?;
675        let commit_id = self.resolve_partial_hash(hash)?;
676        let repo = self.repo.lock();
677        let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
678        let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
679        let blob = repo.find_blob(blob_id)?;
680        std::fs::write(dest, &blob.data)?;
681        Ok(())
682    }
683
684    /// Read a file's content at a specific commit without writing to disk.
685    ///
686    /// Unlike [`restore_file`], this does NOT touch the filesystem — it
687    /// returns the blob data directly. Callers that need to write the
688    /// content should pass it through `KnowledgeBase::note_restore`,
689    /// which acquires the VirtualFs write lock. This avoids a race where
690    /// `restore_file`'s direct `std::fs::write` bypasses the lock and
691    /// clobbers a concurrent `note_write` (I-4).
692    pub fn file_at_commit(&self, rel_path: &str, hash: &str) -> Result<Vec<u8>> {
693        // Validate path (defense-in-depth, same as restore_file).
694        self.ensure_within_root(rel_path)?;
695        let commit_id = self.resolve_partial_hash(hash)?;
696        let repo = self.repo.lock();
697        let commit_tree_id = Self::commit_tree_id(&repo, commit_id)?;
698        let blob_id = Self::find_blob_in_tree(&repo, commit_tree_id, rel_path)?;
699        let blob = repo.find_blob(blob_id)?;
700        Ok(blob.data.to_vec())
701    }
702
703    /// Return commit log entries for a specific file, most recent first.
704    ///
705    /// Walks the commit graph and includes only commits where the file's
706    /// blob OID actually changed (tree diff, not message string matching).
707    /// Replaces the old approach of filtering `log(N)` by commit message
708    /// substring, which was inaccurate and limited to N entries.
709    pub fn log_for_file(&self, rel_path: &str, max_count: usize) -> Result<Vec<LogEntry>> {
710        if !self.enabled {
711            return Ok(Vec::new());
712        }
713        let repo = self.repo.lock();
714        let head_id = repo.head_id()?.detach();
715        let mut entries = Vec::new();
716        let mut current_id: Option<ObjectId> = Some(head_id);
717        let mut prev_blob: Option<ObjectId> = None;
718
719        while let Some(id) = current_id {
720            if entries.len() >= max_count {
721                break;
722            }
723            let commit = repo.find_commit(id)?;
724            let decoded = commit.decode()?;
725            let tree_id = decoded.tree();
726            let current_blob = Self::find_blob_in_tree(&repo, tree_id, rel_path).ok();
727            if current_blob != prev_blob {
728                let msg_ref = decoded.message();
729                let msg = if let Some(body) = msg_ref.body {
730                    format!("{}\n\n{}", msg_ref.title, body)
731                } else {
732                    msg_ref.title.to_string()
733                };
734                let timestamp = decoded.time().map(|t| t.to_string()).unwrap_or_default();
735                let author = decoded
736                    .author()
737                    .map(|a| a.name.to_string())
738                    .unwrap_or_default();
739                let hex = id.to_hex().to_string();
740                entries.push(LogEntry {
741                    hash: hex.clone(),
742                    short_hash: hex[..7].into(),
743                    message: msg,
744                    timestamp,
745                    author,
746                });
747            }
748            prev_blob = current_blob;
749            current_id = decoded.parents().next();
750        }
751        Ok(entries)
752    }
753
754    // ── Diff API (Phase 3) ────────────────────────────────────────────────
755
756    /// Compute the diff between two commits.
757    pub fn diff_commits(&self, from_hash: &str, to_hash: &str) -> Result<CommitDiff> {
758        let repo = self.repo.lock();
759        let from_id = self.resolve_hash_inner(&repo, from_hash)?;
760        let to_id = self.resolve_hash_inner(&repo, to_hash)?;
761
762        let from_tree_id = Self::commit_tree_id(&repo, from_id)?;
763        let to_tree_id = Self::commit_tree_id(&repo, to_id)?;
764
765        let mut files = Vec::new();
766        Self::diff_trees(&repo, from_tree_id, to_tree_id, "", &mut files)?;
767
768        // Compute patches for modified/added files.
769        for fd in &mut files {
770            let old_data = fd
771                .old_hash
772                .as_ref()
773                .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
774                .and_then(|id| repo.find_blob(id).ok())
775                .map(|b| b.data.to_vec());
776            let new_data = fd
777                .new_hash
778                .as_ref()
779                .and_then(|h| ObjectId::from_hex(h.as_bytes()).ok())
780                .and_then(|id| repo.find_blob(id).ok())
781                .map(|b| b.data.to_vec());
782
783            match (&old_data, &new_data) {
784                (Some(old), Some(new)) => {
785                    fd.patch = compute_unified_diff(old, new, &fd.path);
786                }
787                (None, Some(new)) => {
788                    fd.patch = compute_unified_diff(&[], new, &fd.path);
789                }
790                _ => {}
791            }
792        }
793
794        let stats = DiffStats {
795            files_changed: files.len(),
796            additions: files
797                .iter()
798                .filter_map(|f| f.patch.as_ref())
799                .map(|p| {
800                    p.lines()
801                        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
802                        .count()
803                })
804                .sum(),
805            deletions: files
806                .iter()
807                .filter_map(|f| f.patch.as_ref())
808                .map(|p| {
809                    p.lines()
810                        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
811                        .count()
812                })
813                .sum(),
814        };
815
816        Ok(CommitDiff {
817            from_hash: from_id.to_hex().to_string(),
818            to_hash: to_id.to_hex().to_string(),
819            files,
820            stats,
821        })
822    }
823
824    // file_at_commit is defined above (line 692) — this duplicate removed.
825
826    // ── Diff helpers ──────────────────────────────────────────────────────
827
828    /// Recursively compare two trees and collect changed files.
829    fn diff_trees(
830        repo: &gix::Repository,
831        old_tree: ObjectId,
832        new_tree: ObjectId,
833        prefix: &str,
834        changes: &mut Vec<FileDiff>,
835    ) -> Result<()> {
836        let old_tree_obj = repo.find_tree(old_tree)?;
837        let old_decoded = old_tree_obj.decode()?;
838        let new_tree_obj = repo.find_tree(new_tree)?;
839        let new_decoded = new_tree_obj.decode()?;
840
841        let old_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
842            old_decoded
843                .entries
844                .iter()
845                .map(|e| (e.filename, e))
846                .collect();
847        let new_entries: std::collections::HashMap<&BStr, &gix::objs::tree::EntryRef<'_>> =
848            new_decoded
849                .entries
850                .iter()
851                .map(|e| (e.filename, e))
852                .collect();
853
854        // Detect additions and modifications.
855        for (name, new_entry) in &new_entries {
856            let path = format!("{prefix}{name}");
857            match old_entries.get(name) {
858                None => {
859                    if new_entry.mode.is_tree() {
860                        let empty = ObjectId::empty_tree(repo.object_hash());
861                        Self::diff_trees(
862                            repo,
863                            empty,
864                            new_entry.oid.to_owned(),
865                            &format!("{path}/"),
866                            changes,
867                        )?;
868                    } else {
869                        changes.push(FileDiff {
870                            path,
871                            old_hash: None,
872                            new_hash: Some(new_entry.oid.to_hex().to_string()),
873                            kind: DiffKind::Added,
874                            patch: None,
875                        });
876                    }
877                }
878                Some(old_entry) => {
879                    if old_entry.oid == new_entry.oid {
880                        continue;
881                    }
882                    if new_entry.mode.is_tree() && old_entry.mode.is_tree() {
883                        Self::diff_trees(
884                            repo,
885                            old_entry.oid.to_owned(),
886                            new_entry.oid.to_owned(),
887                            &format!("{path}/"),
888                            changes,
889                        )?;
890                    } else {
891                        changes.push(FileDiff {
892                            path,
893                            old_hash: Some(old_entry.oid.to_hex().to_string()),
894                            new_hash: Some(new_entry.oid.to_hex().to_string()),
895                            kind: DiffKind::Modified,
896                            patch: None,
897                        });
898                    }
899                }
900            }
901        }
902
903        // Detect deletions.
904        for (name, old_entry) in &old_entries {
905            if new_entries.contains_key(name) {
906                continue;
907            }
908            let path = format!("{prefix}{name}");
909            changes.push(FileDiff {
910                path,
911                old_hash: Some(old_entry.oid.to_hex().to_string()),
912                new_hash: None,
913                kind: DiffKind::Deleted,
914                patch: None,
915            });
916        }
917
918        Ok(())
919    }
920
921    // ── Verify / accessors ────────────────────────────────────────────────
922
923    /// Verify repository integrity.
924    pub fn verify(&self) -> Result<bool> {
925        let repo = self.repo.lock();
926        let refs = repo.references()?;
927        for reference in refs.all()? {
928            let _ = reference.map_err(|e| anyhow::anyhow!("ref verify: {e:#}"))?;
929        }
930        if repo.head_id().is_err() {
931            tracing::debug!("verify: no HEAD yet (empty repository)");
932        }
933        Ok(true)
934    }
935
936    /// Whether auto-commit is enabled.
937    pub fn is_enabled(&self) -> bool {
938        self.enabled
939    }
940
941    /// Get the root path of this git repository.
942    pub fn root(&self) -> &Path {
943        &self.root
944    }
945
946    // ── Private info builders ─────────────────────────────────────────────
947
948    fn noop_commit(&self, ctx: &CommitContext, message: &str) -> Result<CommitInfo> {
949        Ok(CommitInfo {
950            hash: "(disabled)".into(),
951            short_hash: "(dis)".into(),
952            message: message.into(),
953            timestamp: chrono::Utc::now().to_rfc3339(),
954            author: ctx.author_name(),
955        })
956    }
957
958    fn make_info(&self, id: &gix::Id, message: &str, author: &str) -> CommitInfo {
959        let hex = id.to_hex().to_string();
960        CommitInfo {
961            short_hash: hex[..7].into(),
962            hash: hex,
963            message: message.into(),
964            timestamp: chrono::Utc::now().to_rfc3339(),
965            author: author.into(),
966        }
967    }
968}
969
970// ── Free functions ──────────────────────────────────────────────────────────
971
972/// Produce a simple unified-style diff between two byte sequences.
973fn compute_unified_diff(old: &[u8], new: &[u8], path: &str) -> Option<String> {
974    let old_str = std::str::from_utf8(old).ok()?;
975    let new_str = std::str::from_utf8(new).ok()?;
976
977    use similar::{ChangeTag, TextDiff};
978    let diff = TextDiff::from_lines(old_str, new_str);
979
980    let mut output = format!("--- a/{path}\n+++ b/{path}\n");
981    for change in diff.iter_all_changes() {
982        let prefix = match change.tag() {
983            ChangeTag::Delete => '-',
984            ChangeTag::Insert => '+',
985            ChangeTag::Equal => ' ',
986        };
987        output.push_str(&format!("{prefix}{change}"));
988    }
989
990    Some(output)
991}
992
993// ── Tests ───────────────────────────────────────────────────────────────────
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998    use tempfile::TempDir;
999
1000    fn setup() -> (TempDir, GitLayer) {
1001        let dir = tempfile::tempdir().unwrap();
1002        let layer = GitLayer::new(dir.path().to_path_buf(), true).unwrap();
1003        (dir, layer)
1004    }
1005
1006    #[test]
1007    fn test_init_creates_repo() {
1008        let (dir, _) = setup();
1009        assert!(dir.path().join(".git").exists());
1010    }
1011
1012    #[test]
1013    fn test_commit_file() {
1014        let (dir, layer) = setup();
1015        std::fs::write(dir.path().join("test.json"), b"{\"hello\":1}").unwrap();
1016        let info = layer.commit_file("test.json", "test commit").unwrap();
1017        assert!(!info.hash.is_empty());
1018        assert_eq!(info.short_hash.len(), 7);
1019        assert_eq!(info.message, "test commit");
1020        assert!(info.hash.starts_with(&info.short_hash));
1021    }
1022
1023    #[test]
1024    fn test_log_query() {
1025        let (dir, layer) = setup();
1026        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1027        layer.commit_file("a.json", "first").unwrap();
1028        std::fs::write(dir.path().join("a.json"), b"2").unwrap();
1029        layer.commit_file("a.json", "second").unwrap();
1030        let log = layer.log(10).unwrap();
1031        assert!(log.len() >= 2);
1032        assert!(log[0].message.contains("second"));
1033    }
1034
1035    #[test]
1036    fn test_tag_create_list() {
1037        let (dir, layer) = setup();
1038        std::fs::write(dir.path().join("x.json"), b"1").unwrap();
1039        layer.commit_file("x.json", "tag test").unwrap();
1040        layer.tag("v1", "first tag").unwrap();
1041        let tags = layer.list_tags().unwrap();
1042        assert!(tags.iter().any(|t| t == "v1"));
1043    }
1044
1045    #[test]
1046    fn test_disabled_noop() {
1047        let dir = tempfile::tempdir().unwrap();
1048        let layer = GitLayer::new(dir.path().to_path_buf(), false).unwrap();
1049        std::fs::write(dir.path().join("test.json"), b"1").unwrap();
1050        let info = layer.commit_file("test.json", "noop").unwrap();
1051        assert_eq!(info.hash, "(disabled)");
1052        assert_eq!(info.short_hash, "(dis)");
1053    }
1054
1055    #[test]
1056    fn test_log_action() {
1057        let (dir, layer) = setup();
1058        layer
1059            .log_action("agent-A", "read", "file.txt", true, None)
1060            .unwrap();
1061        let audit_file = dir
1062            .path()
1063            .join("audit")
1064            .join(format!("{}.audit", chrono::Utc::now().format("%Y-%m")));
1065        assert!(audit_file.exists());
1066        let content = std::fs::read_to_string(&audit_file).unwrap();
1067        assert!(content.contains("agent-A"));
1068        assert!(content.contains("ALLOW"));
1069    }
1070
1071    #[test]
1072    fn test_verify() {
1073        let (_, layer) = setup();
1074        assert!(layer.verify().unwrap());
1075    }
1076
1077    #[test]
1078    fn test_remove_file() {
1079        let (dir, layer) = setup();
1080        std::fs::write(dir.path().join("todelete.json"), b"1").unwrap();
1081        layer.commit_file("todelete.json", "add file").unwrap();
1082        std::fs::remove_file(dir.path().join("todelete.json")).unwrap();
1083        let info = layer.remove_file("todelete.json", "remove file").unwrap();
1084        assert!(!info.hash.is_empty());
1085        assert!(info.hash != "(disabled)");
1086    }
1087
1088    #[test]
1089    fn test_commit_files_batch() {
1090        let (dir, layer) = setup();
1091        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1092        std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1093        let info = layer
1094            .commit_files(&["a.json", "b.json"], "batch commit")
1095            .unwrap();
1096        assert!(!info.hash.is_empty());
1097        assert_eq!(info.message, "batch commit");
1098    }
1099
1100    #[test]
1101    fn test_restore_file() {
1102        let (dir, layer) = setup();
1103        std::fs::write(dir.path().join("state.json"), b"v1").unwrap();
1104        let first = layer.commit_file("state.json", "v1").unwrap();
1105        std::fs::write(dir.path().join("state.json"), b"v2").unwrap();
1106        layer.commit_file("state.json", "v2").unwrap();
1107        layer.restore_file("state.json", &first.short_hash).unwrap();
1108        let content = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
1109        assert_eq!(content, "v1");
1110    }
1111
1112    #[test]
1113    fn test_gitignore_created() {
1114        let (dir, _) = setup();
1115        assert!(dir.path().join(".gitignore").exists());
1116        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
1117        assert!(content.contains("Oxios"));
1118    }
1119
1120    // ── B1: Signature timestamps ──────────────────────────────────────────
1121
1122    #[test]
1123    fn test_signature_timestamps_are_fresh() {
1124        // B1 fix: each Signature captures its own timestamp at creation time,
1125        // not a process-wide cached value. Verify that signatures created 1s
1126        // apart produce different timestamps.
1127        let sig1 = Signature::new("a", "a@a");
1128        assert!(!sig1.time.is_empty());
1129
1130        std::thread::sleep(std::time::Duration::from_millis(1100));
1131        let sig3 = Signature::new("c", "c@c");
1132        assert_ne!(
1133            sig1.time, sig3.time,
1134            "Signature created 1s later must have a different timestamp"
1135        );
1136    }
1137
1138    // ── D1: Agent identification ──────────────────────────────────────────
1139
1140    #[test]
1141    fn test_commit_file_with_agent_context() {
1142        let (dir, layer) = setup();
1143        std::fs::write(dir.path().join("agent_work.json"), b"{\"result\":42}").unwrap();
1144
1145        let agent_id = uuid::Uuid::new_v4();
1146        let ctx = CommitContext::agent(agent_id);
1147        layer
1148            .commit_file_with("agent_work.json", "agent did work", ctx)
1149            .unwrap();
1150
1151        let log = layer.log(10).unwrap();
1152        let agent_commit = log
1153            .iter()
1154            .find(|e| e.message.contains("agent did work"))
1155            .expect("should find agent commit");
1156
1157        let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1158        assert_eq!(agent_commit.author, expected_author);
1159    }
1160
1161    #[test]
1162    fn test_commit_file_with_tag() {
1163        let (dir, layer) = setup();
1164        std::fs::write(dir.path().join("audit.json"), b"{\"event\":\"test\"}").unwrap();
1165
1166        let ctx = CommitContext::tagged("audit");
1167        let info = layer
1168            .commit_file_with("audit.json", "flush audit trail", ctx)
1169            .unwrap();
1170
1171        assert!(info.message.contains("[audit]"));
1172        assert!(info.message.contains("flush audit trail"));
1173    }
1174
1175    #[test]
1176    fn test_default_context_is_oxios() {
1177        let (dir, layer) = setup();
1178        std::fs::write(dir.path().join("sys.json"), b"1").unwrap();
1179
1180        let info = layer
1181            .commit_file_with("sys.json", "system commit", CommitContext::default())
1182            .unwrap();
1183
1184        assert_eq!(info.author, "oxios");
1185    }
1186
1187    #[test]
1188    fn test_commit_context_author_name() {
1189        assert_eq!(CommitContext::default().author_name(), "oxios");
1190        assert_eq!(CommitContext::system().author_name(), "oxios");
1191
1192        let id = uuid::Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").unwrap();
1193        assert_eq!(CommitContext::agent(id).author_name(), "agent-aaaaaaaa");
1194
1195        assert_eq!(CommitContext::tagged("memory").author_name(), "oxios");
1196    }
1197
1198    #[test]
1199    fn test_commit_context_message_prefix() {
1200        assert!(CommitContext::default().message_prefix().is_empty());
1201        assert_eq!(CommitContext::tagged("audit").message_prefix(), "[audit] ");
1202
1203        assert_eq!(
1204            CommitContext::tagged("memory").message_prefix(),
1205            "[memory] "
1206        );
1207    }
1208
1209    #[test]
1210    fn test_commit_files_with_context() {
1211        let (dir, layer) = setup();
1212        std::fs::write(dir.path().join("a.json"), b"1").unwrap();
1213        std::fs::write(dir.path().join("b.json"), b"2").unwrap();
1214
1215        let agent_id = uuid::Uuid::new_v4();
1216        let ctx = CommitContext::agent(agent_id);
1217        let info = layer
1218            .commit_files_with(&["a.json", "b.json"], "batch agent work", ctx)
1219            .unwrap();
1220
1221        let expected_author = format!("agent-{}", &agent_id.to_string()[..8]);
1222        assert_eq!(info.author, expected_author);
1223    }
1224
1225    #[test]
1226    fn test_backward_compat_commit_file_is_oxios() {
1227        let (dir, layer) = setup();
1228        std::fs::write(dir.path().join("compat.json"), b"1").unwrap();
1229        let info = layer.commit_file("compat.json", "compat check").unwrap();
1230        assert_eq!(info.author, "oxios");
1231    }
1232
1233    // ── B2: Nested path restore ───────────────────────────────────────────
1234
1235    #[test]
1236    fn test_restore_nested_file() {
1237        let (dir, layer) = setup();
1238
1239        // Create a nested file via log_action.
1240        layer
1241            .log_action("agent-X", "write", "secret.txt", true, None)
1242            .unwrap();
1243
1244        let audit_rel = format!("audit/{}.audit", chrono::Utc::now().format("%Y-%m"));
1245        let audit_path = dir.path().join(&audit_rel);
1246        assert!(audit_path.exists(), "audit file should exist");
1247
1248        // Overwrite it.
1249        let _original = std::fs::read_to_string(&audit_path).unwrap();
1250        std::fs::write(&audit_path, "CORRUPTED").unwrap();
1251        layer.commit_file(&audit_rel, "corrupt").unwrap();
1252
1253        // Find the audit commit and restore.
1254        let log = layer.log(10).unwrap();
1255        let audit_commit = log
1256            .iter()
1257            .find(|e| e.message.contains("audit: agent-X"))
1258            .expect("should find audit commit");
1259
1260        layer
1261            .restore_file(&audit_rel, &audit_commit.short_hash)
1262            .unwrap();
1263
1264        let restored = std::fs::read_to_string(&audit_path).unwrap();
1265        assert!(restored.contains("agent-X"));
1266        assert!(!restored.contains("CORRUPTED"));
1267    }
1268
1269    // ── D3b: list_tags filter ─────────────────────────────────────────────
1270
1271    #[test]
1272    fn test_list_tags_excludes_non_tags() {
1273        let (dir, layer) = setup();
1274        std::fs::write(dir.path().join("t.json"), b"1").unwrap();
1275        layer.commit_file("t.json", "for tag").unwrap();
1276        layer.tag("release-v1", "first release").unwrap();
1277        let tags = layer.list_tags().unwrap();
1278        assert!(tags.iter().any(|t| t == "release-v1"));
1279        assert!(tags.iter().all(|t| t != "main" && t != "HEAD"));
1280    }
1281
1282    // ── Phase 3: Diff ─────────────────────────────────────────────────────
1283
1284    #[test]
1285    fn test_diff_added_file() {
1286        let (dir, layer) = setup();
1287        let first = layer.log(1).unwrap()[0].hash.clone();
1288
1289        std::fs::write(dir.path().join("new.txt"), b"hello\n").unwrap();
1290        let info = layer.commit_file("new.txt", "add file").unwrap();
1291
1292        let diff = layer.diff_commits(&first, &info.hash).unwrap();
1293        assert!(
1294            diff.files
1295                .iter()
1296                .any(|f| f.path == "new.txt" && f.kind == DiffKind::Added)
1297        );
1298    }
1299
1300    #[test]
1301    fn test_diff_modified_file() {
1302        let (dir, layer) = setup();
1303
1304        std::fs::write(dir.path().join("data.txt"), b"v1\n").unwrap();
1305        let first = layer.commit_file("data.txt", "v1").unwrap();
1306
1307        std::fs::write(dir.path().join("data.txt"), b"v2\n").unwrap();
1308        let second = layer.commit_file("data.txt", "v2").unwrap();
1309
1310        let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1311        assert!(
1312            diff.files
1313                .iter()
1314                .any(|f| f.path == "data.txt" && f.kind == DiffKind::Modified)
1315        );
1316
1317        let patch = diff
1318            .files
1319            .iter()
1320            .find(|f| f.path == "data.txt")
1321            .unwrap()
1322            .patch
1323            .as_ref()
1324            .expect("should have patch");
1325        assert!(patch.contains("-v1"));
1326        assert!(patch.contains("+v2"));
1327    }
1328
1329    #[test]
1330    fn test_diff_deleted_file() {
1331        let (dir, layer) = setup();
1332
1333        std::fs::write(dir.path().join("temp.txt"), b"bye\n").unwrap();
1334        let first = layer.commit_file("temp.txt", "add temp").unwrap();
1335
1336        std::fs::remove_file(dir.path().join("temp.txt")).unwrap();
1337        let second = layer.remove_file("temp.txt", "remove temp").unwrap();
1338
1339        let diff = layer.diff_commits(&first.hash, &second.hash).unwrap();
1340        assert!(
1341            diff.files
1342                .iter()
1343                .any(|f| f.path == "temp.txt" && f.kind == DiffKind::Deleted)
1344        );
1345    }
1346
1347    #[test]
1348    fn test_file_at_commit() {
1349        let (dir, layer) = setup();
1350
1351        std::fs::write(dir.path().join("state.json"), b"{\"v\":1}").unwrap();
1352        let first = layer.commit_file("state.json", "v1").unwrap();
1353
1354        std::fs::write(dir.path().join("state.json"), b"{\"v\":2}").unwrap();
1355        layer.commit_file("state.json", "v2").unwrap();
1356
1357        let content = layer
1358            .file_at_commit("state.json", &first.short_hash)
1359            .unwrap();
1360        assert_eq!(content, b"{\"v\":1}");
1361    }
1362}