1use std::collections::{HashSet, VecDeque};
3
4use objects::{
5 object::{ChangeId, ContentHash, State, TreeEntryTarget},
6 store::{ObjectStore, pack::ObjectType as PackObjectType},
7};
8use serde::{Deserialize, Serialize};
9
10use crate::{ProtocolError, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum ObjectId {
14 Hash(ContentHash),
15 ChangeId(ChangeId),
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ObjectInfo {
20 pub id: ObjectId,
21 pub obj_type: ObjectType,
22 pub size: u64,
23 pub delta_base: Option<ContentHash>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct PlannedObject {
28 pub id: ObjectId,
29 pub obj_type: ObjectType,
30}
31
32#[derive(Debug, Clone)]
33pub struct StateClosureTransferObjects {
34 pub planned_objects: Vec<PlannedObject>,
35 pub full_objects: Option<Vec<ObjectInfo>>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub enum ObjectType {
40 Blob,
41 Tree,
42 State,
43 Action,
44 Redaction,
50 StateVisibility,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum ObjectTypeBucket {
61 Blob,
62 Tree,
63 State,
64 Action,
65 Redaction,
66 StateVisibility,
67}
68
69impl ObjectType {
70 pub fn wire_name(self) -> &'static str {
71 match self {
72 ObjectType::Blob => "blob",
73 ObjectType::Tree => "tree",
74 ObjectType::State => "state",
75 ObjectType::Action => "action",
76 ObjectType::Redaction => "redaction",
77 ObjectType::StateVisibility => "state_visibility",
78 }
79 }
80
81 pub fn from_wire(value: &str) -> Result<Self> {
82 match value {
83 "blob" => Ok(ObjectType::Blob),
84 "tree" => Ok(ObjectType::Tree),
85 "state" => Ok(ObjectType::State),
86 "action" => Ok(ObjectType::Action),
87 "redaction" => Ok(ObjectType::Redaction),
88 "state_visibility" => Ok(ObjectType::StateVisibility),
89 _ => Err(ProtocolError::InvalidState(format!(
90 "unknown object type: {value}"
91 ))),
92 }
93 }
94
95 pub fn packable(self) -> bool {
96 !matches!(self, ObjectType::Redaction | ObjectType::StateVisibility)
97 }
98
99 pub fn pack_object_type(self) -> Result<PackObjectType> {
100 match self {
101 ObjectType::Blob => Ok(PackObjectType::Blob),
102 ObjectType::Tree => Ok(PackObjectType::Tree),
103 ObjectType::State => Ok(PackObjectType::State),
104 ObjectType::Action => Ok(PackObjectType::Action),
105 ObjectType::Redaction => Err(ProtocolError::InvalidState(
106 "Redaction sidecar records cannot be packed into the content-addressed object pack"
107 .to_string(),
108 )),
109 ObjectType::StateVisibility => Err(ProtocolError::InvalidState(
110 "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
111 .to_string(),
112 )),
113 }
114 }
115
116 pub fn bucket(self) -> ObjectTypeBucket {
117 match self {
118 ObjectType::Blob => ObjectTypeBucket::Blob,
119 ObjectType::Tree => ObjectTypeBucket::Tree,
120 ObjectType::State => ObjectTypeBucket::State,
121 ObjectType::Action => ObjectTypeBucket::Action,
122 ObjectType::Redaction => ObjectTypeBucket::Redaction,
123 ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Default)]
129pub struct StateClosureOptions {
130 pub depth: Option<u32>,
131 pub exclude_states: Vec<ChangeId>,
132}
133
134pub fn enumerate_state_closure(
135 store: &impl ObjectStore,
136 state_id: ChangeId,
137) -> Result<Vec<ObjectInfo>> {
138 enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
139}
140
141pub fn enumerate_state_closure_with_options(
142 store: &impl ObjectStore,
143 state_id: ChangeId,
144 options: StateClosureOptions,
145) -> Result<Vec<ObjectInfo>> {
146 let mut out = Vec::new();
147 walk_state_closure(store, state_id, options, |event| {
148 if let Some(info) = object_info_from_event(store, event)? {
149 out.push(info);
150 }
151 Ok(())
152 })?;
153
154 Ok(out)
155}
156
157pub fn enumerate_state_closure_plan(
158 store: &impl ObjectStore,
159 state_id: ChangeId,
160) -> Result<Vec<PlannedObject>> {
161 enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
162}
163
164pub fn enumerate_state_closure_plan_with_options(
165 store: &impl ObjectStore,
166 state_id: ChangeId,
167 options: StateClosureOptions,
168) -> Result<Vec<PlannedObject>> {
169 let mut out = Vec::new();
170 walk_state_closure(store, state_id, options, |event| {
171 if let Some(object) = planned_object_from_event(store, event)? {
172 out.push(object);
173 }
174 Ok(())
175 })?;
176
177 Ok(out)
178}
179
180pub fn enumerate_state_closure_transfer_with_options(
181 store: &impl ObjectStore,
182 state_id: ChangeId,
183 options: StateClosureOptions,
184 full_descriptor_object_threshold: usize,
185) -> Result<StateClosureTransferObjects> {
186 let mut planned_objects = Vec::new();
187 let mut full_objects = Some(Vec::new());
188
189 walk_state_closure(store, state_id, options, |event| {
190 if let Some(object) = planned_object_from_event(store, event)? {
191 planned_objects.push(object);
192 }
193
194 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
195 full_objects = None;
196 }
197 if let Some(objects) = full_objects.as_mut()
198 && let Some(info) = object_info_from_event(store, event)?
199 {
200 objects.push(info);
201 }
202
203 Ok(())
204 })?;
205
206 Ok(StateClosureTransferObjects {
207 planned_objects,
208 full_objects,
209 })
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213enum BlobSource {
214 Tree,
215 StateMetadata,
216}
217
218#[derive(Debug, Clone, Copy)]
219enum StateClosureEvent<'a> {
220 State {
221 id: ChangeId,
222 state: &'a State,
223 },
224 Tree {
225 hash: ContentHash,
226 tree: &'a objects::object::Tree,
227 },
228 Blob {
229 hash: ContentHash,
230 source: BlobSource,
231 },
232 Redaction {
233 blob: ContentHash,
234 },
235 StateVisibility {
236 state: ChangeId,
237 },
238 ExcludedState {
239 id: ChangeId,
240 },
241 ExcludedHash {
242 hash: ContentHash,
243 },
244}
245
246fn walk_state_closure(
247 store: &impl ObjectStore,
248 state_id: ChangeId,
249 options: StateClosureOptions,
250 mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
251) -> Result<()> {
252 let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
253
254 let mut seen_states: HashSet<ChangeId> = HashSet::new();
255 let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
256 let mut queue: VecDeque<(ChangeId, u32)> = VecDeque::new();
257 queue.push_back((state_id, 0));
258
259 while let Some((id, depth)) = queue.pop_front() {
260 if excluded_states.contains(&id) {
261 visit(StateClosureEvent::ExcludedState { id })?;
262 continue;
263 }
264 if !seen_states.insert(id) {
265 continue;
266 }
267
268 let state = store
269 .get_state(&id)?
270 .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
271
272 visit(StateClosureEvent::State { id, state: &state })?;
273 if store.has_state_visibility_for_state(&id)? {
274 visit(StateClosureEvent::StateVisibility { state: id })?;
275 }
276
277 if options.depth.map(|max| depth < max).unwrap_or(true) {
278 for parent in &state.parents {
279 queue.push_back((*parent, depth + 1));
280 }
281 }
282
283 walk_tree_closure_filtered(
284 store,
285 state.tree,
286 &excluded_hashes,
287 &mut seen_hashes,
288 &mut visit,
289 )?;
290 if let Some(provenance_root) = state.provenance {
291 walk_tree_closure_filtered(
292 store,
293 provenance_root,
294 &excluded_hashes,
295 &mut seen_hashes,
296 &mut visit,
297 )?;
298 }
299 if let Some(context_root) = state.context {
300 walk_tree_closure_filtered(
301 store,
302 context_root,
303 &excluded_hashes,
304 &mut seen_hashes,
305 &mut visit,
306 )?;
307 }
308 for metadata_blob in state_blob_dependencies(&state) {
309 walk_blob_filtered(
310 store,
311 metadata_blob,
312 BlobSource::StateMetadata,
313 &excluded_hashes,
314 &mut seen_hashes,
315 &mut visit,
316 )?;
317 }
318 }
319
320 Ok(())
321}
322
323fn walk_tree_closure_filtered(
324 store: &impl ObjectStore,
325 tree_hash: ContentHash,
326 excluded: &HashSet<ContentHash>,
327 seen: &mut HashSet<ContentHash>,
328 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
329) -> Result<()> {
330 if excluded.contains(&tree_hash) {
331 visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
332 return Ok(());
333 }
334 if !seen.insert(tree_hash) {
335 return Ok(());
336 }
337
338 let tree = store
339 .get_tree(&tree_hash)?
340 .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
341
342 visit(StateClosureEvent::Tree {
343 hash: tree_hash,
344 tree: &tree,
345 })?;
346
347 for entry in tree.entries() {
348 match entry.target() {
349 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
350 walk_blob_filtered(store, *hash, BlobSource::Tree, excluded, seen, visit)?;
351 }
352 TreeEntryTarget::Tree { hash } => {
353 walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
354 }
355 TreeEntryTarget::Gitlink { .. } => {}
356 TreeEntryTarget::Spoollink { .. } => {}
359 }
360 }
361
362 Ok(())
363}
364
365fn walk_blob_filtered(
366 store: &impl ObjectStore,
367 blob_hash: ContentHash,
368 source: BlobSource,
369 excluded: &HashSet<ContentHash>,
370 seen: &mut HashSet<ContentHash>,
371 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
372) -> Result<()> {
373 if excluded.contains(&blob_hash) {
374 visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
375 return Ok(());
376 }
377 if !seen.insert(blob_hash) {
378 return Ok(());
379 }
380 visit(StateClosureEvent::Blob {
381 hash: blob_hash,
382 source,
383 })?;
384 if store.has_redactions_for_blob(&blob_hash)? {
385 visit(StateClosureEvent::Redaction { blob: blob_hash })?;
386 }
387 Ok(())
388}
389
390fn object_info_from_event(
391 store: &impl ObjectStore,
392 event: StateClosureEvent<'_>,
393) -> Result<Option<ObjectInfo>> {
394 match event {
395 StateClosureEvent::State { id, state } => {
396 let state_bytes = rmp_serde::to_vec_named(state)?;
397 Ok(Some(ObjectInfo {
398 id: ObjectId::ChangeId(id),
399 obj_type: ObjectType::State,
400 size: state_bytes.len() as u64,
401 delta_base: None,
402 }))
403 }
404 StateClosureEvent::Tree { hash, tree } => {
405 let tree_bytes = rmp_serde::to_vec_named(tree)?;
406 Ok(Some(ObjectInfo {
407 id: ObjectId::Hash(hash),
408 obj_type: ObjectType::Tree,
409 size: tree_bytes.len() as u64,
410 delta_base: None,
411 }))
412 }
413 StateClosureEvent::Blob { hash, .. } => {
414 let blob = store
415 .get_blob(&hash)?
416 .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
417 Ok(Some(ObjectInfo {
418 id: ObjectId::Hash(hash),
419 obj_type: ObjectType::Blob,
420 size: blob.size() as u64,
421 delta_base: None,
422 }))
423 }
424 StateClosureEvent::Redaction { blob } => Ok(store
425 .get_redactions_bytes_for_blob(&blob)?
426 .map(|bytes| ObjectInfo {
427 id: ObjectId::Hash(blob),
428 obj_type: ObjectType::Redaction,
429 size: bytes.len() as u64,
430 delta_base: None,
431 })),
432 StateClosureEvent::StateVisibility { state } => Ok(store
433 .get_state_visibility_bytes_for_state(&state)?
434 .map(|bytes| ObjectInfo {
435 id: ObjectId::ChangeId(state),
436 obj_type: ObjectType::StateVisibility,
437 size: bytes.len() as u64,
438 delta_base: None,
439 })),
440 StateClosureEvent::ExcludedState { id } => {
441 let _ = id;
442 Ok(None)
443 }
444 StateClosureEvent::ExcludedHash { hash } => {
445 let _ = hash;
446 Ok(None)
447 }
448 }
449}
450
451fn planned_object_from_event(
452 store: &impl ObjectStore,
453 event: StateClosureEvent<'_>,
454) -> Result<Option<PlannedObject>> {
455 match event {
456 StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
457 id: ObjectId::ChangeId(id),
458 obj_type: ObjectType::State,
459 })),
460 StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
461 id: ObjectId::Hash(hash),
462 obj_type: ObjectType::Tree,
463 })),
464 StateClosureEvent::Blob { hash, source } => {
465 if source == BlobSource::StateMetadata && store.get_blob(&hash)?.is_none() {
466 return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
467 }
468 Ok(Some(PlannedObject {
469 id: ObjectId::Hash(hash),
470 obj_type: ObjectType::Blob,
471 }))
472 }
473 StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
474 id: ObjectId::Hash(blob),
475 obj_type: ObjectType::Redaction,
476 })),
477 StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
478 id: ObjectId::ChangeId(state),
479 obj_type: ObjectType::StateVisibility,
480 })),
481 StateClosureEvent::ExcludedState { id } => {
482 let _ = id;
483 Ok(None)
484 }
485 StateClosureEvent::ExcludedHash { hash } => {
486 let _ = hash;
487 Ok(None)
488 }
489 }
490}
491
492pub fn missing_blobs_in_tree(
493 store: &impl ObjectStore,
494 tree_hash: ContentHash,
495) -> Result<Vec<ContentHash>> {
496 let mut missing = Vec::new();
497 collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
498 Ok(missing)
499}
500
501fn collect_missing_blobs_recursive(
502 store: &impl ObjectStore,
503 tree_hash: &ContentHash,
504 missing: &mut Vec<ContentHash>,
505) -> Result<()> {
506 let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
507 ProtocolError::InvalidState(format!(
508 "load tree {} while collecting lazy hydration missing blobs: {err}",
509 tree_hash.to_hex()
510 ))
511 })?
512 else {
513 return Ok(());
514 };
515
516 for entry in tree.entries() {
517 match entry.target() {
518 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
519 if !store.has_blob(hash).map_err(|err| {
520 ProtocolError::InvalidState(format!(
521 "check blob {} while collecting lazy hydration missing blobs: {err}",
522 hash.to_hex()
523 ))
524 })? {
525 missing.push(*hash);
526 }
527 }
528 TreeEntryTarget::Tree { hash } => {
529 collect_missing_blobs_recursive(store, hash, missing)?;
530 }
531 TreeEntryTarget::Gitlink { .. } => {}
532 TreeEntryTarget::Spoollink { .. } => {}
535 }
536 }
537 Ok(())
538}
539
540fn collect_excluded(
541 store: &impl ObjectStore,
542 roots: &[ChangeId],
543) -> Result<(HashSet<ChangeId>, HashSet<ContentHash>)> {
544 if roots.is_empty() {
545 return Ok((HashSet::new(), HashSet::new()));
546 }
547
548 let mut excluded_states: HashSet<ChangeId> = HashSet::new();
549 let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
550 let mut queue: VecDeque<ChangeId> = VecDeque::new();
551
552 for id in roots {
553 queue.push_back(*id);
554 }
555
556 while let Some(id) = queue.pop_front() {
557 if !excluded_states.insert(id) {
558 continue;
559 }
560
561 let state = match store.get_state(&id)? {
562 Some(state) => state,
563 None => continue,
564 };
565
566 for parent in &state.parents {
567 queue.push_back(*parent);
568 }
569
570 collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
571 if let Some(provenance_root) = state.provenance {
572 collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
573 }
574 if let Some(context_root) = state.context {
575 collect_tree_hashes(store, context_root, &mut excluded_hashes)?;
576 }
577 for metadata_blob in state_blob_dependencies(&state) {
578 excluded_hashes.insert(metadata_blob);
579 }
580 }
581
582 Ok((excluded_states, excluded_hashes))
583}
584
585fn state_blob_dependencies(state: &State) -> impl Iterator<Item = ContentHash> + '_ {
586 [
587 state.risk_signals,
588 state.review_signatures,
589 state.discussions,
590 state.structured_conflicts,
591 ]
592 .into_iter()
593 .flatten()
594}
595
596fn collect_tree_hashes(
597 store: &impl ObjectStore,
598 tree_hash: ContentHash,
599 excluded: &mut HashSet<ContentHash>,
600) -> Result<()> {
601 if !excluded.insert(tree_hash) {
602 return Ok(());
603 }
604
605 let tree = match store.get_tree(&tree_hash)? {
606 Some(tree) => tree,
607 None => return Ok(()),
608 };
609
610 for entry in tree.entries() {
611 match entry.target() {
612 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
613 excluded.insert(*hash);
614 }
615 TreeEntryTarget::Tree { hash } => {
616 collect_tree_hashes(store, *hash, excluded)?;
617 }
618 TreeEntryTarget::Gitlink { .. } => {}
619 TreeEntryTarget::Spoollink { .. } => {}
622 }
623 }
624
625 Ok(())
626}
627
628pub fn is_ancestor(
629 store: &impl ObjectStore,
630 ancestor: ChangeId,
631 descendant: ChangeId,
632) -> Result<bool> {
633 if ancestor == descendant {
634 return Ok(true);
635 }
636
637 let mut seen: HashSet<ChangeId> = HashSet::new();
638 let mut queue: VecDeque<ChangeId> = VecDeque::new();
639 queue.push_back(descendant);
640
641 while let Some(id) = queue.pop_front() {
642 if !seen.insert(id) {
643 continue;
644 }
645 let state = match store.get_state(&id)? {
646 Some(s) => s,
647 None => return Ok(false),
648 };
649 for parent in state.parents {
650 if parent == ancestor {
651 return Ok(true);
652 }
653 queue.push_back(parent);
654 }
655 }
656
657 Ok(false)
658}
659
660#[cfg(test)]
661mod tests {
662 use std::{
663 collections::HashSet,
664 sync::atomic::{AtomicUsize, Ordering},
665 };
666
667 use chrono::Utc;
668 use objects::{
669 object::{
670 Action, ActionId, Attribution, Blob, ChangeId, ContentHash, Discussion,
671 DiscussionResolution, DiscussionTurn, DiscussionsBlob, Principal, Redaction, State,
672 StateVisibility, SymbolAnchor, Tree, TreeEntry, VisibilityTier,
673 },
674 store::{ObjectStore, Result as StoreResult},
675 };
676 use repo::Repository;
677 use sley::ObjectId as GitObjectId;
678 use tempfile::TempDir;
679
680 use super::{
681 ObjectId, ObjectInfo, ObjectType, PlannedObject, StateClosureOptions,
682 enumerate_state_closure_plan_with_options, enumerate_state_closure_transfer_with_options,
683 enumerate_state_closure_with_options, missing_blobs_in_tree,
684 };
685
686 fn pairs_from_full(objects: &[ObjectInfo]) -> HashSet<(ObjectId, ObjectType)> {
687 objects
688 .iter()
689 .map(|info| (info.id.clone(), info.obj_type))
690 .collect()
691 }
692
693 fn pairs_from_plan(objects: &[PlannedObject]) -> HashSet<(ObjectId, ObjectType)> {
694 objects
695 .iter()
696 .map(|info| (info.id.clone(), info.obj_type))
697 .collect()
698 }
699
700 fn object_info_fingerprint(
701 objects: &[ObjectInfo],
702 ) -> Vec<(ObjectId, ObjectType, u64, Option<ContentHash>)> {
703 objects
704 .iter()
705 .map(|info| (info.id.clone(), info.obj_type, info.size, info.delta_base))
706 .collect()
707 }
708
709 fn assert_plan_parity(
710 repo: &Repository,
711 state_id: ChangeId,
712 options: StateClosureOptions,
713 ) -> HashSet<(ObjectId, ObjectType)> {
714 let full =
715 enumerate_state_closure_with_options(repo.store(), state_id, options.clone()).unwrap();
716 let plan =
717 enumerate_state_closure_plan_with_options(repo.store(), state_id, options).unwrap();
718
719 let full_pairs = pairs_from_full(&full);
720 let plan_pairs = pairs_from_plan(&plan);
721 assert_eq!(full_pairs, plan_pairs);
722 full_pairs
723 }
724
725 fn assert_contains_object(
726 objects: &HashSet<(ObjectId, ObjectType)>,
727 id: ObjectId,
728 obj_type: ObjectType,
729 ) {
730 assert!(
731 objects.contains(&(id.clone(), obj_type)),
732 "expected closure to contain {id:?} as {obj_type:?}: {objects:?}"
733 );
734 }
735
736 struct CountingStore<'a, S> {
737 inner: &'a S,
738 state_reads: AtomicUsize,
739 }
740
741 impl<'a, S> CountingStore<'a, S> {
742 fn new(inner: &'a S) -> Self {
743 Self {
744 inner,
745 state_reads: AtomicUsize::new(0),
746 }
747 }
748
749 fn state_reads(&self) -> usize {
750 self.state_reads.load(Ordering::SeqCst)
751 }
752 }
753
754 impl<S: ObjectStore> ObjectStore for CountingStore<'_, S> {
755 fn get_blob(&self, hash: &ContentHash) -> StoreResult<Option<Blob>> {
756 self.inner.get_blob(hash)
757 }
758
759 fn put_blob(&self, blob: &Blob) -> StoreResult<ContentHash> {
760 self.inner.put_blob(blob)
761 }
762
763 fn has_blob(&self, hash: &ContentHash) -> StoreResult<bool> {
764 self.inner.has_blob(hash)
765 }
766
767 fn get_tree(&self, hash: &ContentHash) -> StoreResult<Option<Tree>> {
768 self.inner.get_tree(hash)
769 }
770
771 fn put_tree(&self, tree: &Tree) -> StoreResult<ContentHash> {
772 self.inner.put_tree(tree)
773 }
774
775 fn has_tree(&self, hash: &ContentHash) -> StoreResult<bool> {
776 self.inner.has_tree(hash)
777 }
778
779 fn get_state(&self, id: &ChangeId) -> StoreResult<Option<State>> {
780 self.state_reads.fetch_add(1, Ordering::SeqCst);
781 self.inner.get_state(id)
782 }
783
784 fn put_state(&self, state: &State) -> StoreResult<()> {
785 self.inner.put_state(state)
786 }
787
788 fn has_state(&self, id: &ChangeId) -> StoreResult<bool> {
789 self.inner.has_state(id)
790 }
791
792 fn list_states(&self) -> StoreResult<Vec<ChangeId>> {
793 self.inner.list_states()
794 }
795
796 fn get_action(&self, id: &ActionId) -> StoreResult<Option<Action>> {
797 self.inner.get_action(id)
798 }
799
800 fn put_action(&self, action: &mut Action) -> StoreResult<ActionId> {
801 self.inner.put_action(action)
802 }
803
804 fn list_actions(&self) -> StoreResult<Vec<ActionId>> {
805 self.inner.list_actions()
806 }
807
808 fn list_blobs(&self) -> StoreResult<Vec<ContentHash>> {
809 self.inner.list_blobs()
810 }
811
812 fn list_trees(&self) -> StoreResult<Vec<ContentHash>> {
813 self.inner.list_trees()
814 }
815 }
816
817 fn test_attribution() -> Attribution {
818 Attribution::human(Principal::new("Graph Tester", "graph@example.com"))
819 }
820
821 #[test]
822 fn lean_closure_planner_matches_object_info_ids_and_types() {
823 let temp = TempDir::new().unwrap();
824 let repo = Repository::init_default(temp.path()).unwrap();
825 std::fs::create_dir_all(temp.path().join("src")).unwrap();
826 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
827 std::fs::write(temp.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
828 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
829
830 let full = enumerate_state_closure_with_options(
831 repo.store(),
832 state.change_id,
833 StateClosureOptions::default(),
834 )
835 .unwrap();
836 let lean = enumerate_state_closure_plan_with_options(
837 repo.store(),
838 state.change_id,
839 StateClosureOptions::default(),
840 )
841 .unwrap();
842
843 let full_pairs = full
844 .into_iter()
845 .map(|info| (info.id, info.obj_type))
846 .collect::<std::collections::HashSet<_>>();
847 let lean_pairs = lean
848 .into_iter()
849 .map(|info| (info.id, info.obj_type))
850 .collect::<std::collections::HashSet<_>>();
851
852 assert_eq!(full_pairs, lean_pairs);
853 assert!(
854 full_pairs
855 .iter()
856 .any(|(id, _)| matches!(id, ObjectId::ChangeId(_)))
857 );
858 }
859
860 #[test]
861 fn transfer_projection_matches_full_and_plan_on_mixed_state_closure_fixture() {
862 let temp = TempDir::new().unwrap();
863 let repo = Repository::init_default(temp.path()).unwrap();
864
865 let excluded_blob = repo
866 .store()
867 .put_blob(&Blob::from("excluded"))
868 .expect("put excluded blob");
869 let excluded_tree_hash = repo
870 .store()
871 .put_tree(&Tree::from_entries(vec![
872 TreeEntry::file("excluded.txt", excluded_blob, false).unwrap(),
873 ]))
874 .expect("put excluded tree");
875 let excluded_parent = State::new(excluded_tree_hash, Vec::new(), test_attribution());
876 repo.store()
877 .put_state(&excluded_parent)
878 .expect("put excluded parent");
879
880 let redacted_blob = repo
881 .store()
882 .put_blob(&Blob::from("secret"))
883 .expect("put redacted blob");
884 let nested_blob = repo
885 .store()
886 .put_blob(&Blob::from("nested"))
887 .expect("put nested blob");
888 let symlink_blob = repo
889 .store()
890 .put_blob(&Blob::from("target"))
891 .expect("put symlink blob");
892 let context_blob = repo
893 .store()
894 .put_blob(&Blob::from("context"))
895 .expect("put context blob");
896 let provenance_blob = repo
897 .store()
898 .put_blob(&Blob::from("provenance"))
899 .expect("put provenance blob");
900 let risk_blob = repo
901 .store()
902 .put_blob(&Blob::from("risk"))
903 .expect("put risk blob");
904 let review_blob = repo
905 .store()
906 .put_blob(&Blob::from("review"))
907 .expect("put review blob");
908 let discussions_blob = repo
909 .store()
910 .put_blob(&Blob::from("discussion"))
911 .expect("put discussion blob");
912 let conflicts_blob = repo
913 .store()
914 .put_blob(&Blob::from("conflicts"))
915 .expect("put conflicts blob");
916
917 let nested_tree_hash = repo
918 .store()
919 .put_tree(&Tree::from_entries(vec![
920 TreeEntry::file("nested.txt", nested_blob, false).unwrap(),
921 TreeEntry::symlink("latest", symlink_blob).unwrap(),
922 ]))
923 .expect("put nested tree");
924 let context_tree_hash = repo
925 .store()
926 .put_tree(&Tree::from_entries(vec![
927 TreeEntry::file("context.txt", context_blob, false).unwrap(),
928 ]))
929 .expect("put context tree");
930 let provenance_tree_hash = repo
931 .store()
932 .put_tree(&Tree::from_entries(vec![
933 TreeEntry::file("lineage.txt", provenance_blob, false).unwrap(),
934 ]))
935 .expect("put provenance tree");
936 let gitlink_target: GitObjectId = "0303030303030303030303030303030303030303"
937 .parse()
938 .expect("git oid");
939 let root_tree_hash = repo
940 .store()
941 .put_tree(&Tree::from_entries(vec![
942 TreeEntry::file("secret.txt", redacted_blob, false).unwrap(),
943 TreeEntry::directory("nested", nested_tree_hash).unwrap(),
944 TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
945 ]))
946 .expect("put root tree");
947 let state = State::new(
948 root_tree_hash,
949 vec![excluded_parent.change_id],
950 test_attribution(),
951 )
952 .with_context(context_tree_hash)
953 .with_provenance(provenance_tree_hash)
954 .with_risk_signals(risk_blob)
955 .with_review_signatures(review_blob)
956 .with_discussions(discussions_blob)
957 .with_structured_conflicts(conflicts_blob);
958 repo.store().put_state(&state).expect("put state");
959
960 repo.put_redaction(Redaction {
961 redacted_blob,
962 state: state.change_id,
963 path: "secret.txt".to_string(),
964 reason: "test leak".to_string(),
965 redactor: Principal::new("Tester", "tester@example.test"),
966 redacted_at: Utc::now(),
967 signature: None,
968 purged_at: None,
969 supersedes: None,
970 })
971 .expect("put redaction");
972 repo.put_state_visibility(StateVisibility {
973 state: state.change_id,
974 tier: VisibilityTier::Restricted {
975 scope_label: "security".to_string(),
976 },
977 embargo_until: None,
978 declarer: Principal::new("Tester", "tester@example.test"),
979 declared_at: Utc::now(),
980 signature: None,
981 supersedes: None,
982 })
983 .expect("put visibility");
984
985 let options = StateClosureOptions {
986 depth: None,
987 exclude_states: vec![excluded_parent.change_id],
988 };
989 let transfer = enumerate_state_closure_transfer_with_options(
990 repo.store(),
991 state.change_id,
992 options.clone(),
993 512,
994 )
995 .expect("transfer projection");
996
997 let full =
998 enumerate_state_closure_with_options(repo.store(), state.change_id, options.clone())
999 .expect("full closure");
1000 let plan =
1001 enumerate_state_closure_plan_with_options(repo.store(), state.change_id, options)
1002 .expect("plan closure");
1003 assert_eq!(
1004 transfer
1005 .full_objects
1006 .as_deref()
1007 .map(object_info_fingerprint),
1008 Some(object_info_fingerprint(&full))
1009 );
1010 assert_eq!(transfer.planned_objects, plan);
1011
1012 let full_pairs = pairs_from_full(&full);
1013 assert_eq!(full_pairs, pairs_from_plan(&plan));
1014 assert_contains_object(
1015 &full_pairs,
1016 ObjectId::ChangeId(state.change_id),
1017 ObjectType::State,
1018 );
1019 assert_contains_object(
1020 &full_pairs,
1021 ObjectId::ChangeId(state.change_id),
1022 ObjectType::StateVisibility,
1023 );
1024 assert_contains_object(&full_pairs, ObjectId::Hash(redacted_blob), ObjectType::Blob);
1025 assert_contains_object(
1026 &full_pairs,
1027 ObjectId::Hash(redacted_blob),
1028 ObjectType::Redaction,
1029 );
1030 for hash in [
1031 root_tree_hash,
1032 nested_tree_hash,
1033 context_tree_hash,
1034 provenance_tree_hash,
1035 ] {
1036 assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Tree);
1037 }
1038 for hash in [
1039 nested_blob,
1040 symlink_blob,
1041 context_blob,
1042 provenance_blob,
1043 risk_blob,
1044 review_blob,
1045 discussions_blob,
1046 conflicts_blob,
1047 ] {
1048 assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Blob);
1049 }
1050 assert!(!full_pairs.contains(&(
1051 ObjectId::ChangeId(excluded_parent.change_id),
1052 ObjectType::State
1053 )));
1054 assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_tree_hash), ObjectType::Tree)));
1055 assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_blob), ObjectType::Blob)));
1056 }
1057
1058 #[test]
1059 fn transfer_projection_reads_root_state_once_on_small_transfer() {
1060 let temp = TempDir::new().unwrap();
1061 let repo = Repository::init_default(temp.path()).unwrap();
1062 let blob = repo
1063 .store()
1064 .put_blob(&Blob::from("hello\n"))
1065 .expect("put blob");
1066 let tree_hash = repo
1067 .store()
1068 .put_tree(&Tree::from_entries(vec![
1069 TreeEntry::file("README.md", blob, false).unwrap(),
1070 ]))
1071 .expect("put tree");
1072 let state = State::new(tree_hash, Vec::new(), test_attribution());
1073 repo.store().put_state(&state).expect("put state");
1074 let store = CountingStore::new(repo.store());
1075
1076 let transfer = enumerate_state_closure_transfer_with_options(
1077 &store,
1078 state.change_id,
1079 StateClosureOptions::default(),
1080 512,
1081 )
1082 .expect("transfer projection");
1083
1084 assert!(
1085 !transfer.planned_objects.is_empty(),
1086 "lean projection should be available"
1087 );
1088 assert!(transfer.full_objects.is_some());
1089 assert_eq!(
1090 store.state_reads(),
1091 1,
1092 "small transfer projection must not read the root state through a second closure walk"
1093 );
1094 }
1095
1096 #[test]
1097 fn transfer_projection_drops_full_descriptors_after_threshold() {
1098 let temp = TempDir::new().unwrap();
1099 let repo = Repository::init_default(temp.path()).unwrap();
1100 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1101 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1102
1103 let transfer = enumerate_state_closure_transfer_with_options(
1104 repo.store(),
1105 state.change_id,
1106 StateClosureOptions::default(),
1107 0,
1108 )
1109 .expect("transfer projection");
1110
1111 assert!(
1112 !transfer.planned_objects.is_empty(),
1113 "lean projection should still be available over the threshold"
1114 );
1115 assert!(transfer.full_objects.is_none());
1116 }
1117
1118 #[test]
1119 fn depth_and_exclude_options_match_between_full_and_plan() {
1120 let temp = TempDir::new().unwrap();
1121 let repo = Repository::init_default(temp.path()).unwrap();
1122 let path = temp.path().join("story.txt");
1123
1124 std::fs::write(&path, "base\n").unwrap();
1125 let base = repo.snapshot(Some("base".to_string()), None).unwrap();
1126 std::fs::write(&path, "middle\n").unwrap();
1127 let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
1128 std::fs::write(&path, "tip\n").unwrap();
1129 let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
1130
1131 let depth_zero = assert_plan_parity(
1132 &repo,
1133 tip.change_id,
1134 StateClosureOptions {
1135 depth: Some(0),
1136 exclude_states: Vec::new(),
1137 },
1138 );
1139 assert!(depth_zero.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
1140 assert!(!depth_zero.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State)));
1141 assert!(!depth_zero.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
1142
1143 let depth_one = assert_plan_parity(
1144 &repo,
1145 tip.change_id,
1146 StateClosureOptions {
1147 depth: Some(1),
1148 exclude_states: Vec::new(),
1149 },
1150 );
1151 assert!(depth_one.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
1152 assert!(depth_one.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State)));
1153 assert!(!depth_one.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
1154
1155 let exclude_middle = assert_plan_parity(
1156 &repo,
1157 tip.change_id,
1158 StateClosureOptions {
1159 depth: None,
1160 exclude_states: vec![middle.change_id],
1161 },
1162 );
1163 assert!(exclude_middle.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
1164 assert!(
1165 !exclude_middle.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State))
1166 );
1167 assert!(!exclude_middle.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
1168 }
1169
1170 #[test]
1171 fn shared_tree_and_blob_references_are_emitted_once() {
1172 let temp = TempDir::new().unwrap();
1173 let repo = Repository::init_default(temp.path()).unwrap();
1174
1175 let shared_blob = Blob::from("shared contents\n");
1176 let shared_blob_hash = repo.store().put_blob(&shared_blob).unwrap();
1177 let shared_tree = Tree::from_entries(vec![
1178 TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
1179 ]);
1180 let shared_tree_hash = repo.store().put_tree(&shared_tree).unwrap();
1181 let root = Tree::from_entries(vec![
1182 TreeEntry::directory("left", shared_tree_hash).unwrap(),
1183 TreeEntry::directory("right", shared_tree_hash).unwrap(),
1184 ]);
1185 let root_hash = repo.store().put_tree(&root).unwrap();
1186 let state = State::new(root_hash, Vec::new(), test_attribution());
1187 repo.store().put_state(&state).unwrap();
1188
1189 let full = enumerate_state_closure_with_options(
1190 repo.store(),
1191 state.change_id,
1192 StateClosureOptions::default(),
1193 )
1194 .unwrap();
1195 let plan = enumerate_state_closure_plan_with_options(
1196 repo.store(),
1197 state.change_id,
1198 StateClosureOptions::default(),
1199 )
1200 .unwrap();
1201
1202 assert_eq!(
1203 pairs_from_full(&full),
1204 pairs_from_plan(&plan),
1205 "full and lean closure enumerators must dedup the same objects"
1206 );
1207
1208 assert_eq!(
1209 full.iter()
1210 .filter(|info| info.id == ObjectId::Hash(root_hash)
1211 && info.obj_type == ObjectType::Tree)
1212 .count(),
1213 1
1214 );
1215 assert_eq!(
1216 full.iter()
1217 .filter(|info| info.id == ObjectId::Hash(shared_tree_hash)
1218 && info.obj_type == ObjectType::Tree)
1219 .count(),
1220 1
1221 );
1222 assert_eq!(
1223 full.iter()
1224 .filter(|info| info.id == ObjectId::Hash(shared_blob_hash)
1225 && info.obj_type == ObjectType::Blob)
1226 .count(),
1227 1
1228 );
1229 }
1230
1231 #[test]
1232 fn state_closure_skips_gitlink_targets() {
1233 let temp = TempDir::new().unwrap();
1234 let repo = Repository::init_default(temp.path()).unwrap();
1235 let target: GitObjectId = "0303030303030303030303030303030303030303"
1236 .parse()
1237 .expect("git oid");
1238 let root = Tree::from_entries(vec![
1239 TreeEntry::gitlink("vendor", target).expect("gitlink entry"),
1240 ]);
1241 let root_hash = repo.store().put_tree(&root).unwrap();
1242 let state = State::new(root_hash, Vec::new(), test_attribution());
1243 repo.store().put_state(&state).unwrap();
1244
1245 let full = enumerate_state_closure_with_options(
1246 repo.store(),
1247 state.change_id,
1248 StateClosureOptions::default(),
1249 )
1250 .unwrap();
1251 let plan = enumerate_state_closure_plan_with_options(
1252 repo.store(),
1253 state.change_id,
1254 StateClosureOptions::default(),
1255 )
1256 .unwrap();
1257
1258 assert_eq!(pairs_from_full(&full), pairs_from_plan(&plan));
1259 assert!(
1260 !full.iter().any(|info| info.obj_type == ObjectType::Blob),
1261 "gitlinks carry foreign Git commit ids, not Heddle blob dependencies: {full:?}"
1262 );
1263 assert!(full.iter().any(|info| {
1264 info.id == ObjectId::Hash(root_hash) && info.obj_type == ObjectType::Tree
1265 }));
1266 }
1267
1268 #[test]
1269 fn missing_blobs_in_tree_skips_gitlinks_and_walks_nested_side_paths() {
1270 let temp = TempDir::new().unwrap();
1271 let repo = Repository::init_default(temp.path()).unwrap();
1272 let present_blob = repo
1273 .store()
1274 .put_blob(&Blob::from("already local"))
1275 .expect("put present blob");
1276 let missing_nested = ContentHash::from_bytes([7; 32]);
1277 let missing_symlink = ContentHash::from_bytes([8; 32]);
1278 let nested_tree = Tree::from_entries(vec![
1279 TreeEntry::file("remote.txt", missing_nested, false).unwrap(),
1280 TreeEntry::symlink("remote-link", missing_symlink).unwrap(),
1281 ]);
1282 let nested_tree_hash = repo
1283 .store()
1284 .put_tree(&nested_tree)
1285 .expect("put nested tree");
1286 let gitlink_target: GitObjectId = "0404040404040404040404040404040404040404"
1287 .parse()
1288 .expect("git oid");
1289 let root = Tree::from_entries(vec![
1290 TreeEntry::file("local.txt", present_blob, false).unwrap(),
1291 TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1292 TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1293 ]);
1294 let root_hash = repo.store().put_tree(&root).expect("put root tree");
1295
1296 let missing = missing_blobs_in_tree(repo.store(), root_hash).expect("missing blobs");
1297
1298 assert_eq!(
1299 missing.into_iter().collect::<HashSet<_>>(),
1300 HashSet::from([missing_nested, missing_symlink])
1301 );
1302 }
1303
1304 #[test]
1309 fn enumerate_state_closure_emits_redaction_for_redacted_blob() {
1310 let temp = TempDir::new().unwrap();
1311 let repo = Repository::init_default(temp.path()).unwrap();
1312 std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
1313 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1314
1315 let tree = repo
1317 .store()
1318 .get_tree(&state.tree)
1319 .unwrap()
1320 .expect("tree present");
1321 let blob_hash = tree
1322 .iter()
1323 .find(|e| e.name() == "secret.toml")
1324 .expect("entry present")
1325 .blob_hash()
1326 .expect("secret.toml is a blob");
1327
1328 let redaction = Redaction {
1329 redacted_blob: blob_hash,
1330 state: state.change_id,
1331 path: "secret.toml".to_string(),
1332 reason: "test leak".to_string(),
1333 redactor: Principal {
1334 name: "Tester".into(),
1335 email: "tester@heddle.sh".into(),
1336 },
1337 redacted_at: Utc::now(),
1338 signature: None,
1339 purged_at: None,
1340 supersedes: None,
1341 };
1342 repo.put_redaction(redaction).unwrap();
1343
1344 let full = enumerate_state_closure_with_options(
1345 repo.store(),
1346 state.change_id,
1347 StateClosureOptions::default(),
1348 )
1349 .unwrap();
1350 let plan = enumerate_state_closure_plan_with_options(
1351 repo.store(),
1352 state.change_id,
1353 StateClosureOptions::default(),
1354 )
1355 .unwrap();
1356
1357 assert!(
1358 full.iter()
1359 .any(|info| info.obj_type == ObjectType::Redaction
1360 && info.id == ObjectId::Hash(blob_hash)),
1361 "full closure must include a Redaction entry for the redacted blob"
1362 );
1363 assert!(
1364 plan.iter()
1365 .any(|p| p.obj_type == ObjectType::Redaction && p.id == ObjectId::Hash(blob_hash)),
1366 "plan closure must include a Redaction entry for the redacted blob"
1367 );
1368 }
1369
1370 #[test]
1371 fn enumerate_state_closure_emits_state_visibility_for_visible_state() {
1372 let temp = TempDir::new().unwrap();
1373 let repo = Repository::init_default(temp.path()).unwrap();
1374 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1375 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1376
1377 repo.put_state_visibility(StateVisibility {
1378 state: state.change_id,
1379 tier: VisibilityTier::Restricted {
1380 scope_label: "security-embargo".into(),
1381 },
1382 embargo_until: None,
1383 declarer: Principal {
1384 name: "Tester".into(),
1385 email: "tester@heddle.sh".into(),
1386 },
1387 declared_at: Utc::now(),
1388 signature: None,
1389 supersedes: None,
1390 })
1391 .unwrap();
1392
1393 let full = enumerate_state_closure_with_options(
1394 repo.store(),
1395 state.change_id,
1396 StateClosureOptions::default(),
1397 )
1398 .unwrap();
1399 let plan = enumerate_state_closure_plan_with_options(
1400 repo.store(),
1401 state.change_id,
1402 StateClosureOptions::default(),
1403 )
1404 .unwrap();
1405
1406 assert!(
1407 full.iter()
1408 .any(|info| info.obj_type == ObjectType::StateVisibility
1409 && info.id == ObjectId::ChangeId(state.change_id)),
1410 "full closure must include a StateVisibility entry for the visible state"
1411 );
1412 assert!(
1413 plan.iter()
1414 .any(|p| p.obj_type == ObjectType::StateVisibility
1415 && p.id == ObjectId::ChangeId(state.change_id)),
1416 "plan closure must include a StateVisibility entry for the visible state"
1417 );
1418 }
1419
1420 #[test]
1421 fn enumerate_state_closure_emits_state_metadata_blobs() {
1422 let temp = TempDir::new().unwrap();
1423 let repo = Repository::init_default(temp.path()).unwrap();
1424 std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1425 let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1426
1427 let principal = Principal::new("Tester", "tester@example.test");
1428 let discussion_bytes = DiscussionsBlob::new(vec![Discussion {
1429 id: "disc-1".to_string(),
1430 anchor: SymbolAnchor::new("src/lib.rs", "answer"),
1431 opened_against_state: state.change_id,
1432 opened_at: 1_782_400_000,
1433 thread_ref: None,
1434 turns: vec![DiscussionTurn {
1435 author: principal,
1436 body: "Should this sync?".to_string(),
1437 posted_at: 1_782_400_000,
1438 }],
1439 resolution: DiscussionResolution::Open,
1440 body_changed_since_open: false,
1441 orphaned: false,
1442 visibility: VisibilityTier::default(),
1443 resolved_annotation_id: None,
1444 }])
1445 .encode()
1446 .expect("encode discussions");
1447 let discussion_hash = repo
1448 .store()
1449 .put_blob(&Blob::new(discussion_bytes))
1450 .expect("put discussions blob");
1451 let risk_hash = repo
1452 .store()
1453 .put_blob(&Blob::from_slice(b"risk signals"))
1454 .expect("put risk blob");
1455 let review_hash = repo
1456 .store()
1457 .put_blob(&Blob::from_slice(b"review signatures"))
1458 .expect("put review blob");
1459 let conflicts_hash = repo
1460 .store()
1461 .put_blob(&Blob::from_slice(b"structured conflicts"))
1462 .expect("put conflicts blob");
1463 let state_with_metadata = state
1464 .with_risk_signals(risk_hash)
1465 .with_review_signatures(review_hash)
1466 .with_discussions(discussion_hash)
1467 .with_structured_conflicts(conflicts_hash);
1468 repo.store()
1469 .put_state(&state_with_metadata)
1470 .expect("put state with metadata");
1471
1472 let full = enumerate_state_closure_with_options(
1473 repo.store(),
1474 state_with_metadata.change_id,
1475 StateClosureOptions::default(),
1476 )
1477 .unwrap();
1478 let plan = enumerate_state_closure_plan_with_options(
1479 repo.store(),
1480 state_with_metadata.change_id,
1481 StateClosureOptions::default(),
1482 )
1483 .unwrap();
1484
1485 for metadata_hash in [risk_hash, review_hash, discussion_hash, conflicts_hash] {
1486 assert!(
1487 full.iter().any(|info| info.obj_type == ObjectType::Blob
1488 && info.id == ObjectId::Hash(metadata_hash)),
1489 "full closure must include state metadata blob {metadata_hash}"
1490 );
1491 assert!(
1492 plan.iter().any(
1493 |p| p.obj_type == ObjectType::Blob && p.id == ObjectId::Hash(metadata_hash)
1494 ),
1495 "plan closure must include state metadata blob {metadata_hash}"
1496 );
1497 }
1498 }
1499}