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, PushOptions, PushOutcome,
29    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        self.dispatch_push(
505            repo,
506            self.push_url(),
507            refs,
508            &opts,
509            progress,
510            cancel,
511            http_factory,
512            ConnectOptions {
513                protocol_version: 0,
514                diagnostics: Some(repo.diagnostics()),
515                network_trace: repo.network_trace_enabled(),
516                ..Default::default()
517            },
518        )
519    }
520
521    /// List references on this remote (all transports), matching `git ls-remote` ordering.
522    ///
523    /// `repo` supplies git-dir/worktree for relative local URLs and optional ODB for peeling.
524    ///
525    /// # Errors
526    ///
527    /// Returns [`RemoteError`] on resolution or wire failures.
528    pub fn list_refs(
529        &self,
530        repo: Option<&Repository>,
531        opts: &ListRefsOptions,
532        http_factory: Option<&dyn HttpClientFactory>,
533    ) -> RemoteResult<Vec<RemoteRef>> {
534        let (git_dir, work_tree, odb) = repo_context(repo);
535        match &self.fetch_url {
536            RemoteUrl::Bundle(path) => list_refs_from_bundle(path, opts),
537            RemoteUrl::Local(_) | RemoteUrl::File(_) => {
538                let remote_git = local_git_dir_from_url(&self.fetch_url, git_dir, work_tree)?;
539                list_refs_from_git_dir(&remote_git, None, opts)
540            }
541            wire => self.list_refs_wire(wire, git_dir, odb.as_ref(), opts, http_factory, repo),
542        }
543    }
544
545    /// List the refs a push to this remote's [`push_url`](Self::push_url) starts from.
546    ///
547    /// Local and `file://` destinations are read with one scan of every ref under `refs/`, the
548    /// set `git receive-pack` advertises (no `HEAD` line). Other transports return the
549    /// [`list_refs`](Self::list_refs) listing of the push URL.
550    ///
551    /// # Errors
552    ///
553    /// Returns [`RemoteError`] on resolution or wire failures, and
554    /// [`RemoteError::UnsupportedScheme`] for bundles.
555    pub fn list_push_refs(
556        &self,
557        repo: &Repository,
558        http_factory: Option<&dyn HttpClientFactory>,
559    ) -> RemoteResult<Vec<RemoteRef>> {
560        let url = self.push_url();
561        match url {
562            RemoteUrl::Bundle(_) => Err(RemoteError::UnsupportedScheme),
563            RemoteUrl::Local(_) | RemoteUrl::File(_) => {
564                let remote_git = local_git_dir_from_url(url, repo.git_dir(), repo.work_tree())?;
565                let refs =
566                    crate::refs::list_refs(&remote_git, "refs/").map_err(RemoteError::Library)?;
567                Ok(refs
568                    .into_iter()
569                    .map(|(name, oid)| RemoteRef {
570                        name,
571                        oid,
572                        symref_target: None,
573                    })
574                    .collect())
575            }
576            wire => self.list_refs_wire(
577                wire,
578                repo.git_dir(),
579                Some(repo.odb()),
580                &ListRefsOptions::default(),
581                http_factory,
582                Some(repo),
583            ),
584        }
585    }
586
587    #[allow(clippy::too_many_arguments)]
588    fn dispatch_fetch(
589        &self,
590        repo: &Repository,
591        url: &RemoteUrl,
592        opts: &mut FetchOptions,
593        progress: &mut dyn Progress,
594        cancel: &dyn crate::progress::Cancel,
595        http_factory: Option<&dyn HttpClientFactory>,
596        connect: ConnectOptions,
597    ) -> RemoteResult<FetchOutcome> {
598        match url {
599            RemoteUrl::Bundle(path) => Ok(crate::bundle_remote::fetch_from_bundle_into(
600                repo, path, opts, progress, cancel,
601            )?),
602            RemoteUrl::Local(_) | RemoteUrl::File(_) => {
603                let remote_git = local_git_dir_from_url(url, repo.git_dir(), repo.work_tree())?;
604                let cfg = ConfigSet::load(&opts.environment, Some(repo.git_dir()), true)
605                    .map_err(RemoteError::Library)?;
606                if opts.filter.is_none() {
607                    if let Some(name) = opts.remote_name.as_deref().or(self.name.as_deref()) {
608                        opts.filter = crate::partial_clone::stored_remote_filter(&cfg, name);
609                    }
610                }
611                if crate::partial_clone::fetch_requires_upload_pack(
612                    opts.filter.as_ref(),
613                    &cfg,
614                    opts.remote_name.as_deref().or(self.name.as_deref()),
615                ) {
616                    let mut conn = crate::transport::connect_local_upload_pack(
617                        &remote_git,
618                        Service::UploadPack,
619                        &connect,
620                    )
621                    .map_err(RemoteError::Library)?;
622                    return Ok(fetch_remote_into(repo, &mut *conn, opts, progress, cancel)?);
623                }
624                Ok(fetch_local_into(repo, &remote_git, opts, progress, cancel)?)
625            }
626            RemoteUrl::Http(u) | RemoteUrl::Https(u) => {
627                let client: Arc<dyn HttpClient> = Arc::from(http_client(http_factory, repo)?);
628                let transport = SmartHttpTransport::new(client);
629                let mut conn = transport.connect(u, Service::UploadPack, &connect)?;
630                Ok(fetch_remote_into(repo, &mut *conn, opts, progress, cancel)?)
631            }
632            RemoteUrl::Git(u) => {
633                let mut conn =
634                    GitDaemonTransport::new().connect(u, Service::UploadPack, &connect)?;
635                Ok(fetch_remote_into(repo, &mut *conn, opts, progress, cancel)?)
636            }
637            RemoteUrl::Ssh(u) => {
638                let transport = ssh_transport(Some(repo), Some(&opts.environment));
639                let mut conn = transport.connect(u, Service::UploadPack, &connect)?;
640                Ok(fetch_remote_into(repo, &mut *conn, opts, progress, cancel)?)
641            }
642        }
643    }
644
645    #[allow(clippy::too_many_arguments)]
646    fn dispatch_push(
647        &self,
648        repo: &Repository,
649        url: &RemoteUrl,
650        refs: &[PushRefSpec],
651        opts: &PushOptions,
652        progress: &mut dyn Progress,
653        cancel: &dyn crate::progress::Cancel,
654        http_factory: Option<&dyn HttpClientFactory>,
655        connect: ConnectOptions,
656    ) -> RemoteResult<PushOutcome> {
657        match url {
658            RemoteUrl::Local(_) | RemoteUrl::File(_) => {
659                let remote_git = local_git_dir_from_url(url, repo.git_dir(), repo.work_tree())?;
660                Ok(push_local_from(repo, &remote_git, refs, opts, cancel)?)
661            }
662            RemoteUrl::Http(u) | RemoteUrl::Https(u) => {
663                let client: Arc<dyn HttpClient> = Arc::from(http_client(http_factory, repo)?);
664                let transport = SmartHttpTransport::new(client);
665                let mut conn = transport.connect(u, Service::ReceivePack, &connect)?;
666                Ok(push_remote_from(
667                    repo, &mut *conn, refs, opts, progress, cancel,
668                )?)
669            }
670            RemoteUrl::Git(u) => {
671                let mut conn =
672                    GitDaemonTransport::new().connect(u, Service::ReceivePack, &connect)?;
673                Ok(push_remote_from(
674                    repo, &mut *conn, refs, opts, progress, cancel,
675                )?)
676            }
677            RemoteUrl::Ssh(u) => {
678                let transport = ssh_transport(Some(repo), None);
679                let mut conn = transport.connect(u, Service::ReceivePack, &connect)?;
680                Ok(push_remote_from(
681                    repo, &mut *conn, refs, opts, progress, cancel,
682                )?)
683            }
684            RemoteUrl::Bundle(_) => Err(RemoteError::UnsupportedScheme),
685        }
686    }
687
688    fn list_refs_wire(
689        &self,
690        url: &RemoteUrl,
691        local_git_dir: &Path,
692        odb: Option<&Odb>,
693        opts: &ListRefsOptions,
694        http_factory: Option<&dyn HttpClientFactory>,
695        repo: Option<&Repository>,
696    ) -> RemoteResult<Vec<RemoteRef>> {
697        let wire = url.as_wire_url().ok_or(RemoteError::UnsupportedScheme)?;
698        let requested = opts.protocol_version.unwrap_or(2);
699        let connect_opts = ConnectOptions {
700            protocol_version: requested,
701            ..Default::default()
702        };
703        match url {
704            RemoteUrl::Http(_) | RemoteUrl::Https(_) => {
705                list_refs_http(wire, local_git_dir, odb, opts, http_factory, repo)
706            }
707            RemoteUrl::Git(_) => {
708                let mut conn =
709                    GitDaemonTransport::new().connect(wire, Service::UploadPack, &connect_opts)?;
710                list_refs_from_connection(&mut *conn, local_git_dir, odb, opts)
711            }
712            RemoteUrl::Ssh(_) => {
713                let transport = ssh_transport(repo, None);
714                let mut conn = transport.connect(wire, Service::UploadPack, &connect_opts)?;
715                list_refs_from_connection(&mut *conn, local_git_dir, odb, opts)
716            }
717            RemoteUrl::Local(_) | RemoteUrl::File(_) | RemoteUrl::Bundle(_) => unreachable!(),
718        }
719    }
720}
721
722fn repo_context(repo: Option<&Repository>) -> (&Path, Option<&Path>, Option<Odb>) {
723    match repo {
724        Some(r) => (r.git_dir(), r.work_tree(), Some(r.odb().clone())),
725        None => (Path::new("."), None, None),
726    }
727}
728
729fn local_git_dir_from_url(
730    url: &RemoteUrl,
731    git_dir: &Path,
732    work_tree: Option<&Path>,
733) -> RemoteResult<PathBuf> {
734    let s = url.to_url_string();
735    let remote_git = resolve_local_remote_git_dir(&s, git_dir, work_tree);
736    crate::transfer::ensure_local_remote_repository(&remote_git)?;
737    Ok(remote_git)
738}
739
740fn ssh_transport(
741    repo: Option<&Repository>,
742    fetch_env: Option<&crate::environment::Environment>,
743) -> SshTransport {
744    let mut transport = SshTransport::new();
745    if let Some(r) = repo {
746        transport = transport.with_command_runner(r.command_runner());
747    }
748    let env = ssh_wire_environment(repo, fetch_env);
749    transport = transport.with_ssh_environment(std::sync::Arc::new(env.clone()));
750    if let Some(r) = repo {
751        if let Ok(config) = r.config() {
752            transport = transport.with_ssh_config(config);
753        }
754    } else if let Ok(config) = ConfigSet::load(&env, None, true) {
755        transport = transport.with_ssh_config(Arc::new(config));
756    }
757    transport
758}
759
760/// Environment for SSH subprocess spawn: repository discovery env unless the fetch
761/// options carry an explicit non-default [`FetchOptions::environment`] snapshot.
762fn ssh_wire_environment(
763    repo: Option<&Repository>,
764    fetch_env: Option<&crate::environment::Environment>,
765) -> crate::environment::Environment {
766    if let Some(fe) = fetch_env {
767        if !fe.is_fetch_options_default() {
768            return fe.clone();
769        }
770    }
771    repo.map(|r| r.environment().clone())
772        .unwrap_or_else(crate::environment::Environment::capture_process)
773}
774
775fn http_client(
776    factory: Option<&dyn HttpClientFactory>,
777    repo: &Repository,
778) -> RemoteResult<Box<dyn HttpClient>> {
779    let config = repo.config().map_err(RemoteError::Library)?;
780    if let Some(f) = factory {
781        return f.create(config.as_ref());
782    }
783    #[cfg(feature = "http-ureq")]
784    {
785        DefaultHttpClientFactory::with_environment(repo.environment().clone())
786            .create(config.as_ref())
787    }
788    #[cfg(not(feature = "http-ureq"))]
789    {
790        let _ = config;
791        Err(RemoteError::HttpUnavailable)
792    }
793}
794
795fn list_refs_http(
796    repo_url: &str,
797    local_git_dir: &Path,
798    odb: Option<&Odb>,
799    opts: &ListRefsOptions,
800    http_factory: Option<&dyn HttpClientFactory>,
801    repo: Option<&Repository>,
802) -> RemoteResult<Vec<RemoteRef>> {
803    let config: Arc<ConfigSet> = if let Some(r) = repo {
804        r.config().map_err(RemoteError::Library)?
805    } else {
806        Arc::new(ConfigSet::new())
807    };
808    let client: Box<dyn HttpClient> = match http_factory {
809        Some(f) => f.create(config.as_ref())?,
810        None => {
811            #[cfg(feature = "http-ureq")]
812            {
813                repo.map_or_else(DefaultHttpClientFactory::default, |r| {
814                    DefaultHttpClientFactory::with_environment(r.environment().clone())
815                })
816                .create(config.as_ref())?
817            }
818            #[cfg(not(feature = "http-ureq"))]
819            {
820                return Err(RemoteError::HttpUnavailable);
821            }
822        }
823    };
824
825    // When `protocol_version` is set, honor it on discovery (including v0 with no
826    // `Git-Protocol` header). `None` defers to the HTTP client's config default.
827    let protocol_header_value = opts
828        .protocol_version
829        .and_then(crate::protocol::client_git_protocol_header_value);
830    let git_protocol: Option<&str> = match opts.protocol_version {
831        None => None,
832        Some(0) => Some(""),
833        Some(_) => protocol_header_value.as_deref(),
834    };
835
836    let info_url = crate::transport::http::smart_info_refs_discovery_url(repo_url);
837    let want_v2 = opts.protocol_version.unwrap_or(2) >= 2;
838    let (body, final_url) = client
839        .get_with_final_url_exact(&info_url, git_protocol)
840        .map_err(RemoteError::Library)?;
841    let effective_base =
842        crate::transport::http::rebased_base_from_redirect(repo_url, final_url.as_deref())
843            .unwrap_or_else(|| repo_url.to_owned());
844    let (protocol_version, refs, caps, head_symref) =
845        if effective_base.trim_end_matches('/') != repo_url.trim_end_matches('/') {
846            client.reset_auth_after_redirect_rebase();
847            crate::transport::http::discover_upload_pack_for_list_refs(
848                client.as_ref(),
849                &effective_base,
850                git_protocol,
851            )
852            .map_err(RemoteError::Library)?
853        } else {
854            crate::transport::http::discover_upload_pack_for_list_refs_from_body(&body)
855                .map_err(RemoteError::Library)?
856        };
857
858    if protocol_version >= 2 && want_v2 {
859        let local_odb = odb.cloned().unwrap_or_else(|| open_odb(local_git_dir));
860        let req = build_list_refs_v2_request(&caps, &local_odb, opts)?;
861        let post_url = format!("{}/git-upload-pack", effective_base.trim_end_matches('/'));
862        let body = client
863            .as_ref()
864            .post(
865                &post_url,
866                "application/x-git-upload-pack-request",
867                "application/x-git-upload-pack-result",
868                &req,
869                git_protocol,
870            )
871            .map_err(RemoteError::Library)?;
872        let mut cur = Cursor::new(body);
873        parse_list_refs_v2_response(&mut cur, opts, head_symref)
874    } else {
875        list_refs_v0_advertisement(&refs, head_symref.as_deref(), odb, opts)
876    }
877}
878
879fn list_refs_from_connection(
880    conn: &mut dyn crate::transport::Connection,
881    local_git_dir: &Path,
882    odb: Option<&Odb>,
883    opts: &ListRefsOptions,
884) -> RemoteResult<Vec<RemoteRef>> {
885    let want_v2 = opts.protocol_version.unwrap_or(2) >= 2;
886    if conn.protocol_version() >= 2 && want_v2 {
887        list_refs_v2_connection(conn, local_git_dir, odb, opts)
888    } else {
889        let out =
890            list_refs_v0_advertisement(conn.advertised_refs(), conn.head_symref(), odb, opts)?;
891        crate::transport::send_client_flush(conn).map_err(RemoteError::Library)?;
892        Ok(out)
893    }
894}
895
896fn open_odb(git_dir: &Path) -> Odb {
897    crate::transfer::open_odb(git_dir)
898}
899
900fn list_refs_v2_connection(
901    conn: &mut dyn crate::transport::Connection,
902    local_git_dir: &Path,
903    odb: Option<&Odb>,
904    opts: &ListRefsOptions,
905) -> RemoteResult<Vec<RemoteRef>> {
906    let local_odb = odb.cloned().unwrap_or_else(|| open_odb(local_git_dir));
907    let req = build_list_refs_v2_request(conn.capabilities(), &local_odb, opts)?;
908    conn.writer().write_all(&req).map_err(Error::Io)?;
909    conn.writer().flush().map_err(Error::Io)?;
910    let discovery_head = conn.head_symref().map(str::to_owned);
911    let out = parse_list_refs_v2_response(conn.reader(), opts, discovery_head)?;
912    conn.finish_send();
913    Ok(out)
914}
915
916fn build_list_refs_v2_request(
917    server_caps: &[String],
918    local_odb: &Odb,
919    opts: &ListRefsOptions,
920) -> RemoteResult<Vec<u8>> {
921    use crate::fetch::v2_object_format;
922    use crate::pkt_line;
923    use crate::protocol_v2;
924
925    let object_format = v2_object_format(server_caps, local_odb);
926    let cap_echo = protocol_v2::cap_lines_for_command_request(server_caps);
927    let mut req: Vec<u8> = Vec::new();
928    pkt_line::write_line(&mut req, "command=ls-refs").map_err(Error::Io)?;
929    if cap_echo.iter().any(|c| c.starts_with("object-format=")) {
930        for line in &cap_echo {
931            pkt_line::write_line(&mut req, line).map_err(Error::Io)?;
932        }
933    } else {
934        for line in &cap_echo {
935            pkt_line::write_line(&mut req, line).map_err(Error::Io)?;
936        }
937        pkt_line::write_line(&mut req, &format!("object-format={object_format}"))
938            .map_err(Error::Io)?;
939    }
940    pkt_line::write_delim(&mut req).map_err(Error::Io)?;
941    if opts.symrefs {
942        pkt_line::write_line(&mut req, "symrefs").map_err(Error::Io)?;
943        if crate::protocol_v2::server_advertises_ls_refs_unborn(server_caps) {
944            pkt_line::write_line(&mut req, "unborn").map_err(Error::Io)?;
945        }
946    }
947    if opts.peel {
948        pkt_line::write_line(&mut req, "peel").map_err(Error::Io)?;
949    }
950    if list_refs_includes_head(opts) {
951        pkt_line::write_line(&mut req, "ref-prefix HEAD").map_err(Error::Io)?;
952    }
953    for prefix in list_ref_prefixes(opts) {
954        pkt_line::write_line(&mut req, &format!("ref-prefix {prefix}")).map_err(Error::Io)?;
955    }
956    pkt_line::write_flush(&mut req).map_err(Error::Io)?;
957    Ok(req)
958}
959
960fn list_refs_includes_head(opts: &ListRefsOptions) -> bool {
961    !opts.heads && !opts.tags && opts.prefixes.is_empty()
962}
963
964fn list_ref_prefixes(opts: &ListRefsOptions) -> Vec<String> {
965    if !opts.prefixes.is_empty() {
966        if opts.heads && !opts.tags {
967            return vec!["refs/heads/".to_owned()];
968        }
969        if opts.tags && !opts.heads {
970            return vec!["refs/tags/".to_owned()];
971        }
972        return vec!["refs/heads/".to_owned(), "refs/tags/".to_owned()];
973    }
974    let mut out = Vec::new();
975    let all = !opts.heads && !opts.tags;
976    if all || opts.heads {
977        out.push("refs/heads/".to_owned());
978    }
979    if all || opts.tags {
980        out.push("refs/tags/".to_owned());
981    }
982    out
983}
984
985fn parse_list_refs_v2_response(
986    reader: &mut dyn Read,
987    opts: &ListRefsOptions,
988    mut head_symref: Option<String>,
989) -> RemoteResult<Vec<RemoteRef>> {
990    use crate::fetch::parse_ls_refs_v2_line;
991    use crate::pkt_line;
992
993    let mut entries: Vec<RemoteRef> = Vec::new();
994    let mut peel_map: std::collections::HashMap<String, ObjectId> =
995        std::collections::HashMap::new();
996    let mut reader = reader;
997    loop {
998        match pkt_line::read_packet(&mut reader).map_err(Error::Io)? {
999            None | Some(pkt_line::Packet::Flush) | Some(pkt_line::Packet::Delim) => break,
1000            Some(pkt_line::Packet::ResponseEnd) => break,
1001            Some(pkt_line::Packet::Data(line)) => {
1002                let line = line.trim_end_matches('\n');
1003                if let Some(msg) = line.strip_prefix("ERR ") {
1004                    return Err(RemoteError::Library(Error::Message(format!(
1005                        "remote error: {}",
1006                        msg.trim_end()
1007                    ))));
1008                }
1009                if let Some(t) = crate::fetch::parse_v2_ls_refs_unborn_head_symref(line) {
1010                    head_symref = Some(t);
1011                    continue;
1012                }
1013                let Some((name, oid, symref_target, peel)) = parse_ls_refs_v2_line(line) else {
1014                    continue;
1015                };
1016                if name.ends_with("^{}") {
1017                    continue;
1018                }
1019                if name == "HEAD" {
1020                    let sym =
1021                        symref_target.filter(|t| crate::refs::is_valid_advertised_symref_target(t));
1022                    if let Some(t) = sym.clone() {
1023                        head_symref = Some(t);
1024                    }
1025                    if list_refs_includes_head(opts) && ref_matches_list_opts("HEAD", opts) {
1026                        entries.push(RemoteRef {
1027                            name: "HEAD".to_owned(),
1028                            oid,
1029                            symref_target: if opts.symrefs { sym } else { None },
1030                        });
1031                    }
1032                    continue;
1033                }
1034                if !crate::refs::is_valid_fetch_advertised_ref(&name) {
1035                    continue;
1036                }
1037                if let Some(p) = peel {
1038                    peel_map.insert(name.clone(), p);
1039                }
1040                let sym =
1041                    symref_target.filter(|t| crate::refs::is_valid_advertised_symref_target(t));
1042                entries.push(RemoteRef {
1043                    name,
1044                    oid,
1045                    symref_target: sym,
1046                });
1047            }
1048        }
1049    }
1050    finalize_list_refs_output(entries, peel_map, opts, head_symref, None)
1051}
1052
1053fn list_refs_v0_advertisement(
1054    advertised: &[(String, ObjectId)],
1055    head_symref: Option<&str>,
1056    odb: Option<&Odb>,
1057    opts: &ListRefsOptions,
1058) -> RemoteResult<Vec<RemoteRef>> {
1059    let mut entries: Vec<RemoteRef> = Vec::new();
1060    let mut peel_map: std::collections::HashMap<String, ObjectId> =
1061        std::collections::HashMap::new();
1062    for (name, oid) in advertised {
1063        if name.ends_with("^{}") {
1064            if let Some(base) = name.strip_suffix("^{}") {
1065                peel_map.insert(base.to_owned(), *oid);
1066            }
1067            continue;
1068        }
1069        entries.push(RemoteRef {
1070            name: name.clone(),
1071            oid: *oid,
1072            symref_target: None,
1073        });
1074    }
1075    finalize_list_refs_output(entries, peel_map, opts, head_symref.map(str::to_owned), odb)
1076}
1077
1078fn finalize_list_refs_output(
1079    mut entries: Vec<RemoteRef>,
1080    peel_map: std::collections::HashMap<String, ObjectId>,
1081    opts: &ListRefsOptions,
1082    head_symref: Option<String>,
1083    odb: Option<&Odb>,
1084) -> RemoteResult<Vec<RemoteRef>> {
1085    entries.retain(|e| remote_ref_matches_namespace(&e.name, opts));
1086    entries.retain(|e| patterns_match_ls_remote(&e.name, &opts.prefixes));
1087    if opts.peel {
1088        let mut peel_lines: Vec<RemoteRef> = Vec::new();
1089        for e in &entries {
1090            if !e.name.starts_with("refs/tags/") {
1091                continue;
1092            }
1093            let peeled = peel_map
1094                .get(&e.name)
1095                .copied()
1096                .or_else(|| odb.and_then(|o| o.peel_tag_target(e.oid).ok().flatten()));
1097            if let Some(p) = peeled {
1098                let peel_name = format!("{}^{{}}", e.name);
1099                if should_emit_peel_line(&peel_name, &opts.prefixes) {
1100                    peel_lines.push(RemoteRef {
1101                        name: peel_name,
1102                        oid: p,
1103                        symref_target: None,
1104                    });
1105                }
1106            }
1107        }
1108        entries.extend(peel_lines);
1109    }
1110    if list_refs_includes_head(opts) {
1111        if let Some(sym) = head_symref {
1112            if let Some(oid) = entries.iter().find(|e| e.name == sym).map(|e| e.oid) {
1113                entries.push(RemoteRef {
1114                    name: "HEAD".to_owned(),
1115                    oid,
1116                    symref_target: if opts.symrefs { Some(sym) } else { None },
1117                });
1118            }
1119        }
1120    }
1121    let query_options = RefQueryOptions {
1122        versionsort_suffixes: opts.versionsort_suffixes.clone(),
1123        ..RefQueryOptions::default()
1124    };
1125    sort_remote_refs(
1126        &mut entries,
1127        &opts.sort,
1128        &query_options.versionsort_suffixes,
1129    );
1130    if let Some(limit) = opts.limit {
1131        entries.truncate(limit);
1132    }
1133    Ok(entries)
1134}
1135
1136/// Filter (heads/tags + ls-remote patterns), sort, and limit an in-memory remote ref list.
1137///
1138/// Callers that already applied peel lines and optional `HEAD` pass the full line list here.
1139///
1140/// # Panics
1141///
1142/// Never panics.
1143#[must_use]
1144pub fn query_remote_refs_in_memory(
1145    mut entries: Vec<RemoteRef>,
1146    opts: &ListRefsOptions,
1147    query_options: &RefQueryOptions,
1148) -> Vec<RemoteRef> {
1149    entries.retain(|e| remote_ref_matches_namespace(&e.name, opts));
1150    entries.retain(|e| patterns_match_ls_remote(&e.name, &opts.prefixes));
1151    sort_remote_refs(
1152        &mut entries,
1153        &opts.sort,
1154        &query_options.versionsort_suffixes,
1155    );
1156    if let Some(limit) = opts.limit {
1157        entries.truncate(limit);
1158    }
1159    entries
1160}
1161
1162fn should_emit_peel_line(peel_name: &str, patterns: &[String]) -> bool {
1163    if patterns.is_empty() {
1164        return true;
1165    }
1166    patterns_match_ls_remote(peel_name, patterns)
1167}
1168
1169fn sort_remote_refs(entries: &mut [RemoteRef], sort: &RefSort, versionsort_suffixes: &[String]) {
1170    entries.sort_by(|a, b| {
1171        let primary = match sort.key {
1172            SortKey::Name => a.name.cmp(&b.name),
1173            SortKey::Version => compare_version_names(&a.name, &b.name, versionsort_suffixes),
1174            SortKey::Date => a.name.cmp(&b.name),
1175        };
1176        let primary = if sort.reverse {
1177            primary.reverse()
1178        } else {
1179            primary
1180        };
1181        if primary != std::cmp::Ordering::Equal {
1182            return primary;
1183        }
1184        a.name.cmp(&b.name)
1185    });
1186}
1187
1188fn remote_ref_matches_namespace(refname: &str, opts: &ListRefsOptions) -> bool {
1189    if opts.heads || opts.tags {
1190        let is_branch = opts.heads && refname.starts_with("refs/heads/");
1191        let is_tag = opts.tags && refname.starts_with("refs/tags/");
1192        if refname == "HEAD" {
1193            return false;
1194        }
1195        if refname.ends_with("^{}") {
1196            return is_tag;
1197        }
1198        if !is_branch && !is_tag {
1199            return false;
1200        }
1201    }
1202    true
1203}
1204
1205fn ref_matches_list_opts(refname: &str, opts: &ListRefsOptions) -> bool {
1206    remote_ref_matches_namespace(refname, opts) && patterns_match_ls_remote(refname, &opts.prefixes)
1207}
1208
1209/// Returns `true` when `refname` matches one of `patterns`, or when `patterns` is empty.
1210#[must_use]
1211pub fn ref_matches_ls_remote_patterns(refname: &str, patterns: &[String]) -> bool {
1212    patterns_match_ls_remote(refname, patterns)
1213}
1214
1215/// List references from a on-disk git directory (local / `file://` remotes).
1216///
1217/// # Errors
1218fn list_refs_from_bundle(path: &Path, opts: &ListRefsOptions) -> RemoteResult<Vec<RemoteRef>> {
1219    let bundle = crate::bundle::Bundle::open(path)
1220        .map_err(|e| RemoteError::Library(Error::Message(e.to_string())))?;
1221    let mut out = Vec::new();
1222    for (name, oid) in &bundle.header().refs {
1223        if !ref_matches_list_opts(name, opts) {
1224            continue;
1225        }
1226        if name != "HEAD" && !crate::refs::is_valid_fetch_advertised_ref(name) {
1227            continue;
1228        }
1229        out.push(RemoteRef {
1230            name: name.clone(),
1231            oid: *oid,
1232            symref_target: None,
1233        });
1234    }
1235    sort_remote_refs(&mut out, &opts.sort, &opts.versionsort_suffixes);
1236    if let Some(limit) = opts.limit {
1237        out.truncate(limit);
1238    }
1239    Ok(out)
1240}
1241
1242/// List refs from a on-disk git directory using explicit scan prefixes (fetch).
1243pub(crate) fn list_refs_from_git_dir_for_fetch(
1244    git_dir: &Path,
1245    odb: Option<&Odb>,
1246    refspecs: &[String],
1247) -> RemoteResult<Vec<RemoteRef>> {
1248    let prefixes = crate::fetch::fetch_ref_list_prefixes(refspecs);
1249    let opts = ListRefsOptions {
1250        symrefs: true,
1251        ..Default::default()
1252    };
1253    list_refs_from_git_dir_with_scan_prefixes(git_dir, odb, &opts, &prefixes)
1254}
1255
1256///
1257/// Returns [`RemoteError::Library`] on I/O or ref resolution failures.
1258pub fn list_refs_from_git_dir(
1259    git_dir: &Path,
1260    odb: Option<&Odb>,
1261    opts: &ListRefsOptions,
1262) -> RemoteResult<Vec<RemoteRef>> {
1263    list_refs_from_git_dir_with_scan_prefixes(git_dir, odb, opts, &list_ref_prefixes(opts))
1264}
1265
1266fn list_refs_from_git_dir_with_scan_prefixes(
1267    git_dir: &Path,
1268    odb: Option<&Odb>,
1269    opts: &ListRefsOptions,
1270    scan_prefixes: &[String],
1271) -> RemoteResult<Vec<RemoteRef>> {
1272    let mut lazy_odb: Option<Odb> = None;
1273
1274    let mut entries = Vec::new();
1275    if list_refs_includes_head(opts) && ref_matches_list_opts("HEAD", opts) {
1276        let symref_target = if opts.symrefs {
1277            crate::refs::read_symbolic_ref(git_dir, "HEAD")
1278                .ok()
1279                .flatten()
1280        } else {
1281            None
1282        };
1283        match crate::refs::resolve_ref(git_dir, "HEAD") {
1284            Ok(head_oid) => {
1285                entries.push(RemoteRef {
1286                    name: "HEAD".to_owned(),
1287                    oid: head_oid,
1288                    symref_target,
1289                });
1290            }
1291            Err(_) if symref_target.is_some() => {
1292                entries.push(RemoteRef {
1293                    name: "HEAD".to_owned(),
1294                    oid: ObjectId::zero(),
1295                    symref_target,
1296                });
1297            }
1298            Err(_) => {}
1299        }
1300    }
1301
1302    for prefix in scan_prefixes {
1303        let listed = crate::refs::list_refs(git_dir, prefix).map_err(RemoteError::Library)?;
1304        for (name, oid) in listed {
1305            if let Some(branch_tail) = name.strip_prefix("refs/heads/") {
1306                if branch_tail.starts_with("refs/") {
1307                    continue;
1308                }
1309            }
1310            if !ref_matches_list_opts(&name, opts) {
1311                continue;
1312            }
1313            entries.push(RemoteRef {
1314                name: name.clone(),
1315                oid,
1316                symref_target: None,
1317            });
1318            if opts.peel && name.starts_with("refs/tags/") {
1319                let peel_name = format!("{name}^{{}}");
1320                if should_emit_peel_line(&peel_name, &opts.prefixes) {
1321                    let peel_odb = match odb {
1322                        Some(o) => o,
1323                        None => lazy_odb.get_or_insert_with(|| {
1324                            Odb::new(&git_dir.join("objects"))
1325                                .with_config_git_dir(git_dir.to_path_buf())
1326                        }),
1327                    };
1328                    if let Ok(Some(peeled)) = peel_odb.peel_tag_target(oid) {
1329                        entries.push(RemoteRef {
1330                            name: peel_name,
1331                            oid: peeled,
1332                            symref_target: None,
1333                        });
1334                    }
1335                }
1336            }
1337        }
1338    }
1339
1340    sort_remote_refs(&mut entries, &opts.sort, &opts.versionsort_suffixes);
1341    if let Some(limit) = opts.limit {
1342        entries.truncate(limit);
1343    }
1344    Ok(entries)
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350
1351    fn remote_ref(name: &str, hex_digit: char) -> RemoteRef {
1352        RemoteRef {
1353            name: name.to_owned(),
1354            oid: ObjectId::from_hex(&hex_digit.to_string().repeat(40)).unwrap(),
1355            symref_target: None,
1356        }
1357    }
1358
1359    #[test]
1360    fn missing_urls_name_the_remote() {
1361        let err = parse_remote_url("  ").expect_err("empty");
1362        assert!(matches!(err, RemoteError::NoUrl { remote: None }));
1363        assert_eq!(err.to_string(), "remote URL is empty");
1364
1365        let config = ConfigSet::new();
1366        let err = Remote::from_config(&config, "upstream").expect_err("no url");
1367        assert!(matches!(&err, RemoteError::NoUrl { remote: Some(name) } if name == "upstream"));
1368        assert_eq!(err.to_string(), "remote 'upstream' has no URL configured");
1369    }
1370
1371    #[test]
1372    fn fold_peeled_refs_attaches_peeled_lines_and_keeps_order() {
1373        let mut head = remote_ref("HEAD", '1');
1374        head.symref_target = Some("refs/heads/main".to_owned());
1375        let refs = vec![
1376            head,
1377            remote_ref("refs/heads/main", '1'),
1378            remote_ref("refs/tags/light", '1'),
1379            remote_ref("refs/tags/v1", '2'),
1380            remote_ref("refs/tags/v1^{}", '1'),
1381        ];
1382        let folded = fold_peeled_refs(&refs);
1383        let names: Vec<&str> = folded.iter().map(|r| r.name.as_str()).collect();
1384        assert_eq!(
1385            names,
1386            ["HEAD", "refs/heads/main", "refs/tags/light", "refs/tags/v1"]
1387        );
1388        assert_eq!(folded[0].symref_target.as_deref(), Some("refs/heads/main"));
1389        assert_eq!(folded[2].peeled, None);
1390        assert_eq!(folded[3].oid, refs[3].oid);
1391        assert_eq!(folded[3].peeled, Some(refs[4].oid));
1392    }
1393
1394    #[test]
1395    fn peeled_tag_only_matches_peel_suffix() {
1396        assert_eq!(
1397            remote_ref("refs/tags/v1^{}", '1').peeled_tag(),
1398            Some("refs/tags/v1")
1399        );
1400        assert_eq!(remote_ref("refs/tags/v1", '1').peeled_tag(), None);
1401    }
1402
1403    #[test]
1404    fn remote_url_parse_table() {
1405        assert!(matches!(
1406            RemoteUrl::try_from("/abs/path").unwrap(),
1407            RemoteUrl::Local(_)
1408        ));
1409        assert!(matches!(
1410            RemoteUrl::try_from("file:///tmp/r.git").unwrap(),
1411            RemoteUrl::File(_)
1412        ));
1413        assert!(matches!(
1414            RemoteUrl::try_from("git://host/repo").unwrap(),
1415            RemoteUrl::Git(_)
1416        ));
1417        assert!(matches!(
1418            RemoteUrl::try_from("git@host:repo.git").unwrap(),
1419            RemoteUrl::Ssh(_)
1420        ));
1421        assert!(matches!(
1422            RemoteUrl::try_from("ssh://git@host/repo").unwrap(),
1423            RemoteUrl::Ssh(_)
1424        ));
1425        assert!(matches!(
1426            RemoteUrl::try_from("https://host/r.git").unwrap(),
1427            RemoteUrl::Https(_)
1428        ));
1429        assert!(matches!(
1430            RemoteUrl::try_from("ftp://x"),
1431            Err(RemoteError::UnsupportedScheme)
1432        ));
1433    }
1434
1435    #[test]
1436    fn pattern_matches_empty_allows_all() {
1437        assert!(ref_matches_ls_remote_patterns("refs/heads/main", &[]));
1438        assert!(ref_matches_ls_remote_patterns("HEAD", &[]));
1439    }
1440
1441    #[test]
1442    fn pattern_matches_exact() {
1443        let pats = vec!["HEAD".to_owned()];
1444        assert!(ref_matches_ls_remote_patterns("HEAD", &pats));
1445        assert!(!ref_matches_ls_remote_patterns("refs/heads/main", &pats));
1446    }
1447
1448    #[test]
1449    fn pattern_matches_suffix_component() {
1450        let pats = vec!["main".to_owned()];
1451        assert!(ref_matches_ls_remote_patterns("refs/heads/main", &pats));
1452        assert!(!ref_matches_ls_remote_patterns("refs/heads/notmain", &pats));
1453        assert!(!ref_matches_ls_remote_patterns("main-branch", &pats));
1454    }
1455
1456    #[test]
1457    fn query_remote_refs_in_memory_empty_pattern_matches_nothing() {
1458        let entries = vec![
1459            RemoteRef {
1460                name: "refs/heads/main".into(),
1461                oid: ObjectId::from_hex("1".repeat(40).as_str()).unwrap(),
1462                symref_target: None,
1463            },
1464            RemoteRef {
1465                name: "refs/tags/v1".into(),
1466                oid: ObjectId::from_hex("2".repeat(40).as_str()).unwrap(),
1467                symref_target: None,
1468            },
1469        ];
1470        let opts = ListRefsOptions {
1471            prefixes: vec![String::new()],
1472            ..Default::default()
1473        };
1474        let out = query_remote_refs_in_memory(entries, &opts, &RefQueryOptions::default());
1475        assert!(out.is_empty());
1476    }
1477
1478    #[test]
1479    fn query_remote_refs_in_memory_version_sort_and_limit() {
1480        let entries = vec![
1481            RemoteRef {
1482                name: "refs/tags/v1.10".into(),
1483                oid: ObjectId::from_hex("1".repeat(40).as_str()).unwrap(),
1484                symref_target: None,
1485            },
1486            RemoteRef {
1487                name: "refs/tags/v1.2".into(),
1488                oid: ObjectId::from_hex("2".repeat(40).as_str()).unwrap(),
1489                symref_target: None,
1490            },
1491            RemoteRef {
1492                name: "refs/tags/v1.1".into(),
1493                oid: ObjectId::from_hex("3".repeat(40).as_str()).unwrap(),
1494                symref_target: None,
1495            },
1496        ];
1497        let opts = ListRefsOptions {
1498            sort: RefSort {
1499                key: SortKey::Version,
1500                reverse: false,
1501            },
1502            limit: Some(2),
1503            ..Default::default()
1504        };
1505        let out = query_remote_refs_in_memory(entries, &opts, &RefQueryOptions::default());
1506        assert_eq!(
1507            out.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
1508            vec!["refs/tags/v1.1", "refs/tags/v1.2"]
1509        );
1510    }
1511
1512    #[test]
1513    fn list_opts_heads_excludes_head() {
1514        let heads_only = super::ListRefsOptions {
1515            heads: true,
1516            ..Default::default()
1517        };
1518        assert!(!super::ref_matches_list_opts("HEAD", &heads_only));
1519        assert!(super::ref_matches_list_opts("refs/heads/main", &heads_only));
1520        assert!(!super::ref_matches_list_opts("refs/tags/v1", &heads_only));
1521    }
1522
1523    #[test]
1524    fn insteadof_rewrite_applied_in_from_config() {
1525        let mut cfg = ConfigSet::new();
1526        cfg.add_command_override("url.https://github.com/.insteadOf", "gh:")
1527            .unwrap();
1528        cfg.add_command_override("remote.origin.url", "gh:org/repo.git")
1529            .unwrap();
1530        let remote = Remote::from_config(&cfg, "origin").unwrap();
1531        assert!(matches!(remote.fetch_url(), RemoteUrl::Https(_)));
1532    }
1533
1534    #[test]
1535    fn push_insteadof_applied_when_pushurl_absent() {
1536        let mut cfg = ConfigSet::new();
1537        cfg.add_command_override("url.ssh://push.example/.pushInsteadOf", "short:")
1538            .unwrap();
1539        cfg.add_command_override("remote.origin.url", "short:org/repo.git")
1540            .unwrap();
1541        let remote = Remote::from_config(&cfg, "origin").unwrap();
1542        assert_eq!(
1543            remote.push_url().to_url_string(),
1544            "ssh://push.example/org/repo.git"
1545        );
1546        assert_eq!(remote.fetch_url().to_url_string(), "short:org/repo.git");
1547    }
1548
1549    #[test]
1550    fn list_ref_prefixes_broad_for_ls_remote_patterns() {
1551        let opts = super::ListRefsOptions {
1552            prefixes: vec!["main".to_owned()],
1553            ..Default::default()
1554        };
1555        let prefixes = super::list_ref_prefixes(&opts);
1556        assert_eq!(
1557            prefixes,
1558            vec!["refs/heads/".to_owned(), "refs/tags/".to_owned()]
1559        );
1560        assert!(super::ref_matches_list_opts("refs/heads/main", &opts));
1561        assert!(!super::ref_matches_list_opts("refs/heads/topic", &opts));
1562    }
1563}