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 crate::cli::UI;
use crate::ops::utils::short_oid as fmt_short_oid;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotInfo {
    pub ref_name: String,
    pub oid: String,
    pub timestamp: i64,
    pub message: String,
}

/// Create a snapshot of the current working tree.
pub fn create(path: &Path, message: Option<&str>, ui: &UI) -> Result<SnapshotInfo> {
    let repo = crate::ops::open_repo(path)?;
    let mut index = repo.index()?;

    // Build a tree from current index + working directory state
    // First, add all tracked and untracked files to a temporary index
    index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
    let tree_oid = index.write_tree()?;

    // Restore original index
    if let Ok(head) = repo.head() {
        if let Ok(tree) = head.peel_to_tree() {
            let mut original_index = repo.index()?;
            original_index.read_tree(&tree)?;
        }
    }

    let tree = repo.find_tree(tree_oid)?;
    let sig = repo
        .signature()
        .unwrap_or_else(|_| git2::Signature::now("securegit", "snapshot@securegit.local").unwrap());

    let timestamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64;

    let msg = message.unwrap_or("auto-snapshot");
    let full_msg = format!("securegit snapshot: {}", msg);

    // Create orphan commit (no parents)
    let oid = repo.commit(None, &sig, &sig, &full_msg, &tree, &[])?;

    // Store under refs/snapshots/<timestamp>
    let ref_name = format!("refs/snapshots/{}", timestamp);
    repo.reference(&ref_name, oid, true, "securegit snapshot")?;

    let hash = fmt_short_oid(&oid);

    let info = SnapshotInfo {
        ref_name: ref_name.clone(),
        oid: oid.to_string(),
        timestamp,
        message: msg.to_string(),
    };

    ui.success(format!("Snapshot created: {} ({})", hash, msg));

    Ok(info)
}

/// List snapshots, most recent first.
pub fn list(path: &Path, count: usize, ui: &UI) -> Result<Vec<SnapshotInfo>> {
    let repo = crate::ops::open_repo(path)?;
    let mut snapshots = Vec::new();

    let refs = crate::ops::refstore::list_refs_with_prefix(&repo, "refs/snapshots/")?;

    for ref_name in &refs {
        if let Ok(reference) = repo.find_reference(ref_name) {
            if let Some(oid) = reference.target() {
                if let Ok(commit) = repo.find_commit(oid) {
                    let ts_str = ref_name.strip_prefix("refs/snapshots/").unwrap_or("0");
                    let timestamp: i64 = ts_str.parse().unwrap_or(0);
                    let msg = commit
                        .message()
                        .unwrap_or("")
                        .strip_prefix("securegit snapshot: ")
                        .unwrap_or(commit.message().unwrap_or(""))
                        .to_string();

                    snapshots.push(SnapshotInfo {
                        ref_name: ref_name.clone(),
                        oid: oid.to_string(),
                        timestamp,
                        message: msg,
                    });
                }
            }
        }
    }

    // Sort by timestamp descending (most recent first)
    snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
    snapshots.truncate(count);

    if snapshots.is_empty() {
        ui.info("No snapshots found");
    } else {
        ui.section("Snapshots (newest first)");
        for snap in &snapshots {
            let short_oid = &snap.oid[..7.min(snap.oid.len())];
            ui.list_item(format!(
                "{}  {} — {}",
                short_oid,
                format_timestamp(snap.timestamp),
                snap.message
            ));
        }
    }

    Ok(snapshots)
}

/// Restore working tree to a snapshot state without moving HEAD.
pub fn restore(path: &Path, id: &str, ui: &UI) -> Result<()> {
    let repo = crate::ops::open_repo(path)?;

    // Try to find the snapshot by short OID prefix or timestamp
    let commit = if let Ok(oid) = git2::Oid::from_str(id) {
        repo.find_commit(oid)?
    } else {
        // Try as timestamp
        let ref_name = format!("refs/snapshots/{}", id);
        if let Ok(reference) = repo.find_reference(&ref_name) {
            let oid = reference
                .target()
                .ok_or_else(|| anyhow::anyhow!("Invalid snapshot ref"))?;
            repo.find_commit(oid)?
        } else {
            // Try prefix match on OIDs
            let refs = crate::ops::refstore::list_refs_with_prefix(&repo, "refs/snapshots/")?;
            let mut found = None;
            for ref_name in &refs {
                if let Ok(reference) = repo.find_reference(ref_name) {
                    if let Some(oid) = reference.target() {
                        if oid.to_string().starts_with(id) {
                            found = Some(repo.find_commit(oid)?);
                            break;
                        }
                    }
                }
            }
            found.ok_or_else(|| anyhow::anyhow!("Snapshot '{}' not found", id))?
        }
    };

    // Checkout the snapshot tree into the working directory
    let mut checkout = git2::build::CheckoutBuilder::new();
    checkout.force();
    repo.checkout_tree(commit.as_object(), Some(&mut checkout))?;

    let hash = fmt_short_oid(&commit.id());
    ui.success(format!("Restored working tree to snapshot {}", hash));

    Ok(())
}

/// Prune snapshots older than the given duration string (e.g., "7d", "24h").
pub fn prune(path: &Path, older_than: &str, dry_run: bool, ui: &UI) -> Result<()> {
    let retention_secs = parse_retention(older_than)?;
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs() as i64;

    let cutoff = now - retention_secs as i64;

    let repo = crate::ops::open_repo(path)?;
    let refs = crate::ops::refstore::list_refs_with_prefix(&repo, "refs/snapshots/")?;

    let mut pruned = 0;
    for ref_name in &refs {
        let ts_str = ref_name.strip_prefix("refs/snapshots/").unwrap_or("0");
        let timestamp: i64 = ts_str.parse().unwrap_or(0);

        if timestamp < cutoff {
            if dry_run {
                ui.list_item(format!(
                    "Would prune: {} ({})",
                    ref_name,
                    format_timestamp(timestamp)
                ));
            } else {
                crate::ops::refstore::delete_ref(&repo, ref_name)?;
                ui.success(format!("Pruned: {}", ref_name));
            }
            pruned += 1;
        }
    }

    if pruned == 0 {
        ui.info("No snapshots to prune");
    } else if dry_run {
        ui.info(format!("{} snapshot(s) would be pruned", pruned));
    } else {
        ui.success(format!("Pruned {} snapshot(s)", pruned));
    }

    Ok(())
}

fn parse_retention(s: &str) -> Result<u64> {
    let s = s.trim();
    if let Some(days) = s.strip_suffix('d') {
        Ok(days.parse::<u64>()? * 86400)
    } else if let Some(hours) = s.strip_suffix('h') {
        Ok(hours.parse::<u64>()? * 3600)
    } else if let Some(mins) = s.strip_suffix('m') {
        Ok(mins.parse::<u64>()? * 60)
    } else {
        // Try as raw seconds
        Ok(s.parse::<u64>()?)
    }
}

fn format_timestamp(ts: i64) -> String {
    let secs = ts as u64;
    let days = secs / 86400;
    let time_of_day = secs % 86400;
    let hours = time_of_day / 3600;
    let minutes = (time_of_day % 3600) / 60;

    let z = days + 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 };

    format!("{:04}-{:02}-{:02} {:02}:{:02}Z", y, m, d, hours, minutes)
}