Skip to main content

gix_diff/blob/
pipeline.rs

1use std::{
2    io::{Read, Write},
3    path::{Path, PathBuf},
4    process::{Command, Stdio},
5};
6
7use bstr::{BStr, ByteSlice};
8use gix_error::{ErrorExt, ExnMessageResult, ResultExt, message, not_found};
9use gix_filter::{
10    driver::apply::{Delay, MaybeDelayed},
11    pipeline::convert::{ToGitOutcome, ToWorktreeOutcome, to_worktree},
12};
13use gix_object::tree::EntryKind;
14
15use crate::blob::{Driver, Pipeline, ResourceKind};
16
17/// A way to access roots for different kinds of resources that are possibly located and accessible in a worktree.
18#[derive(Clone, Debug, Default)]
19pub struct WorktreeRoots {
20    /// A place where the source of a rewrite, rename or copy, or generally the previous version of resources, are located.
21    pub old_root: Option<PathBuf>,
22    /// A place where the destination of a rewrite, rename or copy, or generally the new version of resources, are located.
23    pub new_root: Option<PathBuf>,
24}
25
26/// Access
27impl WorktreeRoots {
28    /// Return the root path for the given `kind`
29    pub fn by_kind(&self, kind: ResourceKind) -> Option<&Path> {
30        match kind {
31            ResourceKind::OldOrSource => self.old_root.as_deref(),
32            ResourceKind::NewOrDestination => self.new_root.as_deref(),
33        }
34    }
35
36    /// Return `true` if all worktree roots are unset.
37    pub fn is_unset(&self) -> bool {
38        self.new_root.is_none() && self.old_root.is_none()
39    }
40}
41
42/// Data as part of an [Outcome].
43#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)]
44pub enum Data {
45    /// The data to use for diffing was written into the buffer that was passed during the call to [`Pipeline::convert_to_diffable()`].
46    Buffer {
47        /// If `true`, a [binary to text filter](Driver::binary_to_text_command) was used to obtain the buffer,
48        /// making it a derived value.
49        ///
50        /// Applications should check for this to avoid treating the buffer content as (original) resource content.
51        is_derived: bool,
52    },
53    /// The size that the binary blob had at the given revision, without having applied filters, as it's either
54    /// considered binary or above the big-file threshold.
55    ///
56    /// In this state, the binary file cannot be diffed.
57    Binary {
58        /// The size of the object prior to performing any filtering or as it was found on disk.
59        ///
60        /// Note that technically, the size isn't always representative of the same 'state' of the
61        /// content, as once it can be the size of the blob in git, and once it's the size of file
62        /// in the worktree.
63        size: u64,
64    },
65}
66
67/// The outcome returned by [Pipeline::convert_to_diffable()](super::Pipeline::convert_to_diffable()).
68#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)]
69pub struct Outcome {
70    /// If available, an index into the `drivers` field to access more diff-related information of the driver for items
71    /// at the given path, as previously determined by git-attributes.
72    ///
73    /// Note that drivers are queried even if there is no object available.
74    pub driver_index: Option<usize>,
75    /// The data itself, suitable for diffing, and if the object or worktree item is present at all.
76    pub data: Option<Data>,
77}
78
79/// Options for use in a [`Pipeline`].
80#[derive(Default, Clone, Copy, PartialEq, Eq, Debug, Hash, Ord, PartialOrd)]
81pub struct Options {
82    /// The amount of bytes that an object has to reach before being treated as binary.
83    /// These objects will not be queried, nor will their data be processed in any way.
84    /// If `0`, no file is ever considered binary due to their size.
85    ///
86    /// Note that for files stored in `git`, what counts is their stored, decompressed size,
87    /// thus `git-lfs` files would typically not be considered binary unless one explicitly sets
88    /// them
89    pub large_file_threshold_bytes: u64,
90    /// Capabilities of the file system which affect how we read worktree files.
91    pub fs: gix_fs::Capabilities,
92}
93
94/// The specific way to convert a resource.
95#[derive(Default, Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
96pub enum Mode {
97    /// Always prepare the version of the resource as it would be in the work-tree, and
98    /// apply binary-to-text filters if present.
99    ///
100    /// This is typically free for resources in the worktree, and will apply filters to resources in the
101    /// object database.
102    #[default]
103    ToWorktreeAndBinaryToText,
104    /// Prepare the version of the resource as it would be in the work-tree if
105    /// binary-to-text filters are present (and apply them), or use the version in `git` otherwise.
106    ToGitUnlessBinaryToTextIsPresent,
107    /// Always prepare resources as they are stored in `git`.
108    ///
109    /// This is usually fastest, even though resources in the worktree needed to be converted files.
110    ToGit,
111}
112
113impl Mode {
114    fn to_worktree(self) -> bool {
115        matches!(
116            self,
117            Mode::ToGitUnlessBinaryToTextIsPresent | Mode::ToWorktreeAndBinaryToText
118        )
119    }
120
121    fn to_git(self) -> bool {
122        matches!(self, Mode::ToGitUnlessBinaryToTextIsPresent | Mode::ToGit)
123    }
124}
125
126/// Lifecycle
127impl Pipeline {
128    /// Create a new instance of a pipeline which produces blobs suitable for diffing. `roots` allow to read worktree files directly, otherwise
129    /// `worktree_filter` is used to transform object database data directly. `drivers` further configure individual paths.
130    /// `options` are used to further configure the way we act..
131    pub fn new(
132        roots: WorktreeRoots,
133        worktree_filter: gix_filter::Pipeline,
134        mut drivers: Vec<super::Driver>,
135        options: Options,
136    ) -> Self {
137        drivers.sort_by(|a, b| a.name.cmp(&b.name));
138        Pipeline {
139            roots,
140            worktree_filter,
141            drivers,
142            options,
143            attrs: {
144                let mut out = gix_filter::attributes::search::Outcome::default();
145                out.initialize_with_selection(&Default::default(), Some("diff"));
146                out
147            },
148            path: Default::default(),
149        }
150    }
151}
152
153/// Access
154impl Pipeline {
155    /// Return all drivers that this instance was initialized with.
156    ///
157    /// They are sorted by [`name`](Driver::name) to support binary searches.
158    pub fn drivers(&self) -> &[super::Driver] {
159        &self.drivers
160    }
161}
162
163/// Conversion
164impl Pipeline {
165    /// Convert the object at `id`, `mode`, `rela_path` and `kind`, providing access to `attributes` and `objects`.
166    /// The resulting diff-able data is written into `out`, assuming it's not too large. The returned [`Outcome`]
167    /// contains information on how to use `out`, or if it's filled at all.
168    ///
169    /// `attributes` must be returning the attributes at `rela_path`, and `objects` must be usable if `kind` is
170    /// a resource in the object database, i.e. has no worktree root available.
171    ///
172    /// If a [worktree root](WorktreeRoots) is present, read the file from there. A non-null `id` is used to look up
173    /// its index version for line-ending conversion. Null IDs are never looked up in `objects`.
174    /// If the worktree file doesn't exist, or no root is present and `id` [is null](gix_hash::ObjectId::is_null()),
175    /// then `out` will be left cleared and [Outcome::data] will be `None`.
176    ///
177    /// Note that `mode` is trusted, and we will not re-validate that the entry in the worktree actually is of that mode.
178    ///
179    /// Use `convert` to control what kind of the resource will be produced.
180    ///
181    /// ### About Tempfiles
182    ///
183    /// When querying from the object database and a binary and a [binary-to-text](Driver::binary_to_text_command) is set,
184    /// a temporary file will be created to serve as input for the converter program, containing the worktree-data that
185    /// exactly as it would be present in the worktree if checked out.
186    ///
187    /// As these files are ultimately named tempfiles, they will be leaked unless the [gix_tempfile] is configured with
188    /// a signal handler. If they leak, they would remain in the system's `$TMP` directory.
189    #[expect(clippy::too_many_arguments)]
190    pub fn convert_to_diffable(
191        &mut self,
192        id: &gix_hash::oid,
193        mode: EntryKind,
194        rela_path: &BStr,
195        kind: ResourceKind,
196        attributes: &mut dyn FnMut(&BStr, &mut gix_filter::attributes::search::Outcome),
197        objects: &dyn gix_object::FindObjectOrHeader,
198        convert: Mode,
199        out: &mut Vec<u8>,
200    ) -> ExnMessageResult<Outcome> {
201        let is_symlink = match mode {
202            EntryKind::Link => true,
203            EntryKind::Blob | EntryKind::BlobExecutable => false,
204            _ => {
205                return Err(
206                    message!("Entry at '{rela_path}' must be regular file or symlink, but was {mode:?}").raise(),
207                );
208            }
209        };
210
211        out.clear();
212        attributes(rela_path, &mut self.attrs);
213        let attr = self.attrs.iter_selected().next().expect("pre-initialized with 'diff'");
214        let driver_index = attr
215            .assignment
216            .state
217            .as_bstr()
218            .and_then(|name| self.drivers.binary_search_by(|d| d.name.as_bstr().cmp(name)).ok());
219        let driver = driver_index.map(|idx| &self.drivers[idx]);
220        let mut is_binary = if let Some(driver) = driver {
221            driver
222                .is_binary
223                .map(|is_binary| is_binary && driver.binary_to_text_command.is_none())
224        } else {
225            attr.assignment.state.is_unset().then_some(true)
226        };
227        match self.roots.by_kind(kind) {
228            Some(root) => {
229                self.path.clear();
230                self.path.push(root);
231                self.path.push(gix_path::from_bstr(rela_path));
232                let data = if is_symlink {
233                    if !self.options.fs.symlink {
234                        return Err(message!(
235                            "Entry at '{rela_path}' is declared as symlink but symlinks are disabled via core.symlinks"
236                        )
237                        .raise());
238                    }
239                    let target = none_if_missing(std::fs::read_link(&self.path))
240                        .or_raise(|| message!("Entry at '{rela_path}' could not be read as symbolic link"))?;
241                    target.map(|target| {
242                        out.extend_from_slice(gix_path::into_bstr(target).as_ref());
243                        Data::Buffer { is_derived: false }
244                    })
245                } else {
246                    let need_size_only = is_binary == Some(true);
247                    let size_in_bytes = (need_size_only
248                        || (is_binary != Some(false) && self.options.large_file_threshold_bytes > 0))
249                        .then(|| {
250                            none_if_missing(self.path.metadata().map(|md| md.len())).or_raise(|| {
251                                message!("Entry at '{rela_path}' could not be opened for reading or read from")
252                            })
253                        })
254                        .transpose()?;
255                    match size_in_bytes {
256                        Some(None) => None, // missing as identified by the size check
257                        Some(Some(size)) if size > self.options.large_file_threshold_bytes || need_size_only => {
258                            Some(Data::Binary { size })
259                        }
260                        _ => {
261                            match driver
262                                .filter(|_| convert.to_worktree())
263                                .and_then(|d| d.prepare_binary_to_text_cmd(&self.path))
264                            {
265                                Some(cmd) => {
266                                    // Avoid letting the driver program fail if it doesn't exist.
267                                    if self.options.large_file_threshold_bytes == 0
268                                        && none_if_missing(std::fs::symlink_metadata(&self.path))
269                                            .or_raise(|| {
270                                                message!(
271                                                    "Entry at '{rela_path}' could not be opened for reading or read from"
272                                                )
273                                            })?
274                                            .is_none()
275                                    {
276                                        None
277                                    } else {
278                                        run_cmd(rela_path, cmd, out)?;
279                                        Some(Data::Buffer { is_derived: true })
280                                    }
281                                }
282                                None => {
283                                    let file = none_if_missing(std::fs::File::open(&self.path)).or_raise(|| {
284                                        message!("Entry at '{rela_path}' could not be opened for reading or read from")
285                                    })?;
286
287                                    match file {
288                                        Some(mut file) => {
289                                            if convert.to_git() {
290                                                let res = self
291                                                    .worktree_filter
292                                                    .convert_to_git(
293                                                        file,
294                                                        gix_path::from_bstr(rela_path).as_ref(),
295                                                        attributes,
296                                                        &mut |buf| {
297                                                            if id.is_null() {
298                                                                return Ok(None);
299                                                            }
300                                                            objects.try_find(id, buf).map(|obj| obj.map(|_| ()))
301                                                        },
302                                                    )
303                                                    .or_raise(|| {
304                                                        message!(
305                                                            "Entry at '{rela_path}' could not be converted to Git form"
306                                                        )
307                                                    })?;
308
309                                                match res {
310                                                    ToGitOutcome::Unchanged(mut file) => {
311                                                        file.read_to_end(out).or_raise(|| {
312                                                            message!(
313                                                                "Entry at '{rela_path}' could not be opened for reading or read from"
314                                                            )
315                                                        })?;
316                                                    }
317                                                    ToGitOutcome::Process(mut stream) => {
318                                                        stream.read_to_end(out).or_raise(|| {
319                                                            message!(
320                                                                "Entry at '{rela_path}' could not be opened for reading or read from"
321                                                            )
322                                                        })?;
323                                                    }
324                                                    ToGitOutcome::Buffer(buf) => {
325                                                        out.clear();
326                                                        out.try_reserve(buf.len())
327                                                            .or_raise(|| message("Memory allocation failed"))?;
328                                                        out.extend_from_slice(buf);
329                                                    }
330                                                }
331                                            } else {
332                                                file.read_to_end(out).or_raise(|| {
333                                                    message!(
334                                                        "Entry at '{rela_path}' could not be opened for reading or read from"
335                                                    )
336                                                })?;
337                                            }
338
339                                            Some(if is_binary.unwrap_or_else(|| is_binary_buf(out)) {
340                                                let size = out.len() as u64;
341                                                out.clear();
342                                                Data::Binary { size }
343                                            } else {
344                                                Data::Buffer { is_derived: false }
345                                            })
346                                        }
347                                        None => None,
348                                    }
349                                }
350                            }
351                        }
352                    }
353                };
354                Ok(Outcome { driver_index, data })
355            }
356            None => {
357                let data = if id.is_null() {
358                    None
359                } else {
360                    let header = objects
361                        .try_header(id)
362                        .or_raise(|| message!("Could not find object {id}"))?
363                        .ok_or_else(|| not_found(format!("An object with id {id} could not be found")))
364                        .or_raise(|| message!("Could not find object {id}"))?;
365                    if is_binary.is_none()
366                        && self.options.large_file_threshold_bytes > 0
367                        && header.size > self.options.large_file_threshold_bytes
368                    {
369                        is_binary = Some(true);
370                    }
371                    let data = if is_binary == Some(true) {
372                        Data::Binary { size: header.size }
373                    } else {
374                        objects
375                            .try_find(id, out)
376                            .or_raise(|| message!("Could not find object {id}"))?
377                            .ok_or_else(|| not_found(format!("An object with id {id} could not be found")))
378                            .or_raise(|| message!("Could not find object {id}"))?;
379                        let mut is_derived = false;
380                        if matches!(mode, EntryKind::Blob | EntryKind::BlobExecutable)
381                            && convert == Mode::ToWorktreeAndBinaryToText
382                            || (convert == Mode::ToGitUnlessBinaryToTextIsPresent
383                                && driver.is_some_and(|d| d.binary_to_text_command.is_some()))
384                        {
385                            let res = self
386                                .worktree_filter
387                                .convert_to_worktree(
388                                    out,
389                                    rela_path,
390                                    attributes,
391                                    to_worktree::Options {
392                                        can_delay: Delay::Forbid,
393                                        unknown_encoding: to_worktree::UnknownEncoding::Fail,
394                                    },
395                                )
396                                .or_raise(|| {
397                                    message!("Entry at '{rela_path}' could not be converted to worktree form")
398                                })?;
399
400                            let cmd_and_file = driver
401                                .and_then(|d| {
402                                    d.binary_to_text_command.is_some().then(|| {
403                                        gix_tempfile::new(
404                                            std::env::temp_dir(),
405                                            gix_tempfile::ContainingDirectory::Exists,
406                                            gix_tempfile::AutoRemove::Tempfile,
407                                        )
408                                        .and_then(|mut tmp_file| {
409                                            self.path.clear();
410                                            tmp_file.with_mut(|tmp| self.path.push(tmp.path()))?;
411                                            Ok(tmp_file)
412                                        })
413                                        .map(|tmp_file| {
414                                            (
415                                                d.prepare_binary_to_text_cmd(&self.path)
416                                                    .expect("always get cmd if command is set"),
417                                                tmp_file,
418                                            )
419                                        })
420                                    })
421                                })
422                                .transpose()
423                                .or_raise(|| {
424                                    message!(
425                                        "Tempfile for binary-to-text conversion for entry at {rela_path} could not be created"
426                                    )
427                                })?;
428                            match cmd_and_file {
429                                Some((cmd, mut tmp_file)) => {
430                                    match res {
431                                        ToWorktreeOutcome::Unchanged(buf) | ToWorktreeOutcome::Buffer(buf) => {
432                                            tmp_file.write_all(buf)
433                                        }
434                                        ToWorktreeOutcome::Process(MaybeDelayed::Immediate(mut stream)) => {
435                                            std::io::copy(&mut stream, &mut tmp_file).map(|_| ())
436                                        }
437                                        ToWorktreeOutcome::Process(MaybeDelayed::Delayed(_)) => {
438                                            unreachable!("we prohibit this")
439                                        }
440                                    }
441                                    .or_raise(|| {
442                                        message!(
443                                            "Entry at '{rela_path}' could not be copied from a filter process to a memory buffer"
444                                        )
445                                    })?;
446                                    out.clear();
447                                    run_cmd(rela_path, cmd, out)?;
448                                    is_derived = true;
449                                }
450                                None => match res {
451                                    ToWorktreeOutcome::Unchanged(_) => {}
452                                    ToWorktreeOutcome::Buffer(src) => {
453                                        out.clear();
454                                        out.try_reserve(src.len())
455                                            .or_raise(|| message("Memory allocation failed"))?;
456                                        out.extend_from_slice(src);
457                                    }
458                                    ToWorktreeOutcome::Process(MaybeDelayed::Immediate(mut stream)) => {
459                                        std::io::copy(&mut stream, out).or_raise(|| {
460                                            message!(
461                                                "Entry at '{rela_path}' could not be copied from a filter process to a memory buffer"
462                                            )
463                                        })?;
464                                    }
465                                    ToWorktreeOutcome::Process(MaybeDelayed::Delayed(_)) => {
466                                        unreachable!("we prohibit this")
467                                    }
468                                },
469                            }
470                        }
471
472                        if driver.is_none_or(|d| d.binary_to_text_command.is_none())
473                            && is_binary.unwrap_or_else(|| is_binary_buf(out))
474                        {
475                            let size = out.len() as u64;
476                            out.clear();
477                            Data::Binary { size }
478                        } else {
479                            Data::Buffer { is_derived }
480                        }
481                    };
482                    Some(data)
483                };
484                Ok(Outcome { driver_index, data })
485            }
486        }
487    }
488}
489
490fn is_binary_buf(buf: &[u8]) -> bool {
491    let buf = &buf[..buf.len().min(8000)];
492    buf.contains(&0)
493}
494
495fn none_if_missing<T>(res: std::io::Result<T>) -> std::io::Result<Option<T>> {
496    match res {
497        Ok(data) => Ok(Some(data)),
498        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
499        Err(err) => Err(err),
500    }
501}
502
503fn run_cmd(rela_path: &BStr, mut cmd: Command, out: &mut Vec<u8>) -> ExnMessageResult {
504    gix_trace::debug!(cmd = ?cmd, "Running binary-to-text command");
505    let mut res = cmd
506        .output()
507        .or_raise(|| message!("Failed to run '{cmd:?}' for binary-to-text conversion of entry at {rela_path}"))?;
508    if !res.status.success() {
509        return Err(message!(
510            "Binary-to-text conversion '{cmd:?}' for entry at {rela_path} failed with: {}",
511            BStr::new(&res.stderr)
512        )
513        .raise());
514    }
515    out.append(&mut res.stdout);
516    Ok(())
517}
518
519impl Driver {
520    /// Produce an invocable command pre-configured to produce the filtered output on stdout after reading `path`.
521    pub fn prepare_binary_to_text_cmd(&self, path: &Path) -> Option<std::process::Command> {
522        let command: &BStr = self.binary_to_text_command.as_ref()?.as_ref();
523        let cmd = gix_command::prepare(gix_path::from_bstr(command).into_owned())
524            // TODO: Add support for an actual Context, validate it *can* match Git
525            .with_context(Default::default())
526            .with_shell()
527            .stdin(Stdio::null())
528            .stdout(Stdio::piped())
529            .stderr(Stdio::piped())
530            .arg(path)
531            .into();
532        Some(cmd)
533    }
534}