weavatrix-git 0.3.1

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

use weavatrix_git::{
    GitError, HistoryOptions, Limits, MemoryObjectBackend, Object, ObjectId, ObjectKind,
    Repository, StatusKind,
};

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

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

impl Fixture {
    fn new() -> Self {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "weavatrix-features-{}-{unique}-{}",
            std::process::id(),
            FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&path).unwrap();
        git(&path, &["init", "-q"]);
        git(&path, &["config", "user.name", "Weavatrix Test"]);
        git(&path, &["config", "user.email", "test@weavatrix.local"]);
        fs::write(path.join("a.txt"), "a0").unwrap();
        fs::write(path.join("b.txt"), "b0").unwrap();
        let mut commits = Vec::new();
        for revision in 0..3 {
            fs::write(path.join("a.txt"), format!("a{revision}")).unwrap();
            git(&path, &["add", "-A"]);
            git(
                &path,
                &["commit", "-q", "-m", &format!("revision {revision}")],
            );
            commits.push(
                git_output(&path, &["rev-parse", "HEAD"])
                    .trim()
                    .parse()
                    .unwrap(),
            );
        }
        Self { path, commits }
    }
}

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

#[test]
fn lazy_revwalk_hides_ancestors_and_resets() {
    let fixture = Fixture::new();
    let repository = Repository::open(&fixture.path).unwrap();
    let mut walk = repository.revwalk();
    walk.push_head().unwrap().push(fixture.commits[2]).unwrap();
    let actual = walk.collect::<Result<Vec<_>, _>>().unwrap();
    let mut expected = fixture.commits.clone();
    expected.reverse();
    assert_eq!(actual, expected);

    let mut walk = repository.revwalk();
    walk.push_head().unwrap().hide(fixture.commits[0]).unwrap();
    let actual = walk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
    assert_eq!(actual, vec![fixture.commits[2], fixture.commits[1]]);
    walk.reset();
    walk.push_ref("HEAD").unwrap().first_parent(true);
    assert_eq!(walk.count(), 3);

    git(
        &fixture.path,
        &["branch", "base", &fixture.commits[0].to_string()],
    );
    let mut walk = repository.revwalk();
    walk.push_head().unwrap().hide_ref("base").unwrap();
    assert_eq!(walk.count(), 2);
}

#[test]
fn first_parent_history_is_ordered_and_bounded() {
    let fixture = Fixture::new();
    let repository = Repository::open(&fixture.path).unwrap();
    let options = HistoryOptions {
        max_commits: 3,
        first_parent: true,
        ..HistoryOptions::default()
    };
    let mut expected = fixture.commits.clone();
    expected.reverse();
    assert_eq!(
        repository.history_ids(fixture.commits[2], options).unwrap(),
        expected
    );
    let one = HistoryOptions {
        max_commits: 1,
        first_parent: true,
        ..HistoryOptions::default()
    };
    assert_eq!(
        repository.history_ids(fixture.commits[2], one).unwrap(),
        [fixture.commits[2]]
    );
    assert_eq!(
        repository.history(fixture.commits[2], one).unwrap()[0].id,
        fixture.commits[2]
    );
    let general = HistoryOptions {
        max_commits: 1,
        ..HistoryOptions::default()
    };
    assert_eq!(
        repository.history_ids(fixture.commits[2], general).unwrap(),
        [fixture.commits[2]]
    );

    let limits = Limits {
        max_history_commits: 2,
        ..Limits::default()
    };
    let repository = Repository::open_with_limits(&fixture.path, limits).unwrap();
    let error = repository
        .history_ids(
            fixture.commits[2],
            HistoryOptions {
                max_commits: 2,
                first_parent: true,
                since: Some(i64::MAX),
                until: None,
            },
        )
        .unwrap_err();
    assert!(matches!(error, GitError::LimitExceeded { .. }));
}

