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