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

nordisk/gunnar

crates/gunnar-cli/tests/refs_diff.rs

#![cfg(all(feature = "server", feature = "control"))]

//! **`Refs.Diff` against a real repository**, over the gRPC control plane.
//!
//! The RPC answered `Unimplemented` until `gunnar-server`'s `diff` module
//! landed, on the reasoning that a diff needs gix's blob porcelain and an S
//! crate must not link it. A *tree* diff needs `GitServe::read` and nothing
//! else, so what is exercised here is a walk over two real trees in a real
//! store, through the real `CoreControlPlane` — not the `MemoryControlPlane`
//! double, which still refuses and should.
//!
//! Every arm asserts the **whole** change set, not that a particular change is
//! present. A diff that reports one correct entry and three spurious ones
//! passes every `contains` assertion ever written about it.

use std::sync::Arc;

use gunnar_cli::server::{CoreControlPlane, ServerStores};
use gunnar_grpc::ControlPlane;
use gunnar_proto::{ChangeKind, RepoPath};
use gunnar_store::git_storage_trait::{GitOps, RefUpdate};

struct Fixture {
    _dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    plane: CoreControlPlane,
    path: RepoPath,
    hash: gix_hash::Kind,
}

fn fixture() -> Fixture {
    let dir = tempfile::tempdir().expect("temporary directory");
    let stores = Arc::new(ServerStores::open(dir.path()).expect("open the stores"));
    let plane = CoreControlPlane::new(stores.clone());
    let path = RepoPath::new("ns", "repo");
    plane
        .create_namespace("ns", "")
        .expect("create the namespace");
    plane
        .create_repo(gunnar_grpc::NewRepo {
            path: path.clone(),
            hash_kind: gunnar_proto::HashKind::Sha1,
            default_branch: String::new(),
            visibility: gunnar_proto::Visibility::Private,
            description: String::new(),
            // A test fixture creates its repository; it never adopts one.
            adopt: false,
        })
        .expect("create the repository");
    Fixture {
        _dir: dir,
        stores,
        plane,
        path,
        hash: gix_hash::Kind::Sha1,
    }
}

impl Fixture {
    fn store(&self) -> Arc<gunnar_cli::server::storage::OpenRepo> {
        let repo_ref = gunnar_core::RepoRef::new(&self.path.namespace, &self.path.repo)
            .expect("a valid repository reference");
        let repo = self
            .stores
            .catalog()
            .get(&repo_ref)
            .expect("read the catalogue")
            .expect("the repository is there");
        self.stores.repo_store(&repo).expect("open the repository")
    }

    /// Store `objects` as one pack, returning their ids in order.
    fn put(&self, objects: &[(gix_object::Kind, Vec<u8>)]) -> Vec<gix_hash::ObjectId> {
        let store = self.store();
        let mut pack = Vec::new();
        let mut writer = gunnar_pack::PackWriter::new(
            &mut pack,
            objects.len() as u32,
            &gunnar_pack::WriteOptions::new(self.hash),
        )
        .expect("start a pack");
        let mut ids = Vec::new();
        for (kind, bytes) in objects {
            writer.add_object(*kind, bytes).expect("add an object");
            ids.push(gix_object::compute_hash(self.hash, *kind, bytes).expect("hash"));
        }
        writer.finish().expect("finish the pack");
        GitOps::put_pack(store.serve(), &pack).expect("store the pack");
        ids
    }

    fn set_ref(&self, name: &str, id: gix_hash::ObjectId) {
        GitOps::put_refs(
            self.store().serve(),
            &[RefUpdate::set(name, id.to_string())],
        )
        .expect("install the reference");
    }

    fn diff(&self, from: &str, to: &str, prefixes: &[&str]) -> Vec<gunnar_proto::FileChange> {
        let prefixes: Vec<String> = prefixes.iter().map(|p| (*p).to_string()).collect();
        self.plane
            .diff(&self.path, from, to, &prefixes)
            .expect("the diff is served")
    }
}

/// `<mode> <name>\0<raw oid>` per entry, in git's own order.
///
/// git sorts tree entries by name with a **subtlety**: a directory sorts as if
/// its name ended in `/`. The fixture keeps names short and unambiguous so this
/// does not become the thing under test — the walk itself never relies on the
/// order, since it collects into a map first.
fn tree_bytes(entries: &[(u32, &str, gix_hash::ObjectId)]) -> Vec<u8> {
    let mut sorted: Vec<_> = entries.to_vec();
    sorted.sort_by(|a, b| {
        let key = |(mode, name, _): &(u32, &str, gix_hash::ObjectId)| {
            let mut k = name.as_bytes().to_vec();
            if *mode == 0o40000 {
                k.push(b'/');
            }
            k
        };
        key(a).cmp(&key(b))
    });
    let mut out = Vec::new();
    for (mode, name, oid) in sorted {
        out.extend_from_slice(format!("{mode:o} ").as_bytes());
        out.extend_from_slice(name.as_bytes());
        out.push(0);
        out.extend_from_slice(oid.as_bytes());
    }
    out
}

