use super::Direction;
use crate::refs::RefName;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
#[non_exhaustive]
pub enum ConfiguredRefspecKind {
Selection,
Exclusion,
Mapping,
AllMatching,
Deletion,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ConfiguredRefspec {
kind: ConfiguredRefspecKind,
source: Option<Vec<u8>>,
destination: Option<Vec<u8>>,
force: bool,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ConfiguredRefspecError {
#[error("invalid configured refspec name")]
Name,
#[error("invalid configured refspec exclusion")]
Exclusion,
#[error("unbalanced configured refspec pattern")]
Pattern,
#[error("empty configured push refspec")]
EmptyPush,
#[error("empty configured push destination")]
EmptyDestination,
}
impl ConfiguredRefspec {
pub fn parse(direction: Direction, bytes: &[u8]) -> Result<Self, ConfiguredRefspecError> {
use ConfiguredRefspecKind as Kind;
if let Some(source) = bytes.strip_prefix(b"^") {
if source.is_empty()
|| source.contains(&b':')
|| (matches!(source.len(), 40 | 64) && source.iter().all(u8::is_ascii_hexdigit))
{
return Err(ConfiguredRefspecError::Exclusion);
}
let source = normalize_source(source);
validate_name(source)?;
return Ok(Self {
kind: Kind::Exclusion,
source: Some(source.to_vec()),
destination: None,
force: false,
});
}
let (force, body) = bytes
.strip_prefix(b"+")
.map_or((false, bytes), |b| (true, b));
let (source, destination) = match body.iter().rposition(|b| *b == b':') {
Some(colon) => (&body[..colon], Some(&body[colon + 1..])),
None => (body, None),
};
if direction == Direction::Push && !source.is_empty() && destination == Some(b"".as_slice())
{
return Err(ConfiguredRefspecError::EmptyDestination);
}
let explicit_destination = destination.is_some();
let source = normalize_source(source);
let destination = destination.filter(|name| !name.is_empty());
if direction == Direction::Push && bytes.is_empty() {
return Err(ConfiguredRefspecError::EmptyPush);
}
let (kind, source, destination, force) = match (direction, source, destination) {
(Direction::Fetch, source, destination) => {
let source = if source.is_empty() { b"HEAD" } else { source };
let kind = if destination.is_some() {
Kind::Mapping
} else {
Kind::Selection
};
(
kind,
Some(source),
destination,
force && destination.is_some(),
)
}
(Direction::Push, [], None) => (Kind::AllMatching, None, None, force),
(Direction::Push, [], Some(destination)) => {
(Kind::Deletion, None, Some(destination), false)
}
(Direction::Push, source, destination) => (
Kind::Mapping,
Some(source),
Some(destination.unwrap_or(source)),
force,
),
};
let source_pattern = match source {
Some(source)
if direction == Direction::Push
&& explicit_destination
&& !source.contains(&b'*') =>
{
false
}
Some(source) => validate_name(source)?,
None => false,
};
let destination_pattern = destination.map(validate_name).transpose()?.unwrap_or(false);
if source_pattern != destination_pattern {
return Err(ConfiguredRefspecError::Pattern);
}
Ok(Self {
kind,
source: source.map(<[u8]>::to_vec),
destination: destination.map(<[u8]>::to_vec),
force,
})
}
pub fn kind(&self) -> ConfiguredRefspecKind {
self.kind
}
pub fn source(&self) -> Option<&[u8]> {
self.source.as_deref()
}
pub fn destination(&self) -> Option<&[u8]> {
self.destination.as_deref()
}
pub fn force(&self) -> bool {
self.force
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::new();
if self.force {
bytes.push(b'+');
}
if self.kind == ConfiguredRefspecKind::Exclusion {
bytes.push(b'^');
}
if let Some(source) = &self.source {
bytes.extend_from_slice(source);
}
if matches!(
self.kind,
ConfiguredRefspecKind::Mapping
| ConfiguredRefspecKind::Deletion
| ConfiguredRefspecKind::AllMatching
) {
bytes.push(b':');
}
if let Some(destination) = &self.destination {
bytes.extend_from_slice(destination);
}
bytes
}
}
fn normalize_source(bytes: &[u8]) -> &[u8] {
if bytes == b"@" { b"HEAD" } else { bytes }
}
fn validate_name(bytes: &[u8]) -> Result<bool, ConfiguredRefspecError> {
if bytes == b"@" {
return Err(ConfiguredRefspecError::Name);
}
let wildcards = bytes.iter().filter(|b| **b == b'*').count();
if wildcards > 1 {
return Err(ConfiguredRefspecError::Pattern);
}
let mut full = b"refs/".to_vec();
full.extend(bytes.iter().map(|b| if *b == b'*' { b'x' } else { *b }));
RefName::new(full).map_err(|_| ConfiguredRefspecError::Name)?;
Ok(wildcards == 1)
}
#[cfg(test)]
mod tests {
use ConfiguredRefspecKind::{AllMatching, Deletion, Exclusion, Mapping, Selection};
use Direction::{Fetch, Push};
use rstest::rstest;
use super::*;
#[rstest]
#[case::fetch_empty(Fetch, b"", b"HEAD", Selection, Some(b"HEAD".as_slice()), None, false)]
#[case::fetch_colon(Fetch, b":", b"HEAD", Selection, Some(b"HEAD".as_slice()), None, false)]
#[case::fetch_plus(Fetch, b"+", b"HEAD", Selection, Some(b"HEAD".as_slice()), None, false)]
#[case::fetch_short(Fetch, b"main", b"main", Selection, Some(b"main".as_slice()), None, false)]
#[case::fetch_empty_destination(Fetch, b"+main:", b"main", Selection, Some(b"main".as_slice()), None, false)]
#[case::fetch_default_mapping(Fetch, b"+:origin/main", b"+HEAD:origin/main", Mapping, Some(b"HEAD".as_slice()), Some(b"origin/main".as_slice()), true)]
#[case::fetch_at(Fetch, b"@", b"HEAD", Selection, Some(b"HEAD".as_slice()), None, false)]
#[case::push_at(Push, b"@", b"HEAD:HEAD", Mapping, Some(b"HEAD".as_slice()), Some(b"HEAD".as_slice()), false)]
#[case::push_short(Push, b"main", b"main:main", Mapping, Some(b"main".as_slice()), Some(b"main".as_slice()), false)]
#[case::push_matching(Push, b":", b":", AllMatching, None, None, false)]
#[case::push_matching_force(Push, b"+", b"+:", AllMatching, None, None, true)]
#[case::push_delete(Push, b"+:main", b":main", Deletion, None, Some(b"main".as_slice()), false)]
#[case::push_revision(Push, b"+HEAD~1:main", b"+HEAD~1:main", Mapping, Some(b"HEAD~1".as_slice()), Some(b"main".as_slice()), true)]
#[case::push_colon_revision(Push, b"HEAD:file:main", b"HEAD:file:main", Mapping, Some(b"HEAD:file".as_slice()), Some(b"main".as_slice()), false)]
#[case::fetch_negative(Fetch, b"^main", b"^main", Exclusion, Some(b"main".as_slice()), None, false)]
#[case::push_negative(Push, b"^main", b"^main", Exclusion, Some(b"main".as_slice()), None, false)]
#[case::negative_plus(Fetch, b"^+refs/a", b"^+refs/a", Exclusion, Some(b"+refs/a".as_slice()), None, false)]
#[case::negative_at(Push, b"^@", b"^HEAD", Exclusion, Some(b"HEAD".as_slice()), None, false)]
fn describes_and_normalizes(
#[case] direction: Direction,
#[case] input: &[u8],
#[case] canonical: &[u8],
#[case] kind: ConfiguredRefspecKind,
#[case] source: Option<&[u8]>,
#[case] destination: Option<&[u8]>,
#[case] force: bool,
) {
let spec = ConfiguredRefspec::parse(direction, input).unwrap();
assert_eq!(spec.kind(), kind);
assert_eq!(spec.source(), source);
assert_eq!(spec.destination(), destination);
assert_eq!(spec.force(), force);
assert_eq!(spec.to_bytes(), canonical);
assert_eq!(
ConfiguredRefspec::parse(direction, canonical).unwrap(),
spec
);
}
#[rstest]
#[case::mapping(b"+refs/a:refs/b")]
#[case::partial_pattern(b"a*:b*")]
#[case::whole_pattern(b"*:refs/*")]
#[case::negative_pattern(b"^refs/*")]
#[case::negative_abbreviated_hash(b"^1111111")]
#[case::negative_nonhex_40(b"^z111111111111111111111111111111111111111")]
#[case::sha1_mapping(b"+1111111111111111111111111111111111111111:refs/b")]
#[case::sha256_mapping(
b"1111111111111111111111111111111111111111111111111111111111111111:refs/b"
)]
#[case::non_utf8(b"+refs/\xff:refs/\xfe")]
#[case::negative_non_utf8(b"^refs/\xff")]
fn roundtrips_both_directions(
#[values(Fetch, Push)] direction: Direction,
#[case] input: &[u8],
) {
let spec = ConfiguredRefspec::parse(direction, input).unwrap();
assert_eq!(spec.to_bytes(), input);
assert_eq!(
ConfiguredRefspec::parse(direction, &spec.to_bytes()).unwrap(),
spec
);
}
#[rstest]
#[case::empty_negative(b"^")]
#[case::negative_destination(b"^main:other")]
#[case::negative_empty_destination(b"^main:")]
#[case::negative_sha1(b"^1111111111111111111111111111111111111111")]
#[case::negative_sha256(b"^1111111111111111111111111111111111111111111111111111111111111111")]
#[case::forced_negative(b"+^refs/a")]
#[case::multiple_patterns(b"a**:b**")]
#[case::unbalanced_source(b"a*:b")]
#[case::unbalanced_destination(b"a:b*")]
#[case::multiple_negative_patterns(b"^a**")]
#[case::invalid_pattern_name(b"a~*:b*")]
#[case::destination_at(b"a:@")]
#[case::destination_control(b"a:b\0")]
#[case::destination_dotdot(b"a:b..c")]
#[case::destination_lock(b"a:b.lock")]
#[case::destination_slashes(b"a:b//c")]
#[case::tag_shorthand(b"tag v1")]
fn rejects_malformed_both_directions(
#[values(Fetch, Push)] direction: Direction,
#[case] input: &[u8],
) {
assert!(ConfiguredRefspec::parse(direction, input).is_err());
}
#[rstest]
#[case::selection_pattern(Fetch, b"refs/*")]
#[case::selection_pattern_empty_destination(Fetch, b"refs/*:")]
#[case::fetch_revision(Fetch, b"HEAD~1:main")]
#[case::fetch_multiple_colons(Fetch, b"a:b:c")]
#[case::push_empty(Push, b"")]
#[case::push_empty_destination(Push, b"main:")]
#[case::push_revision_without_destination(Push, b"HEAD~1")]
fn rejects_direction_specific_syntax(#[case] direction: Direction, #[case] input: &[u8]) {
assert!(ConfiguredRefspec::parse(direction, input).is_err());
}
#[rstest]
#[case::sha1(b"1111111111111111111111111111111111111111")]
#[case::sha256(b"1111111111111111111111111111111111111111111111111111111111111111")]
fn fetch_selects_positive_object_ids(#[case] source: &[u8]) {
let spec = ConfiguredRefspec::parse(Fetch, source).unwrap();
assert_eq!(spec.kind(), Selection);
assert_eq!(spec.source(), Some(source));
assert_eq!(spec.to_bytes(), source);
}
#[test]
fn push_implicit_pattern_maps_to_itself() {
let spec = ConfiguredRefspec::parse(Push, b"+refs/*").unwrap();
assert_eq!(spec.to_bytes(), b"+refs/*:refs/*");
assert_eq!(
ConfiguredRefspec::parse(Push, &spec.to_bytes()).unwrap(),
spec
);
}
#[test]
fn parsing_occurrences_does_not_reset_sort_or_deduplicate() {
let inputs: &[&[u8]] = &[b"z", b"", b"a", b"z"];
let specs = inputs
.iter()
.map(|s| ConfiguredRefspec::parse(Fetch, s).unwrap().to_bytes())
.collect::<Vec<_>>();
assert_eq!(
specs,
[
b"z".to_vec(),
b"HEAD".to_vec(),
b"a".to_vec(),
b"z".to_vec()
]
);
}
}