sley-remote 0.5.0

Callable fetch, push, clone, and ls-remote orchestration over the sley transport and object stack.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! Callable ls-remote advertisement listing for HTTP(S) and local remotes.
//!
//! [`ls_remote`] returns the advertised refs a `git ls-remote` would print for a
//! resolved remote, as a [`LsRemoteRecord`] list, without sorting, printing, or
//! exit-code mapping — those stay in the CLI (the `--sort`/`--symref` formatting
//! and the `--exit-code` ⇒ exit-2 behavior are CLI concerns). Everything is taken
//! as explicit parameters — the resolved [`LsRemoteSource`], the request
//! [`ObjectFormat`], a [`LsRemoteFilter`], a ref-name match predicate, and a
//! [`CredentialProvider`] — so it never reads process-global state, parses
//! arguments, or prints.
//!
//! The ref-name glob/pattern matching (`refs/heads/*` style filters, peeled-tag
//! `^{}` matching) is the CLI's larger ref-filter machinery, so it is injected as
//! the `matches` predicate rather than moved; this module only applies the
//! ref-class filters (`--heads`/`--tags`/`--refs`) and shapes the records.
//!
//! SSH ls-remote still lives in the CLI; only HTTP and local move here.

#[cfg(feature = "http")]
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use sley_config::GitConfig;
use sley_core::{GitError, ObjectFormat, ObjectId, Result};
use sley_object::ObjectType;
use sley_odb::{FileObjectDatabase, ObjectReader};
use sley_refs::{FileRefStore, Ref, RefTarget};
use sley_transport::RemoteUrl;

use crate::CredentialProvider;

/// How [`ls_remote`] obtains the ref advertisements.
///
/// The caller resolves the remote (URL rewriting, repository discovery — all
/// process-state dependent) and hands `ls_remote` a concrete transport.
pub enum LsRemoteSource {
    /// A smart-HTTP(S) remote at the given already-resolved URL.
    Http(RemoteUrl),
    /// An SSH remote at the given already-resolved URL, listed by spawning `ssh`
    /// (the credential seam is unused — the `ssh` program owns authentication).
    Ssh(RemoteUrl),
    /// A native anonymous `git://` remote at the given already-resolved URL.
    Git(RemoteUrl),
    /// A local repository read directly from `git_dir` (refs and the object
    /// database used to peel annotated tags both resolve from this `$GIT_DIR`,
    /// matching `git ls-remote` against a local path).
    Local {
        /// The remote repository's `$GIT_DIR`.
        git_dir: PathBuf,
    },
}

/// The ref-class filters that select which advertised refs to keep, mirroring the
/// `git ls-remote` flags the CLI parses.
#[derive(Debug, Clone, Copy, Default)]
pub struct LsRemoteFilter {
    /// Limit to branch refs (`--heads`/`--branches`).
    pub heads: bool,
    /// Limit to tag refs (`--tags`).
    pub tags: bool,
    /// Drop `HEAD` and peeled `^{}` entries (`--refs`).
    pub refs_only: bool,
}

/// One advertised ref returned by [`ls_remote`] — what the CLI prints as a
/// `<oid>\t<name>` line (with an optional preceding `ref: <symref>\t<name>` line
/// when `--symref` is set and `symref` is present).
#[derive(Debug, Clone)]
pub struct LsRemoteRecord {
    /// The object id the ref points at (peeled to the tag object for `^{}`
    /// records).
    pub oid: ObjectId,
    /// The full ref name (e.g. `refs/heads/main`, `HEAD`, or `refs/tags/v1^{}`).
    pub name: String,
    /// The symref target, when the remote advertised this ref as a symbolic ref
    /// (e.g. `HEAD` → `refs/heads/main`).
    pub symref: Option<String>,
}

/// Fully resolved inputs for an advertisement listing.
pub struct LsRemoteRequest<'a> {
    pub source: &'a LsRemoteSource,
    pub format: ObjectFormat,
    pub filter: &'a LsRemoteFilter,
    pub config: Option<&'a GitConfig>,
}

/// Structured advertisement result for embedders.
#[derive(Debug, Clone)]
pub struct LsRemoteOutcome {
    pub records: Vec<LsRemoteRecord>,
    pub format: ObjectFormat,
}

