Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-client/tests/porcelain.rs

//! The ordinary verbs, cross-checked against stock `git`.
//!
//! Every fixture is built by `git 2.53`, and every assertion compares gunnar's
//! answer to what `git log` / `git diff --name-status` / `git status --porcelain`
//! / `git for-each-ref` say about the same repository. Where gunnar *writes*
//! (`commit`, `branch`, `tag`, `checkout`), stock git reads the result back.
//!
//! Hermetic; no network.

mod common;

use common::*;
use gix::hash::Kind;
use gunnar_client::porcelain::{self, ChangeState, FileChange};

#[test]
fn log_matches_git_log() {
    for kind in BOTH_HASH_KINDS {
        let dir = tempfile::tempdir().unwrap();
        let repo_path = dir.path().join("r");
        init_worktree(&repo_path, kind);
        commit_file(&repo_path, "a.txt", "one\n", "first");
        commit_file(&repo_path, "b.txt", "two\n", "second");
        commit_file(&repo_path, "c.txt", "three\n", "third");

        let repo = open(&repo_path);
        let entries = porcelain::log(&repo, None, None).unwrap();

        let expected: Vec<String> = git(&repo_path, &["log", "--format=%H"])
            .lines()
            .map(str::to_string)
            .collect();
        let got: Vec<String> = entries.iter().map(|c| c.id.to_hex().to_string()).collect();
        assert_eq!(got, expected, "{kind:?}: log order or contents differ");

        assert_eq!(entries[0].summary, "third");
        assert_eq!(entries[0].author, "Fixture <fixture@example.invalid>");
        assert_eq!(entries[0].time_unix, 1_700_000_000);
        assert_eq!(
            entries.last().unwrap().parents.len(),
            0,
            "the root has no parent"
        );
        assert_eq!(entries[0].parents.len(), 1);
        assert_eq!(
            entries[0].tree.to_hex().to_string(),
            git(&repo_path, &["rev-parse", "HEAD^{tree}"])
        );

        let limited = porcelain::log(&repo, None, Some(2)).unwrap();
        assert_eq!(limited.len(), 2, "the limit must actually limit");
        let from_rev = porcelain::log(&repo, Some("HEAD~1"), None).unwrap();
        assert_eq!(from_rev.len(), 2, "walking from an older tip sees less");
        assert_eq!(from_rev[0].summary, "second");
    }
}

#[test]
fn diff_matches_git_diff_name_status() {
    let dir = tempfile::tempdir().unwrap();
    let repo_path = dir.path().join("r");
    init_worktree(&repo_path, Kind::Sha1);
    commit_file(&repo_path, "keep.txt", "keep\n", "first");
    std::fs::write(repo_path.join("gone.txt"), "gone\n").unwrap();
    git(&repo_path, &["add", "gone.txt"]);
    git(&repo_path, &["commit", "-q", "-m", "second"]);

    std::fs::remove_file(repo_path.join("gone.txt")).unwrap();
    std::fs::write(repo_path.join("keep.txt"), "changed\n").unwrap();
    std::fs::write(repo_path.join("new.txt"), "new\n").unwrap();
    git(&repo_path, &["add", "-A"]);
    git(&repo_path, &["commit", "-q", "-m", "third"]);

    let repo = open(&repo_path);
    let head = repo.head_id().unwrap().detach();
    let changes = porcelain::diff_commit(&repo, head).unwrap();

    let mut got: Vec<String> = changes
        .iter()
        .map(|c| {
            let tag = match c {
                FileChange::Added { .. } => "A",
                FileChange::Deleted { .. } => "D",
                FileChange::Modified { .. } => "M",
                FileChange::Renamed { .. } => "R",
            };
            format!("{tag}\t{}", c.path())
        })
        .collect();
    let mut expected: Vec<String> = git(&repo_path, &["diff", "--name-status", "HEAD~1", "HEAD"])
        .lines()
        .map(str::to_string)
        .collect();
    got.sort();
    expected.sort();
    assert_eq!(got, expected);
}

