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/real_git_mirror.rs

//! `gunnar mirror`, proved against real git repositories.
//!
//! The upstream is a repository stock `git` created and stock `git` mutates
//! between syncs; the transport is a real `git upload-pack`; the result is read
//! back with stock git. What is exercised end to end is the verb that
//! ranks first in the project: `add`, `sync`, `status`,
//! and **convergence** (the part that makes it a mirror rather than a fetch),
//! including deletions.
//!
//! `ls-remote` needs an SSH connection, so the remote ref list is supplied
//! directly here, read out of the upstream with `git for-each-ref`. That is the
//! one substitution, and it is the same data `nornir_git::ssh::ls_remote` returns.
//! The diffing, fetching and deleting under test are the real code paths.

mod common;

use common::*;
use gix::hash::{Kind, ObjectId};
use gunnar_client::{AdvertisedRef, MirrorRegistry};

/// The upstream's refs, in the shape `ls-remote` produces.
fn advertised(repo: &std::path::Path) -> Vec<AdvertisedRef> {
    git(repo, &["for-each-ref", "--format=%(objectname) %(refname)"])
        .lines()
        .filter(|l| !l.is_empty())
        .map(|l| {
            let (oid, name) = l.split_once(' ').unwrap();
            AdvertisedRef::new(name, ObjectId::from_hex(oid.as_bytes()).unwrap())
        })
        .collect()
}

fn sync(
    reg: &mut MirrorRegistry,
    name: &str,
    upstream: &std::path::Path,
) -> gunnar_client::SyncReport {
    let refs = advertised(upstream);
    let (o, i, mut child) = GitChild::upload_pack(upstream).into_halves();
    let path = upstream.to_string_lossy().to_string();
    let report = reg.sync_with_io(name, o, i, &refs, &path).unwrap();
    let _ = child.wait();
    report
}

#[test]
fn mirror_add_sync_status_against_a_real_repo() {
    for kind in BOTH_HASH_KINDS {
        let dir = tempfile::tempdir().unwrap();
        let upstream = dir.path().join("upstream");
        init_worktree(&upstream, kind);
        commit_file(&upstream, "a.txt", "one\n", "first");
        commit_file(&upstream, "b.txt", "two\n", "second");
        git(&upstream, &["branch", "topic"]);
        git(&upstream, &["tag", "-a", "v1", "-m", "release"]);

        let root = dir.path().join("mirrors");
        let mut reg = MirrorRegistry::open(&root).unwrap();
        reg.add("nordisk/thing", &upstream.to_string_lossy())
            .unwrap();

        let report = sync(&mut reg, "nordisk/thing", &upstream);
        assert!(!report.unchanged);
        assert_eq!(report.created.len(), 3, "main, topic, v1: {report:?}");
        assert!(report.deleted.is_empty());
        assert!(report.objects_received > 0);

        // Read the mirror back with stock git.
        let local = root.join("nordisk").join("thing.git");
        let mut want = refs_via_git(&upstream);
        let mut got = refs_via_git(&local);
        want.sort();
        got.sort();
        assert_eq!(
            got, want,
            "{kind:?}: the mirror is not a copy of the upstream"
        );
        assert_eq!(git(&local, &["cat-file", "-t", "refs/tags/v1"]), "tag");
        assert!(git_try(&local, &["fsck", "--strict", "--no-progress"]).is_ok());

        let status = reg.status();
        assert_eq!(status.len(), 1);
        assert!(status[0].present, "{kind:?}");
        assert_eq!(status[0].refs, 3);
        assert!(status[0].mirror.last_sync_unix.is_some());
    }
}

