koda-core 0.2.11

Core engine for the Koda AI coding agent (macOS and Linux only)
Documentation
//! Git integration for context injection.
//!
//! Provides a compact snapshot of the current git state for injection into
//! the system prompt. This gives the model awareness of:
//!
//! - **Current branch** — so it knows where it's working
//! - **Staged changes** — diff stat showing what's ready to commit
//! - **Unstaged changes** — diff stat showing what's modified but not staged
//! - **Recent commits** — last N commit subjects for historical context
//!
//! ## What this module does NOT do
//!
//! - **File-level undo** — handled by [`crate::undo`] (in-memory snapshots)
//! - **Git operations** — commits, pushes, etc. are done via the Bash tool
//! - **Worktree management** — handled by [`crate::worktree`]
//!
//! ## Output format
//!
//! ```text
//! [Git: branch=main
//!  Staged: 2 files changed, 15 insertions(+), 3 deletions(-)
//!  Unstaged: 1 file changed, 4 insertions(+)
//!  Recent: fix: align arrows | docs: enrich modules | feat: add AskUser]
//! ```
//!
//! Diff stats are truncated at 2KB to avoid bloating the system prompt.

use std::path::Path;
use std::process::Command;

// ── Context injection (#263) ────────────────────────────────────

/// Maximum characters for the diff stat section.
const MAX_DIFF_STAT_CHARS: usize = 2_000;
/// Maximum recent commits to include.
const MAX_RECENT_COMMITS: usize = 5;

/// Compact git context for injection into the system prompt.
///
/// Returns `None` if not in a git repo. Includes:
/// - Current branch name
/// - Staged diff stat (truncated)
/// - Unstaged diff stat (truncated)
/// - Last N commit subjects
pub fn git_context(project_root: &Path) -> Option<String> {
    let branch = git_cmd(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])?;

    let mut parts = vec![format!("[Git: branch={branch}")];

    // Staged changes (stat only — token-efficient)
    if let Some(staged) = git_cmd(project_root, &["diff", "--cached", "--stat"])
        && !staged.trim().is_empty()
    {
        let truncated = truncate_str(&staged, MAX_DIFF_STAT_CHARS);
        parts.push(format!("staged:\n{truncated}"));
    }

    // Unstaged changes (stat only)
    if let Some(unstaged) = git_cmd(project_root, &["diff", "--stat"])
        && !unstaged.trim().is_empty()
    {
        let truncated = truncate_str(&unstaged, MAX_DIFF_STAT_CHARS);
        parts.push(format!("unstaged:\n{truncated}"));
    }

    // Untracked file count
    if let Some(untracked) = git_cmd(
        project_root,
        &["ls-files", "--others", "--exclude-standard"],
    ) {
        let count = untracked.lines().count();
        if count > 0 {
            parts.push(format!("{count} untracked file(s)"));
        }
    }

    // Recent commits
    if let Some(log) = git_cmd(
        project_root,
        &[
            "log",
            "--oneline",
            &format!("-{MAX_RECENT_COMMITS}"),
            "--no-decorate",
        ],
    ) && !log.trim().is_empty()
    {
        parts.push(format!("recent commits:\n{log}"));
    }

    parts.push("]".to_string());
    Some(parts.join(", "))
}

// ── Helpers ─────────────────────────────────────────────────────

/// Run a git command and return stdout if successful.
fn git_cmd(cwd: &Path, args: &[&str]) -> Option<String> {
    Command::new("git")
        .args(args)
        .current_dir(cwd)
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
}

/// Truncate a string to max chars at a line boundary.
fn truncate_str(s: &str, max: usize) -> String {
    if s.len() <= max {
        return s.to_string();
    }
    // Find last newline before max
    let end = s[..max].rfind('\n').unwrap_or(max);
    let truncated = &s[..end];
    let remaining = s[end..].lines().count();
    format!("{truncated}\n  ... ({remaining} more lines)")
}

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

    #[test]
    fn test_git_context_in_repo() {
        // We're running tests inside the koda repo, so this should work
        let ctx = git_context(Path::new("."));
        assert!(ctx.is_some());
        let ctx = ctx.unwrap();
        assert!(ctx.contains("[Git: branch="));
        assert!(ctx.contains("recent commits:"));
    }

    #[test]
    fn test_git_context_not_a_repo() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = git_context(tmp.path());
        assert!(ctx.is_none());
    }

    #[test]
    fn test_truncate_str_short() {
        assert_eq!(truncate_str("hello", 100), "hello");
    }

    #[test]
    fn test_truncate_str_long() {
        let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
        let input = lines.join("\n");
        let truncated = truncate_str(&input, 50);
        assert!(truncated.len() <= 80); // 50 + "... (N more lines)"
        assert!(truncated.contains("more lines"));
    }
}