fn commit_bytes(tree: gix_hash::ObjectId, parents: &[gix_hash::ObjectId], at: i64) -> Vec<u8> {
    let mut text = format!("tree {}\n", tree.to_hex());
    for parent in parents {
        text.push_str(&format!("parent {}\n", parent.to_hex()));
    }
    text.push_str(&format!(
        "author Gunnar Test <gunnar@example.invalid> {at} +0000\n\
         committer Gunnar Test <gunnar@example.invalid> {at} +0000\n\ndiff fixture\n"
    ));
    text.into_bytes()
}

/// What an arm asserts against: the whole answer, in one readable shape.
fn shape(changes: &[gunnar_proto::FileChange]) -> Vec<(ChangeKind, String, u64, u64)> {
    changes
        .iter()
        .map(|c| {
            (
                ChangeKind::try_from(c.kind).expect("a known change kind"),
                c.path.clone(),
                c.insertions,
                c.deletions,
            )
        })
        .collect()
}

/// Two commits: one file added, one modified, one deleted, one left alone.
///
/// The untouched file is the arm that matters most. A walk that reported every
/// path in the tree would satisfy any assertion about the three that changed.
#[test]
fn a_diff_reports_exactly_the_paths_that_changed() {
    let f = fixture();

    let keep = f.put(&[(gix_object::Kind::Blob, b"unchanged\n".to_vec())])[0];
    let gone = f.put(&[(gix_object::Kind::Blob, b"removed\n".to_vec())])[0];
    let before = f.put(&[(gix_object::Kind::Blob, b"one\ntwo\nthree\n".to_vec())])[0];
    let after = f.put(&[(gix_object::Kind::Blob, b"one\nTWO\nthree\n".to_vec())])[0];
    let added = f.put(&[(gix_object::Kind::Blob, b"new\nfile\n".to_vec())])[0];

    let old_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[
            (0o100644, "keep.txt", keep),
            (0o100644, "gone.txt", gone),
            (0o100644, "edit.txt", before),
        ]),
    )])[0];
    let new_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[
            (0o100644, "keep.txt", keep),
            (0o100644, "edit.txt", after),
            (0o100644, "added.txt", added),
        ]),
    )])[0];

    let old_commit = f.put(&[(gix_object::Kind::Commit, commit_bytes(old_tree, &[], 1))])[0];
    let new_commit = f.put(&[(
        gix_object::Kind::Commit,
        commit_bytes(new_tree, &[old_commit], 2),
    )])[0];
    f.set_ref("refs/heads/old", old_commit);
    f.set_ref("refs/heads/new", new_commit);

    assert_eq!(
        shape(&f.diff("refs/heads/old", "refs/heads/new", &[])),
        vec![
            (ChangeKind::Added, "added.txt".to_owned(), 2, 0),
            (ChangeKind::Modified, "edit.txt".to_owned(), 1, 1),
            (ChangeKind::Deleted, "gone.txt".to_owned(), 0, 1),
        ],
        "the diff must report the three paths that changed, in path order, and must not \
         mention `keep.txt`"
    );
}

/// A tree compared with itself is empty, and that is not the same as an error.
#[test]
fn a_tree_against_itself_is_no_changes_at_all() {
    let f = fixture();
    let blob = f.put(&[(gix_object::Kind::Blob, b"same\n".to_vec())])[0];
    let tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "a.txt", blob)]),
    )])[0];
    let commit = f.put(&[(gix_object::Kind::Commit, commit_bytes(tree, &[], 1))])[0];
    f.set_ref("refs/heads/main", commit);

    assert!(
        f.diff("refs/heads/main", "refs/heads/main", &[]).is_empty(),
        "a commit against itself reported changes"
    );
}

