Skip to main content

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