/// **The arm that separates a mirror from a fetch.** `gix`'s fetch never deletes
/// a ref, so if `plan_ref_sync`'s deletion arm and its application were dead,
/// every "did the new commits arrive" assertion above would still pass and the
/// mirror would quietly accumulate branches forever.
#[test]
fn a_branch_deleted_upstream_is_deleted_in_the_mirror() {
    let dir = tempfile::tempdir().unwrap();
    let upstream = dir.path().join("upstream");
    init_worktree(&upstream, Kind::Sha1);
    commit_file(&upstream, "a.txt", "one\n", "first");
    git(&upstream, &["branch", "doomed"]);
    git(&upstream, &["branch", "keeper"]);

    let root = dir.path().join("mirrors");
    let mut reg = MirrorRegistry::open(&root).unwrap();
    reg.add("ns/repo", &upstream.to_string_lossy()).unwrap();

    let first = sync(&mut reg, "ns/repo", &upstream);
    assert_eq!(first.created.len(), 3);
    let local = root.join("ns").join("repo.git");
    assert!(refs_via_git(&local)
        .iter()
        .any(|r| r.ends_with("refs/heads/doomed")));

    // Stock git removes the branch upstream.
    git(&upstream, &["branch", "-D", "doomed"]);

    let second = sync(&mut reg, "ns/repo", &upstream);
    assert_eq!(
        second.deleted,
        vec!["refs/heads/doomed".to_string()],
        "the mirror did not converge: {second:?}"
    );
    let refs = refs_via_git(&local);
    assert!(
        !refs.iter().any(|r| r.ends_with("refs/heads/doomed")),
        "the deleted branch is still in the mirror: {refs:?}"
    );
    assert!(
        refs.iter().any(|r| r.ends_with("refs/heads/keeper")),
        "convergence deleted more than it should have: {refs:?}"
    );
    assert_eq!(refs_via_git(&local), refs_via_git(&upstream));
}

/// A force-push upstream must be reproduced, not refused. A mirror's job is to
/// be a copy, including of a history rewrite.
#[test]
fn a_force_pushed_branch_upstream_is_reproduced() {
    let dir = tempfile::tempdir().unwrap();
    let upstream = dir.path().join("upstream");
    init_worktree(&upstream, Kind::Sha1);
    commit_file(&upstream, "a.txt", "one\n", "first");
    commit_file(&upstream, "b.txt", "two\n", "second");

    let root = dir.path().join("mirrors");
    let mut reg = MirrorRegistry::open(&root).unwrap();
    reg.add("ns/repo", &upstream.to_string_lossy()).unwrap();
    sync(&mut reg, "ns/repo", &upstream);

    git(&upstream, &["reset", "-q", "--hard", "HEAD~1"]);
    let rewritten = commit_file(&upstream, "c.txt", "three\n", "rewritten");

    let report = sync(&mut reg, "ns/repo", &upstream);
    assert_eq!(
        report.updated,
        vec!["refs/heads/main".to_string()],
        "{report:?}"
    );
    let local = root.join("ns").join("repo.git");
    assert_eq!(git(&local, &["rev-parse", "refs/heads/main"]), rewritten);
}

/// An upstream that has not moved must cost nothing: no fetch, no ref churn.
#[test]
fn an_unchanged_upstream_syncs_to_nothing() {
    let dir = tempfile::tempdir().unwrap();
    let upstream = dir.path().join("upstream");
    init_worktree(&upstream, Kind::Sha1);
    commit_file(&upstream, "a.txt", "one\n", "first");

    let root = dir.path().join("mirrors");
    let mut reg = MirrorRegistry::open(&root).unwrap();
    reg.add("ns/repo", &upstream.to_string_lossy()).unwrap();
    sync(&mut reg, "ns/repo", &upstream);

    let again = sync(&mut reg, "ns/repo", &upstream);
    assert_eq!(again.changed_refs(), 0, "{again:?}");
    assert_eq!(again.objects_received, 0);
}