/// The first commit's diff is its whole content, not nothing.
#[test]
fn a_root_commit_diffs_against_the_empty_tree() {
    let dir = tempfile::tempdir().unwrap();
    let repo_path = dir.path().join("r");
    init_worktree(&repo_path, Kind::Sha1);
    let root = commit_file(&repo_path, "a.txt", "one\n", "first");

    let repo = open(&repo_path);
    let changes = porcelain::diff_commit(
        &repo,
        gix::hash::ObjectId::from_hex(root.as_bytes()).unwrap(),
    )
    .unwrap();
    assert_eq!(changes.len(), 1, "{changes:?}");
    assert!(matches!(&changes[0], FileChange::Added { path, .. } if path == "a.txt"));
}

#[test]
fn status_matches_git_status_porcelain() {
    let dir = tempfile::tempdir().unwrap();
    let repo_path = dir.path().join("r");
    init_worktree(&repo_path, Kind::Sha1);
    commit_file(&repo_path, "tracked.txt", "one\n", "first");

    assert!(
        porcelain::is_clean(&open(&repo_path)).unwrap(),
        "a freshly committed tree is clean"
    );
    assert_eq!(git(&repo_path, &["status", "--porcelain"]), "");

    std::fs::write(repo_path.join("tracked.txt"), "modified\n").unwrap();
    std::fs::write(repo_path.join("untracked.txt"), "new\n").unwrap();
    std::fs::write(repo_path.join("staged.txt"), "staged\n").unwrap();
    git(&repo_path, &["add", "staged.txt"]);

    let entries = porcelain::status(&open(&repo_path)).unwrap();
    let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
    assert!(paths.contains(&"tracked.txt"), "{entries:?}");
    assert!(paths.contains(&"untracked.txt"), "{entries:?}");
    assert!(paths.contains(&"staged.txt"), "{entries:?}");

    let by_path = |p: &str| entries.iter().find(|e| e.path == p).unwrap().state;
    assert_eq!(by_path("tracked.txt"), ChangeState::Modified);
    assert_eq!(by_path("untracked.txt"), ChangeState::Untracked);
    assert_eq!(by_path("staged.txt"), ChangeState::Staged);
    assert!(!porcelain::is_clean(&open(&repo_path)).unwrap());

    // And stock git agrees on the SET of paths.
    let mut theirs: Vec<String> = git(&repo_path, &["status", "--porcelain"])
        .lines()
        .map(|l| l[3..].to_string())
        .collect();
    let mut ours: Vec<String> = entries.iter().map(|e| e.path.clone()).collect();
    theirs.sort();
    theirs.dedup();
    ours.sort();
    ours.dedup();
    assert_eq!(ours, theirs);
}

#[test]
fn status_on_a_bare_repository_is_an_error_not_an_empty_list() {
    let dir = tempfile::tempdir().unwrap();
    let bare = dir.path().join("b.git");
    init_bare(&bare, Kind::Sha1);
    let err = porcelain::status(&open(&bare)).unwrap_err();
    // The chain, not the bare `Display`: a variant's own `Display` is its layer
    // label ("repository") and the sentence lives in its `#[source]`. `{err:#}`
    // is what `gunnar` prints and therefore what a user reads.
    let shown = gunnar_client::chain(&err);
    assert!(
        shown.contains("bare"),
        "an empty status from a bare repo would read as 'clean': {shown}"
    );
}

#[test]
fn commit_all_is_read_back_by_stock_git() {
    let dir = tempfile::tempdir().unwrap();
    let repo_path = dir.path().join("r");
    init_worktree(&repo_path, Kind::Sha1);
    commit_file(&repo_path, "a.txt", "one\n", "first");

    std::fs::write(repo_path.join("b.txt"), "two\n").unwrap();
    std::fs::write(repo_path.join("a.txt"), "one and a half\n").unwrap();
    let id = porcelain::commit_all(&repo_path, "written by gunnar").unwrap();

    assert_eq!(
        git(&repo_path, &["rev-parse", "HEAD"]),
        id.to_hex().to_string(),
        "HEAD did not move to the new commit"
    );
    assert_eq!(
        git(&repo_path, &["log", "-1", "--format=%s"]),
        "written by gunnar"
    );
    assert_eq!(git(&repo_path, &["show", "HEAD:b.txt"]), "two");
    assert_eq!(git(&repo_path, &["show", "HEAD:a.txt"]), "one and a half");
    assert_eq!(
        git(&repo_path, &["status", "--porcelain"]),
        "",
        "the tree should be clean after a snapshot commit"
    );
    assert!(git_try(&repo_path, &["fsck", "--strict", "--no-progress"]).is_ok());
}

