weavatrix-git 0.3.1

Fast, bounded, evidence-carrying Git reader with an optional read-only MCP server
Documentation
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
    process::Command,
    sync::atomic::{AtomicU64, Ordering},
    time::{SystemTime, UNIX_EPOCH},
};

use weavatrix_git::{ChangeKind, HashKind, HistoryOptions, Repository};

struct Fixture {
    path: PathBuf,
    commits: Vec<String>,
}

static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0);

impl Fixture {
    fn new(format: Option<&str>, commit_count: usize) -> Option<Self> {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "weavatrix-git-{}-{unique}-{}",
            std::process::id(),
            FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&path).unwrap();
        let mut init = vec!["init", "-q"];
        let format_arg;
        if let Some(format) = format {
            format_arg = format!("--object-format={format}");
            init.push(&format_arg);
        }
        if !git_status(&path, &init) {
            fs::remove_dir_all(path).ok();
            return None;
        }
        git(&path, &["config", "user.name", "Weavatrix Test"]);
        git(&path, &["config", "user.email", "test@weavatrix.local"]);
        let mut commits = Vec::new();
        for index in 0..commit_count {
            let content = format!(
                "header\n{}\nrevision={index:04}\nfooter\n",
                "shared payload ".repeat(1_000)
            );
            fs::write(path.join("tracked.txt"), content).unwrap();
            if index == 1 {
                fs::create_dir_all(path.join("nested")).unwrap();
                fs::write(path.join("nested").join("added.txt"), "added").unwrap();
            }
            if index == 2 {
                fs::remove_file(path.join("nested").join("added.txt")).unwrap();
                fs::write(path.join("replacement.txt"), "replacement").unwrap();
            }
            git(&path, &["add", "-A"]);
            git(&path, &["commit", "-q", "-m", &format!("revision {index}")]);
            commits.push(git(&path, &["rev-parse", "HEAD"]).trim().to_owned());
        }
        git(&path, &["tag", "-a", "v1", "-m", "fixture tag"]);
        Some(Self { path, commits })
    }

    fn compact(&self) {
        git(&self.path, &["pack-refs", "--all"]);
        git(&self.path, &["gc", "--aggressive", "--prune=now"]);
    }
}

impl Drop for Fixture {
    fn drop(&mut self) {
        fs::remove_dir_all(&self.path).ok();
    }
}

#[test]
fn matches_git_for_loose_history_and_tree_diff() {
    let fixture = Fixture::new(None, 4).expect("Git must support SHA-1 repositories");
    compare_repository(&fixture, HashKind::Sha1);
}

#[test]
fn matches_git_after_pack_and_delta_compression() {
    let fixture = Fixture::new(None, 12).expect("Git must support SHA-1 repositories");
    fixture.compact();
    let repository = Repository::open(&fixture.path).unwrap();
    assert!(repository.pack_count() > 0);
    compare_repository(&fixture, HashKind::Sha1);
}

#[test]
fn reads_sha256_repository_when_supported_by_git() {
    let Some(fixture) = Fixture::new(Some("sha256"), 3) else {
        eprintln!("Git build does not support SHA-256 repositories; skipped");
        return;
    };
    compare_repository(&fixture, HashKind::Sha256);
    fixture.compact();
    compare_repository(&fixture, HashKind::Sha256);
}

