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