/// **Subtrees are descended into, and only the ones that differ.**
///
/// `src/` changes and `docs/` does not. Both sides of `docs/` carry the same
/// tree id, which is what lets the walk skip it without reading it — the
/// property that makes a diff of one commit cost one commit rather than one
/// repository. The observable half is asserted here (nothing under `docs/` is
/// reported); the cost half is asserted by the prefix arm below, which would
/// have to read the whole tree if pruning did not work.
#[test]
fn nested_directories_are_walked_and_unchanged_subtrees_are_not_reported() {
    let f = fixture();

    let doc = f.put(&[(gix_object::Kind::Blob, b"stable\n".to_vec())])[0];
    let docs = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "readme.md", doc)]),
    )])[0];

    let before = f.put(&[(gix_object::Kind::Blob, b"fn main() {}\n".to_vec())])[0];
    let after = f.put(&[(
        gix_object::Kind::Blob,
        b"fn main() {\n    println!();\n}\n".to_vec(),
    )])[0];
    let src_old = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "main.rs", before)]),
    )])[0];
    let src_new = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "main.rs", after)]),
    )])[0];

    let old_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o40000, "docs", docs), (0o40000, "src", src_old)]),
    )])[0];
    let new_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o40000, "docs", docs), (0o40000, "src", src_new)]),
    )])[0];
    let old_commit = f.put(&[(gix_object::Kind::Commit, commit_bytes(old_tree, &[], 1))])[0];
    let new_commit = f.put(&[(
        gix_object::Kind::Commit,
        commit_bytes(new_tree, &[old_commit], 2),
    )])[0];
    f.set_ref("refs/heads/old", old_commit);
    f.set_ref("refs/heads/new", new_commit);

    assert_eq!(
        shape(&f.diff("refs/heads/old", "refs/heads/new", &[])),
        // `fn main() {}` shares no line with the three-line body that replaces
        // it, so the minimal script is three insertions and one deletion. The
        // first version of this assertion said `2, 1` on the assumption that
        // the `fn main() {` line survived; it does not — the old line has a `}`
        // on the end and lines are compared whole.
        vec![(ChangeKind::Modified, "src/main.rs".to_owned(), 3, 1)],
        "the nested change must be reported at its full path, and the unchanged `docs/` subtree \
         must not appear at all"
    );
}

/// A path filter narrows the answer, and narrows it to nothing when nothing
/// matches — rather than falling back to everything.
#[test]
fn a_path_prefix_narrows_the_diff_and_an_unmatched_prefix_empties_it() {
    let f = fixture();

    let a_old = f.put(&[(gix_object::Kind::Blob, b"a\n".to_vec())])[0];
    let a_new = f.put(&[(gix_object::Kind::Blob, b"A\n".to_vec())])[0];
    let b_old = f.put(&[(gix_object::Kind::Blob, b"b\n".to_vec())])[0];
    let b_new = f.put(&[(gix_object::Kind::Blob, b"B\n".to_vec())])[0];
    let src_old = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "a.txt", a_old)]),
    )])[0];
    let src_new = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "a.txt", a_new)]),
    )])[0];

    let old_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o40000, "src", src_old), (0o100644, "b.txt", b_old)]),
    )])[0];
    let new_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o40000, "src", src_new), (0o100644, "b.txt", b_new)]),
    )])[0];
    let old_commit = f.put(&[(gix_object::Kind::Commit, commit_bytes(old_tree, &[], 1))])[0];
    let new_commit = f.put(&[(
        gix_object::Kind::Commit,
        commit_bytes(new_tree, &[old_commit], 2),
    )])[0];
    f.set_ref("refs/heads/old", old_commit);
    f.set_ref("refs/heads/new", new_commit);

    // The control: both paths, with no filter.
    assert_eq!(
        shape(&f.diff("refs/heads/old", "refs/heads/new", &[])).len(),
        2,
        "the unfiltered diff must see both changes, or the filter arms below prove nothing"
    );
    assert_eq!(
        shape(&f.diff("refs/heads/old", "refs/heads/new", &["src/"])),
        vec![(ChangeKind::Modified, "src/a.txt".to_owned(), 1, 1)],
        "the `src/` filter must keep the nested change and drop the top-level one"
    );
    assert!(
        f.diff("refs/heads/old", "refs/heads/new", &["nowhere/"])
            .is_empty(),
        "a prefix that matches nothing returned changes; an empty filter result must not fall \
         back to the unfiltered answer"
    );
}

/// **An empty `from` is the empty tree**, so "everything this commit added" is
/// expressible without a magic object id.
#[test]
fn an_empty_from_diffs_against_nothing() {
    let f = fixture();
    let blob = f.put(&[(gix_object::Kind::Blob, b"first\nsecond\n".to_vec())])[0];
    let tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "a.txt", blob)]),
    )])[0];
    let commit = f.put(&[(gix_object::Kind::Commit, commit_bytes(tree, &[], 1))])[0];
    f.set_ref("refs/heads/main", commit);

    assert_eq!(
        shape(&f.diff("", "refs/heads/main", &[])),
        vec![(ChangeKind::Added, "a.txt".to_owned(), 2, 0)],
        "an empty `from` must mean the empty tree"
    );
}

