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