Skip to main content

gunnar_sendpack/
options.rs

1//! What the client asks for, and what it does when the remote will not give it.
2//!
3//! # What is refused rather than silently downgraded
4//!
5//! A client that asks for `atomic` and gets a non-atomic push has done
6//! something worse than failing: it has reported success for a guarantee it did
7//! not get. [`negotiate`] therefore **errors** when a requested capability was
8//! not advertised, for `atomic`, `push-options` and `delete-refs`. A silent
9//! downgrade is a green that could never go red.
10
11use gix_hash::Kind;
12
13use crate::advertisement::{object_format_name, Advertisement};
14use crate::command::PushCommand;
15use crate::error::{Error, Result};
16
17/// What the client asks for.
18#[derive(Debug, Clone)]
19pub struct SendPackOptions {
20    /// The `agent=` string. Identifies the client in the remote's logs.
21    pub agent: String,
22    /// Require all-or-nothing across the whole command list. **Errors** if the
23    /// remote does not advertise `atomic`.
24    pub atomic: bool,
25    /// Ask for `side-band-64k`, so the remote's hook output and errors arrive
26    /// rather than vanishing.
27    pub side_band: bool,
28    /// Ask the remote to be quiet about progress.
29    pub quiet: bool,
30    /// `push-options`, delivered after the command list. **Errors** if the
31    /// remote does not advertise `push-options`.
32    pub push_options: Vec<String>,
33    /// Prefer `report-status-v2` when advertised.
34    pub report_status_v2: bool,
35}
36
37impl Default for SendPackOptions {
38    fn default() -> Self {
39        SendPackOptions {
40            agent: format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
41            atomic: false,
42            side_band: true,
43            quiet: false,
44            push_options: Vec::new(),
45            report_status_v2: true,
46        }
47    }
48}
49
50/// **May the pack this push writes use `OBJ_OFS_DELTA` entries?**
51///
52/// # One predicate, two readers, because it was one decision made twice
53///
54/// `ofs-delta` on receive-pack means *"I may send you deltas named by distance
55/// backwards rather than by object id"*. It is a thing the CLIENT says, and
56/// gunnar's client never said it: [`negotiate`] had no `ofs-delta` arm at all,
57/// while `gunnar-client`'s push built `WriteOptions::new(kind)` — whose
58/// `ofs_delta` defaults to `true` — and `write_pack_deltified` duly emitted
59/// `OBJ_OFS_DELTA` headers. The encoding was used and never negotiated.
60///
61/// It is not a measured wrong answer against stock git: `index-pack` decodes
62/// `OBJ_OFS_DELTA` whether or not the capability was sent, so nothing breaks
63/// today. That is exactly what makes it this class rather than a bug — a
64/// behaviour the wire does not declare, which happens to be tolerated. A
65/// receive-pack entitled to refuse what it never offered would be within its
66/// rights, and gunnar would be the one in the wrong.
67///
68/// So both halves read this, and neither can be changed alone: the capability
69/// line comes from here and so does the entry header. The same shape
70/// `gunnar_wire::WantPolicy::advertises` has on the serving side, where one
71/// written-out predicate decides both what `check_wants` will serve and what
72/// the v0 advertisement promises.
73pub fn ofs_delta_agreed(adv: &Advertisement) -> bool {
74    adv.capabilities.has("ofs-delta")
75}
76
77/// The capabilities the client will send, given what the remote offered.
78///
79/// Errors rather than downgrades when a requested guarantee is not on offer.
80/// See the module docs.
81pub fn negotiate(
82    adv: &Advertisement,
83    commands: &[PushCommand],
84    local_hash_kind: Kind,
85    opts: &SendPackOptions,
86) -> Result<Vec<String>> {
87    let remote = &adv.capabilities;
88    let mut out: Vec<String> = Vec::new();
89
90    if local_hash_kind != adv.hash_kind {
91        return Err(Error::protocol(format!(
92            "the local repository is {local_hash_kind:?} but the remote is {:?}; \
93             git cannot push between object formats",
94            adv.hash_kind
95        )));
96    }
97
98    if remote.has("report-status-v2") && opts.report_status_v2 {
99        out.push("report-status-v2".to_string());
100    } else if remote.has("report-status") {
101        out.push("report-status".to_string());
102    }
103    // With neither, the remote says nothing at all after the pack and the push
104    // is unverifiable. That is allowed by the protocol; it is recorded so the
105    // caller can see the report is empty by design rather than by failure.
106
107    if opts.side_band && remote.has("side-band-64k") {
108        out.push("side-band-64k".to_string());
109    }
110    // Said because the pack says it: `ofs_delta_agreed` is what the writer's
111    // entry headers are chosen from too, so this line and the encoding cannot
112    // disagree. There is no `SendPackOptions` knob for it — a client that
113    // declined `ofs-delta` while still writing `OBJ_OFS_DELTA` is the defect
114    // this closes, and a knob is one more way to reopen it.
115    if ofs_delta_agreed(adv) {
116        out.push("ofs-delta".to_string());
117    }
118    if opts.quiet && remote.has("quiet") {
119        out.push("quiet".to_string());
120    }
121    if opts.atomic {
122        if !remote.has("atomic") {
123            return Err(Error::protocol(
124                "atomic push was requested but the remote does not advertise `atomic`; \
125                 refusing to push non-atomically under an atomic request",
126            ));
127        }
128        out.push("atomic".to_string());
129    }
130    if !opts.push_options.is_empty() {
131        if !remote.has("push-options") {
132            return Err(Error::protocol(
133                "push options were given but the remote does not advertise `push-options`",
134            ));
135        }
136        out.push("push-options".to_string());
137    }
138    if commands.iter().any(PushCommand::is_delete) && !remote.has("delete-refs") {
139        return Err(Error::protocol(
140            "the push deletes a ref but the remote does not advertise `delete-refs`",
141        ));
142    }
143    // Only echo `object-format` when the remote raised the subject; older
144    // servers reject an unknown capability outright.
145    if remote.has("object-format") {
146        out.push(format!(
147            "object-format={}",
148            object_format_name(local_hash_kind)?
149        ));
150    }
151    if remote.has("agent") {
152        out.push(format!("agent={}", opts.agent));
153    }
154    Ok(out)
155}