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_partial.rs

//! Partial clone (`--filter`) against the **real `git upload-pack` binary**, on
//! both protocol versions and both hash kinds.
//!
//! # Everything here is `--bare`, and that is not a style choice
//!
//! A non-bare clone checks out its worktree, and a checkout **reads every blob
//! in the tree** — which, in a working partial clone, lazily fetches back
//! exactly the objects the filter just omitted. The repository ends up holding
//! them all and the test passes whether the filter worked or not. An earlier
//! filter test was blind for precisely that reason. Every fetch below goes into
//! a bare repository, and the assertion is on **which objects are absent**,
//! counted by stock git.
//!
//! # What is asserted
//!
//! * the `filter <spec>` argument reached the wire, read back from the client's
//!   own writes rather than from a flag;
//! * the arm really is the protocol version it claims (`want …
//!   multi_ack_detailed` and no `command=` for v0, `command=fetch` for v2);
//! * which objects arrived and which did not, named, against stock git's own
//!   `--filter` clone of the same repository;
//! * that the omitted objects come back on demand, which is the difference
//!   between a partial clone and a broken one.

mod common;

use std::io::Write;
use std::path::{Path, PathBuf};

use common::*;
use gix::hash::Kind;
use gunnar_client::fetch::{fetch_with_io_opts, open_or_init_bare, FetchOptions};
use gunnar_client::FilterSpec;

/// Bare-to-bare, so the reference clone and gunnar's fetch hold the same refs.
const BARE_REFS: &[&str] = &["+refs/heads/*:refs/heads/*"];

/// Both protocol versions. `true` is v2.
const BOTH_PROTOCOLS: [bool; 2] = [true, false];

fn label(kind: Kind, v2: bool) -> String {
    format!(
        "{:?}/{}",
        kind,
        if v2 { "protocol-v2" } else { "protocol-v0" }
    )
}

/// One commit on `main` carrying three blobs of known, *different* sizes.
///
/// The sizes straddle 1024 on purpose: `blob:limit=1024` must keep the
/// 1023-byte blob and drop the 1024- and 1025-byte ones, which is the
/// off-by-one git's `list-objects-filter.c` decides with `<` and which
/// `gunnar-wire` matches at `graph.rs:1029`.
struct Scene {
    dir: tempfile::TempDir,
    kind: Kind,
    /// oid of the blob of exactly 1023 bytes.
    small: String,
    /// oid of the blob of exactly 1024 bytes.
    exact: String,
    /// oid of the blob of exactly 1025 bytes.
    large: String,
}

impl Scene {
    fn new(kind: Kind) -> Self {
        let dir = tempfile::tempdir().unwrap();
        let src = dir.path().join("src");
        init_worktree(&src, kind);
        // The `upload-pack` a `file://` clone spawns reads the SOURCE
        // repository's config, not the client's `-c`, so the reference clone
        // only actually filters if the setting lives here. Without it stock git
        // silently serves everything and the reference stops being one.
        git(&src, &["config", "uploadpack.allowFilter", "true"]);
        // A nested directory, so `tree:0` and `tree:1` differ.
        std::fs::create_dir_all(src.join("sub")).unwrap();
        std::fs::write(src.join("small.txt"), "s".repeat(1023)).unwrap();
        std::fs::write(src.join("exact.txt"), "e".repeat(1024)).unwrap();
        std::fs::write(src.join("sub/large.txt"), "l".repeat(1025)).unwrap();
        git(&src, &["add", "."]);
        git(&src, &["commit", "-q", "-m", "c1"]);
        let oid = |path: &str| git(&src, &["rev-parse", &format!("HEAD:{path}")]);
        Scene {
            small: oid("small.txt"),
            exact: oid("exact.txt"),
            large: oid("sub/large.txt"),
            dir,
            kind,
        }
    }

    fn src(&self) -> PathBuf {
        self.dir.path().join("src")
    }

    fn file_url(&self) -> String {
        format!("file://{}", self.src().display())
    }

