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

nordisk/gunnar

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