userland/git: git-compatible CLI on v2's gix code (T10)
Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
//! Non-destructive local Git config edits. No plugin-owned persistent format.
|
||||
use crate::Result;
|
||||
use crate::{http::clean_url, repo::error};
|
||||
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(|u| u.to_string()).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"
|
||||
},
|
||||
));
|
||||
}
|
||||
// Replace all URL occurrences (including duplicate sections), preserving 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) {
|
||||
while section.remove("url").is_some() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut section = config
|
||||
.section_mut_or_create_new("remote", name)
|
||||
.map_err(|e| error("remote config", e))?;
|
||||
section
|
||||
.push("url", Some(url.as_str().into()))
|
||||
.map_err(|e| error("remote config", e))?;
|
||||
if add {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user