    /// Clone with **stock git** under the same filter: the reference every
    /// gunnar arm is judged against.
    fn git_clone_filtered(&self, name: &str, v2: bool, spec: &str) -> PathBuf {
        let dest = self.dir.path().join(name);
        let version = if v2 {
            "protocol.version=2"
        } else {
            "protocol.version=0"
        };
        let url = self.file_url();
        let dest_s = dest.to_string_lossy().to_string();
        let filter = format!("--filter={spec}");
        git(
            self.dir.path(),
            &[
                "-c",
                version,
                "-c",
                "uploadpack.allowFilter=true",
                "clone",
                "-q",
                "--bare",
                "--no-single-branch",
                &filter,
                &url,
                &dest_s,
            ],
        );
        dest
    }

    /// Fetch with **gunnar** into a fresh bare repository under `filter`,
    /// returning the repository, the outcome and the bytes the client wrote.
    fn gunnar_fetch(
        &self,
        name: &str,
        v2: bool,
        filter: Option<FilterSpec>,
    ) -> (PathBuf, gunnar_client::FetchOutcome, String) {
        let dest = self.dir.path().join(name);
        let repo = open_or_init_bare(&dest, self.kind).unwrap();
        let child = GitChild::upload_pack_filtering(&self.src(), v2);
        let (stdout, stdin, mut proc) = child.into_halves();
        let (tee, seen) = Tee::new(stdin);
        let url = self.src().to_string_lossy().to_string();
        let mut options = FetchOptions::new();
        if let Some(filter) = filter {
            options = options.with_filter(filter);
        }
        let outcome = fetch_with_io_opts(&repo, stdout, tee, &url, &url, BARE_REFS, &options);
        let _ = proc.wait();
        let wire = String::from_utf8_lossy(&seen.lock().unwrap()).into_owned();
        if std::env::var_os("GUNNAR_DUMP_WIRE").is_some() {
            eprintln!("--- {} ---\n{}", label(self.kind, v2), readable(&wire));
        }
        match outcome {
            Ok(outcome) => (dest, outcome, wire),
            Err(e) => panic!(
                "gunnar's filtered fetch failed at {}: {e}\nwhat the client wrote:\n{}",
                label(self.kind, v2),
                readable(&wire)
            ),
        }
    }
}

// ── reading the client's own bytes ──────────────────────────────────────────

/// The client's writes, split back into pkt-lines.
///
/// A substring search will not do: v0 bakes capabilities onto the first `want`
/// line and one of them is literally named `filter`, so `wire.contains("filter")`
/// is true of a v0 fetch that asked for no filter at all. An argument is a whole
/// pkt-line, and that is what is compared.
fn pkt_lines(wire: &str) -> Vec<String> {
    let bytes = wire.as_bytes();
    let mut out = Vec::new();
    let mut at = 0usize;
    while at + 4 <= bytes.len() {
        let Ok(header) = std::str::from_utf8(&bytes[at..at + 4]) else {
            break;
        };
        let Ok(len) = usize::from_str_radix(header, 16) else {
            break;
        };
        if len < 4 {
            at += 4;
            continue;
        }
        if at + len > bytes.len() {
            break;
        }
        out.push(
            String::from_utf8_lossy(&bytes[at + 4..at + len])
                .trim_end_matches('\n')
                .to_string(),
        );
        at += len;
    }
    out
}

fn sent(wire: &str, argument: &str) -> bool {
    pkt_lines(wire).iter().any(|line| line == argument)
}

fn sent_any(wire: &str, prefix: &str) -> bool {
    pkt_lines(wire).iter().any(|line| line.starts_with(prefix))
}

fn readable(wire: &str) -> String {
    pkt_lines(wire)
        .into_iter()
        .map(|l| format!("  | {l}"))
        .collect::<Vec<_>>()
        .join("\n")
}

fn is_really_v0(wire: &str) -> bool {
    let lines = pkt_lines(wire);
    !lines.iter().any(|l| l.starts_with("command="))
        && lines
            .iter()
            .any(|l| l.starts_with("want ") && l.contains("multi_ack_detailed"))
}

fn is_really_v2(wire: &str) -> bool {
    pkt_lines(wire).iter().any(|l| l == "command=fetch")
}

