use std::path::{Path, PathBuf};
use std::process::Command;
use crate::commit::Commit;
use crate::error::ReleaseError;
use crate::git::{GitRepository, TagInfo};
use base64::Engine;
use semver::Version;
pub struct NativeGitRepository {
path: PathBuf,
http_auth: Option<(String, String)>, }
impl NativeGitRepository {
pub fn open(path: &Path) -> Result<Self, ReleaseError> {
let repo = Self {
path: path.to_path_buf(),
http_auth: None,
};
repo.git(&["rev-parse", "--git-dir"])?;
Ok(repo)
}
pub fn with_http_auth(mut self, hostname: String, token: String) -> Self {
self.http_auth = Some((hostname, token));
self
}
fn git(&self, args: &[&str]) -> Result<String, ReleaseError> {
let mut cmd = Command::new("git");
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.arg("-C").arg(&self.path);
if let Some((hostname, token)) = &self.http_auth {
let credentials = format!("x-access-token:{token}");
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
let config_key = format!("http.https://{hostname}/.extraheader");
let config_val = format!("AUTHORIZATION: basic {encoded}");
cmd.args(["-c", &format!("{config_key}=")]);
cmd.args(["-c", &format!("{config_key}={config_val}")]);
}
let output = cmd
.args(args)
.output()
.map_err(|e| ReleaseError::Git(format!("failed to run git: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ReleaseError::Git(format!(
"git {} failed: {}",
args.join(" "),
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn parse_remote(&self) -> Result<(String, String), ReleaseError> {
let url = self.git(&["remote", "get-url", "origin"])?;
parse_owner_repo(&url)
}
pub fn parse_remote_full(&self) -> Result<(String, String, String), ReleaseError> {
let url = self.git(&["remote", "get-url", "origin"])?;
parse_remote_url(&url)
}
}
pub fn parse_remote_url(url: &str) -> Result<(String, String, String), ReleaseError> {
let trimmed = url.trim_end_matches(".git");
if let Some(rest) = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))
{
let (hostname, path) = rest
.split_once('/')
.ok_or_else(|| ReleaseError::Git(format!("cannot parse remote URL: {url}")))?;
let (owner, repo) = path
.split_once('/')
.ok_or_else(|| ReleaseError::Git(format!("cannot parse owner/repo from: {url}")))?;
return Ok((hostname.to_string(), owner.to_string(), repo.to_string()));
}
if let Some((host_part, path)) = trimmed.split_once(':') {
let hostname = host_part.rsplit('@').next().unwrap_or(host_part);
let (owner, repo) = path
.split_once('/')
.ok_or_else(|| ReleaseError::Git(format!("cannot parse owner/repo from: {url}")))?;
return Ok((hostname.to_string(), owner.to_string(), repo.to_string()));
}
Err(ReleaseError::Git(format!("cannot parse remote URL: {url}")))
}
pub fn parse_owner_repo(url: &str) -> Result<(String, String), ReleaseError> {
let (_, owner, repo) = parse_remote_url(url)?;
Ok((owner, repo))
}
fn parse_commit_log(output: &str) -> Vec<Commit> {
if output.is_empty() {
return Vec::new();
}
let mut commits = Vec::new();
let mut current_sha: Option<String> = None;
let mut current_message = String::new();
for line in output.lines() {
if line == "--END--" {
if let Some(sha) = current_sha.take() {
commits.push(Commit {
sha,
message: current_message.trim().to_string(),
});
current_message.clear();
}
} else if current_sha.is_none()
&& line.len() == 40
&& line.chars().all(|c| c.is_ascii_hexdigit())
{
current_sha = Some(line.to_string());
} else {
if !current_message.is_empty() {
current_message.push('\n');
}
current_message.push_str(line);
}
}
if let Some(sha) = current_sha {
commits.push(Commit {
sha,
message: current_message.trim().to_string(),
});
}
commits
}
impl GitRepository for NativeGitRepository {
fn latest_tag(&self, prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
let pattern = format!("{prefix}*");
let result = self.git(&["tag", "--list", &pattern, "--sort=-v:refname"]);
let tags_output = match result {
Ok(output) if output.is_empty() => return Ok(None),
Ok(output) => output,
Err(_) => return Ok(None),
};
let tag_name = match tags_output.lines().next() {
Some(name) => name.trim(),
None => return Ok(None),
};
let version_str = tag_name.strip_prefix(prefix).unwrap_or(tag_name);
let version = match Version::parse(version_str) {
Ok(v) => v,
Err(_) => return Ok(None),
};
let sha = self.git(&["rev-list", "-1", tag_name])?;
Ok(Some(TagInfo {
name: tag_name.to_string(),
version,
sha,
}))
}
fn commits_since(&self, from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
let range = match from {
Some(sha) => format!("{sha}..HEAD"),
None => "HEAD".to_string(),
};
let output = self.git(&["log", "--format=%H%n%B%n--END--", &range])?;
Ok(parse_commit_log(&output))
}
fn create_tag(&self, name: &str, message: &str, sign: bool) -> Result<(), ReleaseError> {
let flag = if sign { "-s" } else { "-a" };
self.git(&["tag", flag, name, "-m", message])?;
Ok(())
}
fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
self.git(&["push", "origin", name])?;
Ok(())
}
fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
let mut args = vec!["add", "--"];
args.extend(paths);
self.git(&args)?;
let status = self.git(&["status", "--porcelain"]);
match status {
Ok(s) if s.is_empty() => Ok(false),
_ => {
self.git(&["commit", "--no-verify", "-m", message])?;
Ok(true)
}
}
}
fn is_dirty(&self) -> Result<bool, ReleaseError> {
let output = self.git(&["status", "--porcelain"])?;
Ok(!output.is_empty())
}
fn push(&self) -> Result<(), ReleaseError> {
self.git(&["push", "origin", "HEAD"])?;
Ok(())
}
fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
match self.git(&["rev-parse", "--verify", &format!("refs/tags/{name}")]) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
let output = self.git(&["ls-remote", "--tags", "origin", name])?;
Ok(!output.is_empty())
}
fn all_tags(&self, prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
let pattern = format!("{prefix}*");
let result = self.git(&["tag", "--list", &pattern, "--sort=v:refname"]);
let tags_output = match result {
Ok(output) if output.is_empty() => return Ok(Vec::new()),
Ok(output) => output,
Err(_) => return Ok(Vec::new()),
};
let mut tags = Vec::new();
for line in tags_output.lines() {
let tag_name = line.trim();
if tag_name.is_empty() {
continue;
}
let version_str = tag_name.strip_prefix(prefix).unwrap_or(tag_name);
let version = match Version::parse(version_str) {
Ok(v) => v,
Err(_) => continue,
};
let sha = self.git(&["rev-list", "-1", tag_name])?;
tags.push(TagInfo {
name: tag_name.to_string(),
version,
sha,
});
}
Ok(tags)
}
fn commits_between(&self, from: Option<&str>, to: &str) -> Result<Vec<Commit>, ReleaseError> {
let range = match from {
Some(sha) => format!("{sha}..{to}"),
None => to.to_string(),
};
let output = self.git(&["log", "--format=%H%n%B%n--END--", &range])?;
Ok(parse_commit_log(&output))
}
fn tag_date(&self, tag_name: &str) -> Result<String, ReleaseError> {
let date = self.git(&["log", "-1", "--format=%cd", "--date=short", tag_name])?;
Ok(date)
}
fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
self.git(&["tag", "-f", name])?;
Ok(())
}
fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
self.git(&["push", "origin", name, "--force"])?;
Ok(())
}
fn head_sha(&self) -> Result<String, ReleaseError> {
self.git(&["rev-parse", "HEAD"])
}
fn commits_since_in_path(
&self,
from: Option<&str>,
path: &str,
) -> Result<Vec<Commit>, ReleaseError> {
let range = match from {
Some(sha) => format!("{sha}..HEAD"),
None => "HEAD".to_string(),
};
let output = self.git(&["log", "--format=%H%n%B%n--END--", &range, "--", path])?;
Ok(parse_commit_log(&output))
}
fn commits_between_in_path(
&self,
from: Option<&str>,
to: &str,
path: &str,
) -> Result<Vec<Commit>, ReleaseError> {
let range = match from {
Some(sha) => format!("{sha}..{to}"),
None => to.to_string(),
};
let output = self.git(&["log", "--format=%H%n%B%n--END--", &range, "--", path])?;
Ok(parse_commit_log(&output))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ssh_remote() {
let (owner, repo) = parse_owner_repo("git@github.com:urmzd/sr.git").unwrap();
assert_eq!(owner, "urmzd");
assert_eq!(repo, "sr");
}
#[test]
fn parse_https_remote() {
let (owner, repo) = parse_owner_repo("https://github.com/urmzd/sr.git").unwrap();
assert_eq!(owner, "urmzd");
assert_eq!(repo, "sr");
}
#[test]
fn parse_https_no_git_suffix() {
let (owner, repo) = parse_owner_repo("https://github.com/urmzd/sr").unwrap();
assert_eq!(owner, "urmzd");
assert_eq!(repo, "sr");
}
#[test]
fn parse_remote_url_github_https() {
let (host, owner, repo) = parse_remote_url("https://github.com/urmzd/sr.git").unwrap();
assert_eq!(host, "github.com");
assert_eq!(owner, "urmzd");
assert_eq!(repo, "sr");
}
#[test]
fn parse_remote_url_github_ssh() {
let (host, owner, repo) = parse_remote_url("git@github.com:urmzd/sr.git").unwrap();
assert_eq!(host, "github.com");
assert_eq!(owner, "urmzd");
assert_eq!(repo, "sr");
}
#[test]
fn parse_remote_url_ghes_https() {
let (host, owner, repo) =
parse_remote_url("https://ghes.example.com/org/my-repo.git").unwrap();
assert_eq!(host, "ghes.example.com");
assert_eq!(owner, "org");
assert_eq!(repo, "my-repo");
}
#[test]
fn parse_remote_url_ghes_ssh() {
let (host, owner, repo) = parse_remote_url("git@ghes.example.com:org/my-repo.git").unwrap();
assert_eq!(host, "ghes.example.com");
assert_eq!(owner, "org");
assert_eq!(repo, "my-repo");
}
#[test]
fn parse_remote_url_no_git_suffix() {
let (host, owner, repo) = parse_remote_url("https://github.com/urmzd/sr").unwrap();
assert_eq!(host, "github.com");
assert_eq!(owner, "urmzd");
assert_eq!(repo, "sr");
}
#[test]
fn http_auth_header_encodes_correctly() {
use base64::Engine;
let token = "ghp_testtoken123";
let credentials = format!("x-access-token:{token}");
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes());
let decoded_bytes = base64::engine::general_purpose::STANDARD
.decode(&encoded)
.expect("base64 should decode");
let decoded = String::from_utf8(decoded_bytes).expect("should be valid utf-8");
assert_eq!(decoded, "x-access-token:ghp_testtoken123");
}
#[test]
fn http_auth_header_scoped_to_hostname() {
let hostname = "ghes.example.com";
let config_key = format!("http.https://{hostname}/.extraheader");
assert_eq!(config_key, "http.https://ghes.example.com/.extraheader");
let hostname = "github.com";
let config_key = format!("http.https://{hostname}/.extraheader");
assert_eq!(config_key, "http.https://github.com/.extraheader");
}
}