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