Skip to main content

repo/
git_ref_name.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Typed classification for fully-qualified Git ref names.
3
4/// Sentinel remote name for refs owned by the local repository.
5///
6/// Local branches, tags, and notes use this owner when represented in the
7/// bridge parser. A user remote named `git` would collide with that sentinel.
8pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git";
9
10/// The content namespaces Heddle intentionally mirrors as named Git refs.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum GitRefContentNamespace {
13    /// `refs/heads/<name>`.
14    Branch,
15    /// `refs/tags/<name>`.
16    Tag,
17    /// `refs/notes/<name>`.
18    Note,
19}
20
21/// The wire-level kind used for hosted Git ref updates.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum GitRefKind {
24    /// `refs/heads/<name>` or `refs/remotes/<remote>/<name>`.
25    Branch,
26    /// `refs/tags/<name>`.
27    Tag,
28    /// `refs/notes/<name>`.
29    Note,
30    /// Any non-local-only ref outside the known content namespaces.
31    Other,
32}
33
34/// The namespace family a full ref name belongs to.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum GitRefNamespace {
37    /// `refs/heads/<name>`.
38    Branch,
39    /// `refs/remotes/<remote>/<name>`.
40    RemoteBranch,
41    /// `refs/tags/<name>`.
42    Tag,
43    /// `refs/notes/<name>`.
44    Note,
45    /// `refs/stash`.
46    Stash,
47    /// `refs/original/<name>`.
48    Original,
49    /// `refs/replace/<name>`.
50    Replace,
51    /// Anything outside the named namespaces above.
52    Other,
53}
54
55/// A parsed Git ref name: its kind, short name, and owning remote.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct ParsedGitRef<'a> {
58    pub kind: GitRefKind,
59    /// Short name beneath the namespace, e.g. `main` for `refs/heads/main`
60    /// or `feature/x` for `refs/remotes/origin/feature/x`.
61    pub name: &'a str,
62    /// Owning remote. Local content refs report
63    /// [`REMOTE_NAME_FOR_LOCAL_GIT_REPO`].
64    pub remote: &'a str,
65}
66
67/// A fully-qualified Git ref name classified into Heddle's shared namespace
68/// semantics.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct GitRefName<'a> {
71    full_name: &'a str,
72}
73
74impl<'a> GitRefName<'a> {
75    /// Classify a fully-qualified Git ref name.
76    pub fn new(full_name: &'a str) -> Self {
77        Self { full_name }
78    }
79
80    /// Return the original fully-qualified name.
81    pub fn as_str(&self) -> &'a str {
82        self.full_name
83    }
84
85    /// Return the ref namespace family.
86    pub fn namespace(&self) -> GitRefNamespace {
87        if self.branch_name().is_some() {
88            GitRefNamespace::Branch
89        } else if self.remote_name().is_some() {
90            GitRefNamespace::RemoteBranch
91        } else if self.tag_name().is_some() {
92            GitRefNamespace::Tag
93        } else if self.note_name().is_some() {
94            GitRefNamespace::Note
95        } else if self.full_name == "refs/stash" {
96            GitRefNamespace::Stash
97        } else if self.full_name.starts_with("refs/original/") {
98            GitRefNamespace::Original
99        } else if self.full_name.starts_with("refs/replace/") {
100            GitRefNamespace::Replace
101        } else {
102            GitRefNamespace::Other
103        }
104    }
105
106    /// Whether this ref is local Git bookkeeping and must not be shipped by
107    /// the hosted mirror push path.
108    pub fn is_local_only(&self) -> bool {
109        matches!(
110            self.namespace(),
111            GitRefNamespace::RemoteBranch
112                | GitRefNamespace::Stash
113                | GitRefNamespace::Original
114                | GitRefNamespace::Replace
115        )
116    }
117
118    /// Whether this ref is content for the hosted mirror push path.
119    ///
120    /// This is intentionally denylist-based: future non-local namespaces are
121    /// mirrored as `Other` until product policy says otherwise.
122    pub fn is_hosted_mirror_content(&self) -> bool {
123        !self.is_local_only()
124    }
125
126    /// Return the named content namespace Heddle surfaces in local Git bridge
127    /// operations.
128    pub fn content_namespace(&self) -> Option<GitRefContentNamespace> {
129        match self.namespace() {
130            GitRefNamespace::Branch => Some(GitRefContentNamespace::Branch),
131            GitRefNamespace::Tag => Some(GitRefContentNamespace::Tag),
132            GitRefNamespace::Note => Some(GitRefContentNamespace::Note),
133            _ => None,
134        }
135    }
136
137    /// Return the hosted Git ref update kind for this ref.
138    pub fn wire_kind(&self) -> GitRefKind {
139        match self.namespace() {
140            GitRefNamespace::Branch | GitRefNamespace::RemoteBranch => GitRefKind::Branch,
141            GitRefNamespace::Tag => GitRefKind::Tag,
142            GitRefNamespace::Note => GitRefKind::Note,
143            _ => GitRefKind::Other,
144        }
145    }
146
147    /// Return the remote owner for `refs/remotes/<remote>/<name>`.
148    pub fn remote_name(&self) -> Option<&'a str> {
149        let remote_and_name = self.full_name.strip_prefix("refs/remotes/")?;
150        let remote = remote_and_name
151            .split_once('/')
152            .map_or(remote_and_name, |(remote, _)| remote);
153        (!remote.is_empty()).then_some(remote)
154    }
155
156    /// Return the short name for a branch, remote branch, tag, or note.
157    pub fn short_name(&self) -> Option<&'a str> {
158        self.branch_name()
159            .or_else(|| self.remote_branch_parts().map(|(_, name)| name))
160            .or_else(|| self.tag_name())
161            .or_else(|| self.note_name())
162    }
163
164    /// Parse a bridge-visible ref. Notes are content refs in Heddle and are
165    /// accepted here to match hosted mirror behavior.
166    pub fn bridge_ref(&self) -> Option<ParsedGitRef<'a>> {
167        match self.namespace() {
168            GitRefNamespace::Branch => {
169                let name = self.branch_name()?;
170                (name != "HEAD").then_some(ParsedGitRef {
171                    kind: GitRefKind::Branch,
172                    name,
173                    remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
174                })
175            }
176            GitRefNamespace::RemoteBranch => {
177                let (remote, name) = self.remote_branch_parts()?;
178                (name != "HEAD" && !is_reserved_git_remote_name(remote)).then_some(
179                    ParsedGitRef {
180                        kind: GitRefKind::Branch,
181                        name,
182                        remote,
183                    },
184                )
185            }
186            GitRefNamespace::Tag => self.tag_name().map(|name| ParsedGitRef {
187                kind: GitRefKind::Tag,
188                name,
189                remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
190            }),
191            GitRefNamespace::Note => self.note_name().map(|name| ParsedGitRef {
192                kind: GitRefKind::Note,
193                name,
194                remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
195            }),
196            _ => None,
197        }
198    }
199
200    /// Format `refs/heads/<name>`.
201    pub fn branch_full_name(name: &str) -> String {
202        format!("refs/heads/{name}")
203    }
204
205    /// Format `refs/remotes/<remote>/<name>`.
206    pub fn remote_branch_full_name(remote: &str, name: &str) -> String {
207        format!("refs/remotes/{remote}/{name}")
208    }
209
210    /// Normalize either `refs/remotes/<remote>/<name>` or `<remote>/<name>`
211    /// into a full remote-tracking ref name.
212    pub fn remote_tracking_full_name(name: &str) -> String {
213        if GitRefName::new(name).remote_name().is_some() {
214            name.to_string()
215        } else {
216            format!("refs/remotes/{name}")
217        }
218    }
219
220    /// Format `refs/tags/<name>`.
221    pub fn tag_full_name(name: &str) -> String {
222        format!("refs/tags/{name}")
223    }
224
225    /// Format `refs/notes/<name>`.
226    pub fn note_full_name(name: &str) -> String {
227        format!("refs/notes/{name}")
228    }
229
230    /// Format a named content ref.
231    pub fn content_full_name(namespace: GitRefContentNamespace, name: &str) -> String {
232        match namespace {
233            GitRefContentNamespace::Branch => Self::branch_full_name(name),
234            GitRefContentNamespace::Tag => Self::tag_full_name(name),
235            GitRefContentNamespace::Note => Self::note_full_name(name),
236        }
237    }
238
239    fn branch_name(&self) -> Option<&'a str> {
240        self.full_name.strip_prefix("refs/heads/")
241    }
242
243    fn tag_name(&self) -> Option<&'a str> {
244        self.full_name.strip_prefix("refs/tags/")
245    }
246
247    fn note_name(&self) -> Option<&'a str> {
248        self.full_name.strip_prefix("refs/notes/")
249    }
250
251    fn remote_branch_parts(&self) -> Option<(&'a str, &'a str)> {
252        let remote_and_name = self.full_name.strip_prefix("refs/remotes/")?;
253        let (remote, name) = remote_and_name.split_once('/')?;
254        (!remote.is_empty() && !name.is_empty()).then_some((remote, name))
255    }
256}
257
258/// Whether a remote name collides with Heddle's local-ref sentinel.
259pub fn is_reserved_git_remote_name(remote: &str) -> bool {
260    remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn classifies_every_git_namespace_used_by_sync_and_bridge() {
269        let cases = [
270            (
271                "refs/heads/main",
272                GitRefNamespace::Branch,
273                Some(GitRefContentNamespace::Branch),
274                GitRefKind::Branch,
275                false,
276                true,
277            ),
278            (
279                "refs/remotes/origin/main",
280                GitRefNamespace::RemoteBranch,
281                None,
282                GitRefKind::Branch,
283                true,
284                false,
285            ),
286            (
287                "refs/remotes/origin",
288                GitRefNamespace::RemoteBranch,
289                None,
290                GitRefKind::Branch,
291                true,
292                false,
293            ),
294            (
295                "refs/tags/v1.0",
296                GitRefNamespace::Tag,
297                Some(GitRefContentNamespace::Tag),
298                GitRefKind::Tag,
299                false,
300                true,
301            ),
302            (
303                "refs/notes/heddle",
304                GitRefNamespace::Note,
305                Some(GitRefContentNamespace::Note),
306                GitRefKind::Note,
307                false,
308                true,
309            ),
310            (
311                "refs/stash",
312                GitRefNamespace::Stash,
313                None,
314                GitRefKind::Other,
315                true,
316                false,
317            ),
318            (
319                "refs/original/refs/heads/main",
320                GitRefNamespace::Original,
321                None,
322                GitRefKind::Other,
323                true,
324                false,
325            ),
326            (
327                "refs/replace/deadbeef",
328                GitRefNamespace::Replace,
329                None,
330                GitRefKind::Other,
331                true,
332                false,
333            ),
334            (
335                "refs/heddle/internal",
336                GitRefNamespace::Other,
337                None,
338                GitRefKind::Other,
339                false,
340                true,
341            ),
342        ];
343
344        for (name, namespace, content_namespace, wire_kind, local_only, mirror_content) in cases {
345            let ref_name = GitRefName::new(name);
346            assert_eq!(ref_name.namespace(), namespace, "{name}");
347            assert_eq!(ref_name.content_namespace(), content_namespace, "{name}");
348            assert_eq!(ref_name.wire_kind(), wire_kind, "{name}");
349            assert_eq!(ref_name.is_local_only(), local_only, "{name}");
350            assert_eq!(
351                ref_name.is_hosted_mirror_content(),
352                mirror_content,
353                "{name}"
354            );
355        }
356    }
357
358    #[test]
359    fn parses_bridge_visible_refs() {
360        assert_eq!(
361            GitRefName::new("refs/heads/main").bridge_ref(),
362            Some(ParsedGitRef {
363                kind: GitRefKind::Branch,
364                name: "main",
365                remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
366            })
367        );
368        assert_eq!(
369            GitRefName::new("refs/remotes/origin/feature/x").bridge_ref(),
370            Some(ParsedGitRef {
371                kind: GitRefKind::Branch,
372                name: "feature/x",
373                remote: "origin",
374            })
375        );
376        assert_eq!(
377            GitRefName::new("refs/tags/v1.0").bridge_ref(),
378            Some(ParsedGitRef {
379                kind: GitRefKind::Tag,
380                name: "v1.0",
381                remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
382            })
383        );
384        assert_eq!(
385            GitRefName::new("refs/notes/heddle").bridge_ref(),
386            Some(ParsedGitRef {
387                kind: GitRefKind::Note,
388                name: "heddle",
389                remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
390            })
391        );
392    }
393
394    #[test]
395    fn rejects_symbolic_head_and_reserved_remote_from_bridge_parse() {
396        assert_eq!(GitRefName::new("refs/heads/HEAD").bridge_ref(), None);
397        assert_eq!(GitRefName::new("refs/remotes/origin/HEAD").bridge_ref(), None);
398        assert_eq!(GitRefName::new("refs/remotes/git/main").bridge_ref(), None);
399    }
400
401    #[test]
402    fn formats_full_ref_names() {
403        assert_eq!(GitRefName::branch_full_name("main"), "refs/heads/main");
404        assert_eq!(
405            GitRefName::remote_branch_full_name("origin", "feature/x"),
406            "refs/remotes/origin/feature/x"
407        );
408        assert_eq!(
409            GitRefName::remote_tracking_full_name("origin/feature/x"),
410            "refs/remotes/origin/feature/x"
411        );
412        assert_eq!(
413            GitRefName::remote_tracking_full_name("refs/remotes/origin/feature/x"),
414            "refs/remotes/origin/feature/x"
415        );
416        assert_eq!(GitRefName::tag_full_name("v1.0"), "refs/tags/v1.0");
417        assert_eq!(GitRefName::note_full_name("heddle"), "refs/notes/heddle");
418    }
419}