use std::path::{Path, PathBuf};
use regex::Regex;
use xx::git::Git;
use xx::process;
use crate::error::{Error, Result};
pub fn repo_root() -> Result<PathBuf> {
let path = process::cmd("git", ["rev-parse", "--show-toplevel"]).read()?;
Ok(PathBuf::from(path))
}
pub fn detect_remote(repo_root: &Path) -> Result<String> {
let git = Git::new(repo_root.to_path_buf());
let url = git
.get_remote_url()
.ok_or_else(|| Error::Git("no origin remote found".into()))?;
parse_owner_repo(&url)
}
fn parse_owner_repo(url: &str) -> Result<String> {
if let Some(rest) = url.strip_prefix("git@github.com:") {
let repo = rest.trim_end_matches(".git");
return Ok(repo.to_string());
}
if let Some(rest) = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
{
let repo = rest.trim_end_matches(".git");
return Ok(repo.to_string());
}
Err(Error::Git(format!(
"cannot parse GitHub repo from remote URL: {url}"
)))
}
#[cfg(test)]
pub fn previous_tag(repo_root: &Path, current_tag: &str) -> Result<String> {
previous_tag_filtered(repo_root, current_tag, None, crate::workflow::Channel::All)
}
pub fn previous_tag_filtered(
repo_root: &Path,
current_tag: &str,
pattern: Option<&str>,
channel: crate::workflow::Channel,
) -> Result<String> {
let target = resolve_ref(repo_root, current_tag)?;
let stdout = process::cmd(
"git",
[
"tag",
"--merged",
&target,
"--sort=-v:refname",
"--list",
pattern.unwrap_or("*"),
],
)
.cwd(repo_root)
.read()?;
let target_is_tag = stdout.lines().any(|tag| tag.trim() == current_tag);
let stable = matches!(channel, crate::workflow::Channel::Stable);
let prerelease = Regex::new(r"[0-9]+\.[0-9]+(?:\.[0-9]+)?-").unwrap();
for tag in stdout.lines().map(str::trim).filter(|tag| !tag.is_empty()) {
if tag == current_tag || (stable && prerelease.is_match(tag)) {
continue;
}
if target_is_tag && verify_ref(repo_root, tag)? == target {
continue;
}
return Ok(tag.into());
}
root_commit(repo_root, &target)
}
pub fn verify_ref(repo_root: &Path, git_ref: &str) -> Result<String> {
let commit = format!("{git_ref}^{{commit}}");
process::cmd(
"git",
["rev-parse", "--verify", "--end-of-options", &commit],
)
.cwd(repo_root)
.stderr_capture()
.read()
.map(|s| s.trim().to_string())
.map_err(|_| Error::Git(format!("Cannot resolve git reference '{git_ref}'")))
}
fn root_commit(repo_root: &Path, target: &str) -> Result<String> {
let sha = process::cmd("git", ["rev-list", "--max-parents=0", target])
.cwd(repo_root)
.read()?;
sha.lines()
.next()
.map(|s| s.trim().to_string())
.ok_or_else(|| Error::Git("no commits found".into()))
}
pub fn resolve_ref(repo_root: &Path, git_ref: &str) -> Result<String> {
verify_ref(repo_root, git_ref).or_else(|_| verify_ref(repo_root, "HEAD"))
}
#[cfg(test)]
pub fn log_between(repo_root: &Path, from: &str, to: &str) -> Result<String> {
log_between_paths(repo_root, from, to, &[])
}
pub fn log_between_paths(
repo_root: &Path,
from: &str,
to: &str,
paths: &[String],
) -> Result<String> {
let from = verify_ref(repo_root, from)?;
let to = resolve_ref(repo_root, to)?;
let range = format!("{from}..{to}");
let mut args = vec![
"log".to_string(),
range,
"--pretty=format:%h %s".into(),
"--reverse".into(),
"--".into(),
];
args.extend(paths.iter().map(|p| format!(":(literal){p}")));
process::cmd("git", args)
.cwd(repo_root)
.read()
.map_err(Into::into)
}
pub type CommitCredit = (String, Vec<String>);
pub fn commit_credits(
repo_root: &Path,
from: &str,
to: &str,
paths: &[String],
) -> Result<Vec<CommitCredit>> {
let from = verify_ref(repo_root, from)?;
let to = resolve_ref(repo_root, to)?;
let mut args = vec![
"log".to_string(),
format!("{from}..{to}"),
"--pretty=format:%h%x1f%ae%x1f%(trailers:key=Co-authored-by,valueonly,unfold,separator=%x1e)%x1d"
.into(),
"--reverse".into(),
"--".into(),
];
args.extend(paths.iter().map(|p| format!(":(literal){p}")));
let raw = process::cmd("git", args).cwd(repo_root).read()?;
Ok(parse_commit_credits(&raw))
}
fn parse_commit_credits(raw: &str) -> Vec<CommitCredit> {
raw.split('\x1d')
.filter_map(|record| {
let mut fields = record.trim().splitn(3, '\x1f');
let hash = fields.next().filter(|h| !h.is_empty())?;
let author = fields.next().unwrap_or_default();
let co_authors = fields.next().unwrap_or_default();
let mut logins: Vec<String> = Vec::new();
for login in std::iter::once(author)
.chain(co_authors.split('\x1e'))
.filter_map(login_from_identity)
{
if !logins.iter().any(|l| l.eq_ignore_ascii_case(&login)) {
logins.push(login);
}
}
(!logins.is_empty()).then(|| (hash.to_string(), logins))
})
.collect()
}
fn login_from_identity(identity: &str) -> Option<String> {
let email = identity
.rsplit_once('<')
.map_or(identity, |(_, rest)| rest.trim_end_matches('>'))
.trim();
let local = email.strip_suffix("@users.noreply.github.com")?;
let login = local.rsplit_once('+').map_or(local, |(_, login)| login);
(is_valid_login(login) && !is_automation_login(login)).then(|| login.to_string())
}
fn is_valid_login(login: &str) -> bool {
(1..=39).contains(&login.len())
&& login
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
fn is_automation_login(login: &str) -> bool {
const SKIPPED: &[&str] = &[
"claude",
"copilot",
"codex",
"cursoragent",
"github-actions",
"dependabot",
"renovate",
];
let login = login.to_ascii_lowercase();
login.ends_with("[bot]") || SKIPPED.contains(&login.as_str())
}
pub fn extract_pr_numbers(log: &str) -> Vec<u64> {
let re = Regex::new(r"\(#(\d+)\)").unwrap();
re.captures_iter(log)
.filter_map(|cap| cap[1].parse().ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_respects_ancestry_pattern_and_channel() {
let repo = crate::test_helpers::TempRepo::new();
repo.write_file("cli/file", "a");
repo.commit("base");
repo.tag("cli/v1.0.0");
repo.write_file("cli/file", "b");
repo.commit("beta");
repo.tag("cli/v2.0.0-beta.1");
repo.write_file("cli/file", "c");
repo.commit("target");
repo.tag("cli/v2.0.0");
repo.write_file("cli/file", "d");
repo.commit("future");
repo.tag("cli/v3.0.0");
assert_eq!(
previous_tag_filtered(
repo.path(),
"cli/v2.0.0",
Some("cli/*"),
crate::workflow::Channel::Stable
)
.unwrap(),
"cli/v1.0.0"
);
assert_eq!(
previous_tag_filtered(
repo.path(),
"cli/v2.0.0",
Some("cli/*"),
crate::workflow::Channel::All
)
.unwrap(),
"cli/v2.0.0-beta.1"
);
}
#[test]
fn path_filter_omits_other_packages_and_rejects_bad_baseline() {
let repo = crate::test_helpers::TempRepo::new();
repo.write_file("cli/file", "a");
repo.commit("base");
repo.tag("v1");
repo.write_file("server/file", "a");
repo.commit("server feature");
repo.write_file("cli/file", "b");
repo.commit("cli feature");
repo.tag("v2");
let log = log_between_paths(repo.path(), "v1", "v2", &["cli".into()]).unwrap();
assert!(log.contains("cli feature"));
assert!(!log.contains("server feature"));
assert!(log_between_paths(repo.path(), "typo", "v2", &[]).is_err());
}
#[test]
fn test_parse_owner_repo_ssh() {
assert_eq!(
parse_owner_repo("git@github.com:jdx/pitchfork.git").unwrap(),
"jdx/pitchfork"
);
}
#[test]
fn test_parse_owner_repo_https() {
assert_eq!(
parse_owner_repo("https://github.com/jdx/pitchfork.git").unwrap(),
"jdx/pitchfork"
);
}
#[test]
fn test_extract_pr_numbers() {
let log =
"abc1234 feat: add feature (#123)\ndef5678 fix: bug (#456)\nghi9012 chore: update deps";
assert_eq!(extract_pr_numbers(log), vec![123, 456]);
}
#[test]
fn test_resolve_ref_existing() {
let repo = crate::test_helpers::TempRepo::new();
repo.write_file("f.txt", "a");
repo.commit("first");
repo.tag("v1.0.0");
let sha = resolve_ref(repo.path(), "v1.0.0").unwrap();
assert!(!sha.is_empty());
assert_eq!(sha.len(), 40);
}
#[test]
fn test_resolve_ref_fallback_to_head() {
let repo = crate::test_helpers::TempRepo::new();
repo.write_file("f.txt", "a");
repo.commit("first");
let sha = resolve_ref(repo.path(), "nonexistent-tag").unwrap();
assert_eq!(sha.len(), 40);
}
#[test]
fn test_previous_tag() {
let repo = crate::test_helpers::TempRepo::new();
repo.write_file("f.txt", "a");
repo.commit("first");
repo.tag("v1.0.0");
repo.write_file("f.txt", "b");
repo.commit("second");
repo.tag("v2.0.0");
let prev = previous_tag(repo.path(), "v2.0.0").unwrap();
assert_eq!(prev, "v1.0.0");
}
#[test]
fn test_previous_tag_first_release() {
let repo = crate::test_helpers::TempRepo::new();
repo.write_file("f.txt", "a");
repo.commit("first");
repo.tag("v1.0.0");
let prev = previous_tag(repo.path(), "v1.0.0").unwrap();
assert_eq!(prev.len(), 40);
}
#[test]
fn test_log_between() {
let repo = crate::test_helpers::TempRepo::new();
repo.write_file("f.txt", "a");
repo.commit("first commit");
repo.tag("v1.0.0");
repo.write_file("f.txt", "b");
repo.commit("second commit");
repo.tag("v2.0.0");
let log = log_between(repo.path(), "v1.0.0", "v2.0.0").unwrap();
assert!(log.contains("second commit"));
assert!(!log.contains("first commit"));
}
#[test]
fn login_from_identity_accepts_only_github_noreply() {
assert_eq!(
login_from_identity("Navid EMAD <66868650+navidemad@users.noreply.github.com>"),
Some("navidemad".into())
);
assert_eq!(
login_from_identity("octo@users.noreply.github.com"),
Some("octo".into())
);
assert_eq!(
login_from_identity("Ada <ada,bob@users.noreply.github.com>"),
None
);
assert_eq!(
login_from_identity(&format!("{}@users.noreply.github.com", "a".repeat(40))),
None
);
assert_eq!(
login_from_identity("Ann <9+ann_acme@users.noreply.github.com>"),
Some("ann_acme".into())
);
assert_eq!(login_from_identity("Claude <noreply@anthropic.com>"), None);
assert_eq!(
login_from_identity("bot <1+renovate[bot]@users.noreply.github.com>"),
None
);
assert_eq!(
login_from_identity("Copilot <175728472+Copilot@users.noreply.github.com>"),
None
);
}
#[test]
fn commit_credits_include_author_and_coauthor_trailers() {
let repo = crate::test_helpers::TempRepo::new();
repo.write_file("a", "a");
repo.commit("base");
repo.tag("v1");
repo.write_file("a", "b");
repo.commit_as(
"fix: recreated (#1)\n\nCo-authored-by: Ada <7+ada@users.noreply.github.com>\nCo-authored-by: Claude <noreply@anthropic.com>\nCo-authored-by: Ada <7+ada@users.noreply.github.com>",
"Maintainer <1+Maint@users.noreply.github.com>",
);
repo.write_file("a", "c");
repo.commit_as("chore: plain", "Bob <2+bob@users.noreply.github.com>");
repo.write_file("a", "d");
repo.commit_as("chore: ignored", "Test <test@test.com>");
let credits = commit_credits(repo.path(), "v1", "HEAD", &[]).unwrap();
let logins: Vec<_> = credits.iter().map(|(_, l)| l.clone()).collect();
assert_eq!(
logins,
vec![
vec!["Maint".to_string(), "ada".into()],
vec!["bob".to_string()]
]
);
}
#[test]
fn parse_commit_credits_dedupes_logins_ignoring_case() {
let raw =
"abc\x1f7+Ada@users.noreply.github.com\x1fAda <7+ada@users.noreply.github.com>\x1d";
assert_eq!(
parse_commit_credits(raw),
vec![("abc".to_string(), vec!["Ada".to_string()])]
);
}
}