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}