crates/gunnar-server/src/merge_engine/tests.rs
//! The merge engine against a repository the test wrote object by object
//! ([`crate::testgit`]), so every oid asserted is one the test computed.
use super::*;
use crate::testgit::{Repo, TREE_MODE_BLOB, TREE_MODE_TREE};
fn text(base: &str, ours: &str, theirs: &str) -> std::result::Result<String, TextConflict> {
merge_text(base.as_bytes(), ours.as_bytes(), theirs.as_bytes())
.map(|b| String::from_utf8(b).expect("utf-8"))
}
#[test]
fn changes_to_different_lines_merge() {
let base = "a\nb\nc\nd\ne\n";
let ours = "A\nb\nc\nd\ne\n";
let theirs = "a\nb\nc\nd\nE\n";
assert_eq!(text(base, ours, theirs).unwrap(), "A\nb\nc\nd\nE\n");
// And in the other order, the same answer.
assert_eq!(text(base, theirs, ours).unwrap(), "A\nb\nc\nd\nE\n");
}
#[test]
fn insertions_and_deletions_on_both_sides_merge() {
let base = "one\ntwo\nthree\nfour\nfive\nsix\n";
let ours = "zero\none\ntwo\nthree\nfour\nfive\nsix\n";
let theirs = "one\ntwo\nthree\nfour\nsix\nseven\n";
assert_eq!(
text(base, ours, theirs).unwrap(),
"zero\none\ntwo\nthree\nfour\nsix\nseven\n"
);
}
#[test]
fn the_same_change_on_both_sides_is_no_conflict() {
let base = "a\nb\nc\n";
let both = "a\nB\nc\n";
assert_eq!(text(base, both, both).unwrap(), both);
}
#[test]
fn both_sides_changing_the_same_line_is_a_conflict() {
assert_eq!(
text("a\nb\nc\n", "a\nours\nc\n", "a\ntheirs\nc\n"),
Err(TextConflict::Overlap)
);
}
#[test]
fn changes_that_touch_are_a_conflict() {
// ours rewrites line 2, theirs line 3: adjacent, and git calls that a conflict too.
assert_eq!(
text("a\nb\nc\nd\n", "a\nB\nc\nd\n", "a\nb\nC\nd\n"),
Err(TextConflict::Overlap)
);
// Two different insertions at the same place.
assert_eq!(
text("a\nb\n", "a\nx\nb\n", "a\ny\nb\n"),
Err(TextConflict::Overlap)
);
}
#[test]
fn a_missing_trailing_newline_survives_the_merge() {
assert_eq!(text("a\nb\nc", "A\nb\nc", "a\nb\nC").unwrap(), "A\nb\nC");
assert_eq!(
text("a\nb\nc\nd", "A\nb\nc\nd", "a\nb\nc\nD").unwrap(),
"A\nb\nc\nD"
);
}
#[test]
fn a_binary_file_changed_on_both_sides_is_a_conflict() {
assert_eq!(
merge_text(b"a\0b", b"a\0c", b"a\0d"),
Err(TextConflict::Binary)
);
}
/// main: base -> m1 ; topic: base -> t1. Answers (repo, base, m1, t1, trees).
struct Fork {
repo: Repo,
base: ObjectId,
main: ObjectId,
topic: ObjectId,
}
fn file_tree(repo: &Repo, files: &[(&str, &str)]) -> ObjectId {
let entries: Vec<(&str, &str, ObjectId)> = files
.iter()
.map(|(name, body)| (TREE_MODE_BLOB, *name, repo.blob(body.as_bytes())))
.collect();
repo.tree(&entries)
}
fn fork(base: &[(&str, &str)], main: &[(&str, &str)], topic: &[(&str, &str)]) -> Fork {
let repo = Repo::new();
let bt = file_tree(&repo, base);
let b = repo.commit(bt, &[], "base", "base");
let mt = file_tree(&repo, main);
let m = repo.commit(mt, &[b], "main", "main moved on");
let tt = file_tree(&repo, topic);
let t = repo.commit(tt, &[b], "topic", "the proposal");
Fork {
repo,
base: b,
main: m,
topic: t,
}
}
fn merge(f: &Fork) -> (TreeMerge, Merger<'_>) {
let git = f.repo.serve();
let hash = f.repo.hash_kind();
let base = merge_base(git, hash, &f.main, &f.topic, 1000).expect("one merge base");
assert_eq!(base, f.base);
let bt = commit_tree(git, hash, &base).unwrap();
let mt = commit_tree(git, hash, &f.main).unwrap();
let tt = commit_tree(git, hash, &f.topic).unwrap();
let mut merger = Merger::new(git, hash, false);
let out = merger
.merge_trees(Some(&bt), &mt, &tt)
.expect("the merge ran");
(out, merger)
}
fn read_file(f: &Fork, merger: &Merger<'_>, tree: &ObjectId, name: &str) -> Option<String> {
let overlay = Overlay::new(f.repo.serve(), &merger.new);
let (_, bytes) = overlay.read(tree.as_bytes()).unwrap()?;
let entry = object::tree_entry_named(&bytes, f.repo.hash_kind(), name.as_bytes()).unwrap()?;
let (_, blob) = overlay.read(entry.oid.as_bytes()).unwrap()?;
Some(String::from_utf8(blob).unwrap())
}
#[test]
fn different_files_changed_merge_into_the_tree_git_would_write() {
let f = fork(
&[("a.txt", "a\n"), ("b.txt", "b\n")],
&[("a.txt", "a main\n"), ("b.txt", "b\n")],
&[("a.txt", "a\n"), ("b.txt", "b topic\n"), ("c.txt", "new\n")],
);
let (out, merger) = merge(&f);
let TreeMerge::Clean(tree) = out else {
panic!("a clean merge conflicted: {out:?}")
};
// The tree the test computes itself, with the store's own hashing.
let expected = file_tree(
&f.repo,
&[
("a.txt", "a main\n"),
("b.txt", "b topic\n"),
("c.txt", "new\n"),
],
);
assert_eq!(tree, expected, "the merged tree is not the canonical one");
assert_eq!(merger.new.len(), 1, "only the root tree is new");
}
#[test]
fn one_file_changed_on_both_sides_in_different_places_merges_by_line() {
let f = fork(
&[("a.txt", "1\n2\n3\n4\n5\n")],
&[("a.txt", "one\n2\n3\n4\n5\n")],
&[("a.txt", "1\n2\n3\n4\nfive\n")],
);
let (out, merger) = merge(&f);
let TreeMerge::Clean(tree) = out else {
panic!("conflicted: {out:?}")
};
assert_eq!(
read_file(&f, &merger, &tree, "a.txt").as_deref(),
Some("one\n2\n3\n4\nfive\n")
);
}
#[test]
fn every_conflicting_path_is_named() {
let f = fork(
&[("a.txt", "a\n"), ("gone.txt", "g\n"), ("same.txt", "s\n")],
&[
("a.txt", "main\n"),
("gone.txt", "edited\n"),
("same.txt", "s\n"),
("new.txt", "m\n"),
],
&[
("a.txt", "topic\n"),
("same.txt", "s topic\n"),
("new.txt", "t\n"),
],
);
let (out, _) = merge(&f);
let TreeMerge::Conflicts(conflicts) = out else {
panic!("a conflicting merge came out clean")
};
let named: Vec<(&str, &str)> = conflicts.iter().map(|c| (c.path.as_str(), c.why)).collect();
assert_eq!(
named,
vec![
("a.txt", "both sides changed the same lines"),
("gone.txt", "deleted on one side, changed on the other"),
("new.txt", "added on both sides with different content"),
]
);
}
#[test]
fn a_subdirectory_merges_and_its_conflicts_carry_the_full_path() {
let repo = Repo::new();
let sub = |files: &[(&str, &str)]| {
let inner = file_tree(&repo, files);
repo.tree(&[
(TREE_MODE_TREE, "src", inner),
(TREE_MODE_BLOB, "top", repo.blob(b"t\n")),
])
};
let bt = sub(&[("x.rs", "x\n"), ("y.rs", "y\n")]);
let b = repo.commit(bt, &[], "base", "base");
let mt = sub(&[("x.rs", "x main\n"), ("y.rs", "y\n")]);
let m = repo.commit(mt, &[b], "main", "main");
let tt = sub(&[("x.rs", "x topic\n"), ("y.rs", "y topic\n")]);
let t = repo.commit(tt, &[b], "topic", "topic");
let mut merger = Merger::new(repo.serve(), repo.hash_kind(), false);
let out = merger.merge_trees(Some(&bt), &mt, &tt).unwrap();
let TreeMerge::Conflicts(conflicts) = out else {
panic!("clean")
};
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].path, "src/x.rs");
let _ = (m, t);
}
#[test]
fn an_encrypted_repository_never_merges_content() {
let f = fork(
&[("a.txt", "1\n2\n3\n4\n5\n")],
&[("a.txt", "one\n2\n3\n4\n5\n")],
&[("a.txt", "1\n2\n3\n4\nfive\n")],
);
let git = f.repo.serve();
let hash = f.repo.hash_kind();
let mut merger = Merger::new(git, hash, true);
let out = merger
.merge_trees(
Some(&commit_tree(git, hash, &f.base).unwrap()),
&commit_tree(git, hash, &f.main).unwrap(),
&commit_tree(git, hash, &f.topic).unwrap(),
)
.unwrap();
assert!(
matches!(&out, TreeMerge::Conflicts(c) if c[0].path == "a.txt"),
"a line merge of ciphertext: {out:?}"
);
}
#[test]
fn a_tree_is_written_in_gits_order() {
// git sorts a subtree as its name plus '/': "a.txt" (0x2e) before "a/" (0x2f),
// and "a-b" (0x2d) before both.
let repo = Repo::new();
let mut merger = Merger::new(repo.serve(), repo.hash_kind(), false);
let blob = repo.blob(b"x\n");
let inner = repo.tree(&[(TREE_MODE_BLOB, "f", blob)]);
let mut entries = BTreeMap::new();
entries.insert(
b"a".to_vec(),
Ent {
mode: IFDIR,
oid: inner,
},
);
entries.insert(
b"a.txt".to_vec(),
Ent {
mode: 0o100644,
oid: blob,
},
);
entries.insert(
b"a-b".to_vec(),
Ent {
mode: 0o100755,
oid: blob,
},
);
let id = merger.write_tree(&entries).unwrap();
let (_, bytes) = merger.new.get(id.as_bytes()).unwrap().clone();
let names: Vec<Vec<u8>> = TreeIter::from_bytes(&bytes, repo.hash_kind())
.named()
.map(|e| e.unwrap().name.to_vec())
.collect();
assert_eq!(
names,
vec![b"a-b".to_vec(), b"a.txt".to_vec(), b"a".to_vec()]
);
assert!(bytes.starts_with(b"100755 a-b\0"));
assert!(
bytes.windows(8).any(|w| w == b"40000 a\0"),
"a subtree's mode is written as git writes it, 40000"
);
}
#[test]
fn a_criss_cross_history_is_refused_by_name() {
let repo = Repo::new();
let t0 = file_tree(&repo, &[("a", "0\n")]);
let root = repo.commit(t0, &[], "r", "root");
let a1 = repo.commit(file_tree(&repo, &[("a", "a1\n")]), &[root], "a", "a1");
let b1 = repo.commit(file_tree(&repo, &[("a", "b1\n")]), &[root], "b", "b1");
// Each side merges the other's first commit: two best common ancestors.
let a2 = repo.commit(file_tree(&repo, &[("a", "a2\n")]), &[a1, b1], "a", "a2");
let b2 = repo.commit(file_tree(&repo, &[("a", "b2\n")]), &[b1, a1], "b", "b2");
let why = merge_base(repo.serve(), repo.hash_kind(), &a2, &b2, 1000).unwrap_err();
assert!(why.0.contains("criss-cross"), "{why}");
}
#[test]
fn unrelated_histories_have_no_merge_base() {
let repo = Repo::new();
let a = repo.commit(file_tree(&repo, &[("a", "a\n")]), &[], "a", "a");
let b = repo.commit(file_tree(&repo, &[("b", "b\n")]), &[], "b", "b");
let why = merge_base(repo.serve(), repo.hash_kind(), &a, &b, 1000).unwrap_err();
assert!(why.0.contains("share no history"), "{why}");
}
#[test]
fn the_merge_base_of_a_branch_and_its_ancestor_is_the_ancestor() {
let f = fork(&[("a", "a\n")], &[("a", "m\n")], &[("a", "t\n")]);
let after = f.repo.commit(
file_tree(&f.repo, &[("a", "m2\n")]),
&[f.main],
"main",
"again",
);
assert_eq!(
merge_base(f.repo.serve(), f.repo.hash_kind(), &after, &f.main, 1000).unwrap(),
f.main
);
assert_eq!(
merge_base(f.repo.serve(), f.repo.hash_kind(), &after, &f.topic, 1000).unwrap(),
f.base
);
}
#[test]
fn the_overlay_reads_the_new_objects_and_writes_nothing() {
let repo = Repo::new();
let mut new = NewObjects::default();
let id = new
.add(repo.hash_kind(), ObjType::Blob, b"fresh\n".to_vec())
.unwrap();
assert!(!repo.serve().has(id.as_bytes()).unwrap());
let overlay = Overlay::new(repo.serve(), &new);
assert_eq!(
overlay.read(id.as_bytes()).unwrap(),
Some((ObjType::Blob, b"fresh\n".to_vec()))
);
assert!(overlay.has(id.as_bytes()).unwrap());
assert!(overlay.put_pack(b"PACK").is_err());
assert!(overlay
.update_ref("refs/heads/main", None, Some(id.as_bytes()))
.is_err());
// And the pack the writer makes is one the store takes, with that id in it.
let pack = new
.pack(repo.hash_kind(), repo.serve())
.unwrap()
.expect("one new object");
repo.serve().put_pack(&pack).unwrap();
assert!(repo.serve().has(id.as_bytes()).unwrap());
assert!(
new.pack(repo.hash_kind(), repo.serve()).unwrap().is_none(),
"an object the store holds is not packed again"
);
}
#[test]
fn a_commit_is_written_with_both_parents_and_both_identities() {
let repo = Repo::new();
let tree = file_tree(&repo, &[("a", "a\n")]);
let p1 = repo.commit(tree, &[], "x", "one");
let p2 = repo.commit(tree, &[], "y", "two");
let bytes = commit_bytes(
&tree,
&[p1, p2],
&Ident {
name: "Ann <evil>".into(),
email: "ann@gunnar.invalid".into(),
},
&Ident {
name: "Gunnar".into(),
email: "noreply@gunnar.invalid".into(),
},
1_700_000_000,
"Merge\n\nApproved-by: Bob <bob@gunnar.invalid>\n\n",
);
let commit = object::commit(&bytes, repo.hash_kind()).unwrap();
assert_eq!(commit.parents, vec![p1, p2]);
assert_eq!(commit.author.name, b"Ann evil");
assert_eq!(commit.committer.email, b"noreply@gunnar.invalid");
assert!(commit
.message
.ends_with(b"Approved-by: Bob <bob@gunnar.invalid>\n"));
}
/// ★ **GUN-MERGE-SIGN: a commit gunnar writes carries its own SSH signature**, in the
/// header git reads (`gpgsig`; `gpgsig-sha256` in a SHA-256 repository), over exactly the
/// commit's other bytes: the signed-commits check verifies it with the server's key, and
/// with no other key.
#[test]
fn a_signed_merge_commit_verifies_with_the_servers_key_and_no_other() {
let key = gunnar_core::ssh_key::PrivateKey::from(
gunnar_core::ssh_key::private::Ed25519Keypair::from_seed(&[42u8; 32]),
);
let other = gunnar_core::ssh_key::PrivateKey::from(
gunnar_core::ssh_key::private::Ed25519Keypair::from_seed(&[43u8; 32]),
);
let gunnar = Ident {
name: "Gunnar".into(),
email: "noreply@gunnar.invalid".into(),
};
for (hash, header) in [
(HashKind::Sha1, "gpgsig"),
(HashKind::Sha256, "gpgsig-sha256"),
] {
let tree = ObjectId::empty_tree(hash);
let unsigned = commit_bytes(
&tree,
&[],
&gunnar,
&gunnar,
1_700_000_000,
"Merge\n\nHead: x\n",
);
let signed = sign_commit(&unsigned, hash, &key).expect("signed");
let text = String::from_utf8(signed.clone()).expect("utf-8");
assert!(
text.contains(&format!("\n{header} -----BEGIN SSH SIGNATURE-----\n")),
"{text}"
);
assert!(
text.ends_with("\n\nMerge\n\nHead: x\n"),
"the message is untouched: {text}"
);
let id = ObjectId::null(hash);
let check = gunnar_policy::signature::check_commit_bytes(
id,
&signed,
&[key.public_key().clone()],
gunnar_policy::signature::GIT_NAMESPACE,
);
assert!(check.is_verified(), "{hash:?}: {check:?}");
let wrong = gunnar_policy::signature::check_commit_bytes(
id,
&signed,
&[other.public_key().clone()],
gunnar_policy::signature::GIT_NAMESPACE,
);
assert!(!wrong.is_verified(), "another key does not verify it");
}
}