Skip to main content

grit_lib/transport/
http.rs

1//! Smart-HTTP Git transport over a pluggable HTTP client.
2//!
3//! This module ports the smart-HTTP fetch protocol from the CLI's
4//! `http_smart.rs` into an embedder-shaped surface:
5//!
6//! * [`HttpClient`] — the minimal request surface the protocol needs: a `GET`
7//!   (used for `info/refs?service=git-upload-pack` discovery) and a `POST`
8//!   (used for the stateless-RPC `git-upload-pack` / `git-receive-pack`
9//!   request body). Embedders supply their own client so grit-lib never forces
10//!   a particular TLS / async / proxy stack on them.
11//! * [`SmartHttpTransport`] — a [`Transport`] that performs the `info/refs`
12//!   discovery on [`Transport::connect`] and exposes the parsed advertisement
13//!   through a [`Connection`].
14//! * [`http_fetch`] — drives the stateless-RPC negotiation (`want`/`have`/`done`
15//!   over repeated POSTs), demultiplexes the side-band pack, ingests it with
16//!   [`crate::unpack_objects`], and returns a [`crate::transfer::FetchOutcome`]
17//!   — reusing the same refspec/tag/prune/classification helpers as the
18//!   in-process and `git://` fetch paths.
19//!
20//! A default [`ureq`]-backed [`HttpClient`] lives in [`crate::transport::http::ureq_client`]
21//! behind the `http-ureq` cargo feature; it wires a [`CredentialProvider`] for
22//! HTTP basic auth on `401`.
23//!
24//! Both protocol v0/v1 (the classic stateless RPC) and protocol v2 (the
25//! stateless multi-POST flow) are implemented here. A v2 server is detected from
26//! the `version 2` capability advertisement returned by `info/refs` (requested
27//! with the `Git-Protocol: version=2` header); [`http_fetch`] then runs the v2
28//! `command=ls-refs` + `command=fetch` rounds as separate POSTs — each round
29//! resends the capability echo, all `want`s, and the accumulated `have`s —
30//! reusing the shared v2 request framing and side-band demuxer from
31//! [`crate::fetch`].
32
33use std::collections::HashSet;
34use std::io::{Cursor, Read, Write};
35use std::path::Path;
36
37use crate::error::{Error, Result};
38use crate::fetch::Progress;
39use crate::fetch_negotiator::SkippingNegotiator;
40use crate::objects::ObjectId;
41use crate::pkt_line;
42use crate::protocol_v2;
43use crate::refspec::{parse_fetch_refspec, RefspecItem};
44use crate::transfer::{
45    classify_update, match_positive, open_odb, prune_tracking_refs, ref_excluded, refspecs_force,
46    FetchOptions, FetchOutcome, RefUpdate, TagMode, UpdateMode,
47};
48use crate::transport::{Advertisement, Connection, ConnectOptions, Service, Transport};
49
50#[cfg(feature = "http-ureq")]
51pub mod ureq_client;
52
53/// The minimal HTTP surface the smart-HTTP transport needs.
54///
55/// Implementations legitimately perform real network I/O; the trait makes no
56/// assumption about the underlying stack (blocking/async, TLS provider, proxy,
57/// cookies), so an embedder can route Git's HTTP through whatever client it
58/// already uses.
59///
60/// The `git_protocol` argument carries the value of the `Git-Protocol` request
61/// header (e.g. `version=2`) when the caller wants to negotiate a protocol
62/// version; pass it through verbatim. A default `Git-Protocol` for every request
63/// may be supplied via [`HttpClient::git_protocol_header`].
64pub trait HttpClient: Send + Sync {
65    /// Issue a `GET` to `url`, returning the response body bytes.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error on a transport failure or a non-success HTTP status.
70    fn get(&self, url: &str, git_protocol: Option<&str>) -> Result<Vec<u8>>;
71
72    /// Issue a `POST` to `url` with the given `content_type`, `accept` header,
73    /// and request `body`, returning the response body bytes.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error on a transport failure or a non-success HTTP status.
78    fn post(
79        &self,
80        url: &str,
81        content_type: &str,
82        accept: &str,
83        body: &[u8],
84        git_protocol: Option<&str>,
85    ) -> Result<Vec<u8>>;
86
87    /// The default `Git-Protocol` request-header value to apply when the caller
88    /// passes `None`. Defaults to no header.
89    fn git_protocol_header(&self) -> Option<&str> {
90        None
91    }
92
93    /// Whether smart-HTTP is enabled (vs. dumb-HTTP fallback). Defaults to
94    /// `true`; embedders that honor `GIT_SMART_HTTP=0` may return `false`.
95    fn smart_http_enabled(&self) -> bool {
96        true
97    }
98}
99
100/// Forward [`HttpClient`] through a shared [`std::sync::Arc`], so one client can
101/// back several transports (and be observed by the caller) without moving it.
102impl<C: HttpClient> HttpClient for std::sync::Arc<C> {
103    fn get(&self, url: &str, git_protocol: Option<&str>) -> Result<Vec<u8>> {
104        (**self).get(url, git_protocol)
105    }
106
107    fn post(
108        &self,
109        url: &str,
110        content_type: &str,
111        accept: &str,
112        body: &[u8],
113        git_protocol: Option<&str>,
114    ) -> Result<Vec<u8>> {
115        (**self).post(url, content_type, accept, body, git_protocol)
116    }
117
118    fn git_protocol_header(&self) -> Option<&str> {
119        (**self).git_protocol_header()
120    }
121
122    fn smart_http_enabled(&self) -> bool {
123        (**self).smart_http_enabled()
124    }
125}
126
127const UPLOAD_PACK: &str = "git-upload-pack";
128
129/// Strip the optional `# service=...\n` pkt-line + flush preamble that a
130/// smart-HTTP `info/refs?service=...` response begins with, returning the
131/// remaining advertisement bytes.
132///
133/// A smart server prefixes the advertisement with `001e# service=git-upload-pack\n`
134/// followed by a `0000` flush; a dumb server (or a raw `upload-pack
135/// --advertise-refs` body) omits it. Lifted from the CLI's
136/// `strip_v0_service_advertisement_if_present`.
137fn strip_service_advertisement(body: &[u8]) -> Result<&[u8]> {
138    let mut cur = Cursor::new(body);
139    let start = cur.position();
140    match pkt_line::read_packet(&mut cur)? {
141        Some(pkt_line::Packet::Data(line)) if line.starts_with("# service=") => {
142            // Consume the trailing flush after the service header.
143            match pkt_line::read_packet(&mut cur)? {
144                Some(pkt_line::Packet::Flush) | None => {}
145                _ => {
146                    // No flush after the service line: not a smart preamble; rewind.
147                    return Ok(body);
148                }
149            }
150            let pos = cur.position() as usize;
151            Ok(&body[pos..])
152        }
153        _ => {
154            cur.set_position(start);
155            Ok(body)
156        }
157    }
158}
159
160/// A parsed v0/v1 advertisement ref entry (name -> oid).
161#[derive(Clone, Debug)]
162struct AdvRef {
163    name: String,
164    oid: ObjectId,
165}
166
167/// The discovery outcome: protocol version, advertised refs, capabilities, and
168/// the symref target for `HEAD` (if any).
169struct Discovery {
170    protocol_version: u8,
171    refs: Vec<AdvRef>,
172    caps: HashSet<String>,
173    head_symref: Option<String>,
174    object_format: String,
175}
176
177/// Parse a v0/v1 ref advertisement (after the service preamble is stripped).
178///
179/// Hash-width aware via [`ObjectId::from_hex`]. Capabilities ride on the NUL
180/// suffix of the first ref line; the `symref=HEAD:<target>` capability records
181/// the default branch. The all-zero "unborn HEAD" carrier and `shallow`
182/// trailers are skipped. Lifted from the CLI's `parse_v0_v1_advertisement` /
183/// `discover_http_protocol`.
184fn parse_advertisement(body: &[u8]) -> Result<Discovery> {
185    let mut cur = Cursor::new(body);
186
187    // Peek the first packet to distinguish v2 from v0/v1.
188    let first = match pkt_line::read_packet(&mut cur)? {
189        None | Some(pkt_line::Packet::Flush) => {
190            // Empty advertisement (empty repo on an older server): no refs.
191            return Ok(Discovery {
192                protocol_version: 0,
193                refs: Vec::new(),
194                caps: HashSet::new(),
195                head_symref: None,
196                object_format: "sha1".to_owned(),
197            });
198        }
199        Some(pkt_line::Packet::Data(s)) => s,
200        Some(other) => {
201            return Err(Error::Message(format!(
202                "unexpected first advertisement packet: {other:?}"
203            )))
204        }
205    };
206    if first.trim_end() == "version 2" {
207        // Detect v2 so the caller can report it as unsupported in this pass.
208        let mut caps = HashSet::new();
209        loop {
210            match pkt_line::read_packet(&mut cur)? {
211                None | Some(pkt_line::Packet::Flush) => break,
212                Some(pkt_line::Packet::Data(s)) => {
213                    caps.insert(s.trim_end().to_owned());
214                }
215                Some(_) => break,
216            }
217        }
218        let object_format = caps
219            .iter()
220            .find_map(|c| c.strip_prefix("object-format="))
221            .unwrap_or("sha1")
222            .to_owned();
223        return Ok(Discovery {
224            protocol_version: 2,
225            refs: Vec::new(),
226            caps,
227            head_symref: None,
228            object_format,
229        });
230    }
231
232    // v0/v1: rewind and parse the ref lines.
233    cur.set_position(0);
234    let mut refs = Vec::new();
235    let mut caps: HashSet<String> = HashSet::new();
236    let mut head_symref = None;
237    let mut first_ref_line = true;
238    loop {
239        match pkt_line::read_packet(&mut cur)? {
240            None | Some(pkt_line::Packet::Flush) => break,
241            Some(pkt_line::Packet::Data(line)) => {
242                let line = line.trim_end_matches('\n');
243                if line.starts_with("version ") {
244                    continue;
245                }
246                if line.starts_with("shallow ") || line.starts_with("unshallow ") {
247                    continue;
248                }
249                let (payload, cap_part) = match line.split_once('\0') {
250                    Some((p, c)) => (p.trim(), Some(c)),
251                    None => (line.trim(), None),
252                };
253                let Some((oid_hex, refname)) =
254                    payload.split_once('\t').or_else(|| payload.split_once(' '))
255                else {
256                    continue;
257                };
258                let oid_hex = oid_hex.trim();
259                let refname = refname.trim();
260                if first_ref_line {
261                    if let Some(raw_caps) = cap_part {
262                        for cap in raw_caps.split_whitespace() {
263                            if let Some(target) = cap.strip_prefix("symref=HEAD:") {
264                                head_symref = Some(target.to_owned());
265                            }
266                            caps.insert(cap.to_owned());
267                        }
268                    }
269                    first_ref_line = false;
270                }
271                if refname.is_empty() {
272                    continue;
273                }
274                // All-zero OID marks the unborn-HEAD capabilities carrier (empty repo).
275                if oid_hex.bytes().all(|b| b == b'0') {
276                    continue;
277                }
278                let oid = ObjectId::from_hex(oid_hex).map_err(|e| {
279                    Error::Message(format!("bad oid in advertisement: {oid_hex}: {e}"))
280                })?;
281                refs.push(AdvRef {
282                    name: refname.to_owned(),
283                    oid,
284                });
285            }
286            Some(other) => {
287                return Err(Error::Message(format!(
288                    "unexpected packet in advertisement: {other:?}"
289                )))
290            }
291        }
292    }
293    let object_format = caps
294        .iter()
295        .find_map(|c| c.strip_prefix("object-format="))
296        .unwrap_or("sha1")
297        .to_owned();
298    Ok(Discovery {
299        protocol_version: if caps.contains("version 1") { 1 } else { 0 },
300        refs,
301        caps,
302        head_symref,
303        object_format,
304    })
305}
306
307/// Build the `info/refs?service=git-upload-pack` discovery URL for `repo_url`.
308fn info_refs_url(repo_url: &str) -> String {
309    let base = repo_url.trim_end_matches('/');
310    let mut url = format!("{base}/info/refs");
311    url.push_str(if url.contains('?') { "&" } else { "?" });
312    url.push_str("service=");
313    url.push_str(UPLOAD_PACK);
314    url
315}
316
317/// The `git-upload-pack` stateless-RPC endpoint URL for `repo_url`.
318fn upload_pack_url(repo_url: &str) -> String {
319    let base = repo_url.trim_end_matches('/');
320    format!("{base}/{UPLOAD_PACK}")
321}
322
323/// A live smart-HTTP connection: the parsed advertisement plus the context
324/// needed to issue the stateless-RPC POST. Smart HTTP is request/response, so
325/// there is no persistent duplex socket — the `reader`/`writer` accessors are
326/// not used by [`http_fetch`], which drives the POST loop directly.
327///
328/// `reader`/`writer` return empty/sink streams; embedders that want to drive a
329/// custom negotiation should use [`http_fetch`] (or read the advertisement via
330/// the accessors and POST through their [`HttpClient`]).
331pub struct SmartHttpConnection {
332    repo_url: String,
333    adv_refs: Vec<(String, ObjectId)>,
334    caps: Vec<String>,
335    head_symref: Option<String>,
336    protocol_version: u8,
337    object_format: String,
338    // Held so embedders/tests can identify which service this connection speaks.
339    service: Service,
340    empty_reader: Cursor<Vec<u8>>,
341    sink: Vec<u8>,
342}
343
344impl SmartHttpConnection {
345    /// The repository URL this connection targets.
346    #[must_use]
347    pub fn repo_url(&self) -> &str {
348        &self.repo_url
349    }
350
351    /// The server's advertised object format (`sha1` or `sha256`).
352    #[must_use]
353    pub fn object_format(&self) -> &str {
354        &self.object_format
355    }
356
357    /// The service this connection speaks.
358    #[must_use]
359    pub fn service(&self) -> Service {
360        self.service
361    }
362}
363
364impl Connection for SmartHttpConnection {
365    fn reader(&mut self) -> &mut dyn Read {
366        &mut self.empty_reader
367    }
368
369    fn writer(&mut self) -> &mut dyn Write {
370        &mut self.sink
371    }
372
373    fn advertised_refs(&self) -> &[(String, ObjectId)] {
374        &self.adv_refs
375    }
376
377    fn capabilities(&self) -> &[String] {
378        &self.caps
379    }
380
381    fn head_symref(&self) -> Option<&str> {
382        self.head_symref.as_deref()
383    }
384
385    fn protocol_version(&self) -> u8 {
386        self.protocol_version
387    }
388}
389
390/// A smart-HTTP [`Transport`] over a pluggable [`HttpClient`].
391///
392/// [`Transport::connect`] performs the `info/refs?service=git-upload-pack`
393/// discovery GET and parses the advertisement; the returned [`Connection`]
394/// exposes the advertised refs/capabilities. Use [`http_fetch`] to drive the
395/// fetch negotiation over the same client.
396pub struct SmartHttpTransport<C: HttpClient> {
397    client: C,
398}
399
400impl<C: HttpClient> SmartHttpTransport<C> {
401    /// Build a transport backed by `client`.
402    pub fn new(client: C) -> Self {
403        Self { client }
404    }
405
406    /// Borrow the underlying HTTP client.
407    pub fn client(&self) -> &C {
408        &self.client
409    }
410
411    /// Push `refs` to `repo_url` over smart HTTP (`git-receive-pack`), returning a
412    /// [`crate::transfer::PushOutcome`].
413    ///
414    /// This is the push counterpart to [`http_fetch`]: it discovers the
415    /// receive-pack advertisement, decides each update, builds the command block +
416    /// pack, POSTs `git-receive-pack`, and parses the `report-status` reply —
417    /// reusing the same decision/pack/report machinery as the duplex
418    /// [`crate::push::push_remote`]. Delegates to [`crate::push::push_http`].
419    ///
420    /// Protocol v0/v1 only (a v2 receive-pack advertisement is rejected).
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if discovery fails, the advertisement is protocol v2, a
425    /// source object is missing locally, the pack build fails, or on wire/parse
426    /// I/O failure.
427    pub fn push(
428        &self,
429        local_git_dir: &Path,
430        repo_url: &str,
431        refs: &[crate::transfer::PushRefSpec],
432        opts: &crate::transfer::PushOptions,
433        progress: &mut dyn Progress,
434    ) -> Result<crate::transfer::PushOutcome> {
435        crate::push::push_http(&self.client, local_git_dir, repo_url, refs, opts, progress)
436    }
437
438    /// Perform the `info/refs` discovery for `repo_url` and `service`, returning
439    /// the parsed [`Discovery`].
440    ///
441    /// `git_protocol` is the `Git-Protocol` request-header value to apply (e.g.
442    /// `version=2` to request a v2 advertisement); when `None`, the client's
443    /// default ([`HttpClient::git_protocol_header`]) is used.
444    fn discover(
445        &self,
446        repo_url: &str,
447        _service: Service,
448        git_protocol: Option<&str>,
449    ) -> Result<Discovery> {
450        let url = info_refs_url(repo_url);
451        let gp = git_protocol.or_else(|| self.client.git_protocol_header());
452        let body = self.client.get(&url, gp)?;
453        let stripped = strip_service_advertisement(&body)?;
454        parse_advertisement(stripped)
455    }
456}
457
458/// The `Git-Protocol` request-header value for a requested protocol version, or
459/// `None` for v0 (no header — the classic advertisement).
460fn git_protocol_for_version(version: u8) -> Option<String> {
461    if version >= 1 {
462        Some(format!("version={version}"))
463    } else {
464        None
465    }
466}
467
468impl<C: HttpClient> Transport for SmartHttpTransport<C> {
469    fn connect(
470        &self,
471        url: &str,
472        service: Service,
473        opts: &ConnectOptions,
474    ) -> Result<Box<dyn Connection>> {
475        // Request the protocol version the caller asked for via the
476        // `Git-Protocol` header (a v2 server only returns its v2 capability
477        // advertisement when it sees `version=2`); fall back to the client's
478        // default header otherwise. The server may still downgrade.
479        let gp = git_protocol_for_version(opts.protocol_version);
480        let disc = self.discover(url, service, gp.as_deref())?;
481        let adv_refs: Vec<(String, ObjectId)> = disc
482            .refs
483            .iter()
484            .filter(|r| r.name != "HEAD" && !r.name.ends_with("^{}"))
485            .map(|r| (r.name.clone(), r.oid))
486            .collect();
487        let caps: Vec<String> = disc.caps.iter().cloned().collect();
488        Ok(Box::new(SmartHttpConnection {
489            repo_url: url.to_owned(),
490            adv_refs,
491            caps,
492            head_symref: disc.head_symref,
493            protocol_version: disc.protocol_version,
494            object_format: disc.object_format,
495            service,
496            empty_reader: Cursor::new(Vec::new()),
497            sink: Vec::new(),
498        }))
499    }
500}
501
502/// Read a length-prefixed pkt-line payload, returning `None` on flush/delim/EOF.
503fn read_pkt_payload(r: &mut impl Read) -> std::io::Result<Option<Vec<u8>>> {
504    let mut len_buf = [0u8; 4];
505    match r.read_exact(&mut len_buf) {
506        Ok(()) => {}
507        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
508        Err(e) => return Err(e),
509    }
510    let len_str = std::str::from_utf8(&len_buf)
511        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
512    let len = usize::from_str_radix(len_str, 16)
513        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
514    match len {
515        0..=2 => Ok(None),
516        n if n <= 4 => Err(std::io::Error::new(
517            std::io::ErrorKind::InvalidData,
518            format!("invalid pkt-line length: {n}"),
519        )),
520        n => {
521            let mut buf = vec![0u8; n - 4];
522            r.read_exact(&mut buf)?;
523            Ok(Some(buf))
524        }
525    }
526}
527
528/// Kind of `ACK` status suffix in a v0 negotiation response.
529#[derive(Clone, Copy, PartialEq, Eq)]
530enum AckKind {
531    /// `ACK <oid>` with no status suffix (ends a round / post-`done`).
532    Bare,
533    /// `ACK <oid> common` — the server holds this commit; replay it on the next
534    /// stateless RPC if we had not already marked it common.
535    Common,
536    /// `ACK <oid> continue` — recorded in the negotiator but not replayed.
537    Continue,
538    /// `ACK <oid> ready` — the server has enough; it will send the pack.
539    Ready,
540}
541
542struct Ack {
543    oid: ObjectId,
544    kind: AckKind,
545}
546
547fn parse_ack(line: &str) -> Option<Ack> {
548    let rest = line.strip_prefix("ACK ")?;
549    let hex = rest.split_whitespace().next()?;
550    let oid = ObjectId::from_hex(hex).ok()?;
551    let tail = rest.strip_prefix(hex).unwrap_or("").trim();
552    let kind = if tail.contains("continue") {
553        AckKind::Continue
554    } else if tail.contains("common") {
555        AckKind::Common
556    } else if tail.contains("ready") {
557        AckKind::Ready
558    } else {
559        AckKind::Bare
560    };
561    Some(Ack { oid, kind })
562}
563
564/// Result of parsing one stateless-RPC response.
565struct RoundResult {
566    acks: Vec<Ack>,
567    got_pack: bool,
568    /// Shallow boundaries the server reported (`shallow <oid>`) in this response's
569    /// leading `shallow-info` section (empty unless a deepen was requested).
570    shallow: Vec<ObjectId>,
571    /// Boundaries the server un-shallowed (`unshallow <oid>`) in this response.
572    unshallow: Vec<ObjectId>,
573}
574
575/// Demultiplex the side-band pack from a stateless-RPC response, appending pack
576/// bytes to `out` and forwarding channel-2 progress. Mirrors the CLI's
577/// `read_sideband_pack_until_done`.
578fn read_sideband_pack(
579    r: &mut impl Read,
580    out: &mut Vec<u8>,
581    progress: &mut dyn Progress,
582) -> Result<()> {
583    let mut seen_pack = false;
584    let mut pending: Vec<u8> = Vec::new();
585    loop {
586        let mut len_buf = [0u8; 4];
587        match r.read_exact(&mut len_buf) {
588            Ok(()) => {}
589            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
590            Err(e) => return Err(e.into()),
591        }
592        let len_str = std::str::from_utf8(&len_buf)
593            .map_err(|_| Error::Message("bad pkt length".to_owned()))?;
594        let len = usize::from_str_radix(len_str, 16)
595            .map_err(|_| Error::Message("bad pkt length".to_owned()))?;
596        match len {
597            0 => {
598                if seen_pack {
599                    break;
600                }
601                continue;
602            }
603            1 | 2 => continue,
604            n if n <= 4 => {
605                return Err(Error::Message(format!(
606                    "invalid pkt-line length in side-band stream: {n}"
607                )))
608            }
609            _ => {}
610        }
611        let mut payload = vec![0u8; len - 4];
612        r.read_exact(&mut payload)?;
613        if payload.is_empty() {
614            continue;
615        }
616        match payload[0] {
617            1 => append_pack_data(&payload[1..], out, &mut pending, &mut seen_pack),
618            2 => progress.message(&payload[1..]),
619            3 => {
620                return Err(Error::Message(format!(
621                    "remote error: {}",
622                    String::from_utf8_lossy(&payload[1..]).trim_end()
623                )))
624            }
625            _ => append_pack_data(&payload, out, &mut pending, &mut seen_pack),
626        }
627    }
628    Ok(())
629}
630
631/// Append channel-1 (or raw) data to `out`, scanning for the `PACK` magic that
632/// may straddle chunk boundaries.
633fn append_pack_data(data: &[u8], out: &mut Vec<u8>, pending: &mut Vec<u8>, seen_pack: &mut bool) {
634    if *seen_pack {
635        out.extend_from_slice(data);
636        return;
637    }
638    pending.extend_from_slice(data);
639    if let Some(pos) = pending.windows(4).position(|w| w == b"PACK") {
640        *seen_pack = true;
641        out.extend_from_slice(&pending[pos..]);
642        pending.clear();
643    } else if pending.len() > 3 {
644        let keep_from = pending.len() - 3;
645        pending.drain(..keep_from);
646    }
647}
648
649/// Parse one v0 stateless-RPC `git-upload-pack` response: an optional leading
650/// `shallow-info` section (only when `expect_shallow`, i.e. a deepen was
651/// requested), then optional `ACK`/`NAK` negotiation lines, then (if the server
652/// is generating one) the side-band pack.
653fn read_stateless_response(
654    resp: &[u8],
655    sideband: bool,
656    expect_shallow: bool,
657    pack_buf: &mut Vec<u8>,
658    progress: &mut dyn Progress,
659) -> Result<RoundResult> {
660    let mut cur = Cursor::new(resp);
661    let mut acks = Vec::new();
662    let mut got_pack = false;
663    let mut shallow = Vec::new();
664    let mut unshallow = Vec::new();
665
666    // Shallow-info section: `shallow`/`unshallow` lines terminated by a flush. A
667    // server with nothing to report still emits the trailing flush. Rewind and
668    // fall through if the first line is not a shallow-info line (no section).
669    if expect_shallow {
670        loop {
671            let start = cur.position() as usize;
672            match pkt_line::read_packet(&mut cur)? {
673                None | Some(pkt_line::Packet::Flush) => break,
674                Some(pkt_line::Packet::Data(line)) => {
675                    let line = line.trim_end_matches('\n');
676                    if let Some(rest) = line.strip_prefix("shallow ") {
677                        if let Ok(oid) = ObjectId::from_hex(rest.trim()) {
678                            shallow.push(oid);
679                        }
680                    } else if let Some(rest) = line.strip_prefix("unshallow ") {
681                        if let Ok(oid) = ObjectId::from_hex(rest.trim()) {
682                            unshallow.push(oid);
683                        }
684                    } else {
685                        cur.set_position(start as u64);
686                        break;
687                    }
688                }
689                Some(_) => break,
690            }
691        }
692    }
693
694    loop {
695        let start = cur.position() as usize;
696        let Some(payload) = read_pkt_payload(&mut cur)? else {
697            break;
698        };
699        if payload.is_empty() {
700            continue;
701        }
702        let is_pack = (sideband
703            && payload.first() == Some(&1)
704            && payload.get(1..5) == Some(b"PACK"))
705            || payload.starts_with(b"PACK");
706        if is_pack {
707            got_pack = true;
708            cur.set_position(start as u64);
709            if sideband {
710                read_sideband_pack(&mut cur, pack_buf, progress)?;
711            } else {
712                pack_buf.extend_from_slice(&resp[start..]);
713            }
714            break;
715        }
716        let text = String::from_utf8_lossy(&payload);
717        let line = text.trim_end_matches('\n');
718        if let Some(err) = line.strip_prefix("ERR ") {
719            return Err(Error::Message(format!("remote upload-pack error: {err}")));
720        }
721        if line == "NAK" {
722            continue;
723        }
724        if let Some(ack) = parse_ack(line) {
725            acks.push(ack);
726        }
727    }
728    Ok(RoundResult {
729        acks,
730        got_pack,
731        shallow,
732        unshallow,
733    })
734}
735
736/// The v0/v1 fetch capabilities we request, intersected with what the server
737/// advertised. Mirrors `build_fetch_caps_v0`.
738fn build_fetch_caps(caps: &HashSet<String>) -> String {
739    let mut enabled = Vec::new();
740    let multi_ack_detailed = caps.contains("multi_ack_detailed");
741    if multi_ack_detailed {
742        enabled.push("multi_ack_detailed");
743    }
744    if multi_ack_detailed && caps.contains("no-done") {
745        enabled.push("no-done");
746    }
747    for want in [
748        "side-band-64k",
749        "thin-pack",
750        "no-progress",
751        "include-tag",
752        "ofs-delta",
753    ] {
754        if caps.contains(want) {
755            enabled.push(want);
756        }
757    }
758    if enabled.is_empty() {
759        String::new()
760    } else {
761        format!(" {}", enabled.join(" "))
762    }
763}
764
765/// Next stateless-RPC `have` batch size (mirrors `fetch-pack.c` `next_flush`).
766fn next_flush(count: usize) -> usize {
767    const LARGE_FLUSH: usize = 16384;
768    if count < LARGE_FLUSH {
769        count * 2
770    } else {
771        count * 11 / 10
772    }
773}
774
775/// Append the v0/v1 shallow/deepen request lines (the client's `shallow <oid>`
776/// grafts and any `deepen`/`deepen-since`/`deepen-not`) to the persistent request
777/// `state`, gated on the server capability where one exists. Mirrors the CLI's
778/// `append_fetch_request_extensions_v0_v1`.
779fn append_shallow_request_v0_http(
780    req: &mut Vec<u8>,
781    caps: &HashSet<String>,
782    local_shallow: &[ObjectId],
783    opts: &FetchOptions,
784) -> Result<()> {
785    for oid in local_shallow {
786        pkt_line::write_line_to_vec(req, &format!("shallow {}", oid.to_hex()))?;
787    }
788    if opts.unshallow {
789        pkt_line::write_line_to_vec(req, &format!("deepen {}", crate::shallow::INFINITE_DEPTH))?;
790    } else if let Some(depth) = opts.depth.filter(|d| *d > 0) {
791        pkt_line::write_line_to_vec(req, &format!("deepen {depth}"))?;
792    }
793    if let Some(since) = opts.deepen_since.as_deref().filter(|s| !s.trim().is_empty()) {
794        if caps.contains("deepen-since") {
795            let value = crate::shallow::deepen_since_wire_value(since);
796            pkt_line::write_line_to_vec(req, &format!("deepen-since {value}"))?;
797        }
798    }
799    if caps.contains("deepen-not") {
800        for excl in &opts.deepen_not {
801            let excl = excl.trim();
802            if !excl.is_empty() {
803                pkt_line::write_line_to_vec(req, &format!("deepen-not {excl}"))?;
804            }
805        }
806    }
807    Ok(())
808}
809
810/// Negotiate and download the pack for `wants` over stateless-RPC HTTP,
811/// returning the raw pack bytes (empty if the server sent none) plus any
812/// shallow-boundary updates the server reported.
813fn negotiate_pack_http(
814    client: &dyn HttpClient,
815    local_git_dir: &Path,
816    repo_url: &str,
817    caps: &HashSet<String>,
818    advertised: &[AdvRef],
819    wants: &[ObjectId],
820    opts: &FetchOptions,
821    local_shallow: &[ObjectId],
822    progress: &mut dyn Progress,
823) -> Result<(Vec<u8>, crate::fetch::ShallowUpdate)> {
824    let post_url = upload_pack_url(repo_url);
825    let content_type = format!("application/x-{UPLOAD_PACK}-request");
826    let accept = format!("application/x-{UPLOAD_PACK}-result");
827    let fetch_caps = build_fetch_caps(caps);
828    let sideband = caps.contains("side-band-64k");
829    let multi_ack_detailed = caps.contains("multi_ack_detailed");
830    let no_done = multi_ack_detailed && caps.contains("no-done");
831
832    // A deepen/shallow request precedes the pack with a `shallow-info` section and
833    // does not offer local haves (its objects bottom out at grafts).
834    let shallow_request = opts.has_deepen_request() || !local_shallow.is_empty();
835
836    let want_set: HashSet<ObjectId> = wants.iter().copied().collect();
837
838    // Build the persistent request prefix replayed on every RPC: the want lines
839    // (capabilities on the first), the shallow/deepen extensions, and the
840    // terminating flush.
841    let mut state = Vec::new();
842    let first = wants[0];
843    pkt_line::write_line_to_vec(&mut state, &format!("want {}{}", first.to_hex(), fetch_caps))?;
844    for w in wants.iter().skip(1) {
845        pkt_line::write_line_to_vec(&mut state, &format!("want {}", w.to_hex()))?;
846    }
847    append_shallow_request_v0_http(&mut state, caps, local_shallow, opts)?;
848    pkt_line::write_flush(&mut state)?;
849
850    let mut shallow_update = crate::fetch::ShallowUpdate::default();
851
852    // Build the negotiator from local tips, marking advertised tips we already
853    // have as known-common. Skipped for a shallow request.
854    let local_repo = crate::repo::Repository::open(local_git_dir, None)?;
855    let mut negotiator = SkippingNegotiator::new(local_repo);
856    if !shallow_request {
857        for w in wants {
858            if negotiator.repo().odb.read(w).is_ok() {
859                negotiator.add_tip(*w)?;
860            }
861        }
862        let mut tips: Vec<ObjectId> = Vec::new();
863        for prefix in ["refs/heads/", "refs/tags/"] {
864            if let Ok(entries) = crate::refs::list_refs(local_git_dir, prefix) {
865                for (_, oid) in entries {
866                    if negotiator.repo().odb.read(&oid).is_ok() {
867                        tips.push(oid);
868                    }
869                }
870            }
871        }
872        if let Ok(h) = crate::refs::resolve_ref(local_git_dir, "HEAD") {
873            if negotiator.repo().odb.read(&h).is_ok() {
874                tips.push(h);
875            }
876        }
877        tips.sort_by_key(ObjectId::to_hex);
878        tips.dedup();
879        for t in tips {
880            if want_set.contains(&t) {
881                continue;
882            }
883            negotiator.add_tip(t)?;
884        }
885        for e in advertised {
886            if want_set.contains(&e.oid) {
887                continue;
888            }
889            if negotiator.repo().odb.read(&e.oid).is_ok() {
890                negotiator.known_common(e.oid)?;
891            }
892        }
893    }
894
895    let mut pack_buf: Vec<u8> = Vec::new();
896    let mut got_ready = false;
897    let mut got_pack = false;
898    let mut shallow_applied = false;
899
900    const INITIAL_FLUSH: usize = 16;
901    let mut count: usize = 0;
902    let mut flush_at: usize = INITIAL_FLUSH;
903    let mut round = Vec::new();
904    // The negotiator is empty for a shallow request, so this loop is skipped and
905    // the single `done` RPC below carries the wants + shallow lines.
906    while let Some(oid) = negotiator.next_have()? {
907        pkt_line::write_line_to_vec(&mut round, &format!("have {}", oid.to_hex()))?;
908        count += 1;
909        if count < flush_at {
910            continue;
911        }
912        flush_at = next_flush(count);
913
914        let mut req = state.clone();
915        req.extend_from_slice(&round);
916        pkt_line::write_flush(&mut req)?;
917        round.clear();
918
919        let resp = client.post(&post_url, &content_type, &accept, &req, None)?;
920        let round_result =
921            read_stateless_response(&resp, sideband, shallow_request, &mut pack_buf, progress)?;
922        if shallow_request && !shallow_applied {
923            shallow_update.shallow.extend(round_result.shallow.iter().copied());
924            shallow_update.unshallow.extend(round_result.unshallow.iter().copied());
925            shallow_applied = true;
926        }
927        for ack in &round_result.acks {
928            if matches!(ack.kind, AckKind::Bare) {
929                continue;
930            }
931            let was_common = negotiator.ack(ack.oid)?;
932            if matches!(ack.kind, AckKind::Common) && !was_common {
933                pkt_line::write_line_to_vec(&mut state, &format!("have {}", ack.oid.to_hex()))?;
934            }
935            if matches!(ack.kind, AckKind::Ready) {
936                got_ready = true;
937            }
938        }
939        if round_result.got_pack {
940            got_pack = true;
941            break;
942        }
943        if got_ready {
944            break;
945        }
946    }
947
948    // Final RPC ending in `done`, unless the pack already arrived with
949    // `ACK ... ready` under `no-done`.
950    if !(got_pack || got_ready && no_done) {
951        let mut req = state.clone();
952        pkt_line::write_line_to_vec(&mut req, "done")?;
953        pkt_line::write_flush(&mut req)?;
954        let resp = client.post(&post_url, &content_type, &accept, &req, None)?;
955        let round_result =
956            read_stateless_response(&resp, sideband, shallow_request, &mut pack_buf, progress)?;
957        if shallow_request && !shallow_applied {
958            shallow_update.shallow.extend(round_result.shallow);
959            shallow_update.unshallow.extend(round_result.unshallow);
960        }
961    }
962
963    Ok((pack_buf, shallow_update))
964}
965
966/// Resolve the `wants` for a fetch from the advertised refs and the matched set.
967///
968/// Returns the matched ref records (for later ref-update classification) and the
969/// set of wanted oids.
970struct MatchPlan {
971    matched: Vec<crate::transfer::MatchedRef>,
972    wants: HashSet<ObjectId>,
973    seen: HashSet<String>,
974}
975
976fn match_refspecs(
977    remote_refs: &[(String, ObjectId)],
978    positive: &[RefspecItem],
979    negatives: &[RefspecItem],
980) -> MatchPlan {
981    let mut matched: Vec<crate::transfer::MatchedRef> = Vec::new();
982    let mut wants: HashSet<ObjectId> = HashSet::new();
983    let mut seen: HashSet<String> = HashSet::new();
984    for (name, oid) in remote_refs {
985        if ref_excluded(name, negatives) {
986            continue;
987        }
988        if let Some(local_ref) = match_positive(name, positive) {
989            if seen.insert(name.clone()) {
990                wants.insert(*oid);
991                matched.push(crate::transfer::MatchedRef {
992                    remote_ref: name.clone(),
993                    local_ref,
994                    oid: *oid,
995                    force: refspecs_force(name, positive),
996                    is_tag: name.starts_with("refs/tags/"),
997                });
998            }
999        }
1000    }
1001    MatchPlan {
1002        matched,
1003        wants,
1004        seen,
1005    }
1006}
1007
1008/// Fetch from a smart-HTTP remote, driving the stateless-RPC negotiation and
1009/// writing tracking-ref updates into `local_git_dir`.
1010///
1011/// This is the HTTP counterpart to [`crate::fetch::fetch_remote`]: instead of a
1012/// duplex socket it issues `info/refs` discovery + `git-upload-pack` POSTs
1013/// through `client`. The refspec matching, tag-mode, prune, and update
1014/// classification reuse the shared [`crate::transfer`] helpers, so the
1015/// [`FetchOutcome`] shape matches every other fetch path.
1016///
1017/// Both protocol v0/v1 and protocol v2 are handled: the version is taken from
1018/// the `info/refs` advertisement (the v2 capability block is returned only when
1019/// the discovery GET carries `Git-Protocol: version=2`, which the client's
1020/// default header supplies). For v2 the ref map is recovered with a
1021/// `command=ls-refs` POST and the pack is negotiated with `command=fetch` POSTs
1022/// (stateless: every round resends the wants + accumulated haves).
1023///
1024/// # Errors
1025///
1026/// Returns an error if discovery fails, a refspec is invalid, or negotiation /
1027/// pack ingest / ref I/O fails.
1028pub fn http_fetch(
1029    client: &dyn HttpClient,
1030    local_git_dir: &Path,
1031    repo_url: &str,
1032    opts: &FetchOptions,
1033    progress: &mut dyn Progress,
1034) -> Result<FetchOutcome> {
1035    // 1. Discovery (request v2 via the client's default `Git-Protocol` header;
1036    // a v0/v1 server ignores it and returns the classic advertisement).
1037    let disc = {
1038        let url = info_refs_url(repo_url);
1039        let body = client.get(&url, client.git_protocol_header())?;
1040        let stripped = strip_service_advertisement(&body)?;
1041        parse_advertisement(stripped)?
1042    };
1043    if disc.protocol_version >= 2 {
1044        return http_fetch_v2(client, local_git_dir, repo_url, &disc, opts, progress);
1045    }
1046
1047    let local_odb = open_odb(local_git_dir);
1048
1049    let default_branch = disc
1050        .head_symref
1051        .as_deref()
1052        .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned());
1053
1054    let remote_refs: Vec<(String, ObjectId)> = disc
1055        .refs
1056        .iter()
1057        .filter(|r| r.name != "HEAD" && !r.name.ends_with("^{}"))
1058        .map(|r| (r.name.clone(), r.oid))
1059        .collect();
1060
1061    // 2. Parse refspecs.
1062    let mut positive: Vec<RefspecItem> = Vec::new();
1063    let mut negatives: Vec<RefspecItem> = Vec::new();
1064    for spec in &opts.refspecs {
1065        let item = parse_fetch_refspec(spec)
1066            .map_err(|e| Error::Message(format!("invalid refspec '{spec}': {e}")))?;
1067        if item.negative {
1068            negatives.push(item);
1069        } else {
1070            positive.push(item);
1071        }
1072    }
1073    for spec in &opts.negative_refspecs {
1074        let item = parse_fetch_refspec(spec)
1075            .map_err(|e| Error::Message(format!("invalid negative refspec '{spec}': {e}")))?;
1076        negatives.push(item);
1077    }
1078
1079    // 3. Match refs to refspecs.
1080    let MatchPlan {
1081        mut matched,
1082        mut wants,
1083        mut seen,
1084    } = match_refspecs(&remote_refs, &positive, &negatives);
1085
1086    // 4. TagMode: add tags (the wire `include-tag` capability brings tag
1087    // objects with the pack; All adds every advertised tag, Following adds them
1088    // provisionally and prunes unreachable ones after the pack lands).
1089    if opts.tags != TagMode::None {
1090        for (name, oid) in &remote_refs {
1091            if !name.starts_with("refs/tags/") {
1092                continue;
1093            }
1094            if seen.contains(name) || ref_excluded(name, &negatives) {
1095                continue;
1096            }
1097            seen.insert(name.clone());
1098            wants.insert(*oid);
1099            matched.push(crate::transfer::MatchedRef {
1100                remote_ref: name.clone(),
1101                local_ref: Some(name.clone()),
1102                oid: *oid,
1103                force: false,
1104                is_tag: true,
1105            });
1106        }
1107    }
1108
1109    // 5. Wants → negotiate + ingest the pack. Normally the matched oids absent
1110    // locally; for a deepen/`--unshallow` request we must still `want` the tips
1111    // even if present so the server fills in ancestors past the old boundary.
1112    let local_shallow = crate::shallow::load_shallow_oids(local_git_dir)?;
1113    let shallow_request = opts.has_deepen_request() || !local_shallow.is_empty();
1114    let need: Vec<ObjectId> = if shallow_request {
1115        wants.iter().copied().collect()
1116    } else {
1117        wants
1118            .iter()
1119            .copied()
1120            .filter(|oid| !local_odb.exists(oid))
1121            .collect()
1122    };
1123
1124    let mut shallow_update = crate::fetch::ShallowUpdate::default();
1125
1126    if !need.is_empty() && !opts.dry_run {
1127        let (pack, su) = negotiate_pack_http(
1128            client,
1129            local_git_dir,
1130            repo_url,
1131            &disc.caps,
1132            &disc.refs,
1133            &need,
1134            opts,
1135            &local_shallow,
1136            progress,
1137        )?;
1138        shallow_update = su;
1139        if !pack.is_empty() {
1140            if pack.len() < 12 || &pack[0..4] != b"PACK" {
1141                return Err(Error::Message(
1142                    "did not receive a valid pack from HTTP fetch".to_owned(),
1143                ));
1144            }
1145            let mut cursor = Cursor::new(pack);
1146            crate::unpack_objects::unpack_objects(
1147                &mut cursor,
1148                &local_odb,
1149                &crate::unpack_objects::UnpackOptions {
1150                    quiet: true,
1151                    ..Default::default()
1152                },
1153            )?;
1154        }
1155    }
1156
1157    // Apply shallow/unshallow boundary updates to the on-disk `shallow` file.
1158    if !opts.dry_run {
1159        crate::shallow::apply_shallow_updates(
1160            local_git_dir,
1161            &shallow_update.shallow,
1162            &shallow_update.unshallow,
1163        )?;
1164    }
1165
1166    // 6. For TagMode::Following, drop tags whose target did not arrive.
1167    if opts.tags == TagMode::Following {
1168        retain_following_tags(&local_odb, &mut matched, &wants);
1169    }
1170
1171    // 7. Classify + apply ref updates.
1172    let local_repo = if opts.dry_run {
1173        None
1174    } else {
1175        crate::repo::Repository::open(local_git_dir, None).ok()
1176    };
1177
1178    let mut updates: Vec<RefUpdate> = Vec::new();
1179    if opts.prune {
1180        prune_tracking_refs(
1181            local_git_dir,
1182            &positive,
1183            &remote_refs,
1184            opts.dry_run,
1185            &mut updates,
1186        )?;
1187    }
1188
1189    for m in &matched {
1190        let Some(local_ref) = &m.local_ref else {
1191            updates.push(RefUpdate {
1192                remote_ref: m.remote_ref.clone(),
1193                local_ref: None,
1194                old_oid: None,
1195                new_oid: Some(m.oid),
1196                mode: UpdateMode::NoChangeNeeded,
1197                note: Some("not stored (empty destination)".to_owned()),
1198            });
1199            continue;
1200        };
1201        let old = crate::refs::resolve_ref(local_git_dir, local_ref).ok();
1202        let mode = classify_update(old.as_ref(), &m.oid, m.force, m.is_tag, local_repo.as_ref());
1203        let write = matches!(
1204            mode,
1205            UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
1206        );
1207        if write && !opts.dry_run {
1208            crate::refs::write_ref(local_git_dir, local_ref, &m.oid)?;
1209        }
1210        updates.push(RefUpdate {
1211            remote_ref: m.remote_ref.clone(),
1212            local_ref: Some(local_ref.clone()),
1213            old_oid: old,
1214            new_oid: Some(m.oid),
1215            mode,
1216            note: None,
1217        });
1218    }
1219
1220    Ok(FetchOutcome {
1221        updates,
1222        default_branch,
1223        new_shallow: shallow_update.shallow,
1224        new_unshallow: shallow_update.unshallow,
1225    })
1226}
1227
1228/// Fetch from a smart-HTTP remote that speaks protocol v2 (stateless multi-POST).
1229///
1230/// `disc` is the already-parsed v2 capability advertisement (no refs). This
1231/// recovers the ref map with a `command=ls-refs` POST, matches refspecs / tags
1232/// with the same shared [`crate::transfer`] helpers as the v0/v1 path, then
1233/// negotiates the pack with `command=fetch` POSTs (each round resends the
1234/// capability echo, all `want`s, and the accumulated `have`s) and demuxes the
1235/// side-band-64k `packfile` section. Lifted from the CLI's stateless v2 flow
1236/// (`http_ls_refs` / `http_negotiate_only_common` / `http_fetch_pack`), reusing
1237/// the v2 request framing factored out of [`crate::fetch`].
1238fn http_fetch_v2(
1239    client: &dyn HttpClient,
1240    local_git_dir: &Path,
1241    repo_url: &str,
1242    disc: &Discovery,
1243    opts: &FetchOptions,
1244    progress: &mut dyn Progress,
1245) -> Result<FetchOutcome> {
1246    let local_odb = open_odb(local_git_dir);
1247    // The v2 capability lines, as a `Vec<String>` for the `protocol_v2` /
1248    // `crate::fetch` helpers (each entry is one advertised capability line, e.g.
1249    // `agent=…`, `fetch=…`, `object-format=…`).
1250    let server_caps: Vec<String> = disc.caps.iter().cloned().collect();
1251
1252    let post_url = upload_pack_url(repo_url);
1253    let content_type = format!("application/x-{UPLOAD_PACK}-request");
1254    let accept = format!("application/x-{UPLOAD_PACK}-result");
1255    // Pin v2 on every POST so the server runs its v2 serve loop for this request.
1256    let git_protocol = "version=2";
1257
1258    // 1. Recover the ref map via `command=ls-refs`.
1259    let (remote_refs, head_symref) = {
1260        let req =
1261            crate::fetch::build_v2_ls_refs_request(&server_caps, &local_odb, opts.tags, &opts.refspecs)?;
1262        let resp = client.post(&post_url, &content_type, &accept, &req, Some(git_protocol))?;
1263        let mut cur = Cursor::new(resp);
1264        crate::fetch::parse_v2_ls_refs_response(&mut cur)?
1265    };
1266    let default_branch = head_symref
1267        .as_deref()
1268        .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned());
1269
1270    // 2. Parse refspecs.
1271    let mut positive: Vec<RefspecItem> = Vec::new();
1272    let mut negatives: Vec<RefspecItem> = Vec::new();
1273    for spec in &opts.refspecs {
1274        let item = parse_fetch_refspec(spec)
1275            .map_err(|e| Error::Message(format!("invalid refspec '{spec}': {e}")))?;
1276        if item.negative {
1277            negatives.push(item);
1278        } else {
1279            positive.push(item);
1280        }
1281    }
1282    for spec in &opts.negative_refspecs {
1283        let item = parse_fetch_refspec(spec)
1284            .map_err(|e| Error::Message(format!("invalid negative refspec '{spec}': {e}")))?;
1285        negatives.push(item);
1286    }
1287
1288    // 3. Match refs to refspecs (shared with the v0/v1 path).
1289    let MatchPlan {
1290        mut matched,
1291        mut wants,
1292        mut seen,
1293    } = match_refspecs(&remote_refs, &positive, &negatives);
1294
1295    // 4. TagMode: add tags (the wire `include-tag` capability brings tag objects
1296    // with the pack; All adds every advertised tag, Following adds them
1297    // provisionally and prunes unreachable ones after the pack lands).
1298    if opts.tags != TagMode::None {
1299        for (name, oid) in &remote_refs {
1300            if !name.starts_with("refs/tags/") {
1301                continue;
1302            }
1303            if seen.contains(name) || ref_excluded(name, &negatives) {
1304                continue;
1305            }
1306            seen.insert(name.clone());
1307            wants.insert(*oid);
1308            matched.push(crate::transfer::MatchedRef {
1309                remote_ref: name.clone(),
1310                local_ref: Some(name.clone()),
1311                oid: *oid,
1312                force: false,
1313                is_tag: true,
1314            });
1315        }
1316    }
1317
1318    // 5. Wants → negotiate + ingest the pack. Normally the matched oids absent
1319    // locally; for a deepen/`--unshallow` request we must still `want` the tips
1320    // even if present so the server fills in ancestors past the old boundary.
1321    let local_shallow = crate::shallow::load_shallow_oids(local_git_dir)?;
1322    let shallow_request = opts.has_deepen_request() || !local_shallow.is_empty();
1323    let need: Vec<ObjectId> = if shallow_request {
1324        wants.iter().copied().collect()
1325    } else {
1326        wants
1327            .iter()
1328            .copied()
1329            .filter(|oid| !local_odb.exists(oid))
1330            .collect()
1331    };
1332
1333    let mut shallow_update = crate::fetch::ShallowUpdate::default();
1334
1335    if !need.is_empty() && !opts.dry_run {
1336        let deepen = crate::fetch::V2DeepenArgs::from_opts(opts, &local_shallow);
1337        let (pack, su) = negotiate_pack_v2_http(
1338            client,
1339            local_git_dir,
1340            &post_url,
1341            &content_type,
1342            &accept,
1343            git_protocol,
1344            &server_caps,
1345            &local_odb,
1346            &need,
1347            &deepen,
1348            progress,
1349        )?;
1350        shallow_update = su;
1351        if !pack.is_empty() {
1352            if pack.len() < 12 || &pack[0..4] != b"PACK" {
1353                return Err(Error::Message(
1354                    "did not receive a valid pack from v2 HTTP fetch".to_owned(),
1355                ));
1356            }
1357            let mut cursor = Cursor::new(pack);
1358            crate::unpack_objects::unpack_objects(
1359                &mut cursor,
1360                &local_odb,
1361                &crate::unpack_objects::UnpackOptions {
1362                    quiet: true,
1363                    ..Default::default()
1364                },
1365            )?;
1366        }
1367    }
1368
1369    // Apply shallow/unshallow boundary updates to the on-disk `shallow` file.
1370    if !opts.dry_run {
1371        crate::shallow::apply_shallow_updates(
1372            local_git_dir,
1373            &shallow_update.shallow,
1374            &shallow_update.unshallow,
1375        )?;
1376    }
1377
1378    // 6. For TagMode::Following, drop tags whose target did not arrive.
1379    if opts.tags == TagMode::Following {
1380        retain_following_tags(&local_odb, &mut matched, &wants);
1381    }
1382
1383    // 7. Classify + apply ref updates (shared with the v0/v1 path).
1384    let local_repo = if opts.dry_run {
1385        None
1386    } else {
1387        crate::repo::Repository::open(local_git_dir, None).ok()
1388    };
1389
1390    let mut updates: Vec<RefUpdate> = Vec::new();
1391    if opts.prune {
1392        prune_tracking_refs(
1393            local_git_dir,
1394            &positive,
1395            &remote_refs,
1396            opts.dry_run,
1397            &mut updates,
1398        )?;
1399    }
1400
1401    for m in &matched {
1402        let Some(local_ref) = &m.local_ref else {
1403            updates.push(RefUpdate {
1404                remote_ref: m.remote_ref.clone(),
1405                local_ref: None,
1406                old_oid: None,
1407                new_oid: Some(m.oid),
1408                mode: UpdateMode::NoChangeNeeded,
1409                note: Some("not stored (empty destination)".to_owned()),
1410            });
1411            continue;
1412        };
1413        let old = crate::refs::resolve_ref(local_git_dir, local_ref).ok();
1414        let mode = classify_update(old.as_ref(), &m.oid, m.force, m.is_tag, local_repo.as_ref());
1415        let write = matches!(
1416            mode,
1417            UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
1418        );
1419        if write && !opts.dry_run {
1420            crate::refs::write_ref(local_git_dir, local_ref, &m.oid)?;
1421        }
1422        updates.push(RefUpdate {
1423            remote_ref: m.remote_ref.clone(),
1424            local_ref: Some(local_ref.clone()),
1425            old_oid: old,
1426            new_oid: Some(m.oid),
1427            mode,
1428            note: None,
1429        });
1430    }
1431
1432    Ok(FetchOutcome {
1433        updates,
1434        default_branch,
1435        new_shallow: shallow_update.shallow,
1436        new_unshallow: shallow_update.unshallow,
1437    })
1438}
1439
1440/// Negotiate and download the pack for `wants` over stateless-RPC HTTP using
1441/// protocol v2 (`command=fetch`), returning the raw pack bytes.
1442///
1443/// Stateless: every POST resends the capability echo, every `want`, and all the
1444/// `have`s accumulated so far. The round structure mirrors the v0/v1 stateless
1445/// loop and the streaming v2 path:
1446///
1447/// * no local history → a single POST with `want`s + `done`, then read the
1448///   `packfile` section;
1449/// * otherwise → batched rounds that send `want`s + the growing have-prefix
1450///   *without* `done`, reading the `acknowledgments` section each time. When the
1451///   server replies `ready`, that same response carries the pack (read it and
1452///   stop). If the haves are exhausted without `ready`, a final POST sends every
1453///   have + `done` and reads the pack.
1454#[allow(clippy::too_many_arguments)]
1455fn negotiate_pack_v2_http(
1456    client: &dyn HttpClient,
1457    local_git_dir: &Path,
1458    post_url: &str,
1459    content_type: &str,
1460    accept: &str,
1461    git_protocol: &str,
1462    server_caps: &[String],
1463    local_odb: &crate::odb::Odb,
1464    wants: &[ObjectId],
1465    deepen: &crate::fetch::V2DeepenArgs,
1466    progress: &mut dyn Progress,
1467) -> Result<(Vec<u8>, crate::fetch::ShallowUpdate)> {
1468    if wants.is_empty() {
1469        return Ok((Vec::new(), crate::fetch::ShallowUpdate::default()));
1470    }
1471    let object_format = crate::fetch::v2_object_format(server_caps, local_odb);
1472    let cap_echo = protocol_v2::cap_lines_for_command_request(server_caps);
1473    let sideband_all = protocol_v2::fetch_supports_sideband_all(server_caps);
1474
1475    // A deepen/shallow request does not offer haves (its objects bottom out at
1476    // grafts), forcing the single-round path so the server precedes the pack with
1477    // a `shallow-info` section.
1478    let shallow_request = deepen.is_shallow_request();
1479
1480    // The ordered have list, built with the shared skipping-negotiator helper so
1481    // the wire offers match the streaming v2 path exactly. Empty for a shallow
1482    // request.
1483    let haves = if shallow_request {
1484        Vec::new()
1485    } else {
1486        crate::fetch::v2_local_haves(local_git_dir, wants)?
1487    };
1488
1489    let mut pack = Vec::new();
1490    let mut shallow_update = crate::fetch::ShallowUpdate::default();
1491
1492    // No local history: one POST, wants + done, then the pack.
1493    if haves.is_empty() {
1494        let mut req = Vec::new();
1495        crate::fetch::write_v2_fetch_request(
1496            &mut req,
1497            &object_format,
1498            &cap_echo,
1499            wants,
1500            &[],
1501            sideband_all,
1502            deepen,
1503            true,
1504        )?;
1505        let resp = client.post(post_url, content_type, accept, &req, Some(git_protocol))?;
1506        let mut cur = Cursor::new(resp);
1507        crate::fetch::read_v2_fetch_pack_response(&mut cur, &mut pack, &mut shallow_update, progress)?;
1508        return Ok((pack, shallow_update));
1509    }
1510
1511    // Batched negotiation: each round resends wants + the accumulated have prefix
1512    // (stateless) without `done`, reading the acknowledgments section. The flush
1513    // schedule matches `fetch-pack.c` (`next_flush`).
1514    const INITIAL_FLUSH: usize = 16;
1515    let mut flush_at: usize = INITIAL_FLUSH.min(haves.len());
1516    loop {
1517        if flush_at < haves.len() {
1518            // Non-final round: offer the have prefix [0..flush_at) without `done`.
1519            let mut req = Vec::new();
1520            crate::fetch::write_v2_fetch_request(
1521                &mut req,
1522                &object_format,
1523                &cap_echo,
1524                wants,
1525                &haves[..flush_at],
1526                sideband_all,
1527                deepen,
1528                false,
1529            )?;
1530            let resp = client.post(post_url, content_type, accept, &req, Some(git_protocol))?;
1531            let mut cur = Cursor::new(resp);
1532            let ack = crate::fetch::read_v2_acknowledgments(&mut cur)?;
1533            if let Some(round) = ack {
1534                if round.ready {
1535                    // The pack follows in this same response after the delimiter.
1536                    crate::fetch::read_v2_fetch_pack_response(
1537                        &mut cur,
1538                        &mut pack,
1539                        &mut shallow_update,
1540                        progress,
1541                    )?;
1542                    return Ok((pack, shallow_update));
1543                }
1544            } else {
1545                // Server skipped acknowledgments and went straight to the pack.
1546                crate::fetch::read_v2_fetch_pack_response(
1547                    &mut cur,
1548                    &mut pack,
1549                    &mut shallow_update,
1550                    progress,
1551                )?;
1552                return Ok((pack, shallow_update));
1553            }
1554            flush_at = next_flush(flush_at).min(haves.len());
1555            continue;
1556        }
1557
1558        // Final round: send every have + `done`, then read the pack.
1559        let mut req = Vec::new();
1560        crate::fetch::write_v2_fetch_request(
1561            &mut req,
1562            &object_format,
1563            &cap_echo,
1564            wants,
1565            &haves,
1566            sideband_all,
1567            deepen,
1568            true,
1569        )?;
1570        let resp = client.post(post_url, content_type, accept, &req, Some(git_protocol))?;
1571        let mut cur = Cursor::new(resp);
1572        crate::fetch::read_v2_fetch_pack_response(&mut cur, &mut pack, &mut shallow_update, progress)?;
1573        return Ok((pack, shallow_update));
1574    }
1575}
1576
1577/// Drop provisional `Following` tags whose object did not arrive in the pack.
1578fn retain_following_tags(
1579    odb: &crate::odb::Odb,
1580    matched: &mut Vec<crate::transfer::MatchedRef>,
1581    wants: &HashSet<ObjectId>,
1582) {
1583    let roots: Vec<ObjectId> = matched.iter().filter(|m| !m.is_tag).map(|m| m.oid).collect();
1584    let closure = reachable_closure(odb, &roots);
1585    matched.retain(|m| {
1586        if !m.is_tag {
1587            return true;
1588        }
1589        let peeled = peel_tag_target(odb, m.oid);
1590        let have = odb.exists(&m.oid);
1591        have && (closure.contains(&m.oid) || closure.contains(&peeled) || wants.contains(&peeled))
1592    });
1593}
1594
1595fn peel_tag_target(odb: &crate::odb::Odb, oid: ObjectId) -> ObjectId {
1596    let mut current = oid;
1597    for _ in 0..16 {
1598        let Ok(obj) = odb.read(&current) else {
1599            return current;
1600        };
1601        if obj.kind != crate::objects::ObjectKind::Tag {
1602            return current;
1603        }
1604        match crate::objects::parse_tag(&obj.data) {
1605            Ok(t) => current = t.object,
1606            Err(_) => return current,
1607        }
1608    }
1609    current
1610}
1611
1612fn reachable_closure(odb: &crate::odb::Odb, roots: &[ObjectId]) -> HashSet<ObjectId> {
1613    use crate::objects::{parse_commit, parse_tag, parse_tree, ObjectKind};
1614    let mut seen: HashSet<ObjectId> = HashSet::new();
1615    let mut stack: Vec<ObjectId> = roots.to_vec();
1616    while let Some(oid) = stack.pop() {
1617        if !seen.insert(oid) {
1618            continue;
1619        }
1620        let Ok(obj) = odb.read(&oid) else {
1621            continue;
1622        };
1623        match obj.kind {
1624            ObjectKind::Commit => {
1625                if let Ok(c) = parse_commit(&obj.data) {
1626                    stack.push(c.tree);
1627                    for p in c.parents {
1628                        stack.push(p);
1629                    }
1630                }
1631            }
1632            ObjectKind::Tree => {
1633                if let Ok(entries) = parse_tree(&obj.data) {
1634                    for e in entries {
1635                        stack.push(e.oid);
1636                    }
1637                }
1638            }
1639            ObjectKind::Tag => {
1640                if let Ok(t) = parse_tag(&obj.data) {
1641                    stack.push(t.object);
1642                }
1643            }
1644            ObjectKind::Blob => {}
1645        }
1646    }
1647    seen
1648}
1649
1650/// Convenience: the unused-by-default [`Advertisement`] shape, exported so an
1651/// embedder can reuse the same structured view as the duplex transports.
1652pub fn discovery_advertisement(conn: &SmartHttpConnection) -> Advertisement {
1653    Advertisement {
1654        refs: conn.adv_refs.clone(),
1655        capabilities: conn.caps.clone(),
1656        head_symref: conn.head_symref.clone(),
1657        protocol_version: conn.protocol_version,
1658    }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::*;
1664
1665    #[test]
1666    fn strips_smart_service_preamble() {
1667        let mut body = Vec::new();
1668        pkt_line::write_line_to_vec(&mut body, "# service=git-upload-pack\n").unwrap();
1669        body.extend_from_slice(b"0000");
1670        let oid = "1".repeat(40);
1671        let line = format!("{oid} refs/heads/main\0multi_ack_detailed side-band-64k");
1672        pkt_line::write_line_to_vec(&mut body, &line).unwrap();
1673        body.extend_from_slice(b"0000");
1674
1675        let stripped = strip_service_advertisement(&body).unwrap();
1676        let disc = parse_advertisement(stripped).unwrap();
1677        assert_eq!(disc.protocol_version, 0);
1678        assert_eq!(disc.refs.len(), 1);
1679        assert_eq!(disc.refs[0].name, "refs/heads/main");
1680        assert!(disc.caps.contains("side-band-64k"));
1681    }
1682
1683    #[test]
1684    fn parses_symref_and_caps() {
1685        let mut body = Vec::new();
1686        let main = "2".repeat(40);
1687        let head = format!(
1688            "{main} HEAD\0multi_ack_detailed symref=HEAD:refs/heads/main object-format=sha1"
1689        );
1690        pkt_line::write_line_to_vec(&mut body, &head).unwrap();
1691        let r = format!("{main} refs/heads/main");
1692        pkt_line::write_line_to_vec(&mut body, &r).unwrap();
1693        body.extend_from_slice(b"0000");
1694
1695        let disc = parse_advertisement(&body).unwrap();
1696        assert_eq!(disc.head_symref.as_deref(), Some("refs/heads/main"));
1697        assert_eq!(disc.object_format, "sha1");
1698        // `parse_advertisement` keeps HEAD; the connection/fetch layer filters
1699        // HEAD and peeled `^{}` carriers. Both lines parse here.
1700        assert!(disc.refs.iter().any(|r| r.name == "HEAD"));
1701        assert!(disc.refs.iter().any(|r| r.name == "refs/heads/main"));
1702    }
1703
1704    #[test]
1705    fn detects_v2_preamble() {
1706        let mut body = Vec::new();
1707        pkt_line::write_line_to_vec(&mut body, "version 2").unwrap();
1708        pkt_line::write_line_to_vec(&mut body, "ls-refs").unwrap();
1709        pkt_line::write_line_to_vec(&mut body, "object-format=sha256").unwrap();
1710        body.extend_from_slice(b"0000");
1711        let disc = parse_advertisement(&body).unwrap();
1712        assert_eq!(disc.protocol_version, 2);
1713        assert_eq!(disc.object_format, "sha256");
1714    }
1715
1716    #[test]
1717    fn url_helpers() {
1718        assert_eq!(
1719            info_refs_url("http://h/r.git"),
1720            "http://h/r.git/info/refs?service=git-upload-pack"
1721        );
1722        assert_eq!(
1723            info_refs_url("http://h/r.git/"),
1724            "http://h/r.git/info/refs?service=git-upload-pack"
1725        );
1726        assert_eq!(upload_pack_url("http://h/r.git/"), "http://h/r.git/git-upload-pack");
1727    }
1728}