crates/gunnar-client/src/filter.rs
//! The **client** half of partial clone: `--filter=blob:none`, `tree:<depth>`
//! and `blob:limit=<n>`.
//!
//! # Why this module exists
//!
//! `gunnar-wire` parses and applies all three filters and advertises the
//! `filter` capability on both protocol versions (`gunnar_wire::filter`,
//! `gunnar_wire::caps`). The client had none of them, so gunnar could serve a
//! partial clone it could not make — the same shape as the shallow gap, and the
//! other half of what `actions/checkout` and every large monorepo reach for.
//!
//! # Mirroring the server, not inventing a second grammar
//!
//! The spec strings, the size suffixes and the refusals here are
//! `gunnar-wire`'s, deliberately: a client that accepted `blob:limit=1kb` and a
//! server that did not would produce an error from the far end of a connection
//! for a string the near end had already blessed. What this adds is that the
//! refusal happens **before** a byte is sent.
//!
//! The one semantic worth stating twice, because it is off-by-one bait:
//! `blob:limit=<n>` omits a blob of **exactly** `n` bytes. git's
//! `list-objects-filter.c` compares `object_length < max_bytes`, and
//! `gunnar-wire` matches it at `graph.rs:1029`. MEASURED 2026-08-05 against git
//! 2.53.0 with blobs of 1023, 1024 and 1025 bytes under `blob:limit=1024`: only
//! the 1023-byte blob arrives.
use crate::error::{Error, Result};
/// A partial-clone filter, in the spelling both halves of gunnar use.
///
/// Parsed rather than passed through as a string so a bad spec is refused
/// locally, and so [`Self::to_spec`] can render exactly one wire form.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterSpec {
/// `blob:none` — every blob omitted. The partial clone people mean when
/// they say "no file contents".
BlobNone,
/// `blob:limit=<n>` — blobs **smaller than** `n` bytes are sent, and a blob
/// of exactly `n` is not.
BlobLimit(u64),
/// `tree:<depth>` — trees deeper than `depth` are omitted, and with them
/// every blob they alone reach. `tree:0` sends no trees at all.
TreeDepth(usize),
}
impl FilterSpec {
/// Parse a `--filter` value, refusing what `gunnar-wire` refuses and in the
/// same words.
///
/// The size suffixes are git's and the server's: `k`/`K` = 1024, `m`/`M` =
/// 1024², `g`/`G` = 1024³, with overflow rejected rather than wrapped.
pub fn parse(spec: &str) -> Result<Self> {
if spec == "blob:none" {
return Ok(FilterSpec::BlobNone);
}
if let Some(size) = spec.strip_prefix("blob:limit=") {
return Ok(FilterSpec::BlobLimit(parse_size(size).ok_or_else(
|| {
Error::Invalid(format!(
"--filter {spec:?}: {size:?} is not a size; write digits with an \
optional k, m or g suffix"
))
},
)?));
}
if let Some(depth) = spec.strip_prefix("tree:") {
return Ok(FilterSpec::TreeDepth(depth.parse::<usize>().map_err(
|e| Error::Invalid(format!("--filter {spec:?}: {depth:?} is not a depth ({e})")),
)?));
}
if spec.starts_with("combine:") {
return Err(Error::Invalid(format!(
"--filter {spec:?}: gunnar implements one filter at a time"
)));
}
Err(Error::Invalid(format!(
"--filter {spec:?}: gunnar implements blob:none, blob:limit=<n> and tree:<depth>"
)))
}
/// The wire spelling, which is also the spelling that goes into
/// `remote.origin.partialclonefilter`.
///
/// One renderer (one implementation): the argument sent and the config
/// recorded cannot drift apart, and a later `git fetch` in the clone
/// reuses the config one.
pub fn to_spec(&self) -> String {
match self {
FilterSpec::BlobNone => "blob:none".to_string(),
FilterSpec::BlobLimit(n) => format!("blob:limit={n}"),
FilterSpec::TreeDepth(d) => format!("tree:{d}"),
}
}
}
/// `1024`, `1k`, `4M`, `2g` as a byte count. `None` for anything else.
fn parse_size(text: &str) -> Option<u64> {
let bytes = text.as_bytes();
let (digits, scale) = match bytes.last()? {
b'k' | b'K' => (&text[..text.len() - 1], 1024u64),
b'm' | b'M' => (&text[..text.len() - 1], 1024 * 1024),
b'g' | b'G' => (&text[..text.len() - 1], 1024 * 1024 * 1024),
_ => (text, 1),
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse::<u64>().ok()?.checked_mul(scale)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_three_filters_parse_and_render_back_to_themselves() {
for spec in ["blob:none", "blob:limit=1024", "tree:0", "tree:3"] {
let parsed = FilterSpec::parse(spec).unwrap_or_else(|e| panic!("{spec}: {e}"));
assert_eq!(parsed.to_spec(), spec, "{spec} did not round-trip");
}
}
#[test]
fn the_size_suffixes_are_powers_of_1024() {
assert_eq!(
FilterSpec::parse("blob:limit=1k").unwrap(),
FilterSpec::BlobLimit(1024)
);
assert_eq!(
FilterSpec::parse("blob:limit=2M").unwrap(),
FilterSpec::BlobLimit(2 * 1024 * 1024)
);
assert_eq!(
FilterSpec::parse("blob:limit=1g").unwrap(),
FilterSpec::BlobLimit(1024 * 1024 * 1024)
);
// A suffixed value renders as the expanded byte count, which is what
// the server compares against and what the config must record.
assert_eq!(
FilterSpec::parse("blob:limit=1k").unwrap().to_spec(),
"blob:limit=1024"
);
}
/// Every one of these must be refused, and the test names what it is
/// protecting against: a spec the client blesses and the server then
/// rejects from the far end of a connection.
#[test]
fn what_the_server_refuses_the_client_refuses_first() {
for spec in [
"blob:limit=1kb", // git has no `b` suffix
"blob:limit=", // no digits
"blob:limit=abc", // not a number
"blob:limit=-1", // not unsigned
"tree:", // no depth
"tree:x", // not a number
"tree:-1", // not unsigned
"combine:blob:none+tree:0", // one filter at a time
"sparse:oid=main", // not implemented either half
"blob:all", // not a filter
"", // nothing at all
] {
assert!(
FilterSpec::parse(spec).is_err(),
"{spec:?} was accepted, and the server would then refuse it mid-connection"
);
}
}
/// `blob:limit` overflow must be refused, not wrapped: a wrapped limit is a
/// silently different filter.
#[test]
fn a_size_that_overflows_is_refused_rather_than_wrapped() {
assert!(FilterSpec::parse("blob:limit=99999999999999999999").is_err());
assert!(
FilterSpec::parse("blob:limit=18014398509481985g").is_err(),
"a suffix that overflows u64 must be refused"
);
}
}