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::args::BackupCommands;
use crate::cli::UI;
use crate::ops::backup;
use crate::ops::utils::short_oid;
use anyhow::Result;
use std::path::PathBuf;

pub async fn execute(action: BackupCommands, ui: &UI) -> Result<()> {
    match action {
        BackupCommands::Add {
            name,
            destination,
            backend_type,
            auto,
        } => add(&name, &destination, backend_type.as_deref(), auto, ui),

        BackupCommands::Remove { name } => remove(&name, ui),
        BackupCommands::List => list(ui),

        BackupCommands::Push { name, all_branches } => push(name.as_deref(), all_branches, ui),

        BackupCommands::Status => status(ui),

        BackupCommands::Restore { source, output } => restore(&source, output.as_deref(), ui),

        BackupCommands::Verify { file } => verify(&file, ui),
    }
}

fn add(name: &str, destination: &str, type_hint: Option<&str>, auto: bool, ui: &UI) -> Result<()> {
    let path = PathBuf::from(".");

    let backend = type_hint
        .map(|s| s.to_string())
        .unwrap_or_else(|| backup::detect_backend(destination).to_string());

    backup::add_destination(&path, name, destination, type_hint, auto)?;

    ui.header("Backup");
    ui.blank();
    ui.success(format!("Added backup destination '{}' ({})", name, backend));
    ui.blank();
    ui.field("Destination", destination);
    ui.field("Auto-backup", if auto { "enabled" } else { "disabled" });
    ui.blank();

    if auto {
        ui.info("Backups will run automatically after every push");
    }

    Ok(())
}

fn remove(name: &str, ui: &UI) -> Result<()> {
    let path = PathBuf::from(".");
    backup::remove_destination(&path, name)?;

    ui.success(format!("Removed backup destination '{}'", name));
    Ok(())
}

fn list(ui: &UI) -> Result<()> {
    let path = PathBuf::from(".");
    let config = backup::load_config(&path)?;

    ui.header("Backup Destinations");
    ui.blank();

    if config.destinations.is_empty() {
        ui.info("No backup destinations configured");
        ui.blank();
        ui.info("Add one with: securegit backup add <name> <destination>");
        return Ok(());
    }

    for dest in &config.destinations {
        let auto_tag = if dest.auto_backup { " [auto]" } else { "" };
        ui.list_item(format!(
            "{:<12} {:<8} {}{}",
            dest.name, dest.backend, dest.destination, auto_tag
        ));
    }

    if ui.json {
        ui.json_out(&serde_json::to_value(&config.destinations).unwrap_or_default());
    }

    ui.blank();
    Ok(())
}

fn push(name: Option<&str>, all_branches: bool, ui: &UI) -> Result<()> {
    let path = PathBuf::from(".");
    let mut config = backup::load_config(&path)?;

    if config.destinations.is_empty() {
        anyhow::bail!("No backup destinations configured. Add one with: securegit backup add <name> <destination>");
    }

    let targets: Vec<usize> = if let Some(n) = name {
        let idx = config
            .destinations
            .iter()
            .position(|d| d.name == n)
            .ok_or_else(|| anyhow::anyhow!("No backup destination named '{}'", n))?;
        vec![idx]
    } else {
        (0..config.destinations.len()).collect()
    };

    // Get repo info for display
    let repo = git2::Repository::discover(&path)?;
    let head = repo.head()?;
    let commit = head.peel_to_commit()?;
    let short_sha = short_oid(&commit.id());
    let branch = head.shorthand().unwrap_or("HEAD");
    let repo_name = repo
        .workdir()
        .and_then(|p| p.file_name())
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| "repo".to_string());

    ui.header("Backup");
    ui.blank();
    ui.field("Repository", &repo_name);
    ui.field("Branch", branch);
    ui.field("Commit", short_sha);
    ui.blank();

    // Create bundle
    let spinner = ui.spinner("Creating bundle...");
    let bundle = backup::create_bundle(&path, all_branches)?;
    ui.finish_progress(
        &spinner,
        &format!(
            "Bundle created: {} ({})",
            bundle
                .path
                .file_name()
                .unwrap_or_default()
                .to_string_lossy(),
            backup::format_size(bundle.size_bytes)
        ),
    );

    // Push to each target
    let mut success_count = 0;
    let now = chrono::Utc::now().to_rfc3339();
    let sha = commit.id().to_string();

    for &idx in &targets {
        let dest = &config.destinations[idx];
        let spinner = ui.spinner(&format!("Pushing to {}...", dest.name));
        match backup::push_to_destination(dest, &bundle.path) {
            Ok(()) => {
                ui.finish_progress(
                    &spinner,
                    &format!("{}: uploaded ({})", dest.name, dest.backend),
                );
                success_count += 1;
                // Update last backup info
                config.destinations[idx].last_backup = Some(now.clone());
                config.destinations[idx].last_sha = Some(sha.clone());
            }
            Err(e) => {
                ui.finish_progress(&spinner, "");
                ui.warning(format!("{}: failed ({})", dest.name, e));
            }
        }
    }

    // Save updated timestamps
    let _ = backup::save_config(&path, &config);

    // Clean up temp bundle
    let _ = std::fs::remove_file(&bundle.path);
    if let Some(parent) = bundle.path.parent() {
        let _ = std::fs::remove_dir(parent);
    }

    ui.blank();
    if success_count == targets.len() {
        ui.success(format!(
            "Backup complete — {} destination{} updated",
            success_count,
            if success_count == 1 { "" } else { "s" }
        ));
    } else {
        ui.warning(format!(
            "{}/{} destinations updated",
            success_count,
            targets.len()
        ));
    }
    ui.blank();

    Ok(())
}

