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 { hash: ContentHash, executable: bool },
136    Tree { hash: ContentHash },
137    Symlink { hash: ContentHash },
138    Gitlink { target: GitObjectId },
139    /// Native pointer to a child spool: a spool-id plus an anchored state-id.
140    /// Unlike [`Self::Gitlink`], this is NOT a git object OID and cannot
141    /// round-trip to a git submodule; git-boundary code must handle it
142    /// explicitly (skip on export). The Spool children facet consumes this in
143    /// a later phase.
144    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    /// The child-spool pointer `(spool_id, state_id)` for a spoollink entry,
191    /// or `None` for any other kind.
192    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
230// ── TreeEntry ───────────────────────────────────────────────────────
231
232pub 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    /// Build a native child-spool edge: a pointer to `spool_id` anchored at
318    /// `state_id`. Not a git submodule (see [`TreeEntryTarget::Spoollink`]).
319    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    /// The `(spool_id, state_id)` pointer for a spoollink entry, else `None`.
421    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// ── Tree ────────────────────────────────────────────────────────────
461
462#[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// ── Durable V2 tree encoding ───────────────────────────────────────
559
560#[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    // Child-spool pointer for SPOOLLINK entries (16-byte ChangeIds). `default`
575    // keeps the encoding backward-compatible: pre-SPOOLLINK payloads simply
576    // omit these fields.
577    #[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        // Native edge carries no Heddle content hash and no git OID.
824        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        // Mix a spoollink alongside the existing kinds so the round-trip also
836        // proves existing entries are undisturbed.
837        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        // Hash is stable and distinct from a same-name gitlink/blob shape.
853        assert_eq!(decoded.hash(), tree.hash());
854    }
855
856    #[test]
857    fn file_mode_spoollink_has_no_git_mode() {
858        // The whole point of a dedicated kind: it must NOT masquerade as a
859        // git submodule (160000) or any other real git mode.
860        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}