rskit-git 0.2.0-alpha.1

Composable git repository interfaces backed by libgit2
Documentation
mod helpers;

use std::time::{Duration, SystemTime};

use rskit_git::{
    BlameOptions, Blamer, DescribeOptions, Differ, EntryKind, EntryState, FileStatus, GrepOptions,
    IndexManager, IndexReader, Inspector, LogOptions, LogReader, Repository, TreeReader, open,
};

#[test]
fn diff_reports_added_file() {
    let repo = helpers::TestRepo::init();
    repo.create_tag("v1");
    repo.commit_file("new.txt", "hello", "add new file");
    let r = open(repo.path()).unwrap();

    let entries = r.diff("v1", "HEAD").unwrap();
    assert!(!entries.is_empty());
    assert!(
        entries
            .iter()
            .any(|e| e.path == "new.txt" && e.status == FileStatus::Added)
    );
}

#[test]
fn diff_reports_modified_file() {
    let repo = helpers::TestRepo::init();
    repo.create_tag("v1");
    repo.commit_file("README.md", "updated", "update readme");
    let r = open(repo.path()).unwrap();

    let entries = r.diff("v1", "HEAD").unwrap();
    assert!(
        entries
            .iter()
            .any(|e| e.path == "README.md" && e.status == FileStatus::Modified)
    );
}

#[test]
fn diff_stats_count_changed_files_and_lines() {
    let repo = helpers::TestRepo::init();
    repo.create_tag("v1");
    repo.commit_file("a.txt", "line1\nline2\n", "add a");
    repo.commit_file("b.txt", "line1\n", "add b");
    let r = open(repo.path()).unwrap();

    let stats = r.diff_stats("v1", "HEAD").unwrap();
    assert!(stats.files_changed >= 2);
    assert!(stats.additions >= 3);
}

#[test]
fn status_is_empty_for_clean_worktree() {
    let repo = helpers::TestRepo::init();
    let r = open(repo.path()).unwrap();
    let entries = r.status().unwrap();
    assert!(entries.is_empty());
}

#[test]
fn status_reports_untracked_and_modified_entries() {
    let repo = helpers::TestRepo::init();
    repo.make_untracked("untracked.txt");
    repo.make_dirty("README.md");
    let r = open(repo.path()).unwrap();

    let entries = r.status().unwrap();
    assert!(entries.len() >= 2);
    assert!(
        entries
            .iter()
            .any(|e| e.path == "untracked.txt" && e.state == EntryState::Untracked)
    );
}

#[test]
fn index_entry_reads_staged_blob_identity() {
    let repo = helpers::TestRepo::init();
    std::fs::write(repo.path().join("README.md"), "staged\n").unwrap();
    let r = open(repo.path()).unwrap();

    r.stage(&["README.md"]).unwrap();
    std::fs::write(repo.path().join("README.md"), "# test repo").unwrap();

    let entry = r
        .index_entry("README.md")
        .unwrap()
        .expect("README.md is present in the index");

    assert_eq!(entry.path, "README.md");
    assert_eq!(entry.kind, EntryKind::Blob);
    assert_eq!(r.show(&entry.oid.to_string()).unwrap(), b"staged\n");
}

#[test]
fn file_at_reads_blob_from_revision() {
    let repo = helpers::TestRepo::init();
    repo.commit_file("hello.txt", "hello world", "add hello");
    let r = open(repo.path()).unwrap();

    let content = r.file_at("HEAD", "hello.txt").unwrap();
    assert_eq!(content, b"hello world");
}

#[test]
fn file_at_returns_error_for_missing_path() {
    let repo = helpers::TestRepo::init();
    let r = open(repo.path()).unwrap();
    assert!(r.file_at("HEAD", "nonexistent.txt").is_err());
}

#[test]
fn list_entries_returns_tree_and_blob_entries() {
    let repo = helpers::TestRepo::init();
    repo.commit_file("a.txt", "a", "add a");
    repo.commit_file("sub/b.txt", "b", "add b");
    let r = open(repo.path()).unwrap();

    let entries = r.list_entries("HEAD", "").unwrap();
    assert!(entries.len() >= 2);
    assert!(entries.iter().any(|e| e.kind == EntryKind::Blob));
    assert!(entries.iter().any(|e| e.kind == EntryKind::Tree));
}

#[test]
fn list_entries_can_scope_to_subdirectory() {
    let repo = helpers::TestRepo::init();
    repo.commit_file("sub/file.txt", "content", "add sub/file");
    let r = open(repo.path()).unwrap();

    let entries = r.list_entries("HEAD", "sub").unwrap();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].name, "file.txt");
}

#[test]
fn tree_hash_returns_tree_oid() {
    let repo = helpers::TestRepo::init();
    let r = open(repo.path()).unwrap();
    let hash = r.tree_hash("HEAD", "").unwrap();
    assert!(!hash.is_zero());
}

#[test]
fn tree_hash_changes_when_tree_changes() {
    let repo = helpers::TestRepo::init();
    repo.create_tag("v1");
    let r = open(repo.path()).unwrap();
    let hash1 = r.tree_hash("v1", "").unwrap();

    repo.commit_file("new.txt", "content", "add file");
    let r = open(repo.path()).unwrap();
    let hash2 = r.tree_hash("HEAD", "").unwrap();

    assert_ne!(hash1, hash2);
}

