crates/gunnar-client/tests/fetch_refspec_refusal.rs
//! A fetch refspec that would write nowhere is refused, the way git refuses it.
//!
//! `refs/heads/*` means *"resolve these, update no local reference"*. Stock git
//! will not parse it:
//!
//! ```text
//! fatal: invalid refspec 'refs/heads/*'
//! ```
//!
//! gix's parser accepted it up to 0.86, so gunnar dialled, negotiated, took the
//! pack, wrote nothing and exited 0. That string was `gunnar fetch`'s own default
//! until 2026-08-05 — the original defect — and after the default was fixed it was
//! still accepted from a `--refspec`, one flag away from the same silent nothing.
//!
//! gix 0.87 refuses all three itself, in its own words. What gunnar's guard
//! decides is therefore the **sentence** rather than the outcome; see
//! [`the_guard_that_refuses_a_pattern_with_no_destination_is_gunnars_own`],
//! which measures both halves instead of remembering either.
//!
//! **Stock git 2.53.0 is the judge here and its verdict is measured at test time**,
//! not copied into a table: [`gunnar_and_git_agree_on_fetch_refspec_validity`]
//! runs `git fetch <src> <spec>` for every spec and compares.
//!
//! Hermetic: pipes, temporary directories, no network.
mod common;
use std::io::Write;
use std::path::Path;
use std::sync::{Arc, Mutex};
use common::*;
use gix::hash::Kind;
use gunnar_client::fetch::{check_fetch_refspec, fetch_with_io, open_or_init_bare};
/// The three refspecs gix accepts and git refuses, all one shape: a **pattern
/// source with no pattern destination**.
///
/// MEASURED against git 2.53.0 and gix 0.86 on 2026-08-05 over thirty refspecs;
/// these are the only cells the two disagreed on, which is why they are the only
/// cells `check_fetch_refspec` has to decide. Everything else in
/// [`EVERY_SPEC`] is there to prove this guard did not become a second, stricter
/// opinion about refspecs in general.
const GIX_WOULD_HAVE_TAKEN_THESE: &[&str] = &["refs/heads/*", "+refs/heads/*", "refs/heads/*:"];
/// Every refspec whose verdict is compared against stock git's.
const EVERY_SPEC: &[&str] = &[
// The defect and its two spellings.
"refs/heads/*",
"+refs/heads/*",
"refs/heads/*:",
// A pattern facing a non-pattern, both ways round.
":refs/remotes/o/*",
"refs/heads/*:refs/remotes/o/x",
"refs/heads/master:refs/remotes/o/*",
// Legal, and each one a way to be wrong by over-refusing.
"+refs/heads/*:refs/remotes/o/*",
"refs/heads/master:refs/remotes/o/x",
"refs/heads/master",
"refs/heads/master:",
"master",
"HEAD",
":",
"",
"*:*",
"refs/heads/*:refs/*",
// Negative specs write nowhere by design and git takes them with a glob.
"^refs/heads/master",
"^refs/heads/*",
// Bad ref names and multi-star patterns: git refuses, and so does gix
// already. Here to prove this guard is narrow.
"refs/heads/ma ster",
"refs/heads/x..y",
"refs/heads/x~1",
"refs/heads/x:refs/heads/y z",
"refs/heads/master^{}",
"refs/heads/**:refs/remotes/o/*",
"refs/*/*:refs/remotes/o/*",
"refs/heads/*x*:refs/remotes/o/*",
"refs/heads/*:refs/remotes/o/*:extra",
"refs/heads/master:refs/remotes/o/x:",
"^refs/heads/*:",
"^",
];
/// Did stock git call this spec an invalid refspec?
///
/// Classified on git's own sentence, not on the exit code: `git fetch` also exits
/// 128 for *"couldn't find remote ref"* and *"refusing to fetch into branch"*,
/// neither of which is a parse verdict, and treating those as refusals would make
/// this comparison agree with anything.
fn git_refuses_to_parse(scratch: &Path, src: &Path, spec: &str) -> bool {
let url = src.to_string_lossy().to_string();
match git_try(scratch, &["fetch", &url, spec]) {
Ok(_) => false,
Err(stderr) => stderr.contains("invalid refspec"),
}
}
/// gunnar's verdict on a refspec — **both layers**, because both are on the path
/// a caller takes.
///
/// `fetch_with_io` calls `check_fetch_refspec` and then hands the same strings to
/// `Remote::with_refspecs`, which is where gix's own parser runs. Comparing only
/// the first layer against git would report thirteen false disagreements for
/// specs gix has always refused — bad ref names, two `*` on one side, a negative
/// spec with a destination. What a user experiences is the pair, so the pair is
/// what is compared, in the order and by the calls `fetch_with_io` makes.
///
/// Which layer refuses which cell is not left to inference either:
/// [`the_guard_that_refuses_a_pattern_with_no_destination_is_gunnars_own`] pins
/// the three that are gunnar's.
fn gunnar_refuses_to_parse(repo: &gix::Repository, url: &str, spec: &str) -> bool {
if check_fetch_refspec(spec).is_err() {
return true;
}
repo.remote_at(url)
.expect("build an in-memory remote")
.with_refspecs([spec], gix::remote::Direction::Fetch)
.is_err()
}
/// gunnar's verdict on every refspec must be stock git's verdict.
///
/// Seen RED before the fix on the three cells of [`GIX_WOULD_HAVE_TAKEN_THESE`]:
/// *"gunnar accepted \"refs/heads/*\", git says: fatal: invalid refspec
/// 'refs/heads/*'"*.
#[test]
fn gunnar_and_git_agree_on_fetch_refspec_validity() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, Kind::Sha1);
commit_file(&src, "a.txt", "one\n", "first");
git(&src, &["tag", "-a", "v1", "-m", "release"]);
git(&src, &["branch", "other"]);
// git needs a repository to fetch INTO, and a bare one so `*:*` does not
// fail on a checked-out branch instead of on its refspec.
let scratch = dir.path().join("git-judge.git");
init_bare(&scratch, Kind::Sha1);
// gunnar's side needs a repository too, for the `with_refspecs` half.
let ours = dir.path().join("ours.git");
let repo = open_or_init_bare(&ours, Kind::Sha1).unwrap();
let url = src.to_string_lossy().to_string();
let mut disagreements = Vec::new();
let mut refused_by_git = 0usize;
for spec in EVERY_SPEC {
let git_says_no = git_refuses_to_parse(&scratch, &src, spec);
if git_says_no {
refused_by_git += 1;
}
let gunnar_says_no = gunnar_refuses_to_parse(&repo, &url, spec);
if git_says_no != gunnar_says_no {
disagreements.push(format!(
" {spec:?}: git {} — gunnar {}",
if git_says_no {
"refuses to parse it"
} else {
"accepts it"
},
match check_fetch_refspec(spec) {
Err(e) if gunnar_says_no => format!("refused it: {e}"),
_ if gunnar_says_no => "refused it inside gix's parser".to_string(),
_ => "accepted it".to_string(),
}
));
}
}
// Anti-vacuity, both directions. A table where git refused everything, or
// nothing, would let the comparison above pass while measuring nothing.
assert!(
refused_by_git >= GIX_WOULD_HAVE_TAKEN_THESE.len(),
"stock git refused only {refused_by_git} of {} specs, so this table is not \
exercising the refusal path at all",
EVERY_SPEC.len()
);
assert!(
refused_by_git < EVERY_SPEC.len(),
"stock git refused every single spec, so agreement here proves nothing"
);
assert!(
disagreements.is_empty(),
"gunnar and git 2.53.0 disagree on {} of {} refspecs:\n{}",
disagreements.len(),
EVERY_SPEC.len(),
disagreements.join("\n")
);
}
/// The refusal must be **gunnar's own**, not gix's, on the three cells that
/// matter.
///
/// This is the trap this fix walks into otherwise: a test that only asked
/// "was it refused?" would already be green for `refs/heads/*:refs/remotes/o/x`
/// and `:refs/remotes/o/*` — gix refuses those, with a different sentence, and
/// would go on refusing them if `check_fetch_refspec` were deleted. So the
/// assertion is on the wording, which only this crate produces.
///
/// # gix moved underneath this test, and the test said so — 2026-08-30
///
/// It opened by asserting `gix::refspec::parse` **accepts** all three, and that
/// assertion carried its own instruction: *"If gix ever starts refusing these,
/// this line goes red and says so — which is the correct outcome, not a
/// nuisance."* The gix 0.87 port made it red, and it was telling the truth.
///
/// It is **not** a reason to delete [`check_fetch_refspec`], and the difference
/// between the two readings is measured here rather than argued:
///
/// * gunnar's guard runs **first** — `fetch_with_io` calls it before
/// `Remote::with_refspecs` — so what a person reads is `invalid refspec
/// 'refs/heads/*'`, which is stock git's own sentence, word for word, on all
/// three specs. [`gunnar_and_git_agree_on_fetch_refspec_validity`] measures
/// git's verdict at test time and it still agrees, so nothing a user drives
/// changed;
/// * gix's sentence for the same string is a **different** one, and it is what
/// the user would read instead. MEASURED here on gix 0.87.1, identical for
/// all three specs:
///
/// ```text
/// Both sides of a two-sided specification need a pattern, like 'a/*:b/*'
/// ```
///
/// Good English, and it never names the refspec it is about — where git, and
/// therefore gunnar, quote it. That is now the load-bearing property, and it
/// is what [`a_pattern_refspec_with_no_destination_costs_no_bytes_and_writes_no_pack`]
/// discriminates on end to end: neutering the guard was MEASURED to turn that
/// test's `invalid refspec 'refs/heads/*'` into `Error::Repo`, whose own
/// `Display` is the bare word `repository` with gix's sentence hanging off it
/// as a `source` (see `error.rs`'s `no_variant_display_repeats_its_own_source`
/// for why the layer is one word).
///
/// So the assertion moves from *"gix would have taken it"* to *"gunnar decides
/// the wording, and the wording is git's"*. **What this test can no longer
/// claim** is that gunnar's guard changes the OUTCOME of a fetch: on gix 0.87
/// all three would be refused without it, before a byte reached the wire. What
/// it changes is which sentence the user gets — and `check_fetch_refspec` is
/// still the stated authority for the whole rule (`fetch.rs`: *"stated in full,
/// this function ... does not silently become decorative if gix's half ever
/// changes"*), which is exactly the case that arrived.
#[test]
fn the_guard_that_refuses_a_pattern_with_no_destination_is_gunnars_own() {
// How many of the three gix now refuses on its own. Pinned below so that a
// gix upgrade moving it in either direction is a number somebody has to
// come here and change, rather than a silent shift in which layer speaks.
let mut refused_by_gix = 0usize;
for spec in GIX_WOULD_HAVE_TAKEN_THESE {
// gunnar refuses it, in git's words. This half has never moved.
let err = check_fetch_refspec(spec).expect_err("gunnar accepted a refspec git refuses");
assert_eq!(
err.to_string(),
format!("invalid refspec '{spec}'"),
"the refusal does not use git's wording"
);
// And what gix says about the identical string, MEASURED here rather
// than remembered in a table — which is the mistake that made this test
// go red a version late.
match gix::refspec::parse((*spec).into(), gix::refspec::parse::Operation::Fetch) {
// gix takes it, so gunnar's guard is the only thing between a user
// and a fetch that writes nowhere and exits 0. The original defect.
Ok(_) => {}
Err(gix_err) => {
refused_by_gix += 1;
assert_ne!(
gix_err.to_string(),
err.to_string(),
"gix's sentence for {spec:?} is now word for word gunnar's. Nothing \
downstream can then tell which layer refused, and \
`a_pattern_refspec_with_no_destination_costs_no_bytes_and_writes_no_pack` \
— which asserts on the text — would stay green with \
`check_fetch_refspec` neutered."
);
}
}
}
// MEASURED against gix 0.87 on 2026-08-30: all three. It was 0 against gix
// 0.86 on 2026-08-05, when this file was written.
//
// Moving it DOWN is the interesting direction and the reason this is an
// equality and not a `<=`: a spec gix stops refusing is one where gunnar's
// guard goes back to being the only thing standing between a user and a
// silent no-op fetch, and that is worth a reader's attention, not a green.
assert_eq!(
refused_by_gix,
GIX_WOULD_HAVE_TAKEN_THESE.len(),
"gix refuses {refused_by_gix} of the {} specs in GIX_WOULD_HAVE_TAKEN_THESE, not all \
of them. Update this number and the doc comment above it — and note that for any \
spec gix now ACCEPTS, `check_fetch_refspec` is again the only thing preventing the \
original defect (dial, negotiate, take the pack, write nothing, exit 0).",
GIX_WOULD_HAVE_TAKEN_THESE.len()
);
}
/// The refusal happens **before a byte reaches the wire**, on both hash kinds.
///
/// Asserted on applied output rather than on the error: bytes written to the
/// transport, packs on disk, refs read back by stock git, `FETCH_HEAD`.
///
/// # Why a real `git upload-pack` child and not a dead pipe
///
/// The first cut of this handed `fetch_with_io` an `io::empty()` reader. It went
/// green — and it was blind: with the guard removed the fetch still failed, on the
/// handshake, having written nothing, so *"0 bytes, 0 packs"* held for the wrong
/// reason. Against a real `git upload-pack` the un-guarded path **completes**. RED
/// PROBE 2026-08-05, guard neutered, `refs/heads/*`:
///
/// ```text
/// objects_received: 4, received_pack: true,
/// updated_refs: [refs/tags/v1]
/// ```
///
/// A pack on disk, `refs/heads/main` advertised and **not written** — the branch
/// the refspec named went nowhere, and the only ref that moved is the tag gix
/// follows on its own. That is the original defect exactly, and it can only be
/// observed against a remote that answers.
#[test]
fn a_pattern_refspec_with_no_destination_costs_no_bytes_and_writes_no_pack() {
/// A writer that counts every byte and passes it on.
struct Counting<W: Write> {
inner: W,
seen: Arc<Mutex<usize>>,
}
impl<W: Write> Write for Counting<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
*self.seen.lock().unwrap() += n;
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
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", "v1", "-m", "release"]);
let dest = dir.path().join("dest.git");
let repo = open_or_init_bare(&dest, kind).unwrap();
let url = src.to_string_lossy().to_string();
let written = Arc::new(Mutex::new(0usize));
let (stdout, stdin, mut child) = GitChild::upload_pack(&src).into_halves();
let counting = Counting {
inner: stdin,
seen: Arc::clone(&written),
};
let err = fetch_with_io(&repo, stdout, counting, &url, &url, &["refs/heads/*"])
.expect_err("fetch_with_io took a refspec git refuses");
// The child is still waiting on a request it will never get; end it
// rather than blocking on a `wait` that cannot return.
let _ = child.kill();
let _ = child.wait();
assert_eq!(
err.to_string(),
"invalid refspec 'refs/heads/*'",
"{kind:?}"
);
let bytes = *written.lock().unwrap();
assert_eq!(
bytes, 0,
"{kind:?}: the refusal came after {bytes} bytes had gone to the remote"
);
// Applied output: nothing on disk, nothing in the ref store.
let packs = std::fs::read_dir(dest.join("objects").join("pack"))
.map(|rd| {
rd.filter_map(Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
.count()
})
.unwrap_or(0);
assert_eq!(packs, 0, "{kind:?}: a pack was written for a refused fetch");
assert!(
refs_via_git(&dest).is_empty(),
"{kind:?}: refs exist after a refused fetch: {:?}",
refs_via_git(&dest)
);
assert!(
!dest.join("FETCH_HEAD").exists(),
"{kind:?}: FETCH_HEAD was written for a refused fetch"
);
}
}
/// The over-refusal guard: a source-only spec that is **not** a pattern is legal
/// and must still fetch.
///
/// `git fetch <url> refs/heads/main` writes `FETCH_HEAD` and no other ref, and
/// exits 0. A validator that refused every colon-less refspec would pass every
/// test above and break this, which is why it is here and on both hash kinds.
#[test]
fn a_source_only_non_pattern_refspec_still_fetches_to_fetch_head() {
for kind in BOTH_HASH_KINDS {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
init_worktree(&src, kind);
let head = commit_file(&src, "a.txt", "one\n", "first");
// Stock git's verdict on the same spec, first: if git refused it, this
// test is asserting the wrong thing.
let judge = dir.path().join("judge.git");
init_bare(&judge, kind);
let url = src.to_string_lossy().to_string();
git(&judge, &["fetch", &url, "refs/heads/main"]);
assert_eq!(
std::fs::read_to_string(judge.join("FETCH_HEAD"))
.expect("git wrote no FETCH_HEAD")
.split('\t')
.next()
.unwrap_or_default(),
head,
"{kind:?}: git's own FETCH_HEAD does not hold the commit"
);
let dest = dir.path().join("dest.git");
let repo = open_or_init_bare(&dest, kind).unwrap();
let (stdout, stdin, mut child) = GitChild::upload_pack(&src).into_halves();
let outcome = fetch_with_io(&repo, stdout, stdin, &url, &url, &["refs/heads/main"])
.unwrap_or_else(|e| {
panic!(
"{kind:?}: gunnar refused what git accepts: {}",
gunnar_client::chain(&e)
)
});
let _ = child.wait();
assert!(outcome.received_pack, "{kind:?}: no pack arrived");
let fetch_head = std::fs::read_to_string(dest.join("FETCH_HEAD"))
.unwrap_or_else(|e| panic!("{kind:?}: gunnar wrote no FETCH_HEAD: {e}"));
assert!(
fetch_head.starts_with(&head),
"{kind:?}: FETCH_HEAD holds {fetch_head:?}, not {head}"
);
}
}