Skip to main content

wire/
transfer_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared repository transfer planning primitives.
3//!
4//! The wire protocol still carries the existing push/pull messages. This
5//! module gives local and hosted sync paths one Rust-native vocabulary for the
6//! Heddle object lane: content-addressed objects that can ride the native pack,
7//! and signed sidecars that must use the out-of-pack verification paths.
8
9use objects::{object::StateId, store::ObjectStore};
10
11use crate::{
12    ObjectInfo, ObjectType, ObjectTypeBucket, PlannedObject, Result, StateClosureOptions,
13    enumerate_state_closure_plan_with_options,
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
17pub enum GitLaneTransferIntent {
18    /// This transfer contains only Heddle content-addressed objects and
19    /// sidecars; no Git-lane work is expected.
20    #[default]
21    HeddleObjectsOnly,
22    /// Hosted Git-lane pack streaming remains on the current implementation.
23    /// The shared transfer plan records that fact without taking ownership of
24    /// reachable Git pack construction.
25    ExistingImplementation,
26    /// Placeholder for the future Sley facade boundary. Heddle should not grow
27    /// a second reachable-pack planner locally; once Sley exposes the needed
28    /// facade, this intent can become an executable Git-lane plan.
29    BlockedOnSleyReachablePackPlanning,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RepositoryTransferPlan<T = PlannedObject> {
34    pub partitions: TransferPartitions<T>,
35    pub stats: TransferPlanStats,
36    pub git_lane: GitLaneTransferIntent,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TransferPartitions<T = PlannedObject> {
41    pub packable_objects: Vec<T>,
42    pub sidecar_objects: Vec<T>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub struct TransferPlanStats {
47    pub total_objects: usize,
48    pub packable_objects: usize,
49    pub sidecar_objects: usize,
50    pub blobs: usize,
51    pub trees: usize,
52    pub states: usize,
53    pub actions: usize,
54    pub redactions: usize,
55    pub state_visibilities: usize,
56    pub state_attachments: usize,
57}
58
59impl RepositoryTransferPlan<PlannedObject> {
60    pub fn from_state_closure_plan(
61        store: &impl ObjectStore,
62        root: StateId,
63        options: StateClosureOptions,
64        git_lane: GitLaneTransferIntent,
65    ) -> Result<Self> {
66        let objects = enumerate_state_closure_plan_with_options(store, root, options)?;
67        Ok(Self::from_planned_objects(objects, git_lane))
68    }
69
70    pub fn from_planned_objects(
71        objects: impl IntoIterator<Item = PlannedObject>,
72        git_lane: GitLaneTransferIntent,
73    ) -> Self {
74        build_plan(objects, planned_object_type, git_lane)
75    }
76}
77
78impl RepositoryTransferPlan<ObjectInfo> {
79    pub fn from_object_infos(
80        objects: impl IntoIterator<Item = ObjectInfo>,
81        git_lane: GitLaneTransferIntent,
82    ) -> Self {
83        build_plan(objects, object_info_type, git_lane)
84    }
85}
86
87impl<T> RepositoryTransferPlan<T> {
88    pub fn requires_native_pack(&self, include_full_closure: bool) -> bool {
89        include_full_closure || self.stats.packable_objects > 0
90    }
91
92    pub fn is_heddle_only(&self) -> bool {
93        self.git_lane == GitLaneTransferIntent::HeddleObjectsOnly
94    }
95}
96
97impl<T> TransferPartitions<T> {
98    pub fn is_empty(&self) -> bool {
99        self.packable_objects.is_empty() && self.sidecar_objects.is_empty()
100    }
101
102    pub fn len(&self) -> usize {
103        self.packable_objects.len() + self.sidecar_objects.len()
104    }
105
106    pub fn iter(&self) -> impl Iterator<Item = &T> {
107        self.packable_objects
108            .iter()
109            .chain(self.sidecar_objects.iter())
110    }
111
112    pub fn is_sidecar_object_type(obj_type: ObjectType) -> bool {
113        !obj_type.packable()
114    }
115}
116
117impl<T> Default for TransferPartitions<T> {
118    fn default() -> Self {
119        Self {
120            packable_objects: Vec::new(),
121            sidecar_objects: Vec::new(),
122        }
123    }
124}
125
126impl TransferPlanStats {
127    fn record(&mut self, obj_type: ObjectType) {
128        self.total_objects += 1;
129        if TransferPartitions::<()>::is_sidecar_object_type(obj_type) {
130            self.sidecar_objects += 1;
131        } else {
132            self.packable_objects += 1;
133        }
134        match obj_type.bucket() {
135            ObjectTypeBucket::Blob => self.blobs += 1,
136            ObjectTypeBucket::Tree => self.trees += 1,
137            ObjectTypeBucket::State => self.states += 1,
138            ObjectTypeBucket::Action => self.actions += 1,
139            ObjectTypeBucket::Redaction => self.redactions += 1,
140            ObjectTypeBucket::StateVisibility => self.state_visibilities += 1,
141            ObjectTypeBucket::StateAttachment => self.state_attachments += 1,
142        }
143    }
144}
145
146fn build_plan<T>(
147    objects: impl IntoIterator<Item = T>,
148    object_type: fn(&T) -> ObjectType,
149    git_lane: GitLaneTransferIntent,
150) -> RepositoryTransferPlan<T> {
151    let mut partitions = TransferPartitions::default();
152    let mut stats = TransferPlanStats::default();
153
154    for object in objects {
155        let obj_type = object_type(&object);
156        stats.record(obj_type);
157        if TransferPartitions::<T>::is_sidecar_object_type(obj_type) {
158            partitions.sidecar_objects.push(object);
159        } else {
160            partitions.packable_objects.push(object);
161        }
162    }
163
164    RepositoryTransferPlan {
165        partitions,
166        stats,
167        git_lane,
168    }
169}
170
171fn planned_object_type(object: &PlannedObject) -> ObjectType {
172    object.obj_type
173}
174
175fn object_info_type(object: &ObjectInfo) -> ObjectType {
176    object.obj_type
177}
178
179#[cfg(test)]
180mod tests {
181    use objects::object::{ContentHash, StateId};
182
183    use super::*;
184    use crate::ObjectId;
185
186    fn hash(byte: u8) -> ContentHash {
187        ContentHash::from_bytes([byte; 32])
188    }
189
190    #[test]
191    fn partitions_split_native_pack_objects_from_sidecars() {
192        let state = StateId::from_bytes([9; 32]);
193        let plan = RepositoryTransferPlan::from_planned_objects(
194            vec![
195                PlannedObject {
196                    id: ObjectId::Hash(hash(1)),
197                    obj_type: ObjectType::Blob,
198                },
199                PlannedObject {
200                    id: ObjectId::Hash(hash(2)),
201                    obj_type: ObjectType::Tree,
202                },
203                PlannedObject {
204                    id: ObjectId::Hash(hash(1)),
205                    obj_type: ObjectType::Redaction,
206                },
207                PlannedObject {
208                    id: ObjectId::StateId(state),
209                    obj_type: ObjectType::StateVisibility,
210                },
211            ],
212            GitLaneTransferIntent::HeddleObjectsOnly,
213        );
214
215        assert_eq!(plan.partitions.packable_objects.len(), 2);
216        assert_eq!(plan.partitions.sidecar_objects.len(), 2);
217        assert_eq!(plan.stats.total_objects, 4);
218        assert_eq!(plan.stats.packable_objects, 2);
219        assert_eq!(plan.stats.sidecar_objects, 2);
220        assert_eq!(plan.stats.blobs, 1);
221        assert_eq!(plan.stats.trees, 1);
222        assert_eq!(plan.stats.redactions, 1);
223        assert_eq!(plan.stats.state_visibilities, 1);
224        assert!(plan.requires_native_pack(false));
225    }
226
227    #[test]
228    fn sidecar_only_plan_does_not_require_native_pack() {
229        let state = StateId::from_bytes([3; 32]);
230        let plan = RepositoryTransferPlan::from_object_infos(
231            vec![ObjectInfo {
232                id: ObjectId::StateId(state),
233                obj_type: ObjectType::StateVisibility,
234                size: 128,
235                delta_base: None,
236            }],
237            GitLaneTransferIntent::HeddleObjectsOnly,
238        );
239
240        assert!(!plan.requires_native_pack(false));
241        assert!(plan.requires_native_pack(true));
242        assert_eq!(plan.stats.packable_objects, 0);
243        assert_eq!(plan.stats.sidecar_objects, 1);
244    }
245
246    #[test]
247    fn git_lane_intents_name_current_and_sley_gated_paths() {
248        let hosted = RepositoryTransferPlan::from_planned_objects(
249            Vec::<PlannedObject>::new(),
250            GitLaneTransferIntent::ExistingImplementation,
251        );
252        let sley_blocked = RepositoryTransferPlan::from_planned_objects(
253            Vec::<PlannedObject>::new(),
254            GitLaneTransferIntent::BlockedOnSleyReachablePackPlanning,
255        );
256
257        assert_eq!(
258            hosted.git_lane,
259            GitLaneTransferIntent::ExistingImplementation
260        );
261        assert_eq!(
262            sley_blocked.git_lane,
263            GitLaneTransferIntent::BlockedOnSleyReachablePackPlanning
264        );
265        assert!(!hosted.is_heddle_only());
266        assert!(!sley_blocked.is_heddle_only());
267    }
268}