git-prism 0.9.1

Agent-optimized git data MCP server — structured change manifests and full file snapshots for LLM agents
#![cfg(unix)]

//! Adversarial QA (issue #338, gate 3): the enriched `git show <ref>` diffstat
//! must report insertions/deletions that match real `git show --stat`.
//!
//! These drive the built binary end-to-end via argv[0] = "git" symlink dispatch,
//! with an isolated agent env so the shim intercepts and emits structured JSON.
//!
//! BUG: `handle_show_snapshot` computes the diffstat as
//!   insertions = sum(after.line_count) - sum(before.line_count)   (saturating)
//!   deletions  = sum(before.line_count) - sum(after.line_count)   (saturating)
//! across ALL files. This is a *net* line delta, not insertions/deletions.
//! For any modification (lines changed but count unchanged) it reports 0/0,
//! and across multiple files the cross-file sum cancels real edits.

use std::os::unix::fs::symlink;
use std::process::Command;

use tempfile::TempDir;

/// Run `git show <sha>` through the shim against `repo`, return parsed JSON.
fn run_show(repo: &std::path::Path, sha: &str) -> serde_json::Value {
    let bin = env!("CARGO_BIN_EXE_git-prism");
    let tmp = TempDir::new().unwrap();
    let shim_dir = tmp.path().join("bin");
    std::fs::create_dir_all(&shim_dir).unwrap();
    let git_link = shim_dir.join("git");
    symlink(bin, &git_link).unwrap();

    let real_path = std::env::var("PATH").unwrap_or_default();
    let path = format!("{}:{}", shim_dir.display(), real_path);

    let out = Command::new(&git_link)
        // AI_AGENT marker → detect_calling_agent returns Some → shim intercepts.
        .env("AI_AGENT", "1")
        // Point the shim at the fixture repo so it doesn't use the test cwd.
        .env("GIT_PRISM_REPO", repo)
        // Make sure no loop-break / CI override is set.
        .env_remove("GIT_PRISM_INSIDE_SHIM")
        .env_remove("CI")
        .env("PATH", &path)
        .args(["show", sha])
        .output()
        .unwrap();

    let stdout = String::from_utf8_lossy(&out.stdout);
    serde_json::from_str(&stdout).unwrap_or_else(|e| {
        panic!(
            "expected structured JSON from intercepted `git show`, parse error: {e}\nstdout: {stdout}\nstderr: {}",
            String::from_utf8_lossy(&out.stderr)
        )
    })
}

fn git(repo: &std::path::Path, args: &[&str]) {
    Command::new("git")
        .args(args)
        .current_dir(repo)
        .output()
        .unwrap();
}

fn head_sha(repo: &std::path::Path) -> String {
    let out = Command::new("git")
        .args(["rev-parse", "HEAD"])
        .current_dir(repo)
        .output()
        .unwrap();
    String::from_utf8(out.stdout).unwrap().trim().to_string()
}

fn init_repo() -> (TempDir, std::path::PathBuf) {
    let dir = TempDir::new().unwrap();
    let path = dir.path().to_path_buf();
    git(&path, &["init", "-b", "main"]);
    git(&path, &["config", "user.email", "t@t.com"]);
    git(&path, &["config", "user.name", "T"]);
    (dir, path)
}

/// The enriched `git show <ref>` must return the files the commit changed.
/// The handler calls `build_snapshots(.., &[], ..)` with an EMPTY paths slice,
/// and `build_snapshots` only emits entries for the paths it is given, so the
/// `files` array is ALWAYS empty and `diffstat.files_changed` is ALWAYS 0 —
/// the whole snapshot/diffstat half of the feature is dead.
#[test]
fn show_returns_nonempty_files_for_a_commit_that_changed_files() {
    let (_dir, path) = init_repo();
    std::fs::write(path.join("only.txt"), "alpha\nbeta\n").unwrap();
    git(&path, &["add", "only.txt"]);
    git(&path, &["commit", "-m", "first"]);
    std::fs::write(path.join("only.txt"), "alpha\nbeta\ngamma\n").unwrap();
    git(&path, &["add", "only.txt"]);
    git(&path, &["commit", "-m", "append a line"]);

    let sha = head_sha(&path);
    let json = run_show(&path, &sha);

    let files = json["files"].as_array().unwrap();
    assert!(
        !files.is_empty(),
        "git show enrichment must return the changed file(s), got empty files array\n{json}"
    );
    let files_changed = json["diffstat"]["files_changed"].as_u64().unwrap();
    assert_eq!(
        files_changed, 1,
        "diffstat.files_changed must be 1 (only.txt), got {files_changed}\n{json}"
    );
}

