//! Command implementations and the context they share.
pub mod audit;
pub mod auth;
pub mod ci;
pub mod issue;
pub mod label;
pub mod package;
pub mod pull;
pub mod repo;
pub mod wiki;
use anyhow::{Context as _, Result, anyhow, bail};
use serde::Serialize;
use std::io::{IsTerminal, Read};
use std::process::Command as Proc;
use crate::api::{Client, RepoRef};
use crate::config::Config;
/// Everything a command needs that isn't its own arguments.
pub struct Ctx {
pub api: Client,
pub cfg: Config,
/// Base URL in use, for building links and matching git remotes.
pub base_url: String,
/// `--repo`, or `GITGUD_REPO`.
pub repo_flag: Option<String>,
pub json: bool,
}
impl Ctx {
/// Which repository this invocation is about.
///
/// `--repo` first, then the git remote of the directory you're
/// standing in, then the saved default. The middle one is the point:
/// inside a checkout, `gg ci list` should just work.
pub fn repo(&self) -> Result<RepoRef> {
if let Some(raw) = &self.repo_flag {
return RepoRef::parse(raw);
}
if let Some(found) = repo_from_git_remote(&self.base_url) {
return Ok(found);
}
if let Some(raw) = &self.cfg.default_repo {
return RepoRef::parse(raw);
}
bail!(
"no repository — pass --repo owner/name, run this inside a checkout of one, \
or set a default with `gg login --default-repo owner/name`"
)
}
/// Print raw JSON if `--json` was passed. A `true` return means the
/// command has already said everything it needs to.
pub fn emitted<T: Serialize>(&self, raw: &T) -> Result<bool> {
if self.json {
crate::output::print_json(raw)?;
return Ok(true);
}
Ok(false)
}
/// A browser link for a repository path, since the API's own URLs
/// aren't the ones a person wants to click.
pub fn web_url(&self, path: &str) -> String {
format!(
"{}/{}",
self.base_url.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
/// The web UI mounts repositories under `/r/`, not at the root.
pub fn repo_web_url(&self, repo: &RepoRef, suffix: &str) -> String {
self.web_url(&format!("r/{repo}{suffix}"))
}
}
/// The repository the current directory's git remote points at, when it
/// points at *this* forge.
///
/// Host has to match: a checkout of something on GitHub shouldn't quietly
/// aim a command at a same-named repository here.
fn repo_from_git_remote(base_url: &str) -> Option<RepoRef> {
let forge_host = url::Url::parse(base_url).ok()?.host_str()?.to_lowercase();
let names = git(&["remote"])?;
let mut candidates: Vec<&str> = names
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
// Preferred order, then whatever else the checkout has.
candidates.sort_by_key(|name| match *name {
"origin" => 0,
"upstream" => 1,
_ => 2,
});
for name in candidates {
let Some(url) = git(&["remote", "get-url", name]) else {
continue;
};
if let Some((host, repo)) = parse_remote(url.trim())
&& host.eq_ignore_ascii_case(&forge_host)
{
return Some(repo);
}
}
None
}
/// The branch checked out in the current directory, or `None` outside a
/// checkout or on a detached HEAD.
pub fn current_branch() -> Option<String> {
git(&["symbolic-ref", "--quiet", "--short", "HEAD"]).filter(|b| !b.is_empty())
}
fn git(args: &[&str]) -> Option<String> {
let out = Proc::new("git").args(args).output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// Pull `(host, owner/name)` out of any remote spelling git accepts.
fn parse_remote(raw: &str) -> Option<(String, RepoRef)> {
// scp-style: git@host:owner/name.git — not a URL, so it's first.
if !raw.contains("://")
&& let Some((prefix, path)) = raw.split_once(':')
{
let host = prefix.rsplit('@').next()?.to_string();
return Some((host, RepoRef::parse(path.trim_matches('/')).ok()?));
}
let url = url::Url::parse(raw).ok()?;
let host = url.host_str()?.to_string();
let path = url.path().trim_matches('/');
// A forge may live under a path prefix, so take the last two segments.
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let (owner, name) = match segments.as_slice() {
[.., owner, name] => (*owner, *name),
_ => return None,
};
Some((host, RepoRef::parse(&format!("{owner}/{name}")).ok()?))
}
/// A comment body from `--body`, `--body-file`, or piped stdin.
pub fn read_body(
body: Option<&str>,
body_file: Option<&str>,
required: bool,
) -> Result<Option<String>> {
if let Some(text) = body {
return Ok(Some(text.to_string()));
}
if let Some(path) = body_file {
let text = if path == "-" {
read_stdin()?
} else {
std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
};
return Ok(Some(text));
}
// Piped input counts as the body; an interactive terminal doesn't,
// because blocking on a tty here looks like a hang.
if !std::io::stdin().is_terminal() {
let text = read_stdin()?;
if !text.trim().is_empty() {
return Ok(Some(text));
}
}
if required {
Err(anyhow!(
"no body — pass --body TEXT, --body-file PATH, or pipe it in"
))
} else {
Ok(None)
}
}
fn read_stdin() -> Result<String> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("reading stdin")?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_scp_style_remotes() {
let (host, repo) = parse_remote("git@forge.example.com:acme/widgets.git").unwrap();
assert_eq!(host, "forge.example.com");
assert_eq!(repo.slug(), "acme/widgets");
}
#[test]
fn parses_https_remotes() {
let (host, repo) = parse_remote("https://forge.example.com/acme/widgets.git").unwrap();
assert_eq!(host, "forge.example.com");
assert_eq!(repo.slug(), "acme/widgets");
}
#[test]
fn parses_remotes_under_a_path_prefix() {
let (_, repo) = parse_remote("https://example.com/git/acme/widgets").unwrap();
assert_eq!(repo.slug(), "acme/widgets");
}
#[test]
fn parses_ssh_url_remotes() {
let (host, repo) =
parse_remote("ssh://git@forge.example.com:2222/acme/widgets.git").unwrap();
assert_eq!(host, "forge.example.com");
assert_eq!(repo.slug(), "acme/widgets");
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
6.8 KiB · text
History
d859987