Skip to main content

grit_lib/remote/
core.rs

1//! Unified remote URL typing and transport dispatch for fetch, push, and ref listing.
2//!
3//! [`RemoteUrl`] classifies configured or literal remote URLs. [`Remote`] holds the
4//! resolved URL(s), fetch refspecs, and dispatches to the appropriate grit-lib
5//! transport (local, `git://`, SSH, smart HTTP).
6
7use std::io::{Cursor, Read};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use thiserror::Error;
12
13use crate::config::ConfigSet;
14#[cfg(feature = "http-ureq")]
15use crate::credentials::{
16    askpass_program, Credential, CredentialProvider, HelperCredentialProvider,
17};
18#[cfg(feature = "http-ureq")]
19use crate::environment::Environment;
20use crate::error::Error;
21use crate::fetch::{fetch_remote_into, Progress};
22use crate::objects::ObjectId;
23use crate::odb::Odb;
24use crate::push::push_remote_from;
25use crate::refs::listing::{
26    compare_version_names, patterns_match_ls_remote, RefQueryOptions, RefSort, SortKey,
27};
28use crate::repo::Repository;
29use crate::transfer::{
30    fetch_local_into, push_local_from, FetchOptions, FetchOutcome, PrePushGate, PushOptions,
31    PushOutcome, PushRefSpec,
32};
33use crate::transport::http::{HttpClient, SmartHttpTransport};
34use crate::transport::{ConnectOptions, GitDaemonTransport, Service, SshTransport, Transport};
35use crate::transport_path::{
36    is_local_path_remote_url, resolve_local_remote_git_dir, url_is_local_not_ssh,
37};
38use crate::url_rewrite;
39
40#[cfg(feature = "http-ureq")]
41use crate::transport::http::ureq_client::UreqHttpClient;
42
43/// HTTP client types for non-git HTTP (e.g. OAuth) without pulling transport internals into binaries.
44#[cfg(feature = "http-ureq")]
45pub mod http_client {
46    pub use crate::transport::http::ureq_client::UreqHttpClient;
47    pub use crate::transport::http::HttpClient;
48}
49
50/// Result of a remote operation ([`RemoteError`] on failure).
51pub type RemoteResult<T> = std::result::Result<T, RemoteError>;
52
53/// Default remote name when none is configured (`origin`, matching Git).
54pub const DEFAULT_REMOTE: &str = "origin";
55
56/// Whether `name` can name a remote: its tracking refs `refs/remotes/<name>/…` must be valid.
57#[must_use]
58pub(crate) fn is_valid_remote_name(name: &str) -> bool {
59    use crate::check_ref_format::{check_refname_format, RefNameOptions};
60    !name.is_empty()
61        && check_refname_format(
62            &format!("refs/remotes/{name}/test"),
63            &RefNameOptions::default(),
64        )
65        .is_ok()
66}
67
68/// Errors specific to remote URL resolution and dispatch.
69#[derive(Debug, Error)]
70pub enum RemoteError {
71    /// A remote has no URL: `remote.<name>.url` is unset or blank, or a literal URL was empty.
72    #[error("{}", match remote {
73        Some(name) => format!("remote '{name}' has no URL configured"),
74        None => "remote URL is empty".to_owned(),
75    })]
76    NoUrl {
77        /// The configured remote's name; `None` when an empty literal URL was given.
78        remote: Option<String>,
79    },
80    /// The URL uses a scheme grit does not support for remotes.
81    #[error("unsupported remote URL scheme")]
82    UnsupportedScheme,
83    /// A URL or path failed to parse.
84    #[error("{0}")]
85    InvalidUrl(String),
86    /// Smart HTTP requires the `http-ureq` feature or an injected HTTP client factory.
87    #[error("HTTP remotes require the http-ureq feature or an HttpClientFactory")]
88    HttpUnavailable,
89    /// The argument is neither a configured remote name nor a URL or path.
90    #[error("unknown remote '{0}'")]
91    UnknownRemote(String),
92    /// [`RepositoryRemotes::add`](crate::repo_handles::RepositoryRemotes::add) found an
93    /// existing `remote.<name>.*` section.
94    #[error("remote '{0}' already exists")]
95    RemoteExists(String),
96    /// The name cannot be used for a remote (`refs/remotes/<name>/…` would not be a valid ref).
97    #[error("'{0}' is not a valid remote name")]
98    InvalidRemoteName(String),
99    /// An underlying library error.
100    #[error(transparent)]
101    Library(#[from] Error),
102}
103
104impl RemoteError {
105    /// Whether the remote could not be read or written at all ([`Error::RemoteUnreadable`]).
106    #[must_use]
107    pub fn is_remote_unreadable(&self) -> bool {
108        matches!(self, Self::Library(e) if e.is_remote_unreadable())
109    }
110
111    /// Whether the operation was cancelled ([`Error::Cancelled`]).
112    #[must_use]
113    pub fn is_cancelled(&self) -> bool {
114        matches!(self, Self::Library(e) if e.is_cancelled())
115    }
116}
117
118impl From<RemoteError> for Error {
119    fn from(e: RemoteError) -> Self {
120        match e {
121            RemoteError::Library(err) => err,
122            other => Error::Message(other.to_string()),
123        }
124    }
125}
126
127/// A parsed remote URL by transport kind.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum RemoteUrl {
130    /// Scheme-less or relative filesystem path (not `file://`).
131    Local(PathBuf),
132    /// `file://` URL decoded to a filesystem path.
133    File(PathBuf),
134    /// `git://` daemon URL (stored verbatim for the transport).
135    Git(String),
136    /// `ssh://`, `git+ssh://`, or scp-style SSH URL.
137    Ssh(String),
138    /// `http://` smart HTTP URL.
139    Http(String),
140    /// `https://` smart HTTP URL.
141    Https(String),
142    /// On-disk git bundle file (detected by signature, not extension).
143    Bundle(PathBuf),
144}
145
146impl RemoteUrl {
147    /// Whether this URL is fetched/pushed via a local filesystem path.
148    #[must_use]
149    pub fn is_local(&self) -> bool {
150        matches!(self, Self::Local(_) | Self::File(_))
151    }
152
153    /// Wire URL string for transports that take a URL (not local paths).
154    #[must_use]
155    pub fn as_wire_url(&self) -> Option<&str> {
156        match self {
157            Self::Git(s) | Self::Ssh(s) | Self::Http(s) | Self::Https(s) => Some(s.as_str()),
158            Self::Local(_) | Self::File(_) | Self::Bundle(_) => None,
159        }
160    }
161
162    /// Original configured string form (paths normalized for display).
163    #[must_use]
164    pub fn to_url_string(&self) -> String {
165        match self {
166            Self::Local(p) | Self::File(p) | Self::Bundle(p) => p.to_string_lossy().into_owned(),
167            Self::Git(s) | Self::Ssh(s) | Self::Http(s) | Self::Https(s) => s.clone(),
168        }
169    }
170}
171
172impl TryFrom<&str> for RemoteUrl {
173    type Error = RemoteError;
174
175    fn try_from(url: &str) -> std::result::Result<Self, Self::Error> {
176        parse_remote_url(url)
177    }
178}
179
180/// Parse `url` into a [`RemoteUrl`] without applying config rewrites.
181pub fn parse_remote_url(url: &str) -> RemoteResult<RemoteUrl> {
182    let url = url.trim();
183    if url.is_empty() {
184        return Err(RemoteError::NoUrl { remote: None });
185    }
186    if url.starts_with("file://") {
187        let path = crate::transport_path::file_url_to_local_path(url)
188            .map_err(|e| RemoteError::InvalidUrl(e.to_string()))?;
189        let path = PathBuf::from(path);
190        if crate::bundle_remote::is_bundle_path(&path) {
191            return Ok(RemoteUrl::Bundle(path));
192        }
193        return Ok(RemoteUrl::File(path));
194    }
195    if url.starts_with("git://") {
196        crate::transport::parse_git_url(url).map_err(|e| RemoteError::InvalidUrl(e.to_string()))?;
197        return Ok(RemoteUrl::Git(url.to_owned()));
198    }
199    if url.starts_with("http://") {
200        return Ok(RemoteUrl::Http(url.to_owned()));
201    }
202    if url.starts_with("https://") {
203        return Ok(RemoteUrl::Https(url.to_owned()));
204    }
205    if crate::transport::is_ssh_url(url) {
206        crate::transport::parse_ssh_url(url).map_err(|e| RemoteError::InvalidUrl(e.to_string()))?;
207        return Ok(RemoteUrl::Ssh(url.to_owned()));
208    }
209    if is_local_path_remote_url(url) && url_is_local_not_ssh(url) {
210        let path = PathBuf::from(url);
211        if crate::bundle_remote::is_bundle_path(&path) {
212            return Ok(RemoteUrl::Bundle(path));
213        }
214        return Ok(RemoteUrl::Local(path));
215    }
216    Err(RemoteError::UnsupportedScheme)
217}
218
219/// A single reference returned by [`Remote::list_refs`].
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct RemoteRef {
222    /// Full reference name (`HEAD`, `refs/heads/main`, `refs/tags/v1^{}`, …).
223    pub name: String,
224    /// Object id the ref resolves to.
225    pub oid: ObjectId,
226    /// Symbolic-ref target when [`ListRefsOptions::symrefs`] is set (`HEAD` only on v0/v1).
227    pub symref_target: Option<String>,
228}
229
230impl RemoteRef {
231    /// For a peeled `refs/tags/<tag>^{}` line ([`ListRefsOptions::peel`]), the tag ref it
232    /// belongs to; `None` for every other ref.
233    #[must_use]
234    pub fn peeled_tag(&self) -> Option<&str> {
235        self.name.strip_suffix("^{}")
236    }
237}
238
239/// One advertised ref with its peeled `^{}` line folded in (see [`fold_peeled_refs`]).
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct PeeledRemoteRef {
242    /// Full reference name (never ends in `^{}`).
243    pub name: String,
244    /// Object id the ref points at (the tag object for annotated tags).
245    pub oid: ObjectId,
246    /// Object the annotated tag peels to, when the listing carried a `^{}` line for it.
247    pub peeled: Option<ObjectId>,
248    /// Symbolic-ref target, as in [`RemoteRef::symref_target`].
249    pub symref_target: Option<String>,
250}
251
252/// Fold peeled `^{}` lines from a [`Remote::list_refs`] result into their tag refs.
253///
254/// Keeps the order of `refs` and drops the `^{}` rows themselves.
255#[must_use]
256pub fn fold_peeled_refs(refs: &[RemoteRef]) -> Vec<PeeledRemoteRef> {
257    let peeled: std::collections::HashMap<&str, ObjectId> = refs
258        .iter()
259        .filter_map(|r| Some((r.peeled_tag()?, r.oid)))
260        .collect();
261    refs.iter()
262        .filter(|r| r.peeled_tag().is_none())
263        .map(|r| PeeledRemoteRef {
264            name: r.name.clone(),
265            oid: r.oid,
266            peeled: peeled.get(r.name.as_str()).copied(),
267            symref_target: r.symref_target.clone(),
268        })
269        .collect()
270}
271
272/// Options controlling [`Remote::list_refs`] output, aligned with `git ls-remote`.
273#[derive(Debug, Default, Clone)]
274pub struct ListRefsOptions {
275    /// If non-empty, only refs matching one of these patterns (same rules as `git ls-remote`).
276    pub prefixes: Vec<String>,
277    /// Restrict to `refs/heads/` entries.
278    pub heads: bool,
279    /// Restrict to `refs/tags/` entries.
280    pub tags: bool,
281    /// Include symbolic-ref targets on `HEAD` (and all symrefs on v2 when supported).
282    pub symrefs: bool,
283    /// Append peeled `^{}` lines for annotated tags.
284    pub peel: bool,
285    /// Requested wire protocol version for remote listing (`None` → prefer v2).
286    pub protocol_version: Option<u8>,
287    /// Sort key and direction (after filtering; matches `git ls-remote --sort`).
288    pub sort: RefSort,
289    /// Maximum number of lines to return after sorting (`None` = unlimited).
290    pub limit: Option<usize>,
291    /// `versionsort.suffix` values for [`SortKey::Version`] (Git multi-valued config).
292    pub versionsort_suffixes: Vec<String>,
293}
294
295/// Builds HTTP clients for smart-HTTP remotes.
296pub trait HttpClientFactory: Send + Sync {
297    /// Create a client honoring repository HTTP/credential config.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error when the client cannot be constructed.
302    fn create(&self, config: &ConfigSet) -> RemoteResult<Box<dyn HttpClient>>;
303
304    /// Create a client for a best-effort request whose failure the caller ignores,
305    /// such as the advertisement [`load_push_plan_context`](crate::remote::push_plan::load_push_plan_context)
306    /// reads before a push.
307    ///
308    /// Such a client must not erase credentials the server refuses (no
309    /// [`CredentialProvider::reject`](crate::credentials::CredentialProvider::reject)):
310    /// the request that reports the failure does that, so it can tell the user the
311    /// stored credentials were rejected rather than finding none. A factory whose
312    /// clients never reject (or carry no credentials) can return
313    /// [`create`](Self::create)'s client.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error when the client cannot be constructed.
318    fn create_for_probe(&self, config: &ConfigSet) -> RemoteResult<Box<dyn HttpClient>>;
319}
320
321/// An [`HttpClientFactory`] whose clients are all [probes](HttpClientFactory::create_for_probe)
322/// of the factory a request would otherwise use (the caller's, or the default one).
323pub(crate) struct ProbeHttpClientFactory<'a> {
324    inner: Option<&'a dyn HttpClientFactory>,
325    #[cfg(feature = "http-ureq")]
326    default: DefaultHttpClientFactory,
327}
328
329impl<'a> ProbeHttpClientFactory<'a> {
330    /// Probes of `inner`, or of the default factory for `repo` when there is none.
331    pub(crate) fn new(inner: Option<&'a dyn HttpClientFactory>, repo: &Repository) -> Self {
332        #[cfg(not(feature = "http-ureq"))]
333        let _ = repo;
334        Self {
335            inner,
336            #[cfg(feature = "http-ureq")]
337            default: DefaultHttpClientFactory::with_environment(repo.environment().clone()),
338        }
339    }
340}
341
342impl HttpClientFactory for ProbeHttpClientFactory<'_> {
343    fn create(&self, config: &ConfigSet) -> RemoteResult<Box<dyn HttpClient>> {
344        if let Some(inner) = self.inner {
345            return inner.create_for_probe(config);
346        }
347        #[cfg(feature = "http-ureq")]
348        {
349            self.default.create_for_probe(config)
350        }
351        #[cfg(not(feature = "http-ureq"))]
352        {
353            let _ = config;
354            Err(RemoteError::HttpUnavailable)
355        }
356    }
357
358    fn create_for_probe(&self, config: &ConfigSet) -> RemoteResult<Box<dyn HttpClient>> {
359        self.create(config)
360    }
361}
362
363/// Fills and approves through the wrapped provider, but never rejects.
364#[cfg(feature = "http-ureq")]
365struct KeepRejectedCredentials<P>(P);
366
367#[cfg(feature = "http-ureq")]
368impl<P: CredentialProvider> CredentialProvider for KeepRejectedCredentials<P> {
369    fn fill(&self, input: &Credential) -> crate::error::Result<Credential> {
370        self.0.fill(input)
371    }
372
373    fn approve(&self, cred: &Credential) -> crate::error::Result<()> {
374        self.0.approve(cred)
375    }
376
377    fn reject(&self, _cred: &Credential) -> crate::error::Result<()> {
378        Ok(())
379    }
380}
381
382/// Default [`HttpClientFactory`] using `UreqHttpClient` (`http-ureq` feature).
383///
384/// Clients authenticate through the configured credential helpers, then the
385/// askpass program from [`askpass_program`]. [`Default`] consults
386/// `core.askPass` only; build with
387/// [`with_environment`](DefaultHttpClientFactory::with_environment) to also
388/// honor `GIT_ASKPASS` and `SSH_ASKPASS`.
389#[cfg(feature = "http-ureq")]
390#[derive(Debug, Default, Clone)]
391pub struct DefaultHttpClientFactory {
392    environment: Option<Environment>,
393}
394
395#[cfg(feature = "http-ureq")]
396impl DefaultHttpClientFactory {
397    /// A factory that consults config only (same as [`Default`]); usable in a
398    /// `static`.
399    #[must_use]
400    pub const fn new() -> Self {
401        Self { environment: None }
402    }
403
404    /// A factory that reads `GIT_ASKPASS` / `SSH_ASKPASS` (and `HOME` for a
405    /// `~/` in `core.askPass`) from `environment`.
406    #[must_use]
407    pub fn with_environment(environment: Environment) -> Self {
408        Self {
409            environment: Some(environment),
410        }
411    }
412
413    fn credential_provider(&self, config: &ConfigSet) -> HelperCredentialProvider {
414        let askpass = match &self.environment {
415            Some(env) => askpass_program(env, config),
416            None => askpass_program(&Environment::empty(), config),
417        };
418        HelperCredentialProvider::new(config.clone()).with_askpass(askpass)
419    }
420
421    fn client(
422        config: &ConfigSet,
423        provider: Box<dyn CredentialProvider + Send + Sync>,
424    ) -> RemoteResult<Box<dyn HttpClient>> {
425        let client = UreqHttpClient::from_config(config)
426            .map_err(|e| RemoteError::InvalidUrl(e.to_string()))?
427            .with_credential_provider(provider);
428        Ok(Box::new(client))
429    }
430}
431
432#[cfg(feature = "http-ureq")]
433impl HttpClientFactory for DefaultHttpClientFactory {
434    fn create(&self, config: &ConfigSet) -> RemoteResult<Box<dyn HttpClient>> {
435        Self::client(config, Box::new(self.credential_provider(config)))
436    }
437
438    fn create_for_probe(&self, config: &ConfigSet) -> RemoteResult<Box<dyn HttpClient>> {
439        Self::client(
440            config,
441            Box::new(KeepRejectedCredentials(self.credential_provider(config))),
442        )
443    }
444}
445
446/// A resolved remote: name (if from config), URL(s), and fetch refspecs.
447#[derive(Clone, Debug)]
448pub struct Remote {
449    /// Configured remote name, if any.
450    pub name: Option<String>,
451    fetch_url: RemoteUrl,
452    push_url: Option<RemoteUrl>,
453    /// Fetch refspecs (`+refs/heads/*:refs/remotes/<name>/*` when unset in config).
454    pub fetch_refspecs: Vec<String>,
455}
456
457impl Remote {
458    /// Load `remote.<name>.url`, optional `pushurl`, and `fetch` refspecs from `config`.
459    ///
460    /// Applies `url.*.insteadOf` / `pushInsteadOf` rewrites.
461    ///
462    /// # Errors
463    ///
464    /// Returns [`RemoteError::NoUrl`] when the remote has no URL.
465    pub fn from_config(config: &ConfigSet, name: &str) -> RemoteResult<Self> {
466        let raw_url = config
467            .get(&format!("remote.{name}.url"))
468            .filter(|u| !u.trim().is_empty())
469            .ok_or_else(|| RemoteError::NoUrl {
470                remote: Some(name.to_owned()),
471            })?;
472        let fetch_url = url_rewrite::rewrite_fetch_url(config, &raw_url);
473        let push_source = config
474            .get(&format!("remote.{name}.pushurl"))
475            .filter(|u| !u.trim().is_empty())
476            .unwrap_or_else(|| raw_url.clone());
477        let push_url = url_rewrite::rewrite_push_url(config, push_source.as_str());
478        let mut fetch_refspecs = config.get_all(&format!("remote.{name}.fetch"));
479        fetch_refspecs.retain(|s| !s.trim().is_empty());
480        if fetch_refspecs.is_empty() {
481            fetch_refspecs = vec![format!("+refs/heads/*:refs/remotes/{name}/*")];
482        }
483        Ok(Self {
484            name: Some(name.to_owned()),
485            fetch_url: RemoteUrl::try_from(fetch_url.as_str())?,
486            push_url: Some(RemoteUrl::try_from(push_url.as_str())?),
487            fetch_refspecs,
488        })
489    }
490
491    /// Default refspec for [`Self::from_url`] — match Git's anonymous fetch: download
492    /// advertised heads but do not write remote-tracking refs (only [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion)).
493    pub const ANONYMOUS_FETCH_REFSPEC: &'static str = "refs/heads/*:";
494
495    /// Build a remote from a literal URL or path (not a configured remote name).
496    ///
497    /// Uses [`Self::ANONYMOUS_FETCH_REFSPEC`]; callers that need tracking refs must
498    /// pass explicit [`FetchOptions::refspecs`].
499    ///
500    /// # Errors
501    ///
502    /// Returns an error when `url` is empty or unsupported.
503    pub fn from_url(url: &str) -> RemoteResult<Self> {
504        Ok(Self {
505            name: None,
506            fetch_url: RemoteUrl::try_from(url)?,
507            push_url: None,
508            fetch_refspecs: vec![Self::ANONYMOUS_FETCH_REFSPEC.to_owned()],
509        })
510    }
511
512    /// Fetch URL after config rewrite (for tests and display).
513    #[must_use]
514    pub fn fetch_url(&self) -> &RemoteUrl {
515        &self.fetch_url
516    }
517
518    /// Push URL when `remote.*.pushurl` is set, else the fetch URL.
519    #[must_use]
520    pub fn push_url(&self) -> &RemoteUrl {
521        self.push_url.as_ref().unwrap_or(&self.fetch_url)
522    }
523
524    /// Fetch from this remote into `repo`.
525    ///
526    /// `http_factory` is required for HTTP(S) remotes unless the `http-ureq` feature is
527    /// enabled and `None` is passed (then the default ureq-backed factory is used).
528    ///
529    /// # Errors
530    ///
531    /// Returns [`RemoteError`] or transport errors on failure. Received pack bytes are written
532    /// into the repository's [`OdbBuilder::primary`](crate::odb::OdbBuilder::primary) store
533    /// (including custom backends) via [`ingest_received_pack`](crate::index_pack::ingest_received_pack).
534    pub fn fetch(
535        &self,
536        repo: &Repository,
537        mut opts: FetchOptions,
538        progress: &mut dyn Progress,
539        cancel: &dyn crate::progress::Cancel,
540        http_factory: Option<&dyn HttpClientFactory>,
541    ) -> RemoteResult<FetchOutcome> {
542        if opts.configured_refspecs.is_empty() {
543            opts.configured_refspecs = self.fetch_refspecs.clone();
544        }
545        if opts.refspecs.is_empty() {
546            opts.refspecs = self.fetch_refspecs.clone();
547        }
548        if opts.remote_name.is_none() {
549            opts.remote_name = self.name.clone();
550        }
551        if opts.fetch_source.is_none() {
552            opts.fetch_source = Some(self.fetch_url.to_url_string());
553        }
554        if opts.diagnostics.is_none() {
555            opts.diagnostics = Some(repo.diagnostics());
556            opts.network_trace = repo.network_trace_enabled();
557        }
558        let connect = ConnectOptions {
559            protocol_version: 2,
560            diagnostics: opts.diagnostics.clone(),
561            network_trace: opts.network_trace,
562            ..Default::default()
563        };
564        self.dispatch_fetch(
565            repo,
566            &self.fetch_url,
567            &mut opts,
568            progress,
569            cancel,
570            http_factory,
571            connect,
572        )
573    }
574
575    /// Push `refs` to this remote from `repo`.
576    ///
577    /// `cancel` is polled throughout: while the objects to send are walked, while the
578    /// pack is built and streamed, while a local (`file://` or path) remote indexes
579    /// it, and while a smart-HTTP push waits for the server's report. A cancel
580    /// before the objects are stored updates no remote ref and leaves no temporary
581    /// files; a cancel after a wire push sent its whole request stops waiting, but
582    /// the server may still apply the update. Remote-tracking refs are not updated
583    /// either way.
584    ///
585    /// # Errors
586    ///
587    /// Returns [`Error::Cancelled`] (see [`RemoteError::is_cancelled`]) once `cancel`
588    /// fires, or [`RemoteError`] / transport errors on failure. The pack is built from
589    /// [`Odb::read_store`](crate::odb::Odb::read_store), including custom primaries.
590    pub fn push(
591        &self,
592        repo: &Repository,
593        refs: &[PushRefSpec],
594        mut opts: PushOptions,
595        progress: &mut dyn Progress,
596        cancel: &dyn crate::progress::Cancel,
597        http_factory: Option<&dyn HttpClientFactory>,
598    ) -> RemoteResult<PushOutcome> {
599        if opts.tracking_remote.is_none() {
600            opts.tracking_remote = self.name.clone();
601        }
602        if opts.resumable_threshold.is_none() {
603            opts.resumable_threshold = repo
604                .config()
605                .ok()
606                .and_then(|c| c.get_i64("push.bundleThreshold").and_then(|r| r.ok()))
607                .and_then(|v| u64::try_from(v).ok());
608        }
609        let url = pre_push_hook_url(self.push_url());
610        let gate = PrePushGate {
611            repo,
612            remote: self.name.as_deref().unwrap_or(&url),
613            url: &url,
614        };
615        self.dispatch_push(
616            repo,
617            self.push_url(),
618            refs,
619            &opts,
620            (!opts.no_verify).then_some(&gate),
621            progress,
622            cancel,
623            http_factory,
624            ConnectOptions {
625                protocol_version: 0,
626                diagnostics: Some(repo.diagnostics()),
627                network_trace: repo.network_trace_enabled(),
628                ..Default::default()
629            },
630        )
631    }
632
633    /// Seed the empty repository `repo` from the bundle this remote advertises
634    /// with `bundle-uri` (see [`crate::bundle_uri::seed_from_bundle_uri`]).
635    ///
636    /// Only smart-HTTP remotes advertise bundles; other transports, and
637    /// `transfer.bundleURI=false` in `repo`'s config, give
638    /// [`BundleSeed::NotOffered`](crate::bundle_uri::BundleSeed::NotOffered).
639    ///
640    /// # Parameters
641    ///
642    /// - `repo`: the new repository the clone is filling.
643    /// - `cache_dir`: where bundle downloads are kept between attempts.
644    /// - `progress`: receives the download and unpack progress.
645    /// - `cancel`: checked while the bundle is downloaded and unpacked; a
646    ///   cancelled seed comes back as `Failed`, so callers check `cancel` again.
647    /// - `http_factory`: builds the HTTP client, as for [`Self::fetch`]. Seeding is
648    ///   best-effort, so it uses a [probe client](HttpClientFactory::create_for_probe):
649    ///   credentials the server refuses stay stored for the fetch that follows to
650    ///   report.
651    #[must_use]
652    pub fn seed_from_bundle_uri(
653        &self,
654        repo: &Repository,
655        cache_dir: &Path,
656        progress: &mut dyn Progress,
657        cancel: &dyn crate::progress::Cancel,
658        http_factory: Option<&dyn HttpClientFactory>,
659    ) -> crate::bundle_uri::BundleSeed {
660        use crate::bundle_uri::BundleSeed;
661        let (RemoteUrl::Http(url) | RemoteUrl::Https(url)) = &self.fetch_url else {
662            return BundleSeed::NotOffered;
663        };
664        let enabled = repo
665            .config()
666            .ok()
667            .and_then(|c| c.get_bool("transfer.bundleURI").and_then(|r| r.ok()))
668            .unwrap_or(true);
669        if !enabled {
670            return BundleSeed::NotOffered;
671        }
672        let probe = ProbeHttpClientFactory::new(http_factory, repo);
673        let client = match http_client(Some(&probe), repo) {
674            Ok(c) => c,
675            Err(e) => {
676                return BundleSeed::Failed {
677                    uri: None,
678                    reason: e.to_string(),
679                }
680            }
681        };
682        crate::bundle_uri::seed_from_bundle_uri(
683            repo,
684            client.as_ref(),
685            url,
686            cache_dir,
687            progress,
688            cancel,
689        )
690    }
691
692    /// List references on this remote (all transports), matching `git ls-remote` ordering.
693    ///
694    /// `repo` supplies git-dir/worktree for relative local URLs and optional ODB for peeling.
695    ///
696    /// # Errors
697    ///
698    /// Returns [`RemoteError`] on resolution or wire failures.
699    pub fn list_refs(
700        &self,
701        repo: Option<&Repository>,
702        opts: &ListRefsOptions,
703        http_factory: Option<&dyn HttpClientFactory>,
704    ) -> RemoteResult<Vec<RemoteRef>> {
705        let (git_dir, work_tree, odb) = repo_context(repo);
706        match &self.fetch_url {
707            RemoteUrl::Bundle(path) => list_refs_from_bundle(path, opts),
708            RemoteUrl::Local(_) | RemoteUrl::File(_) => {
709                let remote_git = local_git_dir_from_url(&self.fetch_url, git_dir, work_tree)?;
710                list_refs_from_git_dir(&remote_git, None, opts)
711            }
712            wire => self.list_refs_wire(wire, git_dir, odb.as_ref(), opts, http_factory, repo),
713        }
714    }
715
716    /// List the refs a push to this remote's [`push_url`](Self::push_url) starts from.
717    ///
718    /// Local and `file://` destinations are read with one scan of every ref under `refs/`, the
719    /// set `git receive-pack` advertises (no `HEAD` line). Other transports return the
720    /// [`list_refs`](Self::list_refs) listing of the push URL.
721    ///
722    /// # Errors
723    ///
724    /// Returns [`RemoteError`] on resolution or wire failures, and
725    /// [`RemoteError::UnsupportedScheme`] for bundles.
726    pub fn list_push_refs(
727        &self,
728        repo: &Repository,
729        http_factory: Option<&dyn HttpClientFactory>,
730    ) -> RemoteResult<Vec<RemoteRef>> {
731        let url = self.push_url();
732        match url {
733            RemoteUrl::Bundle(_) => Err(RemoteError::UnsupportedScheme),
734            RemoteUrl::Local(_) | RemoteUrl::File(_) => {
735                let remote_git = local_git_dir_from_url(url, repo.git_dir(), repo.work_tree())?;
736                let refs =
737                    crate::refs::list_refs(&remote_git, "refs/").map_err(RemoteError::Library)?;
738                Ok(refs
739                    .into_iter()
740                    .map(|(name, oid)| RemoteRef {
741                        name,
742                        oid,
743                        symref_target: None,
744                    })
745                    .collect())
746            }
747            wire => self.list_refs_wire(
748                wire,
749                repo.git_dir(),
750                Some(repo.odb()),
751                &ListRefsOptions::default(),
752                http_factory,
753                Some(repo),
754            ),
755        }
756    }
757
758    #[allow(clippy::too_many_arguments)]
759    fn dispatch_fetch(
760        &self,
761        repo: &Repository,
762        url: &RemoteUrl,
763        opts: &mut FetchOptions,
764        progress: &mut dyn Progress,
765        cancel: &dyn crate::progress::Cancel,
766        http_factory: Option<&dyn HttpClientFactory>,
767        connect: ConnectOptions,
768    ) -> RemoteResult<FetchOutcome> {
769        match url {
770            RemoteUrl::Bundle(path) => Ok(crate::bundle_remote::fetch_from_bundle_into(
771                repo, path, opts, progress, cancel,
772            )?),
773            RemoteUrl::Local(_) | RemoteUrl::File(_) => {
774                let remote_git = local_git_dir_from_url(url, repo.git_dir(), repo.work_tree())?;
775                let cfg = ConfigSet::load(&opts.environment, Some(repo.git_dir()), true)
776                    .map_err(RemoteError::Library)?;
777                if opts.filter.is_none() {
778                    if let Some(name) = opts.remote_name.as_deref().or(self.name.as_deref()) {
779                        opts.filter = crate::partial_clone::stored_remote_filter(&cfg, name);
780                    }
781                }
782                // An automatic filter needs the server's promisor advertisement, which
783                // only the wire protocol carries. A source that is itself backed by a
784                // promisor remote (a server whose large blobs live on a LOP) lacks some
785                // objects, so copying its object store would come up short: it is
786                // served like a remote, which omits or fetches what it lacks.
787                if opts.auto_filter
788                    || crate::partial_clone::fetch_requires_upload_pack(
789                        opts.filter.as_ref(),
790                        &cfg,
791                        opts.remote_name.as_deref().or(self.name.as_deref()),
792                    )
793                    || local_source_has_promisor_remotes(&remote_git)
794                {
795                    let client_env = if opts.environment.is_fetch_options_default() {
796                        repo.environment()
797                    } else {
798                        &opts.environment
799                    };
800                    let mut conn = crate::transport::connect_local_upload_pack(
801                        &remote_git,
802                        Service::UploadPack,
803                        &connect,
804                        client_env,
805                        repo.caches().promisor_fetcher(),
806                    )
807                    .map_err(RemoteError::Library)?;
808                    let fetched = fetch_remote_into(repo, &mut *conn, opts, progress, cancel);
809                    // A serving side that failed knows why (the client may only have
810                    // seen the stream end early, or even taken it as an empty pack), so
811                    // its error comes first.
812                    conn.finish().map_err(RemoteError::Library)?;
813                    return Ok(fetched?);
814                }
815                Ok(fetch_local_into(repo, &remote_git, opts, progress, cancel)?)
816            }
817            RemoteUrl::Http(u) | RemoteUrl::Https(u) => {
818                let client: Arc<dyn HttpClient> = Arc::from(http_client(http_factory, repo)?);
819                let transport = SmartHttpTransport::new(client);
820                let mut conn = transport.connect(u, Service::UploadPack, &connect)?;
821                Ok(fetch_remote_into(repo, &mut *conn, opts, progress, cancel)?)
822            }
823            RemoteUrl::Git(u) => {
824                let mut conn =
825                    GitDaemonTransport::new().connect(u, Service::UploadPack, &connect)?;
826                Ok(fetch_remote_into(repo, &mut *conn, opts, progress, cancel)?)
827            }
828            RemoteUrl::Ssh(u) => {
829                let transport = ssh_transport(Some(repo), Some(&opts.environment));
830                let mut conn = transport.connect(u, Service::UploadPack, &connect)?;
831                Ok(fetch_remote_into(repo, &mut *conn, opts, progress, cancel)?)
832            }
833        }
834    }
835
836    #[allow(clippy::too_many_arguments)]
837    fn dispatch_push(
838        &self,
839        repo: &Repository,
840        url: &RemoteUrl,
841        refs: &[PushRefSpec],
842        opts: &PushOptions,
843        pre_push: Option<&PrePushGate<'_>>,
844        progress: &mut dyn Progress,
845        cancel: &dyn crate::progress::Cancel,
846        http_factory: Option<&dyn HttpClientFactory>,
847        connect: ConnectOptions,
848    ) -> RemoteResult<PushOutcome> {
849        let mut over_wire = |conn: &mut dyn crate::transport::Connection| {
850            push_remote_from(repo, conn, refs, opts, progress, cancel, pre_push)
851        };
852        match url {
853            RemoteUrl::Local(_) | RemoteUrl::File(_) => {
854                let remote_git = local_git_dir_from_url(url, repo.git_dir(), repo.work_tree())?;
855                Ok(push_local_from(
856                    repo,
857                    &remote_git,
858                    refs,
859                    opts,
860                    progress,
861                    cancel,
862                    pre_push,
863                )?)
864            }
865            RemoteUrl::Http(u) | RemoteUrl::Https(u) => {
866                let client: Arc<dyn HttpClient> = Arc::from(http_client(http_factory, repo)?);
867                let transport = SmartHttpTransport::new(client.clone());
868                let mut conn = transport.connect(u, Service::ReceivePack, &connect)?;
869                Ok(crate::push::push_remote_with(
870                    repo,
871                    &mut *conn,
872                    refs,
873                    opts,
874                    progress,
875                    cancel,
876                    Some(crate::push::BundlePushTarget {
877                        client: client.as_ref(),
878                        repo_url: u,
879                    }),
880                    pre_push,
881                )?)
882            }
883            RemoteUrl::Git(u) => {
884                let mut conn =
885                    GitDaemonTransport::new().connect(u, Service::ReceivePack, &connect)?;
886                Ok(over_wire(&mut *conn)?)
887            }
888            RemoteUrl::Ssh(u) => {
889                let transport = ssh_transport(Some(repo), None);
890                let mut conn = transport.connect(u, Service::ReceivePack, &connect)?;
891                Ok(over_wire(&mut *conn)?)
892            }
893            RemoteUrl::Bundle(_) => Err(RemoteError::UnsupportedScheme),
894        }
895    }
896
897    fn list_refs_wire(
898        &self,
899        url: &RemoteUrl,
900        local_git_dir: &Path,
901        odb: Option<&Odb>,
902        opts: &ListRefsOptions,
903        http_factory: Option<&dyn HttpClientFactory>,
904        repo: Option<&Repository>,
905    ) -> RemoteResult<Vec<RemoteRef>> {
906        let wire = url.as_wire_url().ok_or(RemoteError::UnsupportedScheme)?;
907        let requested = opts.protocol_version.unwrap_or(2);
908        let connect_opts = ConnectOptions {
909            protocol_version: requested,
910            ..Default::default()
911        };
912        match url {
913            RemoteUrl::Http(_) | RemoteUrl::Https(_) => {
914                list_refs_http(wire, local_git_dir, odb, opts, http_factory, repo)
915            }
916            RemoteUrl::Git(_) => {
917                let mut conn =
918                    GitDaemonTransport::new().connect(wire, Service::UploadPack, &connect_opts)?;
919                list_refs_from_connection(&mut *conn, local_git_dir, odb, opts)
920            }
921            RemoteUrl::Ssh(_) => {
922                let transport = ssh_transport(repo, None);
923                let mut conn = transport.connect(wire, Service::UploadPack, &connect_opts)?;
924                list_refs_from_connection(&mut *conn, local_git_dir, odb, opts)
925            }
926            RemoteUrl::Local(_) | RemoteUrl::File(_) | RemoteUrl::Bundle(_) => unreachable!(),
927        }
928    }
929}
930
931/// The push URL as the `pre-push` hook sees it: `file://` URLs keep their scheme.
932fn pre_push_hook_url(url: &RemoteUrl) -> String {
933    match url {
934        RemoteUrl::File(path) => format!("file://{}", path.to_string_lossy()),
935        other => other.to_url_string(),
936    }
937}
938
939fn repo_context(repo: Option<&Repository>) -> (&Path, Option<&Path>, Option<Odb>) {
940    match repo {
941        Some(r) => (r.git_dir(), r.work_tree(), Some(r.odb().clone())),
942        None => (Path::new("."), None, None),
943    }
944}
945
946/// Whether the local repository at `git_dir` has promisor remotes of its own
947/// (only its own config counts, as for any served repository's partial state).
948fn local_source_has_promisor_remotes(git_dir: &Path) -> bool {
949    ConfigSet::load_repo_local_only(git_dir)
950        .is_ok_and(|cfg| crate::promisor::repo_treats_promisor_packs(git_dir, &cfg))
951}
952
953fn local_git_dir_from_url(
954    url: &RemoteUrl,
955    git_dir: &Path,
956    work_tree: Option<&Path>,
957) -> RemoteResult<PathBuf> {
958    let s = url.to_url_string();
959    let remote_git = resolve_local_remote_git_dir(&s, git_dir, work_tree);
960    crate::transfer::ensure_local_remote_repository(&remote_git)?;
961    Ok(remote_git)
962}
963
964fn ssh_transport(
965    repo: Option<&Repository>,
966    fetch_env: Option<&crate::environment::Environment>,
967) -> SshTransport {
968    let mut transport = SshTransport::new();
969    if let Some(r) = repo {
970        transport = transport.with_command_runner(r.command_runner());
971    }
972    let env = ssh_wire_environment(repo, fetch_env);
973    transport = transport.with_ssh_environment(std::sync::Arc::new(env.clone()));
974    if let Some(r) = repo {
975        if let Ok(config) = r.config() {
976            transport = transport.with_ssh_config(config);
977        }
978    } else if let Ok(config) = ConfigSet::load(&env, None, true) {
979        transport = transport.with_ssh_config(Arc::new(config));
980    }
981    transport
982}
983
984/// Environment for SSH subprocess spawn: repository discovery env unless the fetch
985/// options carry an explicit non-default [`FetchOptions::environment`] snapshot.
986fn ssh_wire_environment(
987    repo: Option<&Repository>,
988    fetch_env: Option<&crate::environment::Environment>,
989) -> crate::environment::Environment {
990    if let Some(fe) = fetch_env {
991        if !fe.is_fetch_options_default() {
992            return fe.clone();
993        }
994    }
995    repo.map(|r| r.environment().clone())
996        .unwrap_or_else(crate::environment::Environment::capture_process)
997}
998
999fn http_client(
1000    factory: Option<&dyn HttpClientFactory>,
1001    repo: &Repository,
1002) -> RemoteResult<Box<dyn HttpClient>> {
1003    let config = repo.config().map_err(RemoteError::Library)?;
1004    if let Some(f) = factory {
1005        return f.create(config.as_ref());
1006    }
1007    #[cfg(feature = "http-ureq")]
1008    {
1009        DefaultHttpClientFactory::with_environment(repo.environment().clone())
1010            .create(config.as_ref())
1011    }
1012    #[cfg(not(feature = "http-ureq"))]
1013    {
1014        let _ = config;
1015        Err(RemoteError::HttpUnavailable)
1016    }
1017}
1018
1019fn list_refs_http(
1020    repo_url: &str,
1021    local_git_dir: &Path,
1022    odb: Option<&Odb>,
1023    opts: &ListRefsOptions,
1024    http_factory: Option<&dyn HttpClientFactory>,
1025    repo: Option<&Repository>,
1026) -> RemoteResult<Vec<RemoteRef>> {
1027    let config: Arc<ConfigSet> = if let Some(r) = repo {
1028        r.config().map_err(RemoteError::Library)?
1029    } else {
1030        Arc::new(ConfigSet::new())
1031    };
1032    let client: Box<dyn HttpClient> = match http_factory {
1033        Some(f) => f.create(config.as_ref())?,
1034        None => {
1035            #[cfg(feature = "http-ureq")]
1036            {
1037                repo.map_or_else(DefaultHttpClientFactory::default, |r| {
1038                    DefaultHttpClientFactory::with_environment(r.environment().clone())
1039                })
1040                .create(config.as_ref())?
1041            }
1042            #[cfg(not(feature = "http-ureq"))]
1043            {
1044                return Err(RemoteError::HttpUnavailable);
1045            }
1046        }
1047    };
1048
1049    // When `protocol_version` is set, honor it on discovery (including v0 with no
1050    // `Git-Protocol` header). `None` defers to the HTTP client's config default.
1051    let protocol_header_value = opts
1052        .protocol_version
1053        .and_then(crate::protocol::client_git_protocol_header_value);
1054    let git_protocol: Option<&str> = match opts.protocol_version {
1055        None => None,
1056        Some(0) => Some(""),
1057        Some(_) => protocol_header_value.as_deref(),
1058    };
1059
1060    let info_url = crate::transport::http::smart_info_refs_discovery_url(repo_url);
1061    let want_v2 = opts.protocol_version.unwrap_or(2) >= 2;
1062    let (body, final_url) = client
1063        .get_with_final_url_exact(&info_url, git_protocol)
1064        .map_err(RemoteError::Library)?;
1065    let effective_base =
1066        crate::transport::http::rebased_base_from_redirect(repo_url, final_url.as_deref())
1067            .unwrap_or_else(|| repo_url.to_owned());
1068    let (protocol_version, refs, caps, head_symref) =
1069        if effective_base.trim_end_matches('/') != repo_url.trim_end_matches('/') {
1070            client.reset_auth_after_redirect_rebase();
1071            crate::transport::http::discover_upload_pack_for_list_refs(
1072                client.as_ref(),
1073                &effective_base,
1074                git_protocol,
1075            )
1076            .map_err(RemoteError::Library)?
1077        } else {
1078            crate::transport::http::discover_upload_pack_for_list_refs_from_body(&body)
1079                .map_err(RemoteError::Library)?
1080        };
1081
1082    if protocol_version >= 2 && want_v2 {
1083        let local_odb = odb.cloned().unwrap_or_else(|| open_odb(local_git_dir));
1084        let req = build_list_refs_v2_request(&caps, &local_odb, opts)?;
1085        let post_url = format!("{}/git-upload-pack", effective_base.trim_end_matches('/'));
1086        let body = client
1087            .as_ref()
1088            .post(
1089                &post_url,
1090                "application/x-git-upload-pack-request",
1091                "application/x-git-upload-pack-result",
1092                &req,
1093                git_protocol,
1094            )
1095            .map_err(RemoteError::Library)?;
1096        let mut cur = Cursor::new(body);
1097        parse_list_refs_v2_response(&mut cur, opts, head_symref)
1098    } else {
1099        list_refs_v0_advertisement(&refs, head_symref.as_deref(), odb, opts)
1100    }
1101}
1102
1103fn list_refs_from_connection(
1104    conn: &mut dyn crate::transport::Connection,
1105    local_git_dir: &Path,
1106    odb: Option<&Odb>,
1107    opts: &ListRefsOptions,
1108) -> RemoteResult<Vec<RemoteRef>> {
1109    let want_v2 = opts.protocol_version.unwrap_or(2) >= 2;
1110    if conn.protocol_version() >= 2 && want_v2 {
1111        list_refs_v2_connection(conn, local_git_dir, odb, opts)
1112    } else {
1113        let out =
1114            list_refs_v0_advertisement(conn.advertised_refs(), conn.head_symref(), odb, opts)?;
1115        crate::transport::send_client_flush(conn).map_err(RemoteError::Library)?;
1116        Ok(out)
1117    }
1118}
1119
1120fn open_odb(git_dir: &Path) -> Odb {
1121    crate::transfer::open_odb(git_dir)
1122}
1123
1124fn list_refs_v2_connection(
1125    conn: &mut dyn crate::transport::Connection,
1126    local_git_dir: &Path,
1127    odb: Option<&Odb>,
1128    opts: &ListRefsOptions,
1129) -> RemoteResult<Vec<RemoteRef>> {
1130    let local_odb = odb.cloned().unwrap_or_else(|| open_odb(local_git_dir));
1131    let req = build_list_refs_v2_request(conn.capabilities(), &local_odb, opts)?;
1132    conn.writer().write_all(&req).map_err(Error::Io)?;
1133    conn.writer().flush().map_err(Error::Io)?;
1134    let discovery_head = conn.head_symref().map(str::to_owned);
1135    let out = parse_list_refs_v2_response(conn.reader(), opts, discovery_head)?;
1136    conn.finish_send();
1137    Ok(out)
1138}
1139
1140fn build_list_refs_v2_request(
1141    server_caps: &[String],
1142    local_odb: &Odb,
1143    opts: &ListRefsOptions,
1144) -> RemoteResult<Vec<u8>> {
1145    use crate::fetch::v2_object_format;
1146    use crate::pkt_line;
1147    use crate::protocol_v2;
1148
1149    let object_format = v2_object_format(server_caps, local_odb);
1150    let cap_echo = protocol_v2::cap_lines_for_command_request(server_caps);
1151    let mut req: Vec<u8> = Vec::new();
1152    pkt_line::write_line(&mut req, "command=ls-refs").map_err(Error::Io)?;
1153    if cap_echo.iter().any(|c| c.starts_with("object-format=")) {
1154        for line in &cap_echo {
1155            pkt_line::write_line(&mut req, line).map_err(Error::Io)?;
1156        }
1157    } else {
1158        for line in &cap_echo {
1159            pkt_line::write_line(&mut req, line).map_err(Error::Io)?;
1160        }
1161        pkt_line::write_line(&mut req, &format!("object-format={object_format}"))
1162            .map_err(Error::Io)?;
1163    }
1164    pkt_line::write_delim(&mut req).map_err(Error::Io)?;
1165    if opts.symrefs {
1166        pkt_line::write_line(&mut req, "symrefs").map_err(Error::Io)?;
1167        if crate::protocol_v2::server_advertises_ls_refs_unborn(server_caps) {
1168            pkt_line::write_line(&mut req, "unborn").map_err(Error::Io)?;
1169        }
1170    }
1171    if opts.peel {
1172        pkt_line::write_line(&mut req, "peel").map_err(Error::Io)?;
1173    }
1174    if list_refs_includes_head(opts) {
1175        pkt_line::write_line(&mut req, "ref-prefix HEAD").map_err(Error::Io)?;
1176    }
1177    for prefix in list_ref_prefixes(opts) {
1178        pkt_line::write_line(&mut req, &format!("ref-prefix {prefix}")).map_err(Error::Io)?;
1179    }
1180    pkt_line::write_flush(&mut req).map_err(Error::Io)?;
1181    Ok(req)
1182}
1183
1184fn list_refs_includes_head(opts: &ListRefsOptions) -> bool {
1185    !opts.heads && !opts.tags && opts.prefixes.is_empty()
1186}
1187
1188fn list_ref_prefixes(opts: &ListRefsOptions) -> Vec<String> {
1189    if !opts.prefixes.is_empty() {
1190        if opts.heads && !opts.tags {
1191            return vec!["refs/heads/".to_owned()];
1192        }
1193        if opts.tags && !opts.heads {
1194            return vec!["refs/tags/".to_owned()];
1195        }
1196        return vec!["refs/heads/".to_owned(), "refs/tags/".to_owned()];
1197    }
1198    let mut out = Vec::new();
1199    let all = !opts.heads && !opts.tags;
1200    if all || opts.heads {
1201        out.push("refs/heads/".to_owned());
1202    }
1203    if all || opts.tags {
1204        out.push("refs/tags/".to_owned());
1205    }
1206    out
1207}
1208
1209fn parse_list_refs_v2_response(
1210    reader: &mut dyn Read,
1211    opts: &ListRefsOptions,
1212    mut head_symref: Option<String>,
1213) -> RemoteResult<Vec<RemoteRef>> {
1214    use crate::fetch::parse_ls_refs_v2_line;
1215    use crate::pkt_line;
1216
1217    let mut entries: Vec<RemoteRef> = Vec::new();
1218    let mut peel_map: std::collections::HashMap<String, ObjectId> =
1219        std::collections::HashMap::new();
1220    let mut reader = reader;
1221    loop {
1222        match pkt_line::read_packet(&mut reader).map_err(Error::Io)? {
1223            None | Some(pkt_line::Packet::Flush) | Some(pkt_line::Packet::Delim) => break,
1224            Some(pkt_line::Packet::ResponseEnd) => break,
1225            Some(pkt_line::Packet::Data(line)) => {
1226                let line = line.trim_end_matches('\n');
1227                if let Some(msg) = line.strip_prefix("ERR ") {
1228                    return Err(RemoteError::Library(Error::Message(format!(
1229                        "remote error: {}",
1230                        msg.trim_end()
1231                    ))));
1232                }
1233                if let Some(t) = crate::fetch::parse_v2_ls_refs_unborn_head_symref(line) {
1234                    head_symref = Some(t);
1235                    continue;
1236                }
1237                let Some((name, oid, symref_target, peel)) = parse_ls_refs_v2_line(line) else {
1238                    continue;
1239                };
1240                if name.ends_with("^{}") {
1241                    continue;
1242                }
1243                if name == "HEAD" {
1244                    let sym =
1245                        symref_target.filter(|t| crate::refs::is_valid_advertised_symref_target(t));
1246                    if let Some(t) = sym.clone() {
1247                        head_symref = Some(t);
1248                    }
1249                    if list_refs_includes_head(opts) && ref_matches_list_opts("HEAD", opts) {
1250                        entries.push(RemoteRef {
1251                            name: "HEAD".to_owned(),
1252                            oid,
1253                            symref_target: if opts.symrefs { sym } else { None },
1254                        });
1255                    }
1256                    continue;
1257                }
1258                if !crate::refs::is_valid_fetch_advertised_ref(&name) {
1259                    continue;
1260                }
1261                if let Some(p) = peel {
1262                    peel_map.insert(name.clone(), p);
1263                }
1264                let sym =
1265                    symref_target.filter(|t| crate::refs::is_valid_advertised_symref_target(t));
1266                entries.push(RemoteRef {
1267                    name,
1268                    oid,
1269                    symref_target: sym,
1270                });
1271            }
1272        }
1273    }
1274    finalize_list_refs_output(entries, peel_map, opts, head_symref, None)
1275}
1276
1277fn list_refs_v0_advertisement(
1278    advertised: &[(String, ObjectId)],
1279    head_symref: Option<&str>,
1280    odb: Option<&Odb>,
1281    opts: &ListRefsOptions,
1282) -> RemoteResult<Vec<RemoteRef>> {
1283    let mut entries: Vec<RemoteRef> = Vec::new();
1284    let mut peel_map: std::collections::HashMap<String, ObjectId> =
1285        std::collections::HashMap::new();
1286    for (name, oid) in advertised {
1287        if name.ends_with("^{}") {
1288            if let Some(base) = name.strip_suffix("^{}") {
1289                peel_map.insert(base.to_owned(), *oid);
1290            }
1291            continue;
1292        }
1293        entries.push(RemoteRef {
1294            name: name.clone(),
1295            oid: *oid,
1296            symref_target: None,
1297        });
1298    }
1299    finalize_list_refs_output(entries, peel_map, opts, head_symref.map(str::to_owned), odb)
1300}
1301
1302fn finalize_list_refs_output(
1303    mut entries: Vec<RemoteRef>,
1304    peel_map: std::collections::HashMap<String, ObjectId>,
1305    opts: &ListRefsOptions,
1306    head_symref: Option<String>,
1307    odb: Option<&Odb>,
1308) -> RemoteResult<Vec<RemoteRef>> {
1309    entries.retain(|e| remote_ref_matches_namespace(&e.name, opts));
1310    entries.retain(|e| patterns_match_ls_remote(&e.name, &opts.prefixes));
1311    if opts.peel {
1312        let mut peel_lines: Vec<RemoteRef> = Vec::new();
1313        for e in &entries {
1314            if !e.name.starts_with("refs/tags/") {
1315                continue;
1316            }
1317            let peeled = peel_map
1318                .get(&e.name)
1319                .copied()
1320                .or_else(|| odb.and_then(|o| o.peel_tag_target(e.oid).ok().flatten()));
1321            if let Some(p) = peeled {
1322                let peel_name = format!("{}^{{}}", e.name);
1323                if should_emit_peel_line(&peel_name, &opts.prefixes) {
1324                    peel_lines.push(RemoteRef {
1325                        name: peel_name,
1326                        oid: p,
1327                        symref_target: None,
1328                    });
1329                }
1330            }
1331        }
1332        entries.extend(peel_lines);
1333    }
1334    if list_refs_includes_head(opts) {
1335        if let Some(sym) = head_symref {
1336            if let Some(oid) = entries.iter().find(|e| e.name == sym).map(|e| e.oid) {
1337                entries.push(RemoteRef {
1338                    name: "HEAD".to_owned(),
1339                    oid,
1340                    symref_target: if opts.symrefs { Some(sym) } else { None },
1341                });
1342            }
1343        }
1344    }
1345    let query_options = RefQueryOptions {
1346        versionsort_suffixes: opts.versionsort_suffixes.clone(),
1347        ..RefQueryOptions::default()
1348    };
1349    sort_remote_refs(
1350        &mut entries,
1351        &opts.sort,
1352        &query_options.versionsort_suffixes,
1353    );
1354    if let Some(limit) = opts.limit {
1355        entries.truncate(limit);
1356    }
1357    Ok(entries)
1358}
1359
1360/// Filter (heads/tags + ls-remote patterns), sort, and limit an in-memory remote ref list.
1361///
1362/// Callers that already applied peel lines and optional `HEAD` pass the full line list here.
1363///
1364/// # Panics
1365///
1366/// Never panics.
1367#[must_use]
1368pub fn query_remote_refs_in_memory(
1369    mut entries: Vec<RemoteRef>,
1370    opts: &ListRefsOptions,
1371    query_options: &RefQueryOptions,
1372) -> Vec<RemoteRef> {
1373    entries.retain(|e| remote_ref_matches_namespace(&e.name, opts));
1374    entries.retain(|e| patterns_match_ls_remote(&e.name, &opts.prefixes));
1375    sort_remote_refs(
1376        &mut entries,
1377        &opts.sort,
1378        &query_options.versionsort_suffixes,
1379    );
1380    if let Some(limit) = opts.limit {
1381        entries.truncate(limit);
1382    }
1383    entries
1384}
1385
1386fn should_emit_peel_line(peel_name: &str, patterns: &[String]) -> bool {
1387    if patterns.is_empty() {
1388        return true;
1389    }
1390    patterns_match_ls_remote(peel_name, patterns)
1391}
1392
1393fn sort_remote_refs(entries: &mut [RemoteRef], sort: &RefSort, versionsort_suffixes: &[String]) {
1394    entries.sort_by(|a, b| {
1395        let primary = match sort.key {
1396            SortKey::Name => a.name.cmp(&b.name),
1397            SortKey::Version => compare_version_names(&a.name, &b.name, versionsort_suffixes),
1398            SortKey::Date => a.name.cmp(&b.name),
1399        };
1400        let primary = if sort.reverse {
1401            primary.reverse()
1402        } else {
1403            primary
1404        };
1405        if primary != std::cmp::Ordering::Equal {
1406            return primary;
1407        }
1408        a.name.cmp(&b.name)
1409    });
1410}
1411
1412fn remote_ref_matches_namespace(refname: &str, opts: &ListRefsOptions) -> bool {
1413    if opts.heads || opts.tags {
1414        let is_branch = opts.heads && refname.starts_with("refs/heads/");
1415        let is_tag = opts.tags && refname.starts_with("refs/tags/");
1416        if refname == "HEAD" {
1417            return false;
1418        }
1419        if refname.ends_with("^{}") {
1420            return is_tag;
1421        }
1422        if !is_branch && !is_tag {
1423            return false;
1424        }
1425    }
1426    true
1427}
1428
1429fn ref_matches_list_opts(refname: &str, opts: &ListRefsOptions) -> bool {
1430    remote_ref_matches_namespace(refname, opts) && patterns_match_ls_remote(refname, &opts.prefixes)
1431}
1432
1433/// Returns `true` when `refname` matches one of `patterns`, or when `patterns` is empty.
1434#[must_use]
1435pub fn ref_matches_ls_remote_patterns(refname: &str, patterns: &[String]) -> bool {
1436    patterns_match_ls_remote(refname, patterns)
1437}
1438
1439/// List references from a on-disk git directory (local / `file://` remotes).
1440///
1441/// # Errors
1442fn list_refs_from_bundle(path: &Path, opts: &ListRefsOptions) -> RemoteResult<Vec<RemoteRef>> {
1443    let bundle = crate::bundle::Bundle::open(path)
1444        .map_err(|e| RemoteError::Library(Error::Message(e.to_string())))?;
1445    let mut out = Vec::new();
1446    for (name, oid) in &bundle.header().refs {
1447        if !ref_matches_list_opts(name, opts) {
1448            continue;
1449        }
1450        if name != "HEAD" && !crate::refs::is_valid_fetch_advertised_ref(name) {
1451            continue;
1452        }
1453        out.push(RemoteRef {
1454            name: name.clone(),
1455            oid: *oid,
1456            symref_target: None,
1457        });
1458    }
1459    sort_remote_refs(&mut out, &opts.sort, &opts.versionsort_suffixes);
1460    if let Some(limit) = opts.limit {
1461        out.truncate(limit);
1462    }
1463    Ok(out)
1464}
1465
1466/// List refs from a on-disk git directory using explicit scan prefixes (fetch).
1467pub(crate) fn list_refs_from_git_dir_for_fetch(
1468    git_dir: &Path,
1469    odb: Option<&Odb>,
1470    refspecs: &[String],
1471) -> RemoteResult<Vec<RemoteRef>> {
1472    let prefixes = crate::fetch::fetch_ref_list_prefixes(refspecs);
1473    let opts = ListRefsOptions {
1474        symrefs: true,
1475        ..Default::default()
1476    };
1477    list_refs_from_git_dir_with_scan_prefixes(git_dir, odb, &opts, &prefixes)
1478}
1479
1480///
1481/// Returns [`RemoteError::Library`] on I/O or ref resolution failures.
1482pub fn list_refs_from_git_dir(
1483    git_dir: &Path,
1484    odb: Option<&Odb>,
1485    opts: &ListRefsOptions,
1486) -> RemoteResult<Vec<RemoteRef>> {
1487    list_refs_from_git_dir_with_scan_prefixes(git_dir, odb, opts, &list_ref_prefixes(opts))
1488}
1489
1490fn list_refs_from_git_dir_with_scan_prefixes(
1491    git_dir: &Path,
1492    odb: Option<&Odb>,
1493    opts: &ListRefsOptions,
1494    scan_prefixes: &[String],
1495) -> RemoteResult<Vec<RemoteRef>> {
1496    let mut lazy_odb: Option<Odb> = None;
1497
1498    let mut entries = Vec::new();
1499    if list_refs_includes_head(opts) && ref_matches_list_opts("HEAD", opts) {
1500        let symref_target = if opts.symrefs {
1501            crate::refs::read_symbolic_ref(git_dir, "HEAD")
1502                .ok()
1503                .flatten()
1504        } else {
1505            None
1506        };
1507        match crate::refs::resolve_ref(git_dir, "HEAD") {
1508            Ok(head_oid) => {
1509                entries.push(RemoteRef {
1510                    name: "HEAD".to_owned(),
1511                    oid: head_oid,
1512                    symref_target,
1513                });
1514            }
1515            Err(_) if symref_target.is_some() => {
1516                entries.push(RemoteRef {
1517                    name: "HEAD".to_owned(),
1518                    oid: ObjectId::zero(),
1519                    symref_target,
1520                });
1521            }
1522            Err(_) => {}
1523        }
1524    }
1525
1526    for prefix in scan_prefixes {
1527        let listed = crate::refs::list_refs(git_dir, prefix).map_err(RemoteError::Library)?;
1528        for (name, oid) in listed {
1529            if let Some(branch_tail) = name.strip_prefix("refs/heads/") {
1530                if branch_tail.starts_with("refs/") {
1531                    continue;
1532                }
1533            }
1534            if !ref_matches_list_opts(&name, opts) {
1535                continue;
1536            }
1537            entries.push(RemoteRef {
1538                name: name.clone(),
1539                oid,
1540                symref_target: None,
1541            });
1542            if opts.peel && name.starts_with("refs/tags/") {
1543                let peel_name = format!("{name}^{{}}");
1544                if should_emit_peel_line(&peel_name, &opts.prefixes) {
1545                    let peel_odb = match odb {
1546                        Some(o) => o,
1547                        None => lazy_odb.get_or_insert_with(|| {
1548                            Odb::new(&git_dir.join("objects"))
1549                                .with_config_git_dir(git_dir.to_path_buf())
1550                        }),
1551                    };
1552                    if let Ok(Some(peeled)) = peel_odb.peel_tag_target(oid) {
1553                        entries.push(RemoteRef {
1554                            name: peel_name,
1555                            oid: peeled,
1556                            symref_target: None,
1557                        });
1558                    }
1559                }
1560            }
1561        }
1562    }
1563
1564    sort_remote_refs(&mut entries, &opts.sort, &opts.versionsort_suffixes);
1565    if let Some(limit) = opts.limit {
1566        entries.truncate(limit);
1567    }
1568    Ok(entries)
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573    use super::*;
1574
1575    fn remote_ref(name: &str, hex_digit: char) -> RemoteRef {
1576        RemoteRef {
1577            name: name.to_owned(),
1578            oid: ObjectId::from_hex(&hex_digit.to_string().repeat(40)).unwrap(),
1579            symref_target: None,
1580        }
1581    }
1582
1583    #[test]
1584    fn missing_urls_name_the_remote() {
1585        let err = parse_remote_url("  ").expect_err("empty");
1586        assert!(matches!(err, RemoteError::NoUrl { remote: None }));
1587        assert_eq!(err.to_string(), "remote URL is empty");
1588
1589        let config = ConfigSet::new();
1590        let err = Remote::from_config(&config, "upstream").expect_err("no url");
1591        assert!(matches!(&err, RemoteError::NoUrl { remote: Some(name) } if name == "upstream"));
1592        assert_eq!(err.to_string(), "remote 'upstream' has no URL configured");
1593    }
1594
1595    #[test]
1596    fn fold_peeled_refs_attaches_peeled_lines_and_keeps_order() {
1597        let mut head = remote_ref("HEAD", '1');
1598        head.symref_target = Some("refs/heads/main".to_owned());
1599        let refs = vec![
1600            head,
1601            remote_ref("refs/heads/main", '1'),
1602            remote_ref("refs/tags/light", '1'),
1603            remote_ref("refs/tags/v1", '2'),
1604            remote_ref("refs/tags/v1^{}", '1'),
1605        ];
1606        let folded = fold_peeled_refs(&refs);
1607        let names: Vec<&str> = folded.iter().map(|r| r.name.as_str()).collect();
1608        assert_eq!(
1609            names,
1610            ["HEAD", "refs/heads/main", "refs/tags/light", "refs/tags/v1"]
1611        );
1612        assert_eq!(folded[0].symref_target.as_deref(), Some("refs/heads/main"));
1613        assert_eq!(folded[2].peeled, None);
1614        assert_eq!(folded[3].oid, refs[3].oid);
1615        assert_eq!(folded[3].peeled, Some(refs[4].oid));
1616    }
1617
1618    #[test]
1619    fn peeled_tag_only_matches_peel_suffix() {
1620        assert_eq!(
1621            remote_ref("refs/tags/v1^{}", '1').peeled_tag(),
1622            Some("refs/tags/v1")
1623        );
1624        assert_eq!(remote_ref("refs/tags/v1", '1').peeled_tag(), None);
1625    }
1626
1627    #[test]
1628    fn remote_url_parse_table() {
1629        assert!(matches!(
1630            RemoteUrl::try_from("/abs/path").unwrap(),
1631            RemoteUrl::Local(_)
1632        ));
1633        assert!(matches!(
1634            RemoteUrl::try_from("file:///tmp/r.git").unwrap(),
1635            RemoteUrl::File(_)
1636        ));
1637        assert!(matches!(
1638            RemoteUrl::try_from("git://host/repo").unwrap(),
1639            RemoteUrl::Git(_)
1640        ));
1641        assert!(matches!(
1642            RemoteUrl::try_from("git@host:repo.git").unwrap(),
1643            RemoteUrl::Ssh(_)
1644        ));
1645        assert!(matches!(
1646            RemoteUrl::try_from("ssh://git@host/repo").unwrap(),
1647            RemoteUrl::Ssh(_)
1648        ));
1649        assert!(matches!(
1650            RemoteUrl::try_from("https://host/r.git").unwrap(),
1651            RemoteUrl::Https(_)
1652        ));
1653        assert!(matches!(
1654            RemoteUrl::try_from("ftp://x"),
1655            Err(RemoteError::UnsupportedScheme)
1656        ));
1657    }
1658
1659    #[test]
1660    fn pattern_matches_empty_allows_all() {
1661        assert!(ref_matches_ls_remote_patterns("refs/heads/main", &[]));
1662        assert!(ref_matches_ls_remote_patterns("HEAD", &[]));
1663    }
1664
1665    #[test]
1666    fn pattern_matches_exact() {
1667        let pats = vec!["HEAD".to_owned()];
1668        assert!(ref_matches_ls_remote_patterns("HEAD", &pats));
1669        assert!(!ref_matches_ls_remote_patterns("refs/heads/main", &pats));
1670    }
1671
1672    #[test]
1673    fn pattern_matches_suffix_component() {
1674        let pats = vec!["main".to_owned()];
1675        assert!(ref_matches_ls_remote_patterns("refs/heads/main", &pats));
1676        assert!(!ref_matches_ls_remote_patterns("refs/heads/notmain", &pats));
1677        assert!(!ref_matches_ls_remote_patterns("main-branch", &pats));
1678    }
1679
1680    #[cfg_attr(
1681        not(feature = "full-tests"),
1682        ignore = "starts system git or is heavy; run with --features full-tests"
1683    )]
1684    #[test]
1685    fn query_remote_refs_in_memory_empty_pattern_matches_nothing() {
1686        let entries = vec![
1687            RemoteRef {
1688                name: "refs/heads/main".into(),
1689                oid: ObjectId::from_hex("1".repeat(40).as_str()).unwrap(),
1690                symref_target: None,
1691            },
1692            RemoteRef {
1693                name: "refs/tags/v1".into(),
1694                oid: ObjectId::from_hex("2".repeat(40).as_str()).unwrap(),
1695                symref_target: None,
1696            },
1697        ];
1698        let opts = ListRefsOptions {
1699            prefixes: vec![String::new()],
1700            ..Default::default()
1701        };
1702        let out = query_remote_refs_in_memory(entries, &opts, &RefQueryOptions::default());
1703        assert!(out.is_empty());
1704    }
1705
1706    #[cfg_attr(
1707        not(feature = "full-tests"),
1708        ignore = "starts system git or is heavy; run with --features full-tests"
1709    )]
1710    #[test]
1711    fn query_remote_refs_in_memory_version_sort_and_limit() {
1712        let entries = vec![
1713            RemoteRef {
1714                name: "refs/tags/v1.10".into(),
1715                oid: ObjectId::from_hex("1".repeat(40).as_str()).unwrap(),
1716                symref_target: None,
1717            },
1718            RemoteRef {
1719                name: "refs/tags/v1.2".into(),
1720                oid: ObjectId::from_hex("2".repeat(40).as_str()).unwrap(),
1721                symref_target: None,
1722            },
1723            RemoteRef {
1724                name: "refs/tags/v1.1".into(),
1725                oid: ObjectId::from_hex("3".repeat(40).as_str()).unwrap(),
1726                symref_target: None,
1727            },
1728        ];
1729        let opts = ListRefsOptions {
1730            sort: RefSort {
1731                key: SortKey::Version,
1732                reverse: false,
1733            },
1734            limit: Some(2),
1735            ..Default::default()
1736        };
1737        let out = query_remote_refs_in_memory(entries, &opts, &RefQueryOptions::default());
1738        assert_eq!(
1739            out.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
1740            vec!["refs/tags/v1.1", "refs/tags/v1.2"]
1741        );
1742    }
1743
1744    #[test]
1745    fn list_opts_heads_excludes_head() {
1746        let heads_only = super::ListRefsOptions {
1747            heads: true,
1748            ..Default::default()
1749        };
1750        assert!(!super::ref_matches_list_opts("HEAD", &heads_only));
1751        assert!(super::ref_matches_list_opts("refs/heads/main", &heads_only));
1752        assert!(!super::ref_matches_list_opts("refs/tags/v1", &heads_only));
1753    }
1754
1755    #[test]
1756    fn insteadof_rewrite_applied_in_from_config() {
1757        let mut cfg = ConfigSet::new();
1758        cfg.add_command_override("url.https://github.com/.insteadOf", "gh:")
1759            .unwrap();
1760        cfg.add_command_override("remote.origin.url", "gh:org/repo.git")
1761            .unwrap();
1762        let remote = Remote::from_config(&cfg, "origin").unwrap();
1763        assert!(matches!(remote.fetch_url(), RemoteUrl::Https(_)));
1764    }
1765
1766    #[test]
1767    fn push_insteadof_applied_when_pushurl_absent() {
1768        let mut cfg = ConfigSet::new();
1769        cfg.add_command_override("url.ssh://push.example/.pushInsteadOf", "short:")
1770            .unwrap();
1771        cfg.add_command_override("remote.origin.url", "short:org/repo.git")
1772            .unwrap();
1773        let remote = Remote::from_config(&cfg, "origin").unwrap();
1774        assert_eq!(
1775            remote.push_url().to_url_string(),
1776            "ssh://push.example/org/repo.git"
1777        );
1778        assert_eq!(remote.fetch_url().to_url_string(), "short:org/repo.git");
1779    }
1780
1781    #[test]
1782    fn list_ref_prefixes_broad_for_ls_remote_patterns() {
1783        let opts = super::ListRefsOptions {
1784            prefixes: vec!["main".to_owned()],
1785            ..Default::default()
1786        };
1787        let prefixes = super::list_ref_prefixes(&opts);
1788        assert_eq!(
1789            prefixes,
1790            vec!["refs/heads/".to_owned(), "refs/tags/".to_owned()]
1791        );
1792        assert!(super::ref_matches_list_opts("refs/heads/main", &opts));
1793        assert!(!super::ref_matches_list_opts("refs/heads/topic", &opts));
1794    }
1795}