use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::atomic::AtomicBool;
use color_eyre::eyre::{Result, WrapErr as _, bail, eyre};
use girt::Repository;
use girt::config::{ConfigInputs, Origin};
use girt::fetch::{AdvertisementKind, FetchLimits, RemoteDiscovery};
use girt::remote::{Remote, RemoteConfig};
use girt::transport::TransportControl;
use crate::Context;
use crate::common::{display_bytes, stdout, write_line};
const SYSTEM_CONFIG: &[&str] = &[
"/opt/homebrew/etc/gitconfig",
"/usr/local/etc/gitconfig",
"/etc/gitconfig",
];
const MAX_CONFIG_BYTES: usize = 16 << 20;
#[derive(Debug, clap::Subcommand)]
pub enum Command {
Get {
key: String,
},
List {
#[arg(long)]
show_origin: bool,
},
Set {
key: String,
value: String,
},
Unset {
key: String,
},
}
#[derive(Debug, clap::Subcommand)]
pub enum RemoteCommand {
List {
#[arg(short, long)]
urls: bool,
},
Add {
name: String,
url: String,
},
Show {
name: String,
},
Heads {
remote: String,
},
}
pub fn run(command: Command, context: &Context) -> Result<ExitCode> {
match command {
Command::Get { key } => get(&key, context),
Command::List { show_origin } => list(show_origin, context),
Command::Set { key, value } => set(&key, &value, context),
Command::Unset { key } => unset(&key, context),
}
}
pub fn remote(command: RemoteCommand, context: &Context) -> Result<ExitCode> {
match command {
RemoteCommand::List { urls } => remote_list(urls, context),
RemoteCommand::Add { name, url } => remote_add(&name, &url, context),
RemoteCommand::Show { name } => remote_show(&name, context),
RemoteCommand::Heads { remote } => remote_heads(&remote, context),
}
}
pub fn configured_repository(context: &Context) -> Result<Repository> {
let discovered = context.repository()?;
let system = SYSTEM_CONFIG
.iter()
.map(PathBuf::from)
.find(|path| path.exists());
let inputs = ConfigInputs::from_environment(system, |name| std::env::var_os(name), 1024)
.wrap_err("reading config environment")?;
let path = discovered.worktree().unwrap_or(discovered.git_dir());
Repository::open_with_config(path, &inputs).wrap_err("opening repository with config")
}
fn split_key(key: &str) -> Result<(&str, Option<&str>, &str)> {
let (section, rest) = key
.split_once('.')
.ok_or_else(|| eyre!("key `{key}` does not contain a section"))?;
let (subsection, name) = match rest.rsplit_once('.') {
Some((subsection, name)) => (Some(subsection), name),
None => (None, rest),
};
if section.is_empty() || name.is_empty() {
bail!("invalid key `{key}`");
}
Ok((section, subsection, name))
}
fn get(key: &str, context: &Context) -> Result<ExitCode> {
let (section, subsection, name) = split_key(key)?;
let repository = configured_repository(context)?;
let value = repository
.config()
.value(section, subsection.map(str::as_bytes), name);
let Some(value) = value else {
return Ok(ExitCode::from(1));
};
let mut out = stdout();
write_line(&mut out, value.unwrap_or(b"true"))?;
out.flush()?;
Ok(ExitCode::SUCCESS)
}
fn list(show_origin: bool, context: &Context) -> Result<ExitCode> {
let repository = configured_repository(context)?;
let base = repository.worktree().unwrap_or(repository.git_dir());
let mut out = stdout();
for entry in repository.config().entries() {
if show_origin {
write_origin(&mut out, entry.origin.as_ref(), base)?;
out.write_all(b"\t")?;
}
out.write_all(&entry.section.to_ascii_lowercase())?;
if let Some(subsection) = &entry.subsection {
out.write_all(b".")?;
out.write_all(subsection)?;
}
out.write_all(b".")?;
out.write_all(&entry.name.to_ascii_lowercase())?;
if let Some(value) = &entry.value {
out.write_all(b"=")?;
out.write_all(value)?;
}
out.write_all(b"\n")?;
}
out.flush()?;
Ok(ExitCode::SUCCESS)
}
fn write_origin(out: &mut impl Write, origin: Option<&Origin>, base: &Path) -> Result<()> {
let Some(origin) = origin else {
return Ok(out.write_all(b"command line:")?);
};
match &origin.location.path {
Some(path) => {
let shown = path.strip_prefix(base).unwrap_or(path);
write!(out, "file:{}", shown.display())?;
}
None => out.write_all(b"command line:")?,
}
Ok(())
}
fn set(key: &str, value: &str, context: &Context) -> Result<ExitCode> {
let (section, subsection, name) = split_key(key)?;
let subsection = subsection.map(str::as_bytes);
let repository = context.repository()?;
let mut edit = repository
.edit_config(MAX_CONFIG_BYTES)
.wrap_err("opening local config")?;
let document = edit.document_mut();
let last = last_occurrence(document.config(), section, subsection, name);
match last {
Some(index) => document.set_value(index, value.as_bytes()),
None => document.append(section, subsection, name, value.as_bytes()),
}
.wrap_err_with(|| format!("setting {key}"))?;
edit.commit().wrap_err("writing local config")?;
Ok(ExitCode::SUCCESS)
}
fn unset(key: &str, context: &Context) -> Result<ExitCode> {
let (section, subsection, name) = split_key(key)?;
let subsection = subsection.map(str::as_bytes);
let repository = context.repository()?;
let mut edit = repository
.edit_config(MAX_CONFIG_BYTES)
.wrap_err("opening local config")?;
let document = edit.document_mut();
let mut removed = false;
while let Some(index) = last_occurrence(document.config(), section, subsection, name) {
document
.remove(index)
.wrap_err_with(|| format!("unsetting {key}"))?;
removed = true;
}
if !removed {
edit.abort().wrap_err("releasing local config")?;
return Ok(ExitCode::from(5));
}
edit.commit().wrap_err("writing local config")?;
Ok(ExitCode::SUCCESS)
}
fn last_occurrence(
config: &girt::Config,
section: &str,
subsection: Option<&[u8]>,
name: &str,
) -> Option<usize> {
config.entries().iter().rposition(|entry| {
entry.section.eq_ignore_ascii_case(section.as_bytes())
&& entry.subsection.as_deref() == subsection
&& entry.name.eq_ignore_ascii_case(name.as_bytes())
})
}
pub fn find_remote(repository: &Repository, name: &str) -> Result<Remote> {
Remote::find(repository.config(), name.as_bytes())
.wrap_err_with(|| format!("reading remote {name}"))?
.ok_or_else(|| eyre!("no such remote `{name}`"))
}
fn push_urls(remote: &Remote) -> &[Vec<u8>] {
match remote.configured_push_urls() {
[] => remote.urls(),
urls => urls,
}
}
fn remote_list(urls: bool, context: &Context) -> Result<ExitCode> {
let repository = configured_repository(context)?;
let mut out = stdout();
let mut names = Remote::names(repository.config());
names.sort_unstable();
for name in names {
if !urls {
write_line(&mut out, name)?;
continue;
}
let remote = Remote::find(repository.config(), name)?
.ok_or_else(|| eyre!("remote {} vanished", display_bytes(name)))?;
for url in remote.fetch_url().into_iter() {
write_url(&mut out, name, url, "fetch")?;
}
for url in push_urls(&remote) {
write_url(&mut out, name, url, "push")?;
}
}
out.flush()?;
Ok(ExitCode::SUCCESS)
}
fn write_url(out: &mut impl Write, name: &[u8], url: &[u8], kind: &str) -> Result<()> {
out.write_all(name)?;
out.write_all(b"\t")?;
out.write_all(url)?;
writeln!(out, " ({kind})")?;
Ok(())
}
fn remote_add(name: &str, url: &str, context: &Context) -> Result<ExitCode> {
let repository = context.repository()?;
let mut edit = repository
.edit_config(MAX_CONFIG_BYTES)
.wrap_err("opening local config")?;
let fetch = format!("+refs/heads/*:refs/remotes/{name}/*");
RemoteConfig::new(edit.document_mut())
.add(name.as_bytes(), url.as_bytes(), &[fetch.as_bytes()])
.wrap_err_with(|| format!("adding remote {name}"))?;
edit.commit().wrap_err("writing local config")?;
Ok(ExitCode::SUCCESS)
}
fn remote_show(name: &str, context: &Context) -> Result<ExitCode> {
if context.git {
bail!("--git is not supported for `remote show` (git contacts the remote)");
}
let repository = configured_repository(context)?;
let remote = find_remote(&repository, name)?;
let mut out = stdout();
writeln!(out, "name\t{name}")?;
for url in remote.urls() {
writeln!(out, "url\t{}", display_bytes(url))?;
}
for url in push_urls(&remote) {
writeln!(out, "pushurl\t{}", display_bytes(url))?;
}
for spec in remote.fetch_refspecs().specs() {
writeln!(out, "fetch\t{}", display_bytes(spec.as_bytes()))?;
}
for spec in remote.push_refspecs().specs() {
writeln!(out, "push\t{}", display_bytes(spec.as_bytes()))?;
}
out.flush()?;
Ok(ExitCode::SUCCESS)
}
fn remote_heads(target: &str, context: &Context) -> Result<ExitCode> {
let url = context
.repository()
.ok()
.and_then(|_| configured_repository(context).ok())
.and_then(|repository| {
Remote::find(repository.config(), target.as_bytes())
.ok()
.flatten()
.and_then(|remote| remote.fetch_url().map(<[u8]>::to_vec))
})
.map(|url| String::from_utf8_lossy(&url).into_owned())
.unwrap_or_else(|| target.to_owned());
let discovery = discover(&url, context.directory())?;
let mut out = stdout();
for reference in &discovery.advertisement.refs {
write!(out, "{}\t", reference.id)?;
out.write_all(reference.name.as_bytes())?;
if reference.kind == AdvertisementKind::PeeledHint {
out.write_all(b"^{}")?;
}
out.write_all(b"\n")?;
}
out.flush()?;
Ok(ExitCode::SUCCESS)
}
pub enum Endpoint {
Local(PathBuf),
Http(String),
}
pub fn endpoint(url: &str, base: &Path) -> Result<Endpoint> {
if url.starts_with("http://") || url.starts_with("https://") {
return Ok(Endpoint::Http(url.to_owned()));
}
if let Some(path) = url.strip_prefix("file://") {
return Ok(Endpoint::Local(PathBuf::from(path)));
}
let scp_like = url
.split_once(':')
.is_some_and(|(host, _)| !host.contains('/'));
if url.contains("://") || scp_like {
bail!("girt CLI does not support this transport yet (only local paths and HTTP(S))");
}
Ok(Endpoint::Local(base.join(url)))
}
pub fn http_remote(url: &str) -> Result<girt::transport::http::HttpRemote> {
girt::transport::http::HttpRemote::new(url, &[], &[]).wrap_err("configuring HTTP remote")
}
pub fn block_on<F: Future>(future: F) -> Result<F::Output> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.wrap_err("starting async runtime")?;
Ok(runtime.block_on(future))
}
fn discover(url: &str, base: &Path) -> Result<RemoteDiscovery> {
let cancel = AtomicBool::new(false);
let control = TransportControl::new(&cancel);
let limits = FetchLimits::trusted();
match endpoint(url, base)? {
Endpoint::Local(path) => girt::fetch::discover_local(&path, limits, control)
.wrap_err_with(|| format!("listing {}", path.display())),
Endpoint::Http(url) => {
let remote = http_remote(&url)?;
block_on(girt::fetch::discover_http(&remote, limits, control))?
.wrap_err_with(|| format!("listing {url}"))
}
}
}