mkit_core/worktree/blob.rs
1//! Blob-content I/O: reading back what the worktree walker stored.
2//!
3//! A file's content lives either in a single inline [`Blob`](crate::object::Blob)
4//! or behind a [`ChunkedBlob`](crate::object::ChunkedBlob) manifest. [`LoadedBlob`]
5//! is the one-read-per-object view over both shapes; [`read_blob`] is the one-shot
6//! full-content convenience on top of it. Extracted from `worktree.rs` (#633) —
7//! this cluster reconstructs content and has nothing to do with walking a worktree.
8
9use std::borrow::Cow;
10use std::io;
11
12use crate::hash::Hash;
13use crate::object::{ChunkedBlob, Object};
14
15use super::{WorktreeError, WorktreeResult};
16
17/// Reassemble the full byte content of a `Blob` or `ChunkedBlob` object
18/// addressed by `hash`.
19///
20/// A plain [`Blob`](crate::object::Blob) returns its bytes directly. A
21/// [`ChunkedBlob`] manifest is reassembled
22/// by concatenating each referenced chunk (every chunk must itself be a
23/// `Blob`). This is the shared counterpart to [`store_file_object`](super::store_file_object) and
24/// backs `mkit cat`, `mkit diff`, conflict rendering, and blame so they
25/// all reconstruct large-file content the same way.
26///
27/// # Errors
28/// - [`WorktreeError::Store`] if `hash` or any chunk is missing.
29/// - [`WorktreeError::Io`] if `hash` (or a chunk) resolves to an object
30/// that is neither a `Blob` nor a `ChunkedBlob` of `Blob`s.
31/// - [`WorktreeError::Object`] if the concatenated chunks do not total
32/// the manifest's `total_size` (SPEC-OBJECTS §7).
33pub fn read_blob<S: crate::store::ObjectSource + ?Sized>(
34 store: &S,
35 hash: &Hash,
36) -> WorktreeResult<Vec<u8>> {
37 LoadedBlob::load(store, hash)?.into_content(store)
38}
39
40/// A blob's top-level object, read from the store exactly once and held so
41/// a caller needing several views of the same blob — byte length, bounded
42/// prefix, full content — pays a single top-level `read_object` instead of
43/// one per view. `diff --stat` is the motivating caller: its text-vs-binary
44/// sniff needs a prefix, then either the length (binary row) or the full
45/// content (text row), and taking each view through a separate store-level
46/// read re-read (and re-hash-verified) the same object two times per
47/// changed file, a per-entry cost that dominates a many-small-files
48/// diffstat (#624).
49///
50/// Chunk objects are still read on demand: [`Self::len`] reads none,
51/// [`Self::prefix`] reads only leading chunks, [`Self::into_content`]
52/// reads them all.
53///
54/// One read stays undeduped by design: [`Self::prefix`] on a chunked blob
55/// reads the leading chunk(s) to sniff, and [`Self::into_content`] reads
56/// every chunk (including that same leading one) to reassemble — a caller
57/// doing both, as `diff --stat` does, reads the first chunk twice. Caching
58/// it would need either interior mutability or a consuming-prefix API, to
59/// save an O(1) read on an O(n-chunks) path; not worth it (#624).
60#[derive(Debug)]
61pub enum LoadedBlob {
62 /// An inline [`Blob`](crate::object::Blob): its full content, already
63 /// in hand.
64 Inline(Vec<u8>),
65 /// A [`ChunkedBlob`] manifest: content lives in chunk objects, read
66 /// only when a view needs them.
67 Chunked(ChunkedBlob),
68}
69
70impl LoadedBlob {
71 /// Read the top-level object addressed by `hash` — one store read.
72 ///
73 /// # Errors
74 /// - [`WorktreeError::Store`] if `hash` is missing.
75 /// - [`WorktreeError::Io`] if `hash` resolves to an object that is
76 /// neither a `Blob` nor a `ChunkedBlob`.
77 pub fn load<S: crate::store::ObjectSource + ?Sized>(
78 store: &S,
79 hash: &Hash,
80 ) -> WorktreeResult<Self> {
81 match store.read_object(hash)? {
82 Object::Blob(b) => Ok(Self::Inline(b.data)),
83 Object::ChunkedBlob(manifest) => Ok(Self::Chunked(manifest)),
84 other => Err(not_a_blob("object", hash, &other)),
85 }
86 }
87
88 /// Content byte length from what is already in hand — an inline blob's
89 /// data length, a chunked blob's manifest `total_size` — no chunk
90 /// reads. `total_size` is trustworthy without re-verifying against the
91 /// chunks: every reassembly path enforces it via
92 /// [`ChunkedBlob::check_reassembled_size`], so a manifest with a wrong
93 /// `total_size` cannot have been durably written (#550).
94 #[must_use]
95 pub fn len(&self) -> u64 {
96 match self {
97 Self::Inline(data) => data.len() as u64,
98 Self::Chunked(manifest) => manifest.total_size,
99 }
100 }
101
102 /// Whether the content is zero bytes long.
103 #[must_use]
104 pub fn is_empty(&self) -> bool {
105 self.len() == 0
106 }
107
108 /// Up to `max_len` leading content bytes. Inline data is borrowed (no
109 /// copy, no reads); a chunked blob reads only as many leading chunks
110 /// as it takes to cover `max_len`, then stops — one chunk in practice,
111 /// since every chunk but possibly the last is at least
112 /// [`crate::chunker::MIN_SIZE`] bytes, well above the sniff windows
113 /// callers use (e.g. [`crate::ops::diff::BINARY_SNIFF_LEN`]).
114 ///
115 /// # Errors
116 /// - [`WorktreeError::Store`] if a needed chunk is missing.
117 /// - [`WorktreeError::Io`] if a needed chunk is not a `Blob`.
118 pub fn prefix<S: crate::store::ObjectSource + ?Sized>(
119 &self,
120 store: &S,
121 max_len: usize,
122 ) -> WorktreeResult<Cow<'_, [u8]>> {
123 match self {
124 Self::Inline(data) => Ok(Cow::Borrowed(&data[..data.len().min(max_len)])),
125 Self::Chunked(manifest) => {
126 let cap = usize::try_from(manifest.total_size)
127 .unwrap_or(max_len)
128 .min(max_len);
129 let mut data = Vec::with_capacity(cap);
130 for chunk in &manifest.chunks {
131 if data.len() >= max_len {
132 break;
133 }
134 data.extend_from_slice(&read_chunk(store, chunk)?);
135 }
136 data.truncate(max_len);
137 Ok(Cow::Owned(data))
138 }
139 }
140 }
141
142 /// The full content: inline data as-is (no further reads), a chunked
143 /// blob reassembled by reading every chunk, with the result length
144 /// enforced against the manifest's `total_size` (SPEC-OBJECTS §7).
145 ///
146 /// # Errors
147 /// - [`WorktreeError::Store`] if a chunk is missing.
148 /// - [`WorktreeError::Io`] if a chunk is not a `Blob`.
149 /// - [`WorktreeError::Object`] if the concatenated chunks do not total
150 /// the manifest's `total_size`.
151 pub fn into_content<S: crate::store::ObjectSource + ?Sized>(
152 self,
153 store: &S,
154 ) -> WorktreeResult<Vec<u8>> {
155 match self {
156 Self::Inline(data) => Ok(data),
157 Self::Chunked(manifest) => {
158 let mut data =
159 Vec::with_capacity(usize::try_from(manifest.total_size).unwrap_or(0));
160 for chunk in &manifest.chunks {
161 data.extend_from_slice(&read_chunk(store, chunk)?);
162 }
163 manifest.check_reassembled_size(data.len())?;
164 Ok(data)
165 }
166 }
167 }
168
169 /// The empty blob: what a diff side with no object (an add's old side,
170 /// a delete's new side) loads as. Zero length, empty prefix, empty
171 /// content — no store reads.
172 #[must_use]
173 pub fn empty() -> Self {
174 Self::Inline(Vec::new())
175 }
176}
177
178/// One chunk of a [`ChunkedBlob`]: must deserialize to an inline `Blob`.
179fn read_chunk<S: crate::store::ObjectSource + ?Sized>(
180 store: &S,
181 chunk: &Hash,
182) -> WorktreeResult<Vec<u8>> {
183 match store.read_object(chunk)? {
184 Object::Blob(b) => Ok(b.data),
185 other => Err(not_a_blob("chunk", chunk, &other)),
186 }
187}
188
189/// The "expected a blob, found something else" error shared by every
190/// [`LoadedBlob`] read path; `what` is `"object"` for a top-level hash and
191/// `"chunk"` for a manifest chunk, preserving the historical wording of
192/// both messages.
193fn not_a_blob(what: &str, hash: &Hash, got: &Object) -> WorktreeError {
194 WorktreeError::Io(io::Error::other(format!(
195 "{what} {} is not a blob (got {})",
196 crate::hash::to_hex(hash),
197 got.object_type().name()
198 )))
199}