/// The registry survives a process boundary, which is the only thing that makes
/// `mirror status` mean anything on a later run.
#[test]
fn the_registry_is_readable_by_a_fresh_process() {
    let dir = tempfile::tempdir().unwrap();
    let upstream = dir.path().join("upstream");
    init_worktree(&upstream, Kind::Sha1);
    commit_file(&upstream, "a.txt", "one\n", "first");

    let root = dir.path().join("mirrors");
    {
        let mut reg = MirrorRegistry::open(&root).unwrap();
        reg.add("ns/repo", &upstream.to_string_lossy()).unwrap();
        sync(&mut reg, "ns/repo", &upstream);
    }
    let reopened = MirrorRegistry::open(&root).unwrap();
    let status = reopened.status();
    assert_eq!(status.len(), 1);
    assert!(status[0].present);
    assert_eq!(status[0].refs, 1);
    assert!(status[0].mirror.last_sync_unix.is_some());
    assert_eq!(status[0].path, root.join("ns").join("repo.git"));
}

/// An upstream that has **no refs yet** must not get to pick the mirror's hash
/// algorithm, because that pick can never be undone.
///
/// `infer_hash_kind` has to answer something for an empty advertisement and
/// answers SHA-1. `sync` used to create the bare repository from that answer
/// *before* deciding there was nothing to do, so registering a brand-new,
/// still-empty SHA-256 upstream — a gunnar server creates SHA-256 repositories
/// by default — left a SHA-1 directory beside it, and every later sync failed
/// for good with *"is a Sha1 repository but the remote is Sha256"*.
///
/// The second half of this test is the red the first half is built on: it
/// creates exactly the SHA-1 directory the old code would have created, and
/// asserts the wedge is real. If `open_or_init_bare` ever started converting
/// instead of refusing, that assertion fires and this guard is re-derived
/// rather than passing for a reason that no longer holds.
#[test]
fn an_empty_upstream_creates_no_mirror_and_so_cannot_pin_the_hash() {
    let dir = tempfile::tempdir().unwrap();
    let upstream = dir.path().join("upstream");
    init_bare(&upstream, Kind::Sha256);

    let root = dir.path().join("mirrors");
    let mut reg = MirrorRegistry::open(&root).unwrap();
    reg.add("ns/fresh", &upstream.to_string_lossy()).unwrap();

    // The upstream advertises nothing. No transport is reached, so a reader and
    // writer that would fail if used are exactly the right thing to hand in.
    let report = reg
        .sync_with_io(
            "ns/fresh",
            std::io::Cursor::new(Vec::new()),
            Vec::new(),
            &[],
            &upstream.to_string_lossy(),
        )
        .unwrap();
    assert!(report.unchanged, "{report:?}");
    let mirror_path = root.join("ns").join("fresh.git");
    assert!(
        !mirror_path.exists(),
        "an empty upstream created {} and pinned it to a guessed hash",
        mirror_path.display()
    );

    // Now the upstream gets SHA-256 content, and the mirror must come out
    // SHA-256.
    let work = dir.path().join("work");
    init_worktree(&work, Kind::Sha256);
    commit_file(&work, "a.txt", "one\n", "first");
    git(
        &work,
        &["push", &upstream.to_string_lossy(), "refs/heads/main"],
    );
    let report = sync(&mut reg, "ns/fresh", &upstream);
    assert!(!report.unchanged, "{report:?}");
    assert_eq!(
        git(&mirror_path, &["rev-parse", "--show-object-format"]),
        "sha256",
        "the mirror did not take the upstream's object format"
    );
    assert_eq!(
        git(&mirror_path, &["rev-parse", "refs/heads/main"]),
        git(&work, &["rev-parse", "refs/heads/main"])
    );

    // ── the red: the wedge the guard above prevents is a real wedge ──────
    let wedged = root.join("ns").join("wedged.git");
    init_bare(&wedged, Kind::Sha1);
    let err = gunnar_client::fetch::open_or_init_bare(&wedged, Kind::Sha256)
        .expect_err("a sha1 mirror must not silently serve a sha256 upstream");
    // The chain, not the bare `Display`: a variant's own `Display` is its layer
    // label ("repository") and the two hash names live in its `#[source]`.
    // `{err:#}` is what `gunnar` prints and therefore what a user reads.
    let shown = gunnar_client::chain(&err);
    assert!(
        shown.contains("Sha1") && shown.contains("Sha256"),
        "the wedge no longer looks like a wedge: {shown}"
    );
}