#[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;
pub enum LsRemoteSource {
Http(RemoteUrl),
Ssh(RemoteUrl),
Git(RemoteUrl),
Local {
git_dir: PathBuf,
},
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LsRemoteFilter {
pub heads: bool,
pub tags: bool,
pub refs_only: bool,
}
#[derive(Debug, Clone)]
pub struct LsRemoteRecord {
pub oid: ObjectId,
pub name: String,
pub symref: Option<String>,
}
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)> {
crate::protocol::check_transport_allowed(scheme_for_ls_remote_source(source), config, None)
.map_err(crate::protocol::transport_policy_git_error)?;
match source {
#[cfg(feature = "http")]
LsRemoteSource::Http(remote) => {
ls_remote_http(remote, format, filter, matches, credentials)
}
#[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"),
),
LsRemoteSource::Local { git_dir } => {
ls_remote_local(git_dir, format, filter, matches, config)
}
}
}
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",
}
}
#[cfg(feature = "http")]
fn ls_remote_http(
remote: &RemoteUrl,
format: ObjectFormat,
filter: &LsRemoteFilter,
matches: &dyn Fn(&str) -> bool,
credentials: &mut dyn CredentialProvider,
) -> Result<(Vec<LsRemoteRecord>, ObjectFormat)> {
let client = crate::http::new_http_client();
let (refs, features) =
crate::http::http_upload_pack_advertisements(&client, remote, format, credentials)?;
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))
}
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 §ion.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('/'))
}
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,
}))
}
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)
}
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)
}