Skip to main content

sley_remote/
ls_remote.rs

1//! Callable ls-remote advertisement listing for HTTP(S) and local remotes.
2//!
3//! [`ls_remote`] returns the advertised refs a `git ls-remote` would print for a
4//! resolved remote, as a [`LsRemoteRecord`] list, without sorting, printing, or
5//! exit-code mapping — those stay in the CLI (the `--sort`/`--symref` formatting
6//! and the `--exit-code` ⇒ exit-2 behavior are CLI concerns). Everything is taken
7//! as explicit parameters — the resolved [`LsRemoteSource`], the request
8//! [`ObjectFormat`], a [`LsRemoteFilter`], a ref-name match predicate, and a
9//! [`CredentialProvider`] — so it never reads process-global state, parses
10//! arguments, or prints.
11//!
12//! The ref-name glob/pattern matching (`refs/heads/*` style filters, peeled-tag
13//! `^{}` matching) is the CLI's larger ref-filter machinery, so it is injected as
14//! the `matches` predicate rather than moved; this module only applies the
15//! ref-class filters (`--heads`/`--tags`/`--refs`) and shapes the records.
16//!
17//! SSH ls-remote still lives in the CLI; only HTTP and local move here.
18
19#[cfg(feature = "http")]
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23use sley_config::GitConfig;
24use sley_core::{GitError, ObjectFormat, ObjectId, Result};
25use sley_object::ObjectType;
26use sley_odb::{FileObjectDatabase, ObjectReader};
27use sley_refs::{FileRefStore, Ref, RefTarget};
28use sley_transport::RemoteUrl;
29
30use crate::CredentialProvider;
31
32/// How [`ls_remote`] obtains the ref advertisements.
33///
34/// The caller resolves the remote (URL rewriting, repository discovery — all
35/// process-state dependent) and hands `ls_remote` a concrete transport.
36pub enum LsRemoteSource {
37    /// A smart-HTTP(S) remote at the given already-resolved URL.
38    Http(RemoteUrl),
39    /// An SSH remote at the given already-resolved URL, listed by spawning `ssh`
40    /// (the credential seam is unused — the `ssh` program owns authentication).
41    Ssh(RemoteUrl),
42    /// A native anonymous `git://` remote at the given already-resolved URL.
43    Git(RemoteUrl),
44    /// A local repository read directly from `git_dir` (refs and the object
45    /// database used to peel annotated tags both resolve from this `$GIT_DIR`,
46    /// matching `git ls-remote` against a local path).
47    Local {
48        /// The remote repository's `$GIT_DIR`.
49        git_dir: PathBuf,
50    },
51}
52
53/// The ref-class filters that select which advertised refs to keep, mirroring the
54/// `git ls-remote` flags the CLI parses.
55#[derive(Debug, Clone, Copy, Default)]
56pub struct LsRemoteFilter {
57    /// Limit to branch refs (`--heads`/`--branches`).
58    pub heads: bool,
59    /// Limit to tag refs (`--tags`).
60    pub tags: bool,
61    /// Drop `HEAD` and peeled `^{}` entries (`--refs`).
62    pub refs_only: bool,
63}
64
65/// One advertised ref returned by [`ls_remote`] — what the CLI prints as a
66/// `<oid>\t<name>` line (with an optional preceding `ref: <symref>\t<name>` line
67/// when `--symref` is set and `symref` is present).
68#[derive(Debug, Clone)]
69pub struct LsRemoteRecord {
70    /// The object id the ref points at (peeled to the tag object for `^{}`
71    /// records).
72    pub oid: ObjectId,
73    /// The full ref name (e.g. `refs/heads/main`, `HEAD`, or `refs/tags/v1^{}`).
74    pub name: String,
75    /// The symref target, when the remote advertised this ref as a symbolic ref
76    /// (e.g. `HEAD` → `refs/heads/main`).
77    pub symref: Option<String>,
78}
79
80/// Fully resolved inputs for an advertisement listing.
81pub struct LsRemoteRequest<'a> {
82    pub source: &'a LsRemoteSource,
83    pub format: ObjectFormat,
84    pub filter: &'a LsRemoteFilter,
85    pub config: Option<&'a GitConfig>,
86}
87
88/// Structured advertisement result for embedders.
89#[derive(Debug, Clone)]
90pub struct LsRemoteOutcome {
91    pub records: Vec<LsRemoteRecord>,
92    pub format: ObjectFormat,
93}
94
95/// List the advertised refs for a resolved `source`.
96///
97/// Performs the work the CLI's `ls_remote_http_records` and inline local
98/// ls-remote path did: advertises the remote's refs (HTTP) or reads them directly
99/// (local), applies the `--heads`/`--tags`/`--refs` class filters and the
100/// caller-supplied `matches` ref-name predicate, and shapes the surviving refs
101/// into [`LsRemoteRecord`]s. For the local path it also emits peeled `^{}` records
102/// for annotated tags (unless `refs_only`).
103///
104/// `format` is the request/expected object format (SHA-1 for HTTP, the local
105/// repository's format for local); the returned [`ObjectFormat`] is the format
106/// actually in effect (HTTP resolves it from the advertisement). Returns the
107/// records and that format; never sorts, prints, or returns `GitError::Exit`. The
108/// caller applies `--sort`, `--symref` formatting, and the `--exit-code` mapping.
109pub fn ls_remote(
110    source: &LsRemoteSource,
111    format: ObjectFormat,
112    filter: &LsRemoteFilter,
113    matches: &dyn Fn(&str) -> bool,
114    config: Option<&GitConfig>,
115    #[cfg_attr(not(feature = "http"), allow(unused_variables))]
116    credentials: &mut dyn CredentialProvider,
117) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
118    let outcome = ls_remote_with(
119        LsRemoteRequest {
120            source,
121            format,
122            filter,
123            config,
124        },
125        matches,
126        credentials,
127    )?;
128    Ok((outcome.records, outcome.format))
129}
130
131/// List advertisements using typed request/outcome values.
132pub fn ls_remote_with(
133    request: LsRemoteRequest<'_>,
134    matches: &dyn Fn(&str) -> bool,
135    credentials: &mut dyn CredentialProvider,
136) -> Result<LsRemoteOutcome> {
137    let LsRemoteRequest {
138        source,
139        format,
140        filter,
141        config,
142    } = request;
143    crate::protocol::check_transport_allowed(scheme_for_ls_remote_source(source), config, None)
144        .map_err(crate::protocol::transport_policy_git_error)?;
145    let (records, format) = match source {
146        #[cfg(feature = "http")]
147        LsRemoteSource::Http(remote) => {
148            ls_remote_http(remote, format, filter, matches, credentials, config)
149        }
150        #[cfg(not(feature = "http"))]
151        LsRemoteSource::Http(_) => Err(GitError::Unsupported(
152            "HTTP transport is not enabled in this build".into(),
153        )),
154        LsRemoteSource::Ssh(remote) => crate::ssh::ls_remote_ssh(remote, filter, matches),
155        LsRemoteSource::Git(remote) => crate::git::ls_remote_git(
156            remote,
157            filter,
158            matches,
159            config.and_then(|config| config.get("protocol", None, "version")) == Some("2"),
160            config,
161        ),
162        LsRemoteSource::Local { git_dir } => {
163            ls_remote_local(git_dir, format, filter, matches, config)
164        }
165    }?;
166    Ok(LsRemoteOutcome { records, format })
167}
168
169fn scheme_for_ls_remote_source(source: &LsRemoteSource) -> &'static str {
170    match source {
171        LsRemoteSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
172        LsRemoteSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
173        LsRemoteSource::Git(remote) => crate::protocol::transport_scheme_for_remote(remote),
174        LsRemoteSource::Local { .. } => "file",
175    }
176}
177
178/// List advertised refs over smart HTTP(S): fetch the upload-pack advertisement,
179/// then apply the class filters and `matches` predicate, attaching the advertised
180/// `HEAD` symref where present.
181#[cfg(feature = "http")]
182fn ls_remote_http(
183    remote: &RemoteUrl,
184    format: ObjectFormat,
185    filter: &LsRemoteFilter,
186    matches: &dyn Fn(&str) -> bool,
187    credentials: &mut dyn CredentialProvider,
188    config: Option<&GitConfig>,
189) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
190    let http_batch = crate::http::HttpOperationBatch::new();
191    let (refs, features) = crate::http::http_upload_pack_advertisements(
192        http_batch.client(),
193        remote,
194        format,
195        credentials,
196        config,
197    )?;
198    let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
199    if format != ObjectFormat::Sha1 {
200        return Err(GitError::Unsupported(format!(
201            "http ls-remote currently supports SHA-1 advertisements, got {}",
202            format.name()
203        )));
204    }
205    let symrefs = features
206        .symrefs
207        .iter()
208        .filter_map(|symref| symref.split_once(':'))
209        .map(|(name, target)| (name.to_string(), target.to_string()))
210        .collect::<HashMap<_, _>>();
211    let mut records = Vec::new();
212    for advertisement in refs {
213        if advertisement.oid.is_null() {
214            continue;
215        }
216        if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
217        {
218            continue;
219        }
220        if !ref_class_selected(&advertisement.name, filter) {
221            continue;
222        }
223        if !matches(&advertisement.name) {
224            continue;
225        }
226        records.push(LsRemoteRecord {
227            oid: advertisement.oid,
228            symref: symrefs.get(&advertisement.name).cloned(),
229            name: advertisement.name,
230        });
231    }
232    Ok((records, format))
233}
234
235/// List advertised refs from a local repository at `git_dir`: `HEAD` (when no
236/// class filter is active), then every ref resolved to its object id, plus a
237/// peeled `^{}` record for each annotated tag (unless `refs_only`).
238fn ls_remote_local(
239    git_dir: &Path,
240    format: ObjectFormat,
241    filter: &LsRemoteFilter,
242    matches: &dyn Fn(&str) -> bool,
243    config: Option<&GitConfig>,
244) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
245    let store = FileRefStore::new(git_dir, format);
246    let db = FileObjectDatabase::from_git_dir(git_dir, format);
247    let config = ls_remote_local_config(git_dir, config);
248    let hidden_refs = upload_pack_hidden_ref_values(&config);
249    let include_non_head_symrefs =
250        !matches!(config.get("protocol", None, "version"), Some("0" | "1"));
251    let mut records = Vec::new();
252
253    if !filter.refs_only
254        && !filter.heads
255        && !filter.tags
256        && let Some(target) = store.read_ref("HEAD")?
257    {
258        let reference = Ref {
259            name: "HEAD".to_string(),
260            target,
261        };
262        if matches(&reference.name)
263            && let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)?
264        {
265            records.push(LsRemoteRecord {
266                oid,
267                name: reference.name,
268                symref,
269            });
270        }
271    }
272
273    for reference in store.list_refs()? {
274        if ref_is_hidden_by_patterns(&reference.name, &hidden_refs) {
275            continue;
276        }
277        if !ref_class_selected(&reference.name, filter) {
278            continue;
279        }
280        if !matches(&reference.name) {
281            continue;
282        }
283        let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)? else {
284            continue;
285        };
286        records.push(LsRemoteRecord {
287            oid,
288            name: reference.name.clone(),
289            symref: if include_non_head_symrefs {
290                symref
291            } else {
292                None
293            },
294        });
295        if !filter.refs_only
296            && let Some(record) = peeled_tag_record(&db, format, &oid, &reference.name, matches)?
297        {
298            records.push(record);
299        }
300    }
301
302    Ok((records, format))
303}
304
305fn ls_remote_local_config(git_dir: &Path, config: Option<&GitConfig>) -> GitConfig {
306    let mut local = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
307    if let Some(config) = config {
308        local.sections.extend(config.sections.clone());
309    }
310    local
311}
312
313fn upload_pack_hidden_ref_values(config: &GitConfig) -> Vec<String> {
314    let mut out = Vec::new();
315    for section in &config.sections {
316        let applies = section.subsection.is_none()
317            && (section.name.eq_ignore_ascii_case("transfer")
318                || section.name.eq_ignore_ascii_case("uploadpack"));
319        if !applies {
320            continue;
321        }
322        for entry in &section.entries {
323            if entry.key.eq_ignore_ascii_case("hiderefs")
324                && let Some(value) = entry.value.as_deref()
325            {
326                out.push(trim_hidden_ref_pattern(value));
327            }
328        }
329    }
330    out
331}
332
333fn trim_hidden_ref_pattern(value: &str) -> String {
334    value.trim_end_matches('/').to_string()
335}
336
337fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
338    for pattern in patterns.iter().rev() {
339        let mut pattern = pattern.as_str();
340        let negated = pattern.strip_prefix('!').is_some();
341        if negated {
342            pattern = &pattern[1..];
343        }
344        if let Some(rest) = pattern.strip_prefix('^') {
345            pattern = rest;
346        }
347        if hidden_ref_pattern_matches(refname, pattern) {
348            return !negated;
349        }
350    }
351    false
352}
353
354fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
355    refname
356        .strip_prefix(pattern)
357        .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
358}
359
360/// The peeled `^{}` record for `name` when `oid` is an annotated tag and the
361/// peeled name passes `matches`; `None` otherwise.
362fn peeled_tag_record(
363    db: &FileObjectDatabase,
364    format: ObjectFormat,
365    oid: &ObjectId,
366    name: &str,
367    matches: &dyn Fn(&str) -> bool,
368) -> Result<Option<LsRemoteRecord>> {
369    let object = db.read_object(oid)?;
370    if object.object_type != ObjectType::Tag {
371        return Ok(None);
372    }
373    let peeled_name = format!("{name}^{{}}");
374    if !matches(&peeled_name) {
375        return Ok(None);
376    }
377    let peeled = sley_rev::peel_tags(db, format, oid)?;
378    Ok(Some(LsRemoteRecord {
379        oid: peeled,
380        name: peeled_name,
381        symref: None,
382    }))
383}
384
385/// Whether `name` survives the `--heads`/`--tags` class filter (no class filter
386/// keeps everything; with one or both set, the ref must be in a selected class).
387fn ref_class_selected(name: &str, filter: &LsRemoteFilter) -> bool {
388    if !filter.heads && !filter.tags {
389        return true;
390    }
391    let is_head = name.starts_with("refs/heads/");
392    let is_tag = name.starts_with("refs/tags/");
393    (filter.heads && is_head) || (filter.tags && is_tag)
394}
395
396/// Resolve a (possibly symbolic) ref target to its object id, following up to
397/// five levels of symbolic indirection, returning the first symbolic name seen.
398fn resolve_for_each_ref_target(
399    store: &FileRefStore,
400    reference: &Ref,
401) -> Result<Option<(ObjectId, Option<String>)>> {
402    let mut target = reference.target.clone();
403    let mut symref = None;
404    for _ in 0..5 {
405        match target {
406            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
407            RefTarget::Symbolic(name) => {
408                symref.get_or_insert_with(|| name.clone());
409                let Some(next) = store.read_ref(&name)? else {
410                    return Ok(None);
411                };
412                target = next;
413            }
414        }
415    }
416    Ok(None)
417}