Skip to main content

triblespace_core/
repo.rs

1//! This module provides a high-level API for storing and retrieving data from repositories.
2//! The design is inspired by Git, but with a focus on object/content-addressed storage.
3//! It separates storage concerns from the data model, and reduces the mutable state of the repository,
4//! to an absolute minimum, making it easier to reason about and allowing for different storage backends.
5//!
6//! Blob repositories are collections of blobs that can be content-addressed by their hash.
7#![allow(clippy::type_complexity)]
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::valueschemas::{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` 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`]. 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` stores commits and branch metadata similar to a remote.
96//! - `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//!
112pub mod branch;
113pub mod commit;
114pub mod hybridstore;
115pub mod memoryrepo;
116pub mod objectstore;
117pub mod pile;
118
119/// Trait for storage backends that require explicit close/cleanup.
120///
121/// Not all storage backends need to implement this; implementations that have
122/// nothing to do on close may return Ok(()) or use `Infallible` as the error
123/// type.
124pub trait StorageClose {
125    /// Error type returned by `close`.
126    type Error: std::error::Error;
127
128    /// Consume the storage and perform any necessary cleanup.
129    fn close(self) -> Result<(), Self::Error>;
130}
131
132// Convenience impl for repositories whose storage supports explicit close.
133impl<Storage> Repository<Storage>
134where
135    Storage: BlobStore<Blake3> + BranchStore<Blake3> + StorageClose,
136{
137    /// Close the repository's underlying storage if it supports explicit
138    /// close operations.
139    ///
140    /// This method is only available when the storage type implements
141    /// [`StorageClose`]. It consumes the repository and delegates to the
142    /// storage's `close` implementation, returning any error produced.
143    pub fn close(self) -> Result<(), <Storage as StorageClose>::Error> {
144        self.storage.close()
145    }
146}
147
148use crate::macros::pattern;
149use std::collections::{HashSet, VecDeque};
150use std::convert::Infallible;
151use std::error::Error;
152use std::fmt::Debug;
153use std::fmt::{self};
154
155use commit::commit_metadata;
156use hifitime::Epoch;
157use itertools::Itertools;
158
159use crate::blob::schemas::simplearchive::UnarchiveError;
160use crate::blob::schemas::UnknownBlob;
161use crate::blob::Blob;
162use crate::blob::BlobSchema;
163use crate::blob::MemoryBlobStore;
164use crate::blob::ToBlob;
165use crate::blob::TryFromBlob;
166use crate::find;
167use crate::id::genid;
168use crate::id::Id;
169use crate::patch::Entry;
170use crate::patch::IdentitySchema;
171use crate::patch::PATCH;
172use crate::prelude::valueschemas::GenId;
173use crate::repo::branch::branch_metadata;
174use crate::trible::TribleSet;
175use crate::value::schemas::hash::Handle;
176use crate::value::schemas::hash::HashProtocol;
177use crate::value::Value;
178use crate::value::ValueSchema;
179use crate::value::VALUE_LEN;
180use ed25519_dalek::SigningKey;
181
182use crate::blob::schemas::longstring::LongString;
183use crate::blob::schemas::simplearchive::SimpleArchive;
184use crate::prelude::*;
185use crate::value::schemas::ed25519 as ed;
186use crate::value::schemas::hash::Blake3;
187use crate::value::schemas::shortstring::ShortString;
188use crate::value::schemas::time::NsTAIInterval;
189
190attributes! {
191    /// The actual data of the commit.
192    "4DD4DDD05CC31734B03ABB4E43188B1F" as pub content: Handle<Blake3, SimpleArchive>;
193    /// Metadata describing the commit content.
194    "88B59BD497540AC5AECDB7518E737C87" as pub metadata: Handle<Blake3, SimpleArchive>;
195    /// A commit that this commit is based on.
196    "317044B612C690000D798CA660ECFD2A" as pub parent: Handle<Blake3, SimpleArchive>;
197    /// A (potentially long) message describing the commit.
198    "B59D147839100B6ED4B165DF76EDF3BB" as pub message: Handle<Blake3, LongString>;
199    /// A short message describing the commit.
200    "12290C0BE0E9207E324F24DDE0D89300" as pub short_message: ShortString;
201    /// The hash of the first commit in the commit chain of the branch.
202    "272FBC56108F336C4D2E17289468C35F" as pub head: Handle<Blake3, SimpleArchive>;
203    /// An id used to track the branch.
204    "8694CC73AF96A5E1C7635C677D1B928A" as pub branch: GenId;
205    /// Timestamp range when this commit was created.
206    "71FF566AB4E3119FC2C5E66A18979586" as pub timestamp: NsTAIInterval;
207    /// The author of the signature identified by their ed25519 public key.
208    "ADB4FFAD247C886848161297EFF5A05B" as pub signed_by: ed::ED25519PublicKey;
209    /// The `r` part of a ed25519 signature.
210    "9DF34F84959928F93A3C40AEB6E9E499" as pub signature_r: ed::ED25519RComponent;
211    /// The `s` part of a ed25519 signature.
212    "1ACE03BF70242B289FDF00E4327C3BC6" as pub signature_s: ed::ED25519SComponent;
213}
214
215/// The `ListBlobs` trait is used to list all blobs in a repository.
216pub trait BlobStoreList<H: HashProtocol> {
217    type Iter<'a>: Iterator<Item = Result<Value<Handle<H, UnknownBlob>>, Self::Err>>
218    where
219        Self: 'a;
220    type Err: Error + Debug + Send + Sync + 'static;
221
222    /// Lists all blobs in the repository.
223    fn blobs<'a>(&'a self) -> Self::Iter<'a>;
224}
225
226/// Metadata about a blob in a repository.
227#[derive(Debug, Clone)]
228pub struct BlobMetadata {
229    /// Timestamp in milliseconds since UNIX epoch when the blob was created/stored.
230    pub timestamp: u64,
231    /// Length of the blob in bytes.
232    pub length: u64,
233}
234
235/// Trait exposing metadata lookup for blobs available in a repository reader.
236pub trait BlobStoreMeta<H: HashProtocol> {
237    /// Error type returned by metadata calls.
238    type MetaError: std::error::Error + Send + Sync + 'static;
239
240    fn metadata<S>(
241        &self,
242        handle: Value<Handle<H, S>>,
243    ) -> Result<Option<BlobMetadata>, Self::MetaError>
244    where
245        S: BlobSchema + 'static,
246        Handle<H, S>: ValueSchema;
247}
248
249/// Trait exposing a monotonic "forget" operation.
250///
251/// Forget is idempotent and monotonic: it removes materialization from a
252/// particular repository but does not semantically delete derived facts.
253pub trait BlobStoreForget<H: HashProtocol> {
254    type ForgetError: std::error::Error + Send + Sync + 'static;
255
256    fn forget<S>(&mut self, handle: Value<Handle<H, S>>) -> Result<(), Self::ForgetError>
257    where
258        S: BlobSchema + 'static,
259        Handle<H, S>: ValueSchema;
260}
261
262/// The `GetBlob` trait is used to retrieve blobs from a repository.
263pub trait BlobStoreGet<H: HashProtocol> {
264    type GetError<E: std::error::Error>: Error;
265
266    /// Retrieves a blob from the repository by its handle.
267    /// The handle is a unique identifier for the blob, and is used to retrieve it from the repository.
268    /// The blob is returned as a `Blob` object, which contains the raw bytes of the blob,
269    /// which can be deserialized via the appropriate schema type, which is specified by the `T` type parameter.
270    ///
271    /// # Errors
272    /// Returns an error if the blob could not be found in the repository.
273    /// The error type is specified by the `Err` associated type.
274    fn get<T, S>(
275        &self,
276        handle: Value<Handle<H, S>>,
277    ) -> Result<T, Self::GetError<<T as TryFromBlob<S>>::Error>>
278    where
279        S: BlobSchema + 'static,
280        T: TryFromBlob<S>,
281        Handle<H, S>: ValueSchema;
282}
283
284/// The `PutBlob` trait is used to store blobs in a repository.
285pub trait BlobStorePut<H: HashProtocol> {
286    type PutError: Error + Debug + Send + Sync + 'static;
287
288    fn put<S, T>(&mut self, item: T) -> Result<Value<Handle<H, S>>, Self::PutError>
289    where
290        S: BlobSchema + 'static,
291        T: ToBlob<S>,
292        Handle<H, S>: ValueSchema;
293}
294
295pub trait BlobStore<H: HashProtocol>: BlobStorePut<H> {
296    type Reader: BlobStoreGet<H> + BlobStoreList<H> + Clone + Send + PartialEq + Eq + 'static;
297    type ReaderError: Error + Debug + Send + Sync + 'static;
298    fn reader(&mut self) -> Result<Self::Reader, Self::ReaderError>;
299}
300
301/// Trait for blob stores that can retain a supplied set of handles.
302pub trait BlobStoreKeep<H: HashProtocol> {
303    /// Retain only the blobs identified by `handles`.
304    fn keep<I>(&mut self, handles: I)
305    where
306        I: IntoIterator<Item = Value<Handle<H, UnknownBlob>>>;
307}
308
309#[derive(Debug)]
310pub enum PushResult<H>
311where
312    H: HashProtocol,
313{
314    Success(),
315    Conflict(Option<Value<Handle<H, SimpleArchive>>>),
316}
317
318pub trait BranchStore<H: HashProtocol> {
319    type BranchesError: Error + Debug + Send + Sync + 'static;
320    type HeadError: Error + Debug + Send + Sync + 'static;
321    type UpdateError: Error + Debug + Send + Sync + 'static;
322
323    type ListIter<'a>: Iterator<Item = Result<Id, Self::BranchesError>>
324    where
325        Self: 'a;
326
327    /// Lists all branches in the repository.
328    /// This function returns a stream of branch ids.
329    fn branches<'a>(&'a mut self) -> Result<Self::ListIter<'a>, Self::BranchesError>;
330
331    // NOTE: keep the API lean — callers may call `branches()` and handle the
332    // fallible iterator directly; we avoid adding an extra helper here.
333
334    /// Retrieves a branch from the repository by its id.
335    /// The id is a unique identifier for the branch, and is used to retrieve it from the repository.
336    ///
337    /// # Errors
338    /// Returns an error if the branch could not be found in the repository.
339    ///
340    /// # Parameters
341    /// * `id` - The id of the branch to retrieve.
342    ///
343    /// # Returns
344    /// * A future that resolves to the handle of the branch.
345    /// * The handle is a unique identifier for the branch, and is used to retrieve it from the repository.
346    fn head(&mut self, id: Id) -> Result<Option<Value<Handle<H, SimpleArchive>>>, Self::HeadError>;
347
348    /// Puts a branch on the repository, creating or updating it.
349    ///
350    /// # Parameters
351    /// * `old` - Expected current value of the branch (None if creating new)
352    /// * `new` - Value to update the branch to (None deletes the branch)
353    ///
354    /// # Returns
355    /// * `Success` - Push completed successfully
356    /// * `Conflict(current)` - Failed because the branch's current value doesn't match `old`
357    ///   (contains the actual current value for conflict resolution)
358    fn update(
359        &mut self,
360        id: Id,
361        old: Option<Value<Handle<H, SimpleArchive>>>,
362        new: Option<Value<Handle<H, SimpleArchive>>>,
363    ) -> Result<PushResult<H>, Self::UpdateError>;
364}
365
366#[derive(Debug)]
367pub enum TransferError<ListErr, LoadErr, StoreErr> {
368    List(ListErr),
369    Load(LoadErr),
370    Store(StoreErr),
371}
372
373impl<ListErr, LoadErr, StoreErr> fmt::Display for TransferError<ListErr, LoadErr, StoreErr> {
374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        write!(f, "failed to transfer blob")
376    }
377}
378
379impl<ListErr, LoadErr, StoreErr> Error for TransferError<ListErr, LoadErr, StoreErr>
380where
381    ListErr: Debug + Error + 'static,
382    LoadErr: Debug + Error + 'static,
383    StoreErr: Debug + Error + 'static,
384{
385    fn source(&self) -> Option<&(dyn Error + 'static)> {
386        match self {
387            Self::List(e) => Some(e),
388            Self::Load(e) => Some(e),
389            Self::Store(e) => Some(e),
390        }
391    }
392}
393
394/// Copies the specified blob handles from `source` into `target`.
395pub fn transfer<'a, BS, BT, HS, HT, Handles>(
396    source: &'a BS,
397    target: &'a mut BT,
398    handles: Handles,
399) -> impl Iterator<
400    Item = Result<
401        (
402            Value<Handle<HS, UnknownBlob>>,
403            Value<Handle<HT, UnknownBlob>>,
404        ),
405        TransferError<
406            Infallible,
407            <BS as BlobStoreGet<HS>>::GetError<Infallible>,
408            <BT as BlobStorePut<HT>>::PutError,
409        >,
410    >,
411> + 'a
412where
413    BS: BlobStoreGet<HS> + 'a,
414    BT: BlobStorePut<HT> + 'a,
415    HS: 'static + HashProtocol,
416    HT: 'static + HashProtocol,
417    Handles: IntoIterator<Item = Value<Handle<HS, UnknownBlob>>> + 'a,
418    Handles::IntoIter: 'a,
419{
420    handles.into_iter().map(move |source_handle| {
421        let blob: Blob<UnknownBlob> = source.get(source_handle).map_err(TransferError::Load)?;
422
423        Ok((
424            source_handle,
425            (target.put(blob).map_err(TransferError::Store)?),
426        ))
427    })
428}
429
430/// Iterator that visits every blob handle reachable from a set of roots.
431pub struct ReachableHandles<'a, BS, H>
432where
433    BS: BlobStoreGet<H>,
434    H: 'static + HashProtocol,
435{
436    source: &'a BS,
437    queue: VecDeque<Value<Handle<H, UnknownBlob>>>,
438    visited: HashSet<[u8; VALUE_LEN]>,
439}
440
441impl<'a, BS, H> ReachableHandles<'a, BS, H>
442where
443    BS: BlobStoreGet<H>,
444    H: 'static + HashProtocol,
445{
446    fn new(source: &'a BS, roots: impl IntoIterator<Item = Value<Handle<H, UnknownBlob>>>) -> Self {
447        let mut queue = VecDeque::new();
448        for handle in roots {
449            queue.push_back(handle);
450        }
451
452        Self {
453            source,
454            queue,
455            visited: HashSet::new(),
456        }
457    }
458
459    fn enqueue_from_blob(&mut self, blob: &Blob<UnknownBlob>) {
460        let bytes = blob.bytes.as_ref();
461        let mut offset = 0usize;
462
463        while offset + VALUE_LEN <= bytes.len() {
464            let mut raw = [0u8; VALUE_LEN];
465            raw.copy_from_slice(&bytes[offset..offset + VALUE_LEN]);
466
467            if !self.visited.contains(&raw) {
468                let candidate = Value::<Handle<H, UnknownBlob>>::new(raw);
469                if self
470                    .source
471                    .get::<anybytes::Bytes, UnknownBlob>(candidate)
472                    .is_ok()
473                {
474                    self.queue.push_back(candidate);
475                }
476            }
477
478            offset += VALUE_LEN;
479        }
480    }
481}
482
483impl<'a, BS, H> Iterator for ReachableHandles<'a, BS, H>
484where
485    BS: BlobStoreGet<H>,
486    H: 'static + HashProtocol,
487{
488    type Item = Value<Handle<H, UnknownBlob>>;
489
490    fn next(&mut self) -> Option<Self::Item> {
491        while let Some(handle) = self.queue.pop_front() {
492            let raw = handle.raw;
493
494            if !self.visited.insert(raw) {
495                continue;
496            }
497
498            if let Ok(blob) = self.source.get(handle) {
499                self.enqueue_from_blob(&blob);
500            }
501
502            return Some(handle);
503        }
504
505        None
506    }
507}
508
509/// Create a breadth-first iterator over blob handles reachable from `roots`.
510pub fn reachable<'a, BS, H>(
511    source: &'a BS,
512    roots: impl IntoIterator<Item = Value<Handle<H, UnknownBlob>>>,
513) -> ReachableHandles<'a, BS, H>
514where
515    BS: BlobStoreGet<H>,
516    H: 'static + HashProtocol,
517{
518    ReachableHandles::new(source, roots)
519}
520
521/// Iterate over every 32-byte candidate in the value column of a [`TribleSet`].
522///
523/// This is a conservative conversion used when scanning metadata for potential
524/// blob handles. Each 32-byte chunk is treated as a `Handle<H, UnknownBlob>`.
525/// Callers can feed the resulting iterator into [`BlobStoreKeep::keep`] or other
526/// helpers that accept collections of handles.
527pub fn potential_handles<'a, H>(
528    set: &'a TribleSet,
529) -> impl Iterator<Item = Value<Handle<H, UnknownBlob>>> + 'a
530where
531    H: HashProtocol,
532{
533    set.vae.iter().map(|raw| {
534        let mut value = [0u8; VALUE_LEN];
535        value.copy_from_slice(&raw[0..VALUE_LEN]);
536        Value::<Handle<H, UnknownBlob>>::new(value)
537    })
538}
539
540/// An error that can occur when creating a commit.
541/// This error can be caused by a failure to store the content or metadata blobs.
542#[derive(Debug)]
543pub enum CreateCommitError<BlobErr: Error + Debug + Send + Sync + 'static> {
544    /// Failed to store the content blob.
545    ContentStorageError(BlobErr),
546    /// Failed to store the commit metadata blob.
547    CommitStorageError(BlobErr),
548}
549
550impl<BlobErr: Error + Debug + Send + Sync + 'static> fmt::Display for CreateCommitError<BlobErr> {
551    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
552        match self {
553            CreateCommitError::ContentStorageError(e) => write!(f, "Content storage failed: {e}"),
554            CreateCommitError::CommitStorageError(e) => {
555                write!(f, "Commit metadata storage failed: {e}")
556            }
557        }
558    }
559}
560
561impl<BlobErr: Error + Debug + Send + Sync + 'static> Error for CreateCommitError<BlobErr> {
562    fn source(&self) -> Option<&(dyn Error + 'static)> {
563        match self {
564            CreateCommitError::ContentStorageError(e) => Some(e),
565            CreateCommitError::CommitStorageError(e) => Some(e),
566        }
567    }
568}
569
570#[derive(Debug)]
571pub enum MergeError {
572    /// The merge failed because the workspaces have different base repos.
573    DifferentRepos(),
574}
575
576#[derive(Debug)]
577pub enum PushError<Storage: BranchStore<Blake3> + BlobStore<Blake3>> {
578    /// An error occurred while enumerating the branch storage branches.
579    StorageBranches(Storage::BranchesError),
580    /// An error occurred while creating a blob reader.
581    StorageReader(<Storage as BlobStore<Blake3>>::ReaderError),
582    /// An error occurred while reading metadata blobs.
583    StorageGet(
584        <<Storage as BlobStore<Blake3>>::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>,
585    ),
586    /// An error occurred while transferring blobs to the repository.
587    StoragePut(<Storage as BlobStorePut<Blake3>>::PutError),
588    /// An error occurred while updating the branch storage.
589    BranchUpdate(Storage::UpdateError),
590    /// Malformed branch metadata.
591    BadBranchMetadata(),
592    /// Merge failed while retrying a push.
593    MergeError(MergeError),
594}
595
596// Allow using the `?` operator to convert MergeError into PushError in
597// contexts where PushError is the function error type. This keeps call sites
598// succinct by avoiding manual mapping closures like
599// `.map_err(|e| PushError::MergeError(e))?`.
600impl<Storage> From<MergeError> for PushError<Storage>
601where
602    Storage: BranchStore<Blake3> + BlobStore<Blake3>,
603{
604    fn from(e: MergeError) -> Self {
605        PushError::MergeError(e)
606    }
607}
608
609// Note: we intentionally avoid generic `From` impls for storage-associated
610// error types because they can overlap with other blanket implementations
611// and lead to coherence conflicts. Call sites use explicit mapping via the
612// enum variant constructors (e.g. `map_err(PushError::StoragePut)`) where
613// needed which keeps conversions explicit and stable.
614
615#[derive(Debug)]
616pub enum BranchError<Storage>
617where
618    Storage: BranchStore<Blake3> + BlobStore<Blake3>,
619{
620    /// An error occurred while creating a blob reader.
621    StorageReader(<Storage as BlobStore<Blake3>>::ReaderError),
622    /// An error occurred while reading metadata blobs.
623    StorageGet(
624        <<Storage as BlobStore<Blake3>>::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>,
625    ),
626    /// An error occurred while storing blobs.
627    StoragePut(<Storage as BlobStorePut<Blake3>>::PutError),
628    /// An error occurred while retrieving branch heads.
629    BranchHead(Storage::HeadError),
630    /// An error occurred while updating the branch storage.
631    BranchUpdate(Storage::UpdateError),
632    /// The branch already exists.
633    AlreadyExists(),
634    /// The referenced base branch does not exist.
635    BranchNotFound(Id),
636}
637
638#[derive(Debug)]
639pub enum LookupError<Storage>
640where
641    Storage: BranchStore<Blake3> + BlobStore<Blake3>,
642{
643    StorageBranches(Storage::BranchesError),
644    BranchHead(Storage::HeadError),
645    StorageReader(<Storage as BlobStore<Blake3>>::ReaderError),
646    StorageGet(
647        <<Storage as BlobStore<Blake3>>::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>,
648    ),
649    /// Multiple branches were found with the given name.
650    NameConflict(Vec<Id>),
651    BadBranchMetadata(),
652}
653
654/// High-level wrapper combining a blob store and branch store into a usable
655/// repository API.
656///
657/// The `Repository` type exposes convenience methods for creating branches,
658/// committing data and pushing changes while delegating actual storage to the
659/// given `BlobStore` and `BranchStore` implementations.
660pub struct Repository<Storage: BlobStore<Blake3> + BranchStore<Blake3>> {
661    storage: Storage,
662    signing_key: SigningKey,
663    commit_metadata: MetadataHandle,
664}
665
666pub enum PullError<BranchStorageErr, BlobReaderErr, BlobStorageErr>
667where
668    BranchStorageErr: Error,
669    BlobReaderErr: Error,
670    BlobStorageErr: Error,
671{
672    /// The branch does not exist in the repository.
673    BranchNotFound(Id),
674    /// An error occurred while accessing the branch storage.
675    BranchStorage(BranchStorageErr),
676    /// An error occurred while creating a blob reader.
677    BlobReader(BlobReaderErr),
678    /// An error occurred while accessing the blob storage.
679    BlobStorage(BlobStorageErr),
680    /// The branch metadata is malformed or does not contain the expected fields.
681    BadBranchMetadata(),
682}
683
684impl<B, R, C> fmt::Debug for PullError<B, R, C>
685where
686    B: Error + fmt::Debug,
687    R: Error + fmt::Debug,
688    C: Error + fmt::Debug,
689{
690    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691        match self {
692            PullError::BranchNotFound(id) => f.debug_tuple("BranchNotFound").field(id).finish(),
693            PullError::BranchStorage(e) => f.debug_tuple("BranchStorage").field(e).finish(),
694            PullError::BlobReader(e) => f.debug_tuple("BlobReader").field(e).finish(),
695            PullError::BlobStorage(e) => f.debug_tuple("BlobStorage").field(e).finish(),
696            PullError::BadBranchMetadata() => f.debug_tuple("BadBranchMetadata").finish(),
697        }
698    }
699}
700
701impl<Storage> Repository<Storage>
702where
703    Storage: BlobStore<Blake3> + BranchStore<Blake3>,
704{
705    /// Creates a new repository with the given storage, signing key, and commit metadata.
706    ///
707    /// The `commit_metadata` TribleSet is stored as a blob in the repository and attached
708    /// to every commit created through this repository's workspaces.
709    pub fn new(
710        mut storage: Storage,
711        signing_key: SigningKey,
712        commit_metadata: TribleSet,
713    ) -> Result<Self, <Storage as BlobStorePut<Blake3>>::PutError> {
714        let commit_metadata = storage.put(commit_metadata)?;
715        Ok(Self {
716            storage,
717            signing_key,
718            commit_metadata,
719        })
720    }
721
722    /// Consume the repository and return the underlying storage backend.
723    ///
724    /// This is useful for callers that need to take ownership of the storage
725    /// (for example to call `close()` on a `Pile`) instead of letting the
726    /// repository drop it implicitly.
727    pub fn into_storage(self) -> Storage {
728        self.storage
729    }
730
731    /// Borrow the underlying storage backend.
732    pub fn storage(&self) -> &Storage {
733        &self.storage
734    }
735
736    /// Borrow the underlying storage backend mutably.
737    pub fn storage_mut(&mut self) -> &mut Storage {
738        &mut self.storage
739    }
740
741    /// Replace the repository signing key.
742    pub fn set_signing_key(&mut self, signing_key: SigningKey) {
743        self.signing_key = signing_key;
744    }
745
746    /// Returns the repository commit metadata handle.
747    pub fn commit_metadata(&self) -> MetadataHandle {
748        self.commit_metadata
749    }
750
751    /// Initializes a new branch in the repository.
752    /// Branches are the only mutable state in the repository,
753    /// and are used to represent the state of a commit chain at a specific point in time.
754    /// A branch must always point to a commit, and this function can be used to create a new branch.
755    ///
756    /// Creates a new branch in the repository.
757    /// This branch is a pointer to a specific commit in the repository.
758    /// The branch is created with name and is initialized to point to the opionally given commit.
759    /// The branch is signed by the branch signing key.
760    ///
761    /// # Parameters
762    /// * `branch_name` - Name of the new branch.
763    /// * `commit` - Commit to initialize the branch from.
764    pub fn create_branch(
765        &mut self,
766        branch_name: &str,
767        commit: Option<CommitHandle>,
768    ) -> Result<ExclusiveId, BranchError<Storage>> {
769        self.create_branch_with_key(branch_name, commit, self.signing_key.clone())
770    }
771
772    /// Same as [`branch_from`] but uses the provided signing key.
773    pub fn create_branch_with_key(
774        &mut self,
775        branch_name: &str,
776        commit: Option<CommitHandle>,
777        signing_key: SigningKey,
778    ) -> Result<ExclusiveId, BranchError<Storage>> {
779        let branch_id = genid();
780        let name_blob = branch_name.to_owned().to_blob();
781        let name_handle = name_blob.get_handle::<Blake3>();
782        self.storage
783            .put(name_blob)
784            .map_err(|e| BranchError::StoragePut(e))?;
785
786        let branch_set = if let Some(commit) = commit {
787            let reader = self
788                .storage
789                .reader()
790                .map_err(|e| BranchError::StorageReader(e))?;
791            let set: TribleSet = reader.get(commit).map_err(|e| BranchError::StorageGet(e))?;
792
793            branch::branch_metadata(&signing_key, *branch_id, name_handle, Some(set.to_blob()))
794        } else {
795            branch::branch_unsigned(*branch_id, name_handle, None)
796        };
797
798        let branch_blob = branch_set.to_blob();
799        let branch_handle = self
800            .storage
801            .put(branch_blob)
802            .map_err(|e| BranchError::StoragePut(e))?;
803        let push_result = self
804            .storage
805            .update(*branch_id, None, Some(branch_handle))
806            .map_err(|e| BranchError::BranchUpdate(e))?;
807
808        match push_result {
809            PushResult::Success() => Ok(branch_id),
810            PushResult::Conflict(_) => Err(BranchError::AlreadyExists()),
811        }
812    }
813
814    /// Pulls an existing branch using the repository's signing key.
815    /// The workspace inherits the repository default metadata if configured.
816    pub fn pull(
817        &mut self,
818        branch_id: Id,
819    ) -> Result<
820        Workspace<Storage>,
821        PullError<
822            Storage::HeadError,
823            Storage::ReaderError,
824            <Storage::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>,
825        >,
826    > {
827        self.pull_with_key(branch_id, self.signing_key.clone())
828    }
829
830    /// Same as [`pull`] but overrides the signing key.
831    pub fn pull_with_key(
832        &mut self,
833        branch_id: Id,
834        signing_key: SigningKey,
835    ) -> Result<
836        Workspace<Storage>,
837        PullError<
838            Storage::HeadError,
839            Storage::ReaderError,
840            <Storage::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>,
841        >,
842    > {
843        // 1. Get the branch metadata head from the branch store.
844        let base_branch_meta_handle = match self.storage.head(branch_id) {
845            Ok(Some(handle)) => handle,
846            Ok(None) => return Err(PullError::BranchNotFound(branch_id)),
847            Err(e) => return Err(PullError::BranchStorage(e)),
848        };
849        // 2. Get the current commit from the branch metadata.
850        let reader = self.storage.reader().map_err(PullError::BlobReader)?;
851        let base_branch_meta: TribleSet = match reader.get(base_branch_meta_handle) {
852            Ok(meta_set) => meta_set,
853            Err(e) => return Err(PullError::BlobStorage(e)),
854        };
855
856        let head_ = match find!(
857            (head_: Value<_>),
858            pattern!(&base_branch_meta, [{ head: ?head_ }])
859        )
860        .at_most_one()
861        {
862            Ok(Some((h,))) => Some(h),
863            Ok(None) => None,
864            Err(_) => return Err(PullError::BadBranchMetadata()),
865        };
866        // Create workspace with the current commit and base blobs.
867        let base_blobs = self.storage.reader().map_err(PullError::BlobReader)?;
868        Ok(Workspace {
869            base_blobs,
870            local_blobs: MemoryBlobStore::new(),
871            head: head_,
872            base_head: head_,
873            base_branch_id: branch_id,
874            base_branch_meta: base_branch_meta_handle,
875            signing_key,
876            commit_metadata: self.commit_metadata,
877        })
878    }
879
880    /// Pushes the workspace's new blobs and commit to the persistent repository.
881    /// This syncs the local BlobSet with the repository's BlobStore and performs
882    /// an atomic branch update (using the stored base_branch_meta).
883    pub fn push(&mut self, workspace: &mut Workspace<Storage>) -> Result<(), PushError<Storage>> {
884        // Retrying push: attempt a single push and, on conflict, merge the
885        // local workspace into the returned conflict workspace and retry.
886        // This implements the common push-merge-retry loop as a convenience
887        // wrapper around `try_push`.
888        while let Some(mut conflict_ws) = self.try_push(workspace)? {
889            // Keep the previous merge order: merge the caller's staged
890            // changes into the incoming conflict workspace. This preserves
891            // the semantic ordering of parents used in the merge commit.
892            conflict_ws.merge(workspace)?;
893
894            // Move the merged incoming workspace into the caller's workspace
895            // so the next try_push operates against the fresh branch state.
896            // Using assignment here is equivalent to `swap` but avoids
897            // retaining the previous `workspace` contents in the temp var.
898            *workspace = conflict_ws;
899        }
900
901        Ok(())
902    }
903
904    /// Single-attempt push: upload local blobs and try to update the branch
905    /// head once. Returns `Ok(None)` on success, or `Ok(Some(conflict_ws))`
906    /// when the branch was updated concurrently and the caller should merge.
907    pub fn try_push(
908        &mut self,
909        workspace: &mut Workspace<Storage>,
910    ) -> Result<Option<Workspace<Storage>>, PushError<Storage>> {
911        // 1. Sync `workspace.local_blobs` to repository's BlobStore.
912        let workspace_reader = workspace.local_blobs.reader().unwrap();
913        for handle in workspace_reader.blobs() {
914            let handle = handle.expect("infallible blob enumeration");
915            let blob: Blob<UnknownBlob> =
916                workspace_reader.get(handle).expect("infallible blob read");
917            self.storage.put(blob).map_err(PushError::StoragePut)?;
918        }
919
920        // 1.5 If the workspace's head did not change since the workspace was
921        // created, there's no commit to reference and therefore no branch
922        // metadata update is required. This avoids touching the branch store
923        // in the common case where only blobs were staged or nothing changed.
924        if workspace.base_head == workspace.head {
925            return Ok(None);
926        }
927
928        // 2. Create a new branch meta blob referencing the new workspace head.
929        let repo_reader = self.storage.reader().map_err(PushError::StorageReader)?;
930        let base_branch_meta: TribleSet = repo_reader
931            .get(workspace.base_branch_meta)
932            .map_err(PushError::StorageGet)?;
933
934        let Ok((branch_name,)) = find!(
935            (name: Value<Handle<Blake3, LongString>>),
936            pattern!(base_branch_meta, [{ crate::metadata::name: ?name }])
937        )
938        .exactly_one() else {
939            return Err(PushError::BadBranchMetadata());
940        };
941
942        let head_handle = workspace.head.ok_or(PushError::BadBranchMetadata())?;
943        let head_: TribleSet = repo_reader
944            .get(head_handle)
945            .map_err(PushError::StorageGet)?;
946
947        let branch_meta = branch_metadata(
948            &workspace.signing_key,
949            workspace.base_branch_id,
950            branch_name,
951            Some(head_.to_blob()),
952        );
953
954        let branch_meta_handle = self
955            .storage
956            .put(branch_meta)
957            .map_err(PushError::StoragePut)?;
958
959        // 3. Use CAS (comparing against workspace.base_branch_meta) to update the branch pointer.
960        let result = self
961            .storage
962            .update(
963                workspace.base_branch_id,
964                Some(workspace.base_branch_meta),
965                Some(branch_meta_handle),
966            )
967            .map_err(PushError::BranchUpdate)?;
968
969        match result {
970            PushResult::Success() => {
971                // Update workspace base pointers so subsequent pushes can detect
972                // that the workspace is already synchronized and avoid re-upload.
973                workspace.base_branch_meta = branch_meta_handle;
974                workspace.base_head = workspace.head;
975                // Refresh the workspace base blob reader to ensure newly
976                // uploaded blobs are visible to subsequent checkout operations.
977                workspace.base_blobs = self.storage.reader().map_err(PushError::StorageReader)?;
978                // Clear staged local blobs now that they have been uploaded and
979                // the branch metadata updated. This frees memory and prevents
980                // repeated uploads of the same staged blobs on subsequent pushes.
981                workspace.local_blobs = MemoryBlobStore::new();
982                Ok(None)
983            }
984            PushResult::Conflict(conflicting_meta) => {
985                let conflicting_meta = conflicting_meta.ok_or(PushError::BadBranchMetadata())?;
986
987                let repo_reader = self.storage.reader().map_err(PushError::StorageReader)?;
988                let branch_meta: TribleSet = repo_reader
989                    .get(conflicting_meta)
990                    .map_err(PushError::StorageGet)?;
991
992                let head_ = match find!((head_: Value<_>),
993                    pattern!(&branch_meta, [{ head: ?head_ }])
994                )
995                .at_most_one()
996                {
997                    Ok(Some((h,))) => Some(h),
998                    Ok(None) => None,
999                    Err(_) => return Err(PushError::BadBranchMetadata()),
1000                };
1001
1002                let conflict_ws = Workspace {
1003                    base_blobs: self.storage.reader().map_err(PushError::StorageReader)?,
1004                    local_blobs: MemoryBlobStore::new(),
1005                    head: head_,
1006                    base_head: head_,
1007                    base_branch_id: workspace.base_branch_id,
1008                    base_branch_meta: conflicting_meta,
1009                    signing_key: workspace.signing_key.clone(),
1010                    commit_metadata: workspace.commit_metadata,
1011                };
1012
1013                Ok(Some(conflict_ws))
1014            }
1015        }
1016    }
1017}
1018
1019type CommitHandle = Value<Handle<Blake3, SimpleArchive>>;
1020type MetadataHandle = Value<Handle<Blake3, SimpleArchive>>;
1021type CommitSet = PATCH<VALUE_LEN, IdentitySchema, ()>;
1022type BranchMetaHandle = Value<Handle<Blake3, SimpleArchive>>;
1023
1024/// The Workspace represents the mutable working area or "staging" state.
1025/// It was formerly known as `Head`. It is sent to worker threads,
1026/// modified (via commits, merges, etc.), and then merged back into the Repository.
1027pub struct Workspace<Blobs: BlobStore<Blake3>> {
1028    /// A local BlobStore that holds any new blobs (commits, trees, deltas) before they are synced.
1029    local_blobs: MemoryBlobStore<Blake3>,
1030    /// The blob storage base for the workspace.
1031    base_blobs: Blobs::Reader,
1032    /// The branch id this workspace is tracking; None for a detached workspace.
1033    base_branch_id: Id,
1034    /// The meta-handle corresponding to the base branch state used for CAS.
1035    base_branch_meta: BranchMetaHandle,
1036    /// Handle to the current commit in the working branch. `None` for an empty branch.
1037    head: Option<CommitHandle>,
1038    /// The branch head snapshot when this workspace was created (pull time).
1039    ///
1040    /// This allows `try_push` to cheaply detect whether the commit head has
1041    /// advanced since the workspace was created without querying the remote
1042    /// branch store.
1043    base_head: Option<CommitHandle>,
1044    /// Signing key used for commit/branch signing.
1045    signing_key: SigningKey,
1046    /// Metadata handle for commits created in this workspace.
1047    commit_metadata: MetadataHandle,
1048}
1049
1050impl<Blobs> fmt::Debug for Workspace<Blobs>
1051where
1052    Blobs: BlobStore<Blake3>,
1053    Blobs::Reader: fmt::Debug,
1054{
1055    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1056        f.debug_struct("Workspace")
1057            .field("local_blobs", &self.local_blobs)
1058            .field("base_blobs", &self.base_blobs)
1059            .field("base_branch_id", &self.base_branch_id)
1060            .field("base_branch_meta", &self.base_branch_meta)
1061            .field("base_head", &self.base_head)
1062            .field("head", &self.head)
1063            .field("commit_metadata", &self.commit_metadata)
1064            .finish()
1065    }
1066}
1067
1068/// Helper trait for [`Workspace::checkout`] specifying commit handles or ranges.
1069pub trait CommitSelector<Blobs: BlobStore<Blake3>> {
1070    fn select(
1071        self,
1072        ws: &mut Workspace<Blobs>,
1073    ) -> Result<
1074        CommitSet,
1075        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1076    >;
1077}
1078
1079/// Selector that returns a commit along with all of its ancestors.
1080pub struct Ancestors(pub CommitHandle);
1081
1082/// Convenience function to create an [`Ancestors`] selector.
1083pub fn ancestors(commit: CommitHandle) -> Ancestors {
1084    Ancestors(commit)
1085}
1086
1087/// Selector that returns the Nth ancestor along the first-parent chain.
1088pub struct NthAncestor(pub CommitHandle, pub usize);
1089
1090/// Convenience function to create an [`NthAncestor`] selector.
1091pub fn nth_ancestor(commit: CommitHandle, n: usize) -> NthAncestor {
1092    NthAncestor(commit, n)
1093}
1094
1095/// Selector that returns the direct parents of a commit.
1096pub struct Parents(pub CommitHandle);
1097
1098/// Convenience function to create a [`Parents`] selector.
1099pub fn parents(commit: CommitHandle) -> Parents {
1100    Parents(commit)
1101}
1102
1103/// Selector that returns commits reachable from either of two commits but not
1104/// both.
1105pub struct SymmetricDiff(pub CommitHandle, pub CommitHandle);
1106
1107/// Convenience function to create a [`SymmetricDiff`] selector.
1108pub fn symmetric_diff(a: CommitHandle, b: CommitHandle) -> SymmetricDiff {
1109    SymmetricDiff(a, b)
1110}
1111
1112/// Selector that returns the union of commits returned by two selectors.
1113pub struct Union<A, B> {
1114    left: A,
1115    right: B,
1116}
1117
1118/// Convenience function to create a [`Union`] selector.
1119pub fn union<A, B>(left: A, right: B) -> Union<A, B> {
1120    Union { left, right }
1121}
1122
1123/// Selector that returns the intersection of commits returned by two selectors.
1124pub struct Intersect<A, B> {
1125    left: A,
1126    right: B,
1127}
1128
1129/// Convenience function to create an [`Intersect`] selector.
1130pub fn intersect<A, B>(left: A, right: B) -> Intersect<A, B> {
1131    Intersect { left, right }
1132}
1133
1134/// Selector that returns commits from the left selector that are not also
1135/// returned by the right selector.
1136pub struct Difference<A, B> {
1137    left: A,
1138    right: B,
1139}
1140
1141/// Convenience function to create a [`Difference`] selector.
1142pub fn difference<A, B>(left: A, right: B) -> Difference<A, B> {
1143    Difference { left, right }
1144}
1145
1146/// Selector that returns commits with timestamps in the given inclusive range.
1147pub struct TimeRange(pub Epoch, pub Epoch);
1148
1149/// Convenience function to create a [`TimeRange`] selector.
1150pub fn time_range(start: Epoch, end: Epoch) -> TimeRange {
1151    TimeRange(start, end)
1152}
1153
1154/// Selector that filters commits returned by another selector.
1155pub struct Filter<S, F> {
1156    selector: S,
1157    filter: F,
1158}
1159
1160/// Convenience function to create a [`Filter`] selector.
1161pub fn filter<S, F>(selector: S, filter: F) -> Filter<S, F> {
1162    Filter { selector, filter }
1163}
1164
1165impl<Blobs> CommitSelector<Blobs> for CommitHandle
1166where
1167    Blobs: BlobStore<Blake3>,
1168{
1169    fn select(
1170        self,
1171        _ws: &mut Workspace<Blobs>,
1172    ) -> Result<
1173        CommitSet,
1174        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1175    > {
1176        let mut patch = CommitSet::new();
1177        patch.insert(&Entry::new(&self.raw));
1178        Ok(patch)
1179    }
1180}
1181
1182impl<Blobs> CommitSelector<Blobs> for Vec<CommitHandle>
1183where
1184    Blobs: BlobStore<Blake3>,
1185{
1186    fn select(
1187        self,
1188        _ws: &mut Workspace<Blobs>,
1189    ) -> Result<
1190        CommitSet,
1191        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1192    > {
1193        let mut patch = CommitSet::new();
1194        for handle in self {
1195            patch.insert(&Entry::new(&handle.raw));
1196        }
1197        Ok(patch)
1198    }
1199}
1200
1201impl<Blobs> CommitSelector<Blobs> for &[CommitHandle]
1202where
1203    Blobs: BlobStore<Blake3>,
1204{
1205    fn select(
1206        self,
1207        _ws: &mut Workspace<Blobs>,
1208    ) -> Result<
1209        CommitSet,
1210        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1211    > {
1212        let mut patch = CommitSet::new();
1213        for handle in self {
1214            patch.insert(&Entry::new(&handle.raw));
1215        }
1216        Ok(patch)
1217    }
1218}
1219
1220impl<Blobs> CommitSelector<Blobs> for Option<CommitHandle>
1221where
1222    Blobs: BlobStore<Blake3>,
1223{
1224    fn select(
1225        self,
1226        _ws: &mut Workspace<Blobs>,
1227    ) -> Result<
1228        CommitSet,
1229        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1230    > {
1231        let mut patch = CommitSet::new();
1232        if let Some(handle) = self {
1233            patch.insert(&Entry::new(&handle.raw));
1234        }
1235        Ok(patch)
1236    }
1237}
1238
1239impl<Blobs> CommitSelector<Blobs> for Ancestors
1240where
1241    Blobs: BlobStore<Blake3>,
1242{
1243    fn select(
1244        self,
1245        ws: &mut Workspace<Blobs>,
1246    ) -> Result<
1247        CommitSet,
1248        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1249    > {
1250        collect_reachable(ws, self.0)
1251    }
1252}
1253
1254impl<Blobs> CommitSelector<Blobs> for NthAncestor
1255where
1256    Blobs: BlobStore<Blake3>,
1257{
1258    fn select(
1259        self,
1260        ws: &mut Workspace<Blobs>,
1261    ) -> Result<
1262        CommitSet,
1263        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1264    > {
1265        let mut current = self.0;
1266        let mut remaining = self.1;
1267
1268        while remaining > 0 {
1269            let meta: TribleSet = ws.get(current).map_err(WorkspaceCheckoutError::Storage)?;
1270            let mut parents = find!((p: Value<_>), pattern!(&meta, [{ parent: ?p }]));
1271            let Some((p,)) = parents.next() else {
1272                return Ok(CommitSet::new());
1273            };
1274            current = p;
1275            remaining -= 1;
1276        }
1277
1278        let mut patch = CommitSet::new();
1279        patch.insert(&Entry::new(&current.raw));
1280        Ok(patch)
1281    }
1282}
1283
1284impl<Blobs> CommitSelector<Blobs> for Parents
1285where
1286    Blobs: BlobStore<Blake3>,
1287{
1288    fn select(
1289        self,
1290        ws: &mut Workspace<Blobs>,
1291    ) -> Result<
1292        CommitSet,
1293        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1294    > {
1295        let meta: TribleSet = ws.get(self.0).map_err(WorkspaceCheckoutError::Storage)?;
1296        let mut result = CommitSet::new();
1297        for (p,) in find!((p: Value<_>), pattern!(&meta, [{ parent: ?p }])) {
1298            result.insert(&Entry::new(&p.raw));
1299        }
1300        Ok(result)
1301    }
1302}
1303
1304impl<Blobs> CommitSelector<Blobs> for SymmetricDiff
1305where
1306    Blobs: BlobStore<Blake3>,
1307{
1308    fn select(
1309        self,
1310        ws: &mut Workspace<Blobs>,
1311    ) -> Result<
1312        CommitSet,
1313        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1314    > {
1315        let a = collect_reachable(ws, self.0)?;
1316        let b = collect_reachable(ws, self.1)?;
1317        let inter = a.intersect(&b);
1318        let mut union = a;
1319        union.union(b);
1320        Ok(union.difference(&inter))
1321    }
1322}
1323
1324impl<A, B, Blobs> CommitSelector<Blobs> for Union<A, B>
1325where
1326    A: CommitSelector<Blobs>,
1327    B: CommitSelector<Blobs>,
1328    Blobs: BlobStore<Blake3>,
1329{
1330    fn select(
1331        self,
1332        ws: &mut Workspace<Blobs>,
1333    ) -> Result<
1334        CommitSet,
1335        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1336    > {
1337        let mut left = self.left.select(ws)?;
1338        let right = self.right.select(ws)?;
1339        left.union(right);
1340        Ok(left)
1341    }
1342}
1343
1344impl<A, B, Blobs> CommitSelector<Blobs> for Intersect<A, B>
1345where
1346    A: CommitSelector<Blobs>,
1347    B: CommitSelector<Blobs>,
1348    Blobs: BlobStore<Blake3>,
1349{
1350    fn select(
1351        self,
1352        ws: &mut Workspace<Blobs>,
1353    ) -> Result<
1354        CommitSet,
1355        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1356    > {
1357        let left = self.left.select(ws)?;
1358        let right = self.right.select(ws)?;
1359        Ok(left.intersect(&right))
1360    }
1361}
1362
1363impl<A, B, Blobs> CommitSelector<Blobs> for Difference<A, B>
1364where
1365    A: CommitSelector<Blobs>,
1366    B: CommitSelector<Blobs>,
1367    Blobs: BlobStore<Blake3>,
1368{
1369    fn select(
1370        self,
1371        ws: &mut Workspace<Blobs>,
1372    ) -> Result<
1373        CommitSet,
1374        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1375    > {
1376        let left = self.left.select(ws)?;
1377        let right = self.right.select(ws)?;
1378        Ok(left.difference(&right))
1379    }
1380}
1381
1382impl<S, F, Blobs> CommitSelector<Blobs> for Filter<S, F>
1383where
1384    Blobs: BlobStore<Blake3>,
1385    S: CommitSelector<Blobs>,
1386    F: for<'x, 'y> Fn(&'x TribleSet, &'y TribleSet) -> bool,
1387{
1388    fn select(
1389        self,
1390        ws: &mut Workspace<Blobs>,
1391    ) -> Result<
1392        CommitSet,
1393        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1394    > {
1395        let patch = self.selector.select(ws)?;
1396        let mut result = CommitSet::new();
1397        let filter = self.filter;
1398        for raw in patch.iter() {
1399            let handle = Value::new(*raw);
1400            let meta: TribleSet = ws.get(handle).map_err(WorkspaceCheckoutError::Storage)?;
1401
1402            let Ok((content_handle,)) = find!(
1403                (c: Value<_>),
1404                pattern!(&meta, [{ content: ?c }])
1405            )
1406            .exactly_one() else {
1407                return Err(WorkspaceCheckoutError::BadCommitMetadata());
1408            };
1409
1410            let payload: TribleSet = ws
1411                .get(content_handle)
1412                .map_err(WorkspaceCheckoutError::Storage)?;
1413
1414            if filter(&meta, &payload) {
1415                result.insert(&Entry::new(raw));
1416            }
1417        }
1418        Ok(result)
1419    }
1420}
1421
1422/// Selector that yields commits touching a specific entity.
1423pub struct HistoryOf(pub Id);
1424
1425/// Convenience function to create a [`HistoryOf`] selector.
1426pub fn history_of(entity: Id) -> HistoryOf {
1427    HistoryOf(entity)
1428}
1429
1430impl<Blobs> CommitSelector<Blobs> for HistoryOf
1431where
1432    Blobs: BlobStore<Blake3>,
1433{
1434    fn select(
1435        self,
1436        ws: &mut Workspace<Blobs>,
1437    ) -> Result<
1438        CommitSet,
1439        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1440    > {
1441        let Some(head_) = ws.head else {
1442            return Ok(CommitSet::new());
1443        };
1444        let entity = self.0;
1445        filter(
1446            ancestors(head_),
1447            move |_: &TribleSet, payload: &TribleSet| payload.iter().any(|t| t.e() == &entity),
1448        )
1449        .select(ws)
1450    }
1451}
1452
1453// Generic range selectors: allow any selector type to be used as a range
1454// endpoint. We still walk the history reachable from the end selector but now
1455// stop descending a branch as soon as we encounter a commit produced by the
1456// start selector. This keeps the mechanics explicit—`start..end` literally
1457// walks from `end` until it hits `start`—while continuing to support selectors
1458// such as `Ancestors(...)` at either boundary.
1459
1460fn collect_reachable_from_patch<Blobs: BlobStore<Blake3>>(
1461    ws: &mut Workspace<Blobs>,
1462    patch: CommitSet,
1463) -> Result<
1464    CommitSet,
1465    WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1466> {
1467    let mut result = CommitSet::new();
1468    for raw in patch.iter() {
1469        let handle = Value::new(*raw);
1470        let reach = collect_reachable(ws, handle)?;
1471        result.union(reach);
1472    }
1473    Ok(result)
1474}
1475
1476fn collect_reachable_from_patch_until<Blobs: BlobStore<Blake3>>(
1477    ws: &mut Workspace<Blobs>,
1478    seeds: CommitSet,
1479    stop: &CommitSet,
1480) -> Result<
1481    CommitSet,
1482    WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1483> {
1484    let mut visited = HashSet::new();
1485    let mut stack: Vec<CommitHandle> = seeds.iter().map(|raw| Value::new(*raw)).collect();
1486    let mut result = CommitSet::new();
1487
1488    while let Some(commit) = stack.pop() {
1489        if !visited.insert(commit) {
1490            continue;
1491        }
1492
1493        if stop.get(&commit.raw).is_some() {
1494            continue;
1495        }
1496
1497        result.insert(&Entry::new(&commit.raw));
1498
1499        let meta: TribleSet = ws
1500            .local_blobs
1501            .reader()
1502            .unwrap()
1503            .get(commit)
1504            .or_else(|_| ws.base_blobs.get(commit))
1505            .map_err(WorkspaceCheckoutError::Storage)?;
1506
1507        for (p,) in find!((p: Value<_>,), pattern!(&meta, [{ parent: ?p }])) {
1508            stack.push(p);
1509        }
1510    }
1511
1512    Ok(result)
1513}
1514
1515impl<T, Blobs> CommitSelector<Blobs> for std::ops::Range<T>
1516where
1517    T: CommitSelector<Blobs>,
1518    Blobs: BlobStore<Blake3>,
1519{
1520    fn select(
1521        self,
1522        ws: &mut Workspace<Blobs>,
1523    ) -> Result<
1524        CommitSet,
1525        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1526    > {
1527        let end_patch = self.end.select(ws)?;
1528        let start_patch = self.start.select(ws)?;
1529
1530        collect_reachable_from_patch_until(ws, end_patch, &start_patch)
1531    }
1532}
1533
1534impl<T, Blobs> CommitSelector<Blobs> for std::ops::RangeFrom<T>
1535where
1536    T: CommitSelector<Blobs>,
1537    Blobs: BlobStore<Blake3>,
1538{
1539    fn select(
1540        self,
1541        ws: &mut Workspace<Blobs>,
1542    ) -> Result<
1543        CommitSet,
1544        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1545    > {
1546        let Some(head_) = ws.head else {
1547            return Ok(CommitSet::new());
1548        };
1549        let exclude_patch = self.start.select(ws)?;
1550
1551        let mut head_patch = CommitSet::new();
1552        head_patch.insert(&Entry::new(&head_.raw));
1553
1554        collect_reachable_from_patch_until(ws, head_patch, &exclude_patch)
1555    }
1556}
1557
1558impl<T, Blobs> CommitSelector<Blobs> for std::ops::RangeTo<T>
1559where
1560    T: CommitSelector<Blobs>,
1561    Blobs: BlobStore<Blake3>,
1562{
1563    fn select(
1564        self,
1565        ws: &mut Workspace<Blobs>,
1566    ) -> Result<
1567        CommitSet,
1568        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1569    > {
1570        let end_patch = self.end.select(ws)?;
1571        collect_reachable_from_patch(ws, end_patch)
1572    }
1573}
1574
1575impl<Blobs> CommitSelector<Blobs> for std::ops::RangeFull
1576where
1577    Blobs: BlobStore<Blake3>,
1578{
1579    fn select(
1580        self,
1581        ws: &mut Workspace<Blobs>,
1582    ) -> Result<
1583        CommitSet,
1584        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1585    > {
1586        let Some(head_) = ws.head else {
1587            return Ok(CommitSet::new());
1588        };
1589        collect_reachable(ws, head_)
1590    }
1591}
1592
1593impl<Blobs> CommitSelector<Blobs> for TimeRange
1594where
1595    Blobs: BlobStore<Blake3>,
1596{
1597    fn select(
1598        self,
1599        ws: &mut Workspace<Blobs>,
1600    ) -> Result<
1601        CommitSet,
1602        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1603    > {
1604        let Some(head_) = ws.head else {
1605            return Ok(CommitSet::new());
1606        };
1607        let start = self.0;
1608        let end = self.1;
1609        filter(
1610            ancestors(head_),
1611            move |meta: &TribleSet, _payload: &TribleSet| {
1612                if let Ok(Some((ts,))) =
1613                    find!((t: Value<_>), pattern!(meta, [{ timestamp: ?t }])).at_most_one()
1614                {
1615                    let (ts_start, ts_end): (Epoch, Epoch) =
1616                        crate::value::FromValue::from_value(&ts);
1617                    ts_start <= end && ts_end >= start
1618                } else {
1619                    false
1620                }
1621            },
1622        )
1623        .select(ws)
1624    }
1625}
1626
1627impl<Blobs: BlobStore<Blake3>> Workspace<Blobs> {
1628    /// Returns the branch id associated with this workspace.
1629    pub fn branch_id(&self) -> Id {
1630        self.base_branch_id
1631    }
1632
1633    /// Returns the current commit handle if one exists.
1634    pub fn head(&self) -> Option<CommitHandle> {
1635        self.head
1636    }
1637
1638    /// Returns the workspace metadata handle.
1639    pub fn metadata(&self) -> MetadataHandle {
1640        self.commit_metadata
1641    }
1642
1643    /// Adds a blob to the workspace's local blob store.
1644    /// Mirrors [`BlobStorePut::put`](crate::repo::BlobStorePut) for ease of use.
1645    pub fn put<S, T>(&mut self, item: T) -> Value<Handle<Blake3, S>>
1646    where
1647        S: BlobSchema + 'static,
1648        T: ToBlob<S>,
1649        Handle<Blake3, S>: ValueSchema,
1650    {
1651        self.local_blobs.put(item).expect("infallible blob put")
1652    }
1653
1654    /// Retrieves a blob from the workspace.
1655    ///
1656    /// The method first checks the workspace's local blob store and falls back
1657    /// to the base blob store if the blob is not found locally.
1658    pub fn get<T, S>(
1659        &mut self,
1660        handle: Value<Handle<Blake3, S>>,
1661    ) -> Result<T, <Blobs::Reader as BlobStoreGet<Blake3>>::GetError<<T as TryFromBlob<S>>::Error>>
1662    where
1663        S: BlobSchema + 'static,
1664        T: TryFromBlob<S>,
1665        Handle<Blake3, S>: ValueSchema,
1666    {
1667        self.local_blobs
1668            .reader()
1669            .unwrap()
1670            .get(handle)
1671            .or_else(|_| self.base_blobs.get(handle))
1672    }
1673
1674    /// Performs a commit in the workspace.
1675    /// This method creates a new commit blob (stored in the local blobset)
1676    /// and updates the current commit handle.
1677    pub fn commit(
1678        &mut self,
1679        content_: impl Into<TribleSet>,
1680        message_: &str,
1681    ) {
1682        let content_ = content_.into();
1683        self.commit_internal(content_, Some(self.commit_metadata), Some(message_));
1684    }
1685
1686    /// Like [`commit`](Self::commit) but attaches a one-off metadata handle
1687    /// instead of the repository default.
1688    pub fn commit_with_metadata(
1689        &mut self,
1690        content_: impl Into<TribleSet>,
1691        metadata_: MetadataHandle,
1692        message_: &str,
1693    ) {
1694        let content_ = content_.into();
1695        self.commit_internal(content_, Some(metadata_), Some(message_));
1696    }
1697
1698    fn commit_internal(
1699        &mut self,
1700        content_: TribleSet,
1701        metadata_handle: Option<MetadataHandle>,
1702        message_: Option<&str>,
1703    ) {
1704        // 1. Create a commit blob from the current head, content, metadata and the commit message.
1705        let content_blob = content_.to_blob();
1706        // If a message is provided, store it as a LongString blob and pass the handle.
1707        let message_handle = message_.map(|m| self.put(m.to_string()));
1708        let parents = self.head.iter().copied();
1709
1710        let commit_set = crate::repo::commit::commit_metadata(
1711            &self.signing_key,
1712            parents,
1713            message_handle,
1714            Some(content_blob.clone()),
1715            metadata_handle,
1716        );
1717        // 2. Store the content and commit blobs in `self.local_blobs`.
1718        let _ = self
1719            .local_blobs
1720            .put(content_blob)
1721            .expect("failed to put content blob");
1722        let commit_handle = self
1723            .local_blobs
1724            .put(commit_set)
1725            .expect("failed to put commit blob");
1726        // 3. Update `self.head` to point to the new commit.
1727        self.head = Some(commit_handle);
1728    }
1729
1730    /// Merge another workspace (or its commit state) into this one.
1731    ///
1732    /// Notes on semantics
1733    /// - This operation will copy the *staged* blobs created in `other`
1734    ///   (i.e., `other.local_blobs`) into `self.local_blobs`, then create a
1735    ///   merge commit whose parents are `self.head` and `other.head`.
1736    /// - The merge does *not* automatically import the entire base history
1737    ///   reachable from `other`'s head. If the incoming parent commits
1738    ///   reference blobs that do not exist in this repository's storage,
1739    ///   reading those commits later will fail until the missing blobs are
1740    ///   explicitly imported (for example via `repo::transfer(reachable(...))`).
1741    /// - This design keeps merge permissive and leaves cross-repository blob
1742    ///   import as an explicit user action.
1743    pub fn merge(&mut self, other: &mut Workspace<Blobs>) -> Result<CommitHandle, MergeError> {
1744        // 1. Transfer all blobs from the other workspace to self.local_blobs.
1745        let other_local = other.local_blobs.reader().unwrap();
1746        for r in other_local.blobs() {
1747            let handle = r.expect("infallible blob enumeration");
1748            let blob: Blob<UnknownBlob> = other_local.get(handle).expect("infallible blob read");
1749
1750            // Store the blob in the local workspace's blob store.
1751            self.local_blobs.put(blob).expect("infallible blob put");
1752        }
1753        // 2. Compute a merge commit from self.current_commit and other.current_commit.
1754        let parents = self.head.iter().copied().chain(other.head.iter().copied());
1755        let merge_commit = commit_metadata(
1756            &self.signing_key,
1757            parents,
1758            None, // No message for the merge commit
1759            None, // No content blob for the merge commit
1760            None, // No metadata blob for the merge commit
1761        );
1762        // 3. Store the merge commit in self.local_blobs.
1763        let commit_handle = self
1764            .local_blobs
1765            .put(merge_commit)
1766            .expect("failed to put merge commit blob");
1767        self.head = Some(commit_handle);
1768
1769        Ok(commit_handle)
1770    }
1771
1772    /// Create a merge commit that ties this workspace's current head and an
1773    /// arbitrary other commit (already present in the underlying blob store)
1774    /// together without requiring another `Workspace` instance.
1775    ///
1776    /// This does not attach any content to the merge commit.
1777    pub fn merge_commit(
1778        &mut self,
1779        other: Value<Handle<Blake3, SimpleArchive>>,
1780    ) -> Result<CommitHandle, MergeError> {
1781        // Validate that `other` can be loaded from either local or base blobs.
1782        // If it cannot be loaded we still proceed with the merge; dereference
1783        // failures will surface later when reading history. Callers should
1784        // ensure the reachable blobs were imported beforehand (e.g. by
1785        // combining `reachable` with `transfer`).
1786
1787        let parents = self.head.iter().copied().chain(Some(other));
1788        let merge_commit = commit_metadata(&self.signing_key, parents, None, None, None);
1789        let commit_handle = self
1790            .local_blobs
1791            .put(merge_commit)
1792            .expect("failed to put merge commit blob");
1793        self.head = Some(commit_handle);
1794        Ok(commit_handle)
1795    }
1796
1797    /// Returns the combined [`TribleSet`] for the specified commits.
1798    ///
1799    /// Each commit handle must reference a commit blob stored either in the
1800    /// workspace's local blob store or the repository's base store. The
1801    /// associated content blobs are loaded and unioned together. An error is
1802    /// returned if any commit or content blob is missing or malformed.
1803    fn checkout_commits<I>(
1804        &mut self,
1805        commits: I,
1806    ) -> Result<
1807        TribleSet,
1808        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1809    >
1810    where
1811        I: IntoIterator<Item = CommitHandle>,
1812    {
1813        let local = self.local_blobs.reader().unwrap();
1814        let mut result = TribleSet::new();
1815        for commit in commits {
1816            let meta: TribleSet = local
1817                .get(commit)
1818                .or_else(|_| self.base_blobs.get(commit))
1819                .map_err(WorkspaceCheckoutError::Storage)?;
1820
1821            // Some commits (for example merge commits) intentionally do not
1822            // carry a content blob. Treat those as no-ops during checkout so
1823            // callers can request ancestor ranges without failing when a
1824            // merge commit is encountered.
1825            let content_opt =
1826                match find!((c: Value<_>), pattern!(&meta, [{ content: ?c }])).at_most_one() {
1827                    Ok(Some((c,))) => Some(c),
1828                    Ok(None) => None,
1829                    Err(_) => return Err(WorkspaceCheckoutError::BadCommitMetadata()),
1830                };
1831
1832            if let Some(c) = content_opt {
1833                let set: TribleSet = local
1834                    .get(c)
1835                    .or_else(|_| self.base_blobs.get(c))
1836                    .map_err(WorkspaceCheckoutError::Storage)?;
1837                result += set;
1838            } else {
1839                // No content for this commit (e.g. merge-only commit); skip it.
1840                continue;
1841            }
1842        }
1843        Ok(result)
1844    }
1845
1846    fn checkout_commits_metadata<I>(
1847        &mut self,
1848        commits: I,
1849    ) -> Result<
1850        TribleSet,
1851        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1852    >
1853    where
1854        I: IntoIterator<Item = CommitHandle>,
1855    {
1856        let local = self.local_blobs.reader().unwrap();
1857        let mut result = TribleSet::new();
1858        for commit in commits {
1859            let meta: TribleSet = local
1860                .get(commit)
1861                .or_else(|_| self.base_blobs.get(commit))
1862                .map_err(WorkspaceCheckoutError::Storage)?;
1863
1864            let metadata_opt =
1865                match find!((c: Value<_>), pattern!(&meta, [{ metadata: ?c }])).at_most_one() {
1866                    Ok(Some((c,))) => Some(c),
1867                    Ok(None) => None,
1868                    Err(_) => return Err(WorkspaceCheckoutError::BadCommitMetadata()),
1869                };
1870
1871            if let Some(c) = metadata_opt {
1872                let set: TribleSet = local
1873                    .get(c)
1874                    .or_else(|_| self.base_blobs.get(c))
1875                    .map_err(WorkspaceCheckoutError::Storage)?;
1876                result += set;
1877            }
1878        }
1879        Ok(result)
1880    }
1881
1882    fn checkout_commits_with_metadata<I>(
1883        &mut self,
1884        commits: I,
1885    ) -> Result<
1886        (TribleSet, TribleSet),
1887        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1888    >
1889    where
1890        I: IntoIterator<Item = CommitHandle>,
1891    {
1892        let local = self.local_blobs.reader().unwrap();
1893        let mut data = TribleSet::new();
1894        let mut metadata_set = TribleSet::new();
1895        for commit in commits {
1896            let meta: TribleSet = local
1897                .get(commit)
1898                .or_else(|_| self.base_blobs.get(commit))
1899                .map_err(WorkspaceCheckoutError::Storage)?;
1900
1901            let content_opt =
1902                match find!((c: Value<_>), pattern!(&meta, [{ content: ?c }])).at_most_one() {
1903                    Ok(Some((c,))) => Some(c),
1904                    Ok(None) => None,
1905                    Err(_) => return Err(WorkspaceCheckoutError::BadCommitMetadata()),
1906                };
1907
1908            if let Some(c) = content_opt {
1909                let set: TribleSet = local
1910                    .get(c)
1911                    .or_else(|_| self.base_blobs.get(c))
1912                    .map_err(WorkspaceCheckoutError::Storage)?;
1913                data += set;
1914            }
1915
1916            let metadata_opt =
1917                match find!((c: Value<_>), pattern!(&meta, [{ metadata: ?c }])).at_most_one() {
1918                    Ok(Some((c,))) => Some(c),
1919                    Ok(None) => None,
1920                    Err(_) => return Err(WorkspaceCheckoutError::BadCommitMetadata()),
1921                };
1922
1923            if let Some(c) = metadata_opt {
1924                let set: TribleSet = local
1925                    .get(c)
1926                    .or_else(|_| self.base_blobs.get(c))
1927                    .map_err(WorkspaceCheckoutError::Storage)?;
1928                metadata_set += set;
1929            }
1930        }
1931        Ok((data, metadata_set))
1932    }
1933
1934    /// Returns the combined [`TribleSet`] for the specified commits or commit
1935    /// ranges. `spec` can be a single [`CommitHandle`], an iterator of handles
1936    /// or any of the standard range types over `CommitHandle`.
1937    pub fn checkout<R>(
1938        &mut self,
1939        spec: R,
1940    ) -> Result<
1941        TribleSet,
1942        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1943    >
1944    where
1945        R: CommitSelector<Blobs>,
1946    {
1947        let patch = spec.select(self)?;
1948        let commits = patch.iter().map(|raw| Value::new(*raw));
1949        self.checkout_commits(commits)
1950    }
1951
1952    /// Returns the combined metadata [`TribleSet`] for the specified commits.
1953    /// Commits without metadata handles contribute an empty set.
1954    pub fn checkout_metadata<R>(
1955        &mut self,
1956        spec: R,
1957    ) -> Result<
1958        TribleSet,
1959        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1960    >
1961    where
1962        R: CommitSelector<Blobs>,
1963    {
1964        let patch = spec.select(self)?;
1965        let commits = patch.iter().map(|raw| Value::new(*raw));
1966        self.checkout_commits_metadata(commits)
1967    }
1968
1969    /// Returns the combined data and metadata [`TribleSet`] for the specified commits.
1970    /// Metadata is loaded from each commit's `metadata` handle, when present.
1971    pub fn checkout_with_metadata<R>(
1972        &mut self,
1973        spec: R,
1974    ) -> Result<
1975        (TribleSet, TribleSet),
1976        WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
1977    >
1978    where
1979        R: CommitSelector<Blobs>,
1980    {
1981        let patch = spec.select(self)?;
1982        let commits = patch.iter().map(|raw| Value::new(*raw));
1983        self.checkout_commits_with_metadata(commits)
1984    }
1985}
1986
1987#[derive(Debug)]
1988pub enum WorkspaceCheckoutError<GetErr: Error> {
1989    /// Error retrieving blobs from storage.
1990    Storage(GetErr),
1991    /// Commit metadata is malformed or ambiguous.
1992    BadCommitMetadata(),
1993}
1994
1995impl<E: Error + fmt::Debug> fmt::Display for WorkspaceCheckoutError<E> {
1996    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1997        match self {
1998            WorkspaceCheckoutError::Storage(e) => write!(f, "storage error: {e}"),
1999            WorkspaceCheckoutError::BadCommitMetadata() => {
2000                write!(f, "commit metadata malformed")
2001            }
2002        }
2003    }
2004}
2005
2006impl<E: Error + fmt::Debug> Error for WorkspaceCheckoutError<E> {}
2007
2008fn collect_reachable<Blobs: BlobStore<Blake3>>(
2009    ws: &mut Workspace<Blobs>,
2010    from: CommitHandle,
2011) -> Result<
2012    CommitSet,
2013    WorkspaceCheckoutError<<Blobs::Reader as BlobStoreGet<Blake3>>::GetError<UnarchiveError>>,
2014> {
2015    let mut visited = HashSet::new();
2016    let mut stack = vec![from];
2017    let mut result = CommitSet::new();
2018
2019    while let Some(commit) = stack.pop() {
2020        if !visited.insert(commit) {
2021            continue;
2022        }
2023        result.insert(&Entry::new(&commit.raw));
2024
2025        let meta: TribleSet = ws
2026            .local_blobs
2027            .reader()
2028            .unwrap()
2029            .get(commit)
2030            .or_else(|_| ws.base_blobs.get(commit))
2031            .map_err(WorkspaceCheckoutError::Storage)?;
2032
2033        for (p,) in find!((p: Value<_>,), pattern!(&meta, [{ parent: ?p }])) {
2034            stack.push(p);
2035        }
2036    }
2037
2038    Ok(result)
2039}