crates/gunnar-client/src/send_pack.rs
//! **send-pack**: the half of git that `gix` does not have.
//!
//! Verified in gix 0.86: `gix-protocol/src/` carries `fetch/`, `ls_refs.rs` and
//! `handshake/`, and **nothing for push**. `Direction::Push` exists only as a
//! refspec direction. So a git client written on gix has to put this on the
//! wire itself (the crate layout).
//!
//! # This module used to *be* the implementation. It is now a seam
//!
//! It was 816 lines of protocol here, and the four wire formats it spoke — the
//! capability list, the reference advertisement, the command list and the
//! status report — were **written a second time** in `gunnar-wire`, for the
//! server side of the identical conversation. Two implementations of one
//! grammar is the twinning one implementation forbids, and the design record
//! named it as a known divergence with the fix already named: *"the one
//! implementation goes in that crate and both directions route through it"*.
//!
//! That crate is [`gunnar_sendpack`] — donation-shaped, zero gunnar
//! dependencies, `MIT OR Apache-2.0`, liftable with `cp -r`. Everything below
//! is a re-export of it plus the one thing that cannot cross that boundary:
//! turning its error into this crate's.
//!
//! # The conversation, for readers who came here first
//!
//! ```text
//! S→C <old-oid> <refname>\0<capabilities>\n ref advertisement, protocol v0
//! S→C 0000
//! C→S <old> <new> <refname>\0<capabilities>\n the command list
//! C→S 0000
//! C→S <push-option>\n … 0000 only if `push-options` was negotiated
//! C→S PACK… raw, NOT pkt-line framed
//! S→C unpack ok\n · ok <ref>\n | ng <ref> <why>\n · 0000
//! ```
//!
//! [`gunnar_sendpack::driver`] documents the three details implementations get
//! wrong, and carries a test for each.
pub use gunnar_sendpack::advertisement::{object_format_name, parse as parse_advertisement};
pub use gunnar_sendpack::command::{lines as command_lines, parse_line as parse_command_line};
pub use gunnar_sendpack::driver::{encode_command_list, encode_push_options};
pub use gunnar_sendpack::options::negotiate as negotiate_capabilities;
pub use gunnar_sendpack::report::parse as parse_report;
pub use gunnar_sendpack::{
Advertisement, Capabilities, PushCommand, PushReport, RefStatus, RemoteRef, SendPackOptions,
};
use crate::error::{Error, Result};
use crate::transport::Transport;
impl From<gunnar_sendpack::Error> for Error {
fn from(err: gunnar_sendpack::Error) -> Self {
match err {
gunnar_sendpack::Error::Io(io) => Error::Io(io),
gunnar_sendpack::Error::Invalid(message) => Error::Invalid(message),
// A hash-kind mismatch is a protocol error here rather than a
// variant of its own: the client cannot do anything about it except
// report it, and the CLI prints the message.
other => Error::Protocol(other.to_string()),
}
}
}
/// This crate's [`Transport`] as [`gunnar_sendpack`]'s.
///
/// The two traits are the same trait — a blocking reader and a blocking writer,
/// handed out as a disjoint pair — declared twice because neither crate may
/// name the other's. The donation crate must name nothing of gunnar's,
/// and this crate's `Transport` is public API that `gunnar-cli`, `nornir-git`
/// and the SSH layer all implement.
///
/// So it is one adapter, in one place, rather than an `impl` per transport.
struct Bridge<'a, T: ?Sized>(&'a mut T);
impl<T: Transport + ?Sized> gunnar_sendpack::Transport for Bridge<'_, T> {
fn io(&mut self) -> (&mut dyn std::io::Read, &mut dyn std::io::Write) {
self.0.io()
}
}
/// Read `git-receive-pack`'s ref advertisement from `reader`.
pub fn read_advertisement(reader: &mut dyn std::io::Read) -> Result<Advertisement> {
Ok(gunnar_sendpack::read_advertisement(reader)?)
}
/// Read the report from `reader`, demultiplexing sidebands when negotiated.
pub fn read_report(reader: &mut dyn std::io::Read, side_band: bool) -> Result<PushReport> {
Ok(gunnar_sendpack::driver::read_report(reader, side_band)?)
}
/// Run one complete send-pack over `transport`, given an advertisement already
/// read from it.
///
/// `write_pack` streams the packfile straight onto the wire; it is `None` for
/// an all-deletes push, which carries no pack. Splitting the advertisement out
/// of this call is what lets [`crate::push`] decide *what* to push after seeing
/// what the remote has; that decision is porcelain's, not the wire's.
pub fn send_pack<T, P>(
transport: &mut T,
advertisement: &Advertisement,
commands: &[PushCommand],
local_hash_kind: gix_hash::Kind,
write_pack: Option<P>,
opts: &SendPackOptions,
) -> Result<PushReport>
where
T: Transport,
P: FnOnce(&mut dyn std::io::Write) -> Result<()>,
{
let mut bridge = Bridge(transport);
// The pack writer's error type crosses the boundary the other way: the
// caller's closure fails with *this* crate's `Error` (it writes a
// `gunnar-pack` packfile), and the donation crate knows only its own. Both
// sides are `io::Error`-shaped for anything the wire can cause, so the
// conversion is lossless for the failures that reach the socket and
// preserves the message for the rest — the whole chain, not the outer
// word alone (it read "invalid argument: packfile").
let pack_failed = std::rc::Rc::new(std::cell::Cell::new(false));
let flag = std::rc::Rc::clone(&pack_failed);
let write_pack = write_pack.map(|f| {
move |w: &mut dyn std::io::Write| -> gunnar_sendpack::Result<()> {
f(w).map_err(|e| {
flag.set(true);
match e {
Error::Io(io) => gunnar_sendpack::Error::Io(io),
other => gunnar_sendpack::Error::invalid(crate::error::chain(&other)),
}
})
}
});
match gunnar_sendpack::send_pack(
&mut bridge,
advertisement,
commands,
local_hash_kind,
write_pack,
opts,
) {
Ok(report) => Ok(report),
// ★ **The remote stopped reading the pack because it REFUSED it**
// (round 5.3.1; the r5.1 silent-twin fill 2026-10-08: 56 pushes ended
// "invalid argument: packfile" while main had refused each for room
// mid-receive — "the data set is full right now: … does not fit beside
// the pushes already arriving" — and written that as its `unpack`
// line). When the pack could not be written, the remote's report is
// read before the error is believed: a refusal in the remote's own
// words is the answer; with no report, the write error stands.
Err(err) if pack_failed.get() => {
match refused_midstream(&mut bridge, advertisement, commands, local_hash_kind, opts) {
Some(report) => Ok(report),
None => Err(err.into()),
}
}
Err(err) => Err(err.into()),
}
}
/// The remote's report after a pack it stopped reading, when it sent one that
/// refuses the push (`unpack` not `ok`) — reconciled with the commands, as a
/// completed send-pack's is. `None` when there is no such report to read.
fn refused_midstream<T: gunnar_sendpack::Transport>(
transport: &mut T,
advertisement: &Advertisement,
commands: &[PushCommand],
local_hash_kind: gix_hash::Kind,
opts: &SendPackOptions,
) -> Option<PushReport> {
let caps =
gunnar_sendpack::options::negotiate(advertisement, commands, local_hash_kind, opts).ok()?;
if !caps
.iter()
.any(|c| c == "report-status" || c == "report-status-v2")
{
return None;
}
let side_band = caps.iter().any(|c| c == "side-band-64k");
let (reader, _) = transport.io();
let mut report = gunnar_sendpack::driver::read_report(reader, side_band).ok()?;
// A remote that sent nothing (the stream simply ended) parses to an empty
// `unpack`: that is no report, and the write error stands.
if report.unpack.trim().is_empty() || report.unpack == "ok" {
return None;
}
let commanded: Vec<_> = commands.iter().map(|c| c.name.clone()).collect();
report.reconcile(&commanded);
Some(report)
}
/// The `agent=` string this client identifies itself with, on the wire.
///
/// [`SendPackOptions::default`] in the donation crate names *that* crate; a
/// gunnar client says `gunnar/<version>`, which is what the remote's logs and
/// the interop tests expect.
pub fn options() -> SendPackOptions {
SendPackOptions {
agent: crate::agent(),
..SendPackOptions::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A remote that reads `take` bytes of the pack, then stops reading (the
/// write fails with EINVAL, as the silent-twin's pushes did).
struct Refusing {
took: usize,
take: usize,
}
impl std::io::Write for Refusing {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.took >= self.take {
return Err(std::io::Error::from_raw_os_error(22));
}
let n = buf.len().min(self.take - self.took);
self.took += n;
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn remote(report: Vec<u8>) -> crate::transport::IoPair<std::io::Cursor<Vec<u8>>, Refusing> {
crate::transport::IoPair {
reader: std::io::Cursor::new(report),
writer: Refusing { took: 0, take: 300 },
}
}
fn pkt(line: &str) -> Vec<u8> {
let mut v = format!("{:04x}", line.len() + 4).into_bytes();
v.extend_from_slice(line.as_bytes());
v
}
/// ★ **A pack the remote refused mid-stream reads as the remote's refusal**
/// (the r5.1 silent-twin fill, 2026-10-08: 56 pushes said "invalid
/// argument: packfile" while main had refused each for room and written
/// why). The write fails, the remote's report is read, and the push answers
/// with its `unpack` line — not the write error.
#[test]
fn a_pack_refused_mid_stream_reads_as_the_remotes_refusal() {
let why = "the data set is full right now: this push does not fit beside the pushes already arriving";
let mut report = pkt(&format!("unpack {why}\n"));
report.extend(pkt("ng refs/heads/main unpacker error\n"));
report.extend_from_slice(b"0000");
let zero = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
let adv = Advertisement {
refs: vec![],
capabilities: Capabilities::parse("report-status"),
hash_kind: gix_hash::Kind::Sha1,
};
let new =
gix_hash::ObjectId::from_hex(b"1111111111111111111111111111111111111111").unwrap();
let commands = vec![PushCommand {
name: "refs/heads/main".into(),
old: zero,
new,
}];
let mut refusing = remote(report);
let pack = |w: &mut dyn std::io::Write| -> Result<()> {
for _ in 0..100 {
w.write_all(&[7u8; 1024]).map_err(Error::Io)?;
}
Ok(())
};
let got = send_pack(
&mut refusing,
&adv,
&commands,
gix_hash::Kind::Sha1,
Some(pack),
&SendPackOptions::default(),
)
.expect("the remote's report is the answer");
assert_eq!(got.unpack, why, "the remote's own words");
assert!(
got.failure_summary()
.is_some_and(|w| w.contains("does not fit beside")),
"{:?}",
got.failure_summary()
);
// No report to read: the write error stands, with its cause in the chain.
let mut silent = remote(Vec::new());
let err = send_pack(
&mut silent,
&adv,
&commands,
gix_hash::Kind::Sha1,
Some(pack),
&SendPackOptions::default(),
)
.expect_err("nothing to read");
assert!(
crate::error::chain(&err).contains("os error 22")
|| crate::error::chain(&err)
.to_lowercase()
.contains("invalid argument"),
"{}",
crate::error::chain(&err)
);
}
/// The one behaviour that lives here rather than in the donation crate:
/// gunnar's own `agent=`. Seen red by asserting the string, since the
/// crate's own default is `gunnar-sendpack/…` and would otherwise pass a
/// `starts_with("gunnar")` check.
#[test]
fn the_agent_identifies_gunnar_and_not_the_donation_crate() {
let agent = options().agent;
assert!(
agent.starts_with("gunnar/"),
"the wire must say `gunnar/<version>`, not {agent:?}"
);
assert!(!agent.starts_with("gunnar-sendpack"));
}
/// The bridge is not a formality: without it nothing in this crate can be
/// handed to the driver at all. Asserted by driving a push through a pair
/// of in-memory buffers, i.e. through `crate::transport::IoPair`.
#[test]
fn this_crates_transport_reaches_the_driver() {
let adv = parse_advertisement(&[
b"1111111111111111111111111111111111111111 refs/heads/main\0delete-refs \
object-format=sha1\n"
.to_vec(),
])
.unwrap();
let mut t = crate::transport::IoPair::new(std::io::Cursor::new(Vec::new()), Vec::new());
let report = send_pack(
&mut t,
&adv,
&[PushCommand::delete(
"refs/heads/main",
gix_hash::ObjectId::from_hex(b"1111111111111111111111111111111111111111").unwrap(),
)],
gix_hash::Kind::Sha1,
None::<fn(&mut dyn std::io::Write) -> Result<()>>,
&options(),
)
.expect("a remote offering no report-status is legal");
assert_eq!(report.unpack, "ok");
assert!(
!t.writer.is_empty(),
"the command list never reached the transport"
);
}
}