use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::Repository;
const BRANCH_REF_PREFIX: &str = "refs/heads/";
const FORBIDDEN_BRANCH_CHARACTERS: [char; 7] = ['~', '^', ':', '?', '*', '[', '\\'];
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
pub enum InvalidHeadRef {
#[error("head ref must be fully qualified as refs/heads/<branch>")]
NotABranchRef,
#[error("head ref names no branch")]
NoBranch,
#[error("branch name is one git refuses to create")]
InvalidBranchName,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "SerializedHead", into = "SerializedHead")]
pub struct ChangeRequestHead {
repository: Repository,
branch: String,
}
#[derive(Clone, Deserialize, Serialize)]
struct SerializedHead {
repository: Repository,
head_ref: String,
}
impl TryFrom<SerializedHead> for ChangeRequestHead {
type Error = InvalidHeadRef;
fn try_from(value: SerializedHead) -> std::result::Result<Self, Self::Error> {
Self::new(value.repository, &value.head_ref)
}
}
impl From<ChangeRequestHead> for SerializedHead {
fn from(value: ChangeRequestHead) -> Self {
Self {
head_ref: format!("{BRANCH_REF_PREFIX}{}", value.branch),
repository: value.repository,
}
}
}
impl ChangeRequestHead {
pub fn new(
repository: Repository,
head_ref: &str,
) -> std::result::Result<Self, InvalidHeadRef> {
Ok(Self {
repository,
branch: head_branch(head_ref)?.to_owned(),
})
}
#[must_use]
pub fn repository(&self) -> &Repository {
&self.repository
}
#[must_use]
pub fn branch(&self) -> &str {
&self.branch
}
}
fn head_branch(head_ref: &str) -> std::result::Result<&str, InvalidHeadRef> {
let branch = head_ref
.strip_prefix(BRANCH_REF_PREFIX)
.ok_or(InvalidHeadRef::NotABranchRef)?;
if branch.is_empty() {
return Err(InvalidHeadRef::NoBranch);
}
if creatable_branch_name(branch) {
Ok(branch)
} else {
Err(InvalidHeadRef::InvalidBranchName)
}
}
fn creatable_branch_name(branch: &str) -> bool {
if branch == "@"
|| branch == "HEAD"
|| branch.starts_with('-')
|| branch.ends_with('.')
|| branch.contains("..")
|| branch.contains("@{")
{
return false;
}
if branch.chars().any(|character| {
character.is_ascii_control()
|| character == ' '
|| FORBIDDEN_BRANCH_CHARACTERS.contains(&character)
}) {
return false;
}
branch.split('/').all(|component| {
!component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sandbox() -> Repository {
Repository::new("civitas-forge", "sandbox").expect("repository")
}
#[test]
fn head_reads_one_ref_spelling_so_every_branch_stays_addressable() {
for (head_ref, branch) in [
("refs/heads/main", "main"),
("refs/heads/feat/open-request", "feat/open-request"),
("refs/heads/refs/heads/main", "refs/heads/main"),
] {
let head = ChangeRequestHead::new(sandbox(), head_ref).expect("branch ref");
assert_eq!(head.branch(), branch);
assert_eq!(head.repository(), &sandbox());
}
}
#[test]
fn head_states_why_a_string_names_no_branch() {
for unqualified in ["", "main", "refs/tags/v1.1.0", "refs/remotes/origin/main"] {
assert_eq!(
ChangeRequestHead::new(sandbox(), unqualified),
Err(InvalidHeadRef::NotABranchRef),
"{unqualified:?}"
);
}
assert_eq!(
ChangeRequestHead::new(sandbox(), "refs/heads/"),
Err(InvalidHeadRef::NoBranch)
);
for uncreatable in [
"refs/heads/@",
"refs/heads/HEAD",
"refs/heads/-topic",
"refs/heads/main.",
"refs/heads/ma..in",
"refs/heads/ma@{in",
"refs/heads/ma:in",
"refs/heads/ma in",
"refs/heads/main\n",
"refs/heads/ma~in",
"refs/heads/ma[in",
"refs/heads/ma\\in",
"refs/heads/feat//open",
"refs/heads/feat/",
"refs/heads//feat",
"refs/heads/feat/.hidden",
"refs/heads/feat/open.lock",
] {
assert_eq!(
ChangeRequestHead::new(sandbox(), uncreatable),
Err(InvalidHeadRef::InvalidBranchName),
"{uncreatable:?}"
);
}
}
#[test]
fn head_keeps_the_branch_characters_git_permits() {
for permitted in [
"refs/heads/mai\u{00a0}n",
"refs/heads/feature.lockfile",
"refs/heads/rele.ase",
"refs/heads/ma@in",
"refs/heads/feat/-topic",
"refs/heads/feat/HEAD",
] {
assert!(
ChangeRequestHead::new(sandbox(), permitted).is_ok(),
"{permitted:?}"
);
}
}
}