Skip to main content

grit_lib/diff/
has_changes.rs

1//! Fast change detection for diffs without building hunks.
2
3use std::path::Path;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::Arc;
6
7use crate::config::ConfigSet;
8use crate::diff::DiffTarget;
9use crate::diff::{
10    apply_rename_detection_to_diff_entries, diff_index_to_tree_with_renames,
11    diff_index_to_worktree_with_options, diff_tree_to_worktree_with_git_dir_and_rules,
12    diff_trees_with_renames_and_policy, index_mtime_for_diff, merge_file, tree_oid_of_commit,
13    DiffEntry, DiffIndexToWorktreeOptions, DiffOptions, DiffStatus,
14};
15use crate::error::{Error, Result};
16use crate::index::Index;
17use crate::objects::odb::Odb;
18use crate::objects::{parse_commit, ObjectId};
19use crate::pathspec::PathspecMatcher;
20use crate::repo::Repository;
21use crate::state::resolve_head_for_repository;
22use crate::worktree::worktree_rules::WorktreeRules;
23
24/// Hook for tests that assert early exit while scanning worktree diffs.
25#[doc(hidden)]
26pub static WORKTREE_HAS_CHANGES_ENTRIES_SCANNED: AtomicUsize = AtomicUsize::new(0);
27
28/// Hook for tests that assert `Diff::has_changes` was (not) invoked.
29#[doc(hidden)]
30pub static HAS_CHANGES_CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
31
32fn opts_need_content_compare(opts: &DiffOptions) -> bool {
33    opts.whitespace.is_active()
34}
35
36fn trust_filemode(config: &ConfigSet) -> bool {
37    config
38        .get_bool("core.filemode")
39        .and_then(|r| r.ok())
40        .unwrap_or(true)
41}
42
43fn rename_threshold(opts: &DiffOptions) -> u32 {
44    opts.renames.threshold_percent().unwrap_or(101)
45}
46
47/// Whether two text sides differ under whitespace options in `opts`.
48#[must_use]
49pub fn text_sides_differ(old: &str, new: &str, opts: &DiffOptions) -> bool {
50    if old == new {
51        return false;
52    }
53    let ws = opts.whitespace;
54    let (a, b) = preprocess_for_whitespace(old, new, ws);
55    if ws.ignore_blank_lines {
56        let a: Vec<_> = a.lines().filter(|l| !l.trim().is_empty()).collect();
57        let b: Vec<_> = b.lines().filter(|l| !l.trim().is_empty()).collect();
58        return a != b;
59    }
60    a != b
61}
62
63fn preprocess_for_whitespace(
64    old: &str,
65    new: &str,
66    ws: crate::diff::WhitespaceMode,
67) -> (String, String) {
68    if ws.ignore_all_space {
69        return (strip_all_whitespace(old), strip_all_whitespace(new));
70    }
71    if ws.ignore_space_change {
72        return (
73            crate::diff::normalize_ignore_space_change(old),
74            crate::diff::normalize_ignore_space_change(new),
75        );
76    }
77    if ws.ignore_space_at_eol {
78        return (trim_trailing_ws_lines(old), trim_trailing_ws_lines(new));
79    }
80    (old.to_owned(), new.to_owned())
81}
82
83fn strip_all_whitespace(s: &str) -> String {
84    s.lines()
85        .map(|line| {
86            line.chars()
87                .filter(|c| !c.is_whitespace())
88                .collect::<String>()
89        })
90        .collect::<Vec<_>>()
91        .join("\n")
92}
93
94fn trim_trailing_ws_lines(s: &str) -> String {
95    s.lines()
96        .map(|line| line.trim_end_matches(|c: char| c.is_whitespace()))
97        .collect::<Vec<_>>()
98        .join("\n")
99}
100
101fn worktree_file_index_blob_differ(
102    odb: &Odb,
103    work_tree: &Path,
104    rel_path: &str,
105    index_oid: &ObjectId,
106    opts: &DiffOptions,
107) -> Result<bool> {
108    let file_path = work_tree.join(rel_path);
109    let wt_bytes = std::fs::read(&file_path).map_err(Error::Io)?;
110    let index_raw = odb.read(index_oid)?.data;
111    if merge_file::is_binary(&wt_bytes) || merge_file::is_binary(&index_raw) {
112        return Ok(wt_bytes != index_raw);
113    }
114    let wt_s = String::from_utf8_lossy(&wt_bytes);
115    let idx_s = String::from_utf8_lossy(&index_raw);
116    Ok(text_sides_differ(&idx_s, &wt_s, opts))
117}
118
119fn worktree_index_entry_has_changes(
120    entry: &DiffEntry,
121    trust_fm: bool,
122    odb: &Odb,
123    work_tree: &Path,
124    opts: &DiffOptions,
125) -> Result<bool> {
126    match entry.status {
127        DiffStatus::Added
128        | DiffStatus::Deleted
129        | DiffStatus::TypeChanged
130        | DiffStatus::Unmerged => {
131            return Ok(true);
132        }
133        DiffStatus::Renamed | DiffStatus::Copied => return Ok(true),
134        DiffStatus::Modified => {}
135    }
136    if entry.old_mode != entry.new_mode && trust_fm {
137        return Ok(true);
138    }
139    let rel_path = entry.path();
140    let content_aware = opts_need_content_compare(opts);
141    if entry.new_oid.is_zero() && !entry.old_oid.is_zero() {
142        if content_aware {
143            return worktree_file_index_blob_differ(odb, work_tree, rel_path, &entry.old_oid, opts);
144        }
145        return Ok(true);
146    }
147    if entry.old_oid != entry.new_oid {
148        return blob_bytes_differ(odb, &entry.old_oid, &entry.new_oid, opts);
149    }
150    if content_aware {
151        return worktree_file_index_blob_differ(odb, work_tree, rel_path, &entry.old_oid, opts);
152    }
153    Ok(true)
154}
155
156fn blob_bytes_differ(
157    odb: &Odb,
158    old_oid: &ObjectId,
159    new_oid: &ObjectId,
160    opts: &DiffOptions,
161) -> Result<bool> {
162    if old_oid == new_oid {
163        return Ok(false);
164    }
165    let old_raw = odb.read(old_oid)?.data;
166    let new_raw = odb.read(new_oid)?.data;
167    if merge_file::is_binary(&old_raw) || merge_file::is_binary(&new_raw) {
168        return Ok(old_raw != new_raw);
169    }
170    let old_s = String::from_utf8_lossy(&old_raw);
171    let new_s = String::from_utf8_lossy(&new_raw);
172    Ok(text_sides_differ(&old_s, &new_s, opts))
173}
174
175/// Whether a tree/index diff entry counts as a real change for `--exit-code` semantics.
176pub(crate) fn tree_entry_has_changes(
177    odb: &Odb,
178    entry: &DiffEntry,
179    trust_fm: bool,
180    opts: &DiffOptions,
181) -> Result<bool> {
182    match entry.status {
183        DiffStatus::Added
184        | DiffStatus::Deleted
185        | DiffStatus::TypeChanged
186        | DiffStatus::Unmerged => {
187            return Ok(true);
188        }
189        DiffStatus::Renamed | DiffStatus::Copied => return Ok(true),
190        DiffStatus::Modified => {}
191    }
192    if entry.old_mode != entry.new_mode && trust_fm {
193        return Ok(true);
194    }
195    // Index↔worktree diffs use the null OID on the worktree side when content differs.
196    if entry.new_oid.is_zero() && !entry.old_oid.is_zero() {
197        return Ok(true);
198    }
199    if entry.old_oid.is_zero() && !entry.new_oid.is_zero() {
200        return Ok(true);
201    }
202    if entry.old_oid != entry.new_oid {
203        return blob_bytes_differ(odb, &entry.old_oid, &entry.new_oid, opts);
204    }
205    Ok(false)
206}
207
208fn worktree_has_changes(
209    repo: &Repository,
210    index: &mut Index,
211    base_tree: Option<&ObjectId>,
212    matcher: &PathspecMatcher<'_>,
213    opts: &DiffOptions,
214) -> Result<bool> {
215    let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
216    let config = repo.config()?;
217    let index_mtime = index_mtime_for_diff(index, None);
218    let rules_arc = Arc::new(std::sync::Mutex::new(WorktreeRules::from_repository(
219        repo, index,
220    )?));
221
222    let cache_valid =
223        crate::index::write_tree::cache_tree_extension_valid(index.cache_tree.as_ref());
224    let index_backed = match base_tree {
225        // Unstaged diffs are always index vs worktree.
226        None => true,
227        Some(tree) => {
228            index
229                .cache_tree_root
230                .as_ref()
231                .is_some_and(|root| root == tree)
232                && cache_valid
233        }
234    };
235
236    if index_backed {
237        WORKTREE_HAS_CHANGES_ENTRIES_SCANNED.store(0, Ordering::SeqCst);
238        let content_compare = opts_need_content_compare(opts);
239        let (entries, _) = diff_index_to_worktree_with_options(
240            repo.odb(),
241            index,
242            work_tree,
243            DiffIndexToWorktreeOptions {
244                repository_git_dir: Some(repo.git_dir().to_path_buf()),
245                index_mtime,
246                config: Some(Arc::clone(&config)),
247                thread_policy: Some(repo.thread_policy()),
248                refresh_index_stat_in_pass: false,
249                for_status: !content_compare,
250                worktree_rules: Some(Arc::clone(&rules_arc)),
251                ..DiffIndexToWorktreeOptions::default()
252            },
253        )?;
254        for entry in &entries {
255            WORKTREE_HAS_CHANGES_ENTRIES_SCANNED.fetch_add(1, Ordering::SeqCst);
256            if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
257                continue;
258            }
259            if worktree_index_entry_has_changes(
260                entry,
261                trust_filemode(config.as_ref()),
262                repo.odb(),
263                work_tree,
264                opts,
265            )? {
266                return Ok(true);
267            }
268        }
269        return Ok(false);
270    }
271
272    let worktree_rules = WorktreeRules::from_repository(repo, index)?;
273    let raw = diff_tree_to_worktree_with_git_dir_and_rules(
274        repo.odb(),
275        base_tree,
276        work_tree,
277        repo.git_dir(),
278        index,
279        Some(config.as_ref()),
280        Some(&worktree_rules),
281    )?;
282    let entries = apply_rename_detection_to_diff_entries(
283        repo.odb(),
284        raw,
285        Some(work_tree),
286        rename_threshold(opts),
287        config.as_ref(),
288        &repo.thread_policy(),
289    )?;
290    for entry in entries {
291        if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
292            continue;
293        }
294        if tree_entry_has_changes(repo.odb(), &entry, trust_filemode(config.as_ref()), opts)? {
295            return Ok(true);
296        }
297    }
298    Ok(false)
299}
300
301fn staged_has_changes(
302    repo: &Repository,
303    matcher: &PathspecMatcher<'_>,
304    opts: &DiffOptions,
305) -> Result<bool> {
306    let head_tree = resolve_head_for_repository(repo)?.tree(repo.odb())?;
307    let index = repo.index_handle().load()?;
308    let config = repo.config()?;
309    let mut entries = diff_index_to_tree_with_renames(
310        repo.odb(),
311        &index,
312        head_tree.as_ref(),
313        false,
314        rename_threshold(opts),
315        config.as_ref(),
316        &repo.thread_policy(),
317    )?;
318    for entry in entries.drain(..) {
319        if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
320            continue;
321        }
322        if tree_entry_has_changes(repo.odb(), &entry, trust_filemode(config.as_ref()), opts)? {
323            return Ok(true);
324        }
325    }
326    Ok(false)
327}
328
329fn unstaged_has_changes(
330    repo: &Repository,
331    matcher: &PathspecMatcher<'_>,
332    opts: &DiffOptions,
333) -> Result<bool> {
334    let mut index = repo.index_handle().load()?;
335    worktree_has_changes(repo, &mut index, None, matcher, opts)
336}
337
338fn commit_trees_has_changes(
339    repo: &Repository,
340    base: &ObjectId,
341    tip: &ObjectId,
342    matcher: &PathspecMatcher<'_>,
343    opts: &DiffOptions,
344) -> Result<bool> {
345    let config = repo.config()?;
346    let base_tree = tree_oid_of_commit(repo.odb(), base)?;
347    let tip_tree = tree_oid_of_commit(repo.odb(), tip)?;
348    let mut entries = diff_trees_with_renames_and_policy(
349        repo.odb(),
350        Some(&base_tree),
351        Some(&tip_tree),
352        "",
353        rename_threshold(opts),
354        config.as_ref(),
355        &repo.thread_policy(),
356    )?;
357    for entry in entries.drain(..) {
358        if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
359            continue;
360        }
361        if tree_entry_has_changes(repo.odb(), &entry, trust_filemode(config.as_ref()), opts)? {
362            return Ok(true);
363        }
364    }
365    Ok(false)
366}
367
368impl<'repo> super::handle::Diff<'repo> {
369    /// Whether the selected diff has any real changes, stopping at the first one.
370    ///
371    /// Worktree targets honor the index stat cache and only hash blob content when needed.
372    /// When whitespace ignore flags are set on `opts`, content is compared under those rules;
373    /// a change that differs only in ignored whitespace returns `false`.
374    ///
375    /// # Errors
376    ///
377    /// Propagates repository, ODB, and diff errors.
378    pub fn has_changes(
379        &self,
380        target: &DiffTarget,
381        pathspecs: &[String],
382        opts: DiffOptions,
383    ) -> Result<bool> {
384        HAS_CHANGES_CALL_COUNT.fetch_add(1, Ordering::SeqCst);
385        let repo = self.repository();
386        let matcher = repo.pathspec_matcher(pathspecs)?;
387        match target {
388            DiffTarget::WorktreeVsHead => {
389                let mut index = repo.index_handle().load()?;
390                let head_tree = resolve_head_for_repository(repo)?.tree(repo.odb())?;
391                worktree_has_changes(repo, &mut index, head_tree.as_ref(), &matcher, &opts)
392            }
393            DiffTarget::IndexVsHead => staged_has_changes(repo, &matcher, &opts),
394            DiffTarget::WorktreeVsIndex => unstaged_has_changes(repo, &matcher, &opts),
395            DiffTarget::WorktreeVsTree(tree) => {
396                let mut index = repo.index_handle().load()?;
397                worktree_has_changes(repo, &mut index, Some(tree), &matcher, &opts)
398            }
399            DiffTarget::Commit(commit) => {
400                let obj = repo.odb().read(commit)?;
401                let parsed = parse_commit(&obj.data)?;
402                let new_tree = parsed.tree;
403                let old_tree = parsed
404                    .parents
405                    .first()
406                    .map(|parent| tree_oid_of_commit(repo.odb(), parent))
407                    .transpose()?;
408                let config = repo.config()?;
409                let mut entries = diff_trees_with_renames_and_policy(
410                    repo.odb(),
411                    old_tree.as_ref(),
412                    Some(&new_tree),
413                    "",
414                    rename_threshold(&opts),
415                    config.as_ref(),
416                    &repo.thread_policy(),
417                )?;
418                for entry in entries.drain(..) {
419                    if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
420                        continue;
421                    }
422                    if tree_entry_has_changes(
423                        repo.odb(),
424                        &entry,
425                        trust_filemode(config.as_ref()),
426                        &opts,
427                    )? {
428                        return Ok(true);
429                    }
430                }
431                Ok(false)
432            }
433            DiffTarget::Trees { old, new } => match old {
434                Some(base) => commit_trees_has_changes(repo, base, new, &matcher, &opts),
435                None => {
436                    let config = repo.config()?;
437                    let tip_tree = super::core::tree_oid_for_diff_side(repo.odb(), new)?;
438                    let mut entries = diff_trees_with_renames_and_policy(
439                        repo.odb(),
440                        None,
441                        Some(&tip_tree),
442                        "",
443                        rename_threshold(&opts),
444                        config.as_ref(),
445                        &repo.thread_policy(),
446                    )?;
447                    for entry in entries.drain(..) {
448                        if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
449                            continue;
450                        }
451                        if tree_entry_has_changes(
452                            repo.odb(),
453                            &entry,
454                            trust_filemode(config.as_ref()),
455                            &opts,
456                        )? {
457                            return Ok(true);
458                        }
459                    }
460                    Ok(false)
461                }
462            },
463        }
464    }
465}