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:?}"
);
}