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/wire.rs

//! The one place gunnar edits its own outgoing request pkt-lines.
//!
//! # Why any editing happens at all
//!
//! Two wire features gunnar needs are not reachable through the gix API it
//! fetches with, for two different reasons:
//!
//! * **`deepen-relative` on protocol v0** — gix gets it *wrong*, in a way that
//!   turns `--depth 1` into a full clone with no error. See
//!   [`RequestEdit::relative`] and the measurement below.
//! * **`filter` (partial clone)** — gix gets it *right* but does not expose it.
//!   `gix_protocol::fetch::Arguments::filter` exists
//!   (`gix-protocol-0.64.0/src/fetch/arguments/mod.rs:159`) and so does
//!   `can_use_filter` (`:40`), but the only caller is a `Negotiate`
//!   implementation, and `gix::Repository`'s high-level
//!   `prepare_fetch().with_shallow().receive()` — the path gunnar uses, and the
//!   path that owns refspec mapping and ref updates — has `with_dry_run`,
//!   `with_write_packed_refs_only`, `with_reflog_message` and `with_shallow`
//!   and nothing else (`gix-0.86.0/src/remote/connection/fetch/mod.rs:225-260`).
//!   Reaching `Arguments` means dropping to `gix_protocol::fetch()` and
//!   reimplementing everything above it.
//!
//! Both are therefore applied to the byte stream on its way out. **One editor,
//! not two** (one implementation): a second pkt-line reframer would be a
//! second chance to get the four-byte header wrong, and the two edits land in
//! the same request section anyway.
//!
//! The editor is inert unless something is actually being asked for, it decides
//! v0 from v2 by reading the client's **own first line** rather than trusting a
//! flag — v0 opens with `want …`, v2 with `command=…` — and everything after the
//! section it edits is passed through byte for byte.
//!
//! # The v0 `deepen-relative` measurement
//!
//! `deepen-relative` is a *capability* in v0 and an *argument* in v2. gix
//! computes v0's want-line capabilities as "every feature in my table that the
//! server also advertised" (`Command::default_features`), with no reference to
//! what the request will ask for, so **every** gix v0 fetch claims it.
//! `upload-pack.c` reads it straight off the want line:
//!
//! ```text
//!   if (parse_feature_request(features, "deepen-relative")) deepen_relative = 1;
//!   …
//!   else if (depth > 0) deepen(writer, depth, deepen_relative, &shallows);
//! ```
//!
//! and `deepen()` with `deepen_relative` set counts from the client's *existing*
//! shallow boundary. A fresh clone has none, so nothing is selected and the
//! server sends the entire history with no `shallow` line. Bisected on the
//! capability string, three commits, `deepen 1`, MEASURED 2026-08-05 against
//! stock `git upload-pack` 2.53.0:
//!
//! | want-line capabilities | server's answer |
//! |---|---|
//! | git 2.53.0's own | `shallow eb921a…`, 292 bytes |
//! | gix's | no shallow line, 1170 bytes — the whole repository |
//! | gix's, minus `deepen-relative` | `shallow …`, 532 bytes |
//!
//! The mirror image: for a genuine `--deepen` gix also writes a
//! `deepen-relative` **line**, which v0 has no argument for, and stock git dies
//! with *protocol error, expected to get object ID, not 'deepen-relative'*. So
//! on v0 the capability is the only place it may appear and the line is the only
//! place it may not.
//!
//! Filed upstream as a gix bug; delete the `relative` half the day it is
//! released. The `filter` half stays until gix grows a `with_filter`.

use crate::shallow::Shallow;

/// Wraps the client's writer and applies the two edits above.
pub(crate) struct RequestEdit<W> {
    inner: W,
    /// Bytes of a pkt-line not yet complete.
    buf: Vec<u8>,
    /// `true` once the first line proved this is v0.
    v0: bool,
    /// `true` once the first line has been classified either way.
    classified: bool,
    /// `true` once editing is over: an unparsable header, or the end of the
    /// request section. Everything after is passed through byte for byte.
    done: bool,
    /// Is a deepening being asked for, so the `deepen-relative` edits apply?
    shallow_active: bool,
    /// Is that deepening a **relative** one? Decides whether the v0 want-line
    /// capability is kept or removed.
    relative: bool,
    /// The partial-clone filter spec to inject, e.g. `blob:none`.
    filter: Option<String>,
}

impl<W: std::io::Write> RequestEdit<W> {
    /// Wrap `inner` for a fetch asking for `shallow` and `filter`.
    ///
    /// Inert when neither needs an edit: with [`Shallow::NoChange`] and no
    /// filter, gix still over-claims `deepen-relative` on v0, but it never sends
    /// a `deepen`, so nothing reads it and there is nothing to correct.
    pub(crate) fn new(inner: W, shallow: &Shallow, filter: Option<String>) -> Self {
        let (shallow_active, relative) = match shallow {
            Shallow::NoChange => (false, false),
            Shallow::Deepen(_) => (true, true),
            Shallow::DepthAtRemote(_) | Shallow::Since { .. } | Shallow::Exclude { .. } => {
                (true, false)
            }
        };
        let done = !shallow_active && filter.is_none();
        RequestEdit {
            inner,
            buf: Vec::new(),
            v0: false,
            classified: false,
            done,
            shallow_active,
            relative,
            filter,
        }
    }

    /// Write one pkt-line with a correct four-byte length header.
    fn line(&mut self, payload: &[u8]) -> std::io::Result<()> {
        let mut out = format!("{:04x}", payload.len() + 4).into_bytes();
        out.extend_from_slice(payload);
        self.inner.write_all(&out)
    }

