use std::collections::{BTreeSet, HashSet};
use crate::check_ref_format::{check_refname_format, RefNameOptions};
use crate::config::{ConfigFile, ConfigScope, ConfigSet};
use crate::error::Result;
use crate::fetch::Progress;
use crate::remote::{
HttpClientFactory, ListRefsOptions, Remote, RemoteError, RemoteRef, DEFAULT_REMOTE,
};
use crate::repo::Repository;
use crate::transfer::{FetchOptions, FetchOutcome, PushOptions, PushOutcome, PushRefSpec};
#[derive(Debug, Copy, Clone)]
pub struct RepositoryRemotes<'repo> {
repo: &'repo Repository,
}
impl<'repo> RepositoryRemotes<'repo> {
pub(crate) fn new(repo: &'repo Repository) -> Self {
Self { repo }
}
#[must_use]
pub fn repository(&self) -> &'repo Repository {
self.repo
}
pub fn list(&self) -> Result<Vec<String>> {
let config = self.repo.config()?;
Ok(configured_remote_names(config.as_ref()))
}
pub fn configured(&self) -> Result<Vec<ConfiguredRemote>> {
let config = self.repo.config()?;
Ok(configured_remotes(config.as_ref()))
}
pub fn add(&self, name: &str, url: &str) -> std::result::Result<(), RemoteError> {
if !is_valid_remote_name(name) {
return Err(RemoteError::InvalidRemoteName(name.to_owned()));
}
if has_remote_section(self.repo.config()?.as_ref(), name) {
return Err(RemoteError::RemoteExists(name.to_owned()));
}
let path = crate::repo::common_git_dir_for_config(self.repo.git_dir()).join("config");
let mut file = match ConfigFile::from_path(&path, ConfigScope::Local)? {
Some(file) => file,
None => ConfigFile::parse(&path, "", ConfigScope::Local)?,
};
file.set(&format!("remote.{name}.url"), url)?;
file.set(
&format!("remote.{name}.fetch"),
&format!("+refs/heads/*:refs/remotes/{name}/*"),
)?;
file.write()?;
self.repo.reload_config()?;
Ok(())
}
pub fn get(&self, name: &str) -> std::result::Result<Remote, RemoteError> {
let config = self.repo.config().map_err(RemoteError::Library)?;
Remote::from_config(config.as_ref(), name)
}
pub fn get_or_default(&self, name: &str) -> std::result::Result<Remote, RemoteError> {
let trimmed = name.trim();
if trimmed.is_empty() {
self.get(DEFAULT_REMOTE)
} else {
self.get(trimmed)
}
}
pub fn ls_remote(
&self,
remote: &Remote,
options: &ListRefsOptions,
http_factory: Option<&dyn HttpClientFactory>,
) -> std::result::Result<Vec<RemoteRef>, RemoteError> {
remote.list_refs(Some(self.repo), options, http_factory)
}
pub fn fetch(
&self,
remote: &Remote,
options: FetchOptions,
progress: &mut dyn Progress,
http_factory: Option<&dyn HttpClientFactory>,
) -> std::result::Result<FetchOutcome, RemoteError> {
remote.fetch(self.repo, options, progress, http_factory)
}
pub fn fetch_all(
&self,
config: &ConfigSet,
base: &FetchOptions,
cli_prune: Option<bool>,
cli_tags: Option<bool>,
progress: &mut dyn Progress,
http_factory: Option<&dyn HttpClientFactory>,
) -> Result<Vec<(String, std::result::Result<FetchOutcome, RemoteError>)>> {
use crate::remote::transfer::{resolve_fetch_prune, resolve_fetch_tags};
let mut results = Vec::new();
for name in configured_remote_names_in_config_order(config) {
if remote_skip_fetch_all(config, &name) {
continue;
}
let prune = resolve_fetch_prune(config, &name, cli_prune)?;
let tags = resolve_fetch_tags(config, &name, cli_tags);
let opts = FetchOptions {
tags,
prune,
dry_run: base.dry_run,
fetch_depth: base.fetch_depth,
deepen_since: base.deepen_since.clone(),
deepen_not: base.deepen_not.clone(),
index_pack_threads: base.index_pack_threads,
thread_policy: self.repo.thread_policy(),
environment: base.environment.clone(),
pack_spool_only: base.pack_spool_only,
http_protocol_version: base.http_protocol_version,
remote_progress: base.remote_progress,
..Default::default()
};
let outcome = self
.get(&name)
.and_then(|remote| self.fetch(&remote, opts, progress, http_factory));
results.push((name, outcome));
}
Ok(results)
}
pub fn push(
&self,
remote: &Remote,
refs: &[PushRefSpec],
options: PushOptions,
progress: &mut dyn Progress,
http_factory: Option<&dyn HttpClientFactory>,
) -> std::result::Result<PushOutcome, RemoteError> {
remote.push(self.repo, refs, options, progress, http_factory)
}
}
#[must_use]
pub fn remote_skip_fetch_all(config: &ConfigSet, remote: &str) -> bool {
match config.get_bool(&format!("remote.{remote}.skipFetchAll")) {
None | Some(Ok(false)) => false,
Some(Ok(true)) => true,
Some(Err(_)) => false,
}
}
#[must_use]
pub fn configured_remote_names_in_config_order(config: &ConfigSet) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = Vec::new();
for entry in config.entries() {
let Some(rest) = entry.key.strip_prefix("remote.") else {
continue;
};
let Some((name, key)) = rest.rsplit_once('.') else {
continue;
};
if key == "url" && !name.is_empty() && seen.insert(name.to_owned()) {
names.push(name.to_owned());
}
}
names
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredRemote {
pub name: String,
pub url: Option<String>,
}
#[must_use]
pub fn configured_remotes(config: &ConfigSet) -> Vec<ConfiguredRemote> {
let mut names = BTreeSet::new();
for entry in config.entries() {
let Some(rest) = entry.key.strip_prefix("remote.") else {
continue;
};
if let Some((name, _key)) = rest.rsplit_once('.') {
names.insert(name);
}
}
names
.into_iter()
.map(|name| ConfiguredRemote {
name: name.to_owned(),
url: config.get(&format!("remote.{name}.url")),
})
.collect()
}
pub(crate) fn has_remote_section(config: &ConfigSet, name: &str) -> bool {
config.entries().iter().any(|entry| {
entry
.key
.strip_prefix("remote.")
.and_then(|rest| rest.rsplit_once('.'))
.is_some_and(|(section, _key)| section == name)
})
}
fn is_valid_remote_name(name: &str) -> bool {
!name.is_empty()
&& check_refname_format(
&format!("refs/remotes/{name}/test"),
&RefNameOptions::default(),
)
.is_ok()
}
#[must_use]
pub fn configured_remote_names(config: &ConfigSet) -> Vec<String> {
let mut names = BTreeSet::new();
for entry in config.entries() {
let Some(rest) = entry.key.strip_prefix("remote.") else {
continue;
};
let Some((name, key)) = rest.rsplit_once('.') else {
continue;
};
if key == "url" && !name.is_empty() {
names.insert(name.to_owned());
}
}
names.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::{
configured_remote_names, configured_remote_names_in_config_order, configured_remotes,
has_remote_section, is_valid_remote_name, remote_skip_fetch_all, ConfiguredRemote,
};
use crate::config::{ConfigFile, ConfigScope, ConfigSet};
use crate::remote::RemoteError;
use crate::repo::{InitOptions, Repository};
fn config_set(text: &str) -> ConfigSet {
let file = ConfigFile::parse(std::path::Path::new("cfg"), text, ConfigScope::Local)
.expect("parse");
let mut set = ConfigSet::new();
set.merge(&file);
set
}
#[test]
fn configured_remotes_include_sections_without_url() {
let set = config_set(
"[remote \"zeta\"]\n\turl = /tmp/z.git\n[remote \"team.prod\"]\n\tfetch = +refs/heads/*:refs/remotes/team.prod/*\n[remote \"zeta\"]\n\turl = /tmp/z2.git\n",
);
assert_eq!(
configured_remotes(&set),
vec![
ConfiguredRemote {
name: "team.prod".to_owned(),
url: None,
},
ConfiguredRemote {
name: "zeta".to_owned(),
url: Some("/tmp/z2.git".to_owned()),
},
]
);
assert_eq!(configured_remote_names(&set), vec!["zeta".to_owned()]);
assert!(has_remote_section(&set, "team.prod"));
assert!(has_remote_section(&set, "zeta"));
assert!(!has_remote_section(&set, "team"));
}
#[test]
fn remote_name_validation_follows_tracking_ref_rules() {
for ok in ["origin", "team.prod", "a/b", "up-stream"] {
assert!(is_valid_remote_name(ok), "{ok}");
}
for bad in [
"",
".",
"..",
"a..b",
"has space",
"x:y",
"star*",
"end.lock",
] {
assert!(!is_valid_remote_name(bad), "{bad}");
}
}
#[test]
fn add_writes_url_and_default_fetch_refspec() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
repo.remotes()
.add("upstream", "https://example.com/r.git")
.expect("add");
let config = repo.config().expect("config");
assert_eq!(
config.get("remote.upstream.url").as_deref(),
Some("https://example.com/r.git")
);
assert_eq!(
config.get_all("remote.upstream.fetch"),
vec!["+refs/heads/*:refs/remotes/upstream/*".to_owned()]
);
assert_eq!(repo.remotes().list().expect("list"), vec!["upstream"]);
}
#[test]
fn add_rejects_existing_and_invalid_names() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
repo.remotes().add("origin", "/tmp/a.git").expect("add");
assert!(matches!(
repo.remotes().add("origin", "/tmp/b.git"),
Err(RemoteError::RemoteExists(name)) if name == "origin"
));
assert!(matches!(
repo.remotes().add("bad name", "/tmp/b.git"),
Err(RemoteError::InvalidRemoteName(name)) if name == "bad name"
));
assert_eq!(
repo.config()
.expect("config")
.get("remote.origin.url")
.as_deref(),
Some("/tmp/a.git")
);
}
#[test]
fn configured_remote_names_in_config_order_preserves_file_order() {
let file = ConfigFile::parse(
std::path::Path::new("cfg"),
r#"[remote "second"]
url = /tmp/second.git
[remote "first"]
url = /tmp/first.git
"#,
ConfigScope::Local,
)
.expect("parse");
let mut set = ConfigSet::new();
set.merge(&file);
assert_eq!(
configured_remote_names_in_config_order(&set),
vec!["second".to_owned(), "first".to_owned()]
);
}
#[test]
fn remote_skip_fetch_all_reads_config() {
let file = ConfigFile::parse(
std::path::Path::new("cfg"),
r#"[remote "skipme"]
url = /tmp/x.git
skipFetchAll = true
"#,
ConfigScope::Local,
)
.expect("parse");
let mut set = ConfigSet::new();
set.merge(&file);
assert!(remote_skip_fetch_all(&set, "skipme"));
assert!(!remote_skip_fetch_all(&set, "other"));
}
#[test]
fn configured_remote_names_parses_dotted_subsections() {
let file = ConfigFile::parse(
std::path::Path::new("cfg"),
r#"[remote "team.prod"]
url = /tmp/upstream.git
[remote "origin"]
url = /tmp/origin.git
"#,
ConfigScope::Local,
)
.expect("parse");
let mut set = ConfigSet::new();
set.merge(&file);
let names = configured_remote_names(&set);
assert!(names.contains(&"team.prod".to_owned()));
assert!(names.contains(&"origin".to_owned()));
}
}