rskit-git 0.2.0-alpha.3

Composable git repository interfaces backed by libgit2
Documentation
use crate::helpers;

use std::path::Path;
use std::time::{Duration, UNIX_EPOCH};

use rskit_errors::ErrorCode;
use rskit_git::{
    BlameOptions, Blamer, Differ, EntryKind, EntryState, FileStatus, IndexReader, LogReader,
    Repository, TreeReader, discover, init, open,
};
use rskit_process::{ProcessConfig, ProcessSpec, run};

#[test]
fn repository_open_discover_and_head_edge_errors_are_typed() {
    let empty = helpers::TestRepo::empty_dir();

    let missing = open(empty.path().join("missing"))
        .err()
        .expect("missing path is not found");
    assert_eq!(missing.code(), ErrorCode::NotFound);
    assert!(missing.message().contains("repository"));

    let not_a_repo = open(empty.path())
        .err()
        .expect("plain directory is not a repository");
    assert_eq!(not_a_repo.code(), ErrorCode::NotFound);
    assert!(not_a_repo.message().contains("repository"));

    let undiscovered = discover(empty.path())
        .err()
        .expect("no parent repository exists");
    assert_eq!(undiscovered.code(), ErrorCode::NotFound);
    assert!(undiscovered.message().contains("repository"));

    let unborn = init(empty.path().join("repo")).expect("init empty repo");
    let head = unborn.head().expect_err("unborn HEAD has no target");
    assert_eq!(head.code(), ErrorCode::NotFound);
}

#[test]
fn bare_and_detached_heads_report_roots_and_references() {
    let bare = helpers::TestRepo::init_bare();
    let bare_repo = open(bare.path()).expect("open bare repo");
    assert_eq!(
        bare_repo.root().canonicalize().unwrap(),
        bare.path().canonicalize().unwrap()
    );

    let repo = helpers::TestRepo::init();
    let head = repo.rev_parse("HEAD");
    run_git(repo.path(), &["checkout", "--detach", &head]);
    let r = open(repo.path()).expect("open detached repo");

    let detached = r.head().expect("detached HEAD still resolves");
    assert_eq!(detached.name, "HEAD");
    assert!(!detached.is_branch);
    assert_eq!(detached.target.to_string(), head);
}

#[test]
fn invalid_revisions_and_tree_paths_return_typed_errors() {
    let repo = helpers::TestRepo::init();
    let r = open(repo.path()).unwrap();
    let tree = r.tree_hash("HEAD", "").unwrap().to_string();
    let blob = r
        .index_entry("README.md")
        .unwrap()
        .expect("README.md is indexed")
        .oid
        .to_string();

    assert_not_found(r.file_at("missing-ref", "README.md"), "missing-ref");
    assert_not_found(r.file_at(&tree, "README.md"), &tree);
    assert_invalid_input(r.tree_hash("HEAD", "README.md"), "README.md");
    assert_invalid_input(r.list_entries("HEAD", "missing-dir"), "missing-dir");
    assert_not_found(r.list_entries(&blob, ""), &blob);
    assert_not_found(r.diff("missing-ref", "HEAD"), "missing-ref");
    assert_not_found(r.diff(&blob, "HEAD"), &blob);
    assert_not_found(r.merge_base("missing-ref", "HEAD"), "missing-ref");
    assert_not_found(r.merge_base(&tree, "HEAD"), &tree);
    assert_not_found(r.resolve_ref("refs/heads/does-not-exist"), "does-not-exist");
}

#[test]
fn diff_status_and_tree_entries_cover_deleted_conflicted_and_submodule_states() {
    let repo = helpers::TestRepo::init();
    repo.commit_file("deleted.txt", "gone\n", "add deleted file");
    repo.create_tag("before-delete");
    std::fs::remove_file(repo.path().join("deleted.txt")).unwrap();
    run_git(repo.path(), &["add", "deleted.txt"]);
    run_git(repo.path(), &["commit", "-m", "delete file"]);

    let r = open(repo.path()).unwrap();
    let diff = r.diff("before-delete", "HEAD").unwrap();
    assert!(
        diff.iter()
            .any(|entry| entry.path == "deleted.txt" && entry.status == FileStatus::Deleted)
    );

    let submodule_oid = repo.rev_parse("HEAD");
    run_git(
        repo.path(),
        &[
            "update-index",
            "--add",
            "--cacheinfo",
            &format!("160000,{submodule_oid},vendor/lib"),
        ],
    );
    run_git(repo.path(), &["commit", "-m", "add gitlink"]);
    let r = open(repo.path()).unwrap();
    let entries = r.list_entries("HEAD", "vendor").unwrap();
    assert!(
        entries
            .iter()
            .any(|entry| entry.name == "lib" && entry.kind == EntryKind::Submodule)
    );

    let conflict = helpers::TestRepo::init();
    conflict.commit_file("conflict.txt", "base\n", "add conflict base");
    conflict.create_branch("feature");
    let main = conflict.current_branch();
    conflict.checkout("feature");
    conflict.commit_file("conflict.txt", "feature\n", "feature edit");
    conflict.checkout(&main);
    conflict.commit_file("conflict.txt", "main\n", "main edit");
    let merge = run_git_allow_failure(conflict.path(), &["merge", "feature"]);
    assert!(!merge.success(), "merge should conflict");
    let conflicted = open(conflict.path()).unwrap().status().unwrap();
    assert!(
        conflicted
            .iter()
            .any(|entry| entry.path == "conflict.txt" && entry.state == EntryState::Conflicted)
    );
}