/// A deletion in the working tree must reach the commit. A snapshot that only
/// ever adds would pass every "is the new file there" assertion.
#[test]
fn commit_all_captures_a_deletion() {
    let dir = tempfile::tempdir().unwrap();
    let repo_path = dir.path().join("r");
    init_worktree(&repo_path, Kind::Sha1);
    commit_file(&repo_path, "a.txt", "one\n", "first");
    commit_file(&repo_path, "b.txt", "two\n", "second");

    std::fs::remove_file(repo_path.join("b.txt")).unwrap();
    porcelain::commit_all(&repo_path, "drop b").unwrap();

    assert!(
        git_try(&repo_path, &["cat-file", "-e", "HEAD:b.txt"]).is_err(),
        "the deletion did not reach the commit"
    );
    assert!(git_try(&repo_path, &["cat-file", "-e", "HEAD:a.txt"]).is_ok());
}

#[test]
fn branches_are_created_listed_and_deleted() {
    let dir = tempfile::tempdir().unwrap();
    let repo_path = dir.path().join("r");
    init_worktree(&repo_path, Kind::Sha1);
    let head = commit_file(&repo_path, "a.txt", "one\n", "first");

    let repo = open(&repo_path);
    porcelain::create_branch(&repo, "topic", None).unwrap();
    assert_eq!(
        git(&repo_path, &["rev-parse", "refs/heads/topic"]),
        head,
        "stock git does not see the branch gunnar created"
    );

    let listed = porcelain::branches(&repo).unwrap();
    let names: Vec<&str> = listed.iter().map(|b| b.name.as_str()).collect();
    assert_eq!(names, vec!["main", "topic"]);
    assert!(listed.iter().find(|b| b.name == "main").unwrap().is_head);
    assert!(!listed.iter().find(|b| b.name == "topic").unwrap().is_head);

    // Creating it twice must fail rather than silently move it.
    assert!(porcelain::create_branch(&repo, "topic", None).is_err());
    // The current branch must not be deletable.
    assert!(porcelain::delete_branch(&repo, "main").is_err());

    porcelain::delete_branch(&repo, "topic").unwrap();
    assert!(
        git_try(&repo_path, &["rev-parse", "refs/heads/topic"]).is_err(),
        "the branch survived the delete"
    );
    assert!(
        porcelain::delete_branch(&repo, "topic").is_err(),
        "and deleting it again fails"
    );
}

#[test]
fn tags_lightweight_and_annotated_are_read_back_by_stock_git() {
    for kind in BOTH_HASH_KINDS {
        let dir = tempfile::tempdir().unwrap();
        let repo_path = dir.path().join("r");
        init_worktree(&repo_path, kind);
        let head_sha = commit_file(&repo_path, "a.txt", "one\n", "first");
        let head = gix::hash::ObjectId::from_hex(head_sha.as_bytes()).unwrap();

        porcelain::tag_lightweight(&repo_path, "light", head).unwrap();
        let repo = open(&repo_path);
        let tag_id = porcelain::tag_annotated(&repo, "heavy", head, "a release", None).unwrap();

        assert_eq!(
            git(&repo_path, &["cat-file", "-t", "refs/tags/light"]),
            "commit"
        );
        assert_eq!(
            git(&repo_path, &["cat-file", "-t", "refs/tags/heavy"]),
            "tag"
        );
        assert_eq!(
            git(&repo_path, &["rev-parse", "refs/tags/heavy"]),
            tag_id.to_hex().to_string()
        );
        assert_eq!(
            git(&repo_path, &["rev-parse", "refs/tags/heavy^{commit}"]),
            head_sha,
            "{kind:?}: the annotated tag does not peel to the commit"
        );
        assert!(git_try(&repo_path, &["fsck", "--strict", "--no-progress"]).is_ok());

        let listed = porcelain::tags(&repo).unwrap();
        let heavy = listed.iter().find(|t| t.name == "heavy").unwrap();
        let light = listed.iter().find(|t| t.name == "light").unwrap();
        assert!(heavy.annotated, "{kind:?}");
        assert_eq!(heavy.peeled, Some(head));
        assert_eq!(heavy.target, tag_id);
        assert!(
            !light.annotated,
            "{kind:?}: a lightweight tag has no tag object"
        );
        assert_eq!(light.target, head);
        assert_eq!(light.peeled, None);

        // A second annotated tag of the same name must fail, not overwrite.
        assert!(porcelain::tag_annotated(&repo, "heavy", head, "again", None).is_err());
    }
}

