Skip to main content

objects/object/
tree.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tree types: entries, structure, and supporting enums.
3
4use 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;
16/// Native child-spool edge: the entry's payload is a spool-id + anchored
17/// state-id (both 16-byte [`ChangeId`]s), NOT a git commit OID. This link is
18/// deliberately NOT a git submodule — see [`FileMode::Spoollink`].
19const ENTRY_KIND_SPOOLLINK: u8 = 4;
20const GIT_OBJECT_FORMAT_SHA1: u8 = 1;
21const GIT_OBJECT_FORMAT_SHA256: u8 = 2;
22
23// ── TreeError ───────────────────────────────────────────────────────
24
25#[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// ── FileMode ────────────────────────────────────────────────────────
43
44#[repr(u8)]
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum FileMode {
47    Normal,
48    Executable,
49    Symlink,
50    Gitlink,
51    /// Native child-spool edge. This is NOT a git file mode: a spoollink
52    /// points at a spool-id + state-id, not a git object, so it has no valid
53    /// git submodule (`160000`) representation and [`Self::to_unix_mode`]
54    /// returns `0`. Git-boundary code MUST handle it explicitly rather than
55    /// emit a bogus mode.
56    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    /// The git tree/index mode for this entry. A spoollink has no git mode
82    /// (it is not a git object) and returns `0` — callers on a git boundary
83    /// must skip spoollinks rather than treat this as a real mode.
84    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// ── EntryType ───────────────────────────────────────────────────────
96
97#[repr(u8)]
98#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub enum EntryType {
100    Blob,
101    Tree,
102    Symlink,
103    Gitlink,
104    /// Native child-spool edge (see [`TreeEntryTarget::Spoollink`]).
105    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// ── TreeEntryTarget ────────────────────────────────────────────────
132
133#[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    /// Native pointer to a child spool: a spool-id plus an anchored state-id.
149    /// Unlike [`Self::Gitlink`], this is NOT a git object OID and cannot
150    /// round-trip to a git submodule; git-boundary code must handle it
151    /// explicitly (skip on export). The Spool children facet consumes this in
152    /// a later phase.
153    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    /// The child-spool pointer `(spool_id, state_id)` for a spoollink entry,
200    /// or `None` for any other kind.
201    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
239// ── TreeEntry ───────────────────────────────────────────────────────
240
241pub 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    /// Build a native child-spool edge: a pointer to `spool_id` anchored at
327    /// `state_id`. Not a git submodule (see [`TreeEntryTarget::Spoollink`]).
328    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    /// The `(spool_id, state_id)` pointer for a spoollink entry, else `None`.
430    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// ── Tree ────────────────────────────────────────────────────────────
470
471#[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// ── Durable V2 tree encoding ───────────────────────────────────────
568
569#[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    // Child-spool pointer for SPOOLLINK entries (16-byte ChangeIds). `default`
584    // keeps the encoding backward-compatible: pre-SPOOLLINK payloads simply
585    // omit these fields.
586    #[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        // Native edge carries no Heddle content hash and no git OID.
833        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        // Mix a spoollink alongside the existing kinds so the round-trip also
845        // proves existing entries are undisturbed.
846        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        // Hash is stable and distinct from a same-name gitlink/blob shape.
864        assert_eq!(decoded.hash(), tree.hash());
865    }
866
867    #[test]
868    fn file_mode_spoollink_has_no_git_mode() {
869        // The whole point of a dedicated kind: it must NOT masquerade as a
870        // git submodule (160000) or any other real git mode.
871        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}