/src/gitoxide/gix-merge/src/blob/pipeline.rs
Line | Count | Source |
1 | | use std::{ |
2 | | io::Read, |
3 | | path::{Path, PathBuf}, |
4 | | }; |
5 | | |
6 | | use bstr::BStr; |
7 | | use gix_filter::{ |
8 | | driver::apply::{Delay, MaybeDelayed}, |
9 | | pipeline::convert::{ToGitOutcome, ToWorktreeOutcome, to_worktree}, |
10 | | }; |
11 | | use gix_object::tree::EntryKind; |
12 | | |
13 | | use super::{Pipeline, ResourceKind}; |
14 | | |
15 | | /// Options for use in a [`Pipeline`]. |
16 | | #[derive(Default, Clone, PartialEq, Eq, Debug, Hash, Ord, PartialOrd)] |
17 | | pub struct Options { |
18 | | /// The amount of bytes that an object has to reach before being treated as binary. |
19 | | /// These objects will not be queried, nor will their data be processed in any way. |
20 | | /// If `0`, no file is ever considered binary due to their size. |
21 | | /// |
22 | | /// Note that for files stored in `git`, what counts is their stored, decompressed size, |
23 | | /// thus `git-lfs` files would typically not be considered binary unless one explicitly sets |
24 | | /// them. |
25 | | /// However, if they are to be retrieved from the worktree, the worktree size is what matters, |
26 | | /// even though that also might be a `git-lfs` file which is small in Git. |
27 | | pub large_file_threshold_bytes: u64, |
28 | | } |
29 | | |
30 | | /// The specific way to convert a resource. |
31 | | #[derive(Default, Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] |
32 | | pub enum Mode { |
33 | | /// Prepare resources as they are stored in `git`. |
34 | | /// |
35 | | /// This is naturally the case when object-ids are used, but a conversion is needed |
36 | | /// when data is read from a worktree. |
37 | | #[default] |
38 | | ToGit, |
39 | | /// For sources that are object-ids, convert them to what *would* be stored in the worktree, |
40 | | /// and back to what *would* be stored in Git. |
41 | | /// |
42 | | /// Sources that are located in a worktree are merely converted to what *would* be stored in Git. |
43 | | /// |
44 | | /// This is useful to prevent merge conflicts due to inconcistent whitespace. |
45 | | Renormalize, |
46 | | } |
47 | | |
48 | | /// A way to access roots for different kinds of resources that are possibly located and accessible in a worktree. |
49 | | #[derive(Clone, Debug, Default)] |
50 | | pub struct WorktreeRoots { |
51 | | /// The worktree root where the current (or our) version of the resource is present. |
52 | | pub current_root: Option<PathBuf>, |
53 | | /// The worktree root where the other (or their) version of the resource is present. |
54 | | pub other_root: Option<PathBuf>, |
55 | | /// The worktree root where containing the resource of the common ancestor of our and their version. |
56 | | pub common_ancestor_root: Option<PathBuf>, |
57 | | } |
58 | | |
59 | | impl WorktreeRoots { |
60 | | /// Return the root path for the given `kind` |
61 | 0 | pub fn by_kind(&self, kind: ResourceKind) -> Option<&Path> { |
62 | 0 | match kind { |
63 | 0 | ResourceKind::CurrentOrOurs => self.current_root.as_deref(), |
64 | 0 | ResourceKind::CommonAncestorOrBase => self.common_ancestor_root.as_deref(), |
65 | 0 | ResourceKind::OtherOrTheirs => self.other_root.as_deref(), |
66 | | } |
67 | 0 | } |
68 | | |
69 | | /// Return `true` if all worktree roots are unset. |
70 | 0 | pub fn is_unset(&self) -> bool { |
71 | 0 | self.current_root.is_none() && self.other_root.is_none() && self.common_ancestor_root.is_none() |
72 | 0 | } |
73 | | } |
74 | | |
75 | | /// Lifecycle |
76 | | impl Pipeline { |
77 | | /// Create a new instance of a pipeline which produces blobs suitable for merging. |
78 | | /// |
79 | | /// `roots` allow to read worktree files directly, and `worktree_filter` is used |
80 | | /// to transform object database data directly. |
81 | | /// `options` are used to further configure the way we act. |
82 | 0 | pub fn new(roots: WorktreeRoots, worktree_filter: gix_filter::Pipeline, options: Options) -> Self { |
83 | 0 | Pipeline { |
84 | 0 | roots, |
85 | 0 | filter: worktree_filter, |
86 | 0 | options, |
87 | 0 | path: Default::default(), |
88 | 0 | } |
89 | 0 | } |
90 | | } |
91 | | |
92 | | /// Access |
93 | | impl Pipeline {} |
94 | | |
95 | | /// Data as returned by [`Pipeline::convert_to_mergeable()`]. |
96 | | #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)] |
97 | | pub enum Data { |
98 | | /// The data to use for merging was written into the buffer that was passed during the call to [`Pipeline::convert_to_mergeable()`]. |
99 | | Buffer, |
100 | | /// The file or blob is above the big-file threshold and cannot be processed. |
101 | | /// |
102 | | /// In this state, the file cannot be merged. |
103 | | TooLarge { |
104 | | /// The size of the object prior to performing any filtering or as it was found on disk. |
105 | | /// |
106 | | /// Note that technically, the size isn't always representative of the same 'state' of the |
107 | | /// content, as once it can be the size of the blob in git, and once it's the size of file |
108 | | /// in the worktree - both can differ a lot depending on filters. |
109 | | size: u64, |
110 | | }, |
111 | | } |
112 | | |
113 | | /// |
114 | | pub mod convert_to_mergeable { |
115 | | use std::collections::TryReserveError; |
116 | | |
117 | | use bstr::BString; |
118 | | use gix_object::tree::EntryKind; |
119 | | |
120 | | /// The error returned by [Pipeline::convert_to_mergeable()](super::Pipeline::convert_to_mergeable()). |
121 | | #[derive(Debug, thiserror::Error)] |
122 | | #[expect(missing_docs)] |
123 | | pub enum Error { |
124 | | #[error("Entry at '{rela_path}' must be regular file or symlink, but was {actual:?}")] |
125 | | InvalidEntryKind { rela_path: BString, actual: EntryKind }, |
126 | | #[error("Entry at '{rela_path}' could not be read as symbolic link")] |
127 | | ReadLink { rela_path: BString, source: std::io::Error }, |
128 | | #[error("Entry at '{rela_path}' could not be opened for reading or read from")] |
129 | | OpenOrRead { rela_path: BString, source: std::io::Error }, |
130 | | #[error("Entry at '{rela_path}' could not be copied from a filter process to a memory buffer")] |
131 | | StreamCopy { rela_path: BString, source: std::io::Error }, |
132 | | #[error(transparent)] |
133 | | FindObject(#[from] gix_object::find::existing_object::Error), |
134 | | #[error(transparent)] |
135 | | ConvertToWorktree(#[from] gix_filter::pipeline::convert::to_worktree::Error), |
136 | | #[error(transparent)] |
137 | | ConvertToGit(#[from] gix_filter::pipeline::convert::to_git::Error), |
138 | | #[error("Memory allocation failed")] |
139 | | OutOfMemory(#[from] TryReserveError), |
140 | | } |
141 | | } |
142 | | |
143 | | /// Conversion |
144 | | impl Pipeline { |
145 | | /// Convert the object at `id`, `mode`, `rela_path` and `kind`, providing access to `attributes` and `objects`. |
146 | | /// The resulting merge-able data is written into `out`, if it's not too large. |
147 | | /// The returned [`Data`] contains information on how to use `out`, which will be cleared if it is `None`, indicating |
148 | | /// that no object was found at the location *on disk* - it's always an error to provide an object ID that doesn't exist |
149 | | /// in the object database. |
150 | | /// |
151 | | /// `attributes` must be returning the attributes at `rela_path` and is used for obtaining worktree filter settings, |
152 | | /// and `objects` must be usable if `kind` is a resource in the object database, |
153 | | /// i.e. if no worktree root is available. It's notable that if a worktree root is present for `kind`, |
154 | | /// then a `rela_path` is used to access it on disk. |
155 | | /// |
156 | | /// If `id` [is null](gix_hash::ObjectId::is_null()) or the file in question doesn't exist in the worktree in case |
157 | | /// [a root](WorktreeRoots) is present, then `out` will be left cleared and the output data will be `None`. |
158 | | /// This is useful to simplify the calling code as empty buffers signal that nothing is there. |
159 | | /// |
160 | | /// Note that `mode` is trusted, and we will not re-validate that the entry in the worktree actually is of that mode. |
161 | | /// Only blobs are allowed. |
162 | | /// |
163 | | /// Use `convert` to control what kind of the resource will be produced. |
164 | | #[expect(clippy::too_many_arguments)] |
165 | 0 | pub fn convert_to_mergeable( |
166 | 0 | &mut self, |
167 | 0 | id: &gix_hash::oid, |
168 | 0 | mode: EntryKind, |
169 | 0 | rela_path: &BStr, |
170 | 0 | kind: ResourceKind, |
171 | 0 | attributes: &mut dyn FnMut(&BStr, &mut gix_filter::attributes::search::Outcome), |
172 | 0 | objects: &dyn gix_object::FindObjectOrHeader, |
173 | 0 | convert: Mode, |
174 | 0 | out: &mut Vec<u8>, |
175 | 0 | ) -> Result<Option<Data>, convert_to_mergeable::Error> { |
176 | 0 | if !matches!(mode, EntryKind::Blob | EntryKind::BlobExecutable) { |
177 | 0 | return Err(convert_to_mergeable::Error::InvalidEntryKind { |
178 | 0 | rela_path: rela_path.to_owned(), |
179 | 0 | actual: mode, |
180 | 0 | }); |
181 | 0 | } |
182 | | |
183 | 0 | out.clear(); |
184 | 0 | match self.roots.by_kind(kind) { |
185 | 0 | Some(root) => { |
186 | 0 | self.path.clear(); |
187 | 0 | self.path.push(root); |
188 | 0 | self.path.push(gix_path::from_bstr(rela_path)); |
189 | 0 | let size_in_bytes = (self.options.large_file_threshold_bytes > 0) |
190 | 0 | .then(|| { |
191 | 0 | none_if_missing(self.path.metadata().map(|md| md.len())).map_err(|err| { |
192 | 0 | convert_to_mergeable::Error::OpenOrRead { |
193 | 0 | rela_path: rela_path.to_owned(), |
194 | 0 | source: err, |
195 | 0 | } |
196 | 0 | }) |
197 | 0 | }) |
198 | 0 | .transpose()?; |
199 | 0 | let data = match size_in_bytes { |
200 | 0 | Some(None) => None, // missing as identified by the size check |
201 | 0 | Some(Some(size)) if size > self.options.large_file_threshold_bytes => Some(Data::TooLarge { size }), |
202 | | _ => { |
203 | 0 | let file = none_if_missing(std::fs::File::open(&self.path)).map_err(|err| { |
204 | 0 | convert_to_mergeable::Error::OpenOrRead { |
205 | 0 | rela_path: rela_path.to_owned(), |
206 | 0 | source: err, |
207 | 0 | } |
208 | 0 | })?; |
209 | | |
210 | 0 | if let Some(file) = file { |
211 | 0 | match convert { |
212 | | Mode::ToGit | Mode::Renormalize => { |
213 | 0 | let res = self.filter.convert_to_git( |
214 | 0 | file, |
215 | 0 | gix_path::from_bstr(rela_path).as_ref(), |
216 | 0 | attributes, |
217 | 0 | &mut |buf| { |
218 | 0 | if convert == Mode::Renormalize { |
219 | 0 | Ok(None) |
220 | | } else { |
221 | 0 | objects.try_find(id, buf).map(|obj| obj.map(|_| ())) |
222 | | } |
223 | 0 | }, |
224 | 0 | )?; |
225 | | |
226 | 0 | match res { |
227 | 0 | ToGitOutcome::Unchanged(mut file) => { |
228 | 0 | file.read_to_end(out).map_err(|err| { |
229 | 0 | convert_to_mergeable::Error::OpenOrRead { |
230 | 0 | rela_path: rela_path.to_owned(), |
231 | 0 | source: err, |
232 | 0 | } |
233 | 0 | })?; |
234 | | } |
235 | 0 | ToGitOutcome::Process(mut stream) => { |
236 | 0 | stream.read_to_end(out).map_err(|err| { |
237 | 0 | convert_to_mergeable::Error::OpenOrRead { |
238 | 0 | rela_path: rela_path.to_owned(), |
239 | 0 | source: err, |
240 | 0 | } |
241 | 0 | })?; |
242 | | } |
243 | 0 | ToGitOutcome::Buffer(buf) => { |
244 | 0 | out.clear(); |
245 | 0 | out.try_reserve(buf.len())?; |
246 | 0 | out.extend_from_slice(buf); |
247 | | } |
248 | | } |
249 | | } |
250 | | } |
251 | | |
252 | 0 | Some(Data::Buffer) |
253 | | } else { |
254 | 0 | None |
255 | | } |
256 | | } |
257 | | }; |
258 | 0 | Ok(data) |
259 | | } |
260 | | None => { |
261 | 0 | let data = if id.is_null() { |
262 | 0 | None |
263 | | } else { |
264 | 0 | let header = objects |
265 | 0 | .try_header(id) |
266 | 0 | .map_err(gix_object::find::existing_object::Error::Find)? |
267 | 0 | .ok_or_else(|| gix_object::find::existing_object::Error::NotFound { oid: id.to_owned() })?; |
268 | 0 | let is_binary = self.options.large_file_threshold_bytes > 0 |
269 | 0 | && header.size > self.options.large_file_threshold_bytes; |
270 | 0 | let data = if is_binary { |
271 | 0 | Data::TooLarge { size: header.size } |
272 | | } else { |
273 | 0 | objects |
274 | 0 | .try_find(id, out) |
275 | 0 | .map_err(gix_object::find::existing_object::Error::Find)? |
276 | 0 | .ok_or_else(|| gix_object::find::existing_object::Error::NotFound { oid: id.to_owned() })?; |
277 | | |
278 | 0 | if convert == Mode::Renormalize { |
279 | | { |
280 | 0 | let res = self.filter.convert_to_worktree( |
281 | 0 | out, |
282 | 0 | rela_path, |
283 | 0 | attributes, |
284 | 0 | to_worktree::Options { |
285 | 0 | can_delay: Delay::Forbid, |
286 | 0 | unknown_encoding: to_worktree::UnknownEncoding::Fail, |
287 | 0 | }, |
288 | 0 | )?; |
289 | | |
290 | 0 | match res { |
291 | 0 | ToWorktreeOutcome::Unchanged(_) => {} |
292 | 0 | ToWorktreeOutcome::Buffer(src) => { |
293 | 0 | out.clear(); |
294 | 0 | out.try_reserve(src.len())?; |
295 | 0 | out.extend_from_slice(src); |
296 | | } |
297 | 0 | ToWorktreeOutcome::Process(MaybeDelayed::Immediate(mut stream)) => { |
298 | 0 | std::io::copy(&mut stream, out).map_err(|err| { |
299 | 0 | convert_to_mergeable::Error::StreamCopy { |
300 | 0 | rela_path: rela_path.to_owned(), |
301 | 0 | source: err, |
302 | 0 | } |
303 | 0 | })?; |
304 | | } |
305 | | ToWorktreeOutcome::Process(MaybeDelayed::Delayed(_)) => { |
306 | 0 | unreachable!("we prohibit this") |
307 | | } |
308 | | } |
309 | | } |
310 | | |
311 | 0 | let res = self.filter.convert_to_git( |
312 | 0 | &**out, |
313 | 0 | &gix_path::from_bstr(rela_path), |
314 | 0 | attributes, |
315 | 0 | &mut |_buf| Ok(None), |
316 | 0 | )?; |
317 | | |
318 | 0 | match res { |
319 | 0 | ToGitOutcome::Unchanged(_) => {} |
320 | 0 | ToGitOutcome::Process(mut stream) => { |
321 | 0 | stream |
322 | 0 | .read_to_end(out) |
323 | 0 | .map_err(|err| convert_to_mergeable::Error::OpenOrRead { |
324 | 0 | rela_path: rela_path.to_owned(), |
325 | 0 | source: err, |
326 | 0 | })?; |
327 | | } |
328 | 0 | ToGitOutcome::Buffer(buf) => { |
329 | 0 | out.clear(); |
330 | 0 | out.try_reserve(buf.len())?; |
331 | 0 | out.extend_from_slice(buf); |
332 | | } |
333 | | } |
334 | 0 | } |
335 | | |
336 | 0 | Data::Buffer |
337 | | }; |
338 | 0 | Some(data) |
339 | | }; |
340 | 0 | Ok(data) |
341 | | } |
342 | | } |
343 | 0 | } |
344 | | } |
345 | | |
346 | 0 | fn none_if_missing<T>(res: std::io::Result<T>) -> std::io::Result<Option<T>> { |
347 | 0 | match res { |
348 | 0 | Ok(data) => Ok(Some(data)), |
349 | 0 | Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), |
350 | 0 | Err(err) => Err(err), |
351 | | } |
352 | 0 | } Unexecuted instantiation: gix_merge::blob::pipeline::none_if_missing::<std::fs::File> Unexecuted instantiation: gix_merge::blob::pipeline::none_if_missing::<u64> |