190 lines
6.6 KiB
Rust
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)
|
|
}
|