6.8 KiB · text History df92b0a
//! Command implementations and the context they share.

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");
    }
}