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