Skip to main content

grit_lib/remote/
core.rs

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