Skip to main content

triblespace_core/
repo.rs

1#![allow(clippy::type_complexity)]
2//! This module provides a high-level API for storing and retrieving data from repositories.
3//! The design is inspired by Git, but with a focus on object/content-addressed storage.
4//! It separates storage concerns from the data model, and reduces the mutable state of the repository,
5//! to an absolute minimum, making it easier to reason about and allowing for different storage backends.
6//!
7//! Blob repositories are collections of blobs that can be content-addressed by their hash.
8//! This is typically local `.pile` file or a S3 bucket or a similar service.
9//! On their own they have no notion of branches or commits, or other stateful constructs.
10//! As such they also don't have a notion of time, order or history,
11//! massively relaxing the constraints on storage.
12//! This makes it possible to use a wide range of storage services, including those that don't support
13//! atomic transactions or have other limitations.
14//!
15//! Branch repositories on the other hand are a stateful construct that can be used to represent a branch pointing to a specific commit.
16//! They are stored in a separate repository, typically a  local `.pile` file, a database or an S3 compatible service with a compare-and-swap operation,
17//! and can be used to represent the state of a repository at a specific point in time.
18//!
19//! Technically, branches are just a mapping from a branch id to a blob hash,
20//! But because TribleSets are themselves easily stored in a blob, and because
21//! trible commit histories are an append-only chain of TribleSet metadata,
22//! the hash of the head is sufficient to represent the entire history of a branch.
23//!
24//! ## Basic usage
25//!
26//! ```rust,ignore
27//! use ed25519_dalek::SigningKey;
28//! use rand::rngs::OsRng;
29//! use triblespace::prelude::*;
30//! use triblespace::prelude::inlineencodings::{GenId, ShortString};
31//! use triblespace::repo::{memoryrepo::MemoryRepo, Repository};
32//!
33//! let storage = MemoryRepo::default();
34//! let mut repo = Repository::new(storage, SigningKey::generate(&mut OsRng), TribleSet::new()).unwrap();
35//! let branch_id = repo.create_branch("main", None).expect("create branch");
36//! let mut ws = repo.pull(*branch_id).expect("pull branch");
37//!
38//! attributes! {
39//!     "8F180883F9FD5F787E9E0AF0DF5866B9" as pub author: GenId;
40//!     "0DBB530B37B966D137C50B943700EDB2" as pub firstname: ShortString;
41//!     "6BAA463FD4EAF45F6A103DB9433E4545" as pub lastname: ShortString;
42//! }
43//! let author = fucid();
44//! ws.commit(
45//!     entity!{ &author @
46//!         literature::firstname: "Frank",
47//!         literature::lastname: "Herbert",
48//!      },
49//!     "initial commit",
50//! );
51//!
52//! // Single-attempt push: `try_push` uploads local blobs and attempts a
53//! // single CAS update. On conflict it returns a workspace containing the
54//! // new branch state which you should merge into before retrying.
55//! match repo.try_push(&mut ws).expect("try_push") {
56//!     None => {}
57//!     Some(_) => panic!("unexpected conflict"),
58//! }
59//! ```
60//!
61//! `create_branch` registers a new branch and returns an [`ExclusiveId`](crate::id::ExclusiveId) guard.
62//! `pull` creates a new workspace from an existing branch while
63//! `branch_from` can be used to start a new branch from a specific commit
64//! handle. See `examples/workspace.rs` for a more complete example.
65//!
66//! ## Handling conflicts
67//!
68//! The single-attempt primitive is [`Repository::try_push`](crate::repo::Repository::try_push). It returns
69//! `Ok(None)` on success or `Ok(Some(conflict_ws))` when the branch advanced
70//! concurrently. Callers that want explicit conflict handling may use this
71//! form:
72//!
73//! ```rust,ignore
74//! while let Some(mut other) = repo.try_push(&mut ws)? {
75//!     // Merge our staged changes into the incoming workspace and retry.
76//!     other.merge(&mut ws)?;
77//!     ws = other;
78//! }
79//! ```
80//!
81//! For convenience `Repository::push` is provided as a retrying wrapper that
82//! performs the merge-and-retry loop for you. Call `push` when you prefer the
83//! repository to handle conflicts automatically; call `try_push` when you need
84//! to inspect or control the intermediate conflict workspace yourself.
85//!
86//! `push` performs a compare‐and‐swap (CAS) update on the branch metadata.
87//! This optimistic concurrency control keeps branches consistent without
88//! locking and can be emulated by many storage systems (for example by
89//! using conditional writes on S3).
90//!
91//! ## Git parallels
92//!
93//! The API deliberately mirrors concepts from Git to make its usage familiar:
94//!
95//! - A [`Repository`](crate::repo::Repository) stores commits and branch metadata similar to a remote.
96//! - [`Workspace`](crate::repo::Workspace) is akin to a working directory combined with an index. It
97//!   tracks changes against a branch head until you `push` them.
98//! - `create_branch` and `branch_from` correspond to creating new branches from
99//!   scratch or from a specific commit, respectively.
100//! - `push` updates the repository atomically. If the branch advanced in the
101//!   meantime, you receive a conflict workspace which can be merged before
102//!   retrying the push.
103//! - `pull` is similar to cloning a branch into a new workspace.
104//!
105//! `pull` uses the repository's default signing key for new commits. If you
106//! need to work with a different identity, the `_with_key` variants allow providing
107//! an explicit key when creating branches or pulling workspaces.
108//!
109//! These parallels should help readers leverage their Git knowledge when
110//! working with trible repositories.
111//!
112/// Branch metadata construction and signature verification.
113pub mod branch;
114/// Capability-based authorization for triblespace networks.
115pub mod capability;
116/// Commit metadata construction and signature verification.
117pub mod commit;
118/// Storage adapter that delegates blobs and branches to separate backends.
119pub mod hybridstore;
120/// Fully in-memory repository implementation for tests and ephemeral use.
121pub mod memoryrepo;
122#[cfg(feature = "object-store")]
123/// Repository backed by an `object_store`-compatible remote (S3, local FS, etc.).
124pub mod objectstore;
125/// Local file-based pile storage backend.
126pub mod pile;
127
128/// Trait for storage backends that require explicit close/cleanup.
129///
130/// Not all storage backends need to implement this; implementations that have
131/// nothing to do on close may return Ok(()) or use `Infallible` as the error
132/// type.
133pub trait StorageClose {
134    /// Error type returned by `close`.
135    type Error: std::error::Error;
136
137    /// Consume the storage and perform any necessary cleanup.
138    fn close(self) -> Result<(), Self::Error>;
139}
140
141// Convenience impl for repositories whose storage supports explicit close.
142impl<Storage> Repository<Storage>
143where
144    Storage: BlobStore + PinStore + StorageClose,
145{
146    /// Close the repository's underlying storage if it supports explicit
147    /// close operations.
148    ///
149    /// This method is only available when the storage type implements
150    /// [`StorageClose`]. It consumes the repository and delegates to the
151    /// storage's `close` implementation, returning any error produced.
152    pub fn close(self) -> Result<(), <Storage as StorageClose>::Error> {
153        self.storage.close()
154    }
155}
156
157use crate::macros::pattern;
158use std::collections::{HashSet, VecDeque};
159use std::convert::Infallible;
160use std::error::Error;
161use std::fmt::Debug;
162use std::fmt::{self};
163
164use commit::commit_metadata;
165use hifitime::Epoch;
166use itertools::Itertools;
167
168use crate::blob::encodings::simplearchive::UnarchiveError;
169use crate::blob::encodings::UnknownBlob;
170use crate::blob::Blob;
171use crate::blob::BlobEncoding;
172use crate::blob::MemoryBlobStore;
173use crate::blob::IntoBlob;
174use crate::blob::TryFromBlob;
175use crate::find;
176use crate::id::genid;
177use crate::id::Id;
178use crate::patch::Entry;
179use crate::patch::IdentitySchema;
180use crate::patch::PATCH;
181use crate::prelude::inlineencodings::GenId;
182use crate::repo::branch::branch_metadata;
183use crate::trible::TribleSet;
184use crate::inline::encodings::hash::Handle;
185use crate::inline::Inline;
186use crate::inline::InlineEncoding;
187use crate::inline::INLINE_LEN;
188use ed25519_dalek::SigningKey;
189
190use crate::blob::encodings::longstring::LongString;
191use crate::blob::encodings::simplearchive::SimpleArchive;
192use crate::blob::encodings::succinctarchive::SuccinctArchiveBlob;
193use crate::prelude::*;
194use crate::inline::encodings::ed25519 as ed;
195use crate::inline::encodings::shortstring::ShortString;
196
197attributes! {
198    /// The actual data of the commit.
199    "4DD4DDD05CC31734B03ABB4E43188B1F" as pub content: Handle<SimpleArchive>;
200    /// Metadata describing the commit content.
201    "88B59BD497540AC5AECDB7518E737C87" as pub metadata: Handle<SimpleArchive>;
202    /// A commit that this commit is based on.
203    "317044B612C690000D798CA660ECFD2A" as pub parent: Handle<SimpleArchive>;
204    /// A (potentially long) message describing the commit.
205    "B59D147839100B6ED4B165DF76EDF3BB" as pub message: Handle<LongString>;
206    /// A short message describing the commit.
207    "12290C0BE0E9207E324F24DDE0D89300" as pub short_message: ShortString;
208    /// The hash of the first commit in the commit chain of the branch.
209    "272FBC56108F336C4D2E17289468C35F" as pub head: Handle<SimpleArchive>;
210    /// An id used to track the branch.
211    "8694CC73AF96A5E1C7635C677D1B928A" as pub branch: GenId;
212    /// The author of the signature identified by their ed25519 public key.
213    "ADB4FFAD247C886848161297EFF5A05B" as pub signed_by: ed::ED25519PublicKey;
214    /// The `r` part of a ed25519 signature.
215    "9DF34F84959928F93A3C40AEB6E9E499" as pub signature_r: ed::ED25519RComponent;
216    /// The `s` part of a ed25519 signature.
217    "1ACE03BF70242B289FDF00E4327C3BC6" as pub signature_s: ed::ED25519SComponent;
218    /// Optional SuccinctArchive rollup of the branch HEAD's logical contents.
219    ///
220    /// Readers can fetch this blob via the repository's blob store to obtain
221    /// a compact, instantly-queryable representation of the branch's state
222    /// without having to materialise the TribleSet from the commit chain.
223    /// Absent on branches that haven't had a rollup built yet. Soft state:
224    /// the rollup is redundant with (and must agree with) whatever
225    /// `ws.checkout(..)` would return for the same HEAD.
226    "D7D14C6737AA27A51E1E08D380D13EF9" as pub rollup: Handle<SuccinctArchiveBlob>;
227}
228
229/// The `ListBlobs` trait is used to list all blobs in a repository.
230pub trait BlobStoreList {
231    /// Iterator over blob handles in the store.
232    type Iter<'a>: Iterator<Item = Result<Inline<Handle<UnknownBlob>>, Self::Err>>
233    where
234        Self: 'a;
235    /// Error type for listing operations.
236    type Err: Error + Debug + Send + Sync + 'static;
237
238    /// Lists all blobs in the repository.
239    fn blobs<'a>(&'a self) -> Self::Iter<'a>;
240
241    /// Lists blobs in `self` that are not in `old`.
242    ///
243    /// Backends with true snapshot semantics (e.g. [`Pile`],
244    /// where each [`Reader`](BlobStore::Reader) holds a frozen clone of the
245    /// in-memory blob index) compute the difference cheaply via the index's
246    /// own set-difference operation. Backends without snapshot semantics
247    /// (e.g. an object store, where the Reader is just a handle to the live
248    /// remote) fall back to the default implementation, which lists all
249    /// current blobs — over-eager but always correct.
250    ///
251    /// Use this for "what blobs are new since I last looked" patterns
252    /// (e.g. announcing newly-imported blobs to a DHT) where holding the
253    /// previous Reader as a baseline gives you the delta.
254    fn blobs_diff<'a>(&'a self, _old: &Self) -> Self::Iter<'a> {
255        self.blobs()
256    }
257}
258
259/// Metadata about a blob in a repository.
260#[derive(Debug, Clone)]
261pub struct BlobMetadata {
262    /// Timestamp in milliseconds since UNIX epoch when the blob was created/stored.
263    pub timestamp: u64,
264    /// Length of the blob in bytes.
265    pub length: u64,
266}
267
268/// Trait exposing metadata lookup for blobs available in a repository reader.
269pub trait BlobStoreMeta {
270    /// Error type returned by metadata calls.
271    type MetaError: std::error::Error + Send + Sync + 'static;
272
273    /// Returns metadata for the blob identified by `handle`, or `None` if
274    /// the blob is not present.
275    fn metadata<S>(
276        &self,
277        handle: Inline<Handle<S>>,
278    ) -> Result<Option<BlobMetadata>, Self::MetaError>
279    where
280        S: BlobEncoding + 'static,
281        Handle<S>: InlineEncoding;
282}
283
284/// Trait exposing a monotonic "forget" operation.
285///
286/// Forget is idempotent and monotonic: it removes materialization from a
287/// particular repository but does not semantically delete derived facts.
288pub trait BlobStoreForget {
289    /// Error type for forget operations.
290    type ForgetError: std::error::Error + Send + Sync + 'static;
291
292    /// Removes the materialized blob identified by `handle` from this store.
293    fn forget<S>(&mut self, handle: Inline<Handle<S>>) -> Result<(), Self::ForgetError>
294    where
295        S: BlobEncoding + 'static,
296        Handle<S>: InlineEncoding;
297}
298
299/// The `GetBlob` trait is used to retrieve blobs from a repository.
300pub trait BlobStoreGet {
301    /// Error type for get operations, parameterised by the deserialization error.
302    type GetError<E: std::error::Error + Send + Sync + 'static>: Error + Send + Sync + 'static;
303
304    /// Retrieves a blob from the repository by its handle.
305    /// The handle is a unique identifier for the blob, and is used to retrieve it from the repository.
306    /// The blob is returned as a [`Blob`] object, which contains the raw bytes of the blob,
307    /// which can be deserialized via the appropriate schema type, which is specified by the `T` type parameter.
308    ///
309    /// # Errors
310    /// Returns an error if the blob could not be found in the repository.
311    /// The error type is specified by the `Err` associated type.
312    fn get<T, S>(
313        &self,
314        handle: Inline<Handle<S>>,
315    ) -> Result<T, Self::GetError<<T as TryFromBlob<S>>::Error>>
316    where
317        S: BlobEncoding + 'static,
318        T: TryFromBlob<S>,
319        Handle<S>: InlineEncoding;
320}
321
322/// The `PutBlob` trait is used to store blobs in a repository.
323pub trait BlobStorePut {
324    /// Error type for put operations.
325    type PutError: Error + Debug + Send + Sync + 'static;
326
327    /// Serialises `item` as a blob, stores it, and returns its handle.
328    fn put<S, T>(&mut self, item: T) -> Result<Inline<Handle<S>>, Self::PutError>
329    where
330        S: BlobEncoding + 'static,
331        T: IntoBlob<S>,
332        Handle<S>: InlineEncoding;
333}
334
335/// Combined read/write blob storage.
336///
337/// Extends [`BlobStorePut`] with the ability to create a shareable
338/// [`Reader`](BlobStore::Reader) snapshot for concurrent reads.
339pub trait BlobStore: BlobStorePut {
340    /// A clonable reader handle for concurrent blob lookups.
341    type Reader: BlobStoreGet + BlobStoreList + Clone + Send + PartialEq + Eq + 'static;
342    /// Error type for creating a reader.
343    type ReaderError: Error + Debug + Send + Sync + 'static;
344    /// Creates a shareable reader snapshot of the current store state.
345    fn reader(&mut self) -> Result<Self::Reader, Self::ReaderError>;
346}
347
348/// Trait for blob stores that can retain a supplied set of handles.
349pub trait BlobStoreKeep {
350    /// Retain only the blobs identified by `handles`.
351    fn keep<I>(&mut self, handles: I)
352    where
353        I: IntoIterator<Item = Inline<Handle<UnknownBlob>>>;
354}
355
356/// Trait for stores that can enumerate a blob's child references.
357///
358/// "Children" are the 32-byte-aligned values in a blob that correspond
359/// to existing blobs in the store — the conservative set of references.
360///
361/// The default implementation scans the blob's bytes and checks each
362/// 32-byte chunk with [`BlobStoreGet::get`]. Backends with batch
363/// capabilities (e.g. a network store with a SYNC protocol) can
364/// override this for efficiency.
365pub trait BlobChildren: BlobStoreGet {
366    /// Return handles of blobs referenced by `handle` that exist in this store.
367    fn children(
368        &self,
369        handle: Inline<Handle<UnknownBlob>>,
370    ) -> Vec<Inline<Handle<UnknownBlob>>> {
371        let Ok(blob) = self.get::<Blob<UnknownBlob>, UnknownBlob>(handle) else {
372            return Vec::new();
373        };
374        let bytes = blob.bytes.as_ref();
375        let mut result = Vec::new();
376        let mut offset = 0usize;
377        while offset + INLINE_LEN <= bytes.len() {
378            let mut raw = [0u8; INLINE_LEN];
379            raw.copy_from_slice(&bytes[offset..offset + INLINE_LEN]);
380            let candidate = Inline::<Handle<UnknownBlob>>::new(raw);
381            if self.get::<anybytes::Bytes, UnknownBlob>(candidate).is_ok() {
382                result.push(candidate);
383            }
384            offset += INLINE_LEN;
385        }
386        result
387    }
388}
389
390// No blanket impl — types opt in explicitly so they can provide
391// optimized implementations (e.g. network stores with batch protocols).
392// Use `impl_blob_children_default!` for the scan-and-check fallback.
393
394/// Outcome of a compare-and-swap pin update (used by both the
395/// primitive `PinStore::update` and the higher-level
396/// `Repository::push` for content branches).
397#[derive(Debug)]
398pub enum PushResult {
399    /// The CAS succeeded — the pin now points to the new value.
400    Success(),
401    /// The CAS failed — the pin's head had advanced. Contains the
402    /// current head, or `None` if the pin was tombstoned concurrently.
403    Conflict(Option<Inline<Handle<SimpleArchive>>>),
404}
405
406/// Storage backend for pins: named, atomically-updatable handles to
407/// SimpleArchive blobs.
408///
409/// A *pin* is the storage primitive — a named cell holding a single
410/// `Inline<Handle<SimpleArchive>>`, updated via compare-and-swap. The
411/// pile's compaction sweep treats every pin head as a reachability
412/// root: blobs reachable from a pin survive; the rest are reclaimed.
413///
414/// Pins back several specialized use patterns, distinguished at
415/// higher layers via metadata markers:
416/// - A **branch** is a pin whose value resolves to a commit-chain
417///   head (Repository's content abstraction). Branch metadata
418///   carries `metadata::name` for human-readable lookup.
419/// - A **tracking pin** mirrors a remote peer's branch head and
420///   carries `tracking_remote_pin` + `remote_name`.
421/// - A **local-only pin** (renewal policy, pending requests,
422///   per-team cap holdings) carries `local_only_pin: <kind>` and is
423///   excluded from gossip publication.
424///
425/// `PinStore` itself doesn't know about these distinctions — it just
426/// provides the primitive: enumerate ids, read the current head, CAS
427/// an update. The two-level taxonomy lives at higher layers
428/// (decide#6de2dd95).
429///
430/// This trait is the stateful counterpart to [`BlobStore`]: blob
431/// stores are content-addressed and orderless; pin stores track a
432/// single mutable pointer per pin. The update operation uses
433/// compare-and-swap semantics so multiple writers can coordinate
434/// without locks.
435pub trait PinStore {
436    /// Error type for listing pins.
437    type PinsError: Error + Debug + Send + Sync + 'static;
438    /// Error type for head lookups.
439    type HeadError: Error + Debug + Send + Sync + 'static;
440    /// Error type for CAS updates.
441    type UpdateError: Error + Debug + Send + Sync + 'static;
442
443    /// Iterator over pin IDs.
444    type ListIter<'a>: Iterator<Item = Result<Id, Self::PinsError>>
445    where
446        Self: 'a;
447
448    /// Lists every pin in the store. Returns a fallible iterator over
449    /// pin ids (any role — branches, tracking pins, local-only pins).
450    /// Callers that want only content branches filter by checking for
451    /// the `metadata::name` attribute on each pin's head metadata.
452    fn pins<'a>(&'a mut self) -> Result<Self::ListIter<'a>, Self::PinsError>;
453
454    // NOTE: keep the API lean — callers may call `pins()` and handle
455    // the fallible iterator directly; we avoid adding an extra helper
456    // here.
457
458    /// Retrieves the current head of a pin by its id.
459    ///
460    /// Returns `Ok(Some(handle))` if the pin exists and has a head,
461    /// `Ok(None)` if the pin is tombstoned (deleted), and an error if
462    /// the underlying store failed to read.
463    ///
464    /// # Parameters
465    /// * `id` — The id of the pin to look up.
466    fn head(&mut self, id: Id) -> Result<Option<Inline<Handle<SimpleArchive>>>, Self::HeadError>;
467
468    /// Compare-and-swap update of a pin's head.
469    ///
470    /// Used to create a fresh pin, advance an existing one, or
471    /// tombstone (delete) one. The CAS guard (`old`) lets multiple
472    /// writers coordinate without locks: a stale writer's update
473    /// returns `PushResult::Conflict(current)` carrying the actual
474    /// current head for retry / merge.
475    ///
476    /// # Parameters
477    /// * `id` — The id of the pin to update.
478    /// * `old` — Expected current head (`None` when creating a fresh pin).
479    /// * `new` — New head (`None` tombstones the pin).
480    ///
481    /// # Returns
482    /// * `Success` — The pin now points at `new`.
483    /// * `Conflict(current)` — Some other writer advanced first; the
484    ///   pin's current head is `current`.
485    fn update(
486        &mut self,
487        id: Id,
488        old: Option<Inline<Handle<SimpleArchive>>>,
489        new: Option<Inline<Handle<SimpleArchive>>>,
490    ) -> Result<PushResult, Self::UpdateError>;
491}
492
493/// Error returned by [`transfer`] when copying blobs between stores.
494#[derive(Debug)]
495pub enum TransferError<ListErr, LoadErr, StoreErr> {
496    /// Failed to list handles from the source.
497    List(ListErr),
498    /// Failed to load a blob from the source.
499    Load(LoadErr),
500    /// Failed to store a blob in the target.
501    Store(StoreErr),
502}
503
504impl<ListErr, LoadErr, StoreErr> fmt::Display for TransferError<ListErr, LoadErr, StoreErr> {
505    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506        write!(f, "failed to transfer blob")
507    }
508}
509
510impl<ListErr, LoadErr, StoreErr> Error for TransferError<ListErr, LoadErr, StoreErr>
511where
512    ListErr: Debug + Error + 'static,
513    LoadErr: Debug + Error + 'static,
514    StoreErr: Debug + Error + 'static,
515{
516    fn source(&self) -> Option<&(dyn Error + 'static)> {
517        match self {
518            Self::List(e) => Some(e),
519            Self::Load(e) => Some(e),
520            Self::Store(e) => Some(e),
521        }
522    }
523}
524
525/// Copies the specified blob handles from `source` into `target`.
526pub fn transfer<'a, BS, BT, Handles>(
527    source: &'a BS,
528    target: &'a mut BT,
529    handles: Handles,
530) -> impl Iterator<
531    Item = Result<
532        (
533            Inline<Handle<UnknownBlob>>,
534            Inline<Handle<UnknownBlob>>,
535        ),
536        TransferError<
537            Infallible,
538            <BS as BlobStoreGet>::GetError<Infallible>,
539            <BT as BlobStorePut>::PutError,
540        >,
541    >,
542> + 'a
543where
544    BS: BlobStoreGet + 'a,
545    BT: BlobStorePut + 'a,
546    Handles: IntoIterator<Item = Inline<Handle<UnknownBlob>>> + 'a,
547    Handles::IntoIter: 'a,
548{
549    handles.into_iter().map(move |source_handle| {
550        let blob: Blob<UnknownBlob> = source.get(source_handle).map_err(TransferError::Load)?;
551
552        Ok((
553            source_handle,
554            (target.put(blob).map_err(TransferError::Store)?),
555        ))
556    })
557}
558
559/// Iterator that visits every blob handle reachable from a set of roots.
560///
561/// Uses [`BlobChildren`] to enumerate references at each level,
562/// so backends with batch capabilities get efficient traversal.
563pub struct ReachableHandles<'a, BS>
564where
565    BS: BlobChildren,
566{
567    source: &'a BS,
568    queue: VecDeque<Inline<Handle<UnknownBlob>>>,
569    visited: HashSet<[u8; INLINE_LEN]>,
570}
571
572impl<'a, BS> ReachableHandles<'a, BS>
573where
574    BS: BlobChildren,
575{
576    fn new(source: &'a BS, roots: impl IntoIterator<Item = Inline<Handle<UnknownBlob>>>) -> Self {
577        let mut queue = VecDeque::new();
578        for handle in roots {
579            queue.push_back(handle);
580        }
581
582        Self {
583            source,
584            queue,
585            visited: HashSet::new(),
586        }
587    }
588}
589
590impl<'a, BS> Iterator for ReachableHandles<'a, BS>
591where
592    BS: BlobChildren,
593{
594    type Item = Inline<Handle<UnknownBlob>>;
595
596    fn next(&mut self) -> Option<Self::Item> {
597        while let Some(handle) = self.queue.pop_front() {
598            let raw = handle.raw;
599
600            if !self.visited.insert(raw) {
601                continue;
602            }
603
604            // Use BlobChildren to get references — backends can override
605            // with batch-optimized implementations.
606            for child in self.source.children(handle) {
607                if !self.visited.contains(&child.raw) {
608                    self.queue.push_back(child);
609                }
610            }
611
612            return Some(handle);
613        }
614
615        None
616    }
617}
618
619/// Create a breadth-first iterator over blob handles reachable from `roots`.
620///
621/// Uses [`BlobChildren`] for reference enumeration, so network-backed
622/// stores can provide optimized batch implementations.
623pub fn reachable<'a, BS>(
624    source: &'a BS,
625    roots: impl IntoIterator<Item = Inline<Handle<UnknownBlob>>>,
626) -> ReachableHandles<'a, BS>
627where
628    BS: BlobChildren,
629{
630    ReachableHandles::new(source, roots)
631}
632
633/// Iterate over every 32-byte candidate in the value column of a [`TribleSet`].
634///
635/// This is a conservative conversion used when scanning metadata for potential
636/// blob handles. Each 32-byte chunk is treated as a `Handle<UnknownBlob>`.
637/// Callers can feed the resulting iterator into [`BlobStoreKeep::keep`] or other
638/// helpers that accept collections of handles.
639pub fn potential_handles<'a>(
640    set: &'a TribleSet,
641) -> impl Iterator<Item = Inline<Handle<UnknownBlob>>> + 'a {
642    set.vae.iter().map(|raw| {
643        let mut value = [0u8; INLINE_LEN];
644        value.copy_from_slice(&raw[0..INLINE_LEN]);
645        Inline::<Handle<UnknownBlob>>::new(value)
646    })
647}
648
649/// An error that can occur when creating a commit.
650/// This error can be caused by a failure to store the content or metadata blobs.
651#[derive(Debug)]
652pub enum CreateCommitError<BlobErr: Error + Debug + Send + Sync + 'static> {
653    /// Failed to store the content blob.
654    ContentStorageError(BlobErr),
655    /// Failed to store the commit metadata blob.
656    CommitStorageError(BlobErr),
657}
658
659impl<BlobErr: Error + Debug + Send + Sync + 'static> fmt::Display for CreateCommitError<BlobErr> {
660    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
661        match self {
662            CreateCommitError::ContentStorageError(e) => write!(f, "Content storage failed: {e}"),
663            CreateCommitError::CommitStorageError(e) => {
664                write!(f, "Commit metadata storage failed: {e}")
665            }
666        }
667    }
668}
669
670impl<BlobErr: Error + Debug + Send + Sync + 'static> Error for CreateCommitError<BlobErr> {
671    fn source(&self) -> Option<&(dyn Error + 'static)> {
672        match self {
673            CreateCommitError::ContentStorageError(e) => Some(e),
674            CreateCommitError::CommitStorageError(e) => Some(e),
675        }
676    }
677}
678
679/// Error returned by [`Workspace::merge`].
680#[derive(Debug)]
681pub enum MergeError {
682    /// The merge failed because the workspaces have different base repos.
683    DifferentRepos(),
684    /// The ancestry walk failed because one or more commit blobs along the
685    /// chain weren't readable from the workspace's view. The merge refuses
686    /// to fall through to a divergent-merge in this case — creating a merge
687    /// commit referencing an unknown chain would leave a dangling parent in
688    /// the resulting branch, and the append-only pile keeps that corruption
689    /// forever.
690    ///
691    /// Callers should ensure both heads' full closures are locally present
692    /// (e.g. via `fetch_reachable`) before retrying. The contained string
693    /// is a human-readable description of the underlying read failure.
694    AncestryWalkFailed(String),
695}
696
697/// Error returned by [`Repository::push`] and [`Repository::try_push`].
698/// Error type for [`Repository::compute_rollup`].
699#[derive(Debug)]
700pub enum RollupError<Storage: PinStore + BlobStore> {
701    /// The branch was not found in the underlying storage.
702    UnknownBranch,
703    /// The branch is empty — no HEAD to roll up.
704    EmptyBranch,
705    /// The branch HEAD advanced between checkout and CAS-update. The
706    /// caller may retry (`compute_rollup` is content-addressed so repeat
707    /// calls dedupe against already-uploaded blobs).
708    HeadAdvanced,
709    /// Underlying push / storage error during the attach step.
710    Push(PushError<Storage>),
711    /// Could not pull the branch to obtain a workspace.
712    Pull(PullError<Storage::HeadError,
713                    <Storage as BlobStore>::ReaderError,
714                    <<Storage as BlobStore>::Reader as BlobStoreGet>::GetError<UnarchiveError>>),
715    /// Could not check out the branch state to build the archive.
716    Checkout(WorkspaceCheckoutError<
717        <<Storage as BlobStore>::Reader as BlobStoreGet>::GetError<UnarchiveError>>),
718}
719
720#[derive(Debug)]
721pub enum PushError<Storage: PinStore + BlobStore> {
722    /// An error occurred while enumerating the branch storage branches.
723    StorageBranches(Storage::PinsError),
724    /// An error occurred while creating a blob reader.
725    StorageReader(<Storage as BlobStore>::ReaderError),
726    /// An error occurred while reading metadata blobs.
727    StorageGet(
728        <<Storage as BlobStore>::Reader as BlobStoreGet>::GetError<UnarchiveError>,
729    ),
730    /// An error occurred while transferring blobs to the repository.
731    StoragePut(<Storage as BlobStorePut>::PutError),
732    /// An error occurred while updating the branch storage.
733    BranchUpdate(Storage::UpdateError),
734    /// Malformed branch metadata.
735    BadBranchMetadata(),
736    /// Merge failed while retrying a push.
737    MergeError(MergeError),
738}
739
740// Allow using the `?` operator to convert MergeError into PushError in
741// contexts where PushError is the function error type. This keeps call sites
742// succinct by avoiding manual mapping closures like
743// `.map_err(|e| PushError::MergeError(e))?`.
744impl<Storage> From<MergeError> for PushError<Storage>
745where
746    Storage: PinStore + BlobStore,
747{
748    fn from(e: MergeError) -> Self {
749        PushError::MergeError(e)
750    }
751}
752
753// Note: we intentionally avoid generic `From` impls for storage-associated
754// error types because they can overlap with other blanket implementations
755// and lead to coherence conflicts. Call sites use explicit mapping via the
756// enum variant constructors (e.g. `map_err(PushError::StoragePut)`) where
757// needed which keeps conversions explicit and stable.
758
759/// Error returned by [`Repository::create_branch`] and related methods.
760#[derive(Debug)]
761pub enum BranchError<Storage>
762where
763    Storage: PinStore + BlobStore,
764{
765    /// An error occurred while creating a blob reader.
766    StorageReader(<Storage as BlobStore>::ReaderError),
767    /// An error occurred while reading metadata blobs.
768    StorageGet(
769        <<Storage as BlobStore>::Reader as BlobStoreGet>::GetError<UnarchiveError>,
770    ),
771    /// An error occurred while storing blobs.
772    StoragePut(<Storage as BlobStorePut>::PutError),
773    /// An error occurred while retrieving branch heads.
774    BranchHead(Storage::HeadError),
775    /// An error occurred while updating the branch storage.
776    BranchUpdate(Storage::UpdateError),
777    /// The branch already exists.
778    AlreadyExists(),
779    /// The referenced base branch does not exist.
780    BranchNotFound(Id),
781}
782
783/// Error returned by [`Repository::lookup_branch`].
784#[derive(Debug)]
785pub enum LookupError<Storage>
786where
787    Storage: PinStore + BlobStore,
788{
789    /// Failed to enumerate branches.
790    StorageBranches(Storage::PinsError),
791    /// Failed to read a branch head.
792    BranchHead(Storage::HeadError),
793    /// Failed to create a blob reader.
794    StorageReader(<Storage as BlobStore>::ReaderError),
795    /// Failed to read a metadata blob.
796    StorageGet(
797        <<Storage as BlobStore>::Reader as BlobStoreGet>::GetError<UnarchiveError>,
798    ),
799    /// Multiple branches were found with the given name.
800    NameConflict(Vec<Id>),
801    /// Branch metadata is malformed.
802    BadBranchMetadata(),
803}
804
805/// Error returned by [`Repository::ensure_branch`].
806#[derive(Debug)]
807pub enum EnsureBranchError<Storage>
808where
809    Storage: PinStore + BlobStore,
810{
811    /// Failed to look up the branch.
812    Lookup(LookupError<Storage>),
813    /// Failed to create the branch.
814    Create(BranchError<Storage>),
815}
816
817/// High-level wrapper combining a blob store and branch store into a usable
818/// repository API.
819///
820/// The [`Repository`] type exposes convenience methods for creating branches,
821/// committing data and pushing changes while delegating actual storage to the
822/// given [`BlobStore`] and [`PinStore`] implementations.
823pub struct Repository<Storage: BlobStore + PinStore> {
824    storage: Storage,
825    signing_key: SigningKey,
826    commit_metadata: MetadataHandle,
827}
828
829/// Error returned by [`Repository::pull`].
830pub enum PullError<BranchStorageErr, BlobReaderErr, BlobStorageErr>
831where
832    BranchStorageErr: Error,
833    BlobReaderErr: Error,
834    BlobStorageErr: Error,
835{
836    /// The branch does not exist in the repository.
837    BranchNotFound(Id),
838    /// An error occurred while accessing the branch storage.
839    BranchStorage(BranchStorageErr),
840    /// An error occurred while creating a blob reader.
841    BlobReader(BlobReaderErr),
842    /// An error occurred while accessing the blob storage.
843    BlobStorage(BlobStorageErr),
844    /// The branch metadata is malformed or does not contain the expected fields.
845    BadBranchMetadata(),
846}
847
848impl<B, R, C> fmt::Debug for PullError<B, R, C>
849where
850    B: Error + fmt::Debug,
851    R: Error + fmt::Debug,
852    C: Error + fmt::Debug,
853{
854    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
855        match self {
856            PullError::BranchNotFound(id) => f.debug_tuple("BranchNotFound").field(id).finish(),
857            PullError::BranchStorage(e) => f.debug_tuple("BranchStorage").field(e).finish(),
858            PullError::BlobReader(e) => f.debug_tuple("BlobReader").field(e).finish(),
859            PullError::BlobStorage(e) => f.debug_tuple("BlobStorage").field(e).finish(),
860            PullError::BadBranchMetadata() => f.debug_tuple("BadBranchMetadata").finish(),
861        }
862    }
863}
864
865impl<Storage> Repository<Storage>
866where
867    Storage: BlobStore + PinStore,
868{
869    /// Creates a new repository with the given storage, signing key, and
870    /// repo-wide commit metadata.
871    ///
872    /// `commit_metadata` accepts anything convertible into a [`Fragment`] —
873    /// either a raw [`TribleSet`] (auto-promoted with empty blob store via
874    /// `impl From<TribleSet> for Fragment`), or a Fragment built up via
875    /// `entity!{}` / `attributes!::describe()` that carries auxiliary blobs
876    /// (e.g. `Handle<LongString>` doc strings). The Fragment's blobs are
877    /// absorbed into storage so handles referenced by the metadata facts
878    /// stay resolvable for any downstream reader that pulls a commit and
879    /// calls [`Workspace::checkout_metadata`].
880    ///
881    /// The resulting metadata blob is referenced from every commit produced
882    /// by workspaces of this repository.
883    pub fn new<F: Into<crate::trible::Fragment>>(
884        mut storage: Storage,
885        signing_key: SigningKey,
886        commit_metadata: F,
887    ) -> Result<Self, <Storage as BlobStorePut>::PutError> {
888        let (facts, mut blobs) = commit_metadata.into().into_facts_and_blobs();
889        // Persist any blobs the Fragment carried — typically `Handle<LongString>`
890        // doc strings or other handle-referenced payloads. They're stored as
891        // `UnknownBlob` (raw bytes) because the storage layer is encoding-agnostic;
892        // readers recover the schema via the handle's declared encoding.
893        let reader = blobs
894            .reader()
895            .expect("MemoryBlobStore::reader is infallible");
896        for (_handle, blob) in reader {
897            storage.put::<UnknownBlob, _>(blob)?;
898        }
899        let commit_metadata = storage.put(facts)?;
900        Ok(Self {
901            storage,
902            signing_key,
903            commit_metadata,
904        })
905    }
906
907    /// Consume the repository and return the underlying storage backend.
908    ///
909    /// This is useful for callers that need to take ownership of the storage
910    /// (for example to call `close()` on a [`Pile`]) instead of letting the
911    /// repository drop it implicitly.
912    pub fn into_storage(self) -> Storage {
913        self.storage
914    }
915
916    /// Borrow the underlying storage backend.
917    pub fn storage(&self) -> &Storage {
918        &self.storage
919    }
920
921    /// Borrow the underlying storage backend mutably.
922    pub fn storage_mut(&mut self) -> &mut Storage {
923        &mut self.storage
924    }
925
926    /// Replace the repository signing key.
927    pub fn set_signing_key(&mut self, signing_key: SigningKey) {
928        self.signing_key = signing_key;
929    }
930
931    /// Returns the repository commit metadata handle.
932    pub fn commit_metadata(&self) -> MetadataHandle {
933        self.commit_metadata
934    }
935
936    /// Initializes a new branch in the repository.
937    /// Branches are the only mutable state in the repository,
938    /// and are used to represent the state of a commit chain at a specific point in time.
939    /// A branch must always point to a commit, and this function can be used to create a new branch.
940    ///
941    /// Creates a new branch in the repository.
942    /// This branch is a pointer to a specific commit in the repository.
943    /// The branch is created with name and is initialized to point to the opionally given commit.
944    /// The branch is signed by the branch signing key.
945    ///
946    /// # Parameters
947    /// * `branch_name` - Name of the new branch.
948    /// * `commit` - Commit to initialize the branch from.
949    pub fn create_branch(
950        &mut self,
951        branch_name: &str,
952        commit: Option<CommitHandle>,
953    ) -> Result<ExclusiveId, BranchError<Storage>> {
954        self.create_branch_with_key(branch_name, commit, self.signing_key.clone())
955    }
956
957    /// Same as [`Self::create_branch`] but uses the provided signing key.
958    pub fn create_branch_with_key(
959        &mut self,
960        branch_name: &str,
961        commit: Option<CommitHandle>,
962        signing_key: SigningKey,
963    ) -> Result<ExclusiveId, BranchError<Storage>> {
964        let branch_id = genid();
965        let name_blob: Blob<LongString> = branch_name.to_owned().to_blob();
966        let name_handle = name_blob.get_handle();
967        self.storage
968            .put::<LongString, _>(name_blob)
969            .map_err(|e| BranchError::StoragePut(e))?;
970
971        let branch_set = if let Some(commit) = commit {
972            let reader = self
973                .storage
974                .reader()
975                .map_err(|e| BranchError::StorageReader(e))?;
976            let set: TribleSet = reader.get(commit).map_err(|e| BranchError::StorageGet(e))?;
977
978            branch::branch_metadata(
979                &signing_key,
980                *branch_id,
981                name_handle,
982                Some(set.to_blob()),
983                None,
984            )
985        } else {
986            branch::branch_unsigned(*branch_id, name_handle, None, None)
987        };
988
989        let branch_blob = branch_set.to_blob();
990        let branch_handle = self
991            .storage
992            .put(branch_blob)
993            .map_err(|e| BranchError::StoragePut(e))?;
994        let push_result = self
995            .storage
996            .update(*branch_id, None, Some(branch_handle))
997            .map_err(|e| BranchError::BranchUpdate(e))?;
998
999        match push_result {
1000            PushResult::Success() => Ok(branch_id),
1001            PushResult::Conflict(_) => Err(BranchError::AlreadyExists()),
1002        }
1003    }
1004
1005    /// Look up a branch by name.
1006    ///
1007    /// Iterates all branches, reads each one's metadata, and returns the ID
1008    /// of the branch whose name matches. Returns `Ok(None)` if no branch has
1009    /// that name, or `LookupError::NameConflict` if multiple branches share it.
1010    pub fn lookup_branch(&mut self, name: &str) -> Result<Option<Id>, LookupError<Storage>> {
1011        let branch_ids: Vec<Id> = self
1012            .storage
1013            .pins()
1014            .map_err(LookupError::StorageBranches)?
1015            .collect::<Result<Vec<_>, _>>()
1016            .map_err(LookupError::StorageBranches)?;
1017
1018        let mut matches = Vec::new();
1019
1020        for branch_id in branch_ids {
1021            let Some(meta_handle) = self
1022                .storage
1023                .head(branch_id)
1024                .map_err(LookupError::BranchHead)?
1025            else {
1026                continue;
1027            };
1028
1029            let reader = self.storage.reader().map_err(LookupError::StorageReader)?;
1030            let meta_set: TribleSet = reader.get(meta_handle).map_err(LookupError::StorageGet)?;
1031
1032            let Ok((name_handle,)) = find!(
1033                (n: Inline<Handle<LongString>>),
1034                pattern!(&meta_set, [{ crate::metadata::name: ?n }])
1035            )
1036            .exactly_one() else {
1037                continue;
1038            };
1039
1040            let Ok(branch_name): Result<anybytes::View<str>, _> = reader.get(name_handle) else {
1041                continue;
1042            };
1043
1044            if branch_name.as_ref() == name {
1045                matches.push(branch_id);
1046            }
1047        }
1048
1049        match matches.len() {
1050            0 => Ok(None),
1051            1 => Ok(Some(matches[0])),
1052            _ => Err(LookupError::NameConflict(matches)),
1053        }
1054    }
1055
1056    /// Ensure a branch with the given name exists, creating it if necessary.
1057    ///
1058    /// If a branch named `name` already exists, returns its ID.
1059    /// If no such branch exists, creates a new one (optionally from the given
1060    /// commit) and returns its ID.
1061    ///
1062    /// Errors if multiple branches share the same name (ambiguous).
1063    pub fn ensure_branch(
1064        &mut self,
1065        name: &str,
1066        commit: Option<CommitHandle>,
1067    ) -> Result<Id, EnsureBranchError<Storage>> {
1068        match self
1069            .lookup_branch(name)
1070            .map_err(EnsureBranchError::Lookup)?
1071        {
1072            Some(id) => Ok(id),
1073            None => {
1074                let id = self
1075                    .create_branch(name, commit)
1076                    .map_err(EnsureBranchError::Create)?;
1077                Ok(*id)
1078            }
1079        }
1080    }
1081
1082    /// Pulls an existing branch using the repository's signing key.
1083    /// The workspace inherits the repository default metadata if configured.
1084    pub fn pull(
1085        &mut self,
1086        branch_id: Id,
1087    ) -> Result<
1088        Workspace<Storage>,
1089        PullError<
1090            Storage::HeadError,
1091            Storage::ReaderError,
1092            <Storage::Reader as BlobStoreGet>::GetError<UnarchiveError>,
1093        >,
1094    > {
1095        self.pull_with_key(branch_id, self.signing_key.clone())
1096    }
1097
1098    /// Same as [`Self::pull`] but overrides the signing key.
1099    pub fn pull_with_key(
1100        &mut self,
1101        branch_id: Id,
1102        signing_key: SigningKey,
1103    ) -> Result<
1104        Workspace<Storage>,
1105        PullError<
1106            Storage::HeadError,
1107            Storage::ReaderError,
1108            <Storage::Reader as BlobStoreGet>::GetError<UnarchiveError>,
1109        >,
1110    > {
1111        // 1. Get the branch metadata head from the branch store.
1112        let base_branch_meta_handle = match self.storage.head(branch_id) {
1113            Ok(Some(handle)) => handle,
1114            Ok(None) => return Err(PullError::BranchNotFound(branch_id)),
1115            Err(e) => return Err(PullError::BranchStorage(e)),
1116        };
1117        // 2. Get the current commit from the branch metadata.
1118        let reader = self.storage.reader().map_err(PullError::BlobReader)?;
1119        let base_branch_meta: TribleSet = match reader.get(base_branch_meta_handle) {
1120            Ok(meta_set) => meta_set,
1121            Err(e) => return Err(PullError::BlobStorage(e)),
1122        };
1123
1124        let head_ = match find!(
1125            (head_: Inline<_>),
1126            pattern!(&base_branch_meta, [{ head: ?head_ }])
1127        )
1128        .at_most_one()
1129        {
1130            Ok(Some((h,))) => Some(h),
1131            Ok(None) => None,
1132            Err(_) => return Err(PullError::BadBranchMetadata()),
1133        };
1134        // Create workspace with the current commit and base blobs.
1135        let base_blobs = self.storage.reader().map_err(PullError::BlobReader)?;
1136        Ok(Workspace {
1137            base_blobs,
1138            staged: MemoryBlobStore::new(),
1139            head: head_,
1140            base_head: head_,
1141            base_branch_id: branch_id,
1142            base_branch_meta: base_branch_meta_handle,
1143            signing_key,
1144            commit_metadata: self.commit_metadata,
1145        })
1146    }
1147
1148    /// Pushes the workspace's new blobs and commit to the persistent repository.
1149    /// This syncs the local BlobSet with the repository's BlobStore and performs
1150    /// an atomic branch update (using the stored base_branch_meta).
1151    pub fn push(&mut self, workspace: &mut Workspace<Storage>) -> Result<(), PushError<Storage>> {
1152        // Retrying push: attempt a single push and, on conflict, merge the
1153        // local workspace into the returned conflict workspace and retry.
1154        // This implements the common push-merge-retry loop as a convenience
1155        // wrapper around `try_push`.
1156        while let Some(mut conflict_ws) = self.try_push(workspace)? {
1157            // Keep the previous merge order: merge the caller's staged
1158            // changes into the incoming conflict workspace. This preserves
1159            // the semantic ordering of parents used in the merge commit.
1160            conflict_ws.merge(workspace)?;
1161
1162            // Move the merged incoming workspace into the caller's workspace
1163            // so the next try_push operates against the fresh branch state.
1164            // Using assignment here is equivalent to `swap` but avoids
1165            // retaining the previous `workspace` contents in the temp var.
1166            *workspace = conflict_ws;
1167        }
1168
1169        Ok(())
1170    }
1171
1172    /// Single-attempt push: upload local blobs and try to update the branch
1173    /// head once. Returns `Ok(None)` on success, or `Ok(Some(conflict_ws))`
1174    /// when the branch was updated concurrently and the caller should merge.
1175    pub fn try_push(
1176        &mut self,
1177        workspace: &mut Workspace<Storage>,
1178    ) -> Result<Option<Workspace<Storage>>, PushError<Storage>> {
1179        // 1. Sync `workspace.staged` to repository's BlobStore.
1180        let workspace_reader = workspace.staged.reader().unwrap();
1181        for handle in workspace_reader.blobs() {
1182            let handle = handle.expect("infallible blob enumeration");
1183            let blob: Blob<UnknownBlob> =
1184                workspace_reader.get(handle).expect("infallible blob read");
1185            self.storage
1186                .put::<UnknownBlob, _>(blob)
1187                .map_err(PushError::StoragePut)?;
1188        }
1189
1190        // 1.5 If the workspace's head did not change since the workspace was
1191        // created, there's no commit to reference and therefore no branch
1192        // metadata update is required. This avoids touching the branch store
1193        // in the common case where only blobs were staged or nothing changed.
1194        if workspace.base_head == workspace.head {
1195            return Ok(None);
1196        }
1197
1198        // 2. Create a new branch meta blob referencing the new workspace head.
1199        let repo_reader = self.storage.reader().map_err(PushError::StorageReader)?;
1200        let base_branch_meta: TribleSet = repo_reader
1201            .get(workspace.base_branch_meta)
1202            .map_err(PushError::StorageGet)?;
1203
1204        let Ok((branch_name,)) = find!(
1205            (name: Inline<Handle<LongString>>),
1206            pattern!(base_branch_meta, [{ crate::metadata::name: ?name }])
1207        )
1208        .exactly_one() else {
1209            return Err(PushError::BadBranchMetadata());
1210        };
1211
1212        let head_handle = workspace.head.ok_or(PushError::BadBranchMetadata())?;
1213        let head_: TribleSet = repo_reader
1214            .get(head_handle)
1215            .map_err(PushError::StorageGet)?;
1216
1217        let branch_meta = branch_metadata(
1218            &workspace.signing_key,
1219            workspace.base_branch_id,
1220            branch_name,
1221            Some(head_.to_blob()),
1222            // A fresh commit invalidates any prior rollup (it was computed
1223            // against the old HEAD). Readers fall back to checkout until
1224            // `compute_rollup` runs against the new HEAD.
1225            None,
1226        );
1227
1228        let branch_meta_handle = self
1229            .storage
1230            .put(branch_meta)
1231            .map_err(PushError::StoragePut)?;
1232
1233        // 3. Use CAS (comparing against workspace.base_branch_meta) to update the branch pointer.
1234        let result = self
1235            .storage
1236            .update(
1237                workspace.base_branch_id,
1238                Some(workspace.base_branch_meta),
1239                Some(branch_meta_handle),
1240            )
1241            .map_err(PushError::BranchUpdate)?;
1242
1243        match result {
1244            PushResult::Success() => {
1245                // Update workspace base pointers so subsequent pushes can detect
1246                // that the workspace is already synchronized and avoid re-upload.
1247                workspace.base_branch_meta = branch_meta_handle;
1248                workspace.base_head = workspace.head;
1249                // Refresh the workspace base blob reader to ensure newly
1250                // uploaded blobs are visible to subsequent checkout operations.
1251                workspace.base_blobs = self.storage.reader().map_err(PushError::StorageReader)?;
1252                // Clear staged local blobs now that they have been uploaded and
1253                // the branch metadata updated. This frees memory and prevents
1254                // repeated uploads of the same staged blobs on subsequent pushes.
1255                workspace.staged = MemoryBlobStore::new();
1256                Ok(None)
1257            }
1258            PushResult::Conflict(conflicting_meta) => {
1259                let conflicting_meta = conflicting_meta.ok_or(PushError::BadBranchMetadata())?;
1260
1261                let repo_reader = self.storage.reader().map_err(PushError::StorageReader)?;
1262                let branch_meta: TribleSet = repo_reader
1263                    .get(conflicting_meta)
1264                    .map_err(PushError::StorageGet)?;
1265
1266                let head_ = match find!((head_: Inline<_>),
1267                    pattern!(&branch_meta, [{ head: ?head_ }])
1268                )
1269                .at_most_one()
1270                {
1271                    Ok(Some((h,))) => Some(h),
1272                    Ok(None) => None,
1273                    Err(_) => return Err(PushError::BadBranchMetadata()),
1274                };
1275
1276                let conflict_ws = Workspace {
1277                    base_blobs: self.storage.reader().map_err(PushError::StorageReader)?,
1278                    staged: MemoryBlobStore::new(),
1279                    head: head_,
1280                    base_head: head_,
1281                    base_branch_id: workspace.base_branch_id,
1282                    base_branch_meta: conflicting_meta,
1283                    signing_key: workspace.signing_key.clone(),
1284                    commit_metadata: workspace.commit_metadata,
1285                };
1286
1287                Ok(Some(conflict_ws))
1288            }
1289        }
1290    }
1291
1292    /// Builds a [`SuccinctArchive`](crate::blob::encodings::succinctarchive::SuccinctArchive) rollup of the branch's current HEAD,
1293    /// stores it as a blob in the underlying storage, and attaches the
1294    /// resulting handle to the branch metadata via CAS.
1295    ///
1296    /// Returns the new rollup handle on success.
1297    ///
1298    /// Returns [`RollupError::HeadAdvanced`] if the branch HEAD moved
1299    /// between `pull` and the CAS-update. The caller may retry — the
1300    /// archive blob is content-addressed, so subsequent calls dedupe
1301    /// against already-uploaded blobs.
1302    ///
1303    /// Returns [`RollupError::EmptyBranch`] if the branch has no HEAD
1304    /// commit yet (nothing to roll up).
1305    ///
1306    /// This is the sole public write-path for rollups. The companion
1307    /// read-path is [`Workspace::rollup`].
1308    pub fn compute_rollup(
1309        &mut self,
1310        branch_id: Id,
1311    ) -> Result<
1312        Inline<Handle<crate::blob::encodings::succinctarchive::SuccinctArchiveBlob>>,
1313        RollupError<Storage>,
1314    > {
1315        use crate::blob::encodings::succinctarchive::{OrderedUniverse, SuccinctArchive};
1316        use crate::blob::IntoBlob;
1317
1318        let mut ws = self.pull(branch_id).map_err(RollupError::Pull)?;
1319        let head_handle = ws.head().ok_or(RollupError::EmptyBranch)?;
1320
1321        // Materialise the branch state from its commit chain and build the
1322        // succinct index over it.
1323        let space = ws.checkout(..).map_err(RollupError::Checkout)?;
1324        let archive: SuccinctArchive<OrderedUniverse> = (&*space).into();
1325        drop(space);
1326
1327        // Upload the archive blob directly to storage — no workspace-local
1328        // staging needed; the CAS below references it by handle.
1329        let archive_blob = (&archive).to_blob();
1330        let handle: Inline<
1331            Handle<crate::blob::encodings::succinctarchive::SuccinctArchiveBlob>,
1332        > = self
1333            .storage
1334            .put(archive_blob)
1335            .map_err(|e| RollupError::Push(PushError::StoragePut(e)))?;
1336
1337        // Construct a fresh branch meta that carries the same head as
1338        // `base_branch_meta` plus the new rollup attribute.
1339        let reader = self
1340            .storage
1341            .reader()
1342            .map_err(|e| RollupError::Push(PushError::StorageReader(e)))?;
1343        let base_meta: TribleSet = reader
1344            .get(ws.base_branch_meta)
1345            .map_err(|e| RollupError::Push(PushError::StorageGet(e)))?;
1346        let (branch_name,) = find!(
1347            (name: Inline<Handle<LongString>>),
1348            pattern!(&base_meta, [{ crate::metadata::name: ?name }])
1349        )
1350        .exactly_one()
1351        .map_err(|_| RollupError::Push(PushError::BadBranchMetadata()))?;
1352        let head_blob: TribleSet = reader
1353            .get(head_handle)
1354            .map_err(|e| RollupError::Push(PushError::StorageGet(e)))?;
1355
1356        let new_meta = branch::branch_metadata(
1357            &ws.signing_key,
1358            branch_id,
1359            branch_name,
1360            Some(head_blob.to_blob()),
1361            Some(handle),
1362        );
1363        let new_meta_handle = self
1364            .storage
1365            .put(new_meta)
1366            .map_err(|e| RollupError::Push(PushError::StoragePut(e)))?;
1367
1368        // CAS: swap `base_branch_meta` for the new meta. On conflict, the
1369        // head advanced between our pull and this CAS — the rollup we built
1370        // is stale against the new head, so report upstream.
1371        let update_result = self
1372            .storage
1373            .update(branch_id, Some(ws.base_branch_meta), Some(new_meta_handle))
1374            .map_err(|e| RollupError::Push(PushError::BranchUpdate(e)))?;
1375        match update_result {
1376            PushResult::Success() => Ok(handle),
1377            PushResult::Conflict(_) => Err(RollupError::HeadAdvanced),
1378        }
1379    }
1380}
1381
1382/// A handle to a commit blob in the repository.
1383pub type CommitHandle = Inline<Handle<SimpleArchive>>;
1384type MetadataHandle = Inline<Handle<SimpleArchive>>;
1385/// A set of commit handles, used by [`CommitSelector`] and [`Checkout`].
1386pub type CommitSet = PATCH<INLINE_LEN, IdentitySchema, ()>;
1387type BranchMetaHandle = Inline<Handle<SimpleArchive>>;
1388
1389/// The result of a [`Workspace::checkout`] operation: a [`TribleSet`] paired
1390/// with the set of commits that produced it. Pass the commit set as the start
1391/// of a range selector to obtain incremental deltas on the next checkout.
1392///
1393/// [`Checkout`] dereferences to [`TribleSet`], so it can be used directly with
1394/// `find!`, `pattern!`, and `pattern_changes!`.
1395///
1396/// # Example: incremental updates
1397///
1398/// ```rust,ignore
1399/// let mut changed = repo.pull(branch_id)?.checkout(..)?;
1400/// let mut full = changed.facts().clone();
1401///
1402/// loop {
1403///     // full already includes changed
1404///     for result in pattern_changes!(&full, &changed, [{ ... }]) {
1405///         // process new results
1406///     }
1407///
1408///     // Advance — exclude exactly the commits we already processed.
1409///     changed = repo.pull(branch_id)?.checkout(changed.commits()..)?;
1410///     full += &changed;
1411/// }
1412/// ```
1413#[derive(Debug, Clone)]
1414pub struct Checkout {
1415    facts: TribleSet,
1416    commits: CommitSet,
1417}
1418
1419impl PartialEq<TribleSet> for Checkout {
1420    fn eq(&self, other: &TribleSet) -> bool {
1421        self.facts == *other
1422    }
1423}
1424
1425impl PartialEq<Checkout> for TribleSet {
1426    fn eq(&self, other: &Checkout) -> bool {
1427        *self == other.facts
1428    }
1429}
1430
1431impl Checkout {
1432    /// The checked-out tribles.
1433    pub fn facts(&self) -> &TribleSet {
1434        &self.facts
1435    }
1436
1437    /// The set of commits that produced this checkout. Use as the start of a
1438    /// range selector (`checkout.commits()..`) to exclude these commits
1439    /// on the next checkout and obtain only new data.
1440    pub fn commits(&self) -> CommitSet {
1441        self.commits.clone()
1442    }
1443
1444    /// Consume the checkout and return the inner TribleSet.
1445    pub fn into_facts(self) -> TribleSet {
1446        self.facts
1447    }
1448}
1449
1450impl std::ops::Deref for Checkout {
1451    type Target = TribleSet;
1452    fn deref(&self) -> &TribleSet {
1453        &self.facts
1454    }
1455}
1456
1457impl std::ops::AddAssign<&Checkout> for Checkout {
1458    fn add_assign(&mut self, rhs: &Checkout) {
1459        self.facts += rhs.facts.clone();
1460        self.commits.union(rhs.commits.clone());
1461    }
1462}
1463
1464impl std::ops::Add for Checkout {
1465    type Output = Self;
1466    fn add(mut self, rhs: Self) -> Self {
1467        self.facts += rhs.facts;
1468        self.commits.union(rhs.commits);
1469        self
1470    }
1471}
1472
1473impl std::ops::Add<&Checkout> for Checkout {
1474    type Output = Self;
1475    fn add(mut self, rhs: &Checkout) -> Self {
1476        self += rhs;
1477        self
1478    }
1479}
1480
1481/// The Workspace represents the mutable working area or "staging" state.
1482/// It was formerly known as `Head`. It is sent to worker threads,
1483/// modified (via commits, merges, etc.), and then merged back into the Repository.
1484pub struct Workspace<Blobs: BlobStore> {
1485    /// Staged blobs — added to this workspace but not yet pushed to
1486    /// the underlying repo. Analogous to git's staging area (the
1487    /// index): blobs accumulate here via `put` and friends, then
1488    /// `repo.push(&mut ws)` ships everything as one batch to the
1489    /// durable backend.
1490    pub staged: MemoryBlobStore,
1491    /// The blob storage base for the workspace.
1492    base_blobs: Blobs::Reader,
1493    /// The branch id this workspace is tracking; None for a detached workspace.
1494    base_branch_id: Id,
1495    /// The meta-handle corresponding to the base branch state used for CAS.
1496    base_branch_meta: BranchMetaHandle,
1497    /// Handle to the current commit in the working branch. `None` for an empty branch.
1498    head: Option<CommitHandle>,
1499    /// The branch head snapshot when this workspace was created (pull time).
1500    ///
1501    /// This allows `try_push` to cheaply detect whether the commit head has
1502    /// advanced since the workspace was created without querying the remote
1503    /// branch store.
1504    base_head: Option<CommitHandle>,
1505    /// Signing key used for commit/branch signing.
1506    signing_key: SigningKey,
1507    /// Metadata handle for commits created in this workspace.
1508    commit_metadata: MetadataHandle,
1509}
1510
1511impl<Blobs> fmt::Debug for Workspace<Blobs>
1512where
1513    Blobs: BlobStore,
1514    Blobs::Reader: fmt::Debug,
1515{
1516    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1517        f.debug_struct("Workspace")
1518            .field("staged", &self.staged)
1519            .field("base_blobs", &self.base_blobs)
1520            .field("base_branch_id", &self.base_branch_id)
1521            .field("base_branch_meta", &self.base_branch_meta)
1522            .field("base_head", &self.base_head)
1523            .field("head", &self.head)
1524            .field("commit_metadata", &self.commit_metadata)
1525            .finish()
1526    }
1527}
1528
1529/// Helper trait for [`Workspace::checkout`] specifying commit handles or ranges.
1530pub trait CommitSelector<Blobs: BlobStore> {
1531    fn select(
1532        self,
1533        ws: &mut Workspace<Blobs>,
1534    ) -> Result<
1535        CommitSet,
1536        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1537    >;
1538}
1539
1540/// Selector that returns every commit reachable from a starting selector.
1541pub struct Ancestors<S>(pub S);
1542
1543/// Convenience function to create an [`Ancestors`] selector.
1544pub fn ancestors<S>(selector: S) -> Ancestors<S> {
1545    Ancestors(selector)
1546}
1547
1548/// Selector that walks every commit in the input set back N parent steps,
1549/// following all parent links (including merge parents). Returns the set
1550/// of all commits found at exactly depth N from the starting set.
1551///
1552/// This is a wavefront expansion: at each step, every commit in the current
1553/// frontier is replaced by all of its parents. After N steps the frontier
1554/// is the result.
1555pub struct NthAncestors<S>(pub S, pub usize);
1556
1557/// Walk `selector` back `n` parent steps through all parent links.
1558pub fn nth_ancestors<S>(selector: S, n: usize) -> NthAncestors<S> {
1559    NthAncestors(selector, n)
1560}
1561
1562/// Selector that returns the direct parents of commits from a starting selector.
1563pub struct Parents<S>(pub S);
1564
1565/// Convenience function to create a [`Parents`] selector.
1566pub fn parents<S>(selector: S) -> Parents<S> {
1567    Parents(selector)
1568}
1569
1570/// Selector that returns commits reachable from either of two selectors but
1571/// not both.
1572pub struct SymmetricDiff<A, B>(pub A, pub B);
1573
1574/// Convenience function to create a [`SymmetricDiff`] selector.
1575pub fn symmetric_diff<A, B>(a: A, b: B) -> SymmetricDiff<A, B> {
1576    SymmetricDiff(a, b)
1577}
1578
1579/// Selector that returns the union of commits returned by two selectors.
1580pub struct Union<A, B> {
1581    left: A,
1582    right: B,
1583}
1584
1585/// Convenience function to create a [`Union`] selector.
1586pub fn union<A, B>(left: A, right: B) -> Union<A, B> {
1587    Union { left, right }
1588}
1589
1590/// Selector that returns the intersection of commits returned by two selectors.
1591pub struct Intersect<A, B> {
1592    left: A,
1593    right: B,
1594}
1595
1596/// Convenience function to create an [`Intersect`] selector.
1597pub fn intersect<A, B>(left: A, right: B) -> Intersect<A, B> {
1598    Intersect { left, right }
1599}
1600
1601/// Selector that returns commits from the left selector that are not also
1602/// returned by the right selector.
1603pub struct Difference<A, B> {
1604    left: A,
1605    right: B,
1606}
1607
1608/// Convenience function to create a [`Difference`] selector.
1609pub fn difference<A, B>(left: A, right: B) -> Difference<A, B> {
1610    Difference { left, right }
1611}
1612
1613/// Selector that returns commits with timestamps in the given inclusive range.
1614pub struct TimeRange(pub Epoch, pub Epoch);
1615
1616/// Convenience function to create a [`TimeRange`] selector.
1617pub fn time_range(start: Epoch, end: Epoch) -> TimeRange {
1618    TimeRange(start, end)
1619}
1620
1621/// Selector that filters commits returned by another selector.
1622pub struct Filter<S, F> {
1623    selector: S,
1624    filter: F,
1625}
1626
1627/// Convenience function to create a [`Filter`] selector.
1628pub fn filter<S, F>(selector: S, filter: F) -> Filter<S, F> {
1629    Filter { selector, filter }
1630}
1631
1632impl<Blobs> CommitSelector<Blobs> for CommitHandle
1633where
1634    Blobs: BlobStore,
1635{
1636    fn select(
1637        self,
1638        _ws: &mut Workspace<Blobs>,
1639    ) -> Result<
1640        CommitSet,
1641        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1642    > {
1643        let mut patch = CommitSet::new();
1644        patch.insert(&Entry::new(&self.raw));
1645        Ok(patch)
1646    }
1647}
1648
1649impl<Blobs> CommitSelector<Blobs> for CommitSet
1650where
1651    Blobs: BlobStore,
1652{
1653    fn select(
1654        self,
1655        _ws: &mut Workspace<Blobs>,
1656    ) -> Result<
1657        CommitSet,
1658        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1659    > {
1660        Ok(self)
1661    }
1662}
1663
1664impl<Blobs> CommitSelector<Blobs> for Vec<CommitHandle>
1665where
1666    Blobs: BlobStore,
1667{
1668    fn select(
1669        self,
1670        _ws: &mut Workspace<Blobs>,
1671    ) -> Result<
1672        CommitSet,
1673        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1674    > {
1675        let mut patch = CommitSet::new();
1676        for handle in self {
1677            patch.insert(&Entry::new(&handle.raw));
1678        }
1679        Ok(patch)
1680    }
1681}
1682
1683impl<Blobs> CommitSelector<Blobs> for &[CommitHandle]
1684where
1685    Blobs: BlobStore,
1686{
1687    fn select(
1688        self,
1689        _ws: &mut Workspace<Blobs>,
1690    ) -> Result<
1691        CommitSet,
1692        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1693    > {
1694        let mut patch = CommitSet::new();
1695        for handle in self {
1696            patch.insert(&Entry::new(&handle.raw));
1697        }
1698        Ok(patch)
1699    }
1700}
1701
1702impl<Blobs> CommitSelector<Blobs> for Option<CommitHandle>
1703where
1704    Blobs: BlobStore,
1705{
1706    fn select(
1707        self,
1708        _ws: &mut Workspace<Blobs>,
1709    ) -> Result<
1710        CommitSet,
1711        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1712    > {
1713        let mut patch = CommitSet::new();
1714        if let Some(handle) = self {
1715            patch.insert(&Entry::new(&handle.raw));
1716        }
1717        Ok(patch)
1718    }
1719}
1720
1721impl<S, Blobs> CommitSelector<Blobs> for Ancestors<S>
1722where
1723    S: CommitSelector<Blobs>,
1724    Blobs: BlobStore,
1725{
1726    fn select(
1727        self,
1728        ws: &mut Workspace<Blobs>,
1729    ) -> Result<
1730        CommitSet,
1731        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1732    > {
1733        let seeds = self.0.select(ws)?;
1734        collect_reachable_from_patch(ws, seeds)
1735    }
1736}
1737
1738impl<Blobs, S> CommitSelector<Blobs> for NthAncestors<S>
1739where
1740    Blobs: BlobStore,
1741    S: CommitSelector<Blobs>,
1742{
1743    fn select(
1744        self,
1745        ws: &mut Workspace<Blobs>,
1746    ) -> Result<
1747        CommitSet,
1748        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1749    > {
1750        let mut frontier = self.0.select(ws)?;
1751        let mut remaining = self.1;
1752
1753        while remaining > 0 && !frontier.is_empty() {
1754            // Collect current frontier keys before mutating.
1755            let keys: Vec<[u8; INLINE_LEN]> = frontier.iter().copied().collect();
1756            let mut next_frontier = CommitSet::new();
1757            for raw in keys {
1758                let handle = CommitHandle::new(raw);
1759                let meta: TribleSet = ws.get(handle).map_err(WorkspaceCheckoutError::Storage)?;
1760                for (p,) in find!((p: Inline<_>), pattern!(&meta, [{ parent: ?p }])) {
1761                    next_frontier.insert(&Entry::new(&p.raw));
1762                }
1763            }
1764            frontier = next_frontier;
1765            remaining -= 1;
1766        }
1767
1768        Ok(frontier)
1769    }
1770}
1771
1772impl<S, Blobs> CommitSelector<Blobs> for Parents<S>
1773where
1774    S: CommitSelector<Blobs>,
1775    Blobs: BlobStore,
1776{
1777    fn select(
1778        self,
1779        ws: &mut Workspace<Blobs>,
1780    ) -> Result<
1781        CommitSet,
1782        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1783    > {
1784        let seeds = self.0.select(ws)?;
1785        let mut result = CommitSet::new();
1786        for raw in seeds.iter() {
1787            let handle = Inline::new(*raw);
1788            let meta: TribleSet = ws.get(handle).map_err(WorkspaceCheckoutError::Storage)?;
1789            for (p,) in find!((p: Inline<_>), pattern!(&meta, [{ parent: ?p }])) {
1790                result.insert(&Entry::new(&p.raw));
1791            }
1792        }
1793        Ok(result)
1794    }
1795}
1796
1797impl<A, B, Blobs> CommitSelector<Blobs> for SymmetricDiff<A, B>
1798where
1799    A: CommitSelector<Blobs>,
1800    B: CommitSelector<Blobs>,
1801    Blobs: BlobStore,
1802{
1803    fn select(
1804        self,
1805        ws: &mut Workspace<Blobs>,
1806    ) -> Result<
1807        CommitSet,
1808        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1809    > {
1810        let seeds_a = self.0.select(ws)?;
1811        let seeds_b = self.1.select(ws)?;
1812        let a = collect_reachable_from_patch(ws, seeds_a)?;
1813        let b = collect_reachable_from_patch(ws, seeds_b)?;
1814        let inter = a.intersect(&b);
1815        let mut union = a;
1816        union.union(b);
1817        Ok(union.difference(&inter))
1818    }
1819}
1820
1821impl<A, B, Blobs> CommitSelector<Blobs> for Union<A, B>
1822where
1823    A: CommitSelector<Blobs>,
1824    B: CommitSelector<Blobs>,
1825    Blobs: BlobStore,
1826{
1827    fn select(
1828        self,
1829        ws: &mut Workspace<Blobs>,
1830    ) -> Result<
1831        CommitSet,
1832        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1833    > {
1834        let mut left = self.left.select(ws)?;
1835        let right = self.right.select(ws)?;
1836        left.union(right);
1837        Ok(left)
1838    }
1839}
1840
1841impl<A, B, Blobs> CommitSelector<Blobs> for Intersect<A, B>
1842where
1843    A: CommitSelector<Blobs>,
1844    B: CommitSelector<Blobs>,
1845    Blobs: BlobStore,
1846{
1847    fn select(
1848        self,
1849        ws: &mut Workspace<Blobs>,
1850    ) -> Result<
1851        CommitSet,
1852        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1853    > {
1854        let left = self.left.select(ws)?;
1855        let right = self.right.select(ws)?;
1856        Ok(left.intersect(&right))
1857    }
1858}
1859
1860impl<A, B, Blobs> CommitSelector<Blobs> for Difference<A, B>
1861where
1862    A: CommitSelector<Blobs>,
1863    B: CommitSelector<Blobs>,
1864    Blobs: BlobStore,
1865{
1866    fn select(
1867        self,
1868        ws: &mut Workspace<Blobs>,
1869    ) -> Result<
1870        CommitSet,
1871        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1872    > {
1873        let left = self.left.select(ws)?;
1874        let right = self.right.select(ws)?;
1875        Ok(left.difference(&right))
1876    }
1877}
1878
1879impl<S, F, Blobs> CommitSelector<Blobs> for Filter<S, F>
1880where
1881    Blobs: BlobStore,
1882    S: CommitSelector<Blobs>,
1883    F: for<'x, 'y> Fn(&'x TribleSet, &'y TribleSet) -> bool,
1884{
1885    fn select(
1886        self,
1887        ws: &mut Workspace<Blobs>,
1888    ) -> Result<
1889        CommitSet,
1890        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1891    > {
1892        let patch = self.selector.select(ws)?;
1893        let mut result = CommitSet::new();
1894        let filter = self.filter;
1895        for raw in patch.iter() {
1896            let handle = Inline::new(*raw);
1897            let meta: TribleSet = ws.get(handle).map_err(WorkspaceCheckoutError::Storage)?;
1898
1899            let Ok((content_handle,)) = find!(
1900                (c: Inline<_>),
1901                pattern!(&meta, [{ content: ?c }])
1902            )
1903            .exactly_one() else {
1904                return Err(WorkspaceCheckoutError::BadCommitMetadata());
1905            };
1906
1907            let payload: TribleSet = ws
1908                .get(content_handle)
1909                .map_err(WorkspaceCheckoutError::Storage)?;
1910
1911            if filter(&meta, &payload) {
1912                result.insert(&Entry::new(raw));
1913            }
1914        }
1915        Ok(result)
1916    }
1917}
1918
1919/// Selector that yields commits touching a specific entity.
1920pub struct HistoryOf(pub Id);
1921
1922/// Convenience function to create a [`HistoryOf`] selector.
1923pub fn history_of(entity: Id) -> HistoryOf {
1924    HistoryOf(entity)
1925}
1926
1927impl<Blobs> CommitSelector<Blobs> for HistoryOf
1928where
1929    Blobs: BlobStore,
1930{
1931    fn select(
1932        self,
1933        ws: &mut Workspace<Blobs>,
1934    ) -> Result<
1935        CommitSet,
1936        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1937    > {
1938        let Some(head_) = ws.head else {
1939            return Ok(CommitSet::new());
1940        };
1941        let entity = self.0;
1942        filter(
1943            ancestors(head_),
1944            move |_: &TribleSet, payload: &TribleSet| payload.iter().any(|t| t.e() == &entity),
1945        )
1946        .select(ws)
1947    }
1948}
1949
1950// Generic range selectors: allow any selector type to be used as a range
1951// endpoint. We still walk the history reachable from the end selector but now
1952// stop descending a branch as soon as we encounter a commit produced by the
1953// start selector. This keeps the mechanics explicit—`start..end` literally
1954// walks from `end` until it hits `start`—while continuing to support selectors
1955// such as `Ancestors(...)` at either boundary.
1956
1957fn collect_reachable_from_patch<Blobs: BlobStore>(
1958    ws: &mut Workspace<Blobs>,
1959    patch: CommitSet,
1960) -> Result<
1961    CommitSet,
1962    WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1963> {
1964    let mut result = CommitSet::new();
1965    for raw in patch.iter() {
1966        let handle = Inline::new(*raw);
1967        let reach = collect_reachable(ws, handle)?;
1968        result.union(reach);
1969    }
1970    Ok(result)
1971}
1972
1973fn collect_reachable_from_patch_until<Blobs: BlobStore>(
1974    ws: &mut Workspace<Blobs>,
1975    seeds: CommitSet,
1976    stop: &CommitSet,
1977) -> Result<
1978    CommitSet,
1979    WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
1980> {
1981    let mut visited = HashSet::new();
1982    let mut stack: Vec<CommitHandle> = seeds.iter().map(|raw| Inline::new(*raw)).collect();
1983    let mut result = CommitSet::new();
1984
1985    while let Some(commit) = stack.pop() {
1986        if !visited.insert(commit) {
1987            continue;
1988        }
1989
1990        if stop.get(&commit.raw).is_some() {
1991            continue;
1992        }
1993
1994        result.insert(&Entry::new(&commit.raw));
1995
1996        let meta: TribleSet = ws
1997            .staged
1998            .reader()
1999            .unwrap()
2000            .get(commit)
2001            .or_else(|_| ws.base_blobs.get(commit))
2002            .map_err(WorkspaceCheckoutError::Storage)?;
2003
2004        for (p,) in find!((p: Inline<_>,), pattern!(&meta, [{ parent: ?p }])) {
2005            stack.push(p);
2006        }
2007    }
2008
2009    Ok(result)
2010}
2011
2012impl<T, Blobs> CommitSelector<Blobs> for std::ops::Range<T>
2013where
2014    T: CommitSelector<Blobs>,
2015    Blobs: BlobStore,
2016{
2017    fn select(
2018        self,
2019        ws: &mut Workspace<Blobs>,
2020    ) -> Result<
2021        CommitSet,
2022        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2023    > {
2024        let end_patch = self.end.select(ws)?;
2025        let start_patch = self.start.select(ws)?;
2026
2027        collect_reachable_from_patch_until(ws, end_patch, &start_patch)
2028    }
2029}
2030
2031impl<T, Blobs> CommitSelector<Blobs> for std::ops::RangeFrom<T>
2032where
2033    T: CommitSelector<Blobs>,
2034    Blobs: BlobStore,
2035{
2036    fn select(
2037        self,
2038        ws: &mut Workspace<Blobs>,
2039    ) -> Result<
2040        CommitSet,
2041        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2042    > {
2043        let Some(head_) = ws.head else {
2044            return Ok(CommitSet::new());
2045        };
2046        let exclude_patch = self.start.select(ws)?;
2047
2048        let mut head_patch = CommitSet::new();
2049        head_patch.insert(&Entry::new(&head_.raw));
2050
2051        collect_reachable_from_patch_until(ws, head_patch, &exclude_patch)
2052    }
2053}
2054
2055impl<T, Blobs> CommitSelector<Blobs> for std::ops::RangeTo<T>
2056where
2057    T: CommitSelector<Blobs>,
2058    Blobs: BlobStore,
2059{
2060    fn select(
2061        self,
2062        ws: &mut Workspace<Blobs>,
2063    ) -> Result<
2064        CommitSet,
2065        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2066    > {
2067        let end_patch = self.end.select(ws)?;
2068        collect_reachable_from_patch(ws, end_patch)
2069    }
2070}
2071
2072impl<Blobs> CommitSelector<Blobs> for std::ops::RangeFull
2073where
2074    Blobs: BlobStore,
2075{
2076    fn select(
2077        self,
2078        ws: &mut Workspace<Blobs>,
2079    ) -> Result<
2080        CommitSet,
2081        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2082    > {
2083        let Some(head_) = ws.head else {
2084            return Ok(CommitSet::new());
2085        };
2086        collect_reachable(ws, head_)
2087    }
2088}
2089
2090impl<Blobs> CommitSelector<Blobs> for TimeRange
2091where
2092    Blobs: BlobStore,
2093{
2094    fn select(
2095        self,
2096        ws: &mut Workspace<Blobs>,
2097    ) -> Result<
2098        CommitSet,
2099        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2100    > {
2101        let Some(head_) = ws.head else {
2102            return Ok(CommitSet::new());
2103        };
2104        let start = self.0;
2105        let end = self.1;
2106        filter(
2107            ancestors(head_),
2108            move |meta: &TribleSet, _payload: &TribleSet| {
2109                if let Ok(Some(((ts_start, ts_end),))) =
2110                    find!((t: (Epoch, Epoch)), pattern!(meta, [{ crate::metadata::created_at: ?t }])).at_most_one()
2111                {
2112                    ts_start <= end && ts_end >= start
2113                } else {
2114                    false
2115                }
2116            },
2117        )
2118        .select(ws)
2119    }
2120}
2121
2122/// Minimum number of commits at which `checkout_commits*` switches
2123/// from the serial loop to a `rayon::par_iter().try_reduce()` over
2124/// the commits. Each commit involves one (or two) blob fetches plus
2125/// an unarchive — independent work per commit — so the crossover is
2126/// small. Below this the rayon overhead dominates.
2127#[cfg(feature = "parallel")]
2128const PARALLEL_CHECKOUT_THRESHOLD: usize = 8;
2129
2130impl<Blobs: BlobStore> Workspace<Blobs> {
2131    /// Returns the branch id associated with this workspace.
2132    pub fn branch_id(&self) -> Id {
2133        self.base_branch_id
2134    }
2135
2136    /// Returns the current commit handle if one exists.
2137    pub fn head(&self) -> Option<CommitHandle> {
2138        self.head
2139    }
2140
2141    /// Returns the workspace metadata handle.
2142    pub fn metadata(&self) -> MetadataHandle {
2143        self.commit_metadata
2144    }
2145
2146    /// Reads the rollup handle, if any, from the workspace's base branch
2147    /// metadata. Returns `None` if the branch has no rollup yet or if the
2148    /// metadata is missing the attribute. Readers can use this to fetch
2149    /// the archive blob directly and skip `checkout(..)` for warm queries:
2150    ///
2151    /// ```rust,ignore
2152    /// let mut ws = repo.pull(branch)?;
2153    /// match ws.rollup()? {
2154    ///     Some(h) => {
2155    ///         let archive: SuccinctArchive<_> = ws.get(h)?;
2156    ///         // query archive
2157    ///     }
2158    ///     None => {
2159    ///         let space = ws.checkout(..)?;
2160    ///         // query space (commit-chain materialisation)
2161    ///     }
2162    /// }
2163    /// ```
2164    ///
2165    /// Writers don't go through this — attach a rollup via
2166    /// [`Repository::compute_rollup`] instead.
2167    pub fn rollup(
2168        &mut self,
2169    ) -> Result<
2170        Option<Inline<Handle<SuccinctArchiveBlob>>>,
2171        <Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>,
2172    > {
2173        let base_meta: TribleSet = self.base_blobs.get(self.base_branch_meta)?;
2174        Ok(
2175            find!(
2176                (r: Inline<Handle<SuccinctArchiveBlob>>),
2177                pattern!(&base_meta, [{ rollup: ?r }])
2178            )
2179            .next()
2180            .map(|(r,)| r),
2181        )
2182    }
2183
2184    /// Adds a blob to the workspace's local blob store.
2185    /// Mirrors [`BlobStorePut::put`](crate::repo::BlobStorePut) for ease of use.
2186    pub fn put<S, T>(&mut self, item: T) -> Inline<Handle<S>>
2187    where
2188        S: BlobEncoding + 'static,
2189        T: IntoBlob<S>,
2190        Handle<S>: InlineEncoding,
2191    {
2192        self.staged.put(item).expect("infallible blob put")
2193    }
2194
2195
2196    /// Retrieves a blob from the workspace.
2197    ///
2198    /// The method first checks the workspace's local blob store and falls back
2199    /// to the base blob store if the blob is not found locally.
2200    pub fn get<T, S>(
2201        &mut self,
2202        handle: Inline<Handle<S>>,
2203    ) -> Result<T, <Blobs::Reader as BlobStoreGet>::GetError<<T as TryFromBlob<S>>::Error>>
2204    where
2205        S: BlobEncoding + 'static,
2206        T: TryFromBlob<S>,
2207        Handle<S>: InlineEncoding,
2208    {
2209        self.staged
2210            .reader()
2211            .unwrap()
2212            .get(handle)
2213            .or_else(|_| self.base_blobs.get(handle))
2214    }
2215
2216    /// Performs a commit in the workspace.
2217    ///
2218    /// Accepts anything that converts into a [`Fragment`] — either a
2219    /// raw [`TribleSet`] (auto-promoted to a Fragment with empty blob
2220    /// store), or a Fragment built up via `entity!{}` /
2221    /// `MetaDescribe::describe()` whose embedded blobs get absorbed
2222    /// into `self.staged` alongside the commit-content blob.
2223    /// This method creates a new commit blob (stored in the local
2224    /// blobset) and updates the current commit handle.
2225    pub fn commit(&mut self, content_: impl Into<Fragment>, message_: &str) {
2226        self.commit_internal(content_.into(), Some(self.commit_metadata), Some(message_));
2227    }
2228
2229    /// Like [`commit`](Self::commit) but attaches a one-off metadata handle
2230    /// instead of the repository default.
2231    pub fn commit_with_metadata(
2232        &mut self,
2233        content_: impl Into<Fragment>,
2234        metadata_: MetadataHandle,
2235        message_: &str,
2236    ) {
2237        self.commit_internal(content_.into(), Some(metadata_), Some(message_));
2238    }
2239
2240    fn commit_internal(
2241        &mut self,
2242        content_: Fragment,
2243        metadata_handle: Option<MetadataHandle>,
2244        message_: Option<&str>,
2245    ) {
2246        let (content_facts, content_blobs) = content_.into_facts_and_blobs();
2247        // 0. Absorb any blobs the Fragment carried with it into the
2248        //    staging area before producing the commit blob, so handles
2249        //    inside `content_facts` resolve against `self.staged`.
2250        self.staged.union(content_blobs);
2251        // 1. Create a commit blob from the current head, content, metadata and the commit message.
2252        let content_blob: Blob<SimpleArchive> = content_facts.to_blob();
2253        // If a message is provided, store it as a LongString blob and pass the handle.
2254        let message_handle = message_.map(|m| self.put(m.to_string()));
2255        let parents = self.head.iter().copied();
2256
2257        let commit_set = crate::repo::commit::commit_metadata(
2258            &self.signing_key,
2259            parents,
2260            message_handle,
2261            Some(content_blob.clone()),
2262            metadata_handle,
2263        );
2264        // 2. Store the content and commit blobs in `self.staged`.
2265        let _ = self
2266            .staged
2267            .put::<SimpleArchive, _>(content_blob)
2268            .expect("failed to put content blob");
2269        let commit_handle = self
2270            .staged
2271            .put(commit_set)
2272            .expect("failed to put commit blob");
2273        // 3. Update `self.head` to point to the new commit.
2274        self.head = Some(commit_handle);
2275    }
2276
2277    /// Merge another workspace into this one.
2278    ///
2279    /// Always copies the *staged* blobs from `other.staged` into
2280    /// `self.staged` (so standalone blobs that aren't referenced by any
2281    /// commit chain still come along — useful when the other workspace was
2282    /// being used to stage content).
2283    ///
2284    /// Then integrates `other.head` via [`merge_commit`](Self::merge_commit),
2285    /// which picks no-op / fast-forward / merge commit as appropriate.
2286    ///
2287    /// Returns the workspace's new head, or `None` if both workspaces were
2288    /// empty (nothing to merge into anything).
2289    ///
2290    /// Notes:
2291    /// - The merge does *not* automatically import the entire base history
2292    ///   reachable from `other`'s head. If the incoming parent commits
2293    ///   reference blobs that do not exist in this repository's storage,
2294    ///   reading those commits later will fail until the missing blobs are
2295    ///   explicitly imported (for example via `repo::transfer(reachable(...))`).
2296    /// - This design keeps merge permissive and leaves cross-repository blob
2297    ///   import as an explicit user action.
2298    pub fn merge(
2299        &mut self,
2300        other: &mut Workspace<Blobs>,
2301    ) -> Result<Option<CommitHandle>, MergeError> {
2302        // 1. Always transfer staged blobs from `other`. They may include
2303        //    standalone blobs (no commit referring to them yet) that the
2304        //    caller wanted to stash in the workspace independent of any
2305        //    branch state.
2306        let other_local = other.staged.reader().unwrap();
2307        for r in other_local.blobs() {
2308            let handle = r.expect("infallible blob enumeration");
2309            let blob: Blob<UnknownBlob> = other_local.get(handle).expect("infallible blob read");
2310            self.staged
2311                .put::<UnknownBlob, _>(blob)
2312                .expect("infallible blob put");
2313        }
2314
2315        // 2. Integrate `other`'s head via the smart merge_commit. If `other`
2316        //    has no head, there's nothing further to integrate — just return
2317        //    our current head (which may or may not exist).
2318        match other.head {
2319            Some(other_head) => Ok(Some(self.merge_commit(other_head)?)),
2320            None => Ok(self.head),
2321        }
2322    }
2323
2324    /// Integrate another commit into this workspace's history.
2325    ///
2326    /// Picks the cheapest correct strategy:
2327    ///
2328    /// - **No-op** if the workspace has no head and `other` *is* the head, or
2329    ///   if `other` is already in the current head's ancestry.
2330    /// - **Fast-forward** if the workspace has no head, or if the current head
2331    ///   is in `other`'s ancestry — `self.head` is set to `other` directly.
2332    /// - **Merge commit** otherwise — a new commit with `[current_head, other]`
2333    ///   as parents is created and `self.head` advances to it.
2334    ///
2335    /// Returns the workspace's new head in all cases.
2336    ///
2337    /// The ancestor checks are best-effort: if the relevant commit blobs are
2338    /// missing from the workspace's view, the function falls through to the
2339    /// always-correct merge-commit path. Callers that mirror remote chains
2340    /// should ensure reachable blobs were imported (e.g. via `reachable` +
2341    /// `transfer`) for the optimization to kick in.
2342    pub fn merge_commit(
2343        &mut self,
2344        other: Inline<Handle<SimpleArchive>>,
2345    ) -> Result<CommitHandle, MergeError> {
2346        // Trivial cases first.
2347        let local_head = match self.head {
2348            None => {
2349                // No local head — fast-forward to `other`.
2350                self.head = Some(other);
2351                return Ok(other);
2352            }
2353            Some(h) if h == other => {
2354                // Identical — no-op.
2355                return Ok(h);
2356            }
2357            Some(h) => h,
2358        };
2359
2360        // Walk both ancestry chains. If either walk fails because a commit
2361        // blob is missing locally, refuse to merge — falling through to a
2362        // divergent-merge here would write a new commit referencing an
2363        // unknown parent, which `pile diagnose check` would later report as
2364        // a chain break and which `fetch_reachable`'s Phase-1
2365        // `have_local` short-circuit would never re-fetch. Better to fail
2366        // loudly so the caller can re-sync the missing closure and retry.
2367        let remote_in_local = ancestors(local_head)
2368            .select(self)
2369            .map_err(|e| MergeError::AncestryWalkFailed(format!("walking local ancestry: {e:?}")))?
2370            .get(&other.raw)
2371            .is_some();
2372        if remote_in_local {
2373            // `other` is already in our history → no-op.
2374            return Ok(local_head);
2375        }
2376
2377        let local_in_remote = ancestors(other)
2378            .select(self)
2379            .map_err(|e| MergeError::AncestryWalkFailed(format!("walking remote ancestry: {e:?}")))?
2380            .get(&local_head.raw)
2381            .is_some();
2382        if local_in_remote {
2383            // We're behind `other` → fast-forward.
2384            self.head = Some(other);
2385            return Ok(other);
2386        }
2387
2388        // Truly divergent — create a merge commit.
2389        let parents = self.head.iter().copied().chain(Some(other));
2390        let merge_commit = commit_metadata(&self.signing_key, parents, None, None, None);
2391        let commit_handle = self
2392            .staged
2393            .put(merge_commit)
2394            .expect("failed to put merge commit blob");
2395        self.head = Some(commit_handle);
2396        Ok(commit_handle)
2397    }
2398
2399    /// Move the workspace's head to `commit` without creating a new commit.
2400    ///
2401    /// This is the "fast-forward" case: when the new commit is a descendant
2402    /// of (or equal to) the current head, you can advance directly without
2403    /// a merge commit. The caller is responsible for verifying the
2404    /// descendancy relationship — typically via [`ancestors`] over `commit`.
2405    ///
2406    /// Use this in pull/sync flows to avoid spurious merge commits when one
2407    /// peer is simply behind the other.
2408    pub fn set_head(&mut self, commit: CommitHandle) {
2409        self.head = Some(commit);
2410    }
2411
2412    /// Returns the combined [`TribleSet`] for the specified commits.
2413    ///
2414    /// Each commit handle must reference a commit blob stored either in the
2415    /// workspace's local blob store or the repository's base store. The
2416    /// associated content blobs are loaded and unioned together. An error is
2417    /// returned if any commit or content blob is missing or malformed.
2418    fn checkout_commits<I>(
2419        &mut self,
2420        commits: I,
2421    ) -> Result<
2422        TribleSet,
2423        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2424    >
2425    where
2426        I: IntoIterator<Item = CommitHandle>,
2427    {
2428        let local = self.staged.reader().unwrap();
2429        let commits: Vec<CommitHandle> = commits.into_iter().collect();
2430
2431        #[cfg(feature = "parallel")]
2432        {
2433            if commits.len() >= PARALLEL_CHECKOUT_THRESHOLD {
2434                use rayon::prelude::*;
2435                let base = self.base_blobs.clone();
2436                return commits
2437                    .into_par_iter()
2438                    .map_with(
2439                        (local, base),
2440                        |(local, base), commit| -> Result<TribleSet, _> {
2441                            let meta: TribleSet = local
2442                                .get(commit)
2443                                .or_else(|_| base.get(commit))
2444                                .map_err(WorkspaceCheckoutError::Storage)?;
2445                            let content_opt = match find!(
2446                                (c: Inline<_>),
2447                                pattern!(&meta, [{ content: ?c }])
2448                            )
2449                            .at_most_one()
2450                            {
2451                                Ok(Some((c,))) => Some(c),
2452                                Ok(None) => None,
2453                                Err(_) => {
2454                                    return Err(WorkspaceCheckoutError::BadCommitMetadata())
2455                                }
2456                            };
2457                            if let Some(c) = content_opt {
2458                                let set: TribleSet = local
2459                                    .get(c)
2460                                    .or_else(|_| base.get(c))
2461                                    .map_err(WorkspaceCheckoutError::Storage)?;
2462                                Ok(set)
2463                            } else {
2464                                Ok(TribleSet::new())
2465                            }
2466                        },
2467                    )
2468                    .try_reduce(TribleSet::new, |a, b| Ok(a + b));
2469            }
2470        }
2471
2472        let mut result = TribleSet::new();
2473        for commit in commits {
2474            let meta: TribleSet = local
2475                .get(commit)
2476                .or_else(|_| self.base_blobs.get(commit))
2477                .map_err(WorkspaceCheckoutError::Storage)?;
2478
2479            // Some commits (for example merge commits) intentionally do not
2480            // carry a content blob. Treat those as no-ops during checkout so
2481            // callers can request ancestor ranges without failing when a
2482            // merge commit is encountered.
2483            let content_opt =
2484                match find!((c: Inline<_>), pattern!(&meta, [{ content: ?c }])).at_most_one() {
2485                    Ok(Some((c,))) => Some(c),
2486                    Ok(None) => None,
2487                    Err(_) => return Err(WorkspaceCheckoutError::BadCommitMetadata()),
2488                };
2489
2490            if let Some(c) = content_opt {
2491                let set: TribleSet = local
2492                    .get(c)
2493                    .or_else(|_| self.base_blobs.get(c))
2494                    .map_err(WorkspaceCheckoutError::Storage)?;
2495                result += set;
2496            } else {
2497                // No content for this commit (e.g. merge-only commit); skip it.
2498                continue;
2499            }
2500        }
2501        Ok(result)
2502    }
2503
2504    fn checkout_commits_metadata<I>(
2505        &mut self,
2506        commits: I,
2507    ) -> Result<
2508        TribleSet,
2509        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2510    >
2511    where
2512        I: IntoIterator<Item = CommitHandle>,
2513    {
2514        let local = self.staged.reader().unwrap();
2515        let commits: Vec<CommitHandle> = commits.into_iter().collect();
2516
2517        #[cfg(feature = "parallel")]
2518        {
2519            if commits.len() >= PARALLEL_CHECKOUT_THRESHOLD {
2520                use rayon::prelude::*;
2521                let base = self.base_blobs.clone();
2522                return commits
2523                    .into_par_iter()
2524                    .map_with(
2525                        (local, base),
2526                        |(local, base), commit| -> Result<TribleSet, _> {
2527                            let meta: TribleSet = local
2528                                .get(commit)
2529                                .or_else(|_| base.get(commit))
2530                                .map_err(WorkspaceCheckoutError::Storage)?;
2531                            let metadata_opt = match find!(
2532                                (c: Inline<_>),
2533                                pattern!(&meta, [{ metadata: ?c }])
2534                            )
2535                            .at_most_one()
2536                            {
2537                                Ok(Some((c,))) => Some(c),
2538                                Ok(None) => None,
2539                                Err(_) => {
2540                                    return Err(WorkspaceCheckoutError::BadCommitMetadata())
2541                                }
2542                            };
2543                            if let Some(c) = metadata_opt {
2544                                let set: TribleSet = local
2545                                    .get(c)
2546                                    .or_else(|_| base.get(c))
2547                                    .map_err(WorkspaceCheckoutError::Storage)?;
2548                                Ok(set)
2549                            } else {
2550                                Ok(TribleSet::new())
2551                            }
2552                        },
2553                    )
2554                    .try_reduce(TribleSet::new, |a, b| Ok(a + b));
2555            }
2556        }
2557
2558        let mut result = TribleSet::new();
2559        for commit in commits {
2560            let meta: TribleSet = local
2561                .get(commit)
2562                .or_else(|_| self.base_blobs.get(commit))
2563                .map_err(WorkspaceCheckoutError::Storage)?;
2564
2565            let metadata_opt =
2566                match find!((c: Inline<_>), pattern!(&meta, [{ metadata: ?c }])).at_most_one() {
2567                    Ok(Some((c,))) => Some(c),
2568                    Ok(None) => None,
2569                    Err(_) => return Err(WorkspaceCheckoutError::BadCommitMetadata()),
2570                };
2571
2572            if let Some(c) = metadata_opt {
2573                let set: TribleSet = local
2574                    .get(c)
2575                    .or_else(|_| self.base_blobs.get(c))
2576                    .map_err(WorkspaceCheckoutError::Storage)?;
2577                result += set;
2578            }
2579        }
2580        Ok(result)
2581    }
2582
2583    fn checkout_commits_with_metadata<I>(
2584        &mut self,
2585        commits: I,
2586    ) -> Result<
2587        (TribleSet, TribleSet),
2588        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2589    >
2590    where
2591        I: IntoIterator<Item = CommitHandle>,
2592    {
2593        let local = self.staged.reader().unwrap();
2594        let commits: Vec<CommitHandle> = commits.into_iter().collect();
2595
2596        #[cfg(feature = "parallel")]
2597        {
2598            if commits.len() >= PARALLEL_CHECKOUT_THRESHOLD {
2599                use rayon::prelude::*;
2600                let base = self.base_blobs.clone();
2601                return commits
2602                    .into_par_iter()
2603                    .map_with(
2604                        (local, base),
2605                        |(local, base), commit| -> Result<(TribleSet, TribleSet), _> {
2606                            let meta: TribleSet = local
2607                                .get(commit)
2608                                .or_else(|_| base.get(commit))
2609                                .map_err(WorkspaceCheckoutError::Storage)?;
2610                            let content_opt = match find!(
2611                                (c: Inline<_>),
2612                                pattern!(&meta, [{ content: ?c }])
2613                            )
2614                            .at_most_one()
2615                            {
2616                                Ok(Some((c,))) => Some(c),
2617                                Ok(None) => None,
2618                                Err(_) => {
2619                                    return Err(WorkspaceCheckoutError::BadCommitMetadata())
2620                                }
2621                            };
2622                            let data_set = if let Some(c) = content_opt {
2623                                local
2624                                    .get(c)
2625                                    .or_else(|_| base.get(c))
2626                                    .map_err(WorkspaceCheckoutError::Storage)?
2627                            } else {
2628                                TribleSet::new()
2629                            };
2630                            let metadata_opt = match find!(
2631                                (c: Inline<_>),
2632                                pattern!(&meta, [{ metadata: ?c }])
2633                            )
2634                            .at_most_one()
2635                            {
2636                                Ok(Some((c,))) => Some(c),
2637                                Ok(None) => None,
2638                                Err(_) => {
2639                                    return Err(WorkspaceCheckoutError::BadCommitMetadata())
2640                                }
2641                            };
2642                            let metadata_set = if let Some(c) = metadata_opt {
2643                                local
2644                                    .get(c)
2645                                    .or_else(|_| base.get(c))
2646                                    .map_err(WorkspaceCheckoutError::Storage)?
2647                            } else {
2648                                TribleSet::new()
2649                            };
2650                            Ok((data_set, metadata_set))
2651                        },
2652                    )
2653                    .try_reduce(
2654                        || (TribleSet::new(), TribleSet::new()),
2655                        |(a_data, a_meta), (b_data, b_meta)| {
2656                            Ok((a_data + b_data, a_meta + b_meta))
2657                        },
2658                    );
2659            }
2660        }
2661
2662        let mut data = TribleSet::new();
2663        let mut metadata_set = TribleSet::new();
2664        for commit in commits {
2665            let meta: TribleSet = local
2666                .get(commit)
2667                .or_else(|_| self.base_blobs.get(commit))
2668                .map_err(WorkspaceCheckoutError::Storage)?;
2669
2670            let content_opt =
2671                match find!((c: Inline<_>), pattern!(&meta, [{ content: ?c }])).at_most_one() {
2672                    Ok(Some((c,))) => Some(c),
2673                    Ok(None) => None,
2674                    Err(_) => return Err(WorkspaceCheckoutError::BadCommitMetadata()),
2675                };
2676
2677            if let Some(c) = content_opt {
2678                let set: TribleSet = local
2679                    .get(c)
2680                    .or_else(|_| self.base_blobs.get(c))
2681                    .map_err(WorkspaceCheckoutError::Storage)?;
2682                data += set;
2683            }
2684
2685            let metadata_opt =
2686                match find!((c: Inline<_>), pattern!(&meta, [{ metadata: ?c }])).at_most_one() {
2687                    Ok(Some((c,))) => Some(c),
2688                    Ok(None) => None,
2689                    Err(_) => return Err(WorkspaceCheckoutError::BadCommitMetadata()),
2690                };
2691
2692            if let Some(c) = metadata_opt {
2693                let set: TribleSet = local
2694                    .get(c)
2695                    .or_else(|_| self.base_blobs.get(c))
2696                    .map_err(WorkspaceCheckoutError::Storage)?;
2697                metadata_set += set;
2698            }
2699        }
2700        Ok((data, metadata_set))
2701    }
2702
2703    /// Returns the combined [`TribleSet`] for the specified commits or commit
2704    /// ranges. `spec` can be a single [`CommitHandle`], an iterator of handles
2705    /// or any of the standard range types over [`CommitHandle`].
2706    pub fn checkout<R>(
2707        &mut self,
2708        spec: R,
2709    ) -> Result<
2710        Checkout,
2711        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2712    >
2713    where
2714        R: CommitSelector<Blobs>,
2715    {
2716        let commits = spec.select(self)?;
2717        let facts = self.checkout_commits(commits.iter().map(|raw| Inline::new(*raw)))?;
2718        Ok(Checkout { facts, commits })
2719    }
2720
2721    /// Returns the combined metadata [`TribleSet`] for the specified commits.
2722    /// Commits without metadata handles contribute an empty set.
2723    pub fn checkout_metadata<R>(
2724        &mut self,
2725        spec: R,
2726    ) -> Result<
2727        TribleSet,
2728        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2729    >
2730    where
2731        R: CommitSelector<Blobs>,
2732    {
2733        let patch = spec.select(self)?;
2734        let commits = patch.iter().map(|raw| Inline::new(*raw));
2735        self.checkout_commits_metadata(commits)
2736    }
2737
2738    /// Returns the combined data and metadata [`TribleSet`] for the specified commits.
2739    /// Metadata is loaded from each commit's `metadata` handle, when present.
2740    pub fn checkout_with_metadata<R>(
2741        &mut self,
2742        spec: R,
2743    ) -> Result<
2744        (TribleSet, TribleSet),
2745        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2746    >
2747    where
2748        R: CommitSelector<Blobs>,
2749    {
2750        let patch = spec.select(self)?;
2751        let commits = patch.iter().map(|raw| Inline::new(*raw));
2752        self.checkout_commits_with_metadata(commits)
2753    }
2754}
2755
2756#[derive(Debug)]
2757pub enum WorkspaceCheckoutError<GetErr: Error> {
2758    /// Error retrieving blobs from storage.
2759    Storage(GetErr),
2760    /// Commit metadata is malformed or ambiguous.
2761    BadCommitMetadata(),
2762}
2763
2764impl<E: Error + fmt::Debug> fmt::Display for WorkspaceCheckoutError<E> {
2765    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2766        match self {
2767            WorkspaceCheckoutError::Storage(e) => write!(f, "storage error: {e}"),
2768            WorkspaceCheckoutError::BadCommitMetadata() => {
2769                write!(f, "commit metadata malformed")
2770            }
2771        }
2772    }
2773}
2774
2775impl<E: Error + fmt::Debug> Error for WorkspaceCheckoutError<E> {}
2776
2777fn collect_reachable<Blobs: BlobStore>(
2778    ws: &mut Workspace<Blobs>,
2779    from: CommitHandle,
2780) -> Result<
2781    CommitSet,
2782    WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet>::GetError<UnarchiveError>>,
2783> {
2784    let mut visited = HashSet::new();
2785    let mut stack = vec![from];
2786    let mut result = CommitSet::new();
2787
2788    while let Some(commit) = stack.pop() {
2789        if !visited.insert(commit) {
2790            continue;
2791        }
2792        result.insert(&Entry::new(&commit.raw));
2793
2794        let meta: TribleSet = ws
2795            .staged
2796            .reader()
2797            .unwrap()
2798            .get(commit)
2799            .or_else(|_| ws.base_blobs.get(commit))
2800            .map_err(WorkspaceCheckoutError::Storage)?;
2801
2802        for (p,) in find!((p: Inline<_>,), pattern!(&meta, [{ parent: ?p }])) {
2803            stack.push(p);
2804        }
2805    }
2806
2807    Ok(result)
2808}