Skip to main content

grit_lib/diff/
pickaxe.rs

1//! Pickaxe filters (`-S` / `-G`) for revision walks and diffs.
2//!
3//! `-S` keeps commits where the number of occurrences of a string (or ERE pattern with
4//! `--pickaxe-regex`) differs between a file's pre- and post-image compared to the first
5//! parent. `-G` keeps commits where added or removed lines match a regex (Git line-level diff).
6//! Merge commits are omitted unless combined merge diffs are requested (matches default `git log`).
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use crate::config::ConfigSet;
12use crate::diff::core::diff_trees_for_pathspecs;
13use crate::diff::posix_ere::PosixEre;
14use crate::diff::structured::SideBytes;
15use crate::diff::{
16    detect_renames_with_policy, renames_from_config, DiffEntry, DiffStatus, RenameDetection,
17};
18use crate::error::{Error, Result};
19use crate::objects::{parse_commit, FileMode, ObjectId};
20use crate::parallel::ThreadPolicy;
21use crate::repo::Repository;
22
23/// Rename pairing a pickaxe applies to added and deleted paths before matching them.
24#[derive(Debug, Clone, Copy)]
25pub(crate) struct PickaxeRenames<'a> {
26    /// Similarity percent, or `None` when renames are not detected.
27    threshold: Option<u32>,
28    /// Source of `diff.renameLimit` and rename-scoring thread settings.
29    config: &'a ConfigSet,
30}
31
32impl<'a> PickaxeRenames<'a> {
33    pub(crate) fn new(detection: RenameDetection, config: &'a ConfigSet) -> Self {
34        Self {
35            threshold: detection.threshold_percent(),
36            config,
37        }
38    }
39
40    /// Git's `log` default: `diff.renames` from `config`, at the default similarity.
41    pub(crate) fn from_config(config: &'a ConfigSet) -> Self {
42        Self::new(renames_from_config(config), config)
43    }
44
45    pub(crate) fn config(&self) -> &'a ConfigSet {
46        self.config
47    }
48}
49
50/// Which pickaxe mode is active (`-S` or `-G`).
51#[derive(Debug, Clone)]
52pub enum PickaxeKind {
53    /// Occurrence count change (`git log -S` / `git diff -S`).
54    Occurrences {
55        /// Needle string when not using [`Pickaxe::pickaxe_regex`].
56        needle: String,
57    },
58    /// Line-level regex on added/removed lines (`git log -G`).
59    LineGrep,
60}
61
62/// Compiled pickaxe filter for [`crate::rev_list::rev_list`].
63#[derive(Debug, Clone)]
64pub struct Pickaxe {
65    kind: PickaxeKind,
66    ignore_case: bool,
67    /// When true with [`PickaxeKind::Occurrences`], interpret the needle as ERE (`--pickaxe-regex`).
68    pickaxe_regex: bool,
69    /// POSIX extended regex (`REG_EXTENDED | REG_NEWLINE`) for `-G` and `-S --pickaxe-regex`.
70    ere: Option<PosixEre>,
71}
72
73impl Pickaxe {
74    /// `-S` / occurrence-count pickaxe (literal string unless [`Self::pickaxe_regex`]).
75    #[must_use]
76    pub fn occurrences(needle: impl Into<String>) -> Self {
77        Self {
78            kind: PickaxeKind::Occurrences {
79                needle: needle.into(),
80            },
81            ignore_case: false,
82            pickaxe_regex: false,
83            ere: None,
84        }
85    }
86
87    /// `-G` / line-level regex pickaxe (Git `REG_EXTENDED | REG_NEWLINE`).
88    ///
89    /// # Errors
90    ///
91    /// Returns [`PickaxeError::InvalidPattern`] when the pattern does not compile.
92    pub fn line_grep(pattern: &str, ignore_case: bool) -> std::result::Result<Self, PickaxeError> {
93        let ere = compile_pickaxe_ere(pattern, ignore_case)?;
94        Ok(Self {
95            kind: PickaxeKind::LineGrep,
96            ignore_case,
97            pickaxe_regex: false,
98            ere: Some(ere),
99        })
100    }
101
102    /// Case-insensitive matching (Git `-i` / `--regexp-ignore-case`).
103    #[must_use]
104    pub fn ignore_case(mut self, on: bool) -> Self {
105        self.ignore_case = on;
106        self
107    }
108
109    /// Treat `-S` needle as an ERE (`--pickaxe-regex`; only valid with occurrence mode).
110    #[must_use]
111    pub fn pickaxe_regex(mut self, on: bool) -> Self {
112        self.pickaxe_regex = on;
113        self
114    }
115
116    /// Finalize a pickaxe built with [`Self::occurrences`] (compiles ERE when requested).
117    ///
118    /// # Errors
119    ///
120    /// Returns [`PickaxeError::InvalidPattern`] when `--pickaxe-regex` is set and the needle
121    /// does not compile as ERE.
122    pub fn build(mut self) -> std::result::Result<Self, PickaxeError> {
123        if self.pickaxe_regex {
124            let PickaxeKind::Occurrences { ref needle } = self.kind else {
125                return Err(PickaxeError::InvalidPattern {
126                    pattern: String::new(),
127                    detail: "--pickaxe-regex cannot be used with -G".into(),
128                });
129            };
130            self.ere = Some(compile_pickaxe_ere(needle, self.ignore_case)?);
131        }
132        Ok(self)
133    }
134
135    /// Whether this filter requires per-commit tree diffs.
136    #[must_use]
137    pub fn is_active(&self) -> bool {
138        true
139    }
140
141    /// Shared handle for [`crate::rev_list::RevListOptions`].
142    #[must_use]
143    pub fn into_shared(self) -> Arc<Self> {
144        Arc::new(self)
145    }
146
147    /// Returns true when `commit` would be kept by `git log` with this pickaxe vs its first parent.
148    ///
149    /// Matches Git's order of work: the tree diff is limited to `paths` first, then deleted and
150    /// added paths are paired as renames per `renames`, and only then is each file pair tested.
151    /// A rename whose old and new file hold the same number of matches (`-S`), or whose diff
152    /// changes no matching line (`-G`), is therefore not a change. A file moved into the
153    /// pathspec from outside it is an addition, since its old path was never diffed.
154    ///
155    /// Merge commits are skipped (Git default without combined merge diffs). Non-merge commits
156    /// compare to the first parent only. Binary files are skipped for `-G`; `-S` searches binary
157    /// blobs.
158    ///
159    /// # Parameters
160    ///
161    /// - `paths` — repository-relative pathspecs limiting the diff; empty means the whole tree.
162    /// - `renames` — rename pairing; callers following Git use
163    ///   [`renames_from_config`] (`diff.renames`).
164    /// - `config` — supplies `diff.renameLimit` and rename-scoring thread settings.
165    ///
166    /// # Errors
167    ///
168    /// Propagates object read and diff errors.
169    pub fn matches_commit(
170        &self,
171        repo: &Repository,
172        commit_oid: ObjectId,
173        paths: &[String],
174        renames: RenameDetection,
175        config: &ConfigSet,
176    ) -> Result<bool> {
177        let data = repo.odb().read(&commit_oid)?.data;
178        let commit = parse_commit(&data)?;
179        if commit.parents.len() > 1 {
180            return Ok(false);
181        }
182        let parent_tree = if let Some(&parent_oid) = commit.parents.first() {
183            let parent_data = repo.odb().read(&parent_oid)?.data;
184            let parent = parse_commit(&parent_data)?;
185            if parent.tree == commit.tree {
186                return Ok(false);
187            }
188            Some(parent.tree)
189        } else {
190            None
191        };
192        let renames = PickaxeRenames::new(renames, config);
193        self.matches_trees(
194            repo,
195            parent_tree.as_ref(),
196            &commit.tree,
197            paths,
198            renames,
199            &mut HashMap::new(),
200        )
201    }
202
203    /// Pickaxe match using pre-resolved parent and commit trees (avoids re-reading commits).
204    ///
205    /// `parent_tree` is `None` for root commits. Merge commits must be filtered out by the caller.
206    /// `blob_occurrence_cache` memoizes literal `-S` counts per blob across calls.
207    pub(crate) fn matches_trees(
208        &self,
209        repo: &Repository,
210        parent_tree: Option<&ObjectId>,
211        commit_tree: &ObjectId,
212        paths: &[String],
213        renames: PickaxeRenames<'_>,
214        blob_occurrence_cache: &mut HashMap<ObjectId, usize>,
215    ) -> Result<bool> {
216        let diff_options = crate::diff::DiffOptions::from_config(renames.config());
217        let odb = repo.odb();
218        let entries = diff_trees_for_pathspecs(odb, parent_tree, Some(commit_tree), paths)?;
219        // Rename pairing only ever combines an added path with a deleted one, so every other
220        // entry can be tested (and can end the search) before paying for it.
221        let mut unpaired = Vec::new();
222        for entry in entries {
223            if matches!(entry.status, DiffStatus::Added | DiffStatus::Deleted) {
224                unpaired.push(entry);
225            } else if self.changed_filepair_matches(
226                odb,
227                &entry,
228                &diff_options,
229                blob_occurrence_cache,
230            )? {
231                return Ok(true);
232            }
233        }
234        if unpaired.is_empty() {
235            return Ok(false);
236        }
237        // Any pair or lone side that matches has a side that matches on its own.
238        let mut any_side_matches = false;
239        for entry in &unpaired {
240            if self.side_may_match(odb, entry, blob_occurrence_cache)? {
241                any_side_matches = true;
242                break;
243            }
244        }
245        if !any_side_matches {
246            return Ok(false);
247        }
248        let pairs = match renames.threshold {
249            Some(threshold) => detect_renames_with_policy(
250                odb,
251                None,
252                unpaired,
253                threshold,
254                renames.config,
255                &ThreadPolicy::default(),
256                None,
257            )?,
258            None => unpaired,
259        };
260        for entry in &pairs {
261            if self.changed_filepair_matches(odb, entry, &diff_options, blob_occurrence_cache)? {
262                return Ok(true);
263            }
264        }
265        Ok(false)
266    }
267
268    fn changed_filepair_matches(
269        &self,
270        odb: &crate::odb::Odb,
271        entry: &DiffEntry,
272        diff_options: &crate::diff::DiffOptions,
273        blob_occurrence_cache: &mut HashMap<ObjectId, usize>,
274    ) -> Result<bool> {
275        if entry.old_oid == entry.new_oid && entry.old_mode == entry.new_mode {
276            return Ok(false);
277        }
278        self.filepair_matches(odb, entry, diff_options, blob_occurrence_cache)
279    }
280
281    /// Whether the one present side of an added or deleted `entry` holds a match by itself.
282    fn side_may_match(
283        &self,
284        odb: &crate::odb::Odb,
285        entry: &DiffEntry,
286        blob_occurrence_cache: &mut HashMap<ObjectId, usize>,
287    ) -> Result<bool> {
288        let (oid, mode) = if entry.status == DiffStatus::Deleted {
289            (&entry.old_oid, entry.old_mode)
290        } else {
291            (&entry.new_oid, entry.new_mode)
292        };
293        match &self.kind {
294            PickaxeKind::Occurrences { .. } => {
295                Ok(self.occurrences_in(odb, oid, mode, blob_occurrence_cache)? > 0)
296            }
297            PickaxeKind::LineGrep => {
298                let (bytes, binary) = blob_side(odb, mode, oid)?;
299                let Some(regex) = self.ere.as_ref().filter(|_| !binary) else {
300                    return Ok(false);
301                };
302                Ok(regex.is_match(&bytes.data))
303            }
304        }
305    }
306
307    fn filepair_matches(
308        &self,
309        odb: &crate::odb::Odb,
310        entry: &DiffEntry,
311        diff_options: &crate::diff::DiffOptions,
312        blob_occurrence_cache: &mut HashMap<ObjectId, usize>,
313    ) -> Result<bool> {
314        match &self.kind {
315            PickaxeKind::Occurrences { .. } => {
316                let old = self.occurrences_in(
317                    odb,
318                    &entry.old_oid,
319                    entry.old_mode,
320                    blob_occurrence_cache,
321                )?;
322                let new = self.occurrences_in(
323                    odb,
324                    &entry.new_oid,
325                    entry.new_mode,
326                    blob_occurrence_cache,
327                )?;
328                Ok(old != new)
329            }
330            PickaxeKind::LineGrep => {
331                let (old_bytes, old_bin) = blob_side(odb, entry.old_mode, &entry.old_oid)?;
332                let (new_bytes, new_bin) = blob_side(odb, entry.new_mode, &entry.new_oid)?;
333                if old_bin || new_bin {
334                    return Ok(false);
335                }
336                let Some(regex) = self.ere.as_ref() else {
337                    return Ok(false);
338                };
339                // Under REG_NEWLINE a match within one line is also a match within its whole
340                // blob, so a pair where neither side matches has no matching changed line.
341                if !regex.is_match(&old_bytes.data) && !regex.is_match(&new_bytes.data) {
342                    return Ok(false);
343                }
344                Ok(line_grep_match(
345                    &String::from_utf8_lossy(&old_bytes.data),
346                    &String::from_utf8_lossy(&new_bytes.data),
347                    regex,
348                    diff_options,
349                ))
350            }
351        }
352    }
353
354    /// `-S` match count in one side of a file pair, memoized per blob in `cache`.
355    ///
356    /// The cache is only valid for this pickaxe: counts depend on the needle and its flags.
357    fn occurrences_in(
358        &self,
359        odb: &crate::odb::Odb,
360        oid: &ObjectId,
361        mode: FileMode,
362        cache: &mut HashMap<ObjectId, usize>,
363    ) -> Result<usize> {
364        let PickaxeKind::Occurrences { needle } = &self.kind else {
365            return Ok(0);
366        };
367        if mode.is_absent() || *oid == ObjectId::zero() {
368            return Ok(0);
369        }
370        let count_in =
371            |bytes: &[u8]| count_occurrences(bytes, needle, self.ignore_case, self.ere.as_ref());
372        if mode == FileMode::GITLINK {
373            let (bytes, _) = blob_side(odb, mode, oid)?;
374            return Ok(count_in(bytes.data.as_slice()));
375        }
376        if let Some(&count) = cache.get(oid) {
377            return Ok(count);
378        }
379        let count = count_in(&odb.read(oid)?.data);
380        cache.insert(*oid, count);
381        Ok(count)
382    }
383}
384
385/// Invalid pickaxe pattern.
386#[derive(Debug, thiserror::Error)]
387pub enum PickaxeError {
388    /// Pattern compilation failed.
389    #[error("invalid pickaxe pattern `{pattern}`: {detail}")]
390    InvalidPattern {
391        /// Original pattern text.
392        pattern: String,
393        /// Underlying error message.
394        detail: String,
395    },
396}
397
398impl From<PickaxeError> for Error {
399    fn from(value: PickaxeError) -> Self {
400        match value {
401            PickaxeError::InvalidPattern { pattern, detail } => {
402                Error::Message(format!("invalid pickaxe pattern `{pattern}`: {detail}"))
403            }
404        }
405    }
406}
407
408/// Git `regcomp(..., REG_EXTENDED | REG_NEWLINE)` for pickaxe patterns.
409fn compile_pickaxe_ere(
410    pattern: &str,
411    ignore_case: bool,
412) -> std::result::Result<PosixEre, PickaxeError> {
413    PosixEre::compile(pattern, ignore_case).map_err(|detail| PickaxeError::InvalidPattern {
414        pattern: pattern.to_owned(),
415        detail,
416    })
417}
418
419fn count_occurrences(
420    haystack: &[u8],
421    needle: &str,
422    ignore_case: bool,
423    ere: Option<&PosixEre>,
424) -> usize {
425    if haystack.is_empty() {
426        return 0;
427    }
428    if let Some(re) = ere {
429        return re.count_nonoverlapping(haystack);
430    }
431    if ignore_case {
432        let h = String::from_utf8_lossy(haystack).to_lowercase();
433        let n = needle.to_lowercase();
434        return count_substr(h.as_bytes(), n.as_bytes());
435    }
436    count_substr(haystack, needle.as_bytes())
437}
438
439fn count_substr(haystack: &[u8], needle: &[u8]) -> usize {
440    if needle.is_empty() {
441        return 0;
442    }
443    memchr::memmem::find_iter(haystack, needle).count()
444}
445
446fn blob_side(odb: &crate::odb::Odb, mode: FileMode, oid: &ObjectId) -> Result<(SideBytes, bool)> {
447    if mode == FileMode::GITLINK {
448        let line = if *oid == ObjectId::zero() {
449            String::new()
450        } else {
451            format!("Subproject commit {}\n", oid.to_hex())
452        };
453        return Ok((SideBytes::from_bytes(line.into_bytes()), false));
454    }
455    if mode.is_absent() || *oid == ObjectId::zero() {
456        return Ok((SideBytes::from_bytes(Vec::new()), false));
457    }
458    let data = odb.read(oid)?.data;
459    let binary = data.iter().take(8000).any(|&b| b == 0);
460    Ok((SideBytes::from_bytes(data), binary))
461}
462
463fn line_grep_match(
464    old_text: &str,
465    new_text: &str,
466    regex: &PosixEre,
467    diff_options: &crate::diff::DiffOptions,
468) -> bool {
469    use crate::diff::core::compacted_line_ops_for_options;
470    use crate::diff::unified_emit::LineSide;
471    use similar::DiffOp;
472
473    let before = LineSide::from_text(old_text);
474    let after = LineSide::from_text(new_text);
475    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
476    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
477    for op in compacted_line_ops_for_options(old_text, new_text, diff_options) {
478        match op {
479            DiffOp::Equal { .. } => {}
480            DiffOp::Delete {
481                old_index, old_len, ..
482            } => {
483                for line in &old_lines[old_index..old_index + old_len] {
484                    if regex.is_match(line.as_bytes()) {
485                        return true;
486                    }
487                }
488            }
489            DiffOp::Insert {
490                new_index, new_len, ..
491            } => {
492                for line in &new_lines[new_index..new_index + new_len] {
493                    if regex.is_match(line.as_bytes()) {
494                        return true;
495                    }
496                }
497            }
498            DiffOp::Replace {
499                old_index,
500                old_len,
501                new_index,
502                new_len,
503                ..
504            } => {
505                for line in &old_lines[old_index..old_index + old_len] {
506                    if regex.is_match(line.as_bytes()) {
507                        return true;
508                    }
509                }
510                for line in &new_lines[new_index..new_index + new_len] {
511                    if regex.is_match(line.as_bytes()) {
512                        return true;
513                    }
514                }
515            }
516        }
517    }
518    false
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn literal_counts_do_not_overlap() {
527        assert_eq!(count_occurrences(b"aaaa", "aa", false, None), 2);
528        assert_eq!(count_occurrences(b"aaa", "aa", false, None), 1);
529        assert_eq!(count_occurrences(b"abcabc", "abc", false, None), 2);
530        assert_eq!(count_occurrences(b"abc", "", false, None), 0);
531        assert_eq!(count_occurrences(b"", "a", false, None), 0);
532    }
533
534    #[test]
535    fn ignore_case_counts_fold_both_sides() {
536        assert_eq!(count_occurrences(b"Needle NEEDLE", "needle", true, None), 2);
537        assert_eq!(
538            count_occurrences(b"Needle NEEDLE", "needle", false, None),
539            0
540        );
541    }
542}