use std::path::Path;
use std::process::Command;
use crate::error::JoyError;
const MIN_GIT_MAJOR: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Forge {
GitHub,
GitLab,
Gitea,
Unknown,
}
pub trait Vcs {
fn is_repo(&self, root: &Path) -> bool;
fn init_repo(&self, root: &Path) -> Result<(), JoyError>;
fn user_email(&self) -> Result<String, JoyError>;
fn version_tags(&self, root: &Path) -> Result<Vec<String>, JoyError>;
fn latest_version_tag(&self, root: &Path) -> Result<Option<String>, JoyError>;
}
pub struct GitVcs;
fn git_output(root: &Path, args: &[&str]) -> Result<String, JoyError> {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
JoyError::Git("git is not installed or not in PATH".into())
} else {
JoyError::Git(format!("failed to run git {}: {e}", args.join(" ")))
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let cmd = format!("git {}", args.join(" "));
return Err(JoyError::Git(if stderr.is_empty() {
format!("{cmd} failed (exit {})", output.status.code().unwrap_or(-1))
} else {
format!("{cmd} failed: {stderr}")
}));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_run(root: &Path, args: &[&str]) -> Result<(), JoyError> {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
JoyError::Git("git is not installed or not in PATH".into())
} else {
JoyError::Git(format!("failed to run git {}: {e}", args.join(" ")))
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let cmd = format!("git {}", args.join(" "));
return Err(JoyError::Git(if stderr.is_empty() {
format!("{cmd} failed (exit {})", output.status.code().unwrap_or(-1))
} else {
format!("{cmd} failed: {stderr}")
}));
}
Ok(())
}
impl Vcs for GitVcs {
fn is_repo(&self, root: &Path) -> bool {
Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn init_repo(&self, root: &Path) -> Result<(), JoyError> {
git_run(root, &["init"])
}
fn user_email(&self) -> Result<String, JoyError> {
let email = git_output(Path::new("."), &["config", "user.email"])?;
if email.is_empty() {
return Err(JoyError::Git("git user.email is empty".into()));
}
Ok(email)
}
fn version_tags(&self, root: &Path) -> Result<Vec<String>, JoyError> {
let output = git_output(root, &["tag", "--list", "--sort=-v:refname"]).unwrap_or_default();
let tags: Vec<String> = output
.lines()
.filter(|l| l.starts_with('v') || l.starts_with('V'))
.map(|l| l.to_string())
.collect();
Ok(tags)
}
fn latest_version_tag(&self, root: &Path) -> Result<Option<String>, JoyError> {
match git_output(root, &["describe", "--tags", "--abbrev=0", "--match", "v*"]) {
Ok(tag) if !tag.is_empty() => Ok(Some(tag)),
_ => Ok(None),
}
}
}
impl GitVcs {
pub fn config_get(&self, root: &Path, key: &str) -> Result<String, JoyError> {
git_output(root, &["config", "--local", key])
}
pub fn config_set(&self, root: &Path, key: &str, value: &str) -> Result<(), JoyError> {
git_run(root, &["config", "--local", key, value])
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub raw: String,
}
impl GitVcs {
pub fn version(&self) -> Result<GitVersion, JoyError> {
let raw = git_output(Path::new("."), &["--version"])?;
parse_git_version(&raw)
}
pub fn check_version(&self) -> Result<GitVersion, JoyError> {
let v = self.version()?;
if v.major < MIN_GIT_MAJOR {
return Err(JoyError::Git(format!(
"git {}.{}.{} is too old (minimum: {MIN_GIT_MAJOR}.0)\n \
= help: update git to version {MIN_GIT_MAJOR}.0 or newer",
v.major, v.minor, v.patch
)));
}
Ok(v)
}
}
fn parse_git_version(raw: &str) -> Result<GitVersion, JoyError> {
let version_str = raw.strip_prefix("git version ").unwrap_or(raw).trim();
let parts: Vec<&str> = version_str.splitn(4, '.').collect();
let major: u32 = parts
.first()
.and_then(|s| s.parse().ok())
.ok_or_else(|| JoyError::Git(format!("cannot parse git version: {raw}")))?;
let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let patch: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
Ok(GitVersion {
major,
minor,
patch,
raw: raw.to_string(),
})
}
impl GitVcs {
pub fn add(&self, root: &Path, paths: &[&str]) -> Result<(), JoyError> {
let mut args = vec!["add"];
args.extend_from_slice(paths);
git_run(root, &args)
}
pub fn add_all(&self, root: &Path) -> Result<(), JoyError> {
git_run(root, &["add", "-A"])
}
pub fn commit(&self, root: &Path, message: &str) -> Result<(), JoyError> {
git_run(root, &["commit", "--quiet", "-m", message])
}
pub fn tag_annotated(&self, root: &Path, name: &str, body: &str) -> Result<(), JoyError> {
git_run(root, &["tag", "-a", name, "-m", body])
}
pub fn tag(&self, root: &Path, name: &str) -> Result<(), JoyError> {
git_run(root, &["tag", name])
}
pub fn push(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
git_run(root, &["push", "--quiet", remote])
}
pub fn push_tag(&self, root: &Path, remote: &str, tag: &str) -> Result<(), JoyError> {
git_run(root, &["push", "--quiet", remote, tag])
}
pub fn push_with_tags(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
self.push(root, remote)?;
git_run(root, &["push", "--quiet", remote, "--tags"])
}
pub fn default_remote(&self, root: &Path) -> Result<String, JoyError> {
let remote = git_output(root, &["remote"])?;
let first = remote.lines().next().unwrap_or("origin");
Ok(first.to_string())
}
pub fn remote_url(&self, root: &Path, remote: &str) -> Result<String, JoyError> {
git_output(root, &["remote", "get-url", remote])
}
pub fn is_clean(&self, root: &Path) -> Result<bool, JoyError> {
let output = git_output(root, &["status", "--porcelain"])?;
Ok(output.is_empty())
}
pub fn head_is_tagged(&self, root: &Path) -> bool {
git_output(root, &["describe", "--tags", "--exact-match", "HEAD"]).is_ok()
}
}
impl GitVcs {
pub fn detect_forge(&self, root: &Path) -> Forge {
let remote = match self.default_remote(root) {
Ok(r) => r,
Err(_) => return Forge::Unknown,
};
let url = match self.remote_url(root, &remote) {
Ok(u) => u,
Err(_) => return Forge::Unknown,
};
parse_forge_from_url(&url)
}
}
pub fn parse_forge_from_url(url: &str) -> Forge {
let lower = url.to_lowercase();
if lower.contains("github.com") {
Forge::GitHub
} else if lower.contains("gitlab.com") || lower.contains("gitlab") {
Forge::GitLab
} else if lower.contains("gitea") || lower.contains("codeberg.org") {
Forge::Gitea
} else {
Forge::Unknown
}
}
pub fn gh_version() -> Result<String, JoyError> {
let output = Command::new("gh").arg("--version").output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
JoyError::Git("gh (GitHub CLI) is not installed or not in PATH".into())
} else {
JoyError::Git(format!("failed to run gh: {e}"))
}
})?;
if !output.status.success() {
return Err(JoyError::Git("gh --version failed".into()));
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
let version = raw
.lines()
.next()
.unwrap_or(&raw)
.strip_prefix("gh version ")
.unwrap_or(&raw)
.split_whitespace()
.next()
.unwrap_or(&raw)
.to_string();
Ok(version)
}
pub fn has_gh() -> bool {
Command::new("gh")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
pub fn gh_create_release(
root: &Path,
tag: &str,
title: &str,
notes: &str,
) -> Result<String, JoyError> {
let output = Command::new("gh")
.args(["release", "create", tag, "--title", title, "--notes", notes])
.current_dir(root)
.output()
.map_err(|e| JoyError::Git(format!("failed to run gh release create: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(JoyError::Git(format!("gh release create failed: {stderr}")));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn default_vcs() -> GitVcs {
GitVcs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore] fn git_vcs_user_email() {
let vcs = GitVcs;
let result = vcs.user_email();
assert!(result.is_ok());
assert!(!result.unwrap().is_empty());
}
#[test]
fn git_vcs_is_repo() {
let vcs = GitVcs;
assert!(vcs.is_repo(Path::new(".")));
}
#[test]
#[ignore] fn git_vcs_version_tags() {
let vcs = GitVcs;
let tags = vcs.version_tags(Path::new(".")).unwrap();
assert!(!tags.is_empty());
}
#[test]
fn parse_git_version_standard() {
let v = parse_git_version("git version 2.43.0").unwrap();
assert_eq!(v.major, 2);
assert_eq!(v.minor, 43);
assert_eq!(v.patch, 0);
}
#[test]
fn parse_git_version_windows() {
let v = parse_git_version("git version 2.43.0.windows.1").unwrap();
assert_eq!(v.major, 2);
assert_eq!(v.minor, 43);
assert_eq!(v.patch, 0);
}
#[test]
fn parse_git_version_old() {
let v = parse_git_version("git version 1.8.5").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 8);
assert_eq!(v.patch, 5);
}
#[test]
fn forge_detection_github() {
assert_eq!(
parse_forge_from_url("git@github.com:joyint/joy.git"),
Forge::GitHub
);
assert_eq!(
parse_forge_from_url("https://github.com/joyint/joy.git"),
Forge::GitHub
);
}
#[test]
fn forge_detection_gitlab() {
assert_eq!(
parse_forge_from_url("git@gitlab.com:user/repo.git"),
Forge::GitLab
);
assert_eq!(
parse_forge_from_url("https://gitlab.example.com/user/repo.git"),
Forge::GitLab
);
}
#[test]
fn forge_detection_gitea() {
assert_eq!(
parse_forge_from_url("https://codeberg.org/user/repo.git"),
Forge::Gitea
);
assert_eq!(
parse_forge_from_url("https://gitea.example.com/user/repo.git"),
Forge::Gitea
);
}
#[test]
fn forge_detection_unknown() {
assert_eq!(
parse_forge_from_url("https://example.com/repo.git"),
Forge::Unknown
);
}
#[test]
fn git_version_check() {
let vcs = GitVcs;
let v = vcs.check_version().unwrap();
assert!(v.major >= MIN_GIT_MAJOR);
}
#[test]
fn git_clean_check() {
let vcs = GitVcs;
let _ = vcs.is_clean(Path::new("."));
}
#[test]
fn git_detect_forge() {
let vcs = GitVcs;
let forge = vcs.detect_forge(Path::new("."));
assert_eq!(forge, Forge::GitHub);
}
}