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