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}