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        crate::net_trace::net_trace!(
480            "http(s) discover {url} (service={}, request protocol v{})",
481            service.wire_name(),
482            opts.protocol_version
483        );
484        let gp = git_protocol_for_version(opts.protocol_version);
485        let disc = self.discover(url, service, gp.as_deref())?;
486        let adv_refs: Vec<(String, ObjectId)> = disc
487            .refs
488            .iter()
489            .filter(|r| r.name != "HEAD" && !r.name.ends_with("^{}"))
490            .map(|r| (r.name.clone(), r.oid))
491            .collect();
492        let caps: Vec<String> = disc.caps.iter().cloned().collect();
493        crate::net_trace::net_trace!(
494            "http(s) discovered: protocol v{}, {} ref(s) advertised",
495            disc.protocol_version,
496            adv_refs.len()
497        );
498        Ok(Box::new(SmartHttpConnection {
499            repo_url: url.to_owned(),
500            adv_refs,
501            caps,
502            head_symref: disc.head_symref,
503            protocol_version: disc.protocol_version,
504            object_format: disc.object_format,
505            service,
506            empty_reader: Cursor::new(Vec::new()),
507            sink: Vec::new(),
508        }))
509    }
510}
511
512/// Read a length-prefixed pkt-line payload, returning `None` on flush/delim/EOF.
513fn read_pkt_payload(r: &mut impl Read) -> std::io::Result<Option<Vec<u8>>> {
514    let mut len_buf = [0u8; 4];
515    match r.read_exact(&mut len_buf) {
516        Ok(()) => {}
517        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
518        Err(e) => return Err(e),
519    }
520    let len_str = std::str::from_utf8(&len_buf)
521        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
522    let len = usize::from_str_radix(len_str, 16)
523        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
524    match len {
525        0..=2 => Ok(None),
526        n if n <= 4 => Err(std::io::Error::new(
527            std::io::ErrorKind::InvalidData,
528            format!("invalid pkt-line length: {n}"),
529        )),
530        n => {
531            let mut buf = vec![0u8; n - 4];
532            r.read_exact(&mut buf)?;
533            Ok(Some(buf))
534        }
535    }
536}
537
538/// Kind of `ACK` status suffix in a v0 negotiation response.
539#[derive(Clone, Copy, PartialEq, Eq)]
540enum AckKind {
541    /// `ACK <oid>` with no status suffix (ends a round / post-`done`).
542    Bare,
543    /// `ACK <oid> common` — the server holds this commit; replay it on the next
544    /// stateless RPC if we had not already marked it common.
545    Common,
546    /// `ACK <oid> continue` — recorded in the negotiator but not replayed.
547    Continue,
548    /// `ACK <oid> ready` — the server has enough; it will send the pack.
549    Ready,
550}
551
552struct Ack {
553    oid: ObjectId,
554    kind: AckKind,
555}
556
557fn parse_ack(line: &str) -> Option<Ack> {
558    let rest = line.strip_prefix("ACK ")?;
559    let hex = rest.split_whitespace().next()?;
560    let oid = ObjectId::from_hex(hex).ok()?;
561    let tail = rest.strip_prefix(hex).unwrap_or("").trim();
562    let kind = if tail.contains("continue") {
563        AckKind::Continue
564    } else if tail.contains("common") {
565        AckKind::Common
566    } else if tail.contains("ready") {
567        AckKind::Ready
568    } else {
569        AckKind::Bare
570    };
571    Some(Ack { oid, kind })
572}
573
574/// Result of parsing one stateless-RPC response.
575struct RoundResult {
576    acks: Vec<Ack>,
577    got_pack: bool,
578    /// Shallow boundaries the server reported (`shallow <oid>`) in this response's
579    /// leading `shallow-info` section (empty unless a deepen was requested).
580    shallow: Vec<ObjectId>,
581    /// Boundaries the server un-shallowed (`unshallow <oid>`) in this response.
582    unshallow: Vec<ObjectId>,
583}
584
585/// Demultiplex the side-band pack from a stateless-RPC response, appending pack
586/// bytes to `out` and forwarding channel-2 progress. Mirrors the CLI's
587/// `read_sideband_pack_until_done`.
588fn read_sideband_pack(
589    r: &mut impl Read,
590    out: &mut Vec<u8>,
591    progress: &mut dyn Progress,
592) -> Result<()> {
593    let mut seen_pack = false;
594    let mut pending: Vec<u8> = Vec::new();
595    loop {
596        let mut len_buf = [0u8; 4];
597        match r.read_exact(&mut len_buf) {
598            Ok(()) => {}
599            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
600            Err(e) => return Err(e.into()),
601        }
602        let len_str = std::str::from_utf8(&len_buf)
603            .map_err(|_| Error::Message("bad pkt length".to_owned()))?;
604        let len = usize::from_str_radix(len_str, 16)
605            .map_err(|_| Error::Message("bad pkt length".to_owned()))?;
606        match len {
607            0 => {
608                if seen_pack {
609                    break;
610                }
611                continue;
612            }
613            1 | 2 => continue,
614            n if n <= 4 => {
615                return Err(Error::Message(format!(
616                    "invalid pkt-line length in side-band stream: {n}"
617                )))
618            }
619            _ => {}
620        }
621        let mut payload = vec![0u8; len - 4];
622        r.read_exact(&mut payload)?;
623        if payload.is_empty() {
624            continue;
625        }
626        match payload[0] {
627            1 => append_pack_data(&payload[1..], out, &mut pending, &mut seen_pack),
628            2 => progress.message(&payload[1..]),
629            3 => {
630                return Err(Error::Message(format!(
631                    "remote error: {}",
632                    String::from_utf8_lossy(&payload[1..]).trim_end()
633                )))
634            }
635            _ => append_pack_data(&payload, out, &mut pending, &mut seen_pack),
636        }
637    }
638    Ok(())
639}
640
641/// Append channel-1 (or raw) data to `out`, scanning for the `PACK` magic that
642/// may straddle chunk boundaries.
643fn append_pack_data(data: &[u8], out: &mut Vec<u8>, pending: &mut Vec<u8>, seen_pack: &mut bool) {
644    if *seen_pack {
645        out.extend_from_slice(data);
646        return;
647    }
648    pending.extend_from_slice(data);
649    if let Some(pos) = pending.windows(4).position(|w| w == b"PACK") {
650        *seen_pack = true;
651        out.extend_from_slice(&pending[pos..]);
652        pending.clear();
653    } else if pending.len() > 3 {
654        let keep_from = pending.len() - 3;
655        pending.drain(..keep_from);
656    }
657}
658
659/// Parse one v0 stateless-RPC `git-upload-pack` response: an optional leading
660/// `shallow-info` section (only when `expect_shallow`, i.e. a deepen was
661/// requested), then optional `ACK`/`NAK` negotiation lines, then (if the server
662/// is generating one) the side-band pack.
663fn read_stateless_response(
664    resp: &[u8],
665    sideband: bool,
666    expect_shallow: bool,
667    pack_buf: &mut Vec<u8>,
668    progress: &mut dyn Progress,
669) -> Result<RoundResult> {
670    let mut cur = Cursor::new(resp);
671    let mut acks = Vec::new();
672    let mut got_pack = false;
673    let mut shallow = Vec::new();
674    let mut unshallow = Vec::new();
675
676    // Shallow-info section: `shallow`/`unshallow` lines terminated by a flush. A
677    // server with nothing to report still emits the trailing flush. Rewind and
678    // fall through if the first line is not a shallow-info line (no section).
679    if expect_shallow {
680        loop {
681            let start = cur.position() as usize;
682            match pkt_line::read_packet(&mut cur)? {
683                None | Some(pkt_line::Packet::Flush) => break,
684                Some(pkt_line::Packet::Data(line)) => {
685                    let line = line.trim_end_matches('\n');
686                    if let Some(rest) = line.strip_prefix("shallow ") {
687                        if let Ok(oid) = ObjectId::from_hex(rest.trim()) {
688                            shallow.push(oid);
689                        }
690                    } else if let Some(rest) = line.strip_prefix("unshallow ") {
691                        if let Ok(oid) = ObjectId::from_hex(rest.trim()) {
692                            unshallow.push(oid);
693                        }
694                    } else {
695                        cur.set_position(start as u64);
696                        break;
697                    }
698                }
699                Some(_) => break,
700            }
701        }
702    }
703
704    loop {
705        let start = cur.position() as usize;
706        let Some(payload) = read_pkt_payload(&mut cur)? else {
707            break;
708        };
709        if payload.is_empty() {
710            continue;
711        }
712        let is_pack = (sideband
713            && payload.first() == Some(&1)
714            && payload.get(1..5) == Some(b"PACK"))
715            || payload.starts_with(b"PACK");
716        if is_pack {
717            got_pack = true;
718            cur.set_position(start as u64);
719            if sideband {
720                read_sideband_pack(&mut cur, pack_buf, progress)?;
721            } else {
722                pack_buf.extend_from_slice(&resp[start..]);
723            }
724            break;
725        }
726        let text = String::from_utf8_lossy(&payload);
727        let line = text.trim_end_matches('\n');
728        if let Some(err) = line.strip_prefix("ERR ") {
729            return Err(Error::Message(format!("remote upload-pack error: {err}")));
730        }
731        if line == "NAK" {
732            continue;
733        }
734        if let Some(ack) = parse_ack(line) {
735            acks.push(ack);
736        }
737    }
738    Ok(RoundResult {
739        acks,
740        got_pack,
741        shallow,
742        unshallow,
743    })
744}
745
746/// The v0/v1 fetch capabilities we request, intersected with what the server
747/// advertised. Mirrors `build_fetch_caps_v0`.
748fn build_fetch_caps(caps: &HashSet<String>) -> String {
749    let mut enabled = Vec::new();
750    let multi_ack_detailed = caps.contains("multi_ack_detailed");
751    if multi_ack_detailed {
752        enabled.push("multi_ack_detailed");
753    }
754    if multi_ack_detailed && caps.contains("no-done") {
755        enabled.push("no-done");
756    }
757    for want in [
758        "side-band-64k",
759        "thin-pack",
760        "no-progress",
761        "include-tag",
762        "ofs-delta",
763    ] {
764        if caps.contains(want) {
765            enabled.push(want);
766        }
767    }
768    if enabled.is_empty() {
769        String::new()
770    } else {
771        format!(" {}", enabled.join(" "))
772    }
773}
774
775/// Next stateless-RPC `have` batch size (mirrors `fetch-pack.c` `next_flush`).
776fn next_flush(count: usize) -> usize {
777    const LARGE_FLUSH: usize = 16384;
778    if count < LARGE_FLUSH {
779        count * 2
780    } else {
781        count * 11 / 10
782    }
783}
784
785/// Append the v0/v1 shallow/deepen request lines (the client's `shallow <oid>`
786/// grafts and any `deepen`/`deepen-since`/`deepen-not`) to the persistent request
787/// `state`, gated on the server capability where one exists. Mirrors the CLI's
788/// `append_fetch_request_extensions_v0_v1`.
789fn append_shallow_request_v0_http(
790    req: &mut Vec<u8>,
791    caps: &HashSet<String>,
792    local_shallow: &[ObjectId],
793    opts: &FetchOptions,
794) -> Result<()> {
795    for oid in local_shallow {
796        pkt_line::write_line_to_vec(req, &format!("shallow {}", oid.to_hex()))?;
797    }
798    if opts.unshallow {
799        pkt_line::write_line_to_vec(req, &format!("deepen {}", crate::shallow::INFINITE_DEPTH))?;
800    } else if let Some(depth) = opts.depth.filter(|d| *d > 0) {
801        pkt_line::write_line_to_vec(req, &format!("deepen {depth}"))?;
802    }
803    if let Some(since) = opts.deepen_since.as_deref().filter(|s| !s.trim().is_empty()) {
804        if caps.contains("deepen-since") {
805            let value = crate::shallow::deepen_since_wire_value(since);
806            pkt_line::write_line_to_vec(req, &format!("deepen-since {value}"))?;
807        }
808    }
809    if caps.contains("deepen-not") {
810        for excl in &opts.deepen_not {
811            let excl = excl.trim();
812            if !excl.is_empty() {
813                pkt_line::write_line_to_vec(req, &format!("deepen-not {excl}"))?;
814            }
815        }
816    }
817    Ok(())
818}
819
820/// Negotiate and download the pack for `wants` over stateless-RPC HTTP,
821/// returning the raw pack bytes (empty if the server sent none) plus any
822/// shallow-boundary updates the server reported.
823fn negotiate_pack_http(
824    client: &dyn HttpClient,
825    local_git_dir: &Path,
826    repo_url: &str,
827    caps: &HashSet<String>,
828    advertised: &[AdvRef],
829    wants: &[ObjectId],
830    opts: &FetchOptions,
831    local_shallow: &[ObjectId],
832    progress: &mut dyn Progress,
833) -> Result<(Vec<u8>, crate::fetch::ShallowUpdate)> {
834    let post_url = upload_pack_url(repo_url);
835    let content_type = format!("application/x-{UPLOAD_PACK}-request");
836    let accept = format!("application/x-{UPLOAD_PACK}-result");
837    let fetch_caps = build_fetch_caps(caps);
838    let sideband = caps.contains("side-band-64k");
839    let multi_ack_detailed = caps.contains("multi_ack_detailed");
840    let no_done = multi_ack_detailed && caps.contains("no-done");
841
842    // A deepen/shallow request precedes the pack with a `shallow-info` section and
843    // does not offer local haves (its objects bottom out at grafts).
844    let shallow_request = opts.has_deepen_request() || !local_shallow.is_empty();
845
846    let want_set: HashSet<ObjectId> = wants.iter().copied().collect();
847
848    // Build the persistent request prefix replayed on every RPC: the want lines
849    // (capabilities on the first), the shallow/deepen extensions, and the
850    // terminating flush.
851    let mut state = Vec::new();
852    let first = wants[0];
853    pkt_line::write_line_to_vec(&mut state, &format!("want {}{}", first.to_hex(), fetch_caps))?;
854    for w in wants.iter().skip(1) {
855        pkt_line::write_line_to_vec(&mut state, &format!("want {}", w.to_hex()))?;
856    }
857    append_shallow_request_v0_http(&mut state, caps, local_shallow, opts)?;
858    pkt_line::write_flush(&mut state)?;
859
860    let mut shallow_update = crate::fetch::ShallowUpdate::default();
861
862    // Build the negotiator from local tips, marking advertised tips we already
863    // have as known-common. Skipped for a shallow request.
864    let local_repo = crate::repo::Repository::open(local_git_dir, None)?;
865    let mut negotiator = SkippingNegotiator::new(local_repo);
866    if !shallow_request {
867        for w in wants {
868            if negotiator.repo().odb.read(w).is_ok() {
869                negotiator.add_tip(*w)?;
870            }
871        }
872        let mut tips: Vec<ObjectId> = Vec::new();
873        for prefix in ["refs/heads/", "refs/tags/"] {
874            if let Ok(entries) = crate::refs::list_refs(local_git_dir, prefix) {
875                for (_, oid) in entries {
876                    if negotiator.repo().odb.read(&oid).is_ok() {
877                        tips.push(oid);
878                    }
879                }
880            }
881        }
882        if let Ok(h) = crate::refs::resolve_ref(local_git_dir, "HEAD") {
883            if negotiator.repo().odb.read(&h).is_ok() {
884                tips.push(h);
885            }
886        }
887        tips.sort_by_key(ObjectId::to_hex);
888        tips.dedup();
889        for t in tips {
890            if want_set.contains(&t) {
891                continue;
892            }
893            negotiator.add_tip(t)?;
894        }
895        for e in advertised {
896            if want_set.contains(&e.oid) {
897                continue;
898            }
899            if negotiator.repo().odb.read(&e.oid).is_ok() {
900                negotiator.known_common(e.oid)?;
901            }
902        }
903    }
904
905    let mut pack_buf: Vec<u8> = Vec::new();
906    let mut got_ready = false;
907    let mut got_pack = false;
908    let mut shallow_applied = false;
909
910    const INITIAL_FLUSH: usize = 16;
911    let mut count: usize = 0;
912    let mut flush_at: usize = INITIAL_FLUSH;
913    let mut round = Vec::new();
914    // The negotiator is empty for a shallow request, so this loop is skipped and
915    // the single `done` RPC below carries the wants + shallow lines.
916    while let Some(oid) = negotiator.next_have()? {
917        pkt_line::write_line_to_vec(&mut round, &format!("have {}", oid.to_hex()))?;
918        count += 1;
919        if count < flush_at {
920            continue;
921        }
922        flush_at = next_flush(count);
923
924        let mut req = state.clone();
925        req.extend_from_slice(&round);
926        pkt_line::write_flush(&mut req)?;
927        round.clear();
928
929        let resp = client.post(&post_url, &content_type, &accept, &req, None)?;
930        let round_result =
931            read_stateless_response(&resp, sideband, shallow_request, &mut pack_buf, progress)?;
932        if shallow_request && !shallow_applied {
933            shallow_update.shallow.extend(round_result.shallow.iter().copied());
934            shallow_update.unshallow.extend(round_result.unshallow.iter().copied());
935            shallow_applied = true;
936        }
937        for ack in &round_result.acks {
938            if matches!(ack.kind, AckKind::Bare) {
939                continue;
940            }
941            let was_common = negotiator.ack(ack.oid)?;
942            if matches!(ack.kind, AckKind::Common) && !was_common {
943                pkt_line::write_line_to_vec(&mut state, &format!("have {}", ack.oid.to_hex()))?;
944            }
945            if matches!(ack.kind, AckKind::Ready) {
946                got_ready = true;
947            }
948        }
949        if round_result.got_pack {
950            got_pack = true;
951            break;
952        }
953        if got_ready {
954            break;
955        }
956    }
957
958    // Final RPC ending in `done`, unless the pack already arrived with
959    // `ACK ... ready` under `no-done`.
960    if !(got_pack || got_ready && no_done) {
961        let mut req = state.clone();
962        pkt_line::write_line_to_vec(&mut req, "done")?;
963        pkt_line::write_flush(&mut req)?;
964        let resp = client.post(&post_url, &content_type, &accept, &req, None)?;
965        let round_result =
966            read_stateless_response(&resp, sideband, shallow_request, &mut pack_buf, progress)?;
967        if shallow_request && !shallow_applied {
968            shallow_update.shallow.extend(round_result.shallow);
969            shallow_update.unshallow.extend(round_result.unshallow);
970        }
971    }
972
973    Ok((pack_buf, shallow_update))
974}
975
976/// Resolve the `wants` for a fetch from the advertised refs and the matched set.
977///
978/// Returns the matched ref records (for later ref-update classification) and the
979/// set of wanted oids.
980struct MatchPlan {
981    matched: Vec<crate::transfer::MatchedRef>,
982    wants: HashSet<ObjectId>,
983    seen: HashSet<String>,
984}
985
986fn match_refspecs(
987    remote_refs: &[(String, ObjectId)],
988    positive: &[RefspecItem],
989    negatives: &[RefspecItem],
990) -> MatchPlan {
991    let mut matched: Vec<crate::transfer::MatchedRef> = Vec::new();
992    let mut wants: HashSet<ObjectId> = HashSet::new();
993    let mut seen: HashSet<String> = HashSet::new();
994    for (name, oid) in remote_refs {
995        if ref_excluded(name, negatives) {
996            continue;
997        }
998        if let Some(local_ref) = match_positive(name, positive) {
999            if seen.insert(name.clone()) {
1000                wants.insert(*oid);
1001                matched.push(crate::transfer::MatchedRef {
1002                    remote_ref: name.clone(),
1003                    local_ref,
1004                    oid: *oid,
1005                    force: refspecs_force(name, positive),
1006                    is_tag: name.starts_with("refs/tags/"),
1007                });
1008            }
1009        }
1010    }
1011    MatchPlan {
1012        matched,
1013        wants,
1014        seen,
1015    }
1016}
1017
1018/// Fetch from a smart-HTTP remote, driving the stateless-RPC negotiation and
1019/// writing tracking-ref updates into `local_git_dir`.
1020///
1021/// This is the HTTP counterpart to [`crate::fetch::fetch_remote`]: instead of a
1022/// duplex socket it issues `info/refs` discovery + `git-upload-pack` POSTs
1023/// through `client`. The refspec matching, tag-mode, prune, and update
1024/// classification reuse the shared [`crate::transfer`] helpers, so the
1025/// [`FetchOutcome`] shape matches every other fetch path.
1026///
1027/// Both protocol v0/v1 and protocol v2 are handled: the version is taken from
1028/// the `info/refs` advertisement (the v2 capability block is returned only when
1029/// the discovery GET carries `Git-Protocol: version=2`, which the client's
1030/// default header supplies). For v2 the ref map is recovered with a
1031/// `command=ls-refs` POST and the pack is negotiated with `command=fetch` POSTs
1032/// (stateless: every round resends the wants + accumulated haves).
1033///
1034/// # Errors
1035///
1036/// Returns an error if discovery fails, a refspec is invalid, or negotiation /
1037/// pack ingest / ref I/O fails.
1038pub fn http_fetch(
1039    client: &dyn HttpClient,
1040    local_git_dir: &Path,
1041    repo_url: &str,
1042    opts: &FetchOptions,
1043    progress: &mut dyn Progress,
1044) -> Result<FetchOutcome> {
1045    use crate::net_trace::net_trace;
1046    net_trace!(
1047        "http_fetch: begin — {} ({} refspec(s), tags={:?})",
1048        repo_url,
1049        opts.refspecs.len(),
1050        opts.tags
1051    );
1052    // 1. Discovery (request v2 via the client's default `Git-Protocol` header;
1053    // a v0/v1 server ignores it and returns the classic advertisement).
1054    let disc = {
1055        let url = info_refs_url(repo_url);
1056        let body = client.get(&url, client.git_protocol_header())?;
1057        let stripped = strip_service_advertisement(&body)?;
1058        parse_advertisement(stripped)?
1059    };
1060    net_trace!(
1061        "http_fetch: discovered protocol v{}, {} ref(s)",
1062        disc.protocol_version,
1063        disc.refs.len()
1064    );
1065    if disc.protocol_version >= 2 {
1066        net_trace!("http_fetch: delegating to v2 stateless fetch");
1067        return http_fetch_v2(client, local_git_dir, repo_url, &disc, opts, progress);
1068    }
1069
1070    let local_odb = open_odb(local_git_dir);
1071
1072    let default_branch = disc
1073        .head_symref
1074        .as_deref()
1075        .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned());
1076
1077    let remote_refs: Vec<(String, ObjectId)> = disc
1078        .refs
1079        .iter()
1080        .filter(|r| r.name != "HEAD" && !r.name.ends_with("^{}"))
1081        .map(|r| (r.name.clone(), r.oid))
1082        .collect();
1083
1084    // 2. Parse refspecs.
1085    let mut positive: Vec<RefspecItem> = Vec::new();
1086    let mut negatives: Vec<RefspecItem> = Vec::new();
1087    for spec in &opts.refspecs {
1088        let item = parse_fetch_refspec(spec)
1089            .map_err(|e| Error::Message(format!("invalid refspec '{spec}': {e}")))?;
1090        if item.negative {
1091            negatives.push(item);
1092        } else {
1093            positive.push(item);
1094        }
1095    }
1096    for spec in &opts.negative_refspecs {
1097        let item = parse_fetch_refspec(spec)
1098            .map_err(|e| Error::Message(format!("invalid negative refspec '{spec}': {e}")))?;
1099        negatives.push(item);
1100    }
1101
1102    // 3. Match refs to refspecs.
1103    let MatchPlan {
1104        mut matched,
1105        mut wants,
1106        mut seen,
1107    } = match_refspecs(&remote_refs, &positive, &negatives);
1108
1109    // 4. TagMode: add tags (the wire `include-tag` capability brings tag
1110    // objects with the pack; All adds every advertised tag, Following adds them
1111    // provisionally and prunes unreachable ones after the pack lands).
1112    if opts.tags != TagMode::None {
1113        for (name, oid) in &remote_refs {
1114            if !name.starts_with("refs/tags/") {
1115                continue;
1116            }
1117            if seen.contains(name) || ref_excluded(name, &negatives) {
1118                continue;
1119            }
1120            seen.insert(name.clone());
1121            wants.insert(*oid);
1122            matched.push(crate::transfer::MatchedRef {
1123                remote_ref: name.clone(),
1124                local_ref: Some(name.clone()),
1125                oid: *oid,
1126                force: false,
1127                is_tag: true,
1128            });
1129        }
1130    }
1131
1132    // 5. Wants → negotiate + ingest the pack. Normally the matched oids absent
1133    // locally; for a deepen/`--unshallow` request we must still `want` the tips
1134    // even if present so the server fills in ancestors past the old boundary.
1135    let local_shallow = crate::shallow::load_shallow_oids(local_git_dir)?;
1136    let shallow_request = opts.has_deepen_request() || !local_shallow.is_empty();
1137    let need: Vec<ObjectId> = if shallow_request {
1138        wants.iter().copied().collect()
1139    } else {
1140        wants
1141            .iter()
1142            .copied()
1143            .filter(|oid| !local_odb.exists(oid))
1144            .collect()
1145    };
1146
1147    let mut shallow_update = crate::fetch::ShallowUpdate::default();
1148
1149    if !need.is_empty() && !opts.dry_run {
1150        let (pack, su) = negotiate_pack_http(
1151            client,
1152            local_git_dir,
1153            repo_url,
1154            &disc.caps,
1155            &disc.refs,
1156            &need,
1157            opts,
1158            &local_shallow,
1159            progress,
1160        )?;
1161        shallow_update = su;
1162        if !pack.is_empty() {
1163            if pack.len() < 12 || &pack[0..4] != b"PACK" {
1164                return Err(Error::Message(
1165                    "did not receive a valid pack from HTTP fetch".to_owned(),
1166                ));
1167            }
1168            let mut cursor = Cursor::new(pack);
1169            crate::unpack_objects::unpack_objects(
1170                &mut cursor,
1171                &local_odb,
1172                &crate::unpack_objects::UnpackOptions {
1173                    quiet: true,
1174                    ..Default::default()
1175                },
1176            )?;
1177        }
1178    }
1179
1180    // Apply shallow/unshallow boundary updates to the on-disk `shallow` file.
1181    if !opts.dry_run {
1182        crate::shallow::apply_shallow_updates(
1183            local_git_dir,
1184            &shallow_update.shallow,
1185            &shallow_update.unshallow,
1186        )?;
1187    }
1188
1189    // 6. For TagMode::Following, drop tags whose target did not arrive.
1190    if opts.tags == TagMode::Following {
1191        retain_following_tags(&local_odb, &mut matched, &wants);
1192    }
1193
1194    // 7. Classify + apply ref updates.
1195    let local_repo = if opts.dry_run {
1196        None
1197    } else {
1198        crate::repo::Repository::open(local_git_dir, None).ok()
1199    };
1200
1201    let mut updates: Vec<RefUpdate> = Vec::new();
1202    if opts.prune {
1203        prune_tracking_refs(
1204            local_git_dir,
1205            &positive,
1206            &remote_refs,
1207            opts.dry_run,
1208            &mut updates,
1209        )?;
1210    }
1211
1212    for m in &matched {
1213        let Some(local_ref) = &m.local_ref else {
1214            updates.push(RefUpdate {
1215                remote_ref: m.remote_ref.clone(),
1216                local_ref: None,
1217                old_oid: None,
1218                new_oid: Some(m.oid),
1219                mode: UpdateMode::NoChangeNeeded,
1220                note: Some("not stored (empty destination)".to_owned()),
1221            });
1222            continue;
1223        };
1224        let old = crate::refs::resolve_ref(local_git_dir, local_ref).ok();
1225        let mode = classify_update(old.as_ref(), &m.oid, m.force, m.is_tag, local_repo.as_ref());
1226        let write = matches!(
1227            mode,
1228            UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
1229        );
1230        if write && !opts.dry_run {
1231            crate::refs::write_ref(local_git_dir, local_ref, &m.oid)?;
1232        }
1233        updates.push(RefUpdate {
1234            remote_ref: m.remote_ref.clone(),
1235            local_ref: Some(local_ref.clone()),
1236            old_oid: old,
1237            new_oid: Some(m.oid),
1238            mode,
1239            note: None,
1240        });
1241    }
1242
1243    net_trace!("http_fetch: done — {} ref update(s)", updates.len());
1244    Ok(FetchOutcome {
1245        updates,
1246        default_branch,
1247        new_shallow: shallow_update.shallow,
1248        new_unshallow: shallow_update.unshallow,
1249    })
1250}
1251
1252/// Fetch from a smart-HTTP remote that speaks protocol v2 (stateless multi-POST).
1253///
1254/// `disc` is the already-parsed v2 capability advertisement (no refs). This
1255/// recovers the ref map with a `command=ls-refs` POST, matches refspecs / tags
1256/// with the same shared [`crate::transfer`] helpers as the v0/v1 path, then
1257/// negotiates the pack with `command=fetch` POSTs (each round resends the
1258/// capability echo, all `want`s, and the accumulated `have`s) and demuxes the
1259/// side-band-64k `packfile` section. Lifted from the CLI's stateless v2 flow
1260/// (`http_ls_refs` / `http_negotiate_only_common` / `http_fetch_pack`), reusing
1261/// the v2 request framing factored out of [`crate::fetch`].
1262fn http_fetch_v2(
1263    client: &dyn HttpClient,
1264    local_git_dir: &Path,
1265    repo_url: &str,
1266    disc: &Discovery,
1267    opts: &FetchOptions,
1268    progress: &mut dyn Progress,
1269) -> Result<FetchOutcome> {
1270    let local_odb = open_odb(local_git_dir);
1271    // The v2 capability lines, as a `Vec<String>` for the `protocol_v2` /
1272    // `crate::fetch` helpers (each entry is one advertised capability line, e.g.
1273    // `agent=…`, `fetch=…`, `object-format=…`).
1274    let server_caps: Vec<String> = disc.caps.iter().cloned().collect();
1275
1276    let post_url = upload_pack_url(repo_url);
1277    let content_type = format!("application/x-{UPLOAD_PACK}-request");
1278    let accept = format!("application/x-{UPLOAD_PACK}-result");
1279    // Pin v2 on every POST so the server runs its v2 serve loop for this request.
1280    let git_protocol = "version=2";
1281
1282    // 1. Recover the ref map via `command=ls-refs`.
1283    let (remote_refs, head_symref) = {
1284        let req =
1285            crate::fetch::build_v2_ls_refs_request(&server_caps, &local_odb, opts.tags, &opts.refspecs)?;
1286        let resp = client.post(&post_url, &content_type, &accept, &req, Some(git_protocol))?;
1287        let mut cur = Cursor::new(resp);
1288        crate::fetch::parse_v2_ls_refs_response(&mut cur)?
1289    };
1290    let default_branch = head_symref
1291        .as_deref()
1292        .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned());
1293
1294    // 2. Parse refspecs.
1295    let mut positive: Vec<RefspecItem> = Vec::new();
1296    let mut negatives: Vec<RefspecItem> = Vec::new();
1297    for spec in &opts.refspecs {
1298        let item = parse_fetch_refspec(spec)
1299            .map_err(|e| Error::Message(format!("invalid refspec '{spec}': {e}")))?;
1300        if item.negative {
1301            negatives.push(item);
1302        } else {
1303            positive.push(item);
1304        }
1305    }
1306    for spec in &opts.negative_refspecs {
1307        let item = parse_fetch_refspec(spec)
1308            .map_err(|e| Error::Message(format!("invalid negative refspec '{spec}': {e}")))?;
1309        negatives.push(item);
1310    }
1311
1312    // 3. Match refs to refspecs (shared with the v0/v1 path).
1313    let MatchPlan {
1314        mut matched,
1315        mut wants,
1316        mut seen,
1317    } = match_refspecs(&remote_refs, &positive, &negatives);
1318
1319    // 4. TagMode: add tags (the wire `include-tag` capability brings tag objects
1320    // with the pack; All adds every advertised tag, Following adds them
1321    // provisionally and prunes unreachable ones after the pack lands).
1322    if opts.tags != TagMode::None {
1323        for (name, oid) in &remote_refs {
1324            if !name.starts_with("refs/tags/") {
1325                continue;
1326            }
1327            if seen.contains(name) || ref_excluded(name, &negatives) {
1328                continue;
1329            }
1330            seen.insert(name.clone());
1331            wants.insert(*oid);
1332            matched.push(crate::transfer::MatchedRef {
1333                remote_ref: name.clone(),
1334                local_ref: Some(name.clone()),
1335                oid: *oid,
1336                force: false,
1337                is_tag: true,
1338            });
1339        }
1340    }
1341
1342    // 5. Wants → negotiate + ingest the pack. Normally the matched oids absent
1343    // locally; for a deepen/`--unshallow` request we must still `want` the tips
1344    // even if present so the server fills in ancestors past the old boundary.
1345    let local_shallow = crate::shallow::load_shallow_oids(local_git_dir)?;
1346    let shallow_request = opts.has_deepen_request() || !local_shallow.is_empty();
1347    let need: Vec<ObjectId> = if shallow_request {
1348        wants.iter().copied().collect()
1349    } else {
1350        wants
1351            .iter()
1352            .copied()
1353            .filter(|oid| !local_odb.exists(oid))
1354            .collect()
1355    };
1356
1357    let mut shallow_update = crate::fetch::ShallowUpdate::default();
1358
1359    if !need.is_empty() && !opts.dry_run {
1360        let deepen = crate::fetch::V2DeepenArgs::from_opts(opts, &local_shallow);
1361        let (pack, su) = negotiate_pack_v2_http(
1362            client,
1363            local_git_dir,
1364            &post_url,
1365            &content_type,
1366            &accept,
1367            git_protocol,
1368            &server_caps,
1369            &local_odb,
1370            &need,
1371            &deepen,
1372            progress,
1373        )?;
1374        shallow_update = su;
1375        if !pack.is_empty() {
1376            if pack.len() < 12 || &pack[0..4] != b"PACK" {
1377                return Err(Error::Message(
1378                    "did not receive a valid pack from v2 HTTP fetch".to_owned(),
1379                ));
1380            }
1381            let mut cursor = Cursor::new(pack);
1382            crate::unpack_objects::unpack_objects(
1383                &mut cursor,
1384                &local_odb,
1385                &crate::unpack_objects::UnpackOptions {
1386                    quiet: true,
1387                    ..Default::default()
1388                },
1389            )?;
1390        }
1391    }
1392
1393    // Apply shallow/unshallow boundary updates to the on-disk `shallow` file.
1394    if !opts.dry_run {
1395        crate::shallow::apply_shallow_updates(
1396            local_git_dir,
1397            &shallow_update.shallow,
1398            &shallow_update.unshallow,
1399        )?;
1400    }
1401
1402    // 6. For TagMode::Following, drop tags whose target did not arrive.
1403    if opts.tags == TagMode::Following {
1404        retain_following_tags(&local_odb, &mut matched, &wants);
1405    }
1406
1407    // 7. Classify + apply ref updates (shared with the v0/v1 path).
1408    let local_repo = if opts.dry_run {
1409        None
1410    } else {
1411        crate::repo::Repository::open(local_git_dir, None).ok()
1412    };
1413
1414    let mut updates: Vec<RefUpdate> = Vec::new();
1415    if opts.prune {
1416        prune_tracking_refs(
1417            local_git_dir,
1418            &positive,
1419            &remote_refs,
1420            opts.dry_run,
1421            &mut updates,
1422        )?;
1423    }
1424
1425    for m in &matched {
1426        let Some(local_ref) = &m.local_ref else {
1427            updates.push(RefUpdate {
1428                remote_ref: m.remote_ref.clone(),
1429                local_ref: None,
1430                old_oid: None,
1431                new_oid: Some(m.oid),
1432                mode: UpdateMode::NoChangeNeeded,
1433                note: Some("not stored (empty destination)".to_owned()),
1434            });
1435            continue;
1436        };
1437        let old = crate::refs::resolve_ref(local_git_dir, local_ref).ok();
1438        let mode = classify_update(old.as_ref(), &m.oid, m.force, m.is_tag, local_repo.as_ref());
1439        let write = matches!(
1440            mode,
1441            UpdateMode::New | UpdateMode::FastForward | UpdateMode::Forced
1442        );
1443        if write && !opts.dry_run {
1444            crate::refs::write_ref(local_git_dir, local_ref, &m.oid)?;
1445        }
1446        updates.push(RefUpdate {
1447            remote_ref: m.remote_ref.clone(),
1448            local_ref: Some(local_ref.clone()),
1449            old_oid: old,
1450            new_oid: Some(m.oid),
1451            mode,
1452            note: None,
1453        });
1454    }
1455
1456    crate::net_trace::net_trace!("http_fetch (v2): done — {} ref update(s)", updates.len());
1457    Ok(FetchOutcome {
1458        updates,
1459        default_branch,
1460        new_shallow: shallow_update.shallow,
1461        new_unshallow: shallow_update.unshallow,
1462    })
1463}
1464
1465/// Negotiate and download the pack for `wants` over stateless-RPC HTTP using
1466/// protocol v2 (`command=fetch`), returning the raw pack bytes.
1467///
1468/// Stateless: every POST resends the capability echo, every `want`, and all the
1469/// `have`s accumulated so far. The round structure mirrors the v0/v1 stateless
1470/// loop and the streaming v2 path:
1471///
1472/// * no local history → a single POST with `want`s + `done`, then read the
1473///   `packfile` section;
1474/// * otherwise → batched rounds that send `want`s + the growing have-prefix
1475///   *without* `done`, reading the `acknowledgments` section each time. When the
1476///   server replies `ready`, that same response carries the pack (read it and
1477///   stop). If the haves are exhausted without `ready`, a final POST sends every
1478///   have + `done` and reads the pack.
1479#[allow(clippy::too_many_arguments)]
1480fn negotiate_pack_v2_http(
1481    client: &dyn HttpClient,
1482    local_git_dir: &Path,
1483    post_url: &str,
1484    content_type: &str,
1485    accept: &str,
1486    git_protocol: &str,
1487    server_caps: &[String],
1488    local_odb: &crate::odb::Odb,
1489    wants: &[ObjectId],
1490    deepen: &crate::fetch::V2DeepenArgs,
1491    progress: &mut dyn Progress,
1492) -> Result<(Vec<u8>, crate::fetch::ShallowUpdate)> {
1493    if wants.is_empty() {
1494        return Ok((Vec::new(), crate::fetch::ShallowUpdate::default()));
1495    }
1496    let object_format = crate::fetch::v2_object_format(server_caps, local_odb);
1497    let cap_echo = protocol_v2::cap_lines_for_command_request(server_caps);
1498    let sideband_all = protocol_v2::fetch_supports_sideband_all(server_caps);
1499
1500    // A deepen/shallow request does not offer haves (its objects bottom out at
1501    // grafts), forcing the single-round path so the server precedes the pack with
1502    // a `shallow-info` section.
1503    let shallow_request = deepen.is_shallow_request();
1504
1505    // The ordered have list, built with the shared skipping-negotiator helper so
1506    // the wire offers match the streaming v2 path exactly. Empty for a shallow
1507    // request.
1508    let haves = if shallow_request {
1509        Vec::new()
1510    } else {
1511        crate::fetch::v2_local_haves(local_git_dir, wants)?
1512    };
1513
1514    let mut pack = Vec::new();
1515    let mut shallow_update = crate::fetch::ShallowUpdate::default();
1516
1517    // No local history: one POST, wants + done, then the pack.
1518    if haves.is_empty() {
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            &[],
1526            sideband_all,
1527            deepen,
1528            true,
1529        )?;
1530        let resp = client.post(post_url, content_type, accept, &req, Some(git_protocol))?;
1531        let mut cur = Cursor::new(resp);
1532        crate::fetch::read_v2_fetch_pack_response(&mut cur, &mut pack, &mut shallow_update, progress)?;
1533        return Ok((pack, shallow_update));
1534    }
1535
1536    // Batched negotiation: each round resends wants + the accumulated have prefix
1537    // (stateless) without `done`, reading the acknowledgments section. The flush
1538    // schedule matches `fetch-pack.c` (`next_flush`).
1539    const INITIAL_FLUSH: usize = 16;
1540    let mut flush_at: usize = INITIAL_FLUSH.min(haves.len());
1541    loop {
1542        if flush_at < haves.len() {
1543            // Non-final round: offer the have prefix [0..flush_at) without `done`.
1544            let mut req = Vec::new();
1545            crate::fetch::write_v2_fetch_request(
1546                &mut req,
1547                &object_format,
1548                &cap_echo,
1549                wants,
1550                &haves[..flush_at],
1551                sideband_all,
1552                deepen,
1553                false,
1554            )?;
1555            let resp = client.post(post_url, content_type, accept, &req, Some(git_protocol))?;
1556            let mut cur = Cursor::new(resp);
1557            let ack = crate::fetch::read_v2_acknowledgments(&mut cur)?;
1558            if let Some(round) = ack {
1559                if round.ready {
1560                    // The pack follows in this same response after the delimiter.
1561                    crate::fetch::read_v2_fetch_pack_response(
1562                        &mut cur,
1563                        &mut pack,
1564                        &mut shallow_update,
1565                        progress,
1566                    )?;
1567                    return Ok((pack, shallow_update));
1568                }
1569            } else {
1570                // Server skipped acknowledgments and went straight to the pack.
1571                crate::fetch::read_v2_fetch_pack_response(
1572                    &mut cur,
1573                    &mut pack,
1574                    &mut shallow_update,
1575                    progress,
1576                )?;
1577                return Ok((pack, shallow_update));
1578            }
1579            flush_at = next_flush(flush_at).min(haves.len());
1580            continue;
1581        }
1582
1583        // Final round: send every have + `done`, then read the pack.
1584        let mut req = Vec::new();
1585        crate::fetch::write_v2_fetch_request(
1586            &mut req,
1587            &object_format,
1588            &cap_echo,
1589            wants,
1590            &haves,
1591            sideband_all,
1592            deepen,
1593            true,
1594        )?;
1595        let resp = client.post(post_url, content_type, accept, &req, Some(git_protocol))?;
1596        let mut cur = Cursor::new(resp);
1597        crate::fetch::read_v2_fetch_pack_response(&mut cur, &mut pack, &mut shallow_update, progress)?;
1598        return Ok((pack, shallow_update));
1599    }
1600}
1601
1602/// Drop provisional `Following` tags whose object did not arrive in the pack.
1603fn retain_following_tags(
1604    odb: &crate::odb::Odb,
1605    matched: &mut Vec<crate::transfer::MatchedRef>,
1606    wants: &HashSet<ObjectId>,
1607) {
1608    let roots: Vec<ObjectId> = matched.iter().filter(|m| !m.is_tag).map(|m| m.oid).collect();
1609    let closure = reachable_closure(odb, &roots);
1610    matched.retain(|m| {
1611        if !m.is_tag {
1612            return true;
1613        }
1614        let peeled = peel_tag_target(odb, m.oid);
1615        let have = odb.exists(&m.oid);
1616        have && (closure.contains(&m.oid) || closure.contains(&peeled) || wants.contains(&peeled))
1617    });
1618}
1619
1620fn peel_tag_target(odb: &crate::odb::Odb, oid: ObjectId) -> ObjectId {
1621    let mut current = oid;
1622    for _ in 0..16 {
1623        let Ok(obj) = odb.read(&current) else {
1624            return current;
1625        };
1626        if obj.kind != crate::objects::ObjectKind::Tag {
1627            return current;
1628        }
1629        match crate::objects::parse_tag(&obj.data) {
1630            Ok(t) => current = t.object,
1631            Err(_) => return current,
1632        }
1633    }
1634    current
1635}
1636
1637fn reachable_closure(odb: &crate::odb::Odb, roots: &[ObjectId]) -> HashSet<ObjectId> {
1638    use crate::objects::{parse_commit, parse_tag, parse_tree, ObjectKind};
1639    let mut seen: HashSet<ObjectId> = HashSet::new();
1640    let mut stack: Vec<ObjectId> = roots.to_vec();
1641    while let Some(oid) = stack.pop() {
1642        if !seen.insert(oid) {
1643            continue;
1644        }
1645        let Ok(obj) = odb.read(&oid) else {
1646            continue;
1647        };
1648        match obj.kind {
1649            ObjectKind::Commit => {
1650                if let Ok(c) = parse_commit(&obj.data) {
1651                    stack.push(c.tree);
1652                    for p in c.parents {
1653                        stack.push(p);
1654                    }
1655                }
1656            }
1657            ObjectKind::Tree => {
1658                if let Ok(entries) = parse_tree(&obj.data) {
1659                    for e in entries {
1660                        stack.push(e.oid);
1661                    }
1662                }
1663            }
1664            ObjectKind::Tag => {
1665                if let Ok(t) = parse_tag(&obj.data) {
1666                    stack.push(t.object);
1667                }
1668            }
1669            ObjectKind::Blob => {}
1670        }
1671    }
1672    seen
1673}
1674
1675/// Convenience: the unused-by-default [`Advertisement`] shape, exported so an
1676/// embedder can reuse the same structured view as the duplex transports.
1677pub fn discovery_advertisement(conn: &SmartHttpConnection) -> Advertisement {
1678    Advertisement {
1679        refs: conn.adv_refs.clone(),
1680        capabilities: conn.caps.clone(),
1681        head_symref: conn.head_symref.clone(),
1682        protocol_version: conn.protocol_version,
1683    }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use super::*;
1689
1690    #[test]
1691    fn strips_smart_service_preamble() {
1692        let mut body = Vec::new();
1693        pkt_line::write_line_to_vec(&mut body, "# service=git-upload-pack\n").unwrap();
1694        body.extend_from_slice(b"0000");
1695        let oid = "1".repeat(40);
1696        let line = format!("{oid} refs/heads/main\0multi_ack_detailed side-band-64k");
1697        pkt_line::write_line_to_vec(&mut body, &line).unwrap();
1698        body.extend_from_slice(b"0000");
1699
1700        let stripped = strip_service_advertisement(&body).unwrap();
1701        let disc = parse_advertisement(stripped).unwrap();
1702        assert_eq!(disc.protocol_version, 0);
1703        assert_eq!(disc.refs.len(), 1);
1704        assert_eq!(disc.refs[0].name, "refs/heads/main");
1705        assert!(disc.caps.contains("side-band-64k"));
1706    }
1707
1708    #[test]
1709    fn parses_symref_and_caps() {
1710        let mut body = Vec::new();
1711        let main = "2".repeat(40);
1712        let head = format!(
1713            "{main} HEAD\0multi_ack_detailed symref=HEAD:refs/heads/main object-format=sha1"
1714        );
1715        pkt_line::write_line_to_vec(&mut body, &head).unwrap();
1716        let r = format!("{main} refs/heads/main");
1717        pkt_line::write_line_to_vec(&mut body, &r).unwrap();
1718        body.extend_from_slice(b"0000");
1719
1720        let disc = parse_advertisement(&body).unwrap();
1721        assert_eq!(disc.head_symref.as_deref(), Some("refs/heads/main"));
1722        assert_eq!(disc.object_format, "sha1");
1723        // `parse_advertisement` keeps HEAD; the connection/fetch layer filters
1724        // HEAD and peeled `^{}` carriers. Both lines parse here.
1725        assert!(disc.refs.iter().any(|r| r.name == "HEAD"));
1726        assert!(disc.refs.iter().any(|r| r.name == "refs/heads/main"));
1727    }
1728
1729    #[test]
1730    fn detects_v2_preamble() {
1731        let mut body = Vec::new();
1732        pkt_line::write_line_to_vec(&mut body, "version 2").unwrap();
1733        pkt_line::write_line_to_vec(&mut body, "ls-refs").unwrap();
1734        pkt_line::write_line_to_vec(&mut body, "object-format=sha256").unwrap();
1735        body.extend_from_slice(b"0000");
1736        let disc = parse_advertisement(&body).unwrap();
1737        assert_eq!(disc.protocol_version, 2);
1738        assert_eq!(disc.object_format, "sha256");
1739    }
1740
1741    #[test]
1742    fn url_helpers() {
1743        assert_eq!(
1744            info_refs_url("http://h/r.git"),
1745            "http://h/r.git/info/refs?service=git-upload-pack"
1746        );
1747        assert_eq!(
1748            info_refs_url("http://h/r.git/"),
1749            "http://h/r.git/info/refs?service=git-upload-pack"
1750        );
1751        assert_eq!(upload_pack_url("http://h/r.git/"), "http://h/r.git/git-upload-pack");
1752    }
1753}