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