1pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum GitRefContentNamespace {
13 Branch,
15 Tag,
17 Note,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum GitRefKind {
24 Branch,
26 Tag,
28 Note,
30 Other,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum GitRefNamespace {
37 Branch,
39 RemoteBranch,
41 Tag,
43 Note,
45 Stash,
47 Original,
49 Replace,
51 Other,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct ParsedGitRef<'a> {
58 pub kind: GitRefKind,
59 pub name: &'a str,
62 pub remote: &'a str,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct GitRefName<'a> {
71 full_name: &'a str,
72}
73
74impl<'a> GitRefName<'a> {
75 pub fn new(full_name: &'a str) -> Self {
77 Self { full_name }
78 }
79
80 pub fn as_str(&self) -> &'a str {
82 self.full_name
83 }
84
85 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 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 pub fn is_hosted_mirror_content(&self) -> bool {
123 !self.is_local_only()
124 }
125
126 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 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 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 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 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 pub fn branch_full_name(name: &str) -> String {
202 format!("refs/heads/{name}")
203 }
204
205 pub fn remote_branch_full_name(remote: &str, name: &str) -> String {
207 format!("refs/remotes/{remote}/{name}")
208 }
209
210 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 pub fn tag_full_name(name: &str) -> String {
222 format!("refs/tags/{name}")
223 }
224
225 pub fn note_full_name(name: &str) -> String {
227 format!("refs/notes/{name}")
228 }
229
230 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
258pub 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}