1use serde::{Deserialize, Serialize};
5
6use super::invalid;
7use crate::{
8 error::Result,
9 object::{MarkerName, ThreadName},
10};
11
12pub const MAX_IMPORT_REFS: usize = 512;
13
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum GitObjectId {
19 Sha1([u8; 20]),
20 Sha256([u8; 32]),
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum GitRefObjectType {
28 Commit,
29 Tag,
30 Tree,
31 Blob,
32 Unknown,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum GitRefTarget {
38 Direct {
39 oid: GitObjectId,
40 object_type: GitRefObjectType,
41 },
42 Symbolic(Vec<u8>),
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct ImportRefIdentity {
50 pub raw_name: Vec<u8>,
51 pub raw_target: GitRefTarget,
52 pub peeled_commit: Option<GitObjectId>,
53}
54
55impl ImportRefIdentity {
56 pub fn direct_oid(&self) -> Option<&GitObjectId> {
57 match &self.raw_target {
58 GitRefTarget::Direct { oid, .. } => Some(oid),
59 GitRefTarget::Symbolic(_) => None,
60 }
61 }
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum GitObjectFormat {
67 Sha1,
68 Sha256,
69}
70
71impl GitObjectFormat {
72 pub(crate) fn sley(self) -> sley_core::ObjectFormat {
73 match self {
74 Self::Sha1 => sley_core::ObjectFormat::Sha1,
75 Self::Sha256 => sley_core::ObjectFormat::Sha256,
76 }
77 }
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ImportRefDisposition {
83 Branch,
84 CommitTag,
85 DefaultHead,
86 RequiredNotes,
87 Unsupported { reason: ImportSkipReason },
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ImportSkipReason {
94 NonUtf8RefName,
95 InvalidNativeName,
96 RemoteTracking,
97 Replace,
98 Pull,
99 OtherNotes,
100 NonCommitTag,
101 SymbolicRef,
102 OtherRef,
103 DanglingUnsupported,
104}
105
106impl ImportSkipReason {
107 pub fn description(self) -> &'static str {
108 match self {
109 Self::NonUtf8RefName => "non-UTF-8 ref name",
110 Self::InvalidNativeName => "reserved native ref name",
111 Self::RemoteTracking => "remote-tracking ref",
112 Self::Replace => "replace ref",
113 Self::Pull => "pull ref",
114 Self::OtherNotes => "other notes ref",
115 Self::NonCommitTag => "tag does not point to a commit",
116 Self::SymbolicRef => "symbolic ref",
117 Self::OtherRef => "unsupported ref namespace",
118 Self::DanglingUnsupported => "dangling unsupported ref",
119 }
120 }
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum ImportRefFailure {
125 MissingRequiredTarget,
126 InvalidDefaultHead,
127 UnknownRequiredTarget,
128}
129
130pub fn classify_git_import_ref(
132 reference: &ImportRefIdentity,
133) -> std::result::Result<ImportRefDisposition, ImportRefFailure> {
134 let name = reference.raw_name.as_slice();
135 if std::str::from_utf8(name).is_err()
136 || name.windows(3).any(|bytes| bytes == "\u{fffd}".as_bytes())
137 {
138 return Ok(ImportRefDisposition::Unsupported {
139 reason: ImportSkipReason::NonUtf8RefName,
140 });
141 }
142 if name == b"HEAD" {
143 return match &reference.raw_target {
144 GitRefTarget::Symbolic(target)
145 if target.starts_with(b"refs/heads/") && target.len() > b"refs/heads/".len() =>
146 {
147 Ok(ImportRefDisposition::DefaultHead)
148 }
149 _ => Err(ImportRefFailure::InvalidDefaultHead),
150 };
151 }
152 if name.starts_with(b"refs/pull/") {
153 return Ok(ImportRefDisposition::Unsupported {
154 reason: ImportSkipReason::Pull,
155 });
156 }
157 if matches!(&reference.raw_target, GitRefTarget::Symbolic(_)) {
158 return Ok(ImportRefDisposition::Unsupported {
159 reason: ImportSkipReason::SymbolicRef,
160 });
161 }
162 if name == b"refs/notes/heddle" {
163 return match &reference.raw_target {
164 GitRefTarget::Direct {
165 object_type: GitRefObjectType::Commit,
166 ..
167 } => Ok(ImportRefDisposition::RequiredNotes),
168 _ => Err(ImportRefFailure::MissingRequiredTarget),
169 };
170 }
171 let kind = match &reference.raw_target {
172 GitRefTarget::Direct { object_type, .. } => object_type,
173 GitRefTarget::Symbolic(_) => return Err(ImportRefFailure::UnknownRequiredTarget),
174 };
175 if name.starts_with(b"refs/heads/") && name.len() > b"refs/heads/".len() {
176 if ThreadName::try_new(String::from_utf8_lossy(&name[b"refs/heads/".len()..]).into_owned())
177 .is_err()
178 {
179 return Ok(ImportRefDisposition::Unsupported {
180 reason: ImportSkipReason::InvalidNativeName,
181 });
182 }
183 return match (
184 kind,
185 reference.peeled_commit.as_ref(),
186 reference.direct_oid(),
187 ) {
188 (GitRefObjectType::Commit, Some(peeled), Some(raw)) if peeled == raw => {
189 Ok(ImportRefDisposition::Branch)
190 }
191 (GitRefObjectType::Unknown, _, _) => Err(ImportRefFailure::MissingRequiredTarget),
192 _ => Err(ImportRefFailure::UnknownRequiredTarget),
193 };
194 }
195 if name.starts_with(b"refs/tags/") && name.len() > b"refs/tags/".len() {
196 if MarkerName::try_new(String::from_utf8_lossy(&name[b"refs/tags/".len()..]).into_owned())
197 .is_err()
198 {
199 return Ok(ImportRefDisposition::Unsupported {
200 reason: ImportSkipReason::InvalidNativeName,
201 });
202 }
203 return match kind {
204 GitRefObjectType::Commit | GitRefObjectType::Tag
205 if reference.peeled_commit.is_some() =>
206 {
207 Ok(ImportRefDisposition::CommitTag)
208 }
209 GitRefObjectType::Tag | GitRefObjectType::Tree | GitRefObjectType::Blob => {
210 Ok(ImportRefDisposition::Unsupported {
211 reason: ImportSkipReason::NonCommitTag,
212 })
213 }
214 GitRefObjectType::Unknown => Err(ImportRefFailure::MissingRequiredTarget),
215 _ => Err(ImportRefFailure::UnknownRequiredTarget),
216 };
217 }
218 let reason = if *kind == GitRefObjectType::Unknown {
219 ImportSkipReason::DanglingUnsupported
220 } else if name.starts_with(b"refs/remotes/") {
221 ImportSkipReason::RemoteTracking
222 } else if name.starts_with(b"refs/replace/") {
223 ImportSkipReason::Replace
224 } else if name.starts_with(b"refs/notes/") {
225 ImportSkipReason::OtherNotes
226 } else {
227 ImportSkipReason::OtherRef
228 };
229 Ok(ImportRefDisposition::Unsupported { reason })
230}
231
232#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct SkippedImportRef {
234 pub raw_name: Vec<u8>,
235 pub reason: ImportSkipReason,
236}
237
238#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct ClassifiedImportRefs {
242 pub default_branch: Vec<u8>,
243 pub dispositions: Vec<ImportRefDisposition>,
244 pub skipped_refs: Vec<SkippedImportRef>,
245 pub native_ref_count: u32,
246 pub partial: bool,
247}
248
249pub fn classify_frozen_import_refs(refs: &[ImportRefIdentity]) -> Result<ClassifiedImportRefs> {
250 let mut native_ref_count = 0usize;
251 let mut dispositions = Vec::with_capacity(refs.len());
252 let mut skipped_refs = Vec::new();
253 let mut default_branch = None;
254 let mut last_name: Option<&[u8]> = None;
255 let mut partial = false;
256 for reference in refs {
257 if reference.raw_name.is_empty()
258 || last_name.is_some_and(|last| last >= reference.raw_name.as_slice())
259 {
260 return Err(invalid(
261 "Git import refs are not strictly sorted by raw name",
262 ));
263 }
264 last_name = Some(&reference.raw_name);
265 let disposition = classify_git_import_ref(reference)
266 .map_err(|error| invalid(format!("Git import ref cannot be represented: {error:?}")))?;
267 if disposition == ImportRefDisposition::DefaultHead {
268 let GitRefTarget::Symbolic(target) = &reference.raw_target else {
269 return Err(invalid("default HEAD is not symbolic"));
270 };
271 default_branch = Some(target.clone());
272 }
273 if matches!(
274 disposition,
275 ImportRefDisposition::Branch | ImportRefDisposition::CommitTag
276 ) {
277 native_ref_count += 1;
278 if native_ref_count > MAX_IMPORT_REFS {
279 return Err(invalid("Git import has more than 512 native refs"));
280 }
281 }
282 if let ImportRefDisposition::Unsupported { reason } = disposition
284 && reason != ImportSkipReason::Pull
285 {
286 skipped_refs.push(SkippedImportRef {
287 raw_name: reference.raw_name.clone(),
288 reason,
289 });
290 partial = true;
291 }
292 dispositions.push(disposition);
293 }
294 let default_branch =
295 default_branch.ok_or_else(|| invalid("Git import has no symbolic HEAD"))?;
296 let default_index = refs
297 .binary_search_by(|reference| reference.raw_name.cmp(&default_branch))
298 .map_err(|_| invalid("Git import HEAD points to an absent branch"))?;
299 if dispositions.get(default_index) != Some(&ImportRefDisposition::Branch) {
300 return Err(invalid(
301 "Git import HEAD does not point to a supported branch",
302 ));
303 }
304 Ok(ClassifiedImportRefs {
305 default_branch,
306 dispositions,
307 skipped_refs,
308 native_ref_count: native_ref_count as u32,
309 partial,
310 })
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 #[test]
317 fn unrepresentable_ref_kinds_have_explicit_partial_outcomes() {
318 let target = GitObjectId::Sha1([1; 20]);
319 for (name, object_type, reason) in [
320 (
321 b"refs/remotes/origin/main".as_slice(),
322 GitRefObjectType::Commit,
323 ImportSkipReason::RemoteTracking,
324 ),
325 (
326 b"refs/replace/abc".as_slice(),
327 GitRefObjectType::Commit,
328 ImportSkipReason::Replace,
329 ),
330 (
331 b"refs/pull/12/head".as_slice(),
332 GitRefObjectType::Commit,
333 ImportSkipReason::Pull,
334 ),
335 (
336 b"refs/notes/other".as_slice(),
337 GitRefObjectType::Commit,
338 ImportSkipReason::OtherNotes,
339 ),
340 (
341 b"refs/tags/blob".as_slice(),
342 GitRefObjectType::Blob,
343 ImportSkipReason::NonCommitTag,
344 ),
345 (
346 b"refs/tags/tree".as_slice(),
347 GitRefObjectType::Tree,
348 ImportSkipReason::NonCommitTag,
349 ),
350 (
351 b"refs/custom/x".as_slice(),
352 GitRefObjectType::Commit,
353 ImportSkipReason::OtherRef,
354 ),
355 (
356 b"refs/custom/dangling".as_slice(),
357 GitRefObjectType::Unknown,
358 ImportSkipReason::DanglingUnsupported,
359 ),
360 ] {
361 let reference = ImportRefIdentity {
362 raw_name: name.to_vec(),
363 raw_target: GitRefTarget::Direct {
364 oid: target.clone(),
365 object_type,
366 },
367 peeled_commit: None,
368 };
369 assert_eq!(
370 classify_git_import_ref(&reference),
371 Ok(ImportRefDisposition::Unsupported { reason }),
372 "{}",
373 String::from_utf8_lossy(name),
374 );
375 }
376 let symbolic = ImportRefIdentity {
377 raw_name: b"refs/remotes/origin/HEAD".to_vec(),
378 raw_target: GitRefTarget::Symbolic(b"refs/remotes/origin/main".to_vec()),
379 peeled_commit: None,
380 };
381 assert_eq!(
382 classify_git_import_ref(&symbolic),
383 Ok(ImportRefDisposition::Unsupported {
384 reason: ImportSkipReason::SymbolicRef,
385 })
386 );
387 for name in [b"refs/heads/main".as_slice(), b"refs/tags/v1".as_slice()] {
388 let dangling = ImportRefIdentity {
389 raw_name: name.to_vec(),
390 raw_target: GitRefTarget::Direct {
391 oid: target.clone(),
392 object_type: GitRefObjectType::Unknown,
393 },
394 peeled_commit: None,
395 };
396 assert_eq!(
397 classify_git_import_ref(&dangling),
398 Err(ImportRefFailure::MissingRequiredTarget)
399 );
400 }
401 let detached_head = ImportRefIdentity {
402 raw_name: b"HEAD".to_vec(),
403 raw_target: GitRefTarget::Direct {
404 oid: target,
405 object_type: GitRefObjectType::Commit,
406 },
407 peeled_commit: None,
408 };
409 assert_eq!(
410 classify_git_import_ref(&detached_head),
411 Err(ImportRefFailure::InvalidDefaultHead)
412 );
413 }
414
415 #[test]
416 fn github_provider_refs_do_not_make_import_partial() {
417 let commit = GitObjectId::Sha1([4; 20]);
418 let mut refs = vec![ImportRefIdentity {
419 raw_name: b"HEAD".to_vec(),
420 raw_target: GitRefTarget::Symbolic(b"refs/heads/main".to_vec()),
421 peeled_commit: None,
422 }];
423 for name in [
424 b"refs/heads/main".as_slice(),
425 b"refs/pull/12/head".as_slice(),
426 b"refs/pull/12/merge".as_slice(),
427 b"refs/tags/v1".as_slice(),
428 ] {
429 refs.push(ImportRefIdentity {
430 raw_name: name.to_vec(),
431 raw_target: GitRefTarget::Direct {
432 oid: commit.clone(),
433 object_type: GitRefObjectType::Commit,
434 },
435 peeled_commit: Some(commit.clone()),
436 });
437 }
438 let classified = classify_frozen_import_refs(&refs).expect("GitHub-shaped refs");
439 assert!(!classified.partial);
440 assert!(classified.skipped_refs.is_empty());
441 assert_eq!(classified.native_ref_count, 2);
442 assert_eq!(classified.default_branch, b"refs/heads/main");
443 }
444
445 #[test]
446 fn complete_ref_classification_keeps_unsupported_refs_and_requires_head() {
447 let commit = GitObjectId::Sha1([4; 20]);
448 let head = ImportRefIdentity {
449 raw_name: b"HEAD".to_vec(),
450 raw_target: GitRefTarget::Symbolic(b"refs/heads/main".to_vec()),
451 peeled_commit: Some(commit.clone()),
452 };
453 let branch = ImportRefIdentity {
454 raw_name: b"refs/heads/main".to_vec(),
455 raw_target: GitRefTarget::Direct {
456 oid: commit.clone(),
457 object_type: GitRefObjectType::Commit,
458 },
459 peeled_commit: Some(commit.clone()),
460 };
461 let remote = ImportRefIdentity {
462 raw_name: b"refs/remotes/origin/main".to_vec(),
463 raw_target: GitRefTarget::Direct {
464 oid: commit,
465 object_type: GitRefObjectType::Commit,
466 },
467 peeled_commit: None,
468 };
469 let refs = vec![head, branch, remote];
470 let result = classify_frozen_import_refs(&refs).expect("complete snapshot");
471 assert_eq!(result.default_branch, b"refs/heads/main");
472 assert_eq!(result.native_ref_count, 1);
473 assert!(result.partial);
474 assert_eq!(
475 result.skipped_refs,
476 vec![SkippedImportRef {
477 raw_name: b"refs/remotes/origin/main".to_vec(),
478 reason: ImportSkipReason::RemoteTracking,
479 }]
480 );
481 assert_eq!(result.dispositions.len(), refs.len());
482 assert!(classify_frozen_import_refs(&refs[1..]).is_err());
483 let mut moved = refs;
484 moved[0].raw_target = GitRefTarget::Symbolic(b"refs/heads/absent".to_vec());
485 assert!(classify_frozen_import_refs(&moved).is_err());
486 }
487
488 #[test]
489 fn reserved_branch_is_skipped_before_native_emission() {
490 let oid = GitObjectId::Sha1([8; 20]);
491 let reference = ImportRefIdentity {
492 raw_name: b"refs/heads/heddle/reserved".to_vec(),
493 raw_target: GitRefTarget::Direct {
494 oid: oid.clone(),
495 object_type: GitRefObjectType::Commit,
496 },
497 peeled_commit: Some(oid),
498 };
499 assert!(matches!(
500 classify_git_import_ref(&reference),
501 Ok(ImportRefDisposition::Unsupported {
502 reason: ImportSkipReason::InvalidNativeName
503 })
504 ));
505 }
506}