//! 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, /// Fetch refspecs in config order. pub fetch: Vec, } 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 { 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> { 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 { 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) }