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 => "ref name is not valid UTF-8",
110 Self::InvalidNativeName => "invalid Git 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 let Ok(utf8_name) = std::str::from_utf8(name) else {
136 return Ok(ImportRefDisposition::Unsupported {
137 reason: ImportSkipReason::NonUtf8RefName,
138 });
139 };
140 if name == b"HEAD" {
141 return match &reference.raw_target {
142 GitRefTarget::Symbolic(target)
143 if target.starts_with(b"refs/heads/") && target.len() > b"refs/heads/".len() =>
144 {
145 Ok(ImportRefDisposition::DefaultHead)
146 }
147 _ => Err(ImportRefFailure::InvalidDefaultHead),
148 };
149 }
150 if name.starts_with(b"refs/pull/") {
151 return Ok(ImportRefDisposition::Unsupported {
152 reason: ImportSkipReason::Pull,
153 });
154 }
155 if matches!(&reference.raw_target, GitRefTarget::Symbolic(_)) {
156 return Ok(ImportRefDisposition::Unsupported {
157 reason: ImportSkipReason::SymbolicRef,
158 });
159 }
160 if name == b"refs/notes/heddle" {
161 return match &reference.raw_target {
162 GitRefTarget::Direct {
163 object_type: GitRefObjectType::Commit,
164 ..
165 } => Ok(ImportRefDisposition::RequiredNotes),
166 _ => Err(ImportRefFailure::MissingRequiredTarget),
167 };
168 }
169 let kind = match &reference.raw_target {
170 GitRefTarget::Direct { object_type, .. } => object_type,
171 GitRefTarget::Symbolic(_) => return Err(ImportRefFailure::UnknownRequiredTarget),
172 };
173 if name.starts_with(b"refs/heads/") && name.len() > b"refs/heads/".len() {
174 if ThreadName::from_git_branch(&utf8_name["refs/heads/".len()..]).is_err() {
175 return Ok(ImportRefDisposition::Unsupported {
176 reason: ImportSkipReason::InvalidNativeName,
177 });
178 }
179 return match (
180 kind,
181 reference.peeled_commit.as_ref(),
182 reference.direct_oid(),
183 ) {
184 (GitRefObjectType::Commit, Some(peeled), Some(raw)) if peeled == raw => {
185 Ok(ImportRefDisposition::Branch)
186 }
187 (GitRefObjectType::Unknown, _, _) => Err(ImportRefFailure::MissingRequiredTarget),
188 _ => Err(ImportRefFailure::UnknownRequiredTarget),
189 };
190 }
191 if name.starts_with(b"refs/tags/") && name.len() > b"refs/tags/".len() {
192 if MarkerName::from_git_tag(&utf8_name["refs/tags/".len()..]).is_err() {
193 return Ok(ImportRefDisposition::Unsupported {
194 reason: ImportSkipReason::InvalidNativeName,
195 });
196 }
197 return match kind {
198 GitRefObjectType::Commit | GitRefObjectType::Tag
199 if reference.peeled_commit.is_some() =>
200 {
201 Ok(ImportRefDisposition::CommitTag)
202 }
203 GitRefObjectType::Tag | GitRefObjectType::Tree | GitRefObjectType::Blob => {
204 Ok(ImportRefDisposition::Unsupported {
205 reason: ImportSkipReason::NonCommitTag,
206 })
207 }
208 GitRefObjectType::Unknown => Err(ImportRefFailure::MissingRequiredTarget),
209 _ => Err(ImportRefFailure::UnknownRequiredTarget),
210 };
211 }
212 let reason = if *kind == GitRefObjectType::Unknown {
213 ImportSkipReason::DanglingUnsupported
214 } else if name.starts_with(b"refs/remotes/") {
215 ImportSkipReason::RemoteTracking
216 } else if name.starts_with(b"refs/replace/") {
217 ImportSkipReason::Replace
218 } else if name.starts_with(b"refs/notes/") {
219 ImportSkipReason::OtherNotes
220 } else {
221 ImportSkipReason::OtherRef
222 };
223 Ok(ImportRefDisposition::Unsupported { reason })
224}
225
226#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct SkippedImportRef {
228 pub raw_name: Vec<u8>,
229 pub reason: ImportSkipReason,
230}
231
232impl SkippedImportRef {
233 pub fn display_name(&self) -> String {
236 std::str::from_utf8(&self.raw_name)
237 .map_or_else(|_| self.raw_name.escape_ascii().to_string(), str::to_owned)
238 }
239}
240
241#[derive(Clone, Debug, PartialEq, Eq)]
244pub struct ClassifiedImportRefs {
245 pub default_branch: Vec<u8>,
246 pub dispositions: Vec<ImportRefDisposition>,
247 pub skipped_refs: Vec<SkippedImportRef>,
248 pub native_ref_count: u32,
249 pub partial: bool,
250}
251
252pub fn classify_frozen_import_refs(refs: &[ImportRefIdentity]) -> Result<ClassifiedImportRefs> {
253 let mut native_ref_count = 0usize;
254 let mut dispositions = Vec::with_capacity(refs.len());
255 let mut skipped_refs = Vec::new();
256 let mut default_branch = None;
257 let mut last_name: Option<&[u8]> = None;
258 let mut partial = false;
259 for reference in refs {
260 if reference.raw_name.is_empty()
261 || last_name.is_some_and(|last| last >= reference.raw_name.as_slice())
262 {
263 return Err(invalid(
264 "Git import refs are not strictly sorted by raw name",
265 ));
266 }
267 last_name = Some(&reference.raw_name);
268 let disposition = classify_git_import_ref(reference)
269 .map_err(|error| invalid(format!("Git import ref cannot be represented: {error:?}")))?;
270 if disposition == ImportRefDisposition::DefaultHead {
271 let GitRefTarget::Symbolic(target) = &reference.raw_target else {
272 return Err(invalid("default HEAD is not symbolic"));
273 };
274 default_branch = Some(target.clone());
275 }
276 if matches!(
277 disposition,
278 ImportRefDisposition::Branch | ImportRefDisposition::CommitTag
279 ) {
280 native_ref_count += 1;
281 if native_ref_count > MAX_IMPORT_REFS {
282 return Err(invalid("Git import has more than 512 native refs"));
283 }
284 }
285 if let ImportRefDisposition::Unsupported { reason } = disposition
287 && reason != ImportSkipReason::Pull
288 {
289 skipped_refs.push(SkippedImportRef {
290 raw_name: reference.raw_name.clone(),
291 reason,
292 });
293 partial = true;
294 }
295 dispositions.push(disposition);
296 }
297 let default_branch =
298 default_branch.ok_or_else(|| invalid("Git import has no symbolic HEAD"))?;
299 let default_index = refs
300 .binary_search_by(|reference| reference.raw_name.cmp(&default_branch))
301 .map_err(|_| invalid("Git import HEAD points to an absent branch"))?;
302 if dispositions.get(default_index) != Some(&ImportRefDisposition::Branch) {
303 return Err(invalid(
304 "Git import HEAD does not point to a supported branch",
305 ));
306 }
307 Ok(ClassifiedImportRefs {
308 default_branch,
309 dispositions,
310 skipped_refs,
311 native_ref_count: native_ref_count as u32,
312 partial,
313 })
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 #[test]
320 fn unrepresentable_ref_kinds_have_explicit_partial_outcomes() {
321 let target = GitObjectId::Sha1([1; 20]);
322 for (name, object_type, reason) in [
323 (
324 b"refs/remotes/origin/main".as_slice(),
325 GitRefObjectType::Commit,
326 ImportSkipReason::RemoteTracking,
327 ),
328 (
329 b"refs/replace/abc".as_slice(),
330 GitRefObjectType::Commit,
331 ImportSkipReason::Replace,
332 ),
333 (
334 b"refs/pull/12/head".as_slice(),
335 GitRefObjectType::Commit,
336 ImportSkipReason::Pull,
337 ),
338 (
339 b"refs/notes/other".as_slice(),
340 GitRefObjectType::Commit,
341 ImportSkipReason::OtherNotes,
342 ),
343 (
344 b"refs/tags/blob".as_slice(),
345 GitRefObjectType::Blob,
346 ImportSkipReason::NonCommitTag,
347 ),
348 (
349 b"refs/tags/tree".as_slice(),
350 GitRefObjectType::Tree,
351 ImportSkipReason::NonCommitTag,
352 ),
353 (
354 b"refs/custom/x".as_slice(),
355 GitRefObjectType::Commit,
356 ImportSkipReason::OtherRef,
357 ),
358 (
359 b"refs/custom/dangling".as_slice(),
360 GitRefObjectType::Unknown,
361 ImportSkipReason::DanglingUnsupported,
362 ),
363 ] {
364 let reference = ImportRefIdentity {
365 raw_name: name.to_vec(),
366 raw_target: GitRefTarget::Direct {
367 oid: target.clone(),
368 object_type,
369 },
370 peeled_commit: None,
371 };
372 assert_eq!(
373 classify_git_import_ref(&reference),
374 Ok(ImportRefDisposition::Unsupported { reason }),
375 "{}",
376 String::from_utf8_lossy(name),
377 );
378 }
379 let symbolic = ImportRefIdentity {
380 raw_name: b"refs/remotes/origin/HEAD".to_vec(),
381 raw_target: GitRefTarget::Symbolic(b"refs/remotes/origin/main".to_vec()),
382 peeled_commit: None,
383 };
384 assert_eq!(
385 classify_git_import_ref(&symbolic),
386 Ok(ImportRefDisposition::Unsupported {
387 reason: ImportSkipReason::SymbolicRef,
388 })
389 );
390 for name in [b"refs/heads/main".as_slice(), b"refs/tags/v1".as_slice()] {
391 let dangling = ImportRefIdentity {
392 raw_name: name.to_vec(),
393 raw_target: GitRefTarget::Direct {
394 oid: target.clone(),
395 object_type: GitRefObjectType::Unknown,
396 },
397 peeled_commit: None,
398 };
399 assert_eq!(
400 classify_git_import_ref(&dangling),
401 Err(ImportRefFailure::MissingRequiredTarget)
402 );
403 }
404 let detached_head = ImportRefIdentity {
405 raw_name: b"HEAD".to_vec(),
406 raw_target: GitRefTarget::Direct {
407 oid: target,
408 object_type: GitRefObjectType::Commit,
409 },
410 peeled_commit: None,
411 };
412 assert_eq!(
413 classify_git_import_ref(&detached_head),
414 Err(ImportRefFailure::InvalidDefaultHead)
415 );
416 }
417
418 #[test]
419 fn github_provider_refs_do_not_make_import_partial() {
420 let commit = GitObjectId::Sha1([4; 20]);
421 let mut refs = vec![ImportRefIdentity {
422 raw_name: b"HEAD".to_vec(),
423 raw_target: GitRefTarget::Symbolic(b"refs/heads/main".to_vec()),
424 peeled_commit: None,
425 }];
426 for name in [
427 b"refs/heads/main".as_slice(),
428 b"refs/pull/12/head".as_slice(),
429 b"refs/pull/12/merge".as_slice(),
430 b"refs/tags/v1".as_slice(),
431 ] {
432 refs.push(ImportRefIdentity {
433 raw_name: name.to_vec(),
434 raw_target: GitRefTarget::Direct {
435 oid: commit.clone(),
436 object_type: GitRefObjectType::Commit,
437 },
438 peeled_commit: Some(commit.clone()),
439 });
440 }
441 let classified = classify_frozen_import_refs(&refs).expect("GitHub-shaped refs");
442 assert!(!classified.partial);
443 assert!(classified.skipped_refs.is_empty());
444 assert_eq!(classified.native_ref_count, 2);
445 assert_eq!(classified.default_branch, b"refs/heads/main");
446 }
447
448 #[test]
449 fn complete_ref_classification_keeps_unsupported_refs_and_requires_head() {
450 let commit = GitObjectId::Sha1([4; 20]);
451 let head = ImportRefIdentity {
452 raw_name: b"HEAD".to_vec(),
453 raw_target: GitRefTarget::Symbolic(b"refs/heads/main".to_vec()),
454 peeled_commit: Some(commit.clone()),
455 };
456 let branch = ImportRefIdentity {
457 raw_name: b"refs/heads/main".to_vec(),
458 raw_target: GitRefTarget::Direct {
459 oid: commit.clone(),
460 object_type: GitRefObjectType::Commit,
461 },
462 peeled_commit: Some(commit.clone()),
463 };
464 let remote = ImportRefIdentity {
465 raw_name: b"refs/remotes/origin/main".to_vec(),
466 raw_target: GitRefTarget::Direct {
467 oid: commit,
468 object_type: GitRefObjectType::Commit,
469 },
470 peeled_commit: None,
471 };
472 let refs = vec![head, branch, remote];
473 let result = classify_frozen_import_refs(&refs).expect("complete snapshot");
474 assert_eq!(result.default_branch, b"refs/heads/main");
475 assert_eq!(result.native_ref_count, 1);
476 assert!(result.partial);
477 assert_eq!(
478 result.skipped_refs,
479 vec![SkippedImportRef {
480 raw_name: b"refs/remotes/origin/main".to_vec(),
481 reason: ImportSkipReason::RemoteTracking,
482 }]
483 );
484 assert_eq!(result.dispositions.len(), refs.len());
485 assert!(classify_frozen_import_refs(&refs[1..]).is_err());
486 let mut moved = refs;
487 moved[0].raw_target = GitRefTarget::Symbolic(b"refs/heads/absent".to_vec());
488 assert!(classify_frozen_import_refs(&moved).is_err());
489 }
490
491 #[test]
492 fn reserved_branch_is_admitted_for_escaped_native_emission() {
493 let oid = GitObjectId::Sha1([8; 20]);
494 let reference = ImportRefIdentity {
495 raw_name: b"refs/heads/heddle/reserved".to_vec(),
496 raw_target: GitRefTarget::Direct {
497 oid: oid.clone(),
498 object_type: GitRefObjectType::Commit,
499 },
500 peeled_commit: Some(oid),
501 };
502 assert!(matches!(
503 classify_git_import_ref(&reference),
504 Ok(ImportRefDisposition::Branch)
505 ));
506 }
507}