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