1use std::collections::{HashSet, VecDeque};
3
4use objects::{
5 object::{
6 BindingDelta, ContentHash, SemanticEntryKind, SemanticIndexRoot, SemanticTreeNode, State,
7 StateAttachment, StateAttachmentBody, StateAttachmentId, StateAttachmentKind, StateId,
8 TreeEntryTarget,
9 },
10 store::{ObjectStore, pack::ObjectType as PackObjectType},
11};
12use serde::{Deserialize, Serialize};
13
14use crate::{ProtocolError, Result};
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum ObjectId {
18 Hash(ContentHash),
19 StateId(StateId),
20 StateAttachment {
21 state: StateId,
22 id: StateAttachmentId,
23 kind: StateAttachmentKind,
29 },
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ObjectInfo {
34 pub id: ObjectId,
35 pub obj_type: ObjectType,
36 pub size: u64,
37 pub delta_base: Option<ContentHash>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41pub struct PlannedObject {
42 pub id: ObjectId,
43 pub obj_type: ObjectType,
44}
45
46#[derive(Debug, Clone)]
47pub struct StateClosureTransferObjects {
48 pub planned_objects: Vec<PlannedObject>,
49 pub full_objects: Option<Vec<ObjectInfo>>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub enum ObjectType {
54 Blob,
55 Tree,
56 State,
57 Action,
58 Redaction,
64 StateVisibility,
71 StateAttachment,
72 KeyBinding,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum ObjectTypeBucket {
81 Blob,
82 Tree,
83 State,
84 Action,
85 Redaction,
86 StateVisibility,
87 StateAttachment,
88 KeyBinding,
89}
90
91impl ObjectType {
92 pub fn wire_name(self) -> &'static str {
93 match self {
94 ObjectType::Blob => "blob",
95 ObjectType::Tree => "tree",
96 ObjectType::State => "state",
97 ObjectType::Action => "action",
98 ObjectType::Redaction => "redaction",
99 ObjectType::StateVisibility => "state_visibility",
100 ObjectType::StateAttachment => "state_attachment",
101 ObjectType::KeyBinding => "key_binding",
102 }
103 }
104
105 pub fn from_wire(value: &str) -> Result<Self> {
106 match value {
107 "blob" => Ok(ObjectType::Blob),
108 "tree" => Ok(ObjectType::Tree),
109 "state" => Ok(ObjectType::State),
110 "action" => Ok(ObjectType::Action),
111 "redaction" => Ok(ObjectType::Redaction),
112 "state_visibility" => Ok(ObjectType::StateVisibility),
113 "state_attachment" => Ok(ObjectType::StateAttachment),
114 "key_binding" => Ok(ObjectType::KeyBinding),
115 _ => Err(ProtocolError::InvalidState(format!(
116 "unknown object type: {value}"
117 ))),
118 }
119 }
120
121 pub fn packable(self) -> bool {
132 !matches!(
133 self,
134 ObjectType::Redaction | ObjectType::StateVisibility | ObjectType::KeyBinding
135 )
136 }
137
138 pub fn packable_for_push(self) -> bool {
148 self.packable() && !matches!(self, ObjectType::StateAttachment)
149 }
150
151 pub fn packable_for_pull(self) -> bool {
158 self.packable()
159 }
160
161 pub fn pack_object_type(self) -> Result<PackObjectType> {
162 match self {
163 ObjectType::Blob => Ok(PackObjectType::Blob),
164 ObjectType::Tree => Ok(PackObjectType::Tree),
165 ObjectType::State => Ok(PackObjectType::State),
166 ObjectType::Action => Ok(PackObjectType::Action),
167 ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
168 ObjectType::Redaction => Err(ProtocolError::InvalidState(
169 "Redaction sidecar records cannot be packed into the content-addressed object pack"
170 .to_string(),
171 )),
172 ObjectType::StateVisibility => Err(ProtocolError::InvalidState(
173 "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
174 .to_string(),
175 )),
176 ObjectType::KeyBinding => Err(ProtocolError::InvalidState(
177 "KeyBinding registry objects cannot be packed into the content-addressed object pack"
178 .to_string(),
179 )),
180 }
181 }
182
183 pub fn bucket(self) -> ObjectTypeBucket {
184 match self {
185 ObjectType::Blob => ObjectTypeBucket::Blob,
186 ObjectType::Tree => ObjectTypeBucket::Tree,
187 ObjectType::State => ObjectTypeBucket::State,
188 ObjectType::Action => ObjectTypeBucket::Action,
189 ObjectType::Redaction => ObjectTypeBucket::Redaction,
190 ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
191 ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
192 ObjectType::KeyBinding => ObjectTypeBucket::KeyBinding,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Default)]
198pub struct StateClosureOptions {
199 pub depth: Option<u32>,
200 pub exclude_states: Vec<StateId>,
201}
202
203pub fn enumerate_state_closure(
204 store: &impl ObjectStore,
205 state_id: StateId,
206) -> Result<Vec<ObjectInfo>> {
207 enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
208}
209
210pub fn enumerate_state_closure_with_options(
211 store: &impl ObjectStore,
212 state_id: StateId,
213 options: StateClosureOptions,
214) -> Result<Vec<ObjectInfo>> {
215 let mut out = Vec::new();
216 walk_state_closure(store, state_id, options, |event| {
217 if let Some(info) = object_info_from_event(store, event)? {
218 out.push(info);
219 }
220 Ok(())
221 })?;
222
223 Ok(out)
224}
225
226pub fn enumerate_state_closure_plan(
227 store: &impl ObjectStore,
228 state_id: StateId,
229) -> Result<Vec<PlannedObject>> {
230 enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
231}
232
233pub fn enumerate_state_closure_plan_with_options(
234 store: &impl ObjectStore,
235 state_id: StateId,
236 options: StateClosureOptions,
237) -> Result<Vec<PlannedObject>> {
238 let mut out = Vec::new();
239 walk_state_closure(store, state_id, options, |event| {
240 if let Some(object) = planned_object_from_event(store, event)? {
241 out.push(object);
242 }
243 Ok(())
244 })?;
245
246 Ok(out)
247}
248
249pub fn enumerate_state_closure_transfer_with_options(
250 store: &impl ObjectStore,
251 state_id: StateId,
252 options: StateClosureOptions,
253 full_descriptor_object_threshold: usize,
254) -> Result<StateClosureTransferObjects> {
255 let mut planned_objects = Vec::new();
256 let mut full_objects = Some(Vec::new());
257
258 walk_state_closure(store, state_id, options, |event| {
259 if let Some(object) = planned_object_from_event(store, event)? {
260 planned_objects.push(object);
261 }
262
263 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
264 full_objects = None;
265 }
266 if let Some(objects) = full_objects.as_mut()
267 && let Some(info) = object_info_from_event(store, event)?
268 {
269 objects.push(info);
270 }
271
272 Ok(())
273 })?;
274
275 Ok(StateClosureTransferObjects {
276 planned_objects,
277 full_objects,
278 })
279}
280
281pub fn enumerate_state_closure_transfer_from_boundaries(
288 store: &impl ObjectStore,
289 state_id: StateId,
290 boundary_states: &[StateId],
291 full_descriptor_object_threshold: usize,
292) -> Result<StateClosureTransferObjects> {
293 let mut planned_objects = Vec::new();
294 let mut full_objects = Some(Vec::new());
295 let excluded_states = boundary_states.iter().copied().collect();
296
297 walk_state_closure_with_exclusions(
298 store,
299 state_id,
300 None,
301 excluded_states,
302 HashSet::new(),
303 |event| {
304 if let Some(object) = planned_object_from_event(store, event)? {
305 planned_objects.push(object);
306 }
307
308 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
309 full_objects = None;
310 }
311 if let Some(objects) = full_objects.as_mut()
312 && let Some(info) = object_info_from_event(store, event)?
313 {
314 objects.push(info);
315 }
316
317 Ok(())
318 },
319 )?;
320
321 Ok(StateClosureTransferObjects {
322 planned_objects,
323 full_objects,
324 })
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328enum BlobSource {
329 Tree,
330 StateMetadata,
331}
332
333#[derive(Debug, Clone, Copy)]
334enum StateClosureEvent<'a> {
335 State {
336 id: StateId,
337 state: &'a State,
338 },
339 Tree {
340 hash: ContentHash,
341 tree: &'a objects::object::Tree,
342 },
343 Blob {
344 hash: ContentHash,
345 source: BlobSource,
346 },
347 Redaction {
348 blob: ContentHash,
349 },
350 StateVisibility {
351 state: StateId,
352 },
353 StateAttachment {
354 state: StateId,
355 attachment: &'a StateAttachment,
356 },
357 ExcludedState {
358 id: StateId,
359 },
360 ExcludedHash {
361 hash: ContentHash,
362 },
363}
364
365fn walk_state_closure(
366 store: &impl ObjectStore,
367 state_id: StateId,
368 options: StateClosureOptions,
369 visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
370) -> Result<()> {
371 let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
372
373 walk_state_closure_with_exclusions(
374 store,
375 state_id,
376 options.depth,
377 excluded_states,
378 excluded_hashes,
379 visit,
380 )
381}
382
383fn walk_state_closure_with_exclusions(
384 store: &impl ObjectStore,
385 state_id: StateId,
386 max_depth: Option<u32>,
387 excluded_states: HashSet<StateId>,
388 excluded_hashes: HashSet<ContentHash>,
389 mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
390) -> Result<()> {
391 let mut seen_states: HashSet<StateId> = HashSet::new();
392 let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
393 let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
394 queue.push_back((state_id, 0));
395
396 while let Some((id, depth)) = queue.pop_front() {
397 if excluded_states.contains(&id) {
398 visit(StateClosureEvent::ExcludedState { id })?;
399 continue;
400 }
401 if !seen_states.insert(id) {
402 continue;
403 }
404
405 let state = store
406 .get_state(&id)?
407 .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
408
409 visit(StateClosureEvent::State { id, state: &state })?;
410 if store.has_state_visibility_for_state(&id)? {
411 visit(StateClosureEvent::StateVisibility { state: id })?;
412 }
413 for attachment in store.list_state_attachments(&id)? {
414 visit(StateClosureEvent::StateAttachment {
415 state: id,
416 attachment: &attachment,
417 })?;
418 match attachment.body {
419 StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
420 store,
421 root,
422 &excluded_hashes,
423 &mut seen_hashes,
424 &mut visit,
425 )?,
426 StateAttachmentBody::RiskSignals(hash)
427 | StateAttachmentBody::ReviewSignatures(hash)
428 | StateAttachmentBody::Discussions(hash)
429 | StateAttachmentBody::StructuredConflicts(hash) => walk_blob_filtered(
430 store,
431 hash,
432 BlobSource::StateMetadata,
433 &excluded_hashes,
434 &mut seen_hashes,
435 &mut visit,
436 )?,
437 StateAttachmentBody::SemanticIndex(root) => walk_semantic_index_closure(
438 store,
439 root,
440 &excluded_hashes,
441 &mut seen_hashes,
442 &mut visit,
443 )?,
444 StateAttachmentBody::Signature(_) => {}
445 }
446 }
447
448 if max_depth.map(|max| depth < max).unwrap_or(true) {
449 for parent in &state.parents {
450 queue.push_back((*parent, depth + 1));
451 }
452 }
453
454 walk_tree_closure_filtered(
455 store,
456 state.tree,
457 &excluded_hashes,
458 &mut seen_hashes,
459 &mut visit,
460 )?;
461 if let Some(provenance_root) = state.provenance {
462 walk_tree_closure_filtered(
463 store,
464 provenance_root,
465 &excluded_hashes,
466 &mut seen_hashes,
467 &mut visit,
468 )?;
469 }
470 }
471
472 Ok(())
473}
474
475fn walk_tree_closure_filtered(
476 store: &impl ObjectStore,
477 tree_hash: ContentHash,
478 excluded: &HashSet<ContentHash>,
479 seen: &mut HashSet<ContentHash>,
480 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
481) -> Result<()> {
482 if excluded.contains(&tree_hash) {
483 visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
484 return Ok(());
485 }
486 if !seen.insert(tree_hash) {
487 return Ok(());
488 }
489
490 let tree = store
491 .get_tree(&tree_hash)?
492 .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
493
494 visit(StateClosureEvent::Tree {
495 hash: tree_hash,
496 tree: &tree,
497 })?;
498
499 for entry in tree.entries() {
500 match entry.target() {
501 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
502 walk_blob_filtered(store, *hash, BlobSource::Tree, excluded, seen, visit)?;
503 }
504 TreeEntryTarget::Tree { hash } => {
505 walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
506 }
507 TreeEntryTarget::Gitlink { .. } => {}
508 TreeEntryTarget::Spoollink { .. } => {}
511 }
512 }
513
514 Ok(())
515}
516
517fn walk_blob_filtered(
518 store: &impl ObjectStore,
519 blob_hash: ContentHash,
520 source: BlobSource,
521 excluded: &HashSet<ContentHash>,
522 seen: &mut HashSet<ContentHash>,
523 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
524) -> Result<()> {
525 if excluded.contains(&blob_hash) {
526 visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
527 return Ok(());
528 }
529 if !seen.insert(blob_hash) {
530 return Ok(());
531 }
532 visit(StateClosureEvent::Blob {
533 hash: blob_hash,
534 source,
535 })?;
536 if store.has_redactions_for_blob(&blob_hash)? {
537 visit(StateClosureEvent::Redaction { blob: blob_hash })?;
538 }
539 Ok(())
540}
541
542fn walk_semantic_index_closure(
555 store: &impl ObjectStore,
556 root_hash: ContentHash,
557 excluded: &HashSet<ContentHash>,
558 seen: &mut HashSet<ContentHash>,
559 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
560) -> Result<()> {
561 let mut stack: Vec<ContentHash> = vec![root_hash];
564 while let Some(node_hash) = stack.pop() {
565 if !emit_semantic_blob(store, node_hash, excluded, seen, visit)? {
566 continue; }
568 let blob = store
569 .get_blob(&node_hash)?
570 .ok_or_else(|| ProtocolError::ObjectNotFound(node_hash.to_hex()))?;
571 let node = decode_semantic_container(&blob, node_hash)?;
575 for child in node {
576 match child {
577 SemanticChild::Interior(hash) => stack.push(hash),
578 SemanticChild::Leaf(hash) => {
579 emit_semantic_blob(store, hash, excluded, seen, visit)?;
581 }
582 SemanticChild::BindingDelta(hash) => {
583 emit_binding_delta(store, hash, excluded, seen, visit)?;
589 }
590 }
591 }
592 }
593 Ok(())
594}
595
596enum SemanticChild {
598 Interior(ContentHash),
599 Leaf(ContentHash),
600 BindingDelta(ContentHash),
601}
602
603fn decode_semantic_container(
606 blob: &objects::object::Blob,
607 node_hash: ContentHash,
608) -> Result<Vec<SemanticChild>> {
609 if let Ok(root) = SemanticIndexRoot::decode(blob.content()) {
613 let mut children = vec![SemanticChild::Interior(root.tree)];
614 if let Some(binding_delta) = root.binding_delta {
615 children.push(SemanticChild::BindingDelta(binding_delta));
616 }
617 return Ok(children);
618 }
619 let node = SemanticTreeNode::decode(blob.content())
620 .map_err(|err| ProtocolError::Serialization(format!("semantic node {node_hash}: {err}")))?;
621 Ok(node
622 .entries
623 .iter()
624 .filter_map(|entry| match entry.kind {
625 SemanticEntryKind::Dir => Some(SemanticChild::Interior(entry.node)),
626 SemanticEntryKind::File => Some(SemanticChild::Leaf(entry.node)),
627 SemanticEntryKind::Opaque => None,
629 })
630 .collect())
631}
632
633fn emit_binding_delta(
634 store: &impl ObjectStore,
635 hash: ContentHash,
636 excluded: &HashSet<ContentHash>,
637 seen: &mut HashSet<ContentHash>,
638 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
639) -> Result<()> {
640 if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
641 return Ok(());
642 }
643 let blob = store
644 .get_blob(&hash)?
645 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
646 BindingDelta::decode(blob.content()).map_err(|err| {
647 ProtocolError::Serialization(format!("semantic binding delta {hash}: {err}"))
648 })?;
649 Ok(())
650}
651
652fn emit_semantic_blob(
657 store: &impl ObjectStore,
658 hash: ContentHash,
659 excluded: &HashSet<ContentHash>,
660 seen: &mut HashSet<ContentHash>,
661 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
662) -> Result<bool> {
663 if excluded.contains(&hash) {
664 visit(StateClosureEvent::ExcludedHash { hash })?;
665 return Ok(false);
666 }
667 if !seen.insert(hash) {
668 return Ok(false);
669 }
670 if store.get_blob(&hash)?.is_none() {
671 return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
672 }
673 visit(StateClosureEvent::Blob {
674 hash,
675 source: BlobSource::StateMetadata,
676 })?;
677 Ok(true)
678}
679
680fn collect_semantic_hashes(
685 store: &impl ObjectStore,
686 root_hash: ContentHash,
687 excluded: &mut HashSet<ContentHash>,
688) -> Result<()> {
689 let mut stack: Vec<ContentHash> = vec![root_hash];
690 while let Some(node_hash) = stack.pop() {
691 if !excluded.insert(node_hash) {
692 continue;
693 }
694 let Some(blob) = store.get_blob(&node_hash)? else {
695 continue;
696 };
697 let children = match decode_semantic_container(&blob, node_hash) {
698 Ok(children) => children,
699 Err(_) => continue,
700 };
701 for child in children {
702 match child {
703 SemanticChild::Interior(hash) => stack.push(hash),
704 SemanticChild::Leaf(hash) => {
705 excluded.insert(hash);
706 }
707 SemanticChild::BindingDelta(hash) => {
708 excluded.insert(hash);
709 }
710 }
711 }
712 }
713 Ok(())
714}
715
716fn object_info_from_event(
717 store: &impl ObjectStore,
718 event: StateClosureEvent<'_>,
719) -> Result<Option<ObjectInfo>> {
720 match event {
721 StateClosureEvent::State { id, state } => {
722 let state_bytes = rmp_serde::to_vec_named(state)?;
723 Ok(Some(ObjectInfo {
724 id: ObjectId::StateId(id),
725 obj_type: ObjectType::State,
726 size: state_bytes.len() as u64,
727 delta_base: None,
728 }))
729 }
730 StateClosureEvent::Tree { hash, tree } => {
731 let tree_bytes = rmp_serde::to_vec_named(tree)?;
732 Ok(Some(ObjectInfo {
733 id: ObjectId::Hash(hash),
734 obj_type: ObjectType::Tree,
735 size: tree_bytes.len() as u64,
736 delta_base: None,
737 }))
738 }
739 StateClosureEvent::Blob { hash, .. } => {
740 let blob = store
741 .get_blob(&hash)?
742 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
743 Ok(Some(ObjectInfo {
744 id: ObjectId::Hash(hash),
745 obj_type: ObjectType::Blob,
746 size: blob.size() as u64,
747 delta_base: None,
748 }))
749 }
750 StateClosureEvent::Redaction { blob } => Ok(store
751 .get_redactions_bytes_for_blob(&blob)?
752 .map(|bytes| ObjectInfo {
753 id: ObjectId::Hash(blob),
754 obj_type: ObjectType::Redaction,
755 size: bytes.len() as u64,
756 delta_base: None,
757 })),
758 StateClosureEvent::StateVisibility { state } => Ok(store
759 .get_state_visibility_bytes_for_state(&state)?
760 .map(|bytes| ObjectInfo {
761 id: ObjectId::StateId(state),
762 obj_type: ObjectType::StateVisibility,
763 size: bytes.len() as u64,
764 delta_base: None,
765 })),
766 StateClosureEvent::StateAttachment { state, attachment } => {
767 let bytes = rmp_serde::to_vec_named(attachment)?;
768 Ok(Some(ObjectInfo {
769 id: ObjectId::StateAttachment {
770 state,
771 id: attachment.id(),
772 kind: attachment.body.kind(),
773 },
774 obj_type: ObjectType::StateAttachment,
775 size: bytes.len() as u64,
776 delta_base: None,
777 }))
778 }
779 StateClosureEvent::ExcludedState { id } => {
780 let _ = id;
781 Ok(None)
782 }
783 StateClosureEvent::ExcludedHash { hash } => {
784 let _ = hash;
785 Ok(None)
786 }
787 }
788}
789
790fn planned_object_from_event(
791 store: &impl ObjectStore,
792 event: StateClosureEvent<'_>,
793) -> Result<Option<PlannedObject>> {
794 match event {
795 StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
796 id: ObjectId::StateId(id),
797 obj_type: ObjectType::State,
798 })),
799 StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
800 id: ObjectId::Hash(hash),
801 obj_type: ObjectType::Tree,
802 })),
803 StateClosureEvent::Blob { hash, source } => {
804 if source == BlobSource::StateMetadata && store.get_blob(&hash)?.is_none() {
805 return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
806 }
807 Ok(Some(PlannedObject {
808 id: ObjectId::Hash(hash),
809 obj_type: ObjectType::Blob,
810 }))
811 }
812 StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
813 id: ObjectId::Hash(blob),
814 obj_type: ObjectType::Redaction,
815 })),
816 StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
817 id: ObjectId::StateId(state),
818 obj_type: ObjectType::StateVisibility,
819 })),
820 StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
821 id: ObjectId::StateAttachment {
822 state,
823 id: attachment.id(),
824 kind: attachment.body.kind(),
825 },
826 obj_type: ObjectType::StateAttachment,
827 })),
828 StateClosureEvent::ExcludedState { id } => {
829 let _ = id;
830 Ok(None)
831 }
832 StateClosureEvent::ExcludedHash { hash } => {
833 let _ = hash;
834 Ok(None)
835 }
836 }
837}
838
839pub fn missing_blobs_in_tree(
840 store: &impl ObjectStore,
841 tree_hash: ContentHash,
842) -> Result<Vec<ContentHash>> {
843 let mut missing = Vec::new();
844 collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
845 Ok(missing)
846}
847
848fn collect_missing_blobs_recursive(
849 store: &impl ObjectStore,
850 tree_hash: &ContentHash,
851 missing: &mut Vec<ContentHash>,
852) -> Result<()> {
853 let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
854 ProtocolError::InvalidState(format!(
855 "load tree {} while collecting lazy hydration missing blobs: {err}",
856 tree_hash.to_hex()
857 ))
858 })?
859 else {
860 return Ok(());
861 };
862
863 for entry in tree.entries() {
864 match entry.target() {
865 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
866 if !store.has_blob(hash).map_err(|err| {
867 ProtocolError::InvalidState(format!(
868 "check blob {} while collecting lazy hydration missing blobs: {err}",
869 hash.to_hex()
870 ))
871 })? {
872 missing.push(*hash);
873 }
874 }
875 TreeEntryTarget::Tree { hash } => {
876 collect_missing_blobs_recursive(store, hash, missing)?;
877 }
878 TreeEntryTarget::Gitlink { .. } => {}
879 TreeEntryTarget::Spoollink { .. } => {}
882 }
883 }
884 Ok(())
885}
886
887fn collect_excluded(
888 store: &impl ObjectStore,
889 roots: &[StateId],
890) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
891 if roots.is_empty() {
892 return Ok((HashSet::new(), HashSet::new()));
893 }
894
895 let mut excluded_states: HashSet<StateId> = HashSet::new();
896 let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
897 let mut queue: VecDeque<StateId> = VecDeque::new();
898
899 for id in roots {
900 queue.push_back(*id);
901 }
902
903 while let Some(id) = queue.pop_front() {
904 if !excluded_states.insert(id) {
905 continue;
906 }
907
908 let state = match store.get_state(&id)? {
909 Some(state) => state,
910 None => continue,
911 };
912
913 for parent in &state.parents {
914 queue.push_back(*parent);
915 }
916
917 collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
918 if let Some(provenance_root) = state.provenance {
919 collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
920 }
921 for attachment in store.list_state_attachments(&id)? {
922 match attachment.body {
923 StateAttachmentBody::Context(root) => {
924 collect_tree_hashes(store, root, &mut excluded_hashes)?
925 }
926 StateAttachmentBody::RiskSignals(hash)
927 | StateAttachmentBody::ReviewSignatures(hash)
928 | StateAttachmentBody::Discussions(hash)
929 | StateAttachmentBody::StructuredConflicts(hash) => {
930 excluded_hashes.insert(hash);
931 }
932 StateAttachmentBody::SemanticIndex(root) => {
933 collect_semantic_hashes(store, root, &mut excluded_hashes)?;
934 }
935 StateAttachmentBody::Signature(_) => {}
936 }
937 }
938 }
939
940 Ok((excluded_states, excluded_hashes))
941}
942
943fn collect_tree_hashes(
944 store: &impl ObjectStore,
945 tree_hash: ContentHash,
946 excluded: &mut HashSet<ContentHash>,
947) -> Result<()> {
948 if !excluded.insert(tree_hash) {
949 return Ok(());
950 }
951
952 let tree = match store.get_tree(&tree_hash)? {
953 Some(tree) => tree,
954 None => return Ok(()),
955 };
956
957 for entry in tree.entries() {
958 match entry.target() {
959 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
960 excluded.insert(*hash);
961 }
962 TreeEntryTarget::Tree { hash } => {
963 collect_tree_hashes(store, *hash, excluded)?;
964 }
965 TreeEntryTarget::Gitlink { .. } => {}
966 TreeEntryTarget::Spoollink { .. } => {}
969 }
970 }
971
972 Ok(())
973}
974
975pub fn is_ancestor(
976 store: &impl ObjectStore,
977 ancestor: StateId,
978 descendant: StateId,
979) -> Result<bool> {
980 if ancestor == descendant {
981 return Ok(true);
982 }
983
984 let mut seen: HashSet<StateId> = HashSet::new();
985 let mut queue: VecDeque<StateId> = VecDeque::new();
986 queue.push_back(descendant);
987
988 while let Some(id) = queue.pop_front() {
989 if !seen.insert(id) {
990 continue;
991 }
992 let state = match store.get_state(&id)? {
993 Some(s) => s,
994 None => return Ok(false),
995 };
996 for parent in state.parents {
997 if parent == ancestor {
998 return Ok(true);
999 }
1000 queue.push_back(parent);
1001 }
1002 }
1003
1004 Ok(false)
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use std::{
1010 collections::HashSet,
1011 sync::atomic::{AtomicUsize, Ordering},
1012 };
1013
1014 use chrono::Utc;
1015 use objects::{
1016 object::{
1017 Action, ActionId, Attribution, Blob, ContentHash, Discussion, DiscussionResolution,
1018 DiscussionTurn, DiscussionsBlob, Principal, Redaction, State, StateAttachment,
1019 StateAttachmentBody, StateId, StateVisibility, SymbolAnchor, Tree, TreeEntry,
1020 VisibilityTier,
1021 },
1022 store::{ObjectStore, Result as StoreResult},
1023 };
1024 use repo::Repository;
1025 use sley::ObjectId as GitObjectId;
1026 use tempfile::TempDir;
1027
1028 use super::{
1029 ObjectId, ObjectInfo, ObjectType, PlannedObject, StateClosureOptions,
1030 enumerate_state_closure_plan_with_options,
1031 enumerate_state_closure_transfer_from_boundaries,
1032 enumerate_state_closure_transfer_with_options, enumerate_state_closure_with_options,
1033 missing_blobs_in_tree,
1034 };
1035
1036 fn pairs_from_full(objects: &[ObjectInfo]) -> HashSet<(ObjectId, ObjectType)> {
1037 objects
1038 .iter()
1039 .map(|info| (info.id.clone(), info.obj_type))
1040 .collect()
1041 }
1042
1043 fn pairs_from_plan(objects: &[PlannedObject]) -> HashSet<(ObjectId, ObjectType)> {
1044 objects
1045 .iter()
1046 .map(|info| (info.id.clone(), info.obj_type))
1047 .collect()
1048 }
1049
1050 fn object_info_fingerprint(
1051 objects: &[ObjectInfo],
1052 ) -> Vec<(ObjectId, ObjectType, u64, Option<ContentHash>)> {
1053 objects
1054 .iter()
1055 .map(|info| (info.id.clone(), info.obj_type, info.size, info.delta_base))
1056 .collect()
1057 }
1058
1059 fn assert_plan_parity(
1060 repo: &Repository,
1061 state_id: StateId,
1062 options: StateClosureOptions,
1063 ) -> HashSet<(ObjectId, ObjectType)> {
1064 let full =
1065 enumerate_state_closure_with_options(repo.store(), state_id, options.clone()).unwrap();
1066 let plan =
1067 enumerate_state_closure_plan_with_options(repo.store(), state_id, options).unwrap();
1068
1069 let full_pairs = pairs_from_full(&full);
1070 let plan_pairs = pairs_from_plan(&plan);
1071 assert_eq!(full_pairs, plan_pairs);
1072 full_pairs
1073 }
1074
1075 fn assert_contains_object(
1076 objects: &HashSet<(ObjectId, ObjectType)>,
1077 id: ObjectId,
1078 obj_type: ObjectType,
1079 ) {
1080 assert!(
1081 objects.contains(&(id.clone(), obj_type)),
1082 "expected closure to contain {id:?} as {obj_type:?}: {objects:?}"
1083 );
1084 }
1085
1086 struct CountingStore<'a, S> {
1087 inner: &'a S,
1088 state_reads: AtomicUsize,
1089 }
1090
1091 impl<'a, S> CountingStore<'a, S> {
1092 fn new(inner: &'a S) -> Self {
1093 Self {
1094 inner,
1095 state_reads: AtomicUsize::new(0),
1096 }
1097 }
1098
1099 fn state_reads(&self) -> usize {
1100 self.state_reads.load(Ordering::SeqCst)
1101 }
1102 }
1103
1104 impl<S: ObjectStore> ObjectStore for CountingStore<'_, S> {
1105 fn get_blob(&self, hash: &ContentHash) -> StoreResult<Option<Blob>> {
1106 self.inner.get_blob(hash)
1107 }
1108
1109 fn put_blob(&self, blob: &Blob) -> StoreResult<ContentHash> {
1110 self.inner.put_blob(blob)
1111 }
1112
1113 fn has_blob(&self, hash: &ContentHash) -> StoreResult<bool> {
1114 self.inner.has_blob(hash)
1115 }
1116
1117 fn get_tree(&self, hash: &ContentHash) -> StoreResult<Option<Tree>> {
1118 self.inner.get_tree(hash)
1119 }
1120
1121 fn put_tree(&self, tree: &Tree) -> StoreResult<ContentHash> {
1122 self.inner.put_tree(tree)
1123 }
1124
1125 fn has_tree(&self, hash: &ContentHash) -> StoreResult<bool> {
1126 self.inner.has_tree(hash)
1127 }
1128
1129 fn get_state(&self, id: &StateId) -> StoreResult<Option<State>> {
1130 self.state_reads.fetch_add(1, Ordering::SeqCst);
1131 self.inner.get_state(id)
1132 }
1133
1134 fn put_state(&self, state: &State) -> StoreResult<()> {
1135 self.inner.put_state(state)
1136 }
1137
1138 fn has_state(&self, id: &StateId) -> StoreResult<bool> {
1139 self.inner.has_state(id)
1140 }
1141
1142 fn list_states(&self) -> StoreResult<Vec<StateId>> {
1143 self.inner.list_states()
1144 }
1145
1146 fn get_action(&self, id: &ActionId) -> StoreResult<Option<Action>> {
1147 self.inner.get_action(id)
1148 }
1149
1150 fn put_action(&self, action: &mut Action) -> StoreResult<ActionId> {
1151 self.inner.put_action(action)
1152 }
1153
1154 fn list_actions(&self) -> StoreResult<Vec<ActionId>> {
1155 self.inner.list_actions()
1156 }
1157
1158 fn list_blobs(&self) -> StoreResult<Vec<ContentHash>> {
1159 self.inner.list_blobs()
1160 }
1161
1162 fn list_trees(&self) -> StoreResult<Vec<ContentHash>> {
1163 self.inner.list_trees()
1164 }
1165 }
1166
1167 fn test_attribution() -> Attribution {
1168 Attribution::human(Principal::new("Graph Tester", "graph@example.com"))
1169 }
1170
1171 #[test]
1172 fn lean_closure_planner_matches_object_info_ids_and_types() {
1173 let temp = TempDir::new().unwrap();
1174 let repo = Repository::init_default(temp.path()).unwrap();
1175 std::fs::create_dir_all(temp.path().join("src")).unwrap();
1176 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1177 std::fs::write(temp.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
1178 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1179
1180 let full = enumerate_state_closure_with_options(
1181 repo.store(),
1182 state.state_id,
1183 StateClosureOptions::default(),
1184 )
1185 .unwrap();
1186 let lean = enumerate_state_closure_plan_with_options(
1187 repo.store(),
1188 state.state_id,
1189 StateClosureOptions::default(),
1190 )
1191 .unwrap();
1192
1193 let full_pairs = full
1194 .into_iter()
1195 .map(|info| (info.id, info.obj_type))
1196 .collect::<std::collections::HashSet<_>>();
1197 let lean_pairs = lean
1198 .into_iter()
1199 .map(|info| (info.id, info.obj_type))
1200 .collect::<std::collections::HashSet<_>>();
1201
1202 assert_eq!(full_pairs, lean_pairs);
1203 assert!(
1204 full_pairs
1205 .iter()
1206 .any(|(id, _)| matches!(id, ObjectId::StateId(_)))
1207 );
1208 }
1209
1210 #[test]
1211 fn transfer_boundary_stops_at_server_head_without_walking_its_history() {
1212 let temp = TempDir::new().unwrap();
1213 let repo = Repository::init_default(temp.path()).unwrap();
1214 let path = temp.path().join("story.txt");
1215
1216 std::fs::write(&path, "base\n").unwrap();
1217 let base = repo.snapshot(Some("base".to_string()), None).unwrap();
1218 std::fs::write(&path, "middle\n").unwrap();
1219 let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
1220 std::fs::write(&path, "tip\n").unwrap();
1221 let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
1222
1223 let counting = CountingStore::new(repo.store());
1224 let transfer = enumerate_state_closure_transfer_from_boundaries(
1225 &counting,
1226 tip.state_id,
1227 &[middle.state_id],
1228 512,
1229 )
1230 .unwrap();
1231 let states = transfer
1232 .planned_objects
1233 .iter()
1234 .filter_map(|object| match object.id {
1235 ObjectId::StateId(state) if object.obj_type == ObjectType::State => Some(state),
1236 _ => None,
1237 })
1238 .collect::<Vec<_>>();
1239
1240 assert_eq!(states, vec![tip.state_id]);
1241 assert!(!states.contains(&middle.state_id));
1242 assert!(!states.contains(&base.state_id));
1243 assert_eq!(
1244 counting.state_reads(),
1245 1,
1246 "the advertised server boundary must stop the walk before reading old states"
1247 );
1248 }
1249
1250 #[test]
1251 fn transfer_projection_matches_full_and_plan_on_mixed_state_closure_fixture() {
1252 let temp = TempDir::new().unwrap();
1253 let repo = Repository::init_default(temp.path()).unwrap();
1254
1255 let excluded_blob = repo
1256 .store()
1257 .put_blob(&Blob::from("excluded"))
1258 .expect("put excluded blob");
1259 let excluded_tree_hash = repo
1260 .store()
1261 .put_tree(&Tree::from_entries(vec![
1262 TreeEntry::file("excluded.txt", excluded_blob, false).unwrap(),
1263 ]))
1264 .expect("put excluded tree");
1265 let excluded_parent = State::new(excluded_tree_hash, Vec::new(), test_attribution());
1266 repo.store()
1267 .put_state(&excluded_parent)
1268 .expect("put excluded parent");
1269
1270 let redacted_blob = repo
1271 .store()
1272 .put_blob(&Blob::from("secret"))
1273 .expect("put redacted blob");
1274 let nested_blob = repo
1275 .store()
1276 .put_blob(&Blob::from("nested"))
1277 .expect("put nested blob");
1278 let symlink_blob = repo
1279 .store()
1280 .put_blob(&Blob::from("target"))
1281 .expect("put symlink blob");
1282 let context_blob = repo
1283 .store()
1284 .put_blob(&Blob::from("context"))
1285 .expect("put context blob");
1286 let provenance_blob = repo
1287 .store()
1288 .put_blob(&Blob::from("provenance"))
1289 .expect("put provenance blob");
1290 let risk_blob = repo
1291 .store()
1292 .put_blob(&Blob::from("risk"))
1293 .expect("put risk blob");
1294 let review_blob = repo
1295 .store()
1296 .put_blob(&Blob::from("review"))
1297 .expect("put review blob");
1298 let discussions_blob = repo
1299 .store()
1300 .put_blob(&Blob::from("discussion"))
1301 .expect("put discussion blob");
1302 let conflicts_blob = repo
1303 .store()
1304 .put_blob(&Blob::from("conflicts"))
1305 .expect("put conflicts blob");
1306
1307 let nested_tree_hash = repo
1308 .store()
1309 .put_tree(&Tree::from_entries(vec![
1310 TreeEntry::file("nested.txt", nested_blob, false).unwrap(),
1311 TreeEntry::symlink("latest", symlink_blob).unwrap(),
1312 ]))
1313 .expect("put nested tree");
1314 let context_tree_hash = repo
1315 .store()
1316 .put_tree(&Tree::from_entries(vec![
1317 TreeEntry::file("context.txt", context_blob, false).unwrap(),
1318 ]))
1319 .expect("put context tree");
1320 let provenance_tree_hash = repo
1321 .store()
1322 .put_tree(&Tree::from_entries(vec![
1323 TreeEntry::file("lineage.txt", provenance_blob, false).unwrap(),
1324 ]))
1325 .expect("put provenance tree");
1326 let gitlink_target: GitObjectId = "0303030303030303030303030303030303030303"
1327 .parse()
1328 .expect("git oid");
1329 let root_tree_hash = repo
1330 .store()
1331 .put_tree(&Tree::from_entries(vec![
1332 TreeEntry::file("secret.txt", redacted_blob, false).unwrap(),
1333 TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1334 TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1335 ]))
1336 .expect("put root tree");
1337 let state = State::new(
1338 root_tree_hash,
1339 vec![excluded_parent.state_id],
1340 test_attribution(),
1341 )
1342 .with_provenance(provenance_tree_hash);
1343 repo.store().put_state(&state).expect("put state");
1344 for body in [
1345 StateAttachmentBody::Context(context_tree_hash),
1346 StateAttachmentBody::RiskSignals(risk_blob),
1347 StateAttachmentBody::ReviewSignatures(review_blob),
1348 StateAttachmentBody::Discussions(discussions_blob),
1349 StateAttachmentBody::StructuredConflicts(conflicts_blob),
1350 ] {
1351 repo.put_state_attachment(&StateAttachment {
1352 state_id: state.id(),
1353 body,
1354 attribution: state.attribution.clone(),
1355 created_at: Utc::now(),
1356 supersedes: None,
1357 })
1358 .unwrap();
1359 }
1360
1361 repo.put_redaction(Redaction {
1362 redacted_blob,
1363 state: state.state_id,
1364 path: "secret.txt".to_string(),
1365 reason: "test leak".to_string(),
1366 redactor: Principal::new("Tester", "tester@example.test"),
1367 redacted_at: Utc::now(),
1368 signature: None,
1369 purged_at: None,
1370 supersedes: None,
1371 })
1372 .expect("put redaction");
1373 repo.put_state_visibility(StateVisibility {
1374 state: state.state_id,
1375 tier: VisibilityTier::Restricted {
1376 scope_label: "security".to_string(),
1377 },
1378 embargo_until: None,
1379 declarer: Principal::new("Tester", "tester@example.test"),
1380 declared_at: Utc::now(),
1381 signature: None,
1382 supersedes: None,
1383 })
1384 .expect("put visibility");
1385
1386 let options = StateClosureOptions {
1387 depth: None,
1388 exclude_states: vec![excluded_parent.state_id],
1389 };
1390 let transfer = enumerate_state_closure_transfer_with_options(
1391 repo.store(),
1392 state.state_id,
1393 options.clone(),
1394 512,
1395 )
1396 .expect("transfer projection");
1397
1398 let full =
1399 enumerate_state_closure_with_options(repo.store(), state.state_id, options.clone())
1400 .expect("full closure");
1401 let plan = enumerate_state_closure_plan_with_options(repo.store(), state.state_id, options)
1402 .expect("plan closure");
1403 assert_eq!(
1404 transfer
1405 .full_objects
1406 .as_deref()
1407 .map(object_info_fingerprint),
1408 Some(object_info_fingerprint(&full))
1409 );
1410 assert_eq!(transfer.planned_objects, plan);
1411
1412 let full_pairs = pairs_from_full(&full);
1413 assert_eq!(full_pairs, pairs_from_plan(&plan));
1414 assert_contains_object(
1415 &full_pairs,
1416 ObjectId::StateId(state.state_id),
1417 ObjectType::State,
1418 );
1419 assert_contains_object(
1420 &full_pairs,
1421 ObjectId::StateId(state.state_id),
1422 ObjectType::StateVisibility,
1423 );
1424 assert_contains_object(&full_pairs, ObjectId::Hash(redacted_blob), ObjectType::Blob);
1425 assert_contains_object(
1426 &full_pairs,
1427 ObjectId::Hash(redacted_blob),
1428 ObjectType::Redaction,
1429 );
1430 for hash in [
1431 root_tree_hash,
1432 nested_tree_hash,
1433 context_tree_hash,
1434 provenance_tree_hash,
1435 ] {
1436 assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Tree);
1437 }
1438 for hash in [
1439 nested_blob,
1440 symlink_blob,
1441 context_blob,
1442 provenance_blob,
1443 risk_blob,
1444 review_blob,
1445 discussions_blob,
1446 conflicts_blob,
1447 ] {
1448 assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Blob);
1449 }
1450 assert!(!full_pairs.contains(&(
1451 ObjectId::StateId(excluded_parent.state_id),
1452 ObjectType::State
1453 )));
1454 assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_tree_hash), ObjectType::Tree)));
1455 assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_blob), ObjectType::Blob)));
1456 }
1457
1458 #[test]
1459 fn transfer_projection_reads_root_state_once_on_small_transfer() {
1460 let temp = TempDir::new().unwrap();
1461 let repo = Repository::init_default(temp.path()).unwrap();
1462 let blob = repo
1463 .store()
1464 .put_blob(&Blob::from("hello\n"))
1465 .expect("put blob");
1466 let tree_hash = repo
1467 .store()
1468 .put_tree(&Tree::from_entries(vec![
1469 TreeEntry::file("README.md", blob, false).unwrap(),
1470 ]))
1471 .expect("put tree");
1472 let state = State::new(tree_hash, Vec::new(), test_attribution());
1473 repo.store().put_state(&state).expect("put state");
1474 let store = CountingStore::new(repo.store());
1475
1476 let transfer = enumerate_state_closure_transfer_with_options(
1477 &store,
1478 state.state_id,
1479 StateClosureOptions::default(),
1480 512,
1481 )
1482 .expect("transfer projection");
1483
1484 assert!(
1485 !transfer.planned_objects.is_empty(),
1486 "lean projection should be available"
1487 );
1488 assert!(transfer.full_objects.is_some());
1489 assert_eq!(
1490 store.state_reads(),
1491 1,
1492 "small transfer projection must not read the root state through a second closure walk"
1493 );
1494 }
1495
1496 #[test]
1497 fn transfer_projection_drops_full_descriptors_after_threshold() {
1498 let temp = TempDir::new().unwrap();
1499 let repo = Repository::init_default(temp.path()).unwrap();
1500 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1501 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1502
1503 let transfer = enumerate_state_closure_transfer_with_options(
1504 repo.store(),
1505 state.state_id,
1506 StateClosureOptions::default(),
1507 0,
1508 )
1509 .expect("transfer projection");
1510
1511 assert!(
1512 !transfer.planned_objects.is_empty(),
1513 "lean projection should still be available over the threshold"
1514 );
1515 assert!(transfer.full_objects.is_none());
1516 }
1517
1518 #[test]
1519 fn depth_and_exclude_options_match_between_full_and_plan() {
1520 use std::collections::BTreeMap;
1521
1522 use objects::object::{BindingDelta, SemanticIndexRoot, SemanticTreeNode};
1523
1524 let temp = TempDir::new().unwrap();
1525 let repo = Repository::init_default(temp.path()).unwrap();
1526 let path = temp.path().join("story.txt");
1527
1528 std::fs::write(&path, "base\n").unwrap();
1529 let base = repo.snapshot(Some("base".to_string()), None).unwrap();
1530 std::fs::write(&path, "middle\n").unwrap();
1531 let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
1532 std::fs::write(&path, "tip\n").unwrap();
1533 let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
1534
1535 let (semantic_tree, semantic_digest) = SemanticTreeNode::new(Vec::new());
1536 let semantic_tree_hash = repo
1537 .store()
1538 .put_blob(&Blob::new(semantic_tree.encode().unwrap()))
1539 .unwrap();
1540 let attach_delta = |state: StateId, parent: Option<ContentHash>| {
1541 let delta = BindingDelta::new(parent, Vec::new());
1542 let delta_hash = repo
1543 .store()
1544 .put_blob(&Blob::new(delta.encode().unwrap()))
1545 .unwrap();
1546 let root =
1547 SemanticIndexRoot::new(1, BTreeMap::new(), semantic_tree_hash, semantic_digest)
1548 .with_binding_delta(delta_hash, 1);
1549 let root_hash = repo
1550 .store()
1551 .put_blob(&Blob::new(root.encode().unwrap()))
1552 .unwrap();
1553 repo.put_state_attachment(&StateAttachment {
1554 state_id: state,
1555 body: StateAttachmentBody::SemanticIndex(root_hash),
1556 attribution: test_attribution(),
1557 created_at: Utc::now(),
1558 supersedes: None,
1559 })
1560 .unwrap();
1561 delta_hash
1562 };
1563 let base_delta = attach_delta(base.state_id, None);
1564 let middle_delta = attach_delta(middle.state_id, Some(base_delta));
1565 let tip_delta = attach_delta(tip.state_id, Some(middle_delta));
1566
1567 let depth_zero = assert_plan_parity(
1568 &repo,
1569 tip.state_id,
1570 StateClosureOptions {
1571 depth: Some(0),
1572 exclude_states: Vec::new(),
1573 },
1574 );
1575 assert!(depth_zero.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1576 assert!(!depth_zero.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1577 assert!(!depth_zero.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1578 assert!(depth_zero.contains(&(ObjectId::Hash(tip_delta), ObjectType::Blob)));
1579 assert!(!depth_zero.contains(&(ObjectId::Hash(middle_delta), ObjectType::Blob)));
1580 assert!(!depth_zero.contains(&(ObjectId::Hash(base_delta), ObjectType::Blob)));
1581
1582 let depth_one = assert_plan_parity(
1583 &repo,
1584 tip.state_id,
1585 StateClosureOptions {
1586 depth: Some(1),
1587 exclude_states: Vec::new(),
1588 },
1589 );
1590 assert!(depth_one.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1591 assert!(depth_one.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1592 assert!(!depth_one.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1593 assert!(depth_one.contains(&(ObjectId::Hash(tip_delta), ObjectType::Blob)));
1594 assert!(depth_one.contains(&(ObjectId::Hash(middle_delta), ObjectType::Blob)));
1595 assert!(!depth_one.contains(&(ObjectId::Hash(base_delta), ObjectType::Blob)));
1596
1597 let exclude_middle = assert_plan_parity(
1598 &repo,
1599 tip.state_id,
1600 StateClosureOptions {
1601 depth: None,
1602 exclude_states: vec![middle.state_id],
1603 },
1604 );
1605 assert!(exclude_middle.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1606 assert!(!exclude_middle.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1607 assert!(!exclude_middle.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1608 }
1609
1610 #[test]
1611 fn shared_tree_and_blob_references_are_emitted_once() {
1612 let temp = TempDir::new().unwrap();
1613 let repo = Repository::init_default(temp.path()).unwrap();
1614
1615 let shared_blob = Blob::from("shared contents\n");
1616 let shared_blob_hash = repo.store().put_blob(&shared_blob).unwrap();
1617 let shared_tree = Tree::from_entries(vec![
1618 TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
1619 ]);
1620 let shared_tree_hash = repo.store().put_tree(&shared_tree).unwrap();
1621 let root = Tree::from_entries(vec![
1622 TreeEntry::directory("left", shared_tree_hash).unwrap(),
1623 TreeEntry::directory("right", shared_tree_hash).unwrap(),
1624 ]);
1625 let root_hash = repo.store().put_tree(&root).unwrap();
1626 let state = State::new(root_hash, Vec::new(), test_attribution());
1627 repo.store().put_state(&state).unwrap();
1628
1629 let full = enumerate_state_closure_with_options(
1630 repo.store(),
1631 state.state_id,
1632 StateClosureOptions::default(),
1633 )
1634 .unwrap();
1635 let plan = enumerate_state_closure_plan_with_options(
1636 repo.store(),
1637 state.state_id,
1638 StateClosureOptions::default(),
1639 )
1640 .unwrap();
1641
1642 assert_eq!(
1643 pairs_from_full(&full),
1644 pairs_from_plan(&plan),
1645 "full and lean closure enumerators must dedup the same objects"
1646 );
1647
1648 assert_eq!(
1649 full.iter()
1650 .filter(|info| info.id == ObjectId::Hash(root_hash)
1651 && info.obj_type == ObjectType::Tree)
1652 .count(),
1653 1
1654 );
1655 assert_eq!(
1656 full.iter()
1657 .filter(|info| info.id == ObjectId::Hash(shared_tree_hash)
1658 && info.obj_type == ObjectType::Tree)
1659 .count(),
1660 1
1661 );
1662 assert_eq!(
1663 full.iter()
1664 .filter(|info| info.id == ObjectId::Hash(shared_blob_hash)
1665 && info.obj_type == ObjectType::Blob)
1666 .count(),
1667 1
1668 );
1669 }
1670
1671 #[test]
1672 fn state_closure_skips_gitlink_targets() {
1673 let temp = TempDir::new().unwrap();
1674 let repo = Repository::init_default(temp.path()).unwrap();
1675 let target: GitObjectId = "0303030303030303030303030303030303030303"
1676 .parse()
1677 .expect("git oid");
1678 let root = Tree::from_entries(vec![
1679 TreeEntry::gitlink("vendor", target).expect("gitlink entry"),
1680 ]);
1681 let root_hash = repo.store().put_tree(&root).unwrap();
1682 let state = State::new(root_hash, Vec::new(), test_attribution());
1683 repo.store().put_state(&state).unwrap();
1684
1685 let full = enumerate_state_closure_with_options(
1686 repo.store(),
1687 state.state_id,
1688 StateClosureOptions::default(),
1689 )
1690 .unwrap();
1691 let plan = enumerate_state_closure_plan_with_options(
1692 repo.store(),
1693 state.state_id,
1694 StateClosureOptions::default(),
1695 )
1696 .unwrap();
1697
1698 assert_eq!(pairs_from_full(&full), pairs_from_plan(&plan));
1699 assert!(
1700 !full.iter().any(|info| info.obj_type == ObjectType::Blob),
1701 "gitlinks carry foreign Git commit ids, not Heddle blob dependencies: {full:?}"
1702 );
1703 assert!(full.iter().any(|info| {
1704 info.id == ObjectId::Hash(root_hash) && info.obj_type == ObjectType::Tree
1705 }));
1706 }
1707
1708 #[test]
1709 fn missing_blobs_in_tree_skips_gitlinks_and_walks_nested_side_paths() {
1710 let temp = TempDir::new().unwrap();
1711 let repo = Repository::init_default(temp.path()).unwrap();
1712 let present_blob = repo
1713 .store()
1714 .put_blob(&Blob::from("already local"))
1715 .expect("put present blob");
1716 let missing_nested = ContentHash::from_bytes([7; 32]);
1717 let missing_symlink = ContentHash::from_bytes([8; 32]);
1718 let nested_tree = Tree::from_entries(vec![
1719 TreeEntry::file("remote.txt", missing_nested, false).unwrap(),
1720 TreeEntry::symlink("remote-link", missing_symlink).unwrap(),
1721 ]);
1722 let nested_tree_hash = repo
1723 .store()
1724 .put_tree(&nested_tree)
1725 .expect("put nested tree");
1726 let gitlink_target: GitObjectId = "0404040404040404040404040404040404040404"
1727 .parse()
1728 .expect("git oid");
1729 let root = Tree::from_entries(vec![
1730 TreeEntry::file("local.txt", present_blob, false).unwrap(),
1731 TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1732 TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1733 ]);
1734 let root_hash = repo.store().put_tree(&root).expect("put root tree");
1735
1736 let missing = missing_blobs_in_tree(repo.store(), root_hash).expect("missing blobs");
1737
1738 assert_eq!(
1739 missing.into_iter().collect::<HashSet<_>>(),
1740 HashSet::from([missing_nested, missing_symlink])
1741 );
1742 }
1743
1744 #[test]
1749 fn enumerate_state_closure_emits_redaction_for_redacted_blob() {
1750 let temp = TempDir::new().unwrap();
1751 let repo = Repository::init_default(temp.path()).unwrap();
1752 std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
1753 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1754
1755 let tree = repo
1757 .store()
1758 .get_tree(&state.tree)
1759 .unwrap()
1760 .expect("tree present");
1761 let blob_hash = tree
1762 .iter()
1763 .find(|e| e.name() == "secret.toml")
1764 .expect("entry present")
1765 .blob_hash()
1766 .expect("secret.toml is a blob");
1767
1768 let redaction = Redaction {
1769 redacted_blob: blob_hash,
1770 state: state.state_id,
1771 path: "secret.toml".to_string(),
1772 reason: "test leak".to_string(),
1773 redactor: Principal {
1774 name: "Tester".into(),
1775 email: "tester@heddle.sh".into(),
1776 },
1777 redacted_at: Utc::now(),
1778 signature: None,
1779 purged_at: None,
1780 supersedes: None,
1781 };
1782 repo.put_redaction(redaction).unwrap();
1783
1784 let full = enumerate_state_closure_with_options(
1785 repo.store(),
1786 state.state_id,
1787 StateClosureOptions::default(),
1788 )
1789 .unwrap();
1790 let plan = enumerate_state_closure_plan_with_options(
1791 repo.store(),
1792 state.state_id,
1793 StateClosureOptions::default(),
1794 )
1795 .unwrap();
1796
1797 assert!(
1798 full.iter()
1799 .any(|info| info.obj_type == ObjectType::Redaction
1800 && info.id == ObjectId::Hash(blob_hash)),
1801 "full closure must include a Redaction entry for the redacted blob"
1802 );
1803 assert!(
1804 plan.iter()
1805 .any(|p| p.obj_type == ObjectType::Redaction && p.id == ObjectId::Hash(blob_hash)),
1806 "plan closure must include a Redaction entry for the redacted blob"
1807 );
1808 }
1809
1810 #[test]
1811 fn enumerate_state_closure_emits_state_visibility_for_visible_state() {
1812 let temp = TempDir::new().unwrap();
1813 let repo = Repository::init_default(temp.path()).unwrap();
1814 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1815 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1816
1817 repo.put_state_visibility(StateVisibility {
1818 state: state.state_id,
1819 tier: VisibilityTier::Restricted {
1820 scope_label: "security-embargo".into(),
1821 },
1822 embargo_until: None,
1823 declarer: Principal {
1824 name: "Tester".into(),
1825 email: "tester@heddle.sh".into(),
1826 },
1827 declared_at: Utc::now(),
1828 signature: None,
1829 supersedes: None,
1830 })
1831 .unwrap();
1832
1833 let full = enumerate_state_closure_with_options(
1834 repo.store(),
1835 state.state_id,
1836 StateClosureOptions::default(),
1837 )
1838 .unwrap();
1839 let plan = enumerate_state_closure_plan_with_options(
1840 repo.store(),
1841 state.state_id,
1842 StateClosureOptions::default(),
1843 )
1844 .unwrap();
1845
1846 assert!(
1847 full.iter()
1848 .any(|info| info.obj_type == ObjectType::StateVisibility
1849 && info.id == ObjectId::StateId(state.state_id)),
1850 "full closure must include a StateVisibility entry for the visible state"
1851 );
1852 assert!(
1853 plan.iter()
1854 .any(|p| p.obj_type == ObjectType::StateVisibility
1855 && p.id == ObjectId::StateId(state.state_id)),
1856 "plan closure must include a StateVisibility entry for the visible state"
1857 );
1858 }
1859
1860 #[test]
1861 fn enumerate_state_closure_emits_state_metadata_blobs() {
1862 let temp = TempDir::new().unwrap();
1863 let repo = Repository::init_default(temp.path()).unwrap();
1864 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1865 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1866
1867 let principal = Principal::new("Tester", "tester@example.test");
1868 let discussion_bytes = DiscussionsBlob::new(vec![Discussion {
1869 id: "disc-1".to_string(),
1870 anchor: SymbolAnchor::new("src/lib.rs", "answer"),
1871 opened_against_state: state.state_id,
1872 opened_at: 1_782_400_000,
1873 thread_ref: None,
1874 turns: vec![DiscussionTurn {
1875 author: principal,
1876 body: "Should this sync?".to_string(),
1877 posted_at: 1_782_400_000,
1878 references: Vec::new(),
1879 }],
1880 resolution: DiscussionResolution::Open,
1881 body_changed_since_open: false,
1882 orphaned: false,
1883 visibility: VisibilityTier::default(),
1884 resolved_annotation_id: None,
1885 }])
1886 .encode()
1887 .expect("encode discussions");
1888 let discussion_hash = repo
1889 .store()
1890 .put_blob(&Blob::new(discussion_bytes))
1891 .expect("put discussions blob");
1892 let risk_hash = repo
1893 .store()
1894 .put_blob(&Blob::from_slice(b"risk signals"))
1895 .expect("put risk blob");
1896 let review_hash = repo
1897 .store()
1898 .put_blob(&Blob::from_slice(b"review signatures"))
1899 .expect("put review blob");
1900 let conflicts_hash = repo
1901 .store()
1902 .put_blob(&Blob::from_slice(b"structured conflicts"))
1903 .expect("put conflicts blob");
1904 for body in [
1905 StateAttachmentBody::RiskSignals(risk_hash),
1906 StateAttachmentBody::ReviewSignatures(review_hash),
1907 StateAttachmentBody::Discussions(discussion_hash),
1908 StateAttachmentBody::StructuredConflicts(conflicts_hash),
1909 ] {
1910 repo.put_state_attachment(&StateAttachment {
1911 state_id: state.id(),
1912 body,
1913 attribution: state.attribution.clone(),
1914 created_at: Utc::now(),
1915 supersedes: None,
1916 })
1917 .unwrap();
1918 }
1919
1920 let full = enumerate_state_closure_with_options(
1921 repo.store(),
1922 state.state_id,
1923 StateClosureOptions::default(),
1924 )
1925 .unwrap();
1926 let plan = enumerate_state_closure_plan_with_options(
1927 repo.store(),
1928 state.state_id,
1929 StateClosureOptions::default(),
1930 )
1931 .unwrap();
1932
1933 for metadata_hash in [risk_hash, review_hash, discussion_hash, conflicts_hash] {
1934 assert!(
1935 full.iter().any(|info| info.obj_type == ObjectType::Blob
1936 && info.id == ObjectId::Hash(metadata_hash)),
1937 "full closure must include state metadata blob {metadata_hash}"
1938 );
1939 assert!(
1940 plan.iter().any(
1941 |p| p.obj_type == ObjectType::Blob && p.id == ObjectId::Hash(metadata_hash)
1942 ),
1943 "plan closure must include state metadata blob {metadata_hash}"
1944 );
1945 }
1946 }
1947
1948 #[test]
1952 fn packable_predicates_split_state_attachment_by_direction() {
1953 for sidecar in [
1955 ObjectType::Redaction,
1956 ObjectType::StateVisibility,
1957 ObjectType::KeyBinding,
1958 ] {
1959 assert!(!sidecar.packable_for_push(), "{sidecar:?} push");
1960 assert!(!sidecar.packable_for_pull(), "{sidecar:?} pull");
1961 }
1962 for packable in [
1964 ObjectType::Blob,
1965 ObjectType::Tree,
1966 ObjectType::State,
1967 ObjectType::Action,
1968 ] {
1969 assert!(packable.packable_for_push(), "{packable:?} push");
1970 assert!(packable.packable_for_pull(), "{packable:?} pull");
1971 }
1972 assert!(!ObjectType::StateAttachment.packable_for_push());
1974 assert!(ObjectType::StateAttachment.packable_for_pull());
1975 }
1976
1977 #[test]
1982 fn semantic_index_attachment_excluded_from_push_pack_but_kept_for_pull() {
1983 use std::collections::BTreeMap;
1984
1985 use objects::object::{
1986 BindingDelta, FileBindingDelta, SemanticIndexRoot, SemanticTreeNode,
1987 };
1988
1989 let temp = TempDir::new().unwrap();
1990 let repo = Repository::init_default(temp.path()).unwrap();
1991 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1992 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1993
1994 let (node, node_digest) = SemanticTreeNode::new(Vec::new());
1996 let node_hash = repo
1997 .store()
1998 .put_blob(&Blob::new(node.encode().unwrap()))
1999 .expect("put semantic tree node");
2000 let base_delta = BindingDelta::new(
2001 None,
2002 vec![FileBindingDelta::new(
2003 "unreachable-parent.rs",
2004 None,
2005 Vec::new(),
2006 )],
2007 );
2008 let base_delta_hash = repo
2009 .store()
2010 .put_blob(&Blob::new(base_delta.encode().unwrap()))
2011 .expect("put base binding delta");
2012 let delta = BindingDelta::new(Some(base_delta_hash), Vec::new());
2013 let delta_hash = repo
2014 .store()
2015 .put_blob(&Blob::new(delta.encode().unwrap()))
2016 .expect("put binding delta");
2017 let root = SemanticIndexRoot::new(1, BTreeMap::new(), node_hash, node_digest)
2018 .with_binding_delta(delta_hash, 1);
2019 let root_hash = repo
2020 .store()
2021 .put_blob(&Blob::new(root.encode().unwrap()))
2022 .expect("put semantic index root");
2023 repo.put_state_attachment(&StateAttachment {
2024 state_id: state.state_id,
2025 body: StateAttachmentBody::SemanticIndex(root_hash),
2026 attribution: test_attribution(),
2027 created_at: Utc::now(),
2028 supersedes: None,
2029 })
2030 .unwrap();
2031
2032 let plan = enumerate_state_closure_plan_with_options(
2033 repo.store(),
2034 state.state_id,
2035 StateClosureOptions::default(),
2036 )
2037 .unwrap();
2038
2039 let attachments: Vec<_> = plan
2042 .iter()
2043 .filter(|p| p.obj_type == ObjectType::StateAttachment)
2044 .collect();
2045 assert!(
2046 !attachments.is_empty(),
2047 "closure must contain the semantic-index attachment record"
2048 );
2049 for attachment in &attachments {
2050 assert!(matches!(attachment.id, ObjectId::StateAttachment { .. }));
2051 assert!(
2053 !attachment.obj_type.packable_for_push(),
2054 "attachment record must be excluded from the push pack"
2055 );
2056 assert!(
2057 attachment.obj_type.packable_for_pull(),
2058 "attachment record must stay in the pull pack"
2059 );
2060 }
2061
2062 for content in [root_hash, node_hash, delta_hash] {
2066 let obj = plan
2067 .iter()
2068 .find(|p| p.id == ObjectId::Hash(content))
2069 .unwrap_or_else(|| panic!("semantic content blob {content} in closure"));
2070 assert_eq!(obj.obj_type, ObjectType::Blob);
2071 assert!(obj.obj_type.packable_for_push());
2072 assert!(obj.obj_type.packable_for_pull());
2073 }
2074 assert!(
2075 !plan
2076 .iter()
2077 .any(|object| object.id == ObjectId::Hash(base_delta_hash)),
2078 "a binding delta belonging to an unreachable parent state must not ride this state's closure"
2079 );
2080
2081 let (push_pack, push_sidecar): (Vec<_>, Vec<_>) =
2084 plan.iter().partition(|p| p.obj_type.packable_for_push());
2085 assert!(
2086 push_sidecar
2087 .iter()
2088 .any(|p| p.obj_type == ObjectType::StateAttachment),
2089 "attachment record routed to the push sidecar partition"
2090 );
2091 assert!(
2092 !push_pack
2093 .iter()
2094 .any(|p| p.obj_type == ObjectType::StateAttachment),
2095 "attachment record must not be in the push pack partition"
2096 );
2097 }
2098}