    /// Emit the `filter <spec>` argument, if one is pending, and forget it so it
    /// is sent exactly once.
    fn emit_filter(&mut self) -> std::io::Result<()> {
        if let Some(spec) = self.filter.take() {
            self.line(format!("filter {spec}\n").as_bytes())?;
        }
        Ok(())
    }

    /// Emit every complete pkt-line the buffer holds, editing where the rules
    /// above say to.
    fn drain(&mut self) -> std::io::Result<()> {
        let mut at = 0usize;
        while !self.done && at + 4 <= self.buf.len() {
            let Some(len) = parse_pkt_len(&self.buf[at..at + 4]) else {
                // Not a pkt-line header. Nothing here understands this stream,
                // so stop editing rather than guess.
                self.done = true;
                break;
            };
            if len == 0 {
                // A flush. On v0 it ends the want section, which is the last
                // point a `filter` argument may be added and the end of the
                // editable region.
                if self.v0 {
                    self.emit_filter()?;
                    self.done = true;
                }
                self.inner.write_all(&self.buf[at..at + 4])?;
                at += 4;
                continue;
            }
            if len < 4 {
                // delim (`0001`) / response-end (`0002`): no payload.
                self.inner.write_all(&self.buf[at..at + 4])?;
                at += 4;
                continue;
            }
            if at + len > self.buf.len() {
                break; // incomplete line, wait for more
            }
            let payload = self.buf[at + 4..at + len].to_vec();
            if !self.classified {
                self.classified = true;
                // v0 opens with `want …`; v2 with `command=…`.
                self.v0 = payload.starts_with(b"want ");
                if !self.v0 && self.filter.is_none() {
                    // v2 with no filter needs no edit at all: `deepen-relative`
                    // is a legitimate argument there and want lines carry no
                    // capabilities.
                    self.inner.write_all(&self.buf[at..at + len])?;
                    at += len;
                    self.done = true;
                    continue;
                }
            }
            if !self.v0 && payload.strip_suffix(b"\n").unwrap_or(&payload) == b"done" {
                // v2: arguments end at `done`, so the filter goes immediately
                // before it. `done` also ends the editable region.
                self.emit_filter()?;
                self.inner.write_all(&self.buf[at..at + len])?;
                at += len;
                self.done = true;
                continue;
            }
            match self.edit(&payload) {
                LineEdit::Keep => self.inner.write_all(&self.buf[at..at + len])?,
                LineEdit::Drop => {}
                LineEdit::Replace(edited) => self.line(&edited)?,
            }
            at += len;
        }
        self.buf.drain(..at);
        if self.done && !self.buf.is_empty() {
            let rest = std::mem::take(&mut self.buf);
            self.inner.write_all(&rest)?;
        }
        Ok(())
    }

    /// The per-line edits, and nothing else.
    fn edit(&self, payload: &[u8]) -> LineEdit {
        const CAP: &[u8] = b" deepen-relative";
        if self.v0 && payload.starts_with(b"want ") {
            // Only `deepen-relative` is edited here.
            //
            // The v0 `filter` **capability** is deliberately NOT added, because
            // gix already claims it: `filter` is in gix's v0 feature table
            // (`gix-protocol-0.64.0/src/command.rs:77`) and gix claims every
            // feature the server also advertised, so a server offering `filter`
            // gets it back on the want line without gunnar's help. Code to add
            // it was written, and a red probe on 2026-08-05 proved it could
            // never fire — the branch was dead in all four matrix cells, with
            // the observed want line already reading
            // `… multi_ack_detailed filter agent=git/gunnar/1.1.0 …`. It is
            // removed rather than kept behind a comment claiming it matters.
            //
            // The other direction needs no code either: a server that does not
            // advertise `filter` makes gix drop the capability, and sending the
            // argument anyway would be a lie the server is right to refuse.
            if self.shallow_active && !self.relative {
                if let Some(found) = find(payload, CAP) {
                    let after = found + CAP.len();
                    // Only a whole token, so `deepen-relatively` is left alone.
                    if !payload
                        .get(after)
                        .is_some_and(|b| !matches!(b, b' ' | b'\n'))
                    {
                        let mut out = payload.to_vec();
                        out.drain(found..after);
                        return LineEdit::Replace(out);
                    }
                }
            }
            return LineEdit::Keep;
        }
        if self.shallow_active
            && self.v0
            && payload.strip_suffix(b"\n").unwrap_or(payload) == b"deepen-relative"
        {
            // v0 has no such argument line. Stock git dies on it.
            return LineEdit::Drop;
        }
        LineEdit::Keep
    }
}

/// What to do with one request pkt-line.
enum LineEdit {
    Keep,
    Drop,
    Replace(Vec<u8>),
}

fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
    haystack
        .windows(needle.len())
        .position(|window| window == needle)
}

/// A four-byte pkt-line length header as a number.
fn parse_pkt_len(header: &[u8]) -> Option<usize> {
    let text = std::str::from_utf8(header).ok()?;
    usize::from_str_radix(text, 16).ok()
}

impl<W: std::io::Write> std::io::Write for RequestEdit<W> {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        if self.done && self.buf.is_empty() {
            return self.inner.write(buf);
        }
        self.buf.extend_from_slice(buf);
        self.drain()?;
        Ok(buf.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        // A partial line at flush time cannot be edited, and holding it back
        // would deadlock against a peer waiting for it. Forwarded verbatim,
        // which is byte-identical to what would have gone out unwrapped.
        if !self.buf.is_empty() {
            let rest = std::mem::take(&mut self.buf);
            self.inner.write_all(&rest)?;
        }
        self.inner.flush()
    }
}