fn assert_version(wire: &str, v2: bool, at: &str) {
    if v2 {
        assert!(
            is_really_v2(wire),
            "{at}: asked for v2 and the client never sent `command=fetch`:\n{}",
            readable(wire)
        );
    } else {
        assert!(
            is_really_v0(wire),
            "{at}: asked for v0 and the client's bytes do not look like v0:\n{}",
            readable(wire)
        );
    }
}

/// The `filter` argument, and on v0 the capability that gates it, both reached
/// the wire.
fn assert_filter_sent(wire: &str, spec: &str, v2: bool, at: &str) {
    assert!(
        sent(wire, &format!("filter {spec}")),
        "{at}: the client never sent `filter {spec}`:\n{}",
        readable(wire)
    );
    if !v2 {
        // v0 gates the argument on a want-line capability; without it stock git
        // refuses the argument and the fetch is unfiltered.
        let want = pkt_lines(wire)
            .into_iter()
            .find(|l| l.starts_with("want ") && l.split(' ').count() > 2)
            .unwrap_or_default();
        assert!(
            want.split(' ').any(|c| c == "filter"),
            "{at}: v0 needs `filter` among the want-line capabilities:\n{}",
            readable(wire)
        );
    }
}

// ── which objects are actually there ────────────────────────────────────────

/// Is this object in the repository, as **stock git** sees it?
///
/// `cat-file -e` and not gix, so the verdict comes from the other
/// implementation.
///
/// `GIT_NO_LAZY_FETCH=1` is the whole correctness of this function. A promisor
/// repository asked whether it holds an object will **fetch the object in order
/// to answer**, so without it the question changes the answer and every object
/// is present. MEASURED here on 2026-08-05: stock git's own `--filter=blob:none`
/// reference clone reported all three blobs present, and the fixture could not
/// tell a filtered clone from a full one in either direction.
fn has_object(repo: &Path, oid: &str) -> bool {
    git_try_env(
        repo,
        &["cat-file", "-e", oid],
        &[("GIT_NO_LAZY_FETCH", "1")],
    )
    .is_ok()
}

/// Every object the repository holds, as stock git lists them, without chasing
/// anything missing.
///
/// `GIT_NO_LAZY_FETCH=1` again, and for a subtler reason than in [`has_object`]:
/// enumerating objects means **reading trees**, so in a promisor repository
/// `rev-list` will fetch the very trees a `tree:0` filter omitted in order to
/// walk past them. MEASURED here: stock git's `tree:0` reference reported three
/// objects against gunnar's one, and the difference was entirely objects
/// `rev-list` had just fetched.
fn objects_present(repo: &Path) -> Vec<String> {
    let out = git_try_env(
        repo,
        &[
            "rev-list",
            "--objects",
            "--all",
            "--missing=allow-any",
            "--no-object-names",
        ],
        &[("GIT_NO_LAZY_FETCH", "1")],
    )
    .unwrap_or_default();
    let mut v: Vec<String> = out
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty())
        .map(str::to_string)
        .filter(|oid| has_object(repo, oid))
        .collect();
    v.sort();
    v.dedup();
    v
}

// ── the tests ───────────────────────────────────────────────────────────────

/// `--filter=blob:none`: the partial clone people mean.
#[test]
fn blob_none_omits_every_blob_and_keeps_the_commit_and_trees() {
    for kind in BOTH_HASH_KINDS {
        for v2 in BOTH_PROTOCOLS {
            let scene = Scene::new(kind);
            let at = label(kind, v2);
            let reference = scene.git_clone_filtered("ref.git", v2, "blob:none");
            let (dest, _outcome, wire) =
                scene.gunnar_fetch("gunnar.git", v2, Some(FilterSpec::BlobNone));

            assert_version(&wire, v2, &at);
            assert_filter_sent(&wire, "blob:none", v2, &at);

            // Named, not counted: every blob absent, and the commit present.
            for (what, oid) in [
                ("small", &scene.small),
                ("exact", &scene.exact),
                ("large", &scene.large),
            ] {
                assert!(
                    !has_object(&dest, oid),
                    "{at}: blob:none kept the {what} blob {oid}, so nothing was filtered"
                );
                assert!(
                    !has_object(&reference, oid),
                    "{at}: stock git kept the {what} blob under blob:none, so this \
                     fixture cannot tell a filtered clone from a full one"
                );
            }
            let head = git(&dest, &["rev-parse", "refs/heads/main"]);
            assert!(
                has_object(&dest, &head),
                "{at}: the commit itself is missing, so this is not a partial clone \
                 but an empty one"
            );
            // And the same object set as stock git's own filtered clone.
            assert_eq!(
                objects_present(&dest),
                objects_present(&reference),
                "{at}: gunnar's filtered clone holds a different object set than git's"
            );
        }
    }
}

