1use std::{fmt, path::Path};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7use sley::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
8
9use super::{ChangeId, ContentHash};
10
11const TREE_FORMAT_VERSION: u8 = 2;
12const ENTRY_KIND_BLOB: u8 = 0;
13const ENTRY_KIND_TREE: u8 = 1;
14const ENTRY_KIND_SYMLINK: u8 = 2;
15const ENTRY_KIND_GITLINK: u8 = 3;
16const ENTRY_KIND_SPOOLLINK: u8 = 4;
20const GIT_OBJECT_FORMAT_SHA1: u8 = 1;
21const GIT_OBJECT_FORMAT_SHA256: u8 = 2;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum TreeError {
27 InvalidName(String),
28 InvalidStructure(String),
29}
30
31impl std::error::Error for TreeError {}
32
33impl fmt::Display for TreeError {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 TreeError::InvalidName(msg) => write!(f, "invalid tree entry name: {}", msg),
37 TreeError::InvalidStructure(msg) => write!(f, "invalid tree structure: {}", msg),
38 }
39 }
40}
41
42#[repr(u8)]
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum FileMode {
47 Normal,
48 Executable,
49 Symlink,
50 Gitlink,
51 Spoollink,
57}
58
59impl FileMode {
60 pub fn to_byte(&self) -> u8 {
61 match self {
62 FileMode::Normal => 0,
63 FileMode::Executable => 1,
64 FileMode::Symlink => 2,
65 FileMode::Gitlink => 3,
66 FileMode::Spoollink => 4,
67 }
68 }
69
70 pub fn from_byte(b: u8) -> Option<Self> {
71 match b {
72 0 => Some(FileMode::Normal),
73 1 => Some(FileMode::Executable),
74 2 => Some(FileMode::Symlink),
75 3 => Some(FileMode::Gitlink),
76 4 => Some(FileMode::Spoollink),
77 _ => None,
78 }
79 }
80
81 pub fn to_unix_mode(&self) -> u32 {
85 match self {
86 FileMode::Normal => 0o100644,
87 FileMode::Executable => 0o100755,
88 FileMode::Symlink => 0o120000,
89 FileMode::Gitlink => 0o160000,
90 FileMode::Spoollink => 0,
91 }
92 }
93}
94
95#[repr(u8)]
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub enum EntryType {
100 Blob,
101 Tree,
102 Symlink,
103 Gitlink,
104 Spoollink,
106}
107
108impl EntryType {
109 pub fn to_byte(&self) -> u8 {
110 match self {
111 EntryType::Blob => 0,
112 EntryType::Tree => 1,
113 EntryType::Symlink => 2,
114 EntryType::Gitlink => 3,
115 EntryType::Spoollink => 4,
116 }
117 }
118
119 pub fn from_byte(b: u8) -> Option<Self> {
120 match b {
121 0 => Some(EntryType::Blob),
122 1 => Some(EntryType::Tree),
123 2 => Some(EntryType::Symlink),
124 3 => Some(EntryType::Gitlink),
125 4 => Some(EntryType::Spoollink),
126 _ => None,
127 }
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
134pub enum TreeEntryTarget {
135 Blob { hash: ContentHash, executable: bool },
136 Tree { hash: ContentHash },
137 Symlink { hash: ContentHash },
138 Gitlink { target: GitObjectId },
139 Spoollink {
145 spool_id: ChangeId,
146 state_id: ChangeId,
147 },
148}
149
150impl TreeEntryTarget {
151 pub fn entry_type(&self) -> EntryType {
152 match self {
153 TreeEntryTarget::Blob { .. } => EntryType::Blob,
154 TreeEntryTarget::Tree { .. } => EntryType::Tree,
155 TreeEntryTarget::Symlink { .. } => EntryType::Symlink,
156 TreeEntryTarget::Gitlink { .. } => EntryType::Gitlink,
157 TreeEntryTarget::Spoollink { .. } => EntryType::Spoollink,
158 }
159 }
160
161 pub fn mode(&self) -> FileMode {
162 match self {
163 TreeEntryTarget::Blob {
164 executable: true, ..
165 } => FileMode::Executable,
166 TreeEntryTarget::Blob { .. } => FileMode::Normal,
167 TreeEntryTarget::Tree { .. } => FileMode::Normal,
168 TreeEntryTarget::Symlink { .. } => FileMode::Symlink,
169 TreeEntryTarget::Gitlink { .. } => FileMode::Gitlink,
170 TreeEntryTarget::Spoollink { .. } => FileMode::Spoollink,
171 }
172 }
173
174 pub fn content_hash(&self) -> Option<ContentHash> {
175 match self {
176 TreeEntryTarget::Blob { hash, .. }
177 | TreeEntryTarget::Tree { hash }
178 | TreeEntryTarget::Symlink { hash } => Some(*hash),
179 TreeEntryTarget::Gitlink { .. } | TreeEntryTarget::Spoollink { .. } => None,
180 }
181 }
182
183 pub fn gitlink_target(&self) -> Option<GitObjectId> {
184 match self {
185 TreeEntryTarget::Gitlink { target } => Some(*target),
186 _ => None,
187 }
188 }
189
190 pub fn spoollink_target(&self) -> Option<(ChangeId, ChangeId)> {
193 match self {
194 TreeEntryTarget::Spoollink { spool_id, state_id } => Some((*spool_id, *state_id)),
195 _ => None,
196 }
197 }
198
199 fn encoded_payload_len(&self) -> usize {
200 match self {
201 TreeEntryTarget::Blob { hash, .. }
202 | TreeEntryTarget::Tree { hash }
203 | TreeEntryTarget::Symlink { hash } => hash.as_bytes().len(),
204 TreeEntryTarget::Gitlink { target } => target.as_bytes().len(),
205 TreeEntryTarget::Spoollink { spool_id, state_id } => {
206 spool_id.as_bytes().len() + state_id.as_bytes().len()
207 }
208 }
209 }
210
211 fn update_hasher(&self, hasher: &mut blake3::Hasher) {
212 hasher.update(&[self.mode().to_byte()]);
213 hasher.update(&[self.entry_type().to_byte()]);
214 match self {
215 TreeEntryTarget::Blob { hash, .. }
216 | TreeEntryTarget::Tree { hash }
217 | TreeEntryTarget::Symlink { hash } => hasher.update(hash.as_bytes()),
218 TreeEntryTarget::Gitlink { target } => {
219 hasher.update(&[git_format_to_tag(target.format())]);
220 hasher.update(target.as_bytes())
221 }
222 TreeEntryTarget::Spoollink { spool_id, state_id } => {
223 hasher.update(spool_id.as_bytes());
224 hasher.update(state_id.as_bytes())
225 }
226 };
227 }
228}
229
230pub fn validate_name(name: &str) -> Result<(), TreeError> {
233 if name.is_empty() {
234 return Err(TreeError::InvalidName("entry name cannot be empty".into()));
235 }
236 if name == "." || name == ".." {
237 return Err(TreeError::InvalidName(format!(
238 "'{}' is not a valid entry name",
239 name
240 )));
241 }
242 if name.contains('/') || name.contains('\\') {
243 return Err(TreeError::InvalidName(
244 "entry name cannot contain path separators".into(),
245 ));
246 }
247 if name.bytes().any(|b| b < 0x20 || b == 0x7f) {
248 return Err(TreeError::InvalidName(
249 "entry name contains control characters".into(),
250 ));
251 }
252 Ok(())
253}
254
255#[derive(Clone, Debug, PartialEq, Eq)]
256pub struct TreeEntry {
257 name: String,
258 target: TreeEntryTarget,
259}
260
261impl TreeEntry {
262 #[cfg(test)]
263 pub(crate) fn new_unchecked_for_tests(
264 name: impl Into<String>,
265 target: TreeEntryTarget,
266 ) -> Self {
267 Self {
268 name: name.into(),
269 target,
270 }
271 }
272
273 pub(crate) fn validate(&self) -> Result<(), TreeError> {
274 validate_name(&self.name)
275 }
276
277 pub fn file(
278 name: impl Into<String>,
279 hash: ContentHash,
280 executable: bool,
281 ) -> Result<Self, TreeError> {
282 let name = name.into();
283 validate_name(&name)?;
284 Ok(Self {
285 name,
286 target: TreeEntryTarget::Blob { hash, executable },
287 })
288 }
289
290 pub fn directory(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
291 let name = name.into();
292 validate_name(&name)?;
293 Ok(Self {
294 name,
295 target: TreeEntryTarget::Tree { hash },
296 })
297 }
298
299 pub fn symlink(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
300 let name = name.into();
301 validate_name(&name)?;
302 Ok(Self {
303 name,
304 target: TreeEntryTarget::Symlink { hash },
305 })
306 }
307
308 pub fn gitlink(name: impl Into<String>, target: GitObjectId) -> Result<Self, TreeError> {
309 let name = name.into();
310 validate_name(&name)?;
311 Ok(Self {
312 name,
313 target: TreeEntryTarget::Gitlink { target },
314 })
315 }
316
317 pub fn spoollink(
320 name: impl Into<String>,
321 spool_id: ChangeId,
322 state_id: ChangeId,
323 ) -> Result<Self, TreeError> {
324 let name = name.into();
325 validate_name(&name)?;
326 Ok(Self {
327 name,
328 target: TreeEntryTarget::Spoollink { spool_id, state_id },
329 })
330 }
331
332 pub fn name(&self) -> &str {
333 &self.name
334 }
335
336 pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), TreeError> {
337 let name = name.into();
338 validate_name(&name)?;
339 self.name = name;
340 Ok(())
341 }
342
343 pub fn with_mode(&self, mode: FileMode) -> Result<Self, TreeError> {
344 match (&self.target, mode) {
345 (TreeEntryTarget::Blob { hash, .. }, FileMode::Normal | FileMode::Executable) => {
346 Self::file(self.name.clone(), *hash, mode == FileMode::Executable)
347 }
348 (TreeEntryTarget::Symlink { .. }, FileMode::Symlink)
349 | (TreeEntryTarget::Tree { .. }, _)
350 | (TreeEntryTarget::Gitlink { .. }, FileMode::Gitlink)
351 | (TreeEntryTarget::Spoollink { .. }, FileMode::Spoollink)
352 if mode == self.mode() =>
353 {
354 Ok(self.clone())
355 }
356 _ => Err(TreeError::InvalidStructure(format!(
357 "cannot apply mode {:?} to {:?} entry '{}'",
358 mode,
359 self.entry_type(),
360 self.name
361 ))),
362 }
363 }
364
365 pub fn target(&self) -> &TreeEntryTarget {
366 &self.target
367 }
368
369 pub fn entry_type(&self) -> EntryType {
370 self.target.entry_type()
371 }
372
373 pub fn mode(&self) -> FileMode {
374 self.target.mode()
375 }
376
377 pub fn content_hash(&self) -> Option<ContentHash> {
378 self.target.content_hash()
379 }
380
381 pub fn leaf_content_hash(&self) -> Option<ContentHash> {
382 match self.target {
383 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => Some(hash),
384 TreeEntryTarget::Tree { .. }
385 | TreeEntryTarget::Gitlink { .. }
386 | TreeEntryTarget::Spoollink { .. } => None,
387 }
388 }
389
390 pub fn require_content_hash(&self) -> ContentHash {
391 self.content_hash()
392 .expect("tree entry target does not carry a Heddle content hash")
393 }
394
395 pub fn blob_hash(&self) -> Option<ContentHash> {
396 match self.target {
397 TreeEntryTarget::Blob { hash, .. } => Some(hash),
398 _ => None,
399 }
400 }
401
402 pub fn tree_hash(&self) -> Option<ContentHash> {
403 match self.target {
404 TreeEntryTarget::Tree { hash } => Some(hash),
405 _ => None,
406 }
407 }
408
409 pub fn symlink_hash(&self) -> Option<ContentHash> {
410 match self.target {
411 TreeEntryTarget::Symlink { hash } => Some(hash),
412 _ => None,
413 }
414 }
415
416 pub fn gitlink_target(&self) -> Option<GitObjectId> {
417 self.target.gitlink_target()
418 }
419
420 pub fn spoollink_target(&self) -> Option<(ChangeId, ChangeId)> {
422 self.target.spoollink_target()
423 }
424
425 pub fn is_tree(&self) -> bool {
426 self.entry_type() == EntryType::Tree
427 }
428
429 pub fn is_blob(&self) -> bool {
430 self.entry_type() == EntryType::Blob
431 }
432
433 pub fn is_symlink(&self) -> bool {
434 self.entry_type() == EntryType::Symlink
435 }
436
437 pub fn is_gitlink(&self) -> bool {
438 self.entry_type() == EntryType::Gitlink
439 }
440
441 pub fn is_spoollink(&self) -> bool {
442 self.entry_type() == EntryType::Spoollink
443 }
444
445 pub fn is_executable(&self) -> bool {
446 self.mode() == FileMode::Executable
447 }
448
449 pub(crate) fn encoded_len(&self) -> usize {
450 1 + 1 + self.target.encoded_payload_len() + self.name.len() + 1
451 }
452
453 pub(crate) fn update_hasher(&self, hasher: &mut blake3::Hasher) {
454 self.target.update_hasher(hasher);
455 hasher.update(self.name.as_bytes());
456 hasher.update(&[0]);
457 }
458}
459
460#[derive(Clone, Debug, PartialEq, Eq)]
463pub struct Tree {
464 entries: Vec<TreeEntry>,
465}
466
467impl Tree {
468 pub fn new() -> Self {
469 Self {
470 entries: Vec::new(),
471 }
472 }
473
474 pub fn from_entries(mut entries: Vec<TreeEntry>) -> Self {
475 entries.sort_by(|a, b| a.name.cmp(&b.name));
476 Self { entries }
477 }
478
479 #[cfg(test)]
480 pub(crate) fn from_entries_unchecked_for_tests(entries: Vec<TreeEntry>) -> Self {
481 Self { entries }
482 }
483
484 pub fn validate(&self) -> Result<(), TreeError> {
485 let mut previous_name: Option<&str> = None;
486 for entry in &self.entries {
487 entry.validate()?;
488 if let Some(previous) = previous_name
489 && previous >= entry.name.as_str()
490 {
491 return Err(TreeError::InvalidStructure(
492 "entries must be strictly sorted by name".to_string(),
493 ));
494 }
495 previous_name = Some(&entry.name);
496 }
497 Ok(())
498 }
499
500 pub fn entries(&self) -> &[TreeEntry] {
501 &self.entries
502 }
503
504 pub fn get(&self, name: &str) -> Option<&TreeEntry> {
505 let index = self
506 .entries
507 .binary_search_by(|entry| entry.name.as_str().cmp(name))
508 .ok()?;
509 self.entries.get(index)
510 }
511
512 pub fn insert(&mut self, entry: TreeEntry) {
513 self.entries.retain(|e| e.name != entry.name);
514 let pos = self
515 .entries
516 .iter()
517 .position(|e| e.name > entry.name)
518 .unwrap_or(self.entries.len());
519 self.entries.insert(pos, entry);
520 }
521
522 pub fn remove(&mut self, name: &str) -> Option<TreeEntry> {
523 let pos = self.entries.iter().position(|e| e.name == name)?;
524 Some(self.entries.remove(pos))
525 }
526
527 pub fn is_empty(&self) -> bool {
528 self.entries.is_empty()
529 }
530
531 pub fn len(&self) -> usize {
532 self.entries.len()
533 }
534
535 pub fn hash(&self) -> ContentHash {
536 let total_len: usize = self.entries.iter().map(TreeEntry::encoded_len).sum();
537 ContentHash::compute_typed_with_len("tree", total_len as u64, |hasher| {
538 for entry in &self.entries {
539 entry.update_hasher(hasher);
540 }
541 })
542 }
543
544 pub fn iter(&self) -> impl Iterator<Item = &TreeEntry> {
545 self.entries.iter()
546 }
547
548 pub fn get_path(&self, path: &Path) -> Option<&TreeEntry> {
549 let name = path.file_name()?.to_str()?;
550 if path.parent().is_none_or(|p| p.as_os_str().is_empty()) {
551 self.get(name)
552 } else {
553 None
554 }
555 }
556}
557
558#[derive(Serialize, Deserialize)]
561struct EncodedTreeV2 {
562 version: u8,
563 entries: Vec<EncodedTreeEntryV2>,
564}
565
566#[derive(Serialize, Deserialize)]
567struct EncodedTreeEntryV2 {
568 name: String,
569 kind: u8,
570 hash: Option<ContentHash>,
571 executable: Option<bool>,
572 git_format: Option<u8>,
573 git_oid: Option<Vec<u8>>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
578 spool_id: Option<ChangeId>,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 spool_state_id: Option<ChangeId>,
581}
582
583impl Serialize for Tree {
584 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
585 where
586 S: Serializer,
587 {
588 EncodedTreeV2::from(self).serialize(serializer)
589 }
590}
591
592impl<'de> Deserialize<'de> for Tree {
593 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
594 where
595 D: Deserializer<'de>,
596 {
597 let encoded = EncodedTreeV2::deserialize(deserializer)?;
598 Tree::try_from(encoded).map_err(de::Error::custom)
599 }
600}
601
602#[derive(Debug)]
603pub(crate) enum TreeDecodeError {
604 Decode(rmp_serde::decode::Error),
605 Invalid(TreeError),
606}
607
608impl From<rmp_serde::decode::Error> for TreeDecodeError {
609 fn from(error: rmp_serde::decode::Error) -> Self {
610 Self::Decode(error)
611 }
612}
613
614impl From<TreeError> for TreeDecodeError {
615 fn from(error: TreeError) -> Self {
616 Self::Invalid(error)
617 }
618}
619
620impl From<&Tree> for EncodedTreeV2 {
621 fn from(tree: &Tree) -> Self {
622 Self {
623 version: TREE_FORMAT_VERSION,
624 entries: tree.entries.iter().map(EncodedTreeEntryV2::from).collect(),
625 }
626 }
627}
628
629impl From<&TreeEntry> for EncodedTreeEntryV2 {
630 fn from(entry: &TreeEntry) -> Self {
631 match entry.target() {
632 TreeEntryTarget::Blob { hash, executable } => Self {
633 name: entry.name.clone(),
634 kind: ENTRY_KIND_BLOB,
635 hash: Some(*hash),
636 executable: Some(*executable),
637 git_format: None,
638 git_oid: None,
639 spool_id: None,
640 spool_state_id: None,
641 },
642 TreeEntryTarget::Tree { hash } => Self {
643 name: entry.name.clone(),
644 kind: ENTRY_KIND_TREE,
645 hash: Some(*hash),
646 executable: None,
647 git_format: None,
648 git_oid: None,
649 spool_id: None,
650 spool_state_id: None,
651 },
652 TreeEntryTarget::Symlink { hash } => Self {
653 name: entry.name.clone(),
654 kind: ENTRY_KIND_SYMLINK,
655 hash: Some(*hash),
656 executable: None,
657 git_format: None,
658 git_oid: None,
659 spool_id: None,
660 spool_state_id: None,
661 },
662 TreeEntryTarget::Gitlink { target } => Self {
663 name: entry.name.clone(),
664 kind: ENTRY_KIND_GITLINK,
665 hash: None,
666 executable: None,
667 git_format: Some(git_format_to_tag(target.format())),
668 git_oid: Some(target.as_bytes().to_vec()),
669 spool_id: None,
670 spool_state_id: None,
671 },
672 TreeEntryTarget::Spoollink { spool_id, state_id } => Self {
673 name: entry.name.clone(),
674 kind: ENTRY_KIND_SPOOLLINK,
675 hash: None,
676 executable: None,
677 git_format: None,
678 git_oid: None,
679 spool_id: Some(*spool_id),
680 spool_state_id: Some(*state_id),
681 },
682 }
683 }
684}
685
686impl TryFrom<EncodedTreeV2> for Tree {
687 type Error = TreeError;
688
689 fn try_from(encoded: EncodedTreeV2) -> Result<Self, Self::Error> {
690 if encoded.version != TREE_FORMAT_VERSION {
691 return Err(TreeError::InvalidStructure(format!(
692 "unsupported tree format version {}; this binary writes {}",
693 encoded.version, TREE_FORMAT_VERSION
694 )));
695 }
696 let mut entries = Vec::with_capacity(encoded.entries.len());
697 for entry in encoded.entries {
698 entries.push(TreeEntry::try_from(entry)?);
699 }
700 let tree = Tree::from_entries(entries);
701 tree.validate()?;
702 Ok(tree)
703 }
704}
705
706impl Tree {
707 pub(crate) fn decode_current_msgpack(data: &[u8]) -> Result<Self, TreeDecodeError> {
708 let encoded: EncodedTreeV2 = rmp_serde::from_slice(data)?;
709 Ok(Tree::try_from(encoded)?)
710 }
711}
712
713impl TryFrom<EncodedTreeEntryV2> for TreeEntry {
714 type Error = TreeError;
715
716 fn try_from(encoded: EncodedTreeEntryV2) -> Result<Self, Self::Error> {
717 match encoded.kind {
718 ENTRY_KIND_BLOB => TreeEntry::file(
719 encoded.name,
720 required_hash(encoded.hash, ENTRY_KIND_BLOB)?,
721 encoded.executable.unwrap_or(false),
722 ),
723 ENTRY_KIND_TREE => {
724 TreeEntry::directory(encoded.name, required_hash(encoded.hash, ENTRY_KIND_TREE)?)
725 }
726 ENTRY_KIND_SYMLINK => TreeEntry::symlink(
727 encoded.name,
728 required_hash(encoded.hash, ENTRY_KIND_SYMLINK)?,
729 ),
730 ENTRY_KIND_GITLINK => {
731 let format = git_format_from_tag(required_git_format(
732 encoded.git_format,
733 ENTRY_KIND_GITLINK,
734 )?)?;
735 let oid = encoded.git_oid.ok_or_else(|| {
736 TreeError::InvalidStructure("gitlink entry is missing git_oid".into())
737 })?;
738 let target = GitObjectId::from_raw(format, &oid).map_err(|err| {
739 TreeError::InvalidStructure(format!("invalid gitlink target: {err}"))
740 })?;
741 TreeEntry::gitlink(encoded.name, target)
742 }
743 ENTRY_KIND_SPOOLLINK => {
744 let spool_id = encoded.spool_id.ok_or_else(|| {
745 TreeError::InvalidStructure("spoollink entry is missing spool_id".into())
746 })?;
747 let state_id = encoded.spool_state_id.ok_or_else(|| {
748 TreeError::InvalidStructure("spoollink entry is missing spool_state_id".into())
749 })?;
750 TreeEntry::spoollink(encoded.name, spool_id, state_id)
751 }
752 other => Err(TreeError::InvalidStructure(format!(
753 "unknown tree entry kind {other}"
754 ))),
755 }
756 }
757}
758
759fn required_hash(hash: Option<ContentHash>, kind: u8) -> Result<ContentHash, TreeError> {
760 hash.ok_or_else(|| TreeError::InvalidStructure(format!("entry kind {kind} is missing hash")))
761}
762
763fn required_git_format(format: Option<u8>, kind: u8) -> Result<u8, TreeError> {
764 format.ok_or_else(|| {
765 TreeError::InvalidStructure(format!("entry kind {kind} is missing git_format"))
766 })
767}
768
769fn git_format_to_tag(format: GitObjectFormat) -> u8 {
770 match format {
771 GitObjectFormat::Sha1 => GIT_OBJECT_FORMAT_SHA1,
772 GitObjectFormat::Sha256 => GIT_OBJECT_FORMAT_SHA256,
773 }
774}
775
776fn git_format_from_tag(tag: u8) -> Result<GitObjectFormat, TreeError> {
777 match tag {
778 GIT_OBJECT_FORMAT_SHA1 => Ok(GitObjectFormat::Sha1),
779 GIT_OBJECT_FORMAT_SHA256 => Ok(GitObjectFormat::Sha256),
780 other => Err(TreeError::InvalidStructure(format!(
781 "unknown git object format tag {other}"
782 ))),
783 }
784}
785
786impl Default for Tree {
787 fn default() -> Self {
788 Self::new()
789 }
790}
791
792impl IntoIterator for Tree {
793 type Item = TreeEntry;
794 type IntoIter = std::vec::IntoIter<TreeEntry>;
795
796 fn into_iter(self) -> Self::IntoIter {
797 self.entries.into_iter()
798 }
799}
800
801impl<'a> IntoIterator for &'a Tree {
802 type Item = &'a TreeEntry;
803 type IntoIter = std::slice::Iter<'a, TreeEntry>;
804
805 fn into_iter(self) -> Self::IntoIter {
806 self.entries.iter()
807 }
808}
809
810#[cfg(test)]
811mod spoollink_tests {
812 use super::*;
813
814 #[test]
815 fn spoollink_entry_shape() {
816 let spool_id = ChangeId::from_bytes([7u8; 16]);
817 let state_id = ChangeId::from_bytes([9u8; 16]);
818 let entry = TreeEntry::spoollink("child", spool_id, state_id).unwrap();
819
820 assert!(entry.is_spoollink());
821 assert_eq!(entry.entry_type(), EntryType::Spoollink);
822 assert_eq!(entry.mode(), FileMode::Spoollink);
823 assert_eq!(entry.content_hash(), None);
825 assert_eq!(entry.leaf_content_hash(), None);
826 assert_eq!(entry.gitlink_target(), None);
827 assert_eq!(entry.spoollink_target(), Some((spool_id, state_id)));
828 }
829
830 #[test]
831 fn spoollink_roundtrips_through_encoded_tree_v2() {
832 let spool_id = ChangeId::from_bytes([1u8; 16]);
833 let state_id = ChangeId::from_bytes([2u8; 16]);
834
835 let blob_hash = ContentHash::compute(b"hello");
838 let tree = Tree::from_entries(vec![
839 TreeEntry::file("a_blob", blob_hash, false).unwrap(),
840 TreeEntry::spoollink("z_child", spool_id, state_id).unwrap(),
841 ]);
842
843 let bytes = rmp_serde::to_vec(&tree).unwrap();
844 let decoded = Tree::decode_current_msgpack(&bytes).unwrap();
845
846 assert_eq!(decoded, tree, "tree round-trip must be lossless");
847
848 let child = decoded.get("z_child").expect("spoollink survives round-trip");
849 assert_eq!(child.spoollink_target(), Some((spool_id, state_id)));
850 assert_eq!(child.entry_type(), EntryType::Spoollink);
851
852 assert_eq!(decoded.hash(), tree.hash());
854 }
855
856 #[test]
857 fn file_mode_spoollink_has_no_git_mode() {
858 assert_eq!(FileMode::Spoollink.to_unix_mode(), 0);
861 assert_ne!(FileMode::Spoollink.to_unix_mode(), 0o160000);
862 assert_eq!(FileMode::from_byte(FileMode::Spoollink.to_byte()), Some(FileMode::Spoollink));
863 assert_eq!(EntryType::from_byte(EntryType::Spoollink.to_byte()), Some(EntryType::Spoollink));
864 }
865}