Skip to main content

gix_refspec/
parse.rs

1/// The error returned by the [`parse()`][crate::parse()] function.
2#[derive(Debug, thiserror::Error)]
3#[expect(missing_docs)]
4pub enum Error {
5    #[error("Empty refspecs are invalid")]
6    Empty,
7    #[error("Negative refspecs cannot have destinations as they exclude sources")]
8    NegativeWithDestination,
9    #[error("Negative specs must not be empty")]
10    NegativeEmpty,
11    #[error("Negative specs must not be object hashes")]
12    NegativeObjectHash,
13    /// Retained for compatibility; partial negative ref names are accepted and this is no longer returned.
14    #[error("Negative specs must be full ref names, starting with \"refs/\"")]
15    NegativePartialName,
16    /// Retained for compatibility; negative ref patterns containing one `*` are accepted and this is no longer returned.
17    #[error("Negative glob patterns are not allowed")]
18    NegativeGlobPattern,
19    /// Retained for compatibility; invalid fetch destinations are reported as [`Error::ReferenceName`] instead.
20    #[error("Fetch destinations must be ref-names, like 'HEAD:refs/heads/branch'")]
21    InvalidFetchDestination,
22    #[error("Cannot push into an empty destination")]
23    PushToEmpty,
24    #[error("refspec patterns may only contain a single '*' character, found {pattern:?}")]
25    PatternUnsupported { pattern: bstr::BString },
26    #[error("Both sides of a two-sided specification need a pattern, like 'a/*:b/*'")]
27    PatternUnbalanced,
28    #[error(transparent)]
29    ReferenceName(#[from] gix_validate::reference::name::Error),
30}
31
32/// Define how the parsed refspec should be used.
33#[derive(PartialOrd, Ord, PartialEq, Eq, Copy, Clone, Hash, Debug)]
34pub enum Operation {
35    /// The `src` side is local and the `dst` side is remote.
36    Push,
37    /// The `src` side is remote and the `dst` side is local.
38    Fetch,
39}
40
41pub(crate) mod function {
42    use crate::{
43        RefSpecRef,
44        parse::{Error, Operation},
45        types::Mode,
46    };
47    use bstr::{BStr, ByteSlice};
48
49    /// Parse `spec` for use in `operation` and return it if it is valid.
50    pub fn parse(mut spec: &BStr, operation: Operation) -> Result<RefSpecRef<'_>, Error> {
51        fn fetch_head_only(mode: Mode) -> RefSpecRef<'static> {
52            RefSpecRef {
53                mode,
54                op: Operation::Fetch,
55                src: Some("HEAD".into()),
56                dst: None,
57            }
58        }
59
60        let mode = match spec.first() {
61            Some(&b'^') => {
62                spec = &spec[1..];
63                Mode::Negative
64            }
65            Some(&b'+') => {
66                spec = &spec[1..];
67                Mode::Force
68            }
69            Some(_) => Mode::Normal,
70            None => {
71                return match operation {
72                    Operation::Push => Err(Error::Empty),
73                    Operation::Fetch => Ok(fetch_head_only(Mode::Normal)),
74                };
75            }
76        };
77
78        // Split on the last colon like `strrchr()` in Git's `parse_refspec()` does, so that a
79        // push source may itself contain one - `:/message` and `<rev>:<path>` are both valid
80        // revisions. With a single colon this is the same position as the first one.
81        let (mut src, dst) = match spec.rfind_byte(b':') {
82            Some(pos) => {
83                if mode == Mode::Negative {
84                    return Err(Error::NegativeWithDestination);
85                }
86
87                let (src, dst) = spec.split_at(pos);
88                let dst = &dst[1..];
89                let src = (!src.is_empty()).then(|| src.as_bstr());
90                let dst = (!dst.is_empty()).then(|| dst.as_bstr());
91                match (src, dst) {
92                    (None, None) => match operation {
93                        Operation::Push => (None, None),
94                        Operation::Fetch => (Some("HEAD".into()), None),
95                    },
96                    (None, Some(dst)) => match operation {
97                        Operation::Push => (None, Some(dst)),
98                        Operation::Fetch => (Some("HEAD".into()), Some(dst)),
99                    },
100                    (Some(src), None) => match operation {
101                        Operation::Push => return Err(Error::PushToEmpty),
102                        Operation::Fetch => (Some(src), None),
103                    },
104                    (Some(src), Some(dst)) => (Some(src), Some(dst)),
105                }
106            }
107            None => {
108                let src = (!spec.is_empty()).then_some(spec);
109                if Operation::Fetch == operation && mode != Mode::Negative && src.is_none() {
110                    return Ok(fetch_head_only(mode));
111                } else {
112                    (src, None)
113                }
114            }
115        };
116
117        if let Some(spec) = src.as_mut() {
118            if *spec == "@" {
119                *spec = "HEAD".into();
120            }
121        }
122        let (src, src_had_pattern) = validated(src, operation == Operation::Push && dst.is_some())?;
123        let (dst, dst_had_pattern) = validated(dst, false)?;
124        if mode != Mode::Negative
125            && src_had_pattern != dst_had_pattern
126            && !(operation == Operation::Push && dst.is_none())
127        {
128            return Err(Error::PatternUnbalanced);
129        }
130
131        if mode == Mode::Negative {
132            match src {
133                Some(spec) => {
134                    if looks_like_object_hash(spec) {
135                        return Err(Error::NegativeObjectHash);
136                    }
137                }
138                None => return Err(Error::NegativeEmpty),
139            }
140        }
141
142        Ok(RefSpecRef {
143            op: operation,
144            mode,
145            src,
146            dst,
147        })
148    }
149
150    fn looks_like_object_hash(spec: &BStr) -> bool {
151        spec.len() >= gix_hash::Kind::shortest().len_in_hex() && spec.iter().all(u8::is_ascii_hexdigit)
152    }
153
154    fn validate_partial_name_with_single_glob(spec: &BStr) -> Result<(), Error> {
155        let mut buf = smallvec::SmallVec::<[u8; 256]>::with_capacity(spec.len());
156        buf.extend_from_slice(spec);
157        let glob_pos = buf.find_byte(b'*').expect("glob present");
158        buf[glob_pos] = b'a';
159        gix_validate::reference::name_partial(buf.as_bstr())?;
160        Ok(())
161    }
162
163    /// Validate `spec`, and return it along with whether it holds a glob.
164    ///
165    /// `any_name` skips the check entirely, for the one side Git leaves unchecked.
166    fn validated(spec: Option<&BStr>, any_name: bool) -> Result<(Option<&BStr>, bool), Error> {
167        match spec {
168            Some(spec) => {
169                let glob_count = spec.iter().filter(|b| **b == b'*').take(2).count();
170                if glob_count > 1 {
171                    return Err(Error::PatternUnsupported { pattern: spec.into() });
172                }
173                let has_globs = glob_count > 0;
174                if has_globs {
175                    validate_partial_name_with_single_glob(spec)?;
176                } else if !any_name {
177                    gix_validate::reference::name_partial(spec)?;
178                }
179                Ok((Some(spec), has_globs))
180            }
181            None => Ok((None, false)),
182        }
183    }
184}