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;
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 head_tree(repo: &Repository) -> Result<Option<ObjectId>> {
209    let head = resolve_head(repo.git_dir())?;
210    Ok(match head.oid() {
211        Some(oid) => Some(tree_oid_of_commit(repo.odb(), oid)?),
212        None => None,
213    })
214}
215
216fn worktree_has_changes(
217    repo: &Repository,
218    index: &mut Index,
219    base_tree: Option<&ObjectId>,
220    matcher: &PathspecMatcher<'_>,
221    opts: &DiffOptions,
222) -> Result<bool> {
223    let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
224    let config = repo.config()?;
225    let index_mtime = index_mtime_for_diff(index, None);
226    let rules_arc = Arc::new(std::sync::Mutex::new(WorktreeRules::from_repository(
227        repo, index,
228    )?));
229
230    let cache_valid =
231        crate::index::write_tree::cache_tree_extension_valid(index.cache_tree.as_ref());
232    let index_backed = match base_tree {
233        // Unstaged diffs are always index vs worktree.
234        None => true,
235        Some(tree) => {
236            index
237                .cache_tree_root
238                .as_ref()
239                .is_some_and(|root| root == tree)
240                && cache_valid
241        }
242    };
243
244    if index_backed {
245        WORKTREE_HAS_CHANGES_ENTRIES_SCANNED.store(0, Ordering::SeqCst);
246        let content_compare = opts_need_content_compare(opts);
247        let (entries, _) = diff_index_to_worktree_with_options(
248            repo.odb(),
249            index,
250            work_tree,
251            DiffIndexToWorktreeOptions {
252                repository_git_dir: Some(repo.git_dir().to_path_buf()),
253                index_mtime,
254                config: Some(Arc::clone(&config)),
255                thread_policy: Some(repo.thread_policy()),
256                refresh_index_stat_in_pass: false,
257                for_status: !content_compare,
258                worktree_rules: Some(Arc::clone(&rules_arc)),
259                ..DiffIndexToWorktreeOptions::default()
260            },
261        )?;
262        for entry in &entries {
263            WORKTREE_HAS_CHANGES_ENTRIES_SCANNED.fetch_add(1, Ordering::SeqCst);
264            if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
265                continue;
266            }
267            if worktree_index_entry_has_changes(
268                entry,
269                trust_filemode(config.as_ref()),
270                repo.odb(),
271                work_tree,
272                opts,
273            )? {
274                return Ok(true);
275            }
276        }
277        return Ok(false);
278    }
279
280    let worktree_rules = WorktreeRules::from_repository(repo, index)?;
281    let raw = diff_tree_to_worktree_with_git_dir_and_rules(
282        repo.odb(),
283        base_tree,
284        work_tree,
285        repo.git_dir(),
286        index,
287        Some(config.as_ref()),
288        Some(&worktree_rules),
289    )?;
290    let entries = apply_rename_detection_to_diff_entries(
291        repo.odb(),
292        raw,
293        Some(work_tree),
294        rename_threshold(opts),
295        config.as_ref(),
296        &repo.thread_policy(),
297    )?;
298    for entry in entries {
299        if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
300            continue;
301        }
302        if tree_entry_has_changes(repo.odb(), &entry, trust_filemode(config.as_ref()), opts)? {
303            return Ok(true);
304        }
305    }
306    Ok(false)
307}
308
309fn staged_has_changes(
310    repo: &Repository,
311    matcher: &PathspecMatcher<'_>,
312    opts: &DiffOptions,
313) -> Result<bool> {
314    let head_tree = head_tree(repo)?;
315    let index = repo.index_handle().load()?;
316    let config = repo.config()?;
317    let mut entries = diff_index_to_tree_with_renames(
318        repo.odb(),
319        &index,
320        head_tree.as_ref(),
321        false,
322        rename_threshold(opts),
323        config.as_ref(),
324        &repo.thread_policy(),
325    )?;
326    for entry in entries.drain(..) {
327        if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
328            continue;
329        }
330        if tree_entry_has_changes(repo.odb(), &entry, trust_filemode(config.as_ref()), opts)? {
331            return Ok(true);
332        }
333    }
334    Ok(false)
335}
336
337fn unstaged_has_changes(
338    repo: &Repository,
339    matcher: &PathspecMatcher<'_>,
340    opts: &DiffOptions,
341) -> Result<bool> {
342    let mut index = repo.index_handle().load()?;
343    worktree_has_changes(repo, &mut index, None, matcher, opts)
344}
345
346fn commit_trees_has_changes(
347    repo: &Repository,
348    base: &ObjectId,
349    tip: &ObjectId,
350    matcher: &PathspecMatcher<'_>,
351    opts: &DiffOptions,
352) -> Result<bool> {
353    let config = repo.config()?;
354    let base_tree = tree_oid_of_commit(repo.odb(), base)?;
355    let tip_tree = tree_oid_of_commit(repo.odb(), tip)?;
356    let mut entries = diff_trees_with_renames_and_policy(
357        repo.odb(),
358        Some(&base_tree),
359        Some(&tip_tree),
360        "",
361        rename_threshold(opts),
362        config.as_ref(),
363        &repo.thread_policy(),
364    )?;
365    for entry in entries.drain(..) {
366        if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
367            continue;
368        }
369        if tree_entry_has_changes(repo.odb(), &entry, trust_filemode(config.as_ref()), opts)? {
370            return Ok(true);
371        }
372    }
373    Ok(false)
374}
375
376impl<'repo> super::handle::Diff<'repo> {
377    /// Whether the selected diff has any real changes, stopping at the first one.
378    ///
379    /// Worktree targets honor the index stat cache and only hash blob content when needed.
380    /// When whitespace ignore flags are set on `opts`, content is compared under those rules;
381    /// a change that differs only in ignored whitespace returns `false`.
382    ///
383    /// # Errors
384    ///
385    /// Propagates repository, ODB, and diff errors.
386    pub fn has_changes(
387        &self,
388        target: &DiffTarget,
389        pathspecs: &[String],
390        opts: DiffOptions,
391    ) -> Result<bool> {
392        HAS_CHANGES_CALL_COUNT.fetch_add(1, Ordering::SeqCst);
393        let repo = self.repository();
394        let matcher = repo.pathspec_matcher(pathspecs)?;
395        match target {
396            DiffTarget::WorktreeVsHead => {
397                let mut index = repo.index_handle().load()?;
398                let head_tree = head_tree(repo)?;
399                worktree_has_changes(repo, &mut index, head_tree.as_ref(), &matcher, &opts)
400            }
401            DiffTarget::IndexVsHead => staged_has_changes(repo, &matcher, &opts),
402            DiffTarget::WorktreeVsIndex => unstaged_has_changes(repo, &matcher, &opts),
403            DiffTarget::WorktreeVsTree(tree) => {
404                let mut index = repo.index_handle().load()?;
405                worktree_has_changes(repo, &mut index, Some(tree), &matcher, &opts)
406            }
407            DiffTarget::Commit(commit) => {
408                let obj = repo.odb().read(commit)?;
409                let parsed = parse_commit(&obj.data)?;
410                let new_tree = parsed.tree;
411                let old_tree = parsed
412                    .parents
413                    .first()
414                    .map(|parent| tree_oid_of_commit(repo.odb(), parent))
415                    .transpose()?;
416                let config = repo.config()?;
417                let mut entries = diff_trees_with_renames_and_policy(
418                    repo.odb(),
419                    old_tree.as_ref(),
420                    Some(&new_tree),
421                    "",
422                    rename_threshold(&opts),
423                    config.as_ref(),
424                    &repo.thread_policy(),
425                )?;
426                for entry in entries.drain(..) {
427                    if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
428                        continue;
429                    }
430                    if tree_entry_has_changes(
431                        repo.odb(),
432                        &entry,
433                        trust_filemode(config.as_ref()),
434                        &opts,
435                    )? {
436                        return Ok(true);
437                    }
438                }
439                Ok(false)
440            }
441            DiffTarget::Trees { old, new } => match old {
442                Some(base) => commit_trees_has_changes(repo, base, new, &matcher, &opts),
443                None => {
444                    let config = repo.config()?;
445                    let tip_tree = tree_oid_of_commit(repo.odb(), new)?;
446                    let mut entries = diff_trees_with_renames_and_policy(
447                        repo.odb(),
448                        None,
449                        Some(&tip_tree),
450                        "",
451                        rename_threshold(&opts),
452                        config.as_ref(),
453                        &repo.thread_policy(),
454                    )?;
455                    for entry in entries.drain(..) {
456                        if !matcher.matches_change(entry.path(), entry.old_path.as_deref()) {
457                            continue;
458                        }
459                        if tree_entry_has_changes(
460                            repo.odb(),
461                            &entry,
462                            trust_filemode(config.as_ref()),
463                            &opts,
464                        )? {
465                            return Ok(true);
466                        }
467                    }
468                    Ok(false)
469                }
470            },
471        }
472    }
473}