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