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_core::{GitError, ObjectFormat, ObjectId, Result};
24use sley_object::ObjectType;
25use sley_odb::{FileObjectDatabase, ObjectReader};
26use sley_refs::{FileRefStore, Ref, RefTarget};
27use sley_transport::RemoteUrl;
28
29use crate::CredentialProvider;
30
31/// How [`ls_remote`] obtains the ref advertisements.
32///
33/// The caller resolves the remote (URL rewriting, repository discovery — all
34/// process-state dependent) and hands `ls_remote` a concrete transport.
35pub enum LsRemoteSource {
36    /// A smart-HTTP(S) remote at the given already-resolved URL.
37    Http(RemoteUrl),
38    /// An SSH remote at the given already-resolved URL, listed by spawning `ssh`
39    /// (the credential seam is unused — the `ssh` program owns authentication).
40    Ssh(RemoteUrl),
41    /// A native anonymous `git://` remote at the given already-resolved URL.
42    Git(RemoteUrl),
43    /// A local repository read directly from `git_dir` (refs and the object
44    /// database used to peel annotated tags both resolve from this `$GIT_DIR`,
45    /// matching `git ls-remote` against a local path).
46    Local {
47        /// The remote repository's `$GIT_DIR`.
48        git_dir: PathBuf,
49    },
50}
51
52/// The ref-class filters that select which advertised refs to keep, mirroring the
53/// `git ls-remote` flags the CLI parses.
54#[derive(Debug, Clone, Copy, Default)]
55pub struct LsRemoteFilter {
56    /// Limit to branch refs (`--heads`/`--branches`).
57    pub heads: bool,
58    /// Limit to tag refs (`--tags`).
59    pub tags: bool,
60    /// Drop `HEAD` and peeled `^{}` entries (`--refs`).
61    pub refs_only: bool,
62}
63
64/// One advertised ref returned by [`ls_remote`] — what the CLI prints as a
65/// `<oid>\t<name>` line (with an optional preceding `ref: <symref>\t<name>` line
66/// when `--symref` is set and `symref` is present).
67#[derive(Debug, Clone)]
68pub struct LsRemoteRecord {
69    /// The object id the ref points at (peeled to the tag object for `^{}`
70    /// records).
71    pub oid: ObjectId,
72    /// The full ref name (e.g. `refs/heads/main`, `HEAD`, or `refs/tags/v1^{}`).
73    pub name: String,
74    /// The symref target, when the remote advertised this ref as a symbolic ref
75    /// (e.g. `HEAD` → `refs/heads/main`).
76    pub symref: Option<String>,
77}
78
79/// List the advertised refs for a resolved `source`.
80///
81/// Performs the work the CLI's `ls_remote_http_records` and inline local
82/// ls-remote path did: advertises the remote's refs (HTTP) or reads them directly
83/// (local), applies the `--heads`/`--tags`/`--refs` class filters and the
84/// caller-supplied `matches` ref-name predicate, and shapes the surviving refs
85/// into [`LsRemoteRecord`]s. For the local path it also emits peeled `^{}` records
86/// for annotated tags (unless `refs_only`).
87///
88/// `format` is the request/expected object format (SHA-1 for HTTP, the local
89/// repository's format for local); the returned [`ObjectFormat`] is the format
90/// actually in effect (HTTP resolves it from the advertisement). Returns the
91/// records and that format; never sorts, prints, or returns `GitError::Exit`. The
92/// caller applies `--sort`, `--symref` formatting, and the `--exit-code` mapping.
93pub fn ls_remote(
94    source: &LsRemoteSource,
95    format: ObjectFormat,
96    filter: &LsRemoteFilter,
97    matches: &dyn Fn(&str) -> bool,
98    #[cfg_attr(not(feature = "http"), allow(unused_variables))]
99    credentials: &mut dyn CredentialProvider,
100) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
101    match source {
102        #[cfg(feature = "http")]
103        LsRemoteSource::Http(remote) => {
104            ls_remote_http(remote, format, filter, matches, credentials)
105        }
106        #[cfg(not(feature = "http"))]
107        LsRemoteSource::Http(_) => Err(GitError::Unsupported(
108            "HTTP transport is not enabled in this build".into(),
109        )),
110        LsRemoteSource::Ssh(remote) => crate::ssh::ls_remote_ssh(remote, filter, matches),
111        LsRemoteSource::Git(remote) => crate::git::ls_remote_git(remote, filter, matches),
112        LsRemoteSource::Local { git_dir } => ls_remote_local(git_dir, format, filter, matches),
113    }
114}
115
116/// List advertised refs over smart HTTP(S): fetch the upload-pack advertisement,
117/// then apply the class filters and `matches` predicate, attaching the advertised
118/// `HEAD` symref where present.
119#[cfg(feature = "http")]
120fn ls_remote_http(
121    remote: &RemoteUrl,
122    format: ObjectFormat,
123    filter: &LsRemoteFilter,
124    matches: &dyn Fn(&str) -> bool,
125    credentials: &mut dyn CredentialProvider,
126) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
127    let client = crate::http::new_http_client();
128    let (refs, features) =
129        crate::http::http_upload_pack_advertisements(&client, remote, format, credentials)?;
130    let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
131    if format != ObjectFormat::Sha1 {
132        return Err(GitError::Unsupported(format!(
133            "http ls-remote currently supports SHA-1 advertisements, got {}",
134            format.name()
135        )));
136    }
137    let symrefs = features
138        .symrefs
139        .iter()
140        .filter_map(|symref| symref.split_once(':'))
141        .map(|(name, target)| (name.to_string(), target.to_string()))
142        .collect::<HashMap<_, _>>();
143    let mut records = Vec::new();
144    for advertisement in refs {
145        if advertisement.oid.is_null() {
146            continue;
147        }
148        if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
149        {
150            continue;
151        }
152        if !ref_class_selected(&advertisement.name, filter) {
153            continue;
154        }
155        if !matches(&advertisement.name) {
156            continue;
157        }
158        records.push(LsRemoteRecord {
159            oid: advertisement.oid,
160            symref: symrefs.get(&advertisement.name).cloned(),
161            name: advertisement.name,
162        });
163    }
164    Ok((records, format))
165}
166
167/// List advertised refs from a local repository at `git_dir`: `HEAD` (when no
168/// class filter is active), then every ref resolved to its object id, plus a
169/// peeled `^{}` record for each annotated tag (unless `refs_only`).
170fn ls_remote_local(
171    git_dir: &Path,
172    format: ObjectFormat,
173    filter: &LsRemoteFilter,
174    matches: &dyn Fn(&str) -> bool,
175) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
176    let store = FileRefStore::new(git_dir, format);
177    let db = FileObjectDatabase::from_git_dir(git_dir, format);
178    let mut records = Vec::new();
179
180    if !filter.refs_only
181        && !filter.heads
182        && !filter.tags
183        && let Some(target) = store.read_ref("HEAD")?
184    {
185        let reference = Ref {
186            name: "HEAD".to_string(),
187            target,
188        };
189        if matches(&reference.name)
190            && let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)?
191        {
192            records.push(LsRemoteRecord {
193                oid,
194                name: reference.name,
195                symref,
196            });
197        }
198    }
199
200    for reference in store.list_refs()? {
201        if !ref_class_selected(&reference.name, filter) {
202            continue;
203        }
204        if !matches(&reference.name) {
205            continue;
206        }
207        let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)? else {
208            continue;
209        };
210        records.push(LsRemoteRecord {
211            oid,
212            name: reference.name.clone(),
213            symref,
214        });
215        if !filter.refs_only
216            && let Some(record) = peeled_tag_record(&db, format, &oid, &reference.name, matches)?
217        {
218            records.push(record);
219        }
220    }
221
222    Ok((records, format))
223}
224
225/// The peeled `^{}` record for `name` when `oid` is an annotated tag and the
226/// peeled name passes `matches`; `None` otherwise.
227fn peeled_tag_record(
228    db: &FileObjectDatabase,
229    format: ObjectFormat,
230    oid: &ObjectId,
231    name: &str,
232    matches: &dyn Fn(&str) -> bool,
233) -> Result<Option<LsRemoteRecord>> {
234    let object = db.read_object(oid)?;
235    if object.object_type != ObjectType::Tag {
236        return Ok(None);
237    }
238    let peeled_name = format!("{name}^{{}}");
239    if !matches(&peeled_name) {
240        return Ok(None);
241    }
242    let peeled = sley_rev::peel_tags(db, format, oid)?;
243    Ok(Some(LsRemoteRecord {
244        oid: peeled,
245        name: peeled_name,
246        symref: None,
247    }))
248}
249
250/// Whether `name` survives the `--heads`/`--tags` class filter (no class filter
251/// keeps everything; with one or both set, the ref must be in a selected class).
252fn ref_class_selected(name: &str, filter: &LsRemoteFilter) -> bool {
253    if !filter.heads && !filter.tags {
254        return true;
255    }
256    let is_head = name.starts_with("refs/heads/");
257    let is_tag = name.starts_with("refs/tags/");
258    (filter.heads && is_head) || (filter.tags && is_tag)
259}
260
261/// Resolve a (possibly symbolic) ref target to its object id, following up to
262/// five levels of symbolic indirection, returning the first symbolic name seen.
263fn resolve_for_each_ref_target(
264    store: &FileRefStore,
265    reference: &Ref,
266) -> Result<Option<(ObjectId, Option<String>)>> {
267    let mut target = reference.target.clone();
268    let mut symref = None;
269    for _ in 0..5 {
270        match target {
271            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
272            RefTarget::Symbolic(name) => {
273                symref.get_or_insert_with(|| name.clone());
274                let Some(next) = store.read_ref(&name)? else {
275                    return Ok(None);
276                };
277                target = next;
278            }
279        }
280    }
281    Ok(None)
282}