rlls 0.0.30

Cut a version, tag it, and publish a GitHub Release with raw git notes
use crate::util::{run_capture, run_quiet, run_status};
use anyhow::{anyhow, Result};
use fs_err as fs;
use serde_json::Value;
use std::path::{Path, PathBuf};

pub fn current_repo_name() -> Result<String> {
    let remote = run_capture(
        "git",
        &["config", "--get", "remote.origin.url"],
    )?;
    let repo = remote
        .trim()
        .trim_start_matches("git@github.com:")
        .trim_start_matches("https://github.com/")
        .trim_end_matches(".git")
        .to_string();
    if repo.is_empty() {
        return Err(anyhow!("no origin remote configured"));
    }
    Ok(repo)
}

pub fn add_all() -> Result<()> {
    run_quiet("git", &["add", "-A"], "git add")?;
    Ok(())
}

pub fn has_staged_changes() -> Result<bool> {
    let no_changes =
        run_status("git", &["diff", "--cached", "--quiet"]);
    Ok(!no_changes)
}

pub fn working_tree_dirty() -> bool {
    !run_status("git", &["diff", "--quiet"])
        || !run_status("git", &["diff", "--cached", "--quiet"])
}

pub fn commit_with_message(msg: &str) -> Result<()> {
    run_quiet("git", &["commit", "-m", msg], "git commit")?;
    Ok(())
}

pub fn create_annotated_tag(
    tag: &str,
    message: Option<&str>,
) -> Result<()> {
    // sanitize tag name for git compatibility
    let safe_tag = tag.replace('/', "_");
    let m = message.unwrap_or(tag);
    run_quiet("git", &["tag", "-a", &safe_tag, "-m", m], "git tag")?;
    Ok(())
}

pub fn push_commits(dry: bool) -> Result<()> {
    if !dry {
        run_quiet("git", &["push"], "git push")?;
    }
    Ok(())
}

pub fn upstream_remote() -> Option<String> {
    let up = run_capture(
        "git",
        &[
            "rev-parse",
            "--abbrev-ref",
            "--symbolic-full-name",
            "@{u}",
        ],
    )
    .ok()?;
    Some(up.split('/').next().unwrap_or("origin").to_string())
}

pub fn push_tag(tag: &str, dry: bool) -> Result<()> {
    if !dry {
        let remote =
            upstream_remote().unwrap_or_else(|| "origin".to_string());
        run_quiet("git", &["push", &remote, tag], "git push tag")?;
    }
    Ok(())
}

pub fn list_tags(pattern: &str) -> Result<Vec<String>> {
    let s = run_capture(
        "git",
        &["tag", "--list", pattern, "--sort=creatordate"],
    )?;
    Ok(s.lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect())
}

pub fn last_reachable_tag() -> Option<String> {
    run_capture("git", &["describe", "--tags", "--abbrev=0"]).ok()
}

pub fn tag_exists(tag: &str) -> Result<bool> {
    Ok(run_status(
        "git",
        &[
            "rev-parse",
            "-q",
            "--verify",
            &format!("refs/tags/{}", tag),
        ],
    ))
}

pub fn gh_pr_title(owner_repo: &str, number: &str) -> Option<String> {
    let path = format!("repos/{}/pulls/{}", owner_repo, number);
    let out = crate::util::run_capture("gh", &["api", &path]).ok()?;
    let v: Value = serde_json::from_str(&out).ok()?;
    v.get("title")
        .and_then(|t| t.as_str())
        .map(|s| s.to_string())
}

pub fn publish_github_release(
    tag: &str,
    owner_repo: &str,
    notes: &str,
) -> Result<()> {
    let tmp: PathBuf = std::env::temp_dir()
        .join(format!("rlls-{}-notes.md", tag.replace('/', "_")));
    fs::write(&tmp, notes)?;
    run_quiet(
        "gh",
        &[
            "release",
            "create",
            tag,
            "-F",
            tmp.to_string_lossy().as_ref(),
            "--repo",
            owner_repo,
        ],
        "gh release create",
    )?;
    let _ = fs::remove_file(&tmp);
    Ok(())
}