/// List the advertised refs for a resolved `source`.
///
/// Performs the work the CLI's `ls_remote_http_records` and inline local
/// ls-remote path did: advertises the remote's refs (HTTP) or reads them directly
/// (local), applies the `--heads`/`--tags`/`--refs` class filters and the
/// caller-supplied `matches` ref-name predicate, and shapes the surviving refs
/// into [`LsRemoteRecord`]s. For the local path it also emits peeled `^{}` records
/// for annotated tags (unless `refs_only`).
///
/// `format` is the request/expected object format (SHA-1 for HTTP, the local
/// repository's format for local); the returned [`ObjectFormat`] is the format
/// actually in effect (HTTP resolves it from the advertisement). Returns the
/// records and that format; never sorts, prints, or returns `GitError::Exit`. The
/// caller applies `--sort`, `--symref` formatting, and the `--exit-code` mapping.
pub fn ls_remote(
    source: &LsRemoteSource,
    format: ObjectFormat,
    filter: &LsRemoteFilter,
    matches: &dyn Fn(&str) -> bool,
    config: Option<&GitConfig>,
    #[cfg_attr(not(feature = "http"), allow(unused_variables))]
    credentials: &mut dyn CredentialProvider,
) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
    let outcome = ls_remote_with(
        LsRemoteRequest {
            source,
            format,
            filter,
            config,
        },
        matches,
        credentials,
    )?;
    Ok((outcome.records, outcome.format))
}

/// List advertisements using typed request/outcome values.
pub fn ls_remote_with(
    request: LsRemoteRequest<'_>,
    matches: &dyn Fn(&str) -> bool,
    credentials: &mut dyn CredentialProvider,
) -> Result<LsRemoteOutcome> {
    let LsRemoteRequest {
        source,
        format,
        filter,
        config,
    } = request;
    crate::protocol::check_transport_allowed(scheme_for_ls_remote_source(source), config, None)
        .map_err(crate::protocol::transport_policy_git_error)?;
    let (records, format) = match source {
        #[cfg(feature = "http")]
        LsRemoteSource::Http(remote) => {
            ls_remote_http(remote, format, filter, matches, credentials, config)
        }
        #[cfg(not(feature = "http"))]
        LsRemoteSource::Http(_) => Err(GitError::Unsupported(
            "HTTP transport is not enabled in this build".into(),
        )),
        LsRemoteSource::Ssh(remote) => crate::ssh::ls_remote_ssh(remote, filter, matches),
        LsRemoteSource::Git(remote) => crate::git::ls_remote_git(
            remote,
            filter,
            matches,
            config.and_then(|config| config.get("protocol", None, "version")) == Some("2"),
            config,
        ),
        LsRemoteSource::Local { git_dir } => {
            ls_remote_local(git_dir, format, filter, matches, config)
        }
    }?;
    Ok(LsRemoteOutcome { records, format })
}

fn scheme_for_ls_remote_source(source: &LsRemoteSource) -> &'static str {
    match source {
        LsRemoteSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
        LsRemoteSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
        LsRemoteSource::Git(remote) => crate::protocol::transport_scheme_for_remote(remote),
        LsRemoteSource::Local { .. } => "file",
    }
}

/// List advertised refs over smart HTTP(S): fetch the upload-pack advertisement,
/// then apply the class filters and `matches` predicate, attaching the advertised
/// `HEAD` symref where present.
#[cfg(feature = "http")]
fn ls_remote_http(
    remote: &RemoteUrl,
    format: ObjectFormat,
    filter: &LsRemoteFilter,
    matches: &dyn Fn(&str) -> bool,
    credentials: &mut dyn CredentialProvider,
    config: Option<&GitConfig>,
) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
    let http_batch = crate::http::HttpOperationBatch::new();
    let (refs, features) = crate::http::http_upload_pack_advertisements(
        http_batch.client(),
        remote,
        format,
        credentials,
        config,
    )?;
    let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
    if format != ObjectFormat::Sha1 {
        return Err(GitError::Unsupported(format!(
            "http ls-remote currently supports SHA-1 advertisements, got {}",
            format.name()
        )));
    }
    let symrefs = features
        .symrefs
        .iter()
        .filter_map(|symref| symref.split_once(':'))
        .map(|(name, target)| (name.to_string(), target.to_string()))
        .collect::<HashMap<_, _>>();
    let mut records = Vec::new();
    for advertisement in refs {
        if advertisement.oid.is_null() {
            continue;
        }
        if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
        {
            continue;
        }
        if !ref_class_selected(&advertisement.name, filter) {
            continue;
        }
        if !matches(&advertisement.name) {
            continue;
        }
        records.push(LsRemoteRecord {
            oid: advertisement.oid,
            symref: symrefs.get(&advertisement.name).cloned(),
            name: advertisement.name,
        });
    }
    Ok((records, format))
}

