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 anyhow::{bail, Result};
use std::path::Path;

const DEFAULT_API_URL: &str = "https://api.anthropic.com/v1/messages";
const DEFAULT_MODEL: &str = "claude-sonnet-4-5-20250929";
const MAX_DIFF_BYTES: usize = 32768;

struct AiConfig {
    api_key: String,
    api_url: String,
    model: String,
}

const COMMIT_PROMPT: &str = r#"You are a commit message generator. Given the following git diff of staged changes, generate a conventional commit message.

Format: <type>(<optional scope>): <description>

<optional body>

Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Use imperative mood ("add" not "added")
- Keep the first line under 72 characters
- Add a body only if the changes are complex
- Do not include markdown formatting or backticks
- Respond with ONLY the commit message, nothing else

Staged diff:
"#;

fn load_config() -> Result<AiConfig> {
    let api_key = std::env::var("SECUREGIT_AI_API_KEY")
        .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
        .map_err(|_| {
            anyhow::anyhow!(
                "AI commit message requires an API key.\n\
                 Set SECUREGIT_AI_API_KEY or ANTHROPIC_API_KEY environment variable."
            )
        })?;

    let api_url = std::env::var("SECUREGIT_AI_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string());
    let model = std::env::var("SECUREGIT_AI_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());

    Ok(AiConfig {
        api_key,
        api_url,
        model,
    })
}

/// Get the staged diff as a string.
fn get_staged_diff(path: &Path) -> Result<String> {
    let repo = crate::ops::open_repo(path)?;
    let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
    let index = repo.index()?;

    let diff = repo.diff_tree_to_index(head_tree.as_ref(), Some(&index), None)?;

    let mut output = String::new();
    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
        let prefix = match line.origin() {
            '+' => "+",
            '-' => "-",
            ' ' => " ",
            _ => "",
        };
        if let Ok(content) = std::str::from_utf8(line.content()) {
            output.push_str(prefix);
            output.push_str(content);
        }
        // Truncate if too large
        output.len() < MAX_DIFF_BYTES
    })?;

    if output.is_empty() {
        bail!("No staged changes to generate a commit message for.");
    }

    Ok(output)
}

/// Call the AI API to generate a commit message.
async fn call_llm(config: &AiConfig, diff: &str) -> Result<String> {
    let prompt = format!("{}{}", COMMIT_PROMPT, diff);

    let body = serde_json::json!({
        "model": config.model,
        "max_tokens": 256,
        "messages": [{
            "role": "user",
            "content": prompt
        }]
    });

    let client = reqwest::Client::new();
    let response = client
        .post(&config.api_url)
        .header("x-api-key", &config.api_key)
        .header("anthropic-version", "2023-06-01")
        .header("content-type", "application/json")
        .json(&body)
        .send()
        .await?;

    if !response.status().is_success() {
        let status = response.status();
        let text = response.text().await.unwrap_or_default();
        bail!("AI API request failed ({}): {}", status, text);
    }

    let json: serde_json::Value = response.json().await?;
    let message = json["content"][0]["text"]
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("Unexpected AI API response format"))?
        .trim()
        .to_string();

    Ok(message)
}

/// Generate a commit message from staged changes using AI.
pub async fn generate_commit_message(path: &Path) -> Result<String> {
    let config = load_config()?;
    let diff = get_staged_diff(path)?;
    let message = call_llm(&config, &diff).await?;
    Ok(message)
}

/// Display the suggested message and ask for confirmation.
/// Returns the confirmed message or an error if rejected.
pub fn confirm_message(suggested: &str, ui: &UI) -> Result<String> {
    ui.blank();
    ui.section("AI-generated commit message:");
    ui.divider();
    ui.raw(suggested);
    ui.divider();

    println!("\n[Y]es, use this / [e]dit / [n]o, cancel");

    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;

    match input.trim().to_lowercase().as_str() {
        "" | "y" | "yes" => Ok(suggested.to_string()),
        "n" | "no" => bail!("Commit cancelled."),
        "e" | "edit" => {
            println!("Enter your commit message (Ctrl+D when done):");
            let mut message = String::new();
            loop {
                let mut line = String::new();
                match std::io::stdin().read_line(&mut line) {
                    Ok(0) => break,
                    Ok(_) => message.push_str(&line),
                    Err(_) => break,
                }
            }
            let trimmed = message.trim().to_string();
            if trimmed.is_empty() {
                bail!("Empty commit message, aborting.");
            }
            Ok(trimmed)
        }
        _ => Ok(suggested.to_string()),
    }
}