fn compare_repository(fixture: &Fixture, hash: HashKind) {
    let repository = Repository::open(&fixture.path).unwrap();
    assert_eq!(repository.hash_kind(), hash);
    assert!(!repository.git_dir().as_os_str().is_empty());
    assert!(!repository.common_dir().as_os_str().is_empty());
    assert_eq!(
        repository.head().unwrap().target.unwrap().to_string(),
        fixture.commits.last().unwrap().as_str()
    );
    let symbolic = repository.head().unwrap().symbolic.unwrap();
    let short_branch = symbolic.rsplit('/').next().unwrap();
    assert_eq!(
        repository.reference(&symbolic).unwrap().target,
        repository.resolve("HEAD").unwrap()
    );
    assert_eq!(
        repository.resolve(short_branch).unwrap(),
        repository.resolve("HEAD").unwrap()
    );
    assert!(repository.reference("../bad").is_err());
    compare_history(&repository, fixture);

    let tag = repository.tag(repository.resolve("v1").unwrap()).unwrap();
    assert_eq!(tag.name, "v1");
    let head_commit = repository
        .commit(repository.resolve("HEAD").unwrap())
        .unwrap();
    let tree = repository.tree(head_commit.tree).unwrap();
    let blob = tree
        .entries
        .iter()
        .find(|entry| entry.kind == weavatrix_git::EntryKind::Blob)
        .unwrap()
        .id;
    assert_eq!(
        repository.object(blob).unwrap().kind,
        weavatrix_git::ObjectKind::Blob
    );
    assert!(repository.commit(blob).is_err());
    assert!(
        repository
            .diff_trees(head_commit.tree, head_commit.tree)
            .unwrap()
            .is_empty()
    );

    let old = repository.resolve(&fixture.commits[1]).unwrap();
    let new = repository.resolve(&fixture.commits[2]).unwrap();
    let actual = repository
        .diff_commits(old, new)
        .unwrap()
        .into_iter()
        .map(|change| {
            (
                String::from_utf8(change.path).unwrap(),
                match change.kind {
                    ChangeKind::Added => "A",
                    ChangeKind::Deleted => "D",
                    ChangeKind::Modified | ChangeKind::TypeChanged => "M",
                },
            )
        })
        .collect::<BTreeMap<_, _>>();
    assert_eq!(
        actual,
        git_diff(&fixture.path, &fixture.commits[1], &fixture.commits[2])
    );
}

fn compare_history(repository: &Repository, fixture: &Fixture) {
    let history = repository
        .history(
            repository.resolve("HEAD").unwrap(),
            HistoryOptions {
                max_commits: fixture.commits.len(),
                ..HistoryOptions::default()
            },
        )
        .unwrap();
    let actual = history
        .iter()
        .map(|record| record.id.to_string())
        .collect::<Vec<_>>();
    let fast = repository
        .history_ids(
            repository.resolve("HEAD").unwrap(),
            HistoryOptions {
                max_commits: fixture.commits.len(),
                ..HistoryOptions::default()
            },
        )
        .unwrap()
        .into_iter()
        .map(|id| id.to_string())
        .collect::<Vec<_>>();
    let mut expected = fixture.commits.clone();
    expected.reverse();
    assert_eq!(actual, expected);
    assert_eq!(fast, expected);
    assert_eq!(history[0].commit.summary_lossy(), expected_summary(fixture));
    let outside = repository
        .history(
            repository.resolve("HEAD").unwrap(),
            HistoryOptions {
                max_commits: fixture.commits.len(),
                until: Some(-1),
                ..HistoryOptions::default()
            },
        )
        .unwrap();
    assert!(outside.is_empty());
}

fn expected_summary(fixture: &Fixture) -> String {
    let count = fixture.commits.len() - 1;
    format!("revision {count}")
}

fn git_diff(path: &Path, old: &str, new: &str) -> BTreeMap<String, &'static str> {
    git(
        path,
        &[
            "diff-tree",
            "--no-commit-id",
            "--name-status",
            "-r",
            old,
            new,
        ],
    )
    .lines()
    .map(|line| {
        let (kind, path) = line.split_once('\t').unwrap();
        let kind = match kind.as_bytes()[0] {
            b'A' => "A",
            b'D' => "D",
            _ => "M",
        };
        (path.to_owned(), kind)
    })
    .collect()
}

fn git(path: &Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .args(args)
        .current_dir(path)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git {args:?}: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).unwrap()
}

fn git_status(path: &Path, args: &[&str]) -> bool {
    Command::new("git")
        .args(args)
        .current_dir(path)
        .status()
        .is_ok_and(|status| status.success())
}