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

//! `gunnar ls-remote`'s bytes, diffed against `git ls-remote`'s.
//!
//! Scripts parse this output, so the contract is the bytes and not the meaning:
//! one `\t`, `HEAD` first, the remote's order, and an annotated tag followed
//! immediately by its peeled `^{}` line. MEASURED against git 2.53.0.
//!
//! ```text
//! 10c9553660635b30d79ef3781a991190f5efac43\tHEAD
//! 10c9553660635b30d79ef3781a991190f5efac43\trefs/heads/main
//! d320a6055e5756a7f77dc23b481400669ff47235\trefs/tags/v1
//! 10c9553660635b30d79ef3781a991190f5efac43\trefs/tags/v1^{}
//! ```
//!
//! Hermetic: `git upload-pack` over pipes, temporary directories, no network.
//! `ls_remote_with_io` is the seam that makes that possible — the same reason
//! `fetch_with_io` exists.

mod common;

use common::*;
use gix::hash::Kind;
use gunnar_client::fetch::ls_remote_with_io;
use gunnar_client::ls_remote_output;

/// What `git ls-remote` printed, verbatim.
fn git_ls_remote(cwd: &std::path::Path, url: &str) -> String {
    let out = git(cwd, &["ls-remote", url]);
    // `git()` trims the trailing newline; put it back, because the trailing
    // newline is part of the shape being compared.
    if out.is_empty() {
        out
    } else {
        format!("{out}\n")
    }
}

/// What `gunnar ls-remote` prints, over a real `git upload-pack`.
fn gunnar_ls_remote(src: &std::path::Path) -> String {
    let url = src.to_string_lossy().to_string();
    let (stdout, stdin, mut child) = GitChild::upload_pack(src).into_halves();
    let refs = ls_remote_with_io(stdout, stdin, &url).expect("gunnar ls-remote");
    let _ = child.kill();
    let _ = child.wait();
    ls_remote_output(&refs)
}

/// The whole advertisement, byte for byte, on both hash kinds.
///
/// # What this catches that a ref-count does not
///
/// The repository below holds a branch, a second branch, an **annotated** tag and
/// a **lightweight** tag, and the two tags are the point:
///
/// * the annotated tag must print its **tag object** on `refs/tags/ann`, and the
///   commit under it on a second line named `refs/tags/ann^{}`. Printing the
///   commit under the tag's own name is a wrong answer, and is exactly the bug
///   fixed on 2026-08-05 (`peeled.or(target)` → `target.or(peeled)`).
/// * the lightweight tag must print **one** line. A renderer that emitted a `^{}`
///   line for everything would satisfy the annotated case and break this one.
///
/// It is also the identity-value guard: on a lightweight tag the tag object and
/// the peeled commit are the same oid, so a test with only lightweight tags
/// cannot tell the two apart at all.
#[test]
fn gunnar_ls_remote_is_byte_identical_to_git_ls_remote() {
    for kind in BOTH_HASH_KINDS {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        init_worktree(&src, kind);
        commit_file(&src, "a.txt", "one\n", "first");
        git(&src, &["tag", "-a", "ann", "-m", "an annotated tag"]);
        git(&src, &["tag", "lw"]);
        commit_file(&src, "b.txt", "two\n", "second");
        git(&src, &["branch", "topic"]);

        let url = src.to_string_lossy().to_string();
        let theirs = git_ls_remote(dir.path(), &url);
        let ours = gunnar_ls_remote(&src);

        // Anti-vacuity first: two empty strings are byte-identical and prove
        // nothing. The fixture has HEAD, two branches and two tags, one of them
        // annotated, so git must print six lines.
        assert_eq!(
            theirs.lines().count(),
            6,
            "{kind:?}: git printed {} lines, not the six this fixture holds — the \
             comparison below would be measuring the wrong thing:\n{theirs}",
            theirs.lines().count()
        );
        assert!(
            theirs.contains("refs/tags/ann^{}"),
            "{kind:?}: git printed no peeled line, so the tag half of this test is \
             not being exercised:\n{theirs}"
        );

        assert_eq!(
            ours, theirs,
            "{kind:?}: gunnar ls-remote differs from git ls-remote.\n\
             --- gunnar ---\n{ours}--- git ---\n{theirs}"
        );
    }
}

/// The annotated tag's line names the **tag object**, and the `^{}` line names
/// the commit — asserted against `git rev-parse`, not against gunnar's other end.
///
/// The byte-diff above would also pass if git and gunnar were wrong the same way,
/// which they cannot be here — but this states the fact in its own terms so a
/// reader of the failure knows which object went missing.
#[test]
fn an_annotated_tag_prints_the_tag_object_and_its_peel_separately() {
    for kind in BOTH_HASH_KINDS {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        init_worktree(&src, kind);
        commit_file(&src, "a.txt", "one\n", "first");
        git(&src, &["tag", "-a", "ann", "-m", "an annotated tag"]);

        let tag_object = git(&src, &["rev-parse", "refs/tags/ann"]);
        let commit = git(&src, &["rev-parse", "refs/tags/ann^{commit}"]);
        // Without this the whole test is the identity-value trap: on a
        // lightweight tag these two are equal and every assertion below holds
        // whichever object was printed.
        assert_ne!(
            tag_object, commit,
            "{kind:?}: the fixture's tag is not annotated, so this proves nothing"
        );

        let out = gunnar_ls_remote(&src);
        assert!(
            out.contains(&format!("{tag_object}\trefs/tags/ann\n")),
            "{kind:?}: `refs/tags/ann` does not name the tag object {tag_object}:\n{out}"
        );
        assert!(
            out.contains(&format!("{commit}\trefs/tags/ann^{{}}\n")),
            "{kind:?}: no peeled line naming the commit {commit}:\n{out}"
        );
    }
}

/// A lightweight tag has nothing to peel and must print **one** line.
#[test]
fn a_lightweight_tag_prints_no_peeled_line() {
    let dir = tempfile::tempdir().unwrap();
    let src = dir.path().join("src");
    init_worktree(&src, Kind::Sha256);
    commit_file(&src, "a.txt", "one\n", "first");
    git(&src, &["tag", "lw"]);

    let out = gunnar_ls_remote(&src);
    assert!(
        out.contains("\trefs/tags/lw\n"),
        "the lightweight tag is missing entirely:\n{out}"
    );
    assert!(
        !out.contains("refs/tags/lw^{}"),
        "a lightweight tag was given a peeled line it does not have:\n{out}"
    );
    // And git agrees.
    assert_eq!(out, git_ls_remote(dir.path(), &src.to_string_lossy()));
}

/// A remote with no commits advertises nothing, and `ls-remote` says nothing —
/// exit 0, empty output, the way git does.
///
/// This is the shape a `gunnar ls-remote` is most likely to be pointed at first:
/// a repository just created on the server. An error here would read as
/// *unreachable* for a remote that is perfectly reachable.
#[test]
fn an_empty_remote_prints_nothing_and_does_not_fail() {
    for kind in BOTH_HASH_KINDS {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("empty.git");
        init_bare(&src, kind);

        let theirs = git_ls_remote(dir.path(), &src.to_string_lossy());
        assert_eq!(
            theirs, "",
            "{kind:?}: git printed something for an empty repo"
        );
        assert_eq!(
            gunnar_ls_remote(&src),
            theirs,
            "{kind:?}: gunnar disagreed with git about an empty remote"
        );
    }
}