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 fn ls_remote(
95 source: &LsRemoteSource,
96 format: ObjectFormat,
97 filter: &LsRemoteFilter,
98 matches: &dyn Fn(&str) -> bool,
99 config: Option<&GitConfig>,
100 #[cfg_attr(not(feature = "http"), allow(unused_variables))]
101 credentials: &mut dyn CredentialProvider,
102) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
103 crate::protocol::check_transport_allowed(scheme_for_ls_remote_source(source), config, None)
104 .map_err(crate::protocol::transport_policy_git_error)?;
105 match source {
106 #[cfg(feature = "http")]
107 LsRemoteSource::Http(remote) => {
108 ls_remote_http(remote, format, filter, matches, credentials, config)
109 }
110 #[cfg(not(feature = "http"))]
111 LsRemoteSource::Http(_) => Err(GitError::Unsupported(
112 "HTTP transport is not enabled in this build".into(),
113 )),
114 LsRemoteSource::Ssh(remote) => crate::ssh::ls_remote_ssh(remote, filter, matches),
115 LsRemoteSource::Git(remote) => crate::git::ls_remote_git(
116 remote,
117 filter,
118 matches,
119 config.and_then(|config| config.get("protocol", None, "version")) == Some("2"),
120 config,
121 ),
122 LsRemoteSource::Local { git_dir } => {
123 ls_remote_local(git_dir, format, filter, matches, config)
124 }
125 }
126}
127
128fn scheme_for_ls_remote_source(source: &LsRemoteSource) -> &'static str {
129 match source {
130 LsRemoteSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
131 LsRemoteSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
132 LsRemoteSource::Git(remote) => crate::protocol::transport_scheme_for_remote(remote),
133 LsRemoteSource::Local { .. } => "file",
134 }
135}
136
137#[cfg(feature = "http")]
141fn ls_remote_http(
142 remote: &RemoteUrl,
143 format: ObjectFormat,
144 filter: &LsRemoteFilter,
145 matches: &dyn Fn(&str) -> bool,
146 credentials: &mut dyn CredentialProvider,
147 config: Option<&GitConfig>,
148) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
149 let http_batch = crate::http::HttpOperationBatch::new();
150 let (refs, features) = crate::http::http_upload_pack_advertisements(
151 http_batch.client(),
152 remote,
153 format,
154 credentials,
155 config,
156 )?;
157 let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
158 if format != ObjectFormat::Sha1 {
159 return Err(GitError::Unsupported(format!(
160 "http ls-remote currently supports SHA-1 advertisements, got {}",
161 format.name()
162 )));
163 }
164 let symrefs = features
165 .symrefs
166 .iter()
167 .filter_map(|symref| symref.split_once(':'))
168 .map(|(name, target)| (name.to_string(), target.to_string()))
169 .collect::<HashMap<_, _>>();
170 let mut records = Vec::new();
171 for advertisement in refs {
172 if advertisement.oid.is_null() {
173 continue;
174 }
175 if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
176 {
177 continue;
178 }
179 if !ref_class_selected(&advertisement.name, filter) {
180 continue;
181 }
182 if !matches(&advertisement.name) {
183 continue;
184 }
185 records.push(LsRemoteRecord {
186 oid: advertisement.oid,
187 symref: symrefs.get(&advertisement.name).cloned(),
188 name: advertisement.name,
189 });
190 }
191 Ok((records, format))
192}
193
194fn ls_remote_local(
198 git_dir: &Path,
199 format: ObjectFormat,
200 filter: &LsRemoteFilter,
201 matches: &dyn Fn(&str) -> bool,
202 config: Option<&GitConfig>,
203) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
204 let store = FileRefStore::new(git_dir, format);
205 let db = FileObjectDatabase::from_git_dir(git_dir, format);
206 let config = ls_remote_local_config(git_dir, config);
207 let hidden_refs = upload_pack_hidden_ref_values(&config);
208 let include_non_head_symrefs =
209 !matches!(config.get("protocol", None, "version"), Some("0" | "1"));
210 let mut records = Vec::new();
211
212 if !filter.refs_only
213 && !filter.heads
214 && !filter.tags
215 && let Some(target) = store.read_ref("HEAD")?
216 {
217 let reference = Ref {
218 name: "HEAD".to_string(),
219 target,
220 };
221 if matches(&reference.name)
222 && let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)?
223 {
224 records.push(LsRemoteRecord {
225 oid,
226 name: reference.name,
227 symref,
228 });
229 }
230 }
231
232 for reference in store.list_refs()? {
233 if ref_is_hidden_by_patterns(&reference.name, &hidden_refs) {
234 continue;
235 }
236 if !ref_class_selected(&reference.name, filter) {
237 continue;
238 }
239 if !matches(&reference.name) {
240 continue;
241 }
242 let Some((oid, symref)) = resolve_for_each_ref_target(&store, &reference)? else {
243 continue;
244 };
245 records.push(LsRemoteRecord {
246 oid,
247 name: reference.name.clone(),
248 symref: if include_non_head_symrefs {
249 symref
250 } else {
251 None
252 },
253 });
254 if !filter.refs_only
255 && let Some(record) = peeled_tag_record(&db, format, &oid, &reference.name, matches)?
256 {
257 records.push(record);
258 }
259 }
260
261 Ok((records, format))
262}
263
264fn ls_remote_local_config(git_dir: &Path, config: Option<&GitConfig>) -> GitConfig {
265 let mut local = sley_config::read_repo_config(git_dir, None).unwrap_or_default();
266 if let Some(config) = config {
267 local.sections.extend(config.sections.clone());
268 }
269 local
270}
271
272fn upload_pack_hidden_ref_values(config: &GitConfig) -> Vec<String> {
273 let mut out = Vec::new();
274 for section in &config.sections {
275 let applies = section.subsection.is_none()
276 && (section.name.eq_ignore_ascii_case("transfer")
277 || section.name.eq_ignore_ascii_case("uploadpack"));
278 if !applies {
279 continue;
280 }
281 for entry in §ion.entries {
282 if entry.key.eq_ignore_ascii_case("hiderefs")
283 && let Some(value) = entry.value.as_deref()
284 {
285 out.push(trim_hidden_ref_pattern(value));
286 }
287 }
288 }
289 out
290}
291
292fn trim_hidden_ref_pattern(value: &str) -> String {
293 value.trim_end_matches('/').to_string()
294}
295
296fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
297 for pattern in patterns.iter().rev() {
298 let mut pattern = pattern.as_str();
299 let negated = pattern.strip_prefix('!').is_some();
300 if negated {
301 pattern = &pattern[1..];
302 }
303 if let Some(rest) = pattern.strip_prefix('^') {
304 pattern = rest;
305 }
306 if hidden_ref_pattern_matches(refname, pattern) {
307 return !negated;
308 }
309 }
310 false
311}
312
313fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
314 refname
315 .strip_prefix(pattern)
316 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
317}
318
319fn peeled_tag_record(
322 db: &FileObjectDatabase,
323 format: ObjectFormat,
324 oid: &ObjectId,
325 name: &str,
326 matches: &dyn Fn(&str) -> bool,
327) -> Result<Option<LsRemoteRecord>> {
328 let object = db.read_object(oid)?;
329 if object.object_type != ObjectType::Tag {
330 return Ok(None);
331 }
332 let peeled_name = format!("{name}^{{}}");
333 if !matches(&peeled_name) {
334 return Ok(None);
335 }
336 let peeled = sley_rev::peel_tags(db, format, oid)?;
337 Ok(Some(LsRemoteRecord {
338 oid: peeled,
339 name: peeled_name,
340 symref: None,
341 }))
342}
343
344fn ref_class_selected(name: &str, filter: &LsRemoteFilter) -> bool {
347 if !filter.heads && !filter.tags {
348 return true;
349 }
350 let is_head = name.starts_with("refs/heads/");
351 let is_tag = name.starts_with("refs/tags/");
352 (filter.heads && is_head) || (filter.tags && is_tag)
353}
354
355fn resolve_for_each_ref_target(
358 store: &FileRefStore,
359 reference: &Ref,
360) -> Result<Option<(ObjectId, Option<String>)>> {
361 let mut target = reference.target.clone();
362 let mut symref = None;
363 for _ in 0..5 {
364 match target {
365 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
366 RefTarget::Symbolic(name) => {
367 symref.get_or_insert_with(|| name.clone());
368 let Some(next) = store.read_ref(&name)? else {
369 return Ok(None);
370 };
371 target = next;
372 }
373 }
374 }
375 Ok(None)
376}