/// A one-line modification: 3 lines before, 3 lines after.
/// Real `git show --stat` reports "1 insertion(+), 1 deletion(-)".
#[test]
fn show_diffstat_matches_real_git_for_one_line_modification() {
    let (_dir, path) = init_repo();
    std::fs::write(path.join("f.txt"), "line1\nline2\nline3\n").unwrap();
    git(&path, &["add", "f.txt"]);
    git(&path, &["commit", "-m", "first"]);
    std::fs::write(path.join("f.txt"), "line1\nCHANGED\nline3\n").unwrap();
    git(&path, &["add", "f.txt"]);
    git(&path, &["commit", "-m", "modify one line"]);

    let sha = head_sha(&path);
    let json = run_show(&path, &sha);

    let insertions = json["diffstat"]["insertions"].as_u64().unwrap();
    let deletions = json["diffstat"]["deletions"].as_u64().unwrap();
    assert_eq!(
        insertions, 1,
        "diffstat.insertions must match real git `1 insertion(+)`, got {insertions}\n{json}"
    );
    assert_eq!(
        deletions, 1,
        "diffstat.deletions must match real git `1 deletion(-)`, got {deletions}\n{json}"
    );
}

/// Root commit has no parent, so `<sha>^` doesn't exist.
/// The handler must fall back to the empty tree and still return the files.
#[test]
fn show_returns_files_for_root_commit() {
    let (_dir, path) = init_repo();
    std::fs::write(path.join("first.txt"), "hello\nworld\n").unwrap();
    git(&path, &["add", "first.txt"]);
    git(&path, &["commit", "-m", "root commit"]);

    let sha = head_sha(&path);
    let json = run_show(&path, &sha);

    // Root commit: parents array must be empty.
    let parents = json["commit"]["parents"].as_array().unwrap();
    assert!(
        parents.is_empty(),
        "root commit must have 0 parents, got: {json}"
    );

    // Files must be non-empty — first.txt was added.
    let files = json["files"].as_array().unwrap();
    assert!(
        !files.is_empty(),
        "root commit must list added files, got: {json}"
    );
    assert_eq!(
        json["diffstat"]["files_changed"].as_u64().unwrap(),
        1,
        "diffstat.files_changed must be 1 for root commit, got: {json}"
    );
    assert!(
        json["diffstat"]["insertions"].as_u64().unwrap() > 0,
        "root commit insertions must be > 0, got: {json}"
    );
}

/// Per-file fields: change_type, additions, deletions, is_binary.
/// Commit modifies one file, adds one file, deletes one file.
#[test]
fn show_files_carry_per_file_change_type_and_line_counts() {
    let (_dir, path) = init_repo();

    // Base: two files.
    std::fs::write(path.join("keep.txt"), "a\nb\nc\n").unwrap();
    std::fs::write(path.join("drop.txt"), "x\ny\n").unwrap();
    git(&path, &["add", "keep.txt", "drop.txt"]);
    git(&path, &["commit", "-m", "base"]);

    // HEAD: modify keep.txt (1 line changed), add new.txt (2 lines), delete drop.txt.
    std::fs::write(path.join("keep.txt"), "a\nCHANGED\nc\n").unwrap();
    std::fs::write(path.join("new.txt"), "p\nq\n").unwrap();
    std::fs::remove_file(path.join("drop.txt")).unwrap();
    git(&path, &["add", "keep.txt", "new.txt"]);
    git(&path, &["rm", "drop.txt"]);
    git(
        &path,
        &["commit", "-m", "modify keep, add new, delete drop"],
    );

    let sha = head_sha(&path);
    let json = run_show(&path, &sha);

    let files = json["files"].as_array().unwrap();
    assert_eq!(files.len(), 3, "expected 3 file entries, got: {json}");

    // Find each file entry by path.
    let find = |name: &str| {
        files
            .iter()
            .find(|f| f["path"].as_str() == Some(name))
            .unwrap_or_else(|| panic!("missing file entry for {name} in: {json}"))
    };

    let keep = find("keep.txt");
    assert_eq!(keep["change_type"].as_str().unwrap(), "modified");
    assert_eq!(
        keep["additions"].as_u64().unwrap(),
        1,
        "keep.txt: 1 line added"
    );
    assert_eq!(
        keep["deletions"].as_u64().unwrap(),
        1,
        "keep.txt: 1 line removed"
    );
    assert!(
        !keep["is_binary"].as_bool().unwrap(),
        "keep.txt must not be binary"
    );

    let new_f = find("new.txt");
    assert_eq!(new_f["change_type"].as_str().unwrap(), "added");
    assert_eq!(
        new_f["additions"].as_u64().unwrap(),
        2,
        "new.txt: 2 lines added"
    );
    assert_eq!(
        new_f["deletions"].as_u64().unwrap(),
        0,
        "new.txt: 0 deletions"
    );

    let drop = find("drop.txt");
    assert_eq!(drop["change_type"].as_str().unwrap(), "deleted");
    assert_eq!(
        drop["additions"].as_u64().unwrap(),
        0,
        "drop.txt: 0 additions"
    );
    assert_eq!(
        drop["deletions"].as_u64().unwrap(),
        2,
        "drop.txt: 2 lines deleted"
    );
}

