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}