#[test]
fn reads_reflog_and_index_v2_and_v4() {
    let fixture = Fixture::new();
    let repository = Repository::open(&fixture.path).unwrap();
    assert!(repository.status().unwrap().is_empty());
    let object = repository.object_shared(fixture.commits[2]).unwrap();
    assert!(Arc::ptr_eq(
        &object,
        &repository.object_shared(fixture.commits[2]).unwrap()
    ));
    let foreign = "00".repeat(32).parse::<ObjectId>().unwrap();
    assert!(repository.object_shared(foreign).is_err());
    assert!(!repository.contains(foreign));
    let reflog = repository.reflog("HEAD").unwrap();
    assert_eq!(reflog[0].new_id, fixture.commits[2]);
    assert!(reflog[0].message_lossy().contains("revision 2"));

    let index = repository.index_shared().unwrap();
    assert_eq!(repository.index().unwrap(), *index);
    assert!(Arc::ptr_eq(&index, &repository.index_shared().unwrap()));
    assert_eq!(index.version(), 2);
    assert_eq!(
        index
            .entries()
            .iter()
            .map(|entry| entry.path.as_slice())
            .collect::<Vec<_>>(),
        [b"a.txt".as_slice(), b"b.txt".as_slice()]
    );
    git(&fixture.path, &["update-index", "--index-version", "4"]);
    let refreshed = repository.index_shared().unwrap();
    assert_eq!(refreshed.version(), 4);
    assert_eq!(refreshed.entries().len(), 2);
    assert!(!Arc::ptr_eq(&index, &refreshed));
}

#[test]
fn tracked_status_matches_git_index_and_worktree_layers() {
    let fixture = Fixture::new();
    fs::write(fixture.path.join("a.txt"), "unstaged").unwrap();
    fs::write(fixture.path.join("c.txt"), "staged").unwrap();
    git(&fixture.path, &["add", "c.txt"]);
    git(&fixture.path, &["rm", "-q", "b.txt"]);

    let repository = Repository::open(&fixture.path).unwrap();
    let status = repository.status().unwrap();
    assert_eq!(status.len(), 3);
    let a = status.iter().find(|entry| entry.path == b"a.txt").unwrap();
    assert_eq!(a.index, StatusKind::Unmodified);
    assert_eq!(a.worktree, StatusKind::Modified);
    let b = status.iter().find(|entry| entry.path == b"b.txt").unwrap();
    assert_eq!(b.index, StatusKind::Deleted);
    let c = status.iter().find(|entry| entry.path == b"c.txt").unwrap();
    assert_eq!(c.index, StatusKind::Added);
    assert_eq!(c.worktree, StatusKind::Unmodified);

    let porcelain = git_output(&fixture.path, &["status", "--porcelain=v1", "-uno"]);
    assert!(porcelain.lines().any(|line| line == " M a.txt"));
    assert!(porcelain.lines().any(|line| line == "D  b.txt"));
    assert!(porcelain.lines().any(|line| line == "A  c.txt"));
}

#[test]
fn custom_object_backend_precedes_native_storage() {
    let fixture = Fixture::new();
    let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
    let backend = Arc::new(MemoryObjectBackend::new([Object {
        id,
        kind: ObjectKind::Blob,
        data: b"external evidence".to_vec(),
    }]));
    let second: ObjectId = "2222222222222222222222222222222222222222".parse().unwrap();
    backend.insert(Object {
        id: second,
        kind: ObjectKind::Blob,
        data: b"second".to_vec(),
    });
    let repository =
        Repository::open_with_backends(&fixture.path, Limits::default(), vec![backend]).unwrap();
    assert!(repository.contains_checked(id).unwrap());
    assert_eq!(repository.object(id).unwrap().data, b"external evidence");
    let objects = repository.objects_parallel(&[id, second]);
    assert_eq!(objects[1].as_ref().unwrap().data, b"second");
    assert!(
        repository
            .commit_maybe_changed_path(fixture.commits[2], b"a.txt")
            .unwrap()
            .is_none()
    );
    assert!(
        repository
            .bitmap_reachable(fixture.commits[2])
            .unwrap()
            .is_none()
    );
}

#[test]
fn tracked_status_detects_deleted_and_type_changed_files() {
    let fixture = Fixture::new();
    fs::remove_file(fixture.path.join("a.txt")).unwrap();
    fs::remove_file(fixture.path.join("b.txt")).unwrap();
    fs::create_dir(fixture.path.join("b.txt")).unwrap();

    let repository = Repository::open(&fixture.path).unwrap();
    let status = repository.status().unwrap();
    let a = status.iter().find(|entry| entry.path == b"a.txt").unwrap();
    assert_eq!(a.worktree, StatusKind::Deleted);
    let b = status.iter().find(|entry| entry.path == b"b.txt").unwrap();
    assert_eq!(b.worktree, StatusKind::TypeChanged);
    assert!(repository.reflog("../bad").is_err());
}

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

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