1#[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
32pub enum LsRemoteSource {
37 Http(RemoteUrl),
39 Ssh(RemoteUrl),
42 Git(RemoteUrl),
44 Local {
48 git_dir: PathBuf,
50 },
51}
52
53#[derive(Debug, Clone, Copy, Default)]
56pub struct LsRemoteFilter {
57 pub heads: bool,
59 pub tags: bool,
61 pub refs_only: bool,
63}
64
65#[derive(Debug, Clone)]
69pub struct LsRemoteRecord {
70 pub oid: ObjectId,
73 pub name: String,
75 pub symref: Option<String>,
78}
79
80pub 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#[derive(Debug, Clone)]
90pub struct LsRemoteOutcome {
91 pub records: Vec<LsRemoteRecord>,
92 pub format: ObjectFormat,
93}
94
95pub 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
131pub 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#[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
235fn 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 §ion.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
360fn 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
385fn 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
396fn 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}