/// A binary file reports `binary` and no line counts — and the line counts are
/// zero *because* it is binary, which is a different fact from a text file that
/// happens not to have changed.
#[test]
fn a_binary_change_is_flagged_and_carries_no_line_counts() {
    let f = fixture();
    let old = f.put(&[(gix_object::Kind::Blob, b"\x00\x01binary\n".to_vec())])[0];
    let new = f.put(&[(gix_object::Kind::Blob, b"\x00\x02binary\n".to_vec())])[0];
    let old_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "blob.bin", old)]),
    )])[0];
    let new_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "blob.bin", new)]),
    )])[0];
    let old_commit = f.put(&[(gix_object::Kind::Commit, commit_bytes(old_tree, &[], 1))])[0];
    let new_commit = f.put(&[(
        gix_object::Kind::Commit,
        commit_bytes(new_tree, &[old_commit], 2),
    )])[0];
    f.set_ref("refs/heads/old", old_commit);
    f.set_ref("refs/heads/new", new_commit);

    let changes = f.diff("refs/heads/old", "refs/heads/new", &[]);
    assert_eq!(changes.len(), 1);
    assert!(changes[0].binary, "a file with NUL bytes must be binary");
    assert_eq!(
        (changes[0].insertions, changes[0].deletions),
        (0, 0),
        "a binary file must carry no line counts"
    );
}

/// A mode-only change is a change, and it moves no lines.
///
/// The `0/0` here is a measurement and not a gap: the bytes really are
/// identical, and the file really did change.
#[test]
fn making_a_file_executable_is_a_change_with_no_lines_in_it() {
    let f = fixture();
    let blob = f.put(&[(gix_object::Kind::Blob, b"#!/bin/sh\necho hi\n".to_vec())])[0];
    let old_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "run.sh", blob)]),
    )])[0];
    let new_tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100755, "run.sh", blob)]),
    )])[0];
    let old_commit = f.put(&[(gix_object::Kind::Commit, commit_bytes(old_tree, &[], 1))])[0];
    let new_commit = f.put(&[(
        gix_object::Kind::Commit,
        commit_bytes(new_tree, &[old_commit], 2),
    )])[0];
    f.set_ref("refs/heads/old", old_commit);
    f.set_ref("refs/heads/new", new_commit);

    let changes = f.diff("refs/heads/old", "refs/heads/new", &[]);
    assert_eq!(changes.len(), 1, "a mode change must be reported");
    assert_eq!(changes[0].old_mode, 0o100644);
    assert_eq!(changes[0].new_mode, 0o100755);
    assert_eq!(
        changes[0].old_oid, changes[0].new_oid,
        "the object did not change and both ids must say so"
    );
    assert_eq!((changes[0].insertions, changes[0].deletions), (0, 0));
}

/// The refusals: a repository that is not there, a ref that is not there, and a
/// tree-ish that is not tree-ish.
#[test]
fn the_diff_refusals_name_what_was_wrong() {
    let f = fixture();
    let blob = f.put(&[(gix_object::Kind::Blob, b"x\n".to_vec())])[0];
    let tree = f.put(&[(
        gix_object::Kind::Tree,
        tree_bytes(&[(0o100644, "a.txt", blob)]),
    )])[0];
    let commit = f.put(&[(gix_object::Kind::Commit, commit_bytes(tree, &[], 1))])[0];
    f.set_ref("refs/heads/main", commit);

    // A repository that does not exist is NotFound before anything about the
    // arguments is considered, so a typo in the path is not reported as a bad
    // ref name.
    let missing = f
        .plane
        .diff(
            &RepoPath::new("ns", "nope"),
            "refs/heads/main",
            "refs/heads/main",
            &[],
        )
        .expect_err("a repository that is not there cannot be diffed");
    assert!(
        matches!(missing, gunnar_grpc::ControlError::NotFound(_)),
        "got {missing:?}"
    );

    let no_ref = f
        .plane
        .diff(&f.path, "refs/heads/absent", "refs/heads/main", &[])
        .expect_err("a ref that is not there cannot be diffed");
    assert!(
        matches!(no_ref, gunnar_grpc::ControlError::NotFound(_)),
        "got {no_ref:?}"
    );

    // A blob is a perfectly good object and not a side of a tree diff.
    let not_treeish = f
        .plane
        .diff(&f.path, &blob.to_string(), "refs/heads/main", &[])
        .expect_err("a blob is not a tree-ish");
    assert!(
        matches!(not_treeish, gunnar_grpc::ControlError::InvalidArgument(_)),
        "got {not_treeish:?}"
    );
}