1use std::{fmt, path::Path, sync::Arc};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7use sley::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
8
9use super::{ContentHash, SpoolId, StateId};
10
11const TREE_FORMAT_VERSION: u8 = 3;
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: SpoolId,
155 state_id: StateId,
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<(&SpoolId, StateId)> {
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 4 + spool_id.as_str().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_str().len() as u32).to_le_bytes());
233 hasher.update(spool_id.as_str().as_bytes());
234 hasher.update(state_id.as_bytes())
235 }
236 };
237 }
238}
239
240pub fn validate_name(name: &str) -> Result<(), TreeError> {
243 if name.is_empty() {
244 return Err(TreeError::InvalidName("entry name cannot be empty".into()));
245 }
246 if name == "." || name == ".." {
247 return Err(TreeError::InvalidName(format!(
248 "'{}' is not a valid entry name",
249 name
250 )));
251 }
252 if name.contains('/') || name.contains('\\') {
253 return Err(TreeError::InvalidName(
254 "entry name cannot contain path separators".into(),
255 ));
256 }
257 if name.bytes().any(|b| b < 0x20 || b == 0x7f) {
258 return Err(TreeError::InvalidName(
259 "entry name contains control characters".into(),
260 ));
261 }
262 if name.len() > u16::MAX as usize {
263 return Err(TreeError::InvalidName(
264 "entry name exceeds the HTR4 u16 length bound".into(),
265 ));
266 }
267 Ok(())
268}
269
270#[derive(Clone, Debug, PartialEq, Eq)]
271pub struct TreeEntry {
272 name: String,
273 target: TreeEntryTarget,
274}
275
276impl TreeEntry {
277 pub(crate) fn validate(&self) -> Result<(), TreeError> {
278 validate_name(&self.name)
279 }
280
281 pub fn file(
282 name: impl Into<String>,
283 hash: ContentHash,
284 executable: bool,
285 ) -> Result<Self, TreeError> {
286 let name = name.into();
287 validate_name(&name)?;
288 Ok(Self {
289 name,
290 target: TreeEntryTarget::Blob { hash, executable },
291 })
292 }
293
294 pub fn directory(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
295 let name = name.into();
296 validate_name(&name)?;
297 Ok(Self {
298 name,
299 target: TreeEntryTarget::Tree { hash },
300 })
301 }
302
303 pub fn symlink(name: impl Into<String>, hash: ContentHash) -> Result<Self, TreeError> {
304 let name = name.into();
305 validate_name(&name)?;
306 Ok(Self {
307 name,
308 target: TreeEntryTarget::Symlink { hash },
309 })
310 }
311
312 pub fn gitlink(name: impl Into<String>, target: GitObjectId) -> Result<Self, TreeError> {
313 let name = name.into();
314 validate_name(&name)?;
315 Ok(Self {
316 name,
317 target: TreeEntryTarget::Gitlink { target },
318 })
319 }
320
321 pub fn spoollink(
324 name: impl Into<String>,
325 spool_id: SpoolId,
326 state_id: StateId,
327 ) -> Result<Self, TreeError> {
328 let name = name.into();
329 validate_name(&name)?;
330 Ok(Self {
331 name,
332 target: TreeEntryTarget::Spoollink { spool_id, state_id },
333 })
334 }
335
336 pub fn name(&self) -> &str {
337 &self.name
338 }
339
340 pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), TreeError> {
341 let name = name.into();
342 validate_name(&name)?;
343 self.name = name;
344 Ok(())
345 }
346
347 pub fn with_mode(&self, mode: FileMode) -> Result<Self, TreeError> {
348 match (&self.target, mode) {
349 (TreeEntryTarget::Blob { hash, .. }, FileMode::Normal | FileMode::Executable) => {
350 Self::file(self.name.clone(), *hash, mode == FileMode::Executable)
351 }
352 (TreeEntryTarget::Symlink { .. }, FileMode::Symlink)
353 | (TreeEntryTarget::Tree { .. }, _)
354 | (TreeEntryTarget::Gitlink { .. }, FileMode::Gitlink)
355 | (TreeEntryTarget::Spoollink { .. }, FileMode::Spoollink)
356 if mode == self.mode() =>
357 {
358 Ok(self.clone())
359 }
360 _ => Err(TreeError::InvalidStructure(format!(
361 "cannot apply mode {:?} to {:?} entry '{}'",
362 mode,
363 self.entry_type(),
364 self.name
365 ))),
366 }
367 }
368
369 pub fn target(&self) -> &TreeEntryTarget {
370 &self.target
371 }
372
373 pub fn entry_type(&self) -> EntryType {
374 self.target.entry_type()
375 }
376
377 pub fn mode(&self) -> FileMode {
378 self.target.mode()
379 }
380
381 pub fn content_hash(&self) -> Option<ContentHash> {
382 self.target.content_hash()
383 }
384
385 pub fn leaf_content_hash(&self) -> Option<ContentHash> {
386 match self.target {
387 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => Some(hash),
388 TreeEntryTarget::Tree { .. }
389 | TreeEntryTarget::Gitlink { .. }
390 | TreeEntryTarget::Spoollink { .. } => None,
391 }
392 }
393
394 pub fn require_content_hash(&self) -> ContentHash {
395 self.content_hash()
396 .expect("tree entry target does not carry a Heddle content hash")
397 }
398
399 pub fn blob_hash(&self) -> Option<ContentHash> {
400 match self.target {
401 TreeEntryTarget::Blob { hash, .. } => Some(hash),
402 _ => None,
403 }
404 }
405
406 pub fn tree_hash(&self) -> Option<ContentHash> {
407 match self.target {
408 TreeEntryTarget::Tree { hash } => Some(hash),
409 _ => None,
410 }
411 }
412
413 pub fn symlink_hash(&self) -> Option<ContentHash> {
414 match self.target {
415 TreeEntryTarget::Symlink { hash } => Some(hash),
416 _ => None,
417 }
418 }
419
420 pub fn gitlink_target(&self) -> Option<GitObjectId> {
421 self.target.gitlink_target()
422 }
423
424 pub fn spoollink_target(&self) -> Option<(&SpoolId, StateId)> {
426 self.target.spoollink_target()
427 }
428
429 pub fn is_tree(&self) -> bool {
430 self.entry_type() == EntryType::Tree
431 }
432
433 pub fn is_blob(&self) -> bool {
434 self.entry_type() == EntryType::Blob
435 }
436
437 pub fn is_symlink(&self) -> bool {
438 self.entry_type() == EntryType::Symlink
439 }
440
441 pub fn is_gitlink(&self) -> bool {
442 self.entry_type() == EntryType::Gitlink
443 }
444
445 pub fn is_spoollink(&self) -> bool {
446 self.entry_type() == EntryType::Spoollink
447 }
448
449 pub fn is_executable(&self) -> bool {
450 self.mode() == FileMode::Executable
451 }
452
453 pub(crate) fn encoded_len(&self) -> usize {
454 1 + 1 + self.target.encoded_payload_len() + self.name.len() + 1
455 }
456
457 pub fn decoded_size(&self) -> usize {
459 self.name.len() + self.target.encoded_payload_len()
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: Arc<Vec<TreeEntry>>,
477}
478
479impl Tree {
480 pub fn new() -> Self {
481 Self {
482 entries: Arc::new(Vec::new()),
483 }
484 }
485
486 pub fn from_entries(mut entries: Vec<TreeEntry>) -> Self {
487 entries.sort_by(|a, b| a.name.cmp(&b.name));
488 Self {
489 entries: Arc::new(entries),
490 }
491 }
492
493 pub fn try_from_decoded_entries(entries: Vec<TreeEntry>) -> Result<Self, TreeError> {
499 let tree = Self {
500 entries: Arc::new(entries),
501 };
502 tree.validate()?;
503 Ok(tree)
504 }
505
506 pub fn validate(&self) -> Result<(), TreeError> {
507 let mut previous_name: Option<&str> = None;
508 for entry in self.entries.iter() {
509 entry.validate()?;
510 if let Some(previous) = previous_name
511 && previous >= entry.name.as_str()
512 {
513 return Err(TreeError::InvalidStructure(
514 "entries must be strictly sorted by name".to_string(),
515 ));
516 }
517 previous_name = Some(&entry.name);
518 }
519 Ok(())
520 }
521
522 pub fn entries(&self) -> &[TreeEntry] {
523 &self.entries
524 }
525
526 pub fn get(&self, name: &str) -> Option<&TreeEntry> {
527 let index = self
528 .entries
529 .binary_search_by(|entry| entry.name.as_str().cmp(name))
530 .ok()?;
531 self.entries.get(index)
532 }
533
534 pub fn insert(&mut self, entry: TreeEntry) {
535 let entries = Arc::make_mut(&mut self.entries);
536 entries.retain(|e| e.name != entry.name);
537 let pos = entries
538 .iter()
539 .position(|e| e.name > entry.name)
540 .unwrap_or(entries.len());
541 entries.insert(pos, entry);
542 }
543
544 pub fn remove(&mut self, name: &str) -> Option<TreeEntry> {
545 let pos = self.entries.iter().position(|e| e.name == name)?;
546 Some(Arc::make_mut(&mut self.entries).remove(pos))
547 }
548
549 pub fn is_empty(&self) -> bool {
550 self.entries.is_empty()
551 }
552
553 pub fn len(&self) -> usize {
554 self.entries.len()
555 }
556
557 pub fn hash(&self) -> ContentHash {
558 let total_len: usize = self.entries.iter().map(TreeEntry::encoded_len).sum();
559 ContentHash::compute_typed_with_len("tree", total_len as u64, |hasher| {
560 for entry in self.entries.iter() {
561 entry.update_hasher(hasher);
562 }
563 })
564 }
565
566 pub fn iter(&self) -> impl Iterator<Item = &TreeEntry> {
567 self.entries.iter()
568 }
569
570 pub fn get_path(&self, path: &Path) -> Option<&TreeEntry> {
571 let name = path.file_name()?.to_str()?;
572 if path.parent().is_none_or(|p| p.as_os_str().is_empty()) {
573 self.get(name)
574 } else {
575 None
576 }
577 }
578}
579
580#[derive(Serialize, Deserialize)]
583struct EncodedTreeV2 {
584 version: u8,
585 entries: Vec<EncodedTreeEntryV2>,
586}
587
588#[derive(Serialize, Deserialize)]
589struct EncodedTreeEntryV2 {
590 name: String,
591 kind: u8,
592 hash: Option<ContentHash>,
593 executable: Option<bool>,
594 git_format: Option<u8>,
595 git_oid: Option<Vec<u8>>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
600 spool_id: Option<SpoolId>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 spool_state_id: Option<StateId>,
603}
604
605impl Serialize for Tree {
606 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
607 where
608 S: Serializer,
609 {
610 EncodedTreeV2::from(self).serialize(serializer)
611 }
612}
613
614impl<'de> Deserialize<'de> for Tree {
615 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
616 where
617 D: Deserializer<'de>,
618 {
619 let encoded = EncodedTreeV2::deserialize(deserializer)?;
620 Tree::try_from(encoded).map_err(de::Error::custom)
621 }
622}
623
624#[derive(Debug)]
625pub enum TreeDecodeError {
626 Decode(rmp_serde::decode::Error),
627 Invalid(TreeError),
628}
629
630impl From<rmp_serde::decode::Error> for TreeDecodeError {
631 fn from(error: rmp_serde::decode::Error) -> Self {
632 Self::Decode(error)
633 }
634}
635
636impl From<TreeError> for TreeDecodeError {
637 fn from(error: TreeError) -> Self {
638 Self::Invalid(error)
639 }
640}
641
642impl From<&Tree> for EncodedTreeV2 {
643 fn from(tree: &Tree) -> Self {
644 Self {
645 version: TREE_FORMAT_VERSION,
646 entries: tree.entries.iter().map(EncodedTreeEntryV2::from).collect(),
647 }
648 }
649}
650
651impl From<&TreeEntry> for EncodedTreeEntryV2 {
652 fn from(entry: &TreeEntry) -> Self {
653 match entry.target() {
654 TreeEntryTarget::Blob { hash, executable } => Self {
655 name: entry.name.clone(),
656 kind: ENTRY_KIND_BLOB,
657 hash: Some(*hash),
658 executable: Some(*executable),
659 git_format: None,
660 git_oid: None,
661 spool_id: None,
662 spool_state_id: None,
663 },
664 TreeEntryTarget::Tree { hash } => Self {
665 name: entry.name.clone(),
666 kind: ENTRY_KIND_TREE,
667 hash: Some(*hash),
668 executable: None,
669 git_format: None,
670 git_oid: None,
671 spool_id: None,
672 spool_state_id: None,
673 },
674 TreeEntryTarget::Symlink { hash } => Self {
675 name: entry.name.clone(),
676 kind: ENTRY_KIND_SYMLINK,
677 hash: Some(*hash),
678 executable: None,
679 git_format: None,
680 git_oid: None,
681 spool_id: None,
682 spool_state_id: None,
683 },
684 TreeEntryTarget::Gitlink { target } => Self {
685 name: entry.name.clone(),
686 kind: ENTRY_KIND_GITLINK,
687 hash: None,
688 executable: None,
689 git_format: Some(git_format_to_tag(target.format())),
690 git_oid: Some(target.as_bytes().to_vec()),
691 spool_id: None,
692 spool_state_id: None,
693 },
694 TreeEntryTarget::Spoollink { spool_id, state_id } => Self {
695 name: entry.name.clone(),
696 kind: ENTRY_KIND_SPOOLLINK,
697 hash: None,
698 executable: None,
699 git_format: None,
700 git_oid: None,
701 spool_id: Some(spool_id.clone()),
702 spool_state_id: Some(*state_id),
703 },
704 }
705 }
706}
707
708impl TryFrom<EncodedTreeV2> for Tree {
709 type Error = TreeError;
710
711 fn try_from(encoded: EncodedTreeV2) -> Result<Self, Self::Error> {
712 if encoded.version != TREE_FORMAT_VERSION {
713 return Err(TreeError::InvalidStructure(format!(
714 "unsupported tree format version {}; this binary writes {}",
715 encoded.version, TREE_FORMAT_VERSION
716 )));
717 }
718 let mut entries = Vec::with_capacity(encoded.entries.len());
719 for entry in encoded.entries {
720 entries.push(TreeEntry::try_from(entry)?);
721 }
722 Tree::try_from_decoded_entries(entries)
723 }
724}
725
726impl Tree {
727 pub fn decode_current_msgpack(data: &[u8]) -> Result<Self, TreeDecodeError> {
728 let encoded: EncodedTreeV2 = rmp_serde::from_slice(data)?;
729 Ok(Tree::try_from(encoded)?)
730 }
731}
732
733impl TryFrom<EncodedTreeEntryV2> for TreeEntry {
734 type Error = TreeError;
735
736 fn try_from(encoded: EncodedTreeEntryV2) -> Result<Self, Self::Error> {
737 match encoded.kind {
738 ENTRY_KIND_BLOB => TreeEntry::file(
739 encoded.name,
740 required_hash(encoded.hash, ENTRY_KIND_BLOB)?,
741 encoded.executable.unwrap_or(false),
742 ),
743 ENTRY_KIND_TREE => {
744 TreeEntry::directory(encoded.name, required_hash(encoded.hash, ENTRY_KIND_TREE)?)
745 }
746 ENTRY_KIND_SYMLINK => TreeEntry::symlink(
747 encoded.name,
748 required_hash(encoded.hash, ENTRY_KIND_SYMLINK)?,
749 ),
750 ENTRY_KIND_GITLINK => {
751 let format = git_format_from_tag(required_git_format(
752 encoded.git_format,
753 ENTRY_KIND_GITLINK,
754 )?)?;
755 let oid = encoded.git_oid.ok_or_else(|| {
756 TreeError::InvalidStructure("gitlink entry is missing git_oid".into())
757 })?;
758 let target = GitObjectId::from_raw(format, &oid).map_err(|err| {
759 TreeError::InvalidStructure(format!("invalid gitlink target: {err}"))
760 })?;
761 TreeEntry::gitlink(encoded.name, target)
762 }
763 ENTRY_KIND_SPOOLLINK => {
764 let spool_id = encoded.spool_id.ok_or_else(|| {
765 TreeError::InvalidStructure("spoollink entry is missing spool_id".into())
766 })?;
767 let state_id = encoded.spool_state_id.ok_or_else(|| {
768 TreeError::InvalidStructure("spoollink entry is missing spool_state_id".into())
769 })?;
770 TreeEntry::spoollink(encoded.name, spool_id, state_id)
771 }
772 other => Err(TreeError::InvalidStructure(format!(
773 "unknown tree entry kind {other}"
774 ))),
775 }
776 }
777}
778
779fn required_hash(hash: Option<ContentHash>, kind: u8) -> Result<ContentHash, TreeError> {
780 hash.ok_or_else(|| TreeError::InvalidStructure(format!("entry kind {kind} is missing hash")))
781}
782
783fn required_git_format(format: Option<u8>, kind: u8) -> Result<u8, TreeError> {
784 format.ok_or_else(|| {
785 TreeError::InvalidStructure(format!("entry kind {kind} is missing git_format"))
786 })
787}
788
789pub(crate) fn git_format_to_tag(format: GitObjectFormat) -> u8 {
790 match format {
791 GitObjectFormat::Sha1 => GIT_OBJECT_FORMAT_SHA1,
792 GitObjectFormat::Sha256 => GIT_OBJECT_FORMAT_SHA256,
793 }
794}
795
796pub(crate) fn git_format_from_tag(tag: u8) -> Result<GitObjectFormat, TreeError> {
797 match tag {
798 GIT_OBJECT_FORMAT_SHA1 => Ok(GitObjectFormat::Sha1),
799 GIT_OBJECT_FORMAT_SHA256 => Ok(GitObjectFormat::Sha256),
800 other => Err(TreeError::InvalidStructure(format!(
801 "unknown git object format tag {other}"
802 ))),
803 }
804}
805
806impl Default for Tree {
807 fn default() -> Self {
808 Self::new()
809 }
810}
811
812impl IntoIterator for Tree {
813 type Item = TreeEntry;
814 type IntoIter = std::vec::IntoIter<TreeEntry>;
815
816 fn into_iter(self) -> Self::IntoIter {
817 Arc::try_unwrap(self.entries)
818 .unwrap_or_else(|entries| (*entries).clone())
819 .into_iter()
820 }
821}
822
823impl<'a> IntoIterator for &'a Tree {
824 type Item = &'a TreeEntry;
825 type IntoIter = std::slice::Iter<'a, TreeEntry>;
826
827 fn into_iter(self) -> Self::IntoIter {
828 self.entries.iter()
829 }
830}
831
832#[cfg(test)]
833mod spoollink_tests {
834 use super::*;
835
836 #[test]
837 fn spoollink_entry_shape() {
838 let spool_id = SpoolId::parse("acme/child").unwrap();
839 let state_id = StateId::from_bytes([9u8; 32]);
840 let entry = TreeEntry::spoollink("child", spool_id.clone(), state_id).unwrap();
841
842 assert!(entry.is_spoollink());
843 assert_eq!(entry.entry_type(), EntryType::Spoollink);
844 assert_eq!(entry.mode(), FileMode::Spoollink);
845 assert_eq!(entry.content_hash(), None);
847 assert_eq!(entry.leaf_content_hash(), None);
848 assert_eq!(entry.gitlink_target(), None);
849 assert_eq!(entry.spoollink_target(), Some((&spool_id, state_id)));
850 }
851
852 #[test]
853 fn spoollink_roundtrips_through_encoded_tree_v2() {
854 let spool_id = SpoolId::parse("acme/child").unwrap();
855 let state_id = StateId::from_bytes([2u8; 32]);
856
857 let blob_hash = ContentHash::compute(b"hello");
860 let tree = Tree::from_entries(vec![
861 TreeEntry::file("a_blob", blob_hash, false).unwrap(),
862 TreeEntry::spoollink("z_child", spool_id.clone(), state_id).unwrap(),
863 ]);
864
865 let bytes = rmp_serde::to_vec(&tree).unwrap();
866 let decoded = Tree::decode_current_msgpack(&bytes).unwrap();
867
868 assert_eq!(decoded, tree, "tree round-trip must be lossless");
869
870 let child = decoded
871 .get("z_child")
872 .expect("spoollink survives round-trip");
873 assert_eq!(child.spoollink_target(), Some((&spool_id, state_id)));
874 assert_eq!(child.entry_type(), EntryType::Spoollink);
875
876 assert_eq!(decoded.hash(), tree.hash());
878 }
879
880 #[test]
881 fn file_mode_spoollink_has_no_git_mode() {
882 assert_eq!(FileMode::Spoollink.to_unix_mode(), 0);
885 assert_ne!(FileMode::Spoollink.to_unix_mode(), 0o160000);
886 assert_eq!(
887 FileMode::from_byte(FileMode::Spoollink.to_byte()),
888 Some(FileMode::Spoollink)
889 );
890 assert_eq!(
891 EntryType::from_byte(EntryType::Spoollink.to_byte()),
892 Some(EntryType::Spoollink)
893 );
894 }
895}
896
897#[cfg(test)]
898mod cow_tests {
899 use super::*;
900
901 fn fixture() -> Tree {
902 Tree::from_entries(vec![
903 TreeEntry::file("a", ContentHash::compute(b"a"), false).unwrap(),
904 TreeEntry::file("b", ContentHash::compute(b"b"), true).unwrap(),
905 ])
906 }
907
908 #[test]
909 fn clone_shares_entries_until_mutated() {
910 let original = fixture();
911 let mut clone = original.clone();
912 assert!(Arc::ptr_eq(&original.entries, &clone.entries));
913
914 clone.insert(TreeEntry::file("c", ContentHash::compute(b"c"), false).unwrap());
915
916 assert!(!Arc::ptr_eq(&original.entries, &clone.entries));
917 assert!(original.get("c").is_none());
918 assert!(clone.get("c").is_some());
919 }
920
921 #[test]
922 fn clone_mutation_preserves_original_hash_and_encoding() {
923 let original = fixture();
924 let original_hash = original.hash();
925 let original_bytes = rmp_serde::to_vec_named(&original).unwrap();
926 let mut clone = original.clone();
927
928 assert!(clone.remove("a").is_some());
929
930 assert_eq!(original.hash(), original_hash);
931 assert_eq!(rmp_serde::to_vec_named(&original).unwrap(), original_bytes);
932 assert_ne!(clone.hash(), original_hash);
933 }
934
935 #[test]
936 fn clone_and_mutate_roundtrips_through_durable_encoding() {
937 let mut tree = fixture().clone();
938 tree.insert(TreeEntry::directory("dir", ContentHash::compute(b"dir")).unwrap());
939 let encoded = rmp_serde::to_vec_named(&tree).unwrap();
940 let decoded: Tree = rmp_serde::from_slice(&encoded).unwrap();
941
942 assert_eq!(decoded, tree);
943 assert_eq!(decoded.hash(), tree.hash());
944 }
945}