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 annotated_tags: usize,
55    pub redactions: usize,
56    pub purges: usize,
57    pub state_visibilities: usize,
58    pub state_attachments: usize,
59    pub key_bindings: usize,
60}
61
62impl RepositoryTransferPlan<PlannedObject> {
63    pub fn from_state_closure_plan(
64        store: &impl ObjectStore,
65        root: StateId,
66        options: StateClosureOptions,
67        git_lane: GitLaneTransferIntent,
68    ) -> Result<Self> {
69        let objects = enumerate_state_closure_plan_with_options(store, root, options)?;
70        Ok(Self::from_planned_objects(objects, git_lane))
71    }
72
73    pub fn from_planned_objects(
74        objects: impl IntoIterator<Item = PlannedObject>,
75        git_lane: GitLaneTransferIntent,
76    ) -> Self {
77        build_plan(objects, planned_object_type, git_lane)
78    }
79}
80
81impl RepositoryTransferPlan<ObjectInfo> {
82    pub fn from_object_infos(
83        objects: impl IntoIterator<Item = ObjectInfo>,
84        git_lane: GitLaneTransferIntent,
85    ) -> Self {
86        build_plan(objects, object_info_type, git_lane)
87    }
88}
89
90impl<T> RepositoryTransferPlan<T> {
91    pub fn requires_native_pack(&self, include_full_closure: bool) -> bool {
92        include_full_closure || self.stats.packable_objects > 0
93    }
94
95    pub fn is_heddle_only(&self) -> bool {
96        self.git_lane == GitLaneTransferIntent::HeddleObjectsOnly
97    }
98}
99
100impl<T> TransferPartitions<T> {
101    pub fn is_empty(&self) -> bool {
102        self.packable_objects.is_empty() && self.sidecar_objects.is_empty()
103    }
104
105    pub fn len(&self) -> usize {
106        self.packable_objects.len() + self.sidecar_objects.len()
107    }
108
109    pub fn iter(&self) -> impl Iterator<Item = &T> {
110        self.packable_objects
111            .iter()
112            .chain(self.sidecar_objects.iter())
113    }
114
115    pub fn is_sidecar_object_type(obj_type: ObjectType) -> bool {
116        !obj_type.packable()
117    }
118}
119
120impl<T> Default for TransferPartitions<T> {
121    fn default() -> Self {
122        Self {
123            packable_objects: Vec::new(),
124            sidecar_objects: Vec::new(),
125        }
126    }
127}
128
129impl TransferPlanStats {
130    fn record(&mut self, obj_type: ObjectType) {
131        self.total_objects += 1;
132        if TransferPartitions::<()>::is_sidecar_object_type(obj_type) {
133            self.sidecar_objects += 1;
134        } else {
135            self.packable_objects += 1;
136        }
137        match obj_type.bucket() {
138            ObjectTypeBucket::Blob => self.blobs += 1,
139            ObjectTypeBucket::Tree => self.trees += 1,
140            ObjectTypeBucket::State => self.states += 1,
141            ObjectTypeBucket::Action => self.actions += 1,
142            ObjectTypeBucket::AnnotatedTag => self.annotated_tags += 1,
143            ObjectTypeBucket::Redaction => self.redactions += 1,
144            ObjectTypeBucket::Purge => self.purges += 1,
145            ObjectTypeBucket::StateVisibility => self.state_visibilities += 1,
146            ObjectTypeBucket::StateAttachment => self.state_attachments += 1,
147            ObjectTypeBucket::KeyBinding => self.key_bindings += 1,
148        }
149    }
150}
151
152fn build_plan<T>(
153    objects: impl IntoIterator<Item = T>,
154    object_type: fn(&T) -> ObjectType,
155    git_lane: GitLaneTransferIntent,
156) -> RepositoryTransferPlan<T> {
157    let mut partitions = TransferPartitions::default();
158    let mut stats = TransferPlanStats::default();
159
160    for object in objects {
161        let obj_type = object_type(&object);
162        stats.record(obj_type);
163        if TransferPartitions::<T>::is_sidecar_object_type(obj_type) {
164            partitions.sidecar_objects.push(object);
165        } else {
166            partitions.packable_objects.push(object);
167        }
168    }
169
170    RepositoryTransferPlan {
171        partitions,
172        stats,
173        git_lane,
174    }
175}
176
177fn planned_object_type(object: &PlannedObject) -> ObjectType {
178    object.obj_type
179}
180
181fn object_info_type(object: &ObjectInfo) -> ObjectType {
182    object.obj_type
183}
184
185#[cfg(test)]
186mod tests {
187    use objects::object::{ContentHash, StateId};
188
189    use super::*;
190    use crate::ObjectId;
191
192    fn hash(byte: u8) -> ContentHash {
193        ContentHash::from_bytes([byte; 32])
194    }
195
196    #[test]
197    fn partitions_split_native_pack_objects_from_sidecars() {
198        let state = StateId::from_bytes([9; 32]);
199        let plan = RepositoryTransferPlan::from_planned_objects(
200            vec![
201                PlannedObject {
202                    id: ObjectId::Hash(hash(1)),
203                    obj_type: ObjectType::Blob,
204                },
205                PlannedObject {
206                    id: ObjectId::Hash(hash(2)),
207                    obj_type: ObjectType::Tree,
208                },
209                PlannedObject {
210                    id: ObjectId::Hash(hash(1)),
211                    obj_type: ObjectType::Redaction,
212                },
213                PlannedObject {
214                    id: ObjectId::StateId(state),
215                    obj_type: ObjectType::StateVisibility,
216                },
217                PlannedObject {
218                    id: ObjectId::Hash(hash(3)),
219                    obj_type: ObjectType::KeyBinding,
220                },
221            ],
222            GitLaneTransferIntent::HeddleObjectsOnly,
223        );
224
225        assert_eq!(plan.partitions.packable_objects.len(), 2);
226        assert_eq!(plan.partitions.sidecar_objects.len(), 3);
227        assert_eq!(plan.stats.total_objects, 5);
228        assert_eq!(plan.stats.packable_objects, 2);
229        assert_eq!(plan.stats.sidecar_objects, 3);
230        assert_eq!(plan.stats.blobs, 1);
231        assert_eq!(plan.stats.trees, 1);
232        assert_eq!(plan.stats.redactions, 1);
233        assert_eq!(plan.stats.state_visibilities, 1);
234        assert_eq!(plan.stats.key_bindings, 1);
235        assert!(plan.requires_native_pack(false));
236    }
237
238    #[test]
239    fn sidecar_only_plan_does_not_require_native_pack() {
240        let state = StateId::from_bytes([3; 32]);
241        let plan = RepositoryTransferPlan::from_object_infos(
242            vec![ObjectInfo {
243                id: ObjectId::StateId(state),
244                obj_type: ObjectType::StateVisibility,
245                size: 128,
246                delta_base: None,
247            }],
248            GitLaneTransferIntent::HeddleObjectsOnly,
249        );
250
251        assert!(!plan.requires_native_pack(false));
252        assert!(plan.requires_native_pack(true));
253        assert_eq!(plan.stats.packable_objects, 0);
254        assert_eq!(plan.stats.sidecar_objects, 1);
255    }
256
257    #[test]
258    fn git_lane_intents_name_current_and_sley_gated_paths() {
259        let hosted = RepositoryTransferPlan::from_planned_objects(
260            Vec::<PlannedObject>::new(),
261            GitLaneTransferIntent::ExistingImplementation,
262        );
263        let sley_blocked = RepositoryTransferPlan::from_planned_objects(
264            Vec::<PlannedObject>::new(),
265            GitLaneTransferIntent::BlockedOnSleyReachablePackPlanning,
266        );
267
268        assert_eq!(
269            hosted.git_lane,
270            GitLaneTransferIntent::ExistingImplementation
271        );
272        assert_eq!(
273            sley_blocked.git_lane,
274            GitLaneTransferIntent::BlockedOnSleyReachablePackPlanning
275        );
276        assert!(!hosted.is_heddle_only());
277        assert!(!sley_blocked.is_heddle_only());
278    }
279}