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