Skip to main content

gix_diff/blob/
pipeline.rs

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