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::refs::store::{Expected, RefUpdate, RefUpdateFlags};
use crate::refs::{commit_ref_updates_on_store, list_refs_for_repository};
use crate::refspec::{fetch_specs_bind_local_ref, parse_fetch_refspec, RefspecItem};
use crate::remote::{
inspect_remote, HttpClientFactory, InspectOptions, ListRefsOptions, Remote, RemoteError,
RemoteRef, RemoteSummary, DEFAULT_REMOTE,
};
use crate::repo::Repository;
use crate::transfer::{FetchOptions, FetchOutcome, PushOptions, PushOutcome, PushRefSpec};
use crate::url_rewrite;
#[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 set_url(
&self,
name: &str,
url: &str,
kind: RemoteUrlKind,
dry_run: bool,
) -> std::result::Result<UrlChange, RemoteError> {
let config = self.repo.config().map_err(RemoteError::Library)?;
if !has_remote_section(config.as_ref(), name) {
return Err(RemoteError::UnknownRemote(name.to_owned()));
}
let old = match kind {
RemoteUrlKind::Fetch => config.get(&format!("remote.{name}.url")),
RemoteUrlKind::Push => config
.get(&format!("remote.{name}.pushurl"))
.filter(|u| !u.trim().is_empty()),
};
let change = UrlChange {
old,
new: url.to_owned(),
};
if dry_run {
return Ok(change);
}
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)?,
};
match kind {
RemoteUrlKind::Fetch => {
let key = format!("remote.{name}.url");
if file.count(&key)? > 1 {
file.unset(&key)?;
}
file.set(&key, url)?;
}
RemoteUrlKind::Push => {
file.unset(&format!("remote.{name}.pushurl"))?;
file.set(&format!("remote.{name}.pushurl"), url)?;
}
}
file.write()?;
self.repo.reload_config()?;
Ok(change)
}
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)
}
pub fn inspect(
&self,
name: &str,
options: &InspectOptions,
http_factory: Option<&dyn HttpClientFactory>,
) -> std::result::Result<RemoteSummary, RemoteError> {
inspect_remote(self.repo, name, options, http_factory)
}
pub fn remove(
&self,
name: &str,
options: RemoveOptions,
) -> std::result::Result<RemoteRemoval, RemoteError> {
let config = self.repo.config().map_err(RemoteError::Library)?;
if !has_remote_section(config.as_ref(), name) {
return Err(RemoteError::UnknownRemote(name.to_owned()));
}
let refs_to_delete = refs_to_delete_for_remote(self.repo, config.as_ref(), name)?;
let branches_untracked = branches_to_untrack(config.as_ref(), name);
let mut config_keys_removed = branch_untrack_config_keys(&branches_untracked);
for branch in branches_with_push_remote(config.as_ref(), name) {
if !branches_untracked.contains(&branch) {
config_keys_removed.push(format!("branch.{branch}.pushRemote"));
}
}
if config.get("remote.pushDefault").as_deref() == Some(name) {
config_keys_removed.push("remote.pushDefault".to_owned());
}
for entry in config.entries() {
if entry.key.starts_with(&format!("remote.{name}.")) {
config_keys_removed.push(entry.key.clone());
}
}
config_keys_removed.sort_unstable();
config_keys_removed.dedup();
if options.dry_run {
return Ok(RemoteRemoval {
name: name.to_owned(),
refs_deleted: refs_to_delete,
config_keys_removed,
branches_untracked,
dry_run: true,
});
}
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)?,
};
for branch in &branches_untracked {
for suffix in ["remote", "merge"] {
let key = format!("branch.{branch}.{suffix}");
file.unset(&key)?;
}
}
for branch in branches_with_push_remote(config.as_ref(), name) {
file.unset(&format!("branch.{branch}.pushRemote"))?;
}
if config.get("remote.pushDefault").as_deref() == Some(name) {
file.unset("remote.pushDefault")?;
}
let delete_flags = RefUpdateFlags {
no_deref: true,
..RefUpdateFlags::default()
};
let updates: Vec<RefUpdate> = refs_to_delete
.iter()
.map(|refname| RefUpdate {
name: refname.clone(),
new_value: None,
expected: Expected::Any,
reflog: None,
flags: delete_flags,
})
.collect();
if !updates.is_empty() {
commit_ref_updates_on_store(self.repo.refs(), &updates, &[])?;
}
if !file.remove_section(&format!("remote.{name}"))? {
return Err(RemoteError::Library(crate::error::Error::Message(format!(
"could not remove config section remote.{name}"
))));
}
file.write()?;
self.repo.reload_config()?;
Ok(RemoteRemoval {
name: name.to_owned(),
refs_deleted: refs_to_delete,
config_keys_removed,
branches_untracked,
dry_run: false,
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RemoveOptions {
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteRemoval {
pub name: String,
pub refs_deleted: Vec<String>,
pub config_keys_removed: Vec<String>,
pub branches_untracked: Vec<String>,
pub dry_run: bool,
}
#[must_use]
pub(crate) fn branches_tracking(config: &ConfigSet, remote: &str) -> Vec<String> {
let mut branches = BTreeSet::new();
for entry in config.entries() {
let Some(rest) = entry.key.strip_prefix("branch.") else {
continue;
};
let Some((branch, key)) = rest.rsplit_once('.') else {
continue;
};
if key == "remote" && entry.value.as_deref() == Some(remote) {
branches.insert(branch.to_owned());
}
}
branches.into_iter().collect()
}
fn branches_to_untrack(config: &ConfigSet, remote: &str) -> Vec<String> {
branches_tracking(config, remote)
}
fn branches_with_push_remote(config: &ConfigSet, remote: &str) -> Vec<String> {
let mut branches = BTreeSet::new();
for entry in config.entries() {
let Some(rest) = entry.key.strip_prefix("branch.") else {
continue;
};
let Some((branch, key)) = rest.rsplit_once('.') else {
continue;
};
if key.eq_ignore_ascii_case("pushremote") && entry.value.as_deref() == Some(remote) {
branches.insert(branch.to_owned());
}
}
branches.into_iter().collect()
}
fn branch_untrack_config_keys(branches: &[String]) -> Vec<String> {
let mut keys = Vec::with_capacity(branches.len() * 2);
for branch in branches {
keys.push(format!("branch.{branch}.remote"));
keys.push(format!("branch.{branch}.merge"));
}
keys
}
fn remote_has_fetch_keys(config: &ConfigSet, remote: &str) -> bool {
let prefix = format!("remote.{remote}.fetch");
config
.entries()
.iter()
.any(|entry| entry.key.starts_with(&prefix))
}
fn load_remote_fetch_specs(
config: &ConfigSet,
remote: &str,
) -> std::result::Result<Vec<RefspecItem>, RemoteError> {
let had_fetch_keys = remote_has_fetch_keys(config, remote);
let mut raw = config.get_all(&format!("remote.{remote}.fetch"));
raw.retain(|s| !s.trim().is_empty());
let mut specs = Vec::new();
for line in raw {
specs.push(
parse_fetch_refspec(&line)
.map_err(|e| RemoteError::Library(crate::error::Error::Message(e.to_string())))?,
);
}
if !had_fetch_keys && specs.is_empty() {
specs.push(
parse_fetch_refspec(&format!("+refs/heads/*:refs/remotes/{remote}/*"))
.map_err(|e| RemoteError::Library(crate::error::Error::Message(e.to_string())))?,
);
}
Ok(specs)
}
fn ref_tracked_by_fetch(specs: &[RefspecItem], refname: &str) -> bool {
fetch_specs_bind_local_ref(specs, refname).is_some()
}
fn refs_to_delete_for_remote(
repo: &Repository,
config: &ConfigSet,
remote: &str,
) -> std::result::Result<Vec<String>, RemoteError> {
let specs = load_remote_fetch_specs(config, remote)?;
let other_specs: Vec<Vec<RefspecItem>> = configured_remotes(config)
.into_iter()
.map(|r| r.name)
.filter(|n| n != remote)
.filter_map(|name| load_remote_fetch_specs(config, &name).ok())
.collect();
let all_refs = list_refs_for_repository(repo, "refs/").map_err(RemoteError::Library)?;
let mut refs_to_delete = BTreeSet::new();
for (refname, _) in all_refs {
if !refname.starts_with("refs/remotes/") {
continue;
}
if !ref_tracked_by_fetch(&specs, &refname) {
continue;
}
if other_specs
.iter()
.any(|other| ref_tracked_by_fetch(other, &refname))
{
continue;
}
refs_to_delete.insert(refname);
}
Ok(refs_to_delete.into_iter().collect())
}
#[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, Copy, PartialEq, Eq)]
pub enum RemoteUrlKind {
Fetch,
Push,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UrlChange {
pub old: Option<String>,
pub new: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredRemote {
pub name: String,
pub url: Option<String>,
pub push_urls: Vec<String>,
pub fetch_refspecs: Vec<String>,
pub effective_fetch_url: Option<String>,
pub effective_push_url: Option<String>,
pub effective_push_urls: Vec<String>,
}
fn configured_remote_from_config(config: &ConfigSet, name: String) -> ConfiguredRemote {
let url = config.get(&format!("remote.{name}.url"));
let mut fetch_refspecs = config.get_all(&format!("remote.{name}.fetch"));
fetch_refspecs.retain(|s| !s.trim().is_empty());
let push_urls: Vec<String> = config
.get_all(&format!("remote.{name}.pushurl"))
.into_iter()
.filter(|s| !s.trim().is_empty())
.collect();
let effective_push_urls: Vec<String> = push_urls
.iter()
.map(|raw| url_rewrite::rewrite_push_url(config, raw.as_str()))
.collect();
let (effective_fetch_url, effective_push_url) = match url.as_deref().filter(|u| !u.is_empty()) {
Some(raw) => {
let effective_fetch_url = url_rewrite::rewrite_fetch_url(config, raw);
let push_source = config
.get(&format!("remote.{name}.pushurl"))
.filter(|u| !u.trim().is_empty())
.unwrap_or_else(|| raw.to_owned());
let effective_push_url = url_rewrite::rewrite_push_url(config, push_source.as_str());
(Some(effective_fetch_url), Some(effective_push_url))
}
None => {
let effective_push_url = push_urls
.first()
.map(|raw| url_rewrite::rewrite_push_url(config, raw.as_str()));
(None, effective_push_url)
}
};
ConfiguredRemote {
name,
url,
push_urls,
fetch_refspecs,
effective_fetch_url,
effective_push_url,
effective_push_urls,
}
}
#[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| configured_remote_from_config(config, name.to_owned()))
.collect()
}
pub 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,
push_urls: vec![],
fetch_refspecs: vec!["+refs/heads/*:refs/remotes/team.prod/*".to_owned()],
effective_fetch_url: None,
effective_push_url: None,
effective_push_urls: vec![],
},
ConfiguredRemote {
name: "zeta".to_owned(),
url: Some("/tmp/z2.git".to_owned()),
push_urls: vec![],
fetch_refspecs: vec![],
effective_fetch_url: Some("/tmp/z2.git".to_owned()),
effective_push_url: Some("/tmp/z2.git".to_owned()),
effective_push_urls: vec![],
},
]
);
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()));
}
}