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>) {}