Skip to main content

grit_lib/
ref_exclusions.rs

1//! Reference exclusion rules for `rev-list` / `rev-parse` (`--exclude`, `--exclude-hidden`).
2//!
3//! Mirrors Git's `ref_exclusions` / `parse_hide_refs_config` / `ref_is_hidden` in `revision.c`
4//! and `refs.c`.
5
6use crate::config::ConfigSet;
7use crate::wildmatch::{wildmatch, WM_PATHNAME};
8
9/// One `transfer.hideRefs` / `<section>.hideRefs` prefix rule (after normalization).
10#[derive(Debug, Clone)]
11struct HideRefRule {
12    /// Pattern without leading `!` or `^`.
13    pattern: String,
14    /// If true, the rule negates a previous hide (Git `!` prefix).
15    negated: bool,
16    /// If true, match against the full ref name (`^` prefix in config).
17    full_ref: bool,
18}
19
20/// Patterns that exclude refs from `--all` / glob expansion, including hidden-ref config.
21#[derive(Debug, Clone, Default)]
22pub struct RefExclusions {
23    /// `wildmatch` patterns from `--exclude=<pat>` (full ref names).
24    excluded_refs: Vec<String>,
25    /// Rules from config when `--exclude-hidden=<section>` is active.
26    hidden_rules: Vec<HideRefRule>,
27    /// Set after `--exclude-hidden=` is parsed; cleared by [`RefExclusions::clear`].
28    /// Used to reject a second `--exclude-hidden` before the next pseudo-ref clears state.
29    pub hidden_configured: bool,
30}
31
32impl RefExclusions {
33    /// Reset exclusions after `--all` / `--glob` / `--branches` / … (matches Git `clear_ref_exclusions`).
34    pub fn clear(&mut self) {
35        self.excluded_refs.clear();
36        self.hidden_rules.clear();
37        self.hidden_configured = false;
38    }
39
40    /// Append a `--exclude=<pattern>` entry (Git wildmatch on the ref name).
41    pub fn add_excluded_ref(&mut self, pattern: impl Into<String>) {
42        self.excluded_refs.push(pattern.into());
43    }
44
45    /// Load `transfer.hideRefs` and `<section>.hideRefs` into this set.
46    ///
47    /// `section` must be one of `fetch`, `receive`, or `uploadpack`.
48    pub fn load_hidden_refs_from_config(&mut self, config: &ConfigSet, section: &str) {
49        self.hidden_configured = true;
50        let section_key = format!("{section}.hiderefs");
51        for e in config.entries() {
52            if e.key == "transfer.hiderefs" || e.key == section_key {
53                if let Some(v) = e.value.as_deref() {
54                    self.hidden_rules.push(parse_hide_refs_value(v));
55                }
56            }
57        }
58    }
59
60    /// Whether this ref should be omitted from ref listing (exclude + hidden rules).
61    ///
62    /// - `stripped_name` — ref name with `GIT_NAMESPACE` prefix removed, when applicable.
63    /// - `full_name` — storage path of the ref (e.g. `refs/heads/main`).
64    pub fn ref_excluded(&self, stripped_name: Option<&str>, full_name: &str) -> bool {
65        for pat in &self.excluded_refs {
66            if wildmatch(pat.as_bytes(), full_name.as_bytes(), WM_PATHNAME) {
67                return true;
68            }
69        }
70        ref_is_hidden(stripped_name, full_name, &self.hidden_rules)
71    }
72}
73
74fn trim_trailing_slashes(mut s: String) -> String {
75    while s.ends_with('/') {
76        s.pop();
77    }
78    s
79}
80
81fn parse_hide_refs_value(raw: &str) -> HideRefRule {
82    let mut rest = raw;
83    let mut negated = false;
84    if let Some(stripped) = rest.strip_prefix('!') {
85        negated = true;
86        rest = stripped;
87    }
88    let mut full_ref = false;
89    if let Some(stripped) = rest.strip_prefix('^') {
90        full_ref = true;
91        rest = stripped;
92    }
93    HideRefRule {
94        pattern: trim_trailing_slashes(rest.to_owned()),
95        negated,
96        full_ref,
97    }
98}
99
100fn ref_is_hidden(stripped_name: Option<&str>, full_name: &str, rules: &[HideRefRule]) -> bool {
101    for rule in rules.iter().rev() {
102        let subject = if rule.full_ref {
103            full_name
104        } else {
105            match stripped_name {
106                Some(s) => s,
107                None => continue,
108            }
109        };
110        if subject.is_empty() {
111            continue;
112        }
113        let pat = rule.pattern.as_str();
114        if pat.is_empty() {
115            continue;
116        }
117        if skip_prefix_git(subject, pat)
118            .is_some_and(|tail| tail.is_empty() || tail.starts_with('/'))
119        {
120            return !rule.negated;
121        }
122    }
123    false
124}
125
126/// Git `skip_prefix` semantics: `subject` must begin with `prefix` byte-for-byte; returns the tail.
127fn skip_prefix_git<'a>(subject: &'a str, prefix: &str) -> Option<&'a str> {
128    let b = subject.as_bytes();
129    let p = prefix.as_bytes();
130    if p.is_empty() {
131        return Some(subject);
132    }
133    if b.len() < p.len() {
134        return None;
135    }
136    if &b[..p.len()] == p {
137        subject.get(p.len()..)
138    } else {
139        None
140    }
141}
142
143/// `GIT_NAMESPACE` value (e.g. `namespace` / `a/b`) expanded to the `refs/namespaces/.../`
144/// prefix, or empty string when unset.
145pub fn git_namespace_prefix() -> String {
146    let raw = std::env::var("GIT_NAMESPACE").unwrap_or_default();
147    if raw.is_empty() {
148        return String::new();
149    }
150    let mut out = String::new();
151    for comp in raw.split('/') {
152        if comp.is_empty() {
153            continue;
154        }
155        out.push_str("refs/namespaces/");
156        out.push_str(comp);
157        out.push('/');
158    }
159    while out.ends_with('/') {
160        out.pop();
161    }
162    if !out.is_empty() {
163        out.push('/');
164    }
165    out
166}
167
168/// Strip a leading namespace prefix from `refname`, returning `None` when not under the namespace.
169pub fn strip_git_namespace<'a>(refname: &'a str, namespace_prefix: &str) -> Option<&'a str> {
170    if namespace_prefix.is_empty() {
171        return Some(refname);
172    }
173    refname.strip_prefix(namespace_prefix)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn hide_refs_prefix_match() {
182        let rules = vec![parse_hide_refs_value("refs/hidden/")];
183        assert!(ref_is_hidden(
184            Some("refs/hidden/foo"),
185            "refs/hidden/foo",
186            &rules
187        ));
188        assert!(!ref_is_hidden(
189            Some("refs/heads/main"),
190            "refs/heads/main",
191            &rules
192        ));
193    }
194
195    #[test]
196    fn hide_refs_negation() {
197        let rules = vec![
198            parse_hide_refs_value("refs/foo/"),
199            parse_hide_refs_value("!refs/foo/bar"),
200        ];
201        assert!(!ref_is_hidden(Some("refs/foo/bar"), "refs/foo/bar", &rules));
202        assert!(ref_is_hidden(Some("refs/foo/baz"), "refs/foo/baz", &rules));
203    }
204}