girt 0.11.0

An incremental, idiomatic Rust implementation of Git
Documentation
use std::sync::atomic::{AtomicBool, Ordering};

use crate::{
    EntryMode, ObjectId, ObjectKind, ObjectReadError, Objects, ReadLimits, TreeChange, TreeValue,
};

/// Owned blob sides loaded from a tree change, ready to borrow in [`super::diff`].
///
/// Missing sides become empty buffers. Existence and modes remain on the original [`TreeChange`]:
/// an absent side and a present empty blob compare as unchanged content. Symlinks are compared as
/// their stored target bytes, without dereferencing a filesystem path.
#[derive(Debug)]
pub struct BlobContent {
    /// Original payload, or empty bytes for an addition.
    pub old: Vec<u8>,
    /// Replacement payload, or empty bytes for a deletion.
    pub new: Vec<u8>,
}

impl BlobContent {
    /// Loads and verifies both blob sides without changing storage.
    ///
    /// Accepts regular, executable and symlink modes. Tree/gitlink modes are rejected before any
    /// read. Both sides are read even for equal IDs, so dangling or wrong-kind objects cannot be
    /// hidden by an identity skip. `read` bounds each individual read; `max_bytes` additionally
    /// bounds the combined retained payload bytes, constraining decoding before each read.
    /// Memory also includes [`Objects`]' pack snapshot and per-read reconstruction workspace.
    ///
    /// Storage is synchronous and subject to [`Objects::read`]'s trust/pruning assumptions.
    /// Cancellation is checked before mode validation, between reads and after the final read;
    /// an individual storage read cannot be interrupted. A read error takes precedence over a
    /// cancellation observed after it. Callers own scheduling and may borrow the returned buffers
    /// for repeated pure comparisons under different binary policies.
    ///
    /// # Errors
    ///
    /// Returns [`ContentReadError`] for unsupported modes, missing/wrong-kind objects, storage or
    /// decoding failures, or cancellation. Errors retain the offending side's ID or mode; the
    /// caller retains path and side context in `change`. No partial pair or writes are produced.
    ///
    /// ```
    /// use std::sync::atomic::AtomicBool;
    /// use girt::{EntryMode, InitKind, ObjectFormat, Repository, Tree, TreeEntry};
    /// use girt::content_diff::BlobContent;
    /// let directory = tempfile::tempdir()?;
    /// let repo = Repository::init(ObjectFormat::Sha1, directory.path().join("r"), InitKind::Bare)?;
    /// let loose = repo.loose_objects();
    /// let id = loose.write_blob(b"added\n")?;
    /// let tree = loose.write_tree(&Tree::new(ObjectFormat::Sha1,
    ///     vec![TreeEntry { name: b"file".to_vec(), mode: EntryMode::Blob, id }])?)?;
    /// let objects = repo.objects(Default::default())?;
    /// let cancel = AtomicBool::new(false);
    /// let changes = objects.compare_trees(None, Some(tree), Default::default(), &cancel)?;
    ///
    /// let content = BlobContent::read(&objects, &changes[0], Default::default(), 1024, &cancel)?;
    /// assert!(content.old.is_empty());
    /// assert_eq!(content.new, b"added\n");
    /// assert!(changes[0].old.is_none()); // Keep existence independently from the empty content buffer.
    /// # Ok::<(), Box<dyn std::error::Error>>(())
    /// ```
    pub fn read(
        objects: &Objects,
        change: &TreeChange,
        read: ReadLimits,
        max_bytes: usize,
        cancel: &AtomicBool,
    ) -> Result<Self, ContentReadError> {
        check(cancel)?;
        validate_mode(change.old)?;
        validate_mode(change.new)?;
        let old = read_side(objects, change.old, read, max_bytes)?;
        check(cancel)?;
        let new = read_side(objects, change.new, read, max_bytes - old.len())?;
        check(cancel)?;
        Ok(Self { old, new })
    }
}

