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