#[test]
fn log_blame_and_merge_base_edge_cases_are_typed() {
    let empty = init(helpers::TestRepo::empty_dir().path()).expect("init empty repo");
    let log = empty.log(None).expect_err("empty repository has no HEAD");
    assert_eq!(log.code(), ErrorCode::Internal);

    let repo = helpers::TestRepo::init();
    repo.commit_file("notes.txt", "one\ntwo\n", "add notes");
    let r = open(repo.path()).unwrap();

    let range = r
        .blame(
            "HEAD",
            "notes.txt",
            Some(&BlameOptions {
                start_line: Some(3),
                end_line: Some(2),
                ..Default::default()
            }),
        )
        .expect_err("invalid blame range is rejected");
    assert_eq!(range.code(), ErrorCode::InvalidInput);
    assert!(range.message().contains("3..2"));

    assert_not_found(r.blame("missing-ref", "notes.txt", None), "missing-ref");

    let disconnected = helpers::TestRepo::init();
    let first = disconnected.rev_parse("HEAD");
    run_git(disconnected.path(), &["checkout", "--orphan", "other-root"]);
    run_git(disconnected.path(), &["rm", "-rf", "."]);
    std::fs::write(disconnected.path().join("other.txt"), "other\n").unwrap();
    run_git(disconnected.path(), &["add", "other.txt"]);
    run_git(disconnected.path(), &["commit", "-m", "other root"]);
    let r = open(disconnected.path()).unwrap();
    let no_base = r
        .merge_base(&first, "HEAD")
        .expect_err("orphan histories have no merge base");
    assert_eq!(no_base.code(), ErrorCode::NotFound);
    assert!(no_base.message().contains("merge base"));
}

#[test]
fn pre_epoch_commit_times_convert_without_panicking() {
    let repo = helpers::TestRepo::init();
    std::fs::write(repo.path().join("old.txt"), "old\n").unwrap();
    commit_with_pre_epoch_time(repo.path(), "old.txt", "pre epoch");

    let r = open(repo.path()).unwrap();
    let commits = r.log(None).unwrap();
    let old = commits
        .iter()
        .find(|commit| commit.message == "pre epoch")
        .expect("pre epoch commit is present");

    assert_eq!(old.author.when, UNIX_EPOCH - Duration::from_secs(5));
    assert_eq!(old.committer.when, UNIX_EPOCH - Duration::from_secs(5));
}

fn assert_not_found<T>(result: rskit_git::AppResult<T>, needle: &str) {
    let err = result
        .err()
        .expect("operation should return a not found error");
    assert_eq!(err.code(), ErrorCode::NotFound);
    assert!(
        err.message().contains(needle),
        "message {:?} did not contain {:?}",
        err.message(),
        needle
    );
}

fn assert_invalid_input<T>(result: rskit_git::AppResult<T>, needle: &str) {
    let err = result
        .err()
        .expect("operation should return an invalid input error");
    assert_eq!(err.code(), ErrorCode::InvalidInput);
    assert!(
        err.message().contains(needle),
        "message {:?} did not contain {:?}",
        err.message(),
        needle
    );
}

fn run_git(dir: &Path, args: &[&str]) -> String {
    let out = run_git_allow_failure(dir, args);
    assert!(out.success(), "git {args:?} failed: {}", out.stderr);
    out.stdout
}

fn run_git_allow_failure(dir: &Path, args: &[&str]) -> rskit_process::ProcessResult {
    let cmd = ProcessSpec::new("git").args(args.iter().copied()).dir(dir);
    run(&cmd, &ProcessConfig::default().with_timeout(None)).expect("failed to run git")
}

fn commit_with_pre_epoch_time(dir: &Path, path: &str, message: &str) {
    let repo = git2::Repository::open(dir).expect("open git2 repository");
    let mut index = repo.index().expect("read index");
    index.add_path(Path::new(path)).expect("add path");
    index.write().expect("write index");
    let tree_oid = index.write_tree().expect("write tree");
    let head_name = repo
        .head()
        .expect("read head")
        .name()
        .expect("head has a name")
        .to_string();
    let parent = repo
        .head()
        .expect("read head")
        .peel_to_commit()
        .expect("peel head to commit");
    let raw = format!(
        "tree {tree_oid}\nparent {}\nauthor Time Traveler <time@example.com> -5 +0000\ncommitter Time Traveler <time@example.com> -5 +0000\n\n{message}\n",
        parent.id()
    );
    let oid = repo
        .odb()
        .expect("open object database")
        .write(git2::ObjectType::Commit, raw.as_bytes())
        .expect("write pre-epoch commit object");
    repo.reference(&head_name, oid, true, "pre-epoch test commit")
        .expect("move branch to pre-epoch commit");
}