fn check(cancel: &AtomicBool) -> Result<(), ContentReadError> {
    if cancel.load(Ordering::Relaxed) {
        Err(ContentReadError::Cancelled)
    } else {
        Ok(())
    }
}

fn validate_mode(value: Option<TreeValue>) -> Result<(), ContentReadError> {
    if let Some(value) = value {
        match value.mode {
            EntryMode::Blob | EntryMode::Executable | EntryMode::Symlink => (),
            mode => return Err(ContentReadError::UnsupportedMode(mode)),
        }
    }
    Ok(())
}

fn read_side(
    objects: &Objects,
    value: Option<TreeValue>,
    mut limits: ReadLimits,
    remaining: usize,
) -> Result<Vec<u8>, ContentReadError> {
    let Some(value) = value else {
        return Ok(Vec::new());
    };
    limits.max_object_bytes = limits.max_object_bytes.min(remaining);
    let id = value.id;
    let object = objects
        .read(id, limits)
        .map_err(|source| ContentReadError::Read { id, source })?
        .ok_or(ContentReadError::Missing(id))?;
    if object.kind() != ObjectKind::Blob {
        return Err(ContentReadError::NotBlob {
            id,
            actual: object.kind(),
        });
    }
    Ok(object.into_data())
}

/// Loading blob sides failed without returning a partial pair or modifying storage.
///
/// Keep the original [`TreeChange`] when displaying this error: the error preserves the failing
/// identity/mode and storage cause, while the change supplies the byte path and old/new sides.
/// An unsupported gitlink requires submodule presentation rather than a text diff; a missing or
/// corrupt blob needs recovery, not an empty buffer substituted for the failed read.
///
/// ```
/// use girt::{InitKind, ObjectFormat, Repository};
/// use std::sync::atomic::AtomicBool;
/// use girt::{EntryMode, Tree, TreeEntry};
/// use girt::content_diff::{BlobContent, ContentReadError};
///
/// let directory = tempfile::tempdir()?;
/// let repo = Repository::init(ObjectFormat::Sha1, directory.path().join("r"), InitKind::Bare)?;
///
/// let loose = repo.loose_objects();
/// let entry = |name: &[u8], id, mode| TreeEntry { name: name.to_vec(), id, mode };
/// let tree = |entries| loose.write_tree(&Tree::new(ObjectFormat::Sha1, entries)?);
/// let cancel = AtomicBool::new(false);
///
/// let missing = girt::ObjectId::for_blob(ObjectFormat::Sha1, b"not stored");
/// let root = tree(vec![entry(b"file", missing, EntryMode::Blob)])?;
/// let objects = repo.objects(Default::default())?;
/// let changes = objects.compare_trees(None, Some(root), Default::default(), &cancel)?;
/// // Structural comparison succeeded without reading the leaf blob.
/// let error = BlobContent::read(&objects, &changes[0], Default::default(), 1024, &cancel)
///     .unwrap_err();
/// assert!(matches!(error, ContentReadError::Missing(id) if id == missing));
/// assert_eq!(changes[0].path, b"file"); // Retain this path alongside the storage diagnostic.
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ContentReadError {
    /// Trees and gitlinks are not blob payloads.
    #[error("content diff does not support entry mode {0:?}")]
    UnsupportedMode(EntryMode),
    /// A referenced blob is absent.
    #[error("missing blob {0}")]
    Missing(ObjectId),
    /// A leaf ID resolves to a non-blob object.
    #[error("expected blob {id}, found {actual:?}")]
    NotBlob {
        /// Referenced blob ID.
        id: ObjectId,
        /// Verified object kind actually found.
        actual: ObjectKind,
    },
    /// Storage, identity verification or decoding bounds failed.
    #[error("reading blob {id}")]
    Read {
        /// Blob being read.
        id: ObjectId,
        /// Underlying error, including the effective combined payload limit.
        #[source]
        source: ObjectReadError,
    },
    /// The caller's cancellation flag was observed between synchronous reads.
    #[error("content read cancelled")]
    Cancelled,
}

#[cfg(test)]
mod tests;