#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitBranchNameError {
Empty,
InvalidName,
MissingRemoteOrBranch,
}
impl fmt::Display for GitBranchNameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Git branch name cannot be empty"),
Self::InvalidName => formatter.write_str("invalid Git branch name"),
Self::MissingRemoteOrBranch => {
formatter.write_str("remote-tracking branch must contain remote and branch names")
},
}
}
}
impl Error for GitBranchNameError {}
fn has_lock_suffix(value: &str) -> bool {
value
.get(value.len().saturating_sub(5)..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case(".lock"))
}
fn validate_branch_name(value: impl AsRef<str>) -> Result<String, GitBranchNameError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
return Err(GitBranchNameError::Empty);
}
let invalid = trimmed == "HEAD"
|| trimmed.starts_with('/')
|| trimmed.ends_with('/')
|| trimmed.starts_with('.')
|| trimmed.ends_with('.')
|| has_lock_suffix(trimmed)
|| trimmed.contains("//")
|| trimmed.contains("..")
|| trimmed.contains("@{")
|| trimmed.chars().any(|character| {
character.is_ascii_control()
|| character.is_ascii_whitespace()
|| matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
})
|| trimmed.split('/').any(|component| component.ends_with('.'));
if invalid {
Err(GitBranchNameError::InvalidName)
} else {
Ok(trimmed.to_string())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitBranchName(String);
impl GitBranchName {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
validate_branch_name(value).map(Self)
}
#[must_use]
pub fn is_mainline(&self) -> bool {
matches!(self.as_str(), "main" | "master" | "trunk")
}
#[must_use]
pub fn is_feature(&self) -> bool {
self.as_str().starts_with("feature/") || self.as_str().starts_with("feat/")
}
#[must_use]
pub fn is_release(&self) -> bool {
self.as_str().starts_with("release/")
}
#[must_use]
pub fn is_hotfix(&self) -> bool {
self.as_str().starts_with("hotfix/")
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl AsRef<str> for GitBranchName {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for GitBranchName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for GitBranchName {
type Err = GitBranchNameError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl TryFrom<&str> for GitBranchName {
type Error = GitBranchNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LocalBranchName(GitBranchName);
impl LocalBranchName {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
GitBranchName::new(value).map(Self)
}
#[must_use]
pub const fn branch(&self) -> &GitBranchName {
&self.0
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl AsRef<str> for LocalBranchName {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for LocalBranchName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for LocalBranchName {
type Err = GitBranchNameError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemoteTrackingBranchName {
value: String,
remote: String,
branch: GitBranchName,
}
impl RemoteTrackingBranchName {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
let value = value.as_ref().trim();
let Some((remote, branch)) = value.split_once('/') else {
return Err(GitBranchNameError::MissingRemoteOrBranch);
};
if remote.is_empty() || branch.is_empty() || remote.contains(char::is_whitespace) {
return Err(GitBranchNameError::MissingRemoteOrBranch);
}
let branch = GitBranchName::new(branch)?;
Ok(Self {
value: value.to_string(),
remote: remote.to_string(),
branch,
})
}
#[must_use]
pub fn remote(&self) -> &str {
&self.remote
}
#[must_use]
pub const fn branch(&self) -> &GitBranchName {
&self.branch
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.value
}
}
impl fmt::Display for RemoteTrackingBranchName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemoteTrackingBranchName {
type Err = GitBranchNameError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DefaultBranchName(GitBranchName);
impl DefaultBranchName {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
GitBranchName::new(value).map(Self)
}
#[must_use]
pub fn main() -> Self {
Self(GitBranchName(String::from("main")))
}
#[must_use]
pub fn master() -> Self {
Self(GitBranchName(String::from("master")))
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for DefaultBranchName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::{DefaultBranchName, GitBranchName, GitBranchNameError, RemoteTrackingBranchName};
#[test]
fn validates_branch_categories() -> Result<(), GitBranchNameError> {
let feature = GitBranchName::new("feature/use-git")?;
let release = GitBranchName::new("release/0.1")?;
let hotfix = GitBranchName::new("hotfix/docs")?;
assert!(feature.is_feature());
assert!(release.is_release());
assert!(hotfix.is_hotfix());
assert!(DefaultBranchName::main().as_str() == "main");
Ok(())
}
#[test]
fn parses_remote_tracking_branch() -> Result<(), GitBranchNameError> {
let branch = RemoteTrackingBranchName::new("origin/main")?;
assert_eq!(branch.remote(), "origin");
assert_eq!(branch.branch().as_str(), "main");
Ok(())
}
#[test]
fn rejects_invalid_branch_names() {
assert_eq!(GitBranchName::new(""), Err(GitBranchNameError::Empty));
assert_eq!(
GitBranchName::new("HEAD"),
Err(GitBranchNameError::InvalidName)
);
assert_eq!(
GitBranchName::new("feature//x"),
Err(GitBranchNameError::InvalidName)
);
}
}