securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpEntry {
    pub id: String,
    pub timestamp: String,
    pub command: String,
    pub description: String,
    pub before: RepoSnapshot,
    pub after: RepoSnapshot,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoSnapshot {
    pub head_oid: Option<String>,
    pub head_ref: Option<String>,
    pub branches: HashMap<String, String>,
    pub tags: HashMap<String, String>,
    pub index_tree_oid: Option<String>,
}

fn oplog_dir(path: &Path) -> PathBuf {
    path.join(".git/securegit")
}

fn oplog_path(path: &Path) -> PathBuf {
    oplog_dir(path).join("oplog.jsonl")
}

/// Capture the current repo state as a snapshot.
pub fn snapshot_repo(path: &Path) -> Result<RepoSnapshot> {
    let repo = crate::ops::open_repo(path)?;

    let head_oid = repo
        .head()
        .ok()
        .and_then(|h| h.target())
        .map(|oid| oid.to_string());

    let head_ref = repo.head().ok().and_then(|h| {
        if h.is_branch() {
            h.name().map(|s| s.to_string())
        } else {
            None
        }
    });

    let mut branches = HashMap::new();
    if let Ok(branch_iter) = repo.branches(Some(git2::BranchType::Local)) {
        for (branch, _) in branch_iter.flatten() {
            if let (Ok(Some(name)), Some(oid)) = (branch.name(), branch.get().target()) {
                branches.insert(name.to_string(), oid.to_string());
            }
        }
    }

    let mut tags = HashMap::new();
    if let Ok(tag_names) = repo.tag_names(None) {
        for tag_name in tag_names.iter().flatten() {
            if let Ok(reference) = repo.find_reference(&format!("refs/tags/{}", tag_name)) {
                if let Some(oid) = reference.target() {
                    tags.insert(tag_name.to_string(), oid.to_string());
                }
            }
        }
    }

    let index_tree_oid = repo
        .index()
        .ok()
        .and_then(|mut idx| idx.write_tree().ok())
        .map(|oid| oid.to_string());

    Ok(RepoSnapshot {
        head_oid,
        head_ref,
        branches,
        tags,
        index_tree_oid,
    })
}

/// Wrap a mutating operation: snapshot before, run op, snapshot after, append to oplog.
pub fn with_oplog<F, T>(path: &Path, command: &str, description: &str, op: F) -> Result<T>
where
    F: FnOnce() -> Result<T>,
{
    let before = snapshot_repo(path).unwrap_or_else(|_| RepoSnapshot {
        head_oid: None,
        head_ref: None,
        branches: HashMap::new(),
        tags: HashMap::new(),
        index_tree_oid: None,
    });

    let result = op()?;

    let after = snapshot_repo(path).unwrap_or_else(|_| before.clone());

    let entry = OpEntry {
        id: generate_id(),
        timestamp: now_iso8601(),
        command: command.to_string(),
        description: description.to_string(),
        before,
        after,
    };

    // Best-effort append; don't fail the operation if logging fails
    let _ = append_entry(path, &entry);

    Ok(result)
}

/// Read all oplog entries (most recent last in file, we reverse for display).
pub fn read_oplog(path: &Path) -> Result<Vec<OpEntry>> {
    let file_path = oplog_path(path);
    if !file_path.exists() {
        return Ok(Vec::new());
    }

    let content = std::fs::read_to_string(&file_path)?;
    let mut entries: Vec<OpEntry> = content
        .lines()
        .filter(|line| !line.trim().is_empty())
        .filter_map(|line| serde_json::from_str(line).ok())
        .collect();

    entries.reverse(); // most recent first
    Ok(entries)
}

const MAX_OPLOG_ENTRIES: usize = 500;

fn append_entry(path: &Path, entry: &OpEntry) -> Result<()> {
    let dir = oplog_dir(path);
    std::fs::create_dir_all(&dir)?;

    let file_path = oplog_path(path);
    let mut file = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&file_path)?;

    let json = serde_json::to_string(entry)?;
    writeln!(file, "{}", json)?;
    drop(file);

    // Truncate if the oplog has grown too large
    truncate_oplog(&file_path)?;

    Ok(())
}

fn truncate_oplog(file_path: &Path) -> Result<()> {
    let content = std::fs::read_to_string(file_path)?;
    let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
    if lines.len() > MAX_OPLOG_ENTRIES {
        let keep = &lines[lines.len() - MAX_OPLOG_ENTRIES..];
        let mut file = std::fs::File::create(file_path)?;
        for line in keep {
            writeln!(file, "{}", line)?;
        }
    }
    Ok(())
}

fn generate_id() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    format!("{:x}", nanos)
}

pub fn now_iso8601_pub() -> String {
    now_iso8601()
}

fn now_iso8601() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    // Simple ISO 8601 without external crate
    let days = secs / 86400;
    let time_of_day = secs % 86400;
    let hours = time_of_day / 3600;
    let minutes = (time_of_day % 3600) / 60;
    let seconds = time_of_day % 60;

    // Approximate date from days since epoch
    let (year, month, day) = days_to_ymd(days);
    format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        year, month, day, hours, minutes, seconds
    )
}

fn days_to_ymd(days_since_epoch: u64) -> (u64, u64, u64) {
    // Civil date from days since 1970-01-01
    let z = days_since_epoch + 719468;
    let era = z / 146097;
    let doe = z - era * 146097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}