sgr-agent 0.5.1

SGR LLM client + agent framework — structured output, function calling, agent loop, 3 agent variants
Documentation
//! Git tools: status, diff, add, commit.

use anyhow::{Context, Result};
use std::process::Command;

#[derive(Debug, Clone)]
pub struct GitStatus {
    pub branch: String,
    pub dirty: bool,
    pub modified_files: Vec<String>,
    pub staged_files: Vec<String>,
    pub untracked_files: Vec<String>,
}

pub fn git_status() -> Result<Option<GitStatus>> {
    let check = Command::new("git")
        .args(["rev-parse", "--git-dir"])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false);

    if !check {
        return Ok(None);
    }

    let branch_output = Command::new("git")
        .args(["rev-parse", "--abbrev-ref", "HEAD"])
        .output()
        .context("Failed to get git branch")?;

    let branch = String::from_utf8_lossy(&branch_output.stdout)
        .trim()
        .to_string();

    let status_output = Command::new("git")
        .args(["status", "--porcelain"])
        .output()
        .context("Failed to get git status")?;

    let status_str = String::from_utf8_lossy(&status_output.stdout);
    let mut modified = Vec::new();
    let mut staged = Vec::new();
    let mut untracked = Vec::new();

    for line in status_str.lines() {
        if let (Some(status), Some(file)) = (line.get(0..2), line.get(3..)) {
            let file = file.to_string();
            match status {
                " M" | "M " | "MM" => modified.push(file),
                "A " => staged.push(file),
                "??" => untracked.push(file),
                _ => {}
            }
        }
    }

    Ok(Some(GitStatus {
        branch,
        dirty: !status_str.trim().is_empty(),
        modified_files: modified,
        staged_files: staged,
        untracked_files: untracked,
    }))
}

pub fn git_diff(path: Option<&str>, cached: bool) -> Result<String> {
    let mut args = vec!["--no-pager", "diff"];

    if cached {
        args.push("--cached");
    }

    args.push("--no-color");

    if let Some(p) = path {
        args.push("--");
        args.push(p);
    }

    let output = Command::new("git")
        .args(&args)
        .env("GIT_PAGER", "cat")
        .output()
        .context("Failed to run git diff")?;

    if !output.status.success() {
        anyhow::bail!(
            "git diff failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

pub fn git_add(paths: &[String]) -> Result<()> {
    let mut args = vec!["add"];
    for path in paths {
        args.push(path);
    }

    let output = Command::new("git")
        .args(&args)
        .output()
        .context("Failed to run git add")?;

    if !output.status.success() {
        anyhow::bail!(
            "git add failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(())
}

pub fn git_commit(message: &str) -> Result<()> {
    let output = Command::new("git")
        .args(["commit", "-m", message])
        .output()
        .context("Failed to run git commit")?;

    if !output.status.success() {
        anyhow::bail!(
            "git commit failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn git_status_in_repo() {
        let status = git_status().unwrap();
        assert!(status.is_some());
        let info = status.unwrap();
        assert!(!info.branch.is_empty());
    }

    #[test]
    fn git_diff_no_crash() {
        let _ = git_diff(None, false);
    }

    #[test]
    fn git_diff_specific_file() {
        let diff = git_diff(Some("Cargo.toml"), false);
        assert!(diff.is_ok());
    }
}