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