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