Coverage Report

Created: 2026-08-05 07:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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>