#[test]
fn log_options_filter_by_count_path_author_and_time() {
    let repo = helpers::TestRepo::init();
    repo.commit_file("foo.txt", "one\n", "add foo");
    repo.commit_file("bar.txt", "bar\n", "add bar");
    repo.commit_file("foo.txt", "one\ntwo\n", "update foo");
    let r = open(repo.path()).unwrap();

    let commits = r
        .log(Some(&LogOptions {
            max_count: Some(2),
            ..Default::default()
        }))
        .unwrap();
    assert_eq!(commits.len(), 2);
    assert_eq!(commits[0].message, "update foo");
    assert_eq!(commits[1].message, "add bar");

    let foo_commits = r
        .log(Some(&LogOptions {
            path_filter: Some("foo.txt".to_string()),
            ..Default::default()
        }))
        .unwrap();
    assert_eq!(foo_commits.len(), 2);
    assert!(
        foo_commits
            .iter()
            .all(|commit| commit.author.name == "Test User")
    );

    let none_by_author = r
        .log(Some(&LogOptions {
            author_filter: Some("nobody@example.com".to_string()),
            ..Default::default()
        }))
        .unwrap();
    assert!(none_by_author.is_empty());

    let none_since = r
        .log(Some(&LogOptions {
            since: Some(SystemTime::now() + Duration::from_secs(60)),
            ..Default::default()
        }))
        .unwrap();
    assert!(none_since.is_empty());

    let none_until = r
        .log(Some(&LogOptions {
            until: Some(SystemTime::UNIX_EPOCH),
            ..Default::default()
        }))
        .unwrap();
    assert!(none_until.is_empty());
}

#[test]
fn merge_base_and_is_ancestor_report_graph_relationships() {
    let repo = helpers::TestRepo::init();
    repo.commit_file("base.txt", "base\n", "base");
    let base = repo.rev_parse("HEAD");
    repo.create_branch("feature");
    let main_branch = repo.current_branch();

    repo.checkout("feature");
    repo.commit_file("feature.txt", "feature\n", "feature work");
    let feature_head = repo.rev_parse("HEAD");

    repo.checkout(&main_branch);
    repo.commit_file("main.txt", "main\n", "main work");
    let r = open(repo.path()).unwrap();

    let merge_base = r.merge_base("HEAD", &feature_head).unwrap();
    assert_eq!(merge_base.to_string(), base);
    assert!(r.is_ancestor(&base, "HEAD").unwrap());
    assert!(r.is_ancestor(&base, &feature_head).unwrap());
    assert!(!r.is_ancestor("HEAD", &feature_head).unwrap());
    assert!(!r.head().unwrap().target.is_zero());
}

#[test]
fn blame_reports_line_commit_history() {
    let repo = helpers::TestRepo::init();
    repo.commit_file("notes.txt", "line 1\nline 2\nline 3\n", "add notes");
    let add_notes = repo.rev_parse("HEAD");
    repo.commit_file(
        "notes.txt",
        "line 1\nline 2 updated\nline 3\n",
        "update line 2",
    );
    let update_line_2 = repo.rev_parse("HEAD");
    repo.commit_file(
        "notes.txt",
        "line 1\nline 2 updated\nline 3 updated\n",
        "update line 3",
    );
    let update_line_3 = repo.rev_parse("HEAD");
    let r = open(repo.path()).unwrap();

    let lines = r.blame("HEAD", "notes.txt", None).unwrap();
    assert_eq!(lines.len(), 3);
    assert_eq!(lines[0].line, 1);
    assert_eq!(lines[0].content, "line 1");
    assert_eq!(lines[0].commit_oid.to_string(), add_notes);
    assert_eq!(lines[1].content, "line 2 updated");
    assert_eq!(lines[1].commit_oid.to_string(), update_line_2);
    assert_eq!(lines[2].content, "line 3 updated");
    assert_eq!(lines[2].commit_oid.to_string(), update_line_3);

    let subset = r
        .blame(
            "HEAD",
            "notes.txt",
            Some(&BlameOptions {
                start_line: Some(2),
                end_line: Some(3),
                ignore_whitespace: false,
                ..Default::default()
            }),
        )
        .unwrap();
    assert_eq!(subset.len(), 2);
    assert_eq!(subset[0].line, 2);
    assert_eq!(subset[1].line, 3);
}

#[test]
fn rev_parse_describe_grep_and_show_read_repository_objects() {
    let repo = helpers::TestRepo::init();
    repo.create_annotated_tag("v0.1.0", "annotated release");
    repo.commit_file("docs/notes.txt", "Hello World\nsecond line\n", "add notes");
    repo.create_tag("v0.2.0");
    let r = open(repo.path()).unwrap();

    let head = r.rev_parse("HEAD").unwrap();
    assert_eq!(head.to_string(), repo.rev_parse("HEAD"));

    let describe_default = r.describe(None).unwrap();
    assert_eq!(describe_default, "v0.2.0");

    let describe_annotated = r
        .describe(Some(&DescribeOptions {
            annotated_tags_only: true,
            ..Default::default()
        }))
        .unwrap();
    assert!(describe_annotated.starts_with("v0.1.0-1-g"));

    let describe_long = r
        .describe(Some(&DescribeOptions {
            long: true,
            ..Default::default()
        }))
        .unwrap();
    assert!(describe_long.contains("-0-g"));

    let matches = r
        .grep(
            "hello world",
            "HEAD",
            Some(&GrepOptions {
                ignore_case: true,
                line_numbers: true,
                pathspecs: vec!["docs".to_string()],
                ..Default::default()
            }),
        )
        .unwrap();
    assert_eq!(matches.len(), 1);
    assert_eq!(matches[0].path, "docs/notes.txt");
    assert_eq!(matches[0].line_number, Some(1));
    assert_eq!(matches[0].line, "Hello World");

    let shown = r.show("HEAD:docs/notes.txt").unwrap();
    assert_eq!(shown, b"Hello World\nsecond line\n");
}