crates/gunnar-sendpack/src/options.rs
//! What the client asks for, and what it does when the remote will not give it.
//!
//! # What is refused rather than silently downgraded
//!
//! A client that asks for `atomic` and gets a non-atomic push has done
//! something worse than failing: it has reported success for a guarantee it did
//! not get. [`negotiate`] therefore **errors** when a requested capability was
//! not advertised, for `atomic`, `push-options` and `delete-refs`. A silent
//! downgrade is a green that could never go red.
use gix_hash::Kind;
use crate::advertisement::{object_format_name, Advertisement};
use crate::command::PushCommand;
use crate::error::{Error, Result};
/// What the client asks for.
#[derive(Debug, Clone)]
pub struct SendPackOptions {
/// The `agent=` string. Identifies the client in the remote's logs.
pub agent: String,
/// Require all-or-nothing across the whole command list. **Errors** if the
/// remote does not advertise `atomic`.
pub atomic: bool,
/// Ask for `side-band-64k`, so the remote's hook output and errors arrive
/// rather than vanishing.
pub side_band: bool,
/// Ask the remote to be quiet about progress.
pub quiet: bool,
/// `push-options`, delivered after the command list. **Errors** if the
/// remote does not advertise `push-options`.
pub push_options: Vec<String>,
/// Prefer `report-status-v2` when advertised.
pub report_status_v2: bool,
}
impl Default for SendPackOptions {
fn default() -> Self {
SendPackOptions {
agent: format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
atomic: false,
side_band: true,
quiet: false,
push_options: Vec::new(),
report_status_v2: true,
}
}
}
/// **May the pack this push writes use `OBJ_OFS_DELTA` entries?**
///
/// # One predicate, two readers, because it was one decision made twice
///
/// `ofs-delta` on receive-pack means *"I may send you deltas named by distance
/// backwards rather than by object id"*. It is a thing the CLIENT says, and
/// gunnar's client never said it: [`negotiate`] had no `ofs-delta` arm at all,
/// while `gunnar-client`'s push built `WriteOptions::new(kind)` — whose
/// `ofs_delta` defaults to `true` — and `write_pack_deltified` duly emitted
/// `OBJ_OFS_DELTA` headers. The encoding was used and never negotiated.
///
/// It is not a measured wrong answer against stock git: `index-pack` decodes
/// `OBJ_OFS_DELTA` whether or not the capability was sent, so nothing breaks
/// today. That is exactly what makes it this class rather than a bug — a
/// behaviour the wire does not declare, which happens to be tolerated. A
/// receive-pack entitled to refuse what it never offered would be within its
/// rights, and gunnar would be the one in the wrong.
///
/// So both halves read this, and neither can be changed alone: the capability
/// line comes from here and so does the entry header. The same shape
/// `gunnar_wire::WantPolicy::advertises` has on the serving side, where one
/// written-out predicate decides both what `check_wants` will serve and what
/// the v0 advertisement promises.
pub fn ofs_delta_agreed(adv: &Advertisement) -> bool {
adv.capabilities.has("ofs-delta")
}
/// The capabilities the client will send, given what the remote offered.
///
/// Errors rather than downgrades when a requested guarantee is not on offer.
/// See the module docs.
pub fn negotiate(
adv: &Advertisement,
commands: &[PushCommand],
local_hash_kind: Kind,
opts: &SendPackOptions,
) -> Result<Vec<String>> {
let remote = &adv.capabilities;
let mut out: Vec<String> = Vec::new();
if local_hash_kind != adv.hash_kind {
return Err(Error::protocol(format!(
"the local repository is {local_hash_kind:?} but the remote is {:?}; \
git cannot push between object formats",
adv.hash_kind
)));
}
if remote.has("report-status-v2") && opts.report_status_v2 {
out.push("report-status-v2".to_string());
} else if remote.has("report-status") {
out.push("report-status".to_string());
}
// With neither, the remote says nothing at all after the pack and the push
// is unverifiable. That is allowed by the protocol; it is recorded so the
// caller can see the report is empty by design rather than by failure.
if opts.side_band && remote.has("side-band-64k") {
out.push("side-band-64k".to_string());
}
// Said because the pack says it: `ofs_delta_agreed` is what the writer's
// entry headers are chosen from too, so this line and the encoding cannot
// disagree. There is no `SendPackOptions` knob for it — a client that
// declined `ofs-delta` while still writing `OBJ_OFS_DELTA` is the defect
// this closes, and a knob is one more way to reopen it.
if ofs_delta_agreed(adv) {
out.push("ofs-delta".to_string());
}
if opts.quiet && remote.has("quiet") {
out.push("quiet".to_string());
}
if opts.atomic {
if !remote.has("atomic") {
return Err(Error::protocol(
"atomic push was requested but the remote does not advertise `atomic`; \
refusing to push non-atomically under an atomic request",
));
}
out.push("atomic".to_string());
}
if !opts.push_options.is_empty() {
if !remote.has("push-options") {
return Err(Error::protocol(
"push options were given but the remote does not advertise `push-options`",
));
}
out.push("push-options".to_string());
}
if commands.iter().any(PushCommand::is_delete) && !remote.has("delete-refs") {
return Err(Error::protocol(
"the push deletes a ref but the remote does not advertise `delete-refs`",
));
}
// Only echo `object-format` when the remote raised the subject; older
// servers reject an unknown capability outright.
if remote.has("object-format") {
out.push(format!(
"object-format={}",
object_format_name(local_hash_kind)?
));
}
if remote.has("agent") {
out.push(format!("agent={}", opts.agent));
}
Ok(out)
}