Skip to main content

grit_lib/
push.rs

1//! Wire-protocol push orchestration over a [`crate::transport::Connection`].
2//!
3//! [`push_remote`] is the wire counterpart to [`crate::transfer::push_local`]:
4//! instead of copying objects between two on-disk repositories, it drives a
5//! `git-receive-pack` exchange over a live [`crate::transport::Connection`] —
6//! reading the receive-pack advertisement (remote refs + `.have` lines +
7//! capabilities), deciding each ref update against the advertised remote refs
8//! (reusing the same fast-forward / force / force-with-lease rules as
9//! `push_local`), building the minimal pack with [`crate::transfer::build_pack`]
10//! (using the advertised remote tips + `.have`s as the negotiation `haves`),
11//! streaming it, and parsing the `report-status` / `report-status-v2` reply into
12//! per-ref [`crate::push_report::PushRefResult`]s.
13//!
14//! This is the send-pack flow lifted from the CLI's `commands/send_pack.rs`
15//! (`run`, `report_has_rejections`, `demux_report_and_remote_messages`),
16//! generalized to run over the [`crate::transport::Connection`] reader/writer
17//! rather than a spawned `receive-pack` subprocess.
18//!
19//! Protocol v0/v1 only in this phase (the classic receive-pack advertisement).
20//! A protocol-v2 push would require the `command=push` round and is deferred.
21//!
22//! The wire OID width is the repository's hash algorithm (threaded through
23//! [`crate::odb::Odb::hash_algo`]), so SHA-256 repositories push correctly: the
24//! zero/null OID, the empty-pack trailer, and the advertisement parsing are all
25//! hash-width aware.
26
27use std::collections::HashMap;
28use std::collections::HashSet;
29use std::io::Cursor;
30use std::path::Path;
31
32use crate::error::{Error, Result};
33use crate::fetch::Progress;
34use crate::objects::{parse_tag, HashAlgo, ObjectId, ObjectKind};
35use crate::pkt_line::{self, Packet};
36use crate::push_report::{PushRefResult, PushRefStatus};
37use crate::transfer::{
38    build_pack, open_odb, PackBuildOptions, PushOptions, PushOutcome, PushRefSpec,
39};
40use crate::transport::Connection;
41
42/// The receive-pack capabilities we negotiate, in the order Git's `send-pack`
43/// lists them. `report-status-v2` is requested alongside `report-status` so a
44/// modern server can reply with the richer per-ref report; `side-band-64k` lets
45/// the server multiplex the report (band 1) and hook/diagnostic output (band 2).
46const PUSH_CAPS_BASE: &str = "report-status report-status-v2 quiet";
47
48/// Push refs to a remote over a live [`Connection`] speaking `git-receive-pack`.
49///
50/// The flow mirrors [`crate::transfer::push_local`], but the remote ref list and
51/// `.have` hints come from the connection's advertisement, the objects are
52/// streamed over the wire as a single pack, and per-ref acceptance/rejection is
53/// learned from the server's `report-status` reply (a server may reject an update
54/// our local checks would have accepted, e.g. `denyNonFastForwards` or a
55/// pre-receive hook).
56///
57/// Steps:
58/// 1. Read the receive-pack advertisement from `conn`: remote refs (name -> oid),
59///    `.have` oids, and capabilities (`report-status(-v2)`, `side-band-64k`,
60///    `ofs-delta`, `object-format`).
61/// 2. Decide each [`PushRefSpec`] against the advertised remote refs (up-to-date,
62///    new, fast-forward, forced, non-fast-forward rejection, force-with-lease
63///    stale) — the client-side gate before anything is sent.
64/// 3. Write the ref-update commands for the accepted, value-changing updates
65///    (`<old> <new> <ref>\0<caps>\n` first, `<old> <new> <ref>\n` rest), then a
66///    flush.
67/// 4. Build the minimal pack with [`build_pack`] (wants = new tips, haves =
68///    advertised remote tips + `.have`s) and stream it; for deletion-only pushes
69///    stream the empty pack.
70/// 5. Read + parse `report-status` / `report-status-v2` (demultiplexing the
71///    side-band if negotiated) and fold the per-ref `ok`/`ng` lines back into the
72///    decided results.
73///
74/// `progress` receives the remote's side-band channel-2 bytes (hook output,
75/// `remote: …` diagnostics) when `side-band-64k` is negotiated.
76///
77/// Protocol v0/v1 only; a v2 connection is rejected.
78///
79/// # Errors
80///
81/// Returns an error if the connection is protocol v2, if a source object is
82/// missing from the local odb, if the pack build fails, or on wire/parse I/O
83/// failure.
84pub fn push_remote(
85    local_git_dir: &Path,
86    conn: &mut dyn Connection,
87    refs: &[PushRefSpec],
88    opts: &PushOptions,
89    progress: &mut dyn Progress,
90) -> Result<PushOutcome> {
91    use crate::net_trace::net_trace;
92    net_trace!(
93        "push_remote: begin — {} ref update(s), protocol v{}, {} push-option(s)",
94        refs.len(),
95        conn.protocol_version(),
96        opts.push_options.len()
97    );
98    if conn.protocol_version() >= 2 {
99        return Err(Error::Message(
100            "push_remote: protocol v2 not supported in this phase (use v0/v1)".to_owned(),
101        ));
102    }
103
104    let local_odb = open_odb(local_git_dir);
105    let algo = local_odb.hash_algo();
106
107    // 1. Advertisement: split the connection's parsed advertisement into the
108    //    remote ref map and the `.have` hints, and read the negotiated caps.
109    let adv = AdvertisedState::from_connection(conn);
110    net_trace!(
111        "push_remote: remote advertised {} ref(s)",
112        adv.remote_refs.len()
113    );
114
115    // Push-options require the server's `push-options` capability; fail typed
116    // (matching Git) before sending anything if the server lacks it.
117    require_push_options_supported(&adv, opts)?;
118
119    // 2–3. Decide each ref update client-side, handling atomic/dry-run/no-op
120    //       early-returns; the shared planner mirrors `push_local`'s gate.
121    let mut plan = match plan_push(refs, &local_odb, local_git_dir, &adv, opts)? {
122        PlanOutcome::Send(plan) => plan,
123        PlanOutcome::Done(results) => return Ok(PushOutcome { results }),
124    };
125
126    // 4. Write the ref-update commands (first carries the cap list after a NUL),
127    //    then the pack — but only when there are objects to send. A deletion-only
128    //    push streams no pack at all (matching `git send-pack`); `receive-pack`
129    //    does not read one after a delete-only command block, so sending an empty
130    //    pack would leave unread bytes on the wire and reset the connection.
131    let commands = build_command_block(&plan, &adv, algo, &opts.push_options)?;
132    net_trace!("push_remote: sending {} command(s)…", plan.decisions.len());
133    conn.writer().write_all(&commands)?;
134    conn.writer().flush()?;
135
136    if let Some(pack) = build_push_pack(&plan, &local_odb, &adv)? {
137        net_trace!("push_remote: sending pack ({} bytes)…", pack.len());
138        conn.writer().write_all(&pack)?;
139        conn.writer().flush()?;
140    } else {
141        net_trace!("push_remote: no pack (deletion-only / up-to-date)");
142    }
143
144    // 5. Read the server's report. With side-band, band 1 carries the
145    //    report-status pkt-lines and band 2/3 carry remote diagnostics; without
146    //    it the raw stream is the report-status itself.
147    //
148    // Unlike the v2 *fetch* path, we do NOT half-close the write side before
149    // reading: `git-receive-pack` has no persistent v2 serve loop — it consumes
150    // the command list and the (length-delimited) pack, writes the report, and
151    // exits. It is still reading its input while we read its report, so closing
152    // our write half early makes it see a premature EOF ("the remote end hung up
153    // unexpectedly") and abort without sending the report. The server closes its
154    // own output once the report is written, ending `read_to_end`; the write
155    // half is released when the connection is dropped (after the child has
156    // already exited, so the `Drop` teardown does not block).
157    let mut raw = Vec::new();
158    conn.reader().read_to_end(&mut raw)?;
159    let report = if adv.server_sideband {
160        demux_report_and_remote_messages(&raw, progress)?
161    } else {
162        raw
163    };
164
165    apply_report_status(&report, &mut plan.decisions);
166
167    let results: Vec<_> = plan.decisions.into_iter().map(|d| d.result).collect();
168    net_trace!("push_remote: done — {} result(s)", results.len());
169    Ok(PushOutcome { results })
170}
171
172/// Push refs to a remote over smart HTTP (`git-receive-pack`), returning a
173/// [`PushOutcome`].
174///
175/// This is the stateless-RPC counterpart to [`push_remote`]: instead of a duplex
176/// [`Connection`] it issues a `GET info/refs?service=git-receive-pack` discovery
177/// (the receive-pack advertisement: remote refs + `.have`s + capabilities), then
178/// a single `POST git-receive-pack` whose body is the ref-update commands
179/// (`<old> <new> <ref>\0<caps>\n` first, bare after, flush) followed by the
180/// packfile; the response is the `report-status` / `report-status-v2`.
181///
182/// The decision logic, command framing, pack building (thin + delta + advertised
183/// `.have` set + `ofs-delta` per caps), and report parsing are the *same* shared
184/// helpers used by [`push_remote`] — only the wire (discovery GET + one POST vs.
185/// the duplex socket) differs.
186///
187/// `client` is the embedder's [`crate::transport::http::HttpClient`]; `repo_url`
188/// is the remote repository URL (e.g. `http://host/repo.git`). `progress`
189/// receives the server's side-band channel-2 bytes (hook output, `remote: …`
190/// diagnostics) when `side-band-64k` is negotiated.
191///
192/// Protocol v0/v1 only; a v2 receive-pack advertisement is rejected (a v2 push
193/// would require the `command=push` round and is deferred — matching
194/// [`push_remote`]).
195///
196/// # Errors
197///
198/// Returns an error if discovery fails, the advertisement is protocol v2, a
199/// source object is missing from the local odb, the pack build fails, or on
200/// wire/parse I/O failure.
201pub fn push_http(
202    client: &dyn crate::transport::http::HttpClient,
203    local_git_dir: &Path,
204    repo_url: &str,
205    refs: &[PushRefSpec],
206    opts: &PushOptions,
207    progress: &mut dyn Progress,
208) -> Result<PushOutcome> {
209    use crate::net_trace::net_trace;
210    net_trace!(
211        "push_http: begin — {} ref update(s) to {}, {} push-option(s)",
212        refs.len(),
213        repo_url,
214        opts.push_options.len()
215    );
216    let local_odb = open_odb(local_git_dir);
217    let algo = local_odb.hash_algo();
218
219    // 1. Discovery: GET info/refs?service=git-receive-pack.
220    let adv = discover_receive_pack(client, repo_url)?;
221    net_trace!(
222        "push_http: remote advertised {} ref(s) (protocol v{})",
223        adv.state.remote_refs.len(),
224        adv.protocol_version
225    );
226    if adv.protocol_version >= 2 {
227        return Err(Error::Message(
228            "push_http: protocol v2 receive-pack not supported in this phase (use v0/v1)"
229                .to_owned(),
230        ));
231    }
232
233    // Push-options require the server's `push-options` capability; fail typed
234    // (matching Git) before sending anything if the server lacks it.
235    require_push_options_supported(&adv.state, opts)?;
236
237    // 2–3. Decide each ref update client-side (shared with `push_remote`).
238    let mut plan = match plan_push(refs, &local_odb, local_git_dir, &adv.state, opts)? {
239        PlanOutcome::Send(plan) => plan,
240        PlanOutcome::Done(results) => return Ok(PushOutcome { results }),
241    };
242
243    // 4. Build the single POST body: ref-update commands + flush (then the
244    //    push-option lines + flush when negotiated), then the pack (omitted
245    //    entirely for a deletion-only push, matching `git send-pack`).
246    let mut body = build_command_block(&plan, &adv.state, algo, &opts.push_options)?;
247    if let Some(pack) = build_push_pack(&plan, &local_odb, &adv.state)? {
248        body.extend_from_slice(&pack);
249    }
250
251    // 5. POST git-receive-pack and parse the report-status reply.
252    let service_url = receive_pack_url(repo_url);
253    let content_type = format!("application/x-{RECEIVE_PACK}-request");
254    let accept = format!("application/x-{RECEIVE_PACK}-result");
255    net_trace!(
256        "push_http: POST git-receive-pack ({} command(s), {} body bytes)…",
257        plan.decisions.len(),
258        body.len()
259    );
260    let resp = client.post(&service_url, &content_type, &accept, &body, None)?;
261
262    let report = if adv.state.server_sideband {
263        demux_report_and_remote_messages(&resp, progress)?
264    } else {
265        resp
266    };
267
268    apply_report_status(&report, &mut plan.decisions);
269    net_trace!("push_http: done — {} result(s)", plan.decisions.len());
270
271    Ok(PushOutcome {
272        results: plan.decisions.into_iter().map(|d| d.result).collect(),
273    })
274}
275
276const RECEIVE_PACK: &str = "git-receive-pack";
277
278/// The remote ref map + `.have` hints + capability flags parsed from a
279/// receive-pack advertisement. Shared by the duplex ([`push_remote`]) and
280/// stateless-HTTP ([`push_http`]) paths so the decision/command/pack logic is
281/// identical regardless of how the advertisement was obtained.
282struct AdvertisedState {
283    /// Real remote refs (name -> oid), excluding the `.have` carrier lines.
284    remote_refs: HashMap<String, ObjectId>,
285    /// `.have` object hints (objects the remote holds but does not name a ref for).
286    advertised_haves: Vec<ObjectId>,
287    /// Whether the server advertised `side-band-64k`/`side-band` (report demuxing).
288    server_sideband: bool,
289    /// Whether the server advertised `ofs-delta` (offset-relative delta bases).
290    server_ofs_delta: bool,
291    /// Whether the server advertised `push-options` (server-side push options).
292    server_push_options: bool,
293}
294
295impl AdvertisedState {
296    /// Build from a live [`Connection`]'s parsed advertisement. The `.have` lines
297    /// are recorded by the connection as refs literally named `.have`, so peel
298    /// those out here; everything else is a real remote ref.
299    fn from_connection(conn: &mut dyn Connection) -> Self {
300        let mut remote_refs: HashMap<String, ObjectId> = HashMap::new();
301        let mut advertised_haves: Vec<ObjectId> = Vec::new();
302        for (name, oid) in conn.advertised_refs() {
303            if name == ".have" {
304                advertised_haves.push(*oid);
305            } else {
306                remote_refs.insert(name.clone(), *oid);
307            }
308        }
309        let caps = conn.capabilities();
310        Self {
311            remote_refs,
312            advertised_haves,
313            server_sideband: caps
314                .iter()
315                .any(|c| c == "side-band-64k" || c == "side-band"),
316            server_ofs_delta: caps.iter().any(|c| c == "ofs-delta"),
317            server_push_options: caps.iter().any(|c| c == "push-options"),
318        }
319    }
320}
321
322/// A parsed smart-HTTP receive-pack advertisement: protocol version + the shared
323/// [`AdvertisedState`].
324struct ReceivePackAdvertisement {
325    protocol_version: u8,
326    state: AdvertisedState,
327}
328
329/// Discover the `git-receive-pack` advertisement for `repo_url` over an
330/// [`crate::transport::http::HttpClient`] (`GET info/refs?service=git-receive-pack`).
331///
332/// Lifted from the CLI's `http_push_smart.rs` (`discover_receive_pack` /
333/// `read_receive_pack_advertisement`): strips the `# service=…` smart preamble,
334/// detects a v2 capability block, and otherwise parses the v0/v1 ref lines
335/// (capabilities ride the NUL suffix of the first ref line; `.have` lines and the
336/// all-zero capabilities carrier are handled). Hash-width aware via
337/// [`ObjectId::from_hex`].
338fn discover_receive_pack(
339    client: &dyn crate::transport::http::HttpClient,
340    repo_url: &str,
341) -> Result<ReceivePackAdvertisement> {
342    let base = repo_url.trim_end_matches('/');
343    let mut refs_url = format!("{base}/info/refs");
344    refs_url.push_str(if refs_url.contains('?') { "&" } else { "?" });
345    refs_url.push_str("service=");
346    refs_url.push_str(RECEIVE_PACK);
347
348    let body = client.get(&refs_url, None)?;
349    let pkt_body = strip_service_advertisement(&body)?;
350    parse_receive_pack_advertisement(pkt_body)
351}
352
353/// The `git-receive-pack` stateless-RPC endpoint URL for `repo_url`.
354fn receive_pack_url(repo_url: &str) -> String {
355    let base = repo_url.trim_end_matches('/');
356    format!("{base}/{RECEIVE_PACK}")
357}
358
359/// Strip the optional `# service=git-receive-pack\n` pkt-line + flush preamble a
360/// smart-HTTP `info/refs?service=…` response begins with, returning the remaining
361/// advertisement bytes. A dumb server (or raw advertisement) omits it.
362fn strip_service_advertisement(body: &[u8]) -> Result<&[u8]> {
363    let mut cur = Cursor::new(body);
364    match pkt_line::read_packet(&mut cur)? {
365        Some(Packet::Data(line)) if line.starts_with("# service=") => {
366            match pkt_line::read_packet(&mut cur)? {
367                Some(Packet::Flush) | None => {}
368                _ => return Ok(body),
369            }
370            let pos = cur.position() as usize;
371            Ok(&body[pos..])
372        }
373        _ => Ok(body),
374    }
375}
376
377/// Parse a receive-pack advertisement (after the service preamble is stripped)
378/// into a [`ReceivePackAdvertisement`].
379fn parse_receive_pack_advertisement(body: &[u8]) -> Result<ReceivePackAdvertisement> {
380    let mut cur = Cursor::new(body);
381
382    // Peek the first packet to distinguish a v2 capability block from v0/v1.
383    let first = match pkt_line::read_packet(&mut cur)? {
384        None | Some(Packet::Flush) => {
385            return Ok(ReceivePackAdvertisement {
386                protocol_version: 0,
387                state: AdvertisedState {
388                    remote_refs: HashMap::new(),
389                    advertised_haves: Vec::new(),
390                    server_sideband: false,
391                    server_ofs_delta: false,
392                    server_push_options: false,
393                },
394            });
395        }
396        Some(Packet::Data(s)) => s,
397        Some(other) => {
398            return Err(Error::Message(format!(
399                "unexpected first receive-pack advertisement packet: {other:?}"
400            )))
401        }
402    };
403    if first.trim_end() == "version 2" {
404        // A v2 advertisement carries no refs/`.have`s here; capabilities live in
405        // the following lines. We only need the version (push is v0/v1).
406        let mut caps: HashSet<String> = HashSet::new();
407        loop {
408            match pkt_line::read_packet(&mut cur)? {
409                None | Some(Packet::Flush) => break,
410                Some(Packet::Data(s)) => {
411                    caps.insert(s.trim_end().to_owned());
412                }
413                Some(_) => break,
414            }
415        }
416        return Ok(ReceivePackAdvertisement {
417            protocol_version: 2,
418            state: AdvertisedState {
419                remote_refs: HashMap::new(),
420                advertised_haves: Vec::new(),
421                server_sideband: caps
422                    .iter()
423                    .any(|c| c == "side-band-64k" || c == "side-band"),
424                server_ofs_delta: caps.iter().any(|c| c == "ofs-delta"),
425                server_push_options: caps.iter().any(|c| c == "push-options"),
426            },
427        });
428    }
429
430    // v0/v1: rewind and parse the ref lines + `.have`s.
431    cur.set_position(0);
432    let mut remote_refs: HashMap<String, ObjectId> = HashMap::new();
433    let mut advertised_haves: Vec<ObjectId> = Vec::new();
434    let mut caps: HashSet<String> = HashSet::new();
435    let mut first_ref_line = true;
436    let mut protocol_version = 0u8;
437    loop {
438        match pkt_line::read_packet(&mut cur)? {
439            None | Some(Packet::Flush) => break,
440            Some(Packet::Data(line)) => {
441                let line = line.trim_end_matches('\n');
442                if line == "version 1" {
443                    protocol_version = 1;
444                    continue;
445                }
446                if line.starts_with("version ") || line.starts_with("shallow ") {
447                    continue;
448                }
449                let (payload, cap_part) = match line.split_once('\0') {
450                    Some((p, c)) => (p.trim(), Some(c)),
451                    None => (line.trim(), None),
452                };
453                let Some((oid_hex, refname)) =
454                    payload.split_once('\t').or_else(|| payload.split_once(' '))
455                else {
456                    continue;
457                };
458                let oid_hex = oid_hex.trim();
459                let refname = refname.trim();
460                if first_ref_line {
461                    if let Some(raw_caps) = cap_part {
462                        for cap in raw_caps.split_whitespace() {
463                            caps.insert(cap.to_owned());
464                        }
465                    }
466                    first_ref_line = false;
467                }
468                if refname.is_empty() {
469                    continue;
470                }
471                // All-zero OID marks the capabilities-only carrier (empty repo).
472                if oid_hex.bytes().all(|b| b == b'0') {
473                    continue;
474                }
475                let oid = ObjectId::from_hex(oid_hex).map_err(|e| {
476                    Error::Message(format!(
477                        "bad oid in receive-pack advertisement: {oid_hex}: {e}"
478                    ))
479                })?;
480                if refname == ".have" {
481                    advertised_haves.push(oid);
482                } else {
483                    remote_refs.insert(refname.to_owned(), oid);
484                }
485            }
486            Some(other) => {
487                return Err(Error::Message(format!(
488                    "unexpected packet in receive-pack advertisement: {other:?}"
489                )))
490            }
491        }
492    }
493    Ok(ReceivePackAdvertisement {
494        protocol_version,
495        state: AdvertisedState {
496            remote_refs,
497            advertised_haves,
498            server_sideband: caps
499                .iter()
500                .any(|c| c == "side-band-64k" || c == "side-band"),
501            server_ofs_delta: caps.iter().any(|c| c == "ofs-delta"),
502            server_push_options: caps.iter().any(|c| c == "push-options"),
503        },
504    })
505}
506
507/// The accepted, value-changing updates a push will actually send, plus the full
508/// per-ref decision list (so client-rejected/up-to-date refs are still reported).
509struct PushPlan {
510    decisions: Vec<PushDecision>,
511    /// Indices into `decisions` of the updates to send a command for.
512    to_send: Vec<usize>,
513}
514
515/// Outcome of [`plan_push`]: either a [`PushPlan`] to send over the wire, or a
516/// terminal set of results (atomic abort, all up-to-date / client-rejected,
517/// dry-run) that needs no wire round.
518enum PlanOutcome {
519    Send(PushPlan),
520    Done(Vec<PushRefResult>),
521}
522
523/// Decide every [`PushRefSpec`] client-side against the advertised remote refs,
524/// applying the atomic / dry-run / nothing-to-send gates. Shared by
525/// [`push_remote`] and [`push_http`] so both paths reach the wire with an
526/// identical decision set.
527fn plan_push(
528    refs: &[PushRefSpec],
529    local_odb: &crate::odb::Odb,
530    local_git_dir: &Path,
531    adv: &AdvertisedState,
532    opts: &PushOptions,
533) -> Result<PlanOutcome> {
534    let local_repo = crate::repo::Repository::open(local_git_dir, None).ok();
535
536    let mut decisions: Vec<PushDecision> = Vec::with_capacity(refs.len());
537    for spec in refs {
538        decisions.push(decide_push_wire(
539            spec,
540            local_odb,
541            &adv.remote_refs,
542            local_repo.as_ref(),
543        )?);
544    }
545
546    // Atomic: a single client-side rejection aborts the whole push without
547    // sending anything; the otherwise-accepted updates become AtomicPushFailed.
548    let any_rejected = decisions.iter().any(|d| d.result.status.is_error());
549    if opts.atomic && any_rejected {
550        for d in &mut decisions {
551            if matches!(d.result.status, PushRefStatus::Ok) {
552                d.result.status = PushRefStatus::AtomicPushFailed;
553                d.send = false;
554            }
555        }
556        return Ok(PlanOutcome::Done(
557            decisions.into_iter().map(|d| d.result).collect(),
558        ));
559    }
560
561    let to_send: Vec<usize> = decisions
562        .iter()
563        .enumerate()
564        .filter_map(|(i, d)| if d.send { Some(i) } else { None })
565        .collect();
566
567    // Nothing to send (all up-to-date / client-rejected): no wire round needed.
568    if to_send.is_empty() || opts.dry_run {
569        return Ok(PlanOutcome::Done(
570            decisions.into_iter().map(|d| d.result).collect(),
571        ));
572    }
573
574    Ok(PlanOutcome::Send(PushPlan { decisions, to_send }))
575}
576
577/// Reject a push that carries `push_options` when the server's receive-pack did
578/// not advertise the `push-options` capability.
579///
580/// Returns [`Error::PushOptionsUnsupported`] (matching Git's
581/// `fatal: the receiving end does not support push options`) so embedders can
582/// distinguish this negotiation failure without string-matching. A no-op when
583/// `push_options` is empty or the server advertised the capability.
584fn require_push_options_supported(adv: &AdvertisedState, opts: &PushOptions) -> Result<()> {
585    if !opts.push_options.is_empty() && !adv.server_push_options {
586        return Err(Error::PushOptionsUnsupported);
587    }
588    Ok(())
589}
590
591/// Build the ref-update command block: one pkt-line per accepted update plus a
592/// trailing flush. The first command carries the negotiated capability list
593/// after a NUL; the rest are bare. The OID width is the repository's hash
594/// algorithm (zero/null OID for create/delete). Shared by both push paths.
595///
596/// When `push_options` is non-empty, the negotiated capability list includes
597/// `push-options` and, after the command-list flush, one `push-option <value>`
598/// pkt-line per option is written followed by a second flush (matching Git's
599/// `send-pack`: command-list, flush, push-option lines, flush, then pack). The
600/// caller must have already verified the server advertised `push-options`.
601fn build_command_block(
602    plan: &PushPlan,
603    adv: &AdvertisedState,
604    algo: HashAlgo,
605    push_options: &[String],
606) -> Result<Vec<u8>> {
607    let zero_hex = "0".repeat(algo.hex_len());
608    let mut command_caps = PUSH_CAPS_BASE.to_owned();
609    if adv.server_sideband {
610        command_caps.push_str(" side-band-64k");
611    }
612    if !push_options.is_empty() {
613        command_caps.push_str(" push-options");
614    }
615    command_caps.push_str(&format!(" object-format={}", algo.name()));
616
617    let mut commands: Vec<u8> = Vec::new();
618    let mut first = true;
619    for &i in &plan.to_send {
620        let d = &plan.decisions[i];
621        let old_hex = d
622            .result
623            .old_oid
624            .map(|o| o.to_hex())
625            .unwrap_or_else(|| zero_hex.clone());
626        let new_hex = d
627            .result
628            .new_oid
629            .map(|o| o.to_hex())
630            .unwrap_or_else(|| zero_hex.clone());
631        // `write_line_to_vec` appends the pkt-line's trailing newline itself, so
632        // the command payload must NOT carry one of its own; otherwise the bare
633        // (second and later) command lines would frame as `<old> <new> <ref>\n`
634        // and `git-receive-pack` would read a refname with an embedded newline
635        // ("funny refname"). The first line's capability list rides the NUL.
636        let line = if first {
637            first = false;
638            format!(
639                "{old_hex} {new_hex} {}\0{command_caps}",
640                d.result.remote_ref
641            )
642        } else {
643            format!("{old_hex} {new_hex} {}", d.result.remote_ref)
644        };
645        pkt_line::write_line_to_vec(&mut commands, &line)?;
646    }
647    // Flush terminates the command list. When push-options are negotiated, the
648    // option lines follow this flush and are themselves terminated by a second
649    // flush before the pack (per the receive-pack protocol).
650    commands.extend_from_slice(b"0000");
651    if !push_options.is_empty() {
652        for opt in push_options {
653            pkt_line::write_line_to_vec(&mut commands, opt)?;
654        }
655        commands.extend_from_slice(b"0000");
656    }
657    Ok(commands)
658}
659
660/// Build the packfile bytes for a push: a thin, delta-compressed pack of the new
661/// tips minus everything the remote already advertised (its ref tips + `.have`s).
662///
663/// Returns `None` when there is nothing to pack — i.e. a deletion-only push.
664/// Matching `git send-pack` (`send-pack.c`: the pack is written only when
665/// `need_pack_data && cmds_sent`, and `need_pack_data` is set solely for
666/// non-delete updates), no packfile — not even an empty one — is streamed for a
667/// pure deletion. `git-receive-pack` does not read a pack after a delete-only
668/// command block, so sending one leaves unread bytes on the wire and trips a
669/// `ConnectionReset` on the streaming (daemon/ssh) transports. Shared by both
670/// push paths so the wire bytes are identical regardless of transport.
671fn build_push_pack(
672    plan: &PushPlan,
673    local_odb: &crate::odb::Odb,
674    adv: &AdvertisedState,
675) -> Result<Option<Vec<u8>>> {
676    let wants: Vec<ObjectId> = plan
677        .to_send
678        .iter()
679        .filter_map(|&i| plan.decisions[i].new_tip)
680        .collect();
681
682    if wants.is_empty() {
683        return Ok(None);
684    }
685
686    let mut haves: Vec<ObjectId> = adv.remote_refs.values().copied().collect();
687    haves.extend_from_slice(&adv.advertised_haves);
688    // Send a thin, delta-compressed pack: the haves are everything the remote
689    // already advertised, so blob deltas may reference those peer-held bases
690    // without re-sending them (thin), and OFS_DELTA is used only when the server
691    // advertised the `ofs-delta` capability.
692    build_pack(
693        local_odb,
694        &wants,
695        &haves,
696        &PackBuildOptions {
697            thin: true,
698            delta: true,
699            use_ofs_delta: adv.server_ofs_delta,
700            ..PackBuildOptions::default()
701        },
702    )
703    .map(Some)
704}
705
706/// A client-side push decision for one ref, plus what to send over the wire.
707struct PushDecision {
708    result: PushRefResult,
709    /// The new tip object to pack (None for deletions / no-ops).
710    new_tip: Option<ObjectId>,
711    /// Whether to send a ref-update command for this ref to the server.
712    send: bool,
713}
714
715/// Decide one [`PushRefSpec`] against the advertised remote refs, without any
716/// I/O to the remote. Mirrors [`crate::transfer`]'s `decide_push`, but the
717/// "remote current" value comes from the advertisement map rather than an
718/// on-disk remote ref.
719fn decide_push_wire(
720    spec: &PushRefSpec,
721    local_odb: &crate::odb::Odb,
722    remote_refs: &HashMap<String, ObjectId>,
723    local_repo: Option<&crate::repo::Repository>,
724) -> Result<PushDecision> {
725    let remote_current = remote_refs.get(&spec.dst).copied();
726
727    let no_op = |status: PushRefStatus,
728                 old: Option<ObjectId>,
729                 new: Option<ObjectId>,
730                 deletion: bool,
731                 message: Option<String>| {
732        PushDecision {
733            result: PushRefResult {
734                local_ref: None,
735                remote_ref: spec.dst.clone(),
736                old_oid: old,
737                new_oid: new,
738                forced: false,
739                deletion,
740                status,
741                message,
742            },
743            new_tip: None,
744            send: false,
745        }
746    };
747
748    // Up-to-date trumps every lease (creating/moving a ref to where it already
749    // is succeeds, even when a force-with-lease expectation does not hold).
750    if !spec.delete {
751        if let Some(src) = spec.src {
752            if remote_current == Some(src) {
753                return Ok(no_op(
754                    PushRefStatus::UpToDate,
755                    remote_current,
756                    Some(src),
757                    false,
758                    None,
759                ));
760            }
761        }
762    }
763
764    // Absence lease: a destination that already exists fails the lease.
765    if spec.expect_absent && remote_current.is_some() {
766        return Ok(no_op(
767            PushRefStatus::RejectStale,
768            remote_current,
769            spec.src,
770            spec.delete,
771            Some("stale info".to_owned()),
772        ));
773    }
774
775    // Compare-and-swap (force-with-lease): the remote's current value must match.
776    if let Some(expected) = spec.expected_old {
777        if remote_current != Some(expected) {
778            return Ok(no_op(
779                PushRefStatus::RejectStale,
780                remote_current,
781                spec.src,
782                spec.delete,
783                Some("stale info".to_owned()),
784            ));
785        }
786    }
787
788    if spec.delete {
789        // Deleting a ref the remote does not have is a no-op success; otherwise
790        // send the delete command (null new OID) and let the server confirm.
791        return Ok(match remote_current {
792            Some(_) => PushDecision {
793                result: PushRefResult {
794                    local_ref: None,
795                    remote_ref: spec.dst.clone(),
796                    old_oid: remote_current,
797                    new_oid: None,
798                    forced: false,
799                    deletion: true,
800                    status: PushRefStatus::Ok,
801                    message: None,
802                },
803                new_tip: None,
804                send: true,
805            },
806            None => no_op(PushRefStatus::UpToDate, None, None, true, None),
807        });
808    }
809
810    let Some(src) = spec.src else {
811        return Err(Error::Message(format!(
812            "push to '{}' has no source object and is not a deletion",
813            spec.dst
814        )));
815    };
816    if !local_odb.exists(&src) {
817        return Err(Error::Message(format!(
818            "source object {src} for '{}' is missing from the local object store",
819            spec.dst
820        )));
821    }
822
823    // New ref: nothing on the remote yet — always allowed.
824    let Some(old) = remote_current else {
825        return Ok(PushDecision {
826            result: PushRefResult {
827                local_ref: None,
828                remote_ref: spec.dst.clone(),
829                old_oid: None,
830                new_oid: Some(src),
831                forced: false,
832                deletion: false,
833                status: PushRefStatus::Ok,
834                message: None,
835            },
836            new_tip: Some(src),
837            send: true,
838        });
839    };
840
841    // Existing ref: fast-forward when the remote's current commit is an ancestor
842    // of the source; otherwise non-fast-forward (allowed only with force).
843    let is_ff = local_repo
844        .map(|r| crate::merge_base::is_ancestor(r, old, src).unwrap_or(false))
845        .unwrap_or(false);
846
847    if is_ff {
848        Ok(PushDecision {
849            result: PushRefResult {
850                local_ref: None,
851                remote_ref: spec.dst.clone(),
852                old_oid: Some(old),
853                new_oid: Some(src),
854                forced: false,
855                deletion: false,
856                status: PushRefStatus::Ok,
857                message: None,
858            },
859            new_tip: Some(src),
860            send: true,
861        })
862    } else if spec.force {
863        Ok(PushDecision {
864            result: PushRefResult {
865                local_ref: None,
866                remote_ref: spec.dst.clone(),
867                old_oid: Some(old),
868                new_oid: Some(src),
869                forced: true,
870                deletion: false,
871                status: PushRefStatus::Ok,
872                message: None,
873            },
874            new_tip: Some(src),
875            send: true,
876        })
877    } else {
878        Ok(PushDecision {
879            result: PushRefResult {
880                local_ref: None,
881                remote_ref: spec.dst.clone(),
882                old_oid: Some(old),
883                new_oid: Some(src),
884                forced: false,
885                deletion: false,
886                status: PushRefStatus::RejectNonFastForward,
887                message: Some("non-fast-forward".to_owned()),
888            },
889            new_tip: None,
890            send: false,
891        })
892    }
893}
894
895/// Parse the server's `report-status` / `report-status-v2` stream and fold each
896/// per-ref `ok`/`ng` line back into the matching decision.
897///
898/// The report is:
899/// ```text
900/// unpack ok\n            (or `unpack <error>\n`)
901/// ok <ref>\n             (per accepted ref)
902/// ng <ref> <reason>\n    (per rejected ref)
903/// ```
904/// An `ng` line demotes the decided result to [`PushRefStatus::RemoteRejected`]
905/// with the server's reason; an `unpack` failure demotes every sent ref. Lifted
906/// from the CLI's `report_has_rejections`, extended to capture the reason and the
907/// `unpack` status.
908fn apply_report_status(report: &[u8], decisions: &mut [PushDecision]) {
909    let mut by_ref: HashMap<&str, usize> = HashMap::new();
910    for (i, d) in decisions.iter().enumerate() {
911        if d.send {
912            by_ref.insert(d.result.remote_ref.as_str(), i);
913        }
914    }
915    // Resolve indices up front to avoid borrow conflicts while mutating.
916    let mut unpack_error: Option<String> = None;
917    let mut updates: Vec<(usize, Option<String>)> = Vec::new();
918
919    let mut cursor = Cursor::new(report);
920    while let Ok(Some(pkt)) = pkt_line::read_packet(&mut cursor) {
921        let Packet::Data(line) = pkt else {
922            continue;
923        };
924        let line = line.trim_end();
925        if let Some(rest) = line.strip_prefix("unpack ") {
926            if rest.trim() != "ok" {
927                unpack_error = Some(rest.trim().to_owned());
928            }
929        } else if let Some(refname) = line.strip_prefix("ok ") {
930            // Accepted: keep the decided (Ok/UpToDate) status.
931            let _ = by_ref.get(refname.trim());
932        } else if let Some(rest) = line.strip_prefix("ng ") {
933            // `ng <ref> <reason>`: the remote declined this update.
934            let (refname, reason) = rest.split_once(' ').unwrap_or((rest, ""));
935            if let Some(&idx) = by_ref.get(refname.trim()) {
936                let msg = if reason.trim().is_empty() {
937                    None
938                } else {
939                    Some(reason.trim().to_owned())
940                };
941                updates.push((idx, msg));
942            }
943        }
944    }
945
946    for (idx, msg) in updates {
947        decisions[idx].result.status = PushRefStatus::RemoteRejected;
948        decisions[idx].result.message = msg;
949    }
950
951    // A failed `unpack` rejects every ref we sent that the server did not
952    // already mark as failed.
953    if let Some(reason) = unpack_error {
954        for d in decisions.iter_mut() {
955            if d.send && !matches!(d.result.status, PushRefStatus::RemoteRejected) {
956                d.result.status = PushRefStatus::RemoteRejected;
957                d.result.message = Some(format!("unpack failed: {reason}"));
958            }
959        }
960    }
961}
962
963/// Split a side-band stream: band 1 (report-status) is returned; band 2/3
964/// (remote diagnostics) is forwarded to `progress`. Lifted from the CLI's
965/// `demux_report_and_remote_messages`, but progress goes to the callback rather
966/// than directly to stderr (the public API must not assume stdout/stderr).
967fn demux_report_and_remote_messages(input: &[u8], progress: &mut dyn Progress) -> Result<Vec<u8>> {
968    let mut report = Vec::new();
969    let mut i = 0usize;
970    while i + 4 <= input.len() {
971        let len = match pkt_line::parse_hex_len(&input[i..i + 4]) {
972            Ok(l) => l,
973            Err(_) => break,
974        };
975        i += 4;
976        if len == 0 {
977            // Flush packet: a delimiter between report sections, keep scanning.
978            continue;
979        }
980        if len < 4 || i + (len - 4) > input.len() {
981            break;
982        }
983        let payload = &input[i..i + (len - 4)];
984        i += len - 4;
985        if payload.is_empty() {
986            continue;
987        }
988        let band = payload[0];
989        let data = &payload[1..];
990        match band {
991            1 => report.extend_from_slice(data),
992            2 | 3 => progress.message(data),
993            _ => {}
994        }
995    }
996    Ok(report)
997}
998
999/// Peel `oid` to the commit it ultimately names, following annotated tags, using
1000/// the local odb. Returns `None` if it is not a commit (or is missing). Provided
1001/// for symmetry with the CLI's `peel_advertised_commits`; the wire `build_pack`
1002/// uses the advertised ref/`.have` oids directly as `haves`, so this is exposed
1003/// for callers that need commit tips.
1004#[allow(dead_code)]
1005fn peel_to_commit(odb: &crate::odb::Odb, oid: ObjectId) -> Option<ObjectId> {
1006    let mut current = oid;
1007    for _ in 0..16 {
1008        let obj = odb.read(&current).ok()?;
1009        match obj.kind {
1010            ObjectKind::Commit => return Some(current),
1011            ObjectKind::Tag => current = parse_tag(&obj.data).ok()?.object,
1012            _ => return None,
1013        }
1014    }
1015    None
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021
1022    fn make_decision(refname: &str, send: bool) -> PushDecision {
1023        PushDecision {
1024            result: PushRefResult {
1025                local_ref: None,
1026                remote_ref: refname.to_owned(),
1027                old_oid: None,
1028                new_oid: None,
1029                forced: false,
1030                deletion: false,
1031                status: PushRefStatus::Ok,
1032                message: None,
1033            },
1034            new_tip: None,
1035            send,
1036        }
1037    }
1038
1039    fn report_bytes(lines: &[&str]) -> Vec<u8> {
1040        let mut buf = Vec::new();
1041        for l in lines {
1042            pkt_line::write_line_to_vec(&mut buf, l).unwrap();
1043        }
1044        buf.extend_from_slice(b"0000");
1045        buf
1046    }
1047
1048    fn adv_state(sideband: bool, ofs_delta: bool, push_options: bool) -> AdvertisedState {
1049        AdvertisedState {
1050            remote_refs: HashMap::new(),
1051            advertised_haves: Vec::new(),
1052            server_sideband: sideband,
1053            server_ofs_delta: ofs_delta,
1054            server_push_options: push_options,
1055        }
1056    }
1057
1058    /// Decode a command block into a flat list of packets: each `Data(line)`
1059    /// becomes `Some(line)` and each flush becomes `None`, so the framing
1060    /// (command lines / flush / push-option lines / flush) is fully visible.
1061    fn decode_block(block: &[u8]) -> Vec<Option<String>> {
1062        let mut cur = Cursor::new(block);
1063        let mut out = Vec::new();
1064        while let Ok(pkt) = pkt_line::read_packet(&mut cur) {
1065            match pkt {
1066                Some(Packet::Data(s)) => out.push(Some(s.trim_end_matches('\n').to_owned())),
1067                Some(Packet::Flush) => out.push(None),
1068                _ => break,
1069            }
1070        }
1071        out
1072    }
1073
1074    fn send_decision(refname: &str, new_oid: ObjectId) -> PushDecision {
1075        PushDecision {
1076            result: PushRefResult {
1077                local_ref: None,
1078                remote_ref: refname.to_owned(),
1079                old_oid: None,
1080                new_oid: Some(new_oid),
1081                forced: false,
1082                deletion: false,
1083                status: PushRefStatus::Ok,
1084                message: None,
1085            },
1086            new_tip: Some(new_oid),
1087            send: true,
1088        }
1089    }
1090
1091    #[test]
1092    fn command_block_without_push_options_has_no_capability_or_lines() {
1093        let new = ObjectId::from_hex(&"1".repeat(40)).unwrap();
1094        let plan = PushPlan {
1095            decisions: vec![send_decision("refs/heads/main", new)],
1096            to_send: vec![0],
1097        };
1098        let block = build_command_block(&plan, &adv_state(false, false, true), HashAlgo::Sha1, &[])
1099            .unwrap();
1100        let pkts = decode_block(&block);
1101        // command line, then a single terminating flush — nothing else.
1102        assert_eq!(pkts.len(), 2);
1103        let cmd = pkts[0].as_deref().unwrap();
1104        assert!(
1105            cmd.contains("refs/heads/main"),
1106            "first line is the ref command, got {cmd:?}"
1107        );
1108        assert!(
1109            !cmd.contains("push-options"),
1110            "no push-options capability without options, got {cmd:?}"
1111        );
1112        assert_eq!(pkts[1], None, "single trailing flush");
1113    }
1114
1115    #[test]
1116    fn command_block_with_push_options_negotiates_cap_and_emits_lines() {
1117        let new = ObjectId::from_hex(&"1".repeat(40)).unwrap();
1118        let plan = PushPlan {
1119            decisions: vec![send_decision("refs/heads/main", new)],
1120            to_send: vec![0],
1121        };
1122        let opts = vec!["ci.skip".to_owned(), "reviewer=alice".to_owned()];
1123        let block = build_command_block(&plan, &adv_state(true, true, true), HashAlgo::Sha1, &opts)
1124            .unwrap();
1125        let pkts = decode_block(&block);
1126        // command line | flush | push-option ci.skip | push-option reviewer=alice | flush
1127        assert_eq!(
1128            pkts,
1129            vec![
1130                pkts[0].clone(),
1131                None,
1132                Some("ci.skip".to_owned()),
1133                Some("reviewer=alice".to_owned()),
1134                None,
1135            ],
1136            "push-option lines must follow the command-list flush, then a flush"
1137        );
1138        let cmd = pkts[0].as_deref().unwrap();
1139        assert!(
1140            cmd.contains("push-options"),
1141            "capability list must advertise push-options, got {cmd:?}"
1142        );
1143        // The first command line still carries the rest of the negotiated caps.
1144        assert!(cmd.contains("report-status"));
1145        assert!(cmd.contains("side-band-64k"));
1146        assert!(cmd.contains("object-format=sha1"));
1147    }
1148
1149    #[test]
1150    fn require_push_options_errors_typed_when_server_lacks_capability() {
1151        let opts = PushOptions {
1152            push_options: vec!["x".to_owned()],
1153            ..PushOptions::default()
1154        };
1155        // Server did NOT advertise push-options: typed error, not Message.
1156        let err = require_push_options_supported(&adv_state(true, true, false), &opts).unwrap_err();
1157        assert!(
1158            matches!(err, Error::PushOptionsUnsupported),
1159            "expected PushOptionsUnsupported, got {err:?}"
1160        );
1161        assert_eq!(
1162            err.to_string(),
1163            "the receiving end does not support push options"
1164        );
1165        // Server advertised it: ok.
1166        require_push_options_supported(&adv_state(true, true, true), &opts).unwrap();
1167        // No options: ok regardless of capability.
1168        require_push_options_supported(&adv_state(true, true, false), &PushOptions::default())
1169            .unwrap();
1170    }
1171
1172    #[test]
1173    fn receive_pack_url_and_strip_preamble() {
1174        assert_eq!(
1175            receive_pack_url("http://h/r.git/"),
1176            "http://h/r.git/git-receive-pack"
1177        );
1178        // The `# service=…` smart preamble + flush is stripped; the ref bytes remain.
1179        let mut tail = Vec::new();
1180        pkt_line::write_line_to_vec(&mut tail, &format!("{} refs/heads/main", "1".repeat(40)))
1181            .unwrap();
1182        tail.extend_from_slice(b"0000");
1183
1184        let mut body = Vec::new();
1185        pkt_line::write_line_to_vec(&mut body, "# service=git-receive-pack\n").unwrap();
1186        body.extend_from_slice(b"0000");
1187        body.extend_from_slice(&tail);
1188        assert_eq!(strip_service_advertisement(&body).unwrap(), tail.as_slice());
1189        // A body without the preamble is returned verbatim.
1190        assert_eq!(strip_service_advertisement(&tail).unwrap(), tail.as_slice());
1191    }
1192
1193    #[test]
1194    fn parses_v0_receive_pack_advertisement_with_caps_and_have() {
1195        let main = "1".repeat(40);
1196        let have = "2".repeat(40);
1197        let mut body = Vec::new();
1198        // First ref line carries the receive-pack capabilities after a NUL.
1199        pkt_line::write_line_to_vec(
1200            &mut body,
1201            &format!(
1202                "{main} refs/heads/main\0report-status report-status-v2 side-band-64k ofs-delta object-format=sha1"
1203            ),
1204        )
1205        .unwrap();
1206        // A `.have` hint line (object the remote holds, not named by a ref).
1207        pkt_line::write_line_to_vec(&mut body, &format!("{have} .have")).unwrap();
1208        body.extend_from_slice(b"0000");
1209
1210        let adv = parse_receive_pack_advertisement(&body).unwrap();
1211        assert_eq!(adv.protocol_version, 0);
1212        assert!(adv.state.server_sideband);
1213        assert!(adv.state.server_ofs_delta);
1214        assert_eq!(
1215            adv.state
1216                .remote_refs
1217                .get("refs/heads/main")
1218                .map(|o| o.to_hex()),
1219            Some(main.clone())
1220        );
1221        assert_eq!(adv.state.advertised_haves.len(), 1);
1222        assert_eq!(adv.state.advertised_haves[0].to_hex(), have);
1223        // The `.have` carrier is not exposed as a real ref.
1224        assert!(!adv.state.remote_refs.contains_key(".have"));
1225    }
1226
1227    #[test]
1228    fn parses_empty_repo_capabilities_carrier() {
1229        // An empty receive-pack target advertises a single all-zero capabilities
1230        // carrier line; it contributes no refs but still yields the caps.
1231        let zero = "0".repeat(40);
1232        let mut body = Vec::new();
1233        pkt_line::write_line_to_vec(
1234            &mut body,
1235            &format!("{zero} capabilities^{{}}\0report-status delete-refs ofs-delta"),
1236        )
1237        .unwrap();
1238        body.extend_from_slice(b"0000");
1239
1240        let adv = parse_receive_pack_advertisement(&body).unwrap();
1241        assert_eq!(adv.protocol_version, 0);
1242        assert!(adv.state.remote_refs.is_empty());
1243        assert!(adv.state.advertised_haves.is_empty());
1244        assert!(adv.state.server_ofs_delta);
1245        assert!(!adv.state.server_sideband);
1246    }
1247
1248    #[test]
1249    fn detects_v2_receive_pack_advertisement() {
1250        let mut body = Vec::new();
1251        pkt_line::write_line_to_vec(&mut body, "version 2").unwrap();
1252        pkt_line::write_line_to_vec(&mut body, "agent=grit/test").unwrap();
1253        pkt_line::write_line_to_vec(&mut body, "object-format=sha1").unwrap();
1254        body.extend_from_slice(b"0000");
1255        let adv = parse_receive_pack_advertisement(&body).unwrap();
1256        assert_eq!(adv.protocol_version, 2);
1257    }
1258
1259    #[test]
1260    fn report_ng_demotes_to_remote_rejected() {
1261        let mut decisions = vec![
1262            make_decision("refs/heads/main", true),
1263            make_decision("refs/heads/topic", true),
1264        ];
1265        let report = report_bytes(&[
1266            "unpack ok",
1267            "ok refs/heads/main",
1268            "ng refs/heads/topic non-fast-forward",
1269        ]);
1270        apply_report_status(&report, &mut decisions);
1271        assert_eq!(decisions[0].result.status, PushRefStatus::Ok);
1272        assert_eq!(decisions[1].result.status, PushRefStatus::RemoteRejected);
1273        assert_eq!(
1274            decisions[1].result.message.as_deref(),
1275            Some("non-fast-forward")
1276        );
1277    }
1278
1279    #[test]
1280    fn report_unpack_failure_rejects_all_sent() {
1281        let mut decisions = vec![make_decision("refs/heads/main", true)];
1282        let report = report_bytes(&["unpack index-pack abort"]);
1283        apply_report_status(&report, &mut decisions);
1284        assert_eq!(decisions[0].result.status, PushRefStatus::RemoteRejected);
1285        assert!(decisions[0]
1286            .result
1287            .message
1288            .as_deref()
1289            .unwrap()
1290            .starts_with("unpack failed:"));
1291    }
1292
1293    #[test]
1294    fn demux_separates_report_and_progress() {
1295        struct Cap(Vec<u8>);
1296        impl Progress for Cap {
1297            fn message(&mut self, bytes: &[u8]) {
1298                self.0.extend_from_slice(bytes);
1299            }
1300        }
1301        // Band 1 = report, band 2 = progress.
1302        let mut wire = Vec::new();
1303        let mut band1 = vec![1u8];
1304        band1.extend_from_slice(b"unpack ok\n");
1305        pkt_line::write_packet_raw(&mut wire, &band1).unwrap();
1306        let mut band2 = vec![2u8];
1307        band2.extend_from_slice(b"hello from hook\n");
1308        pkt_line::write_packet_raw(&mut wire, &band2).unwrap();
1309        wire.extend_from_slice(b"0000");
1310
1311        let mut cap = Cap(Vec::new());
1312        let report = demux_report_and_remote_messages(&wire, &mut cap).unwrap();
1313        assert_eq!(report, b"unpack ok\n");
1314        assert_eq!(cap.0, b"hello from hook\n");
1315    }
1316}