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