/// List advertised refs from a local repository at `git_dir`: `HEAD` (when no
/// class filter is active), then every ref resolved to its object id, plus a
/// peeled `^{}` record for each annotated tag (unless `refs_only`).
fn ls_remote_local(
    git_dir: &Path,
    format: ObjectFormat,
    filter: &LsRemoteFilter,
    matches: &dyn Fn(&str) -> bool,
    config: Option<&GitConfig>,
) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
    let store = FileRefStore::new(git_dir, format);
    let db = FileObjectDatabase::from_git_dir(git_dir, format);
    let config = ls_remote_local_config(git_dir, config);
    let hidden_refs = upload_pack_hidden_ref_values(&config);
    let include_non_head_symrefs =
        !matches!(config.get("protocol", None, "version"), Some("0" | "1"));
    let mut records = Vec::new();

    if !filter.refs_only
        && !filter.heads
        && !filter.tags
        && let Some(target) = store.read_ref("HEAD")?
    {
        let reference = Ref {
            name: "HEAD".to_string(),
            target,
        };
        if matches(&reference.name)
            && let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)?
        {
            records.push(LsRemoteRecord {
                oid,
                name: reference.name,
                symref,
            });
        }
    }

    for reference in store.list_refs()? {
        if ref_is_hidden_by_patterns(&reference.name, &hidden_refs) {
            continue;
        }
        if !ref_class_selected(&reference.name, filter) {
            continue;
        }
        if !matches(&reference.name) {
            continue;
        }
        let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)? else {
            continue;
        };
        records.push(LsRemoteRecord {
            oid,
            name: reference.name.clone(),
            symref: if include_non_head_symrefs {
                symref
            } else {
                None
            },
        });
        if !filter.refs_only
            && let Some(record) = peeled_tag_record(&db, format, &oid, &reference.name, matches)?
        {
            records.push(record);
        }
    }

    Ok((records, format))
}

fn ls_remote_local_config(git_dir: &Path, config: Option<&GitConfig>) -> GitConfig {
    let mut local = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
    if let Some(config) = config {
        local.sections.extend(config.sections.clone());
    }
    local
}

fn upload_pack_hidden_ref_values(config: &GitConfig) -> Vec<String> {
    let mut out = Vec::new();
    for section in &config.sections {
        let applies = section.subsection.is_none()
            && (section.name.eq_ignore_ascii_case("transfer")
                || section.name.eq_ignore_ascii_case("uploadpack"));
        if !applies {
            continue;
        }
        for entry in &section.entries {
            if entry.key.eq_ignore_ascii_case("hiderefs")
                && let Some(value) = entry.value.as_deref()
            {
                out.push(trim_hidden_ref_pattern(value));
            }
        }
    }
    out
}

fn trim_hidden_ref_pattern(value: &str) -> String {
    value.trim_end_matches('/').to_string()
}

fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
    for pattern in patterns.iter().rev() {
        let mut pattern = pattern.as_str();
        let negated = pattern.strip_prefix('!').is_some();
        if negated {
            pattern = &pattern[1..];
        }
        if let Some(rest) = pattern.strip_prefix('^') {
            pattern = rest;
        }
        if hidden_ref_pattern_matches(refname, pattern) {
            return !negated;
        }
    }
    false
}

fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
    refname
        .strip_prefix(pattern)
        .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}

/// The peeled `^{}` record for `name` when `oid` is an annotated tag and the
/// peeled name passes `matches`; `None` otherwise.
fn peeled_tag_record(
    db: &FileObjectDatabase,
    format: ObjectFormat,
    oid: &ObjectId,
    name: &str,
    matches: &dyn Fn(&str) -> bool,
) -> Result<Option<LsRemoteRecord>> {
    let object = db.read_object(oid)?;
    if object.object_type != ObjectType::Tag {
        return Ok(None);
    }
    let peeled_name = format!("{name}^{{}}");
    if !matches(&peeled_name) {
        return Ok(None);
    }
    let peeled = sley_rev::peel_tags(db, format, oid)?;
    Ok(Some(LsRemoteRecord {
        oid: peeled,
        name: peeled_name,
        symref: None,
    }))
}

/// Whether `name` survives the `--heads`/`--tags` class filter (no class filter
/// keeps everything; with one or both set, the ref must be in a selected class).
fn ref_class_selected(name: &str, filter: &LsRemoteFilter) -> bool {
    if !filter.heads && !filter.tags {
        return true;
    }
    let is_head = name.starts_with("refs/heads/");
    let is_tag = name.starts_with("refs/tags/");
    (filter.heads && is_head) || (filter.tags && is_tag)
}

/// Resolve a (possibly symbolic) ref target to its object id, following up to
/// five levels of symbolic indirection, returning the first symbolic name seen.
fn resolve_for_each_ref_target(
    store: &FileRefStore,
    reference: &Ref,
) -> Result<Option<(ObjectId, Option<String>)>> {
    let mut target = reference.target.clone();
    let mut symref = None;
    for _ in 0..5 {
        match target {
            RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
            RefTarget::Symbolic(name) => {
                symref.get_or_insert_with(|| name.clone());
                let Some(next) = store.read_ref(&name)? else {
                    return Ok(None);
                };
                target = next;
            }
        }
    }
    Ok(None)
}