pub fn delete_github_release(
    tag: &str,
    owner_repo: &str,
) -> Result<()> {
    run_quiet(
        "gh",
        &["release", "delete", tag, "--yes", "--repo", owner_repo],
        "gh release delete",
    )?;
    Ok(())
}

pub fn delete_local_tag(tag: &str) -> Result<()> {
    run_quiet("git", &["tag", "-d", tag], "git tag -d")?;
    Ok(())
}

pub fn delete_remote_tag(tag: &str) -> Result<()> {
    let remote =
        upstream_remote().unwrap_or_else(|| "origin".to_string());
    run_quiet(
        "git",
        &["push", &remote, "--delete", tag],
        "git push --delete",
    )?;
    Ok(())
}

pub fn tag_pointing_at_head() -> Option<String> {
    let s =
        run_capture("git", &["tag", "--points-at", "HEAD"]).ok()?;
    let mut tags: Vec<String> = s
        .lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect();
    if tags.is_empty() {
        return None;
    }
    tags.sort();
    tags.pop()
}

pub fn last_commit_subject() -> Option<String> {
    run_capture("git", &["log", "-1", "--pretty=%s"]).ok()
}

pub fn current_branch() -> Option<String> {
    run_capture("git", &["rev-parse", "--abbrev-ref", "HEAD"]).ok()
}

pub fn reset_hard(target: &str) -> Result<()> {
    run_quiet(
        "git",
        &["reset", "--hard", target],
        "git reset --hard",
    )?;
    Ok(())
}

pub fn push_force_with_lease() -> Result<()> {
    run_quiet(
        "git",
        &["push", "--force-with-lease"],
        "git push --force-with-lease",
    )?;
    Ok(())
}

pub fn run_pre_hooks(_: &crate::config::Config) -> Result<()> {
    Ok(())
}
pub fn run_post_hooks(_: &crate::config::Config) -> Result<()> {
    Ok(())
}

pub fn diff_names(range: &str) -> Result<Vec<String>> {
    let out = run_capture("git", &["diff", "--name-only", range])
        .unwrap_or_default();
    Ok(out
        .lines()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect())
}

pub fn release_url(owner_repo: &str, tag: &str) -> String {
    format!("https://github.com/{}/releases/tag/{}", owner_repo, tag)
}

pub fn has_commits_since(
    tag_opt: Option<&str>,
    path: &Path,
) -> Result<bool> {
    let range = tag_opt
        .map(|t| format!("{}..HEAD", t))
        .unwrap_or_else(|| "HEAD".to_string());
    let out = run_capture(
        "git",
        &[
            "log",
            "--oneline",
            &range,
            "--",
            path.to_string_lossy().as_ref(),
        ],
    )
    .unwrap_or_default();
    Ok(!out.trim().is_empty())
}

pub fn scoped_commits_since(
    tag_opt: Option<&str>,
    path: &Path,
) -> Result<Vec<String>> {
    let range = tag_opt
        .map(|t| format!("{}..HEAD", t))
        .unwrap_or_else(|| "HEAD".to_string());
    let out = run_capture(
        "git",
        &[
            "log",
            "--no-merges",
            "--pretty=- %h %s",
            &range,
            "--",
            path.to_string_lossy().as_ref(),
        ],
    )
    .unwrap_or_default();
    Ok(out.lines().map(|s| s.to_string()).collect())
}

pub fn first_commit() -> Option<String> {
    let out =
        run_capture("git", &["rev-list", "--max-parents=0", "HEAD"])
            .ok()?;
    out.lines().next().map(|s| s.trim().to_string())
}

pub fn commit_count(range: &str) -> usize {
    run_capture("git", &["rev-list", "--count", range])
        .ok()
        .and_then(|s| s.trim().parse::<usize>().ok())
        .unwrap_or(0)
}