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