1pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum GitRefContentNamespace {
14 Branch,
16 Tag,
18 Note,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum GitRefKind {
25 Branch,
27 Tag,
29 Note,
31 Other,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum GitRefNamespace {
38 Branch,
40 RemoteBranch,
42 Tag,
44 Note,
46 Stash,
48 Original,
50 Replace,
52 Other,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct ParsedGitRef<'a> {
59 pub kind: GitRefKind,
60 pub name: &'a str,
63 pub remote: &'a str,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct GitRefName<'a> {
72 full_name: &'a str,
73}
74
75impl<'a> GitRefName<'a> {
76 pub fn new(full_name: &'a str) -> Self {
78 Self { full_name }
79 }
80
81 pub fn as_str(&self) -> &'a str {
83 self.full_name
84 }
85
86 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 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 pub fn is_hosted_mirror_content(&self) -> bool {
124 !self.is_local_only()
125 }
126
127 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 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 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 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 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 pub fn branch_full_name(name: &str) -> String {
201 format!("refs/heads/{name}")
202 }
203
204 pub fn remote_branch_full_name(remote: &str, name: &str) -> String {
206 format!("refs/remotes/{remote}/{name}")
207 }
208
209 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 pub fn tag_full_name(name: &str) -> String {
221 format!("refs/tags/{name}")
222 }
223
224 pub fn note_full_name(name: &str) -> String {
226 format!("refs/notes/{name}")
227 }
228
229 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
257pub 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}