/// `--filter=tree:0`: no trees either, so the commit arrives alone.
#[test]
fn tree_zero_omits_the_trees_as_well() {
    for kind in BOTH_HASH_KINDS {
        for v2 in BOTH_PROTOCOLS {
            let scene = Scene::new(kind);
            let at = label(kind, v2);
            let reference = scene.git_clone_filtered("ref.git", v2, "tree:0");
            let (dest, _outcome, wire) =
                scene.gunnar_fetch("gunnar.git", v2, Some(FilterSpec::TreeDepth(0)));

            assert_version(&wire, v2, &at);
            assert_filter_sent(&wire, "tree:0", v2, &at);

            let head = git(&dest, &["rev-parse", "refs/heads/main"]);
            // Read the tree oid from the COMPLETE source, never from a filtered
            // repository: `rev-parse <commit>^{tree}` verifies the object it
            // resolves to, which in a promisor clone fetches the very tree the
            // filter omitted. MEASURED here — asking the reference this question
            // pulled two trees into it and the comparison below then failed
            // against gunnar's correct one-object clone.
            let root_tree = git(&scene.src(), &["rev-parse", &format!("{head}^{{tree}}")]);
            assert!(
                !has_object(&dest, &root_tree),
                "{at}: tree:0 kept the root tree {root_tree}"
            );
            assert!(
                has_object(&dest, &head),
                "{at}: the commit must still arrive"
            );
            assert_eq!(
                objects_present(&dest),
                objects_present(&reference),
                "{at}: gunnar's tree:0 clone differs from git's"
            );
            // tree:0 is strictly smaller than blob:none, which is what makes
            // this test more than a copy of the one above.
            let (blob_none, _, _) = scene.gunnar_fetch("bn.git", v2, Some(FilterSpec::BlobNone));
            assert!(
                objects_present(&dest).len() < objects_present(&blob_none).len(),
                "{at}: tree:0 must hold fewer objects than blob:none"
            );
        }
    }
}

/// `blob:limit=1024`: the off-by-one. A blob of exactly 1024 bytes is omitted.
#[test]
fn blob_limit_omits_the_blob_of_exactly_that_many_bytes() {
    for kind in BOTH_HASH_KINDS {
        for v2 in BOTH_PROTOCOLS {
            let scene = Scene::new(kind);
            let at = label(kind, v2);
            let reference = scene.git_clone_filtered("ref.git", v2, "blob:limit=1024");
            let (dest, _outcome, wire) =
                scene.gunnar_fetch("gunnar.git", v2, Some(FilterSpec::BlobLimit(1024)));

            assert_version(&wire, v2, &at);
            assert_filter_sent(&wire, "blob:limit=1024", v2, &at);

            // The whole point, stated as three separate facts so a wrong
            // comparison operator cannot pass any of them by accident.
            assert!(
                has_object(&dest, &scene.small),
                "{at}: the 1023-byte blob is under the limit and must arrive"
            );
            assert!(
                !has_object(&dest, &scene.exact),
                "{at}: git compares `size < limit`, so the 1024-byte blob must NOT \
                 arrive under blob:limit=1024"
            );
            assert!(
                !has_object(&dest, &scene.large),
                "{at}: the 1025-byte blob is over the limit"
            );
            // Stock git agrees, on this very fixture — so the rule above is
            // read off the reference implementation, not off a belief.
            assert!(has_object(&reference, &scene.small), "{at}");
            assert!(!has_object(&reference, &scene.exact), "{at}");
            assert!(!has_object(&reference, &scene.large), "{at}");
            assert_eq!(
                objects_present(&dest),
                objects_present(&reference),
                "{at}: gunnar's blob:limit clone differs from git's"
            );
        }
    }
}