/// Merge commit has two parents. The handler must not crash and must still
/// return both commit metadata (parents.len() == 2) and the files array.
#[test]
fn show_merge_commit_has_two_parents() {
    let (_dir, path) = init_repo();

    // Base commit on main.
    std::fs::write(path.join("base.txt"), "base\n").unwrap();
    git(&path, &["add", "base.txt"]);
    git(&path, &["commit", "-m", "base"]);

    // Feature branch adds feature.txt.
    git(&path, &["checkout", "-b", "feature"]);
    std::fs::write(path.join("feature.txt"), "feature\n").unwrap();
    git(&path, &["add", "feature.txt"]);
    git(&path, &["commit", "-m", "add feature"]);

    // Back to main, merge feature (creates merge commit).
    git(&path, &["checkout", "main"]);
    git(
        &path,
        &["merge", "--no-ff", "feature", "-m", "merge feature"],
    );

    let sha = head_sha(&path);
    let json = run_show(&path, &sha);

    let parents = json["commit"]["parents"].as_array().unwrap();
    assert_eq!(
        parents.len(),
        2,
        "merge commit must have 2 parents, got: {json}"
    );

    // First-parent diff brings in feature.txt (1 file, 1 insertion).
    assert_eq!(
        json["diffstat"]["files_changed"].as_u64().unwrap(),
        1,
        "merge first-parent diff must show 1 file changed (feature.txt)\n{json}"
    );
    assert_eq!(
        json["diffstat"]["insertions"].as_u64().unwrap(),
        1,
        "merge first-parent diff must show 1 insertion (feature.txt: 1 line)\n{json}"
    );
    assert_eq!(
        json["diffstat"]["deletions"].as_u64().unwrap(),
        0,
        "merge first-parent diff must show 0 deletions\n{json}"
    );
    let files = json["files"].as_array().unwrap();
    assert!(
        files.iter().any(|f| f["path"] == "feature.txt"),
        "feature.txt must appear in merge first-parent diff\n{json}"
    );
}

/// A multi-file commit: file A modified (+5/-5, count unchanged) plus file B
/// added (+3). Real `git show --stat` reports "8 insertions(+), 5 deletions(-)".
/// The handler's cross-file net sum cancels A's edits entirely.
#[test]
fn show_diffstat_matches_real_git_for_mixed_multi_file_commit() {
    let (_dir, path) = init_repo();
    std::fs::write(path.join("a.txt"), "1\n2\n3\n4\n5\n").unwrap();
    git(&path, &["add", "a.txt"]);
    git(&path, &["commit", "-m", "base"]);

    // Modify all 5 lines of a.txt (same line count) and add b.txt with 3 lines.
    std::fs::write(path.join("a.txt"), "A\nB\nC\nD\nE\n").unwrap();
    std::fs::write(path.join("b.txt"), "x\ny\nz\n").unwrap();
    git(&path, &["add", "a.txt", "b.txt"]);
    git(&path, &["commit", "-m", "modify a, add b"]);

    let sha = head_sha(&path);
    let json = run_show(&path, &sha);

    let insertions = json["diffstat"]["insertions"].as_u64().unwrap();
    let deletions = json["diffstat"]["deletions"].as_u64().unwrap();
    assert_eq!(
        insertions, 8,
        "diffstat.insertions must be 8 (5 modified in a.txt + 3 added in b.txt), got {insertions}\n{json}"
    );
    assert_eq!(
        deletions, 5,
        "diffstat.deletions must be 5 (5 lines replaced in a.txt), got {deletions}\n{json}"
    );
}