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