#[test]
fn checkout_moves_head_and_materialises_the_worktree() {
    let dir = tempfile::tempdir().unwrap();
    let repo_path = dir.path().join("r");
    init_worktree(&repo_path, Kind::Sha1);
    let first = commit_file(&repo_path, "a.txt", "one\n", "first");
    commit_file(&repo_path, "b.txt", "two\n", "second");
    assert!(repo_path.join("b.txt").exists());

    let first_id = gix::hash::ObjectId::from_hex(first.as_bytes()).unwrap();
    porcelain::checkout_detached(&repo_path, first_id).unwrap();

    assert_eq!(git(&repo_path, &["rev-parse", "HEAD"]), first);
    assert!(
        !repo_path.join("b.txt").exists(),
        "the worktree still holds a file the checked-out commit does not have"
    );
    assert_eq!(
        std::fs::read_to_string(repo_path.join("a.txt")).unwrap(),
        "one\n"
    );
    assert_eq!(
        git(&repo_path, &["status", "--porcelain"]),
        "",
        "the materialised worktree must be clean against its own commit"
    );

    // …and back onto a branch.
    let second = git(&repo_path, &["rev-parse", "refs/heads/main"]);
    porcelain::checkout_branch(
        &repo_path,
        "main",
        gix::hash::ObjectId::from_hex(second.as_bytes()).unwrap(),
    )
    .unwrap();
    assert_eq!(git(&repo_path, &["rev-parse", "HEAD"]), second);
    assert!(repo_path.join("b.txt").exists());
    assert_eq!(
        git(&repo_path, &["symbolic-ref", "HEAD"]),
        "refs/heads/main"
    );
}

/// `commit_tree` is the plumbing form: the caller decides the tree and parents.
#[test]
fn commit_tree_writes_a_commit_stock_git_accepts() {
    let dir = tempfile::tempdir().unwrap();
    let repo_path = dir.path().join("r");
    init_worktree(&repo_path, Kind::Sha1);
    let parent_sha = commit_file(&repo_path, "a.txt", "one\n", "first");
    let parent = gix::hash::ObjectId::from_hex(parent_sha.as_bytes()).unwrap();
    let tree =
        gix::hash::ObjectId::from_hex(git(&repo_path, &["rev-parse", "HEAD^{tree}"]).as_bytes())
            .unwrap();

    let repo = open(&repo_path);
    let id =
        porcelain::commit_tree(&repo, "refs/heads/side", "empty change", tree, &[parent]).unwrap();

    assert_eq!(
        git(&repo_path, &["rev-parse", "refs/heads/side"]),
        id.to_hex().to_string()
    );
    assert_eq!(
        git(&repo_path, &["log", "-1", "--format=%s", "refs/heads/side"]),
        "empty change"
    );
    assert_eq!(
        git(&repo_path, &["rev-parse", "refs/heads/side^"]),
        parent_sha
    );
    assert!(git_try(&repo_path, &["fsck", "--strict", "--no-progress"]).is_ok());
}