gitow 1.0.0

Open Git remotes and repository pages in your browser from the CLI
Documentation
use std::{
    path::{Path, PathBuf},
    process::{Command, Output},
};

use crate::error::{GitowError, Result};

#[derive(Debug, Clone)]
pub struct Repository {
    cwd: PathBuf,
}

impl Repository {
    pub fn new(cwd: impl Into<PathBuf>) -> Self {
        Self { cwd: cwd.into() }
    }

    pub fn ensure_work_tree(&self) -> Result<()> {
        let output = self.run_git(["rev-parse", "--is-inside-work-tree"])?;
        if output.status.success() && trim_stdout(&output.stdout) == Some("true") {
            return Ok(());
        }

        Err(GitowError::NotAGitRepository)
    }

    pub fn current_branch(&self) -> Result<Option<String>> {
        self.optional_git(["symbolic-ref", "-q", "--short", "HEAD"])
    }

    pub fn branch_upstream(&self, branch: &str) -> Result<Option<String>> {
        let key = format!("branch.{branch}.merge");
        Ok(self
            .optional_git(["config", &key])?
            .map(|value| value.trim_start_matches("refs/heads/").to_string()))
    }

    pub fn branch_remote(&self, branch: &str) -> Result<Option<String>> {
        let key = format!("branch.{branch}.remote");
        self.optional_git(["config", &key])
    }

    pub fn default_open_remote(&self) -> Result<Option<String>> {
        self.optional_git(["config", "open.default.remote"])
    }

    pub fn resolve_remote_url(&self, remote: &str) -> Result<String> {
        let output = self.run_git(["remote", "get-url", remote])?;
        if !output.status.success() {
            if looks_like_remote_spec(remote) {
                return Ok(remote.to_string());
            }

            return Err(GitowError::MissingRemote(remote.to_string()));
        }

        let git_url = trim_stdout(&output.stdout).unwrap_or_default().to_string();
        if git_url.is_empty() {
            return Err(GitowError::MissingRemote(remote.to_string()));
        }

        Ok(git_url)
    }

    pub fn remotes(&self) -> Result<Vec<String>> {
        let output = self.run_git(["remote"])?;
        if !output.status.success() {
            return Err(self.command_failed("remote", output.stderr));
        }

        let remotes = String::from_utf8_lossy(&output.stdout)
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .map(ToOwned::to_owned)
            .collect();

        Ok(remotes)
    }

    pub fn open_urlmatch(&self, key: &str, open_url: &str) -> Result<Option<String>> {
        let config_key = format!("open.{key}");
        self.optional_git(["config", "--get-urlmatch", &config_key, open_url])
    }

    pub fn exact_tag(&self) -> Result<Option<String>> {
        let output = self.run_git(["describe", "--tags", "--exact-match"])?;
        match output.status.code() {
            Some(0) => Ok(trim_stdout(&output.stdout).map(ToOwned::to_owned)),
            Some(1 | 128) => Ok(None),
            _ => Err(self.command_failed("describe --tags --exact-match", output.stderr)),
        }
    }

    pub fn head_sha(&self) -> Result<String> {
        let output = self.run_git(["rev-parse", "HEAD"])?;
        if !output.status.success() {
            return Err(self.command_failed("rev-parse HEAD", output.stderr));
        }

        Ok(trim_stdout(&output.stdout).unwrap_or_default().to_string())
    }

    pub fn repository_file(&self, reference: &str, file: &str) -> Result<Option<String>> {
        if reference.is_empty() {
            return Ok(None);
        }

        self.optional_git([
            "ls-tree",
            "--full-name",
            "--name-only",
            reference,
            "--",
            file,
        ])
    }

    fn optional_git<const N: usize>(&self, args: [&str; N]) -> Result<Option<String>> {
        let output = self.run_git(args)?;
        match output.status.code() {
            Some(0) => Ok(trim_stdout(&output.stdout).map(ToOwned::to_owned)),
            Some(1) => Ok(None),
            _ => Err(self.command_failed(&args.join(" "), output.stderr)),
        }
    }

    fn run_git<const N: usize>(&self, args: [&str; N]) -> Result<Output> {
        Command::new("git")
            .args(args)
            .current_dir(&self.cwd)
            .output()
            .map_err(|source| GitowError::GitCommand {
                command: args.join(" "),
                source,
            })
    }

    fn command_failed(&self, command: &str, stderr: Vec<u8>) -> GitowError {
        let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
        GitowError::GitCommandFailed {
            command: command.to_string(),
            stderr: if stderr.is_empty() {
                "unknown git error".to_string()
            } else {
                stderr
            },
        }
    }
}

pub fn default_ssh_config_path() -> Option<PathBuf> {
    std::env::var_os("GITOW_SSH_CONFIG")
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var_os("HOME").map(|home| Path::new(&home).join(".ssh").join("config"))
        })
        .filter(|path| path.exists())
}

fn trim_stdout(stdout: &[u8]) -> Option<&str> {
    let trimmed = std::str::from_utf8(stdout).ok()?.trim();
    (!trimmed.is_empty()).then_some(trimmed)
}

fn looks_like_remote_spec(remote: &str) -> bool {
    remote.contains("://") || remote.contains('@') || remote.contains(':')
}