/// The negative control: no `--filter` means no `filter` **argument**, and a
/// complete repository — even though v0's capability list contains the word.
#[test]
fn a_plain_fetch_sends_no_filter_and_stays_complete() {
    for kind in BOTH_HASH_KINDS {
        for v2 in BOTH_PROTOCOLS {
            let scene = Scene::new(kind);
            let at = label(kind, v2);
            let (dest, _outcome, wire) = scene.gunnar_fetch("gunnar.git", v2, None);

            assert_version(&wire, v2, &at);
            assert!(
                !sent_any(&wire, "filter "),
                "{at}: a fetch that asked for no filter sent one:\n{}",
                readable(&wire)
            );
            for (what, oid) in [
                ("small", &scene.small),
                ("exact", &scene.exact),
                ("large", &scene.large),
            ] {
                assert!(
                    has_object(&dest, oid),
                    "{at}: an unfiltered fetch is missing the {what} blob {oid}"
                );
            }
            // No promisor config, because nothing is missing on purpose.
            assert!(
                git_try(&dest, &["config", "remote.origin.promisor"]).is_err(),
                "{at}: an unfiltered fetch marked the repository as a partial clone"
            );
        }
    }
}

/// A partial clone is only useful if the objects it skipped come back.
///
/// This is the test that proves `record_promisor` is load-bearing: without the
/// three config settings it writes, git treats the gaps as corruption and
/// `cat-file` fails instead of fetching.
#[test]
fn the_omitted_blobs_come_back_on_demand() {
    for kind in BOTH_HASH_KINDS {
        for v2 in BOTH_PROTOCOLS {
            let scene = Scene::new(kind);
            let at = label(kind, v2);
            let (dest, _outcome, _wire) =
                scene.gunnar_fetch("gunnar.git", v2, Some(FilterSpec::BlobNone));

            // The promisor needs a URL to fetch from. `open_or_init_bare` writes
            // no remote (a clone does); the lazy fetch is what is under test, not
            // where the URL came from.
            git(&dest, &["config", "remote.origin.url", &scene.file_url()]);
            git(&dest, &["config", "uploadpack.allowFilter", "true"]);

            // The config gunnar wrote, read back with stock git.
            assert_eq!(
                git(&dest, &["config", "remote.origin.promisor"]),
                "true",
                "{at}: remote.origin.promisor was not recorded"
            );
            assert_eq!(
                git(&dest, &["config", "remote.origin.partialclonefilter"]),
                "blob:none",
                "{at}: the filter was not recorded, so a later fetch would widen it"
            );
            assert_eq!(
                git(&dest, &["config", "extensions.partialClone"]),
                "origin",
                "{at}: without this extension git treats the gaps as corruption"
            );
            assert_eq!(
                git(&dest, &["config", "core.repositoryformatversion"]),
                "1",
                "{at}: version 0 ignores the whole [extensions] section, so the \
                 partialClone marker would be silently inert"
            );

            // The blob is genuinely absent...
            assert!(
                !has_object(&dest, &scene.small),
                "{at}: nothing was filtered, so this proves nothing"
            );
            // ...and reading it fetches it from the promisor.
            let shown = git(&dest, &["cat-file", "-p", &scene.small]);
            assert_eq!(
                shown.len(),
                1023,
                "{at}: the lazily fetched blob is the wrong size"
            );
            assert!(
                has_object(&dest, &scene.small),
                "{at}: the blob was printed but not stored, so it was not a real \
                 lazy fetch"
            );
        }
    }
}

/// `Tee` is a `Write`, asserted at compile time so a refactor cannot quietly
/// drop the wire recording every test above depends on.
fn _tee_is_a_writer<W: Write>(_: Tee<W>) {}