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<()> {
let m = message.unwrap_or(tag);
run_quiet("git", &["tag", "-a", tag, "-m", m], "git tag")?;
Ok(())
}
pub fn create_annotated_tag_at(
tag: &str,
message: Option<&str>,
target: &str,
) -> Result<()> {
let m = message.unwrap_or(tag);
run_quiet(
"git",
&["tag", "-a", tag, "-m", m, target],
"git tag at",
)?;
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, &format!("refs/tags/{}", tag)],
"git push tag",
)?;
}
Ok(())
}
pub fn list_tags(pattern: &str) -> Result<Vec<String>> {
let s = run_capture(
"git",
&["tag", "--list", pattern, "--sort=-v:refname"],
)?;
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())
}
fn inferred_release_title(tag: &str) -> String {
if let Some((pkg, ver_with_v)) = tag.rsplit_once('@') {
let ver = ver_with_v.trim_start_matches('v');
format!("{}@{}", pkg, ver)
} else {
tag.to_string()
}
}
pub fn publish_github_release(
tag: &str,
owner_repo: &str,
notes: &str,
) -> Result<()> {
let tmp: PathBuf = std::env::temp_dir()
.join(format!("rlyx-{}-notes.md", safe_tmp_name(tag)));
fs::write(&tmp, notes)?;
let title = inferred_release_title(tag);
run_quiet(
"gh",
&[
"release",
"create",
tag,
"-F",
tmp.to_string_lossy().as_ref(),
"--repo",
owner_repo,
"--title",
&title,
],
"gh release create",
)?;
let _ = fs::remove_file(&tmp);
Ok(())
}
fn safe_tmp_name(tag: &str) -> String {
tag.chars()
.map(|c| {
if c.is_ascii_alphanumeric()
|| c == '.'
|| c == '_'
|| c == '-'
{
c
} else {
'_'
}
})
.collect()
}
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 tags_pointing_at_head() -> Vec<String> {
run_capture("git", &["tag", "--points-at", "HEAD"])
.ok()
.map(|s| {
s.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
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 {
fn enc(tag: &str) -> String {
let mut out = String::new();
for b in tag.bytes() {
let c = b as char;
if c.is_ascii_alphanumeric()
|| c == '-'
|| c == '_'
|| c == '.'
|| c == '~'
{
out.push(c);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
format!(
"https://github.com/{}/releases/tag/{}",
owner_repo,
enc(tag)
)
}
pub fn rev_parse(rev: &str) -> Option<String> {
run_capture("git", &["rev-parse", rev])
.ok()
.map(|s| s.trim().to_string())
}
pub fn head_commit() -> Option<String> {
rev_parse("HEAD")
}
pub fn is_ancestor(ancestor: &str, descendant: &str) -> bool {
run_status(
"git",
&["merge-base", "--is-ancestor", ancestor, descendant],
)
}
pub fn tag_commit(tag: &str) -> Option<String> {
run_capture("git", &["rev-list", "-n", "1", tag])
.ok()
.or_else(|| {
let alt = tag.replace('/', "_");
if alt != tag {
run_capture("git", &["rev-list", "-n", "1", &alt])
.ok()
} else {
None
}
})
.map(|s| s.trim().to_string())
}
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)
}