Skip to main content

grit_lib/
refspec.rs

1//! Refspec parsing and validation — a port of `git/refspec.c`.
2//!
3//! A *refspec* describes how references map between a local and a remote
4//! repository.  It has the general form `[+|^]<src>[:<dst>]`.  This module
5//! parses a single refspec string and validates it according to the same rules
6//! as Git's `parse_refspec()`, distinguishing fetch refspecs from push
7//! refspecs (the two have slightly different validity rules).
8//!
9//! The primary entry points are [`parse_fetch_refspec`] and
10//! [`parse_push_refspec`], which return [`RefspecItem`] on success or
11//! [`RefspecError::Invalid`] when the refspec is malformed.  Callers that only
12//! care about validity (for example loading `remote.<name>.fetch` /
13//! `remote.<name>.push` config) can use [`valid_fetch_refspec`] and
14//! [`valid_push_refspec`].
15
16use crate::check_ref_format::{check_refname_format, RefNameOptions};
17
18/// A parsed refspec item.
19///
20/// Holds a parsed refspec, capturing the
21/// modifier flags and the source/destination sides of the mapping.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct RefspecItem {
24    /// The `+` modifier was present (force / non-fast-forward update).
25    pub force: bool,
26    /// The `^` modifier was present (negative refspec — exclusion).
27    pub negative: bool,
28    /// This refspec is the bare `:` (or `+:`) push refspec for matching refs.
29    pub matching: bool,
30    /// The refspec uses a `*` glob pattern.
31    pub pattern: bool,
32    /// The source side is an exact (full-length hex) object id.
33    pub exact_sha1: bool,
34    /// The source side (`<src>`), or `None` when absent.
35    pub src: Option<String>,
36    /// The destination side (`<dst>`), or `None` when no `:` was present.
37    pub dst: Option<String>,
38}
39
40/// Error returned when a refspec cannot be parsed.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum RefspecError {
43    /// The refspec string is invalid.
44    Invalid(String),
45}
46
47impl std::fmt::Display for RefspecError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            RefspecError::Invalid(s) => write!(f, "invalid refspec '{s}'"),
51        }
52    }
53}
54
55impl std::error::Error for RefspecError {}
56
57/// Length of a full SHA-1 hex object id.
58const SHA1_HEXSZ: usize = 40;
59
60/// Returns `true` when `s` is a string of exactly `SHA1_HEXSZ` hex digits.
61fn is_exact_sha1_hex(s: &str) -> bool {
62    s.len() == SHA1_HEXSZ && s.bytes().all(|b| b.is_ascii_hexdigit())
63}
64
65/// Validate `name` as a refname using the same flags as Git's
66/// `check_refname_format` with `REFNAME_ALLOW_ONELEVEL` and, when `is_glob` is
67/// set, `REFNAME_REFSPEC_PATTERN`.
68///
69/// Returns `true` when the name is well-formed.
70fn refname_ok(name: &str, is_glob: bool) -> bool {
71    let opts = RefNameOptions {
72        allow_onelevel: true,
73        refspec_pattern: is_glob,
74        normalize: false,
75    };
76    check_refname_format(name, &opts).is_ok()
77}
78
79/// Parse a single refspec.  `fetch` selects fetch (`true`) vs push (`false`)
80/// validity rules.
81///
82/// This is a direct port of `parse_refspec()` in `git/refspec.c`.
83fn parse_refspec(refspec: &str, fetch: bool) -> Result<RefspecItem, RefspecError> {
84    let bytes = refspec.as_bytes();
85    let invalid = || RefspecError::Invalid(refspec.to_owned());
86
87    let mut item = RefspecItem::default();
88    let mut is_glob = false;
89
90    // Leading modifier: '+' (force) or '^' (negative).
91    let mut lhs_start = 0usize;
92    if let Some(&first) = bytes.first() {
93        if first == b'+' {
94            item.force = true;
95            lhs_start = 1;
96        } else if first == b'^' {
97            item.negative = true;
98            lhs_start = 1;
99        }
100    }
101
102    let lhs = &refspec[lhs_start..];
103
104    // rhs points to the last ':' within lhs (strrchr).
105    let colon_pos = lhs.rfind(':');
106
107    // Negative refspecs only have one side.
108    if item.negative && colon_pos.is_some() {
109        return Err(invalid());
110    }
111
112    // Special case ":" (or "+:") as a push refspec for matching refs.
113    // In C: rhs == lhs && rhs[1] == '\0' — i.e. the ':' is the first char of
114    // lhs and is the only char.
115    if !fetch && colon_pos == Some(0) && lhs.len() == 1 {
116        item.matching = true;
117        return Ok(item);
118    }
119
120    // Compute src (lhs) and dst (rhs) substrings.
121    let (lhs_str, rhs_opt): (&str, Option<&str>) = match colon_pos {
122        Some(pos) => (&lhs[..pos], Some(&lhs[pos + 1..])),
123        None => (lhs, None),
124    };
125
126    if let Some(rhs) = rhs_opt {
127        let rlen = rhs.len();
128        is_glob = rlen >= 1 && rhs.contains('*');
129        item.dst = Some(rhs.to_owned());
130    } else {
131        item.dst = None;
132    }
133
134    let llen = lhs_str.len();
135    if llen >= 1 && lhs_str.contains('*') {
136        // LHS has a '*'.
137        let rhs_empty = rhs_opt.is_some_and(|r| r.is_empty());
138        if (rhs_opt.is_some() && !is_glob && !rhs_empty)
139            || (rhs_opt.is_none() && !item.negative && fetch)
140        {
141            return Err(invalid());
142        }
143        is_glob = true;
144    } else if rhs_opt.is_some() && is_glob {
145        // RHS is a glob but LHS is not.
146        return Err(invalid());
147    }
148
149    item.pattern = is_glob;
150    if llen == 1 && lhs_str == "@" {
151        item.src = Some("HEAD".to_owned());
152    } else {
153        item.src = Some(lhs_str.to_owned());
154    }
155    let src = item.src.as_deref().unwrap_or("");
156
157    if item.negative {
158        // Negative refspecs only have a LHS.
159        if src.is_empty() {
160            return Err(invalid()); // must not be empty
161        } else if is_exact_sha1_hex(src) {
162            return Err(invalid()); // cannot be exact sha1
163        } else if refname_ok(src, is_glob) {
164            // valid looking ref is ok
165        } else {
166            return Err(invalid());
167        }
168        return Ok(item);
169    }
170
171    if fetch {
172        // LHS
173        if src.is_empty() {
174            // empty is ok; it means "HEAD"
175        } else if is_exact_sha1_hex(src) {
176            item.exact_sha1 = true; // ok
177        } else if refname_ok(src, is_glob) {
178            // valid looking ref is ok
179        } else {
180            return Err(invalid());
181        }
182        // RHS
183        match item.dst.as_deref() {
184            None | Some("") => {} // missing/empty: do not store
185            Some(dst) => {
186                if !refname_ok(dst, is_glob) {
187                    return Err(invalid());
188                }
189            }
190        }
191    } else {
192        // push
193        // LHS
194        if src.is_empty() {
195            // empty is ok
196        } else if is_glob {
197            if !refname_ok(src, is_glob) {
198                return Err(invalid());
199            }
200        } else {
201            // anything goes, for now
202        }
203        // RHS
204        match item.dst.as_deref() {
205            None => {
206                // missing is allowed, but LHS then must be a valid looking ref.
207                if !refname_ok(src, is_glob) {
208                    return Err(invalid());
209                }
210            }
211            Some("") => {
212                // empty is not allowed.
213                return Err(invalid());
214            }
215            Some(dst) => {
216                if !refname_ok(dst, is_glob) {
217                    return Err(invalid());
218                }
219            }
220        }
221    }
222
223    Ok(item)
224}
225
226/// Parse a fetch refspec, returning the parsed item or an error.
227pub fn parse_fetch_refspec(refspec: &str) -> Result<RefspecItem, RefspecError> {
228    parse_refspec(refspec, true)
229}
230
231/// Parse a push refspec, returning the parsed item or an error.
232pub fn parse_push_refspec(refspec: &str) -> Result<RefspecItem, RefspecError> {
233    parse_refspec(refspec, false)
234}
235
236/// Returns `true` when `refspec` is a valid fetch refspec.
237pub fn valid_fetch_refspec(refspec: &str) -> bool {
238    parse_refspec(refspec, true).is_ok()
239}
240
241/// Returns `true` when `refspec` is a valid push refspec.
242pub fn valid_push_refspec(refspec: &str) -> bool {
243    parse_refspec(refspec, false).is_ok()
244}
245
246/// When a refspec side contains exactly one `*`, map `from_ref` through that glob into `into_template`.
247fn translate_single_star(
248    from_template: &str,
249    into_template: &str,
250    from_ref: &str,
251) -> Option<String> {
252    let (from_pre, from_post) = split_one_star(from_template)?;
253    let (into_pre, into_post) = split_one_star(into_template)?;
254    if !from_ref.starts_with(from_pre) || !from_ref.ends_with(from_post) {
255        return None;
256    }
257    let middle_len = from_ref
258        .len()
259        .checked_sub(from_pre.len() + from_post.len())?;
260    let middle = &from_ref[from_pre.len()..from_pre.len() + middle_len];
261    Some(format!("{into_pre}{middle}{into_post}"))
262}
263
264fn split_one_star(template: &str) -> Option<(&str, &str)> {
265    let star = template.find('*')?;
266    if template[star + 1..].contains('*') {
267        return None;
268    }
269    Some((&template[..star], &template[star + 1..]))
270}
271
272fn refname_matches_template(template: &str, is_pattern: bool, refname: &str) -> bool {
273    if is_pattern {
274        translate_single_star(template, template, refname).is_some()
275    } else {
276        template == refname
277    }
278}
279
280/// Binding from a local ref to the upstream ref name implied by fetch refspecs.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub(crate) struct FetchLocalBinding {
283    pub force: bool,
284    pub upstream_ref: String,
285}
286
287fn positive_local_binding(spec: &RefspecItem, local_ref: &str) -> Option<FetchLocalBinding> {
288    if spec.negative {
289        return None;
290    }
291    let dst = spec.dst.as_deref()?;
292    let src = spec.src.as_deref().unwrap_or("");
293    let upstream_ref = if spec.pattern {
294        translate_single_star(dst, src, local_ref)?
295    } else if dst == local_ref {
296        src.to_owned()
297    } else {
298        return None;
299    };
300    Some(FetchLocalBinding {
301        force: spec.force,
302        upstream_ref,
303    })
304}
305
306fn excluded_by_negative_specs(specs: &[RefspecItem], upstream_ref: &str) -> bool {
307    specs.iter().any(|spec| {
308        spec.negative
309            && spec
310                .src
311                .as_deref()
312                .is_some_and(|src| refname_matches_template(src, spec.pattern, upstream_ref))
313    })
314}
315
316/// Whether `local_ref` is a destination of these fetch refspecs and not ruled out by `^` lines.
317#[must_use]
318pub(crate) fn fetch_specs_bind_local_ref(
319    specs: &[RefspecItem],
320    local_ref: &str,
321) -> Option<FetchLocalBinding> {
322    let binding = specs
323        .iter()
324        .find_map(|spec| positive_local_binding(spec, local_ref))?;
325    if excluded_by_negative_specs(specs, &binding.upstream_ref) {
326        return None;
327    }
328    Some(binding)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    // Mirrors the cases in git's t5511-refspec.sh.
336
337    fn fetch_valid(s: &str) {
338        assert!(valid_fetch_refspec(s), "expected fetch '{s}' to be valid");
339    }
340    fn fetch_invalid(s: &str) {
341        assert!(
342            !valid_fetch_refspec(s),
343            "expected fetch '{s}' to be invalid"
344        );
345    }
346    fn push_valid(s: &str) {
347        assert!(valid_push_refspec(s), "expected push '{s}' to be valid");
348    }
349    fn push_invalid(s: &str) {
350        assert!(!valid_push_refspec(s), "expected push '{s}' to be invalid");
351    }
352
353    #[test]
354    fn empty_and_colon() {
355        push_invalid("");
356        push_valid(":");
357        push_invalid("::");
358        push_valid("+:");
359        fetch_valid("");
360        fetch_valid(":");
361        fetch_invalid("::");
362    }
363
364    #[test]
365    fn glob_balance() {
366        push_valid("refs/heads/*:refs/remotes/frotz/*");
367        push_invalid("refs/heads/*:refs/remotes/frotz");
368        push_invalid("refs/heads:refs/remotes/frotz/*");
369        push_valid("refs/heads/main:refs/remotes/frotz/xyzzy");
370
371        fetch_valid("refs/heads/*:refs/remotes/frotz/*");
372        fetch_valid("refs/heads/*:");
373        fetch_invalid("refs/heads/*:refs/remotes/frotz");
374        fetch_invalid("refs/heads:refs/remotes/frotz/*");
375        fetch_valid("refs/heads/main:refs/remotes/frotz/xyzzy");
376        fetch_invalid("refs/heads/main::refs/remotes/frotz/xyzzy");
377        fetch_invalid("refs/heads/maste :refs/remotes/frotz/xyzzy");
378    }
379
380    #[test]
381    fn rev_expressions() {
382        push_valid("main~1:refs/remotes/frotz/backup");
383        fetch_invalid("main~1:refs/remotes/frotz/backup");
384        push_valid("HEAD~4:refs/remotes/frotz/new");
385        fetch_invalid("HEAD~4:refs/remotes/frotz/new");
386    }
387
388    #[test]
389    fn bare_head_and_at() {
390        push_valid("HEAD");
391        fetch_valid("HEAD");
392        push_valid("@");
393        fetch_valid("@");
394        push_invalid("refs/heads/ nitfol");
395        fetch_invalid("refs/heads/ nitfol");
396    }
397
398    #[test]
399    fn head_colon() {
400        push_invalid("HEAD:");
401        fetch_valid("HEAD:");
402        push_invalid("refs/heads/ nitfol:");
403        fetch_invalid("refs/heads/ nitfol:");
404    }
405
406    #[test]
407    fn delete_specs() {
408        push_valid(":refs/remotes/frotz/deleteme");
409        fetch_valid(":refs/remotes/frotz/HEAD-to-me");
410        push_invalid(":refs/remotes/frotz/delete me");
411        fetch_invalid(":refs/remotes/frotz/HEAD to me");
412    }
413
414    #[test]
415    fn star_placements() {
416        fetch_valid("refs/heads/*/for-linus:refs/remotes/mine/*-blah");
417        push_valid("refs/heads/*/for-linus:refs/remotes/mine/*-blah");
418        fetch_valid("refs/heads*/for-linus:refs/remotes/mine/*");
419        push_valid("refs/heads*/for-linus:refs/remotes/mine/*");
420        fetch_invalid("refs/heads/*/*/for-linus:refs/remotes/mine/*");
421        push_invalid("refs/heads/*/*/for-linus:refs/remotes/mine/*");
422        fetch_invalid("refs/heads/*g*/for-linus:refs/remotes/mine/*");
423        push_invalid("refs/heads/*g*/for-linus:refs/remotes/mine/*");
424        fetch_valid("refs/heads/*/for-linus:refs/remotes/mine/*");
425        push_valid("refs/heads/*/for-linus:refs/remotes/mine/*");
426    }
427
428    #[test]
429    fn utf8_and_tab() {
430        fetch_valid("refs/heads/\u{00C4}");
431        fetch_invalid("refs/heads/\ttab");
432    }
433
434    #[test]
435    fn fetch_specs_bind_local_ref_maps_tracking_branch() {
436        let specs = vec![parse_fetch_refspec("+refs/heads/*:refs/remotes/origin/*").unwrap()];
437        let binding = fetch_specs_bind_local_ref(&specs, "refs/remotes/origin/main").unwrap();
438        assert_eq!(binding.upstream_ref, "refs/heads/main");
439    }
440
441    #[test]
442    fn fetch_specs_bind_local_ref_honors_negative_line() {
443        let specs = vec![
444            parse_fetch_refspec("+refs/heads/*:refs/remotes/origin/*").unwrap(),
445            parse_fetch_refspec("^refs/heads/private").unwrap(),
446        ];
447        assert!(fetch_specs_bind_local_ref(&specs, "refs/remotes/origin/main").is_some());
448        assert!(fetch_specs_bind_local_ref(&specs, "refs/remotes/origin/private").is_none());
449    }
450}