fn status(ui: &UI) -> Result<()> {
    let path = PathBuf::from(".");
    let config = backup::load_config(&path)?;

    let repo = git2::Repository::discover(&path)?;
    let head = repo.head()?;
    let commit = head.peel_to_commit()?;
    let short_sha = short_oid(&commit.id());
    let branch = head.shorthand().unwrap_or("HEAD");
    let repo_name = repo
        .workdir()
        .and_then(|p| p.file_name())
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| "repo".to_string());

    ui.header("Backup Status");
    ui.blank();
    ui.field(
        "Repository",
        format!("{} ({} @ {})", repo_name, branch, short_sha),
    );
    ui.blank();

    if config.destinations.is_empty() {
        ui.info("No backup destinations configured");
        ui.blank();
        ui.info("Add one with: securegit backup add <name> <destination>");
        return Ok(());
    }

    ui.section("Destinations");

    for dest in &config.destinations {
        let auto_tag = if dest.auto_backup { "  auto" } else { "" };
        let last = dest
            .last_backup
            .as_deref()
            .map(format_relative_time)
            .unwrap_or_else(|| "never".to_string());

        let status_icon = dest.last_backup.is_some();
        ui.status_item(
            status_icon,
            format!(
                "{:<12} {:<8} {:<16}{}",
                dest.name, dest.backend, last, auto_tag
            ),
        );
    }

    if ui.json {
        ui.json_out(&serde_json::to_value(&config).unwrap_or_default());
    }

    ui.blank();
    Ok(())
}

fn restore(source: &str, output: Option<&std::path::Path>, ui: &UI) -> Result<()> {
    ui.header("Restore");
    ui.blank();
    ui.field("Source", source);
    ui.blank();

    // If source is a local bundle, verify first
    let source_path = std::path::Path::new(source);
    if source_path.exists() {
        let spinner = ui.spinner("Verifying bundle...");
        let verify_output = backup::verify_bundle(source_path)?;
        ui.finish_progress(&spinner, "Bundle verified");
        if !verify_output.is_empty() {
            for line in verify_output.lines().take(5) {
                let trimmed = line.trim();
                if !trimmed.is_empty() {
                    ui.info(trimmed);
                }
            }
        }
    }

    let spinner = ui.spinner("Restoring from bundle...");
    let out_dir = backup::restore_from_bundle(source, output)?;
    ui.finish_progress(&spinner, "");

    ui.blank();
    ui.success("Restored successfully");
    ui.field("Location", out_dir.display());
    ui.blank();

    Ok(())
}

fn verify(file: &std::path::Path, ui: &UI) -> Result<()> {
    ui.header("Bundle Verify");
    ui.blank();
    ui.field("File", file.display());

    let meta = std::fs::metadata(file)?;
    ui.field("Size", backup::format_size(meta.len()));
    ui.blank();

    let output = backup::verify_bundle(file)?;

    ui.success("Bundle is valid");
    ui.blank();

    for line in output.lines() {
        let trimmed = line.trim();
        if !trimmed.is_empty() {
            ui.info(trimmed);
        }
    }

    ui.blank();
    Ok(())
}

fn format_relative_time(timestamp: &str) -> String {
    let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) else {
        return timestamp.to_string();
    };

    let now = chrono::Utc::now();
    let duration = now.signed_duration_since(dt);

    if duration.num_days() > 0 {
        format!(
            "{} day{} ago",
            duration.num_days(),
            if duration.num_days() == 1 { "" } else { "s" }
        )
    } else if duration.num_hours() > 0 {
        format!(
            "{} hour{} ago",
            duration.num_hours(),
            if duration.num_hours() == 1 { "" } else { "s" }
        )
    } else if duration.num_minutes() > 0 {
        format!("{} min ago", duration.num_minutes())
    } else {
        "just now".to_string()
    }
}