Files
sausage/userland/git/src/remotes.rs
T

190 lines
6.6 KiB
Rust

//! Non-destructive local Git config edits. No plugin-owned persistent format.
use crate::Result;
use crate::{repo::error, ssh::clean_url};
use gix::bstr::ByteSlice;
/// Remote identity.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Remote name.
pub name: String,
}
/// Add or change a remote URL.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteUrlParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Remote name.
pub name: String,
/// HTTPS without credentials, ssh:// URL, or [user@]host:path.
pub url: String,
}
/// Remote settings stored in local .git/config.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteInfo {
/// Remote name.
pub name: String,
/// Credential-free URL, if configured.
pub url: Option<String>,
/// Fetch refspecs in config order.
pub fetch: Vec<String>,
}
pub fn validate_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.starts_with('-')
|| name.contains('/')
|| name.contains("..")
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
{
return Err(error(
"remote",
"expected a remote name using letters, digits, ._-",
));
}
let _: gix::refs::FullName = format!("refs/remotes/{name}/branch")
.as_str()
.try_into()
.map_err(|e| error("remote name", e))?;
Ok(())
}
pub fn load(repo: &gix::Repository) -> Result<gix::config::File> {
let text = std::fs::read_to_string(repo.common_dir().join("config"))
.map_err(|e| error("remote config", e))?;
gix::config::File::try_from(text.as_str())
.map_err(|_| error("remote config", "could not parse .git/config"))
}
pub fn save(repo: &gix::Repository, config: &gix::config::File) -> Result<()> {
let mut bytes = Vec::new();
config
.write_to(&mut bytes)
.map_err(|e| error("remote config", e))?;
std::fs::write(repo.common_dir().join("config"), bytes).map_err(|e| error("config write", e))
}
pub fn list(repo: &gix::Repository) -> Result<Vec<RemoteInfo>> {
let config = load(repo)?;
let mut names = std::collections::BTreeSet::new();
for section in config.sections_by_name("remote").into_iter().flatten() {
if let Some(name) = section.header().subsection_name() {
names.insert(name.to_str_lossy().into_owned());
}
}
names
.into_iter()
.map(|name| {
let url = config
.string_by("remote", name.as_str(), "url")
.map(|s| s.to_str_lossy().into_owned());
// Do not return legacy embedded credentials from repositories imported outside this plugin.
let url = url
.map(|s| {
clean_url(&s).map_err(|_| {
error(
"remote list",
"remote URL must be HTTPS without credentials or SSH without password",
)
})
})
.transpose()?;
let fetch = config
.strings_by("remote", name.as_str(), "fetch")
.unwrap_or_default()
.into_iter()
.map(|s| s.to_str_lossy().into_owned())
.collect();
Ok(RemoteInfo { name, url, fetch })
})
.collect()
}
pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result<RemoteInfo> {
validate_name(name)?;
let url = clean_url(url)
.map_err(|e| error("remote URL", e))?
.to_string();
let mut config = load(repo)?;
let exists = config.section("remote", name).is_ok();
if add == exists {
return Err(error(
"remote",
if add {
"remote already exists"
} else {
"remote does not exist"
},
));
}
// Change URL values in place, preserving config order and other keys/comments.
if !add {
let ids: Vec<_> = config
.sections_and_ids_by_name("remote")
.into_iter()
.flatten()
.filter(|(s, _)| {
s.header()
.subsection_name()
.is_some_and(|s| s == name.as_bytes())
})
.map(|(_, id)| id)
.collect();
for id in ids {
if let Some(mut section) = config.section_mut_by_id(id) {
section
.set("url", url.as_str())
.map_err(|e| error("remote config", e))?;
}
}
}
let mut section = config
.section_mut_or_create_new("remote", name)
.map_err(|e| error("remote config", e))?;
if add {
section
.push("url", Some(url.as_str().into()))
.map_err(|e| error("remote config", e))?;
section
.push(
"fetch",
Some(
format!("+refs/heads/*:refs/remotes/{name}/*")
.as_str()
.into(),
),
)
.map_err(|e| error("remote config", e))?;
}
save(repo, &config)?;
list(repo)?
.into_iter()
.find(|r| r.name == name)
.ok_or_else(|| error("remote", "remote missing after config edit"))
}
pub fn remove(repo: &gix::Repository, name: &str) -> Result<()> {
validate_name(name)?;
let mut config = load(repo)?;
if config.remove_section("remote", name).is_none() {
return Err(error("remote", "remote does not exist"));
}
while config.remove_section("remote", name).is_some() {}
save(repo, &config)
}
/// Configure the newly cloned branch's upstream with the same non-destructive editor.
pub fn upstream(repo: &gix::Repository, branch: &str) -> Result<()> {
let mut config = load(repo)?;
let mut section = config
.section_mut_or_create_new("branch", branch)
.map_err(|e| error("branch config", e))?;
section
.push("remote", Some("origin".into()))
.map_err(|e| error("branch config", e))?;
section
.push(
"merge",
Some(format!("refs/heads/{branch}").as_str().into()),
)
.map_err(|e| error("branch config", e))?;
save(repo, &config)
}