Skip to main content

grit_lib/remote/
core.rs

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