Skip to main content

grit_lib/diff/
no_index.rs

1//! Compare arbitrary filesystem paths without a repository (`git diff --no-index`).
2
3use std::collections::{BTreeSet, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use thiserror::Error;
8
9use crate::config::ConfigSet;
10use crate::hash::hash_object;
11use crate::objects::FileMode;
12use crate::objects::{HashAlgo, ObjectId, ObjectKind};
13use crate::odb::Odb;
14use crate::parallel::ThreadPolicy;
15
16use super::core::{
17    apply_rename_detection_to_diff_entries, mode_from_metadata, read_worktree_bytes_for_mode,
18    zero_oid, DiffEntry, DiffStatus,
19};
20use super::detail::line_change_counts;
21use super::options::DiffOptions;
22use super::patch_log::{write_patch, PatchTextOptions};
23use super::structured::{structured_file_diff, SideBytes};
24use super::targets::FileDiff;
25use crate::repo::{InitOptions, Repository};
26
27const S_IFMT: u32 = 0o170000;
28const S_IFREG: u32 = 0o100000;
29
30/// Failure modes specific to [`diff_paths`].
31#[derive(Debug, Error, Clone, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum NoIndexError {
34    /// A path could not be read (missing or permission denied).
35    #[error("Could not access '{path}'")]
36    PathNotAccessible {
37        /// Filesystem path that could not be accessed.
38        path: String,
39    },
40    /// SHA-1 collision detection rejected blob content while hashing a path.
41    #[error("sha-1 hash collision while hashing no-index diff input")]
42    HashCollision,
43}
44
45impl From<NoIndexError> for crate::error::Error {
46    fn from(value: NoIndexError) -> Self {
47        Self::Message(value.to_string())
48    }
49}
50
51/// Diff two filesystem paths (files, directories, or one of each).
52///
53/// Does not require a [`Repository`](crate::repo::Repository). Directory pairs are walked
54/// recursively with sorted names; a file paired with a directory compares against
55/// `directory/basename(file)` like Git. Symlink targets are diffed as text; executable
56/// bit differences appear as mode changes.
57///
58/// # Errors
59///
60/// Returns [`NoIndexError::PathNotAccessible`] when either top-level path cannot be accessed.
61pub fn diff_paths(a: &Path, b: &Path, opts: &DiffOptions) -> Result<Vec<FileDiff>, NoIndexError> {
62    let stat_only = opts.no_index_stat_only;
63    let temp = tempfile::tempdir().map_err(|e| NoIndexError::PathNotAccessible {
64        path: e.to_string(),
65    })?;
66    let odb = Odb::new(temp.path());
67    let entries = no_index_prepared_entries(a, b, opts, &odb)?;
68    if entries.is_empty() {
69        return Ok(Vec::new());
70    }
71    let diff_options = opts
72        .clone()
73        .cap_word_level_inline_for_changed_files(entries.len());
74    Ok(entries
75        .into_iter()
76        .filter_map(|entry| file_diff_from_entry(&odb, &entry, diff_options.clone(), stat_only))
77        .collect())
78}
79
80/// Write `git diff --no-index --binary`-style patch bytes for two filesystem paths.
81///
82/// Returns `Ok(true)` when the paths differ.
83///
84/// # Errors
85///
86/// Propagates [`NoIndexError`] and repository/patch I/O failures.
87pub fn write_paths_patch<W: std::io::Write>(
88    a: &Path,
89    b: &Path,
90    opts: &DiffOptions,
91    out: &mut W,
92) -> Result<bool, NoIndexError> {
93    let temp = tempfile::tempdir().map_err(|e| NoIndexError::PathNotAccessible {
94        path: e.to_string(),
95    })?;
96    let repo = Repository::init(temp.path(), &InitOptions::new().with_bare(true)).map_err(|e| {
97        NoIndexError::PathNotAccessible {
98            path: e.to_string(),
99        }
100    })?;
101    let entries = no_index_prepared_entries(a, b, opts, repo.odb())?;
102    if entries.is_empty() {
103        return Ok(false);
104    }
105    let patch_opts = PatchTextOptions::from_repo(&repo, opts.clone()).map_err(|e| {
106        NoIndexError::PathNotAccessible {
107            path: e.to_string(),
108        }
109    })?;
110    write_patch(&repo, &entries, &patch_opts, out).map_err(|e| {
111        NoIndexError::PathNotAccessible {
112            path: e.to_string(),
113        }
114    })?;
115    Ok(true)
116}
117
118fn no_index_prepared_entries(
119    a: &Path,
120    b: &Path,
121    opts: &DiffOptions,
122    odb: &Odb,
123) -> Result<Vec<DiffEntry>, NoIndexError> {
124    let (left, right) = fixup_paths(a, b)?;
125    let mut entries = Vec::new();
126    queue_diff(left.as_deref(), right.as_deref(), false, &mut entries)?;
127    if entries.is_empty() {
128        return Ok(entries);
129    }
130
131    let config = ConfigSet::default();
132    let thread_policy = ThreadPolicy::default();
133    let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
134    store_changed_blobs(odb, &mut entries)?;
135    if rename_threshold < 100 {
136        apply_rename_detection_to_diff_entries(
137            odb,
138            entries,
139            None,
140            rename_threshold,
141            &config,
142            &thread_policy,
143        )
144        .map_err(|e| NoIndexError::PathNotAccessible {
145            path: e.to_string(),
146        })
147    } else {
148        Ok(entries)
149    }
150}
151
152fn file_diff_from_entry(
153    odb: &Odb,
154    entry: &DiffEntry,
155    opts: DiffOptions,
156    stat_only: bool,
157) -> Option<FileDiff> {
158    let path_unchanged = entry.old_path == entry.new_path;
159    if entry.old_oid == entry.new_oid
160        && entry.old_mode == entry.new_mode
161        && path_unchanged
162        && !matches!(entry.status, DiffStatus::Renamed | DiffStatus::Copied)
163    {
164        return None;
165    }
166    let (old_bytes, old_bin) = side_bytes_for_entry(odb, entry, Side::Old)?;
167    let (new_bytes, new_bin) = side_bytes_for_entry(odb, entry, Side::New)?;
168    let binary = old_bin || new_bin;
169    let path = entry
170        .new_path
171        .clone()
172        .or_else(|| entry.old_path.clone())
173        .unwrap_or_default();
174    let old_path = entry
175        .old_path
176        .clone()
177        .filter(|op| entry.new_path.as_ref() != Some(op));
178    let (old_encoding_lossy, new_encoding_lossy, hunks, line_stat) = if binary {
179        (
180            old_bytes.encoding_lossy,
181            new_bytes.encoding_lossy,
182            Vec::new(),
183            None,
184        )
185    } else if stat_only {
186        let old_text = String::from_utf8_lossy(&old_bytes.data);
187        let new_text = String::from_utf8_lossy(&new_bytes.data);
188        let stat = line_change_counts(old_text.as_ref(), new_text.as_ref(), &opts);
189        (
190            old_bytes.encoding_lossy,
191            new_bytes.encoding_lossy,
192            Vec::new(),
193            Some(stat),
194        )
195    } else {
196        let structured =
197            structured_file_diff(entry.old_mode, entry.new_mode, &old_bytes, &new_bytes, opts);
198        (
199            structured.old_encoding_lossy,
200            structured.new_encoding_lossy,
201            structured.hunks,
202            None,
203        )
204    };
205    Some(FileDiff {
206        path,
207        old_path,
208        status: entry.status,
209        old_mode: entry.old_mode.octal_if_present(),
210        new_mode: entry.new_mode.octal_if_present(),
211        old_oid: entry.old_oid,
212        new_oid: entry.new_oid,
213        binary,
214        old_size: old_bytes.byte_len(),
215        new_size: new_bytes.byte_len(),
216        content_changed: entry.old_oid != entry.new_oid,
217        old_encoding_lossy,
218        new_encoding_lossy,
219        hunks,
220        line_stat,
221    })
222}
223
224enum Side {
225    Old,
226    New,
227}
228
229fn side_bytes_for_entry(odb: &Odb, entry: &DiffEntry, side: Side) -> Option<(SideBytes, bool)> {
230    let (mode, oid) = match side {
231        Side::Old => (entry.old_mode, entry.old_oid),
232        Side::New => (entry.new_mode, entry.new_oid),
233    };
234    if mode.is_absent() {
235        return Some((SideBytes::from_bytes(Vec::new()), false));
236    }
237    let data = odb.read(&oid).ok()?.data;
238    Some(bytes_for_diff(&data))
239}
240
241fn bytes_for_diff(data: &[u8]) -> (SideBytes, bool) {
242    let binary = crate::merge_file::is_binary(data);
243    (SideBytes::from_bytes(data.to_vec()), binary)
244}
245
246/// Write blob objects for changed sides only (rename detection reads from the ODB).
247fn store_changed_blobs(odb: &Odb, entries: &mut [DiffEntry]) -> Result<(), NoIndexError> {
248    for entry in entries {
249        if entry.old_mode != FileMode::ABSENT && !entry.old_oid.is_zero() {
250            if let Some(path) = entry.old_path.as_deref() {
251                write_blob_if_missing(odb, path, entry.old_mode, &mut entry.old_oid)?;
252            }
253        }
254        if entry.new_mode != FileMode::ABSENT && !entry.new_oid.is_zero() {
255            if let Some(path) = entry.new_path.as_deref() {
256                write_blob_if_missing(odb, path, entry.new_mode, &mut entry.new_oid)?;
257            }
258        }
259    }
260    Ok(())
261}
262
263fn write_blob_if_missing(
264    odb: &Odb,
265    path: &str,
266    mode: FileMode,
267    oid: &mut ObjectId,
268) -> Result<(), NoIndexError> {
269    if !oid.is_zero() && odb.read(oid).is_ok() {
270        return Ok(());
271    }
272    *oid = hash_side_at_path(path, mode)?;
273    let data = read_worktree_bytes_for_mode(Path::new(path), mode.bits()).map_err(|_| {
274        NoIndexError::PathNotAccessible {
275            path: path.to_owned(),
276        }
277    })?;
278    let _ = odb.write(ObjectKind::Blob, &data);
279    Ok(())
280}
281
282fn hash_side_at_path(path: &str, mode: FileMode) -> Result<ObjectId, NoIndexError> {
283    let data = read_worktree_bytes_for_mode(Path::new(path), mode.bits()).map_err(|_| {
284        NoIndexError::PathNotAccessible {
285            path: path.to_owned(),
286        }
287    })?;
288    hash_blob(&data)
289}
290
291fn hash_blob(data: &[u8]) -> Result<ObjectId, NoIndexError> {
292    hash_object(HashAlgo::Sha1, ObjectKind::Blob, data).map_err(|e| match e {
293        crate::error::Error::HashCollision { .. } => NoIndexError::HashCollision,
294        other => panic!("unexpected error hashing no-index blob: {other}"),
295    })
296}
297
298fn read_side_bytes(path: &Path, mode: u32) -> Result<Vec<u8>, NoIndexError> {
299    read_worktree_bytes_for_mode(path, mode).map_err(|_| NoIndexError::PathNotAccessible {
300        path: path.display().to_string(),
301    })
302}
303
304fn worktree_content_equal(
305    left: &Path,
306    right: &Path,
307    mode_left: u32,
308    mode_right: u32,
309) -> Result<bool, NoIndexError> {
310    let (ls, rs) = (
311        fs::metadata(left).map(|m| m.len()).ok(),
312        fs::metadata(right).map(|m| m.len()).ok(),
313    );
314    if ls != rs {
315        return Ok(false);
316    }
317    let old_bytes = read_side_bytes(left, mode_left)?;
318    let new_bytes = read_side_bytes(right, mode_right)?;
319    Ok(old_bytes == new_bytes)
320}
321
322/// Blob OIDs for a path pair, reading each present side at most once.
323fn oids_for_pair(
324    left: Option<&Path>,
325    right: Option<&Path>,
326    mode_left: u32,
327    mode_right: u32,
328) -> Result<(ObjectId, ObjectId), NoIndexError> {
329    match (left, right) {
330        (Some(l), Some(r)) if mode_left != 0 && mode_right != 0 => {
331            let old_bytes = read_side_bytes(l, mode_left)?;
332            let new_bytes = read_side_bytes(r, mode_right)?;
333            let old_oid = hash_blob(&old_bytes)?;
334            let new_oid = if old_bytes == new_bytes {
335                old_oid
336            } else {
337                hash_blob(&new_bytes)?
338            };
339            Ok((old_oid, new_oid))
340        }
341        (Some(l), _) if mode_left != 0 => {
342            let old_bytes = read_side_bytes(l, mode_left)?;
343            Ok((hash_blob(&old_bytes)?, zero_oid()))
344        }
345        (_, Some(r)) if mode_right != 0 => {
346            let new_bytes = read_side_bytes(r, mode_right)?;
347            Ok((zero_oid(), hash_blob(&new_bytes)?))
348        }
349        _ => Ok((zero_oid(), zero_oid())),
350    }
351}
352
353fn fixup_paths(a: &Path, b: &Path) -> Result<(Option<PathBuf>, Option<PathBuf>), NoIndexError> {
354    let meta_a = fs::symlink_metadata(a).ok();
355    let meta_b = fs::symlink_metadata(b).ok();
356    if meta_a.is_none() && meta_b.is_none() {
357        return Err(NoIndexError::PathNotAccessible {
358            path: a.display().to_string(),
359        });
360    }
361    let is_dir_a = meta_a.as_ref().is_some_and(|m| m.is_dir());
362    let is_dir_b = meta_b.as_ref().is_some_and(|m| m.is_dir());
363    if is_dir_a && is_dir_b {
364        return Ok((Some(a.to_path_buf()), Some(b.to_path_buf())));
365    }
366    if is_dir_a {
367        let mut joined = a.to_path_buf();
368        append_basename(&mut joined, b);
369        return Ok((Some(joined), Some(b.to_path_buf())));
370    }
371    if is_dir_b {
372        let mut joined = b.to_path_buf();
373        append_basename(&mut joined, a);
374        return Ok((Some(a.to_path_buf()), Some(joined)));
375    }
376    Ok((Some(a.to_path_buf()), Some(b.to_path_buf())))
377}
378
379fn append_basename(dir: &mut PathBuf, file: &Path) {
380    let name = file.file_name().unwrap_or_default();
381    if name.is_empty() {
382        return;
383    }
384    strip_trailing_slashes_preserve_root(dir);
385    dir.push(name);
386}
387
388/// Remove trailing `/` without turning `/` into an empty relative path.
389fn strip_trailing_slashes_preserve_root(path: &mut PathBuf) {
390    let raw = path.as_os_str().to_string_lossy();
391    let trimmed = raw.trim_end_matches('/');
392    if trimmed.is_empty() {
393        *path = PathBuf::from("/");
394    } else {
395        *path = PathBuf::from(trimmed);
396    }
397}
398
399fn queue_diff(
400    left: Option<&Path>,
401    right: Option<&Path>,
402    recursing: bool,
403    out: &mut Vec<DiffEntry>,
404) -> Result<(), NoIndexError> {
405    let (mode_left, mode_right) = (path_mode(left, recursing)?, path_mode(right, recursing)?);
406    let dir_left = path_is_dir(left);
407    let dir_right = path_is_dir(right);
408
409    if mode_left != 0 && mode_right != 0 && dir_left != dir_right {
410        if dir_left {
411            emit_pair(
412                out,
413                None,
414                right,
415                0,
416                mode_right,
417                right.map(path_display_string),
418            )?;
419            return queue_diff(left, None, true, out);
420        }
421        emit_pair(out, left, None, mode_left, 0, left.map(path_display_string))?;
422        return queue_diff(None, right, true, out);
423    }
424
425    if dir_left || dir_right {
426        diff_directories(left, right, out)
427    } else {
428        emit_pair(
429            out,
430            left,
431            right,
432            mode_left,
433            mode_right,
434            left.or(right).map(path_display_string),
435        )?;
436        Ok(())
437    }
438}
439
440fn path_is_dir(path: Option<&Path>) -> bool {
441    path.and_then(|p| fs::symlink_metadata(p).ok())
442        .is_some_and(|m| m.is_dir())
443}
444
445fn diff_directories(
446    left: Option<&Path>,
447    right: Option<&Path>,
448    out: &mut Vec<DiffEntry>,
449) -> Result<(), NoIndexError> {
450    let left_names = read_dir_names(left)?;
451    let right_names = read_dir_names(right)?;
452    let left_set: HashSet<_> = left_names.iter().cloned().collect();
453    let right_set: HashSet<_> = right_names.iter().cloned().collect();
454    let names: BTreeSet<_> = left_names
455        .iter()
456        .chain(right_names.iter())
457        .cloned()
458        .collect();
459    for name in names {
460        let l = left
461            .filter(|_| left_set.contains(&name))
462            .map(|p| p.join(&name));
463        let r = right
464            .filter(|_| right_set.contains(&name))
465            .map(|p| p.join(&name));
466        queue_diff(l.as_deref(), r.as_deref(), true, out)?;
467    }
468    Ok(())
469}
470
471fn read_dir_names(path: Option<&Path>) -> Result<Vec<String>, NoIndexError> {
472    let Some(path) = path else {
473        return Ok(Vec::new());
474    };
475    let mut names = Vec::new();
476    let read_dir = fs::read_dir(path).map_err(|_| NoIndexError::PathNotAccessible {
477        path: path.display().to_string(),
478    })?;
479    for entry in read_dir {
480        let entry = entry.map_err(|_| NoIndexError::PathNotAccessible {
481            path: path.display().to_string(),
482        })?;
483        let name = entry.file_name().to_string_lossy().into_owned();
484        if name == "." || name == ".." {
485            continue;
486        }
487        names.push(name);
488    }
489    names.sort();
490    Ok(names)
491}
492
493fn emit_pair(
494    out: &mut Vec<DiffEntry>,
495    left: Option<&Path>,
496    right: Option<&Path>,
497    mode_left: u32,
498    mode_right: u32,
499    display: Option<String>,
500) -> Result<(), NoIndexError> {
501    let (status, old_path, new_path) = match (left, right) {
502        (Some(_l), Some(_r)) if mode_left == 0 || mode_right == 0 => {
503            if mode_left == 0 {
504                (DiffStatus::Added, None, display.clone())
505            } else {
506                (DiffStatus::Deleted, display.clone(), None)
507            }
508        }
509        (Some(l), None) => (DiffStatus::Deleted, Some(path_display_string(l)), None),
510        (None, Some(r)) => (DiffStatus::Added, None, Some(path_display_string(r))),
511        (Some(l), Some(r)) => {
512            let lp = path_display_string(l);
513            let rp = path_display_string(r);
514            let status = if file_kind(mode_left) != file_kind(mode_right) {
515                DiffStatus::TypeChanged
516            } else {
517                DiffStatus::Modified
518            };
519            (status, Some(lp), Some(rp))
520        }
521        (None, None) => return Ok(()),
522    };
523
524    let old_mode = FileMode::new(mode_left);
525    let new_mode = FileMode::new(mode_right);
526
527    if let (Some(l), Some(r)) = (left, right) {
528        if mode_left != 0
529            && mode_right != 0
530            && old_mode == new_mode
531            && worktree_content_equal(l, r, mode_left, mode_right)?
532        {
533            return Ok(());
534        }
535    }
536
537    let (old_oid, new_oid) = oids_for_pair(left, right, mode_left, mode_right)?;
538
539    if old_oid == new_oid && old_mode == new_mode {
540        return Ok(());
541    }
542
543    out.push(DiffEntry {
544        status,
545        old_path,
546        new_path,
547        old_mode,
548        new_mode,
549        old_oid,
550        new_oid,
551        score: None,
552    });
553    Ok(())
554}
555
556fn file_kind(mode: u32) -> u32 {
557    if mode & S_IFMT != 0 {
558        mode & S_IFMT
559    } else {
560        S_IFREG
561    }
562}
563
564fn path_mode(path: Option<&Path>, recursing: bool) -> Result<u32, NoIndexError> {
565    let Some(path) = path else {
566        return Ok(0);
567    };
568    if !recursing && path.as_os_str().is_empty() {
569        return Ok(0);
570    }
571    match fs::symlink_metadata(path) {
572        Ok(meta) => Ok(mode_from_metadata(&meta)),
573        Err(_) if recursing => Ok(0),
574        Err(_) => Err(NoIndexError::PathNotAccessible {
575            path: path.display().to_string(),
576        }),
577    }
578}
579
580fn path_display_string(path: &Path) -> String {
581    path.to_string_lossy().into_owned()
582}