Skip to main content

girt/rewrites/
detect.rs

1use std::collections::BTreeMap;
2use std::sync::atomic::AtomicBool;
3
4use super::similarity::{Budget, Content, spend};
5use super::{Copies, Detection, Error, InexactSkipped, Kind, Limits, Options, Rewrite};
6use crate::{EntryMode, ObjectId, ObjectKind, Objects, TreeChange, TreeValue};
7
8impl Objects {
9    /// Infers renames and optional copies between two stored trees, without changing storage.
10    ///
11    /// Only additions of regular/executable blobs are destinations. Deleted blobs are rename
12    /// sources; [`Copies::Modified`] additionally uses preimages of modified blobs, including
13    /// mode-only changes, and permits source reuse. [`Copies::ModifiedPostimage`] scores their
14    /// postimages but reports their preimage IDs. Symlinks, gitlinks and directories are never
15    /// results. File/directory replacements use the structural comparison's leaf changes.
16    /// Both object formats are supported; candidate blobs are read and verified even for exact IDs.
17    ///
18    /// Exact identity matches precede unique-basename pairing and exhaustive edited inference.
19    /// Unique unmatched basenames pair without copies at halfway from the minimum score to 100%.
20    /// This can prefer a basename pair over a more similar differently named destination.
21    /// Exact ties prefer an unused deleted source, then equal basename, then source raw path order.
22    /// Edited ties prefer higher rounded percentage, equal basename, source path, then
23    /// destination path; selected pairs greedily consume destinations and (without copies)
24    /// sources. This explicit deterministic policy does not promise Git's every heuristic
25    /// pairing choice. [`Rewrite`] defines scoring.
26    ///
27    /// `targets` is an optional set of exact destination byte paths, applied **after** all
28    /// inference and classification. It cannot change a tie or convert a copy into a rename.
29    /// Results sort by target bytes. `None` allows all; an empty slice returns no records but
30    /// still performs validation/inference. Paths need no OS/UTF-8 conversion.
31    ///
32    /// Cancellation is checked before validation, between reads/candidates, during span scanning,
33    /// and before and after sorting. Individual storage reads, allocation, tree parsing and sorting
34    /// cannot be interrupted. Callers own worker scheduling and must leave `cancel` set until
35    /// return. No attributes, clean/smudge/textconv drivers, index or worktree are consulted: these
36    /// are comparisons of stored blob bytes. Callers own configuration and merge/history policy.
37    ///
38    /// [`Self::detect_rewrites_or_exact`] instead returns the pairs already found when the
39    /// [`Options::candidate_limit`] skips exhaustive edited-content detection.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`Error`] on invalid options, tree/blob storage failures, missing/wrong-kind blobs,
44    /// cancellation, or exhausted candidate/resource limits. Errors return no partial inference;
45    /// no repository state is changed. Exact matching is attempted before the candidate limit,
46    /// but storage and work bounds apply throughout. See [`Limits`] for memory accounting.
47    ///
48    /// ```
49    /// use std::sync::atomic::AtomicBool;
50    ///
51    /// use girt::rewrites::{Kind, Limits, Options};
52    /// use girt::{EntryMode, InitKind, ObjectFormat, Repository, Tree, TreeEntry};
53    ///
54    /// let directory = tempfile::tempdir()?;
55    /// let repo = Repository::init(
56    ///     ObjectFormat::Sha1,
57    ///     directory.path().join("r"),
58    ///     InitKind::Bare,
59    /// )?;
60    ///
61    /// let loose = repo.loose_objects();
62    /// let tree = |files: &[(&[u8], &[u8])]| -> Result<_, Box<dyn std::error::Error>> {
63    ///     let entries = files
64    ///         .iter()
65    ///         .map(|(name, bytes)| {
66    ///             Ok(TreeEntry {
67    ///                 name: name.to_vec(),
68    ///                 mode: EntryMode::Blob,
69    ///                 id: loose.write_blob(bytes)?,
70    ///             })
71    ///         })
72    ///         .collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
73    ///     Ok(loose.write_tree(&Tree::new(ObjectFormat::Sha1, entries)?)?)
74    /// };
75    ///
76    /// let old = tree(&[(b"before", b"same content\n")])?;
77    /// let new = tree(&[(b"after", b"same content\n")])?;
78    ///
79    /// let records = repo.objects_with(Default::default())?.detect_rewrites(
80    ///     Some(old),
81    ///     Some(new),
82    ///     Options::default(),
83    ///     Limits::default(),
84    ///     None,
85    ///     &AtomicBool::new(false),
86    /// )?;
87    ///
88    /// assert_eq!(records[0].kind, Kind::Rename);
89    /// assert_eq!(records[0].similarity, 100);
90    /// assert_eq!(records[0].source_id, records[0].target_id);
91    /// # Ok::<(), Box<dyn std::error::Error>>(())
92    /// ```
93    pub fn detect_rewrites(
94        &self,
95        old: Option<ObjectId>,
96        new: Option<ObjectId>,
97        options: Options,
98        limits: Limits,
99        targets: Option<&[&[u8]]>,
100        cancel: &AtomicBool,
101    ) -> Result<Vec<Rewrite>, Error> {
102        let detection =
103            self.detect_rewrites_or_exact(old, new, options, limits, targets, cancel)?;
104        match detection.inexact_skipped {
105            None => Ok(detection.rewrites),
106            Some(skipped) => Err(Error::Candidates {
107                sources: skipped.sources,
108                targets: skipped.targets,
109                limit: skipped.limit,
110            }),
111        }
112    }
113
114    /// Infers rewrites as [`Self::detect_rewrites`] does, keeping pre-limit matches.
115    ///
116    /// When the remaining candidates exceed [`Options::candidate_limit`], edited-content
117    /// detection is skipped and [`Detection::inexact_skipped`] says so. Exact and unique-basename
118    /// pairs are still returned. Callers decide whether to warn, retry with a higher limit, or
119    /// accept the pairs found before the limit.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`Error`] on the same conditions as [`Self::detect_rewrites`], except that an
124    /// exceeded candidate limit is reported in the result rather than as [`Error::Candidates`].
125    ///
126    /// # Keep exact renames when edited detection is too expensive
127    ///
128    /// The fallback API, available since 0.10.0, keeps a useful result for large changes while
129    /// making its limitation visible. Unpaired additions/deletions must remain visible to the
130    /// caller. Other work limits and storage failures still return errors, never partial
131    /// detection.
132    ///
133    /// ```
134    /// use std::sync::atomic::AtomicBool;
135    ///
136    /// use girt::rewrites::{Error, Limits, Options};
137    /// use girt::{EntryMode, InitKind, ObjectFormat, Repository, Tree, TreeEntry};
138    ///
139    /// let directory = tempfile::tempdir()?;
140    /// let repo = Repository::init(
141    ///     ObjectFormat::Sha1,
142    ///     directory.path().join("r"),
143    ///     InitKind::Bare,
144    /// )?;
145    ///
146    /// let loose = repo.loose_objects();
147    /// let tree = |files: &[(&[u8], &[u8])]| -> Result<_, Box<dyn std::error::Error>> {
148    ///     let entries = files
149    ///         .iter()
150    ///         .map(|(name, bytes)| {
151    ///             Ok(TreeEntry {
152    ///                 name: name.to_vec(),
153    ///                 mode: EntryMode::Blob,
154    ///                 id: loose.write_blob(bytes)?,
155    ///             })
156    ///         })
157    ///         .collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
158    ///     Ok(loose.write_tree(&Tree::new(ObjectFormat::Sha1, entries)?)?)
159    /// };
160    ///
161    /// let old = tree(&[
162    ///     (b"exact-old", b"identical\n"),
163    ///     (b"edited-old", b"line one\nline two\n"),
164    /// ])?;
165    /// let new = tree(&[
166    ///     (b"exact-new", b"identical\n"),
167    ///     (b"edited-new", b"line one\nline THREE\n"),
168    /// ])?;
169    ///
170    /// let objects = repo.objects_with(Default::default())?;
171    /// let options = Options::default().with_candidate_limit(0);
172    /// let cancel = AtomicBool::new(false);
173    /// let detection = objects.detect_rewrites_or_exact(
174    ///     Some(old),
175    ///     Some(new),
176    ///     options,
177    ///     Limits::default(),
178    ///     None,
179    ///     &cancel,
180    /// )?;
181    ///
182    /// assert_eq!(detection.rewrites.len(), 1); // The identical pair survived.
183    /// assert_eq!(detection.rewrites[0].source, b"exact-old");
184    /// let skipped = detection.inexact_skipped.expect("one edited pair remains");
185    /// assert_eq!((skipped.sources, skipped.targets, skipped.limit), (1, 1, 0));
186    /// assert_eq!(skipped.suggested_limit(), 1);
187    ///
188    /// assert!(matches!(
189    ///     objects.detect_rewrites(
190    ///         Some(old),
191    ///         Some(new),
192    ///         options,
193    ///         Limits::default(),
194    ///         None,
195    ///         &cancel
196    ///     ),
197    ///     Err(Error::Candidates { .. })
198    /// ));
199    /// # Ok::<(), Box<dyn std::error::Error>>(())
200    /// ```
201    pub fn detect_rewrites_or_exact(
202        &self,
203        old: Option<ObjectId>,
204        new: Option<ObjectId>,
205        options: Options,
206        limits: Limits,
207        targets: Option<&[&[u8]]>,
208        cancel: &AtomicBool,
209    ) -> Result<Detection, Error> {
210        let budget = Budget {
211            remaining: limits,
212            cancel,
213        };
214        budget.check()?;
215        if !(1..=100).contains(&options.similarity) {
216            return Err(Error::Similarity(options.similarity));
217        }
218        let changes = self.compare_trees_with(old, new, limits.trees, cancel)?;
219        self.detect_rewrites_from_changes(&changes, options, limits, targets, cancel)
220    }
221
222    /// Reuses an already bounded structural comparison without traversing trees again.
223    pub(crate) fn detect_rewrites_from_changes(
224        &self,
225        changes: &[TreeChange],
226        options: Options,
227        limits: Limits,
228        targets: Option<&[&[u8]]>,
229        cancel: &AtomicBool,
230    ) -> Result<Detection, Error> {
231        self.detect_rewrites_with_memory(changes, options, limits, targets, cancel, None)
232    }
233
234    /// Includes verified, newly generated merge blobs without installing them in the store.
235    pub(crate) fn detect_rewrites_with_memory(
236        &self,
237        changes: &[TreeChange],
238        options: Options,
239        limits: Limits,
240        targets: Option<&[&[u8]]>,
241        cancel: &AtomicBool,
242        memory: Option<&BTreeMap<ObjectId, Vec<u8>>>,
243    ) -> Result<Detection, Error> {
244        let mut budget = Budget {
245            remaining: limits,
246            cancel,
247        };
248        budget.check()?;
249        if !(1..=100).contains(&options.similarity) {
250            return Err(Error::Similarity(options.similarity));
251        }
252        let (sources, destinations) = candidates(changes, options.copies, &mut budget)?;
253        let mut blobs = BTreeMap::new();
254        for candidate in sources.iter().chain(&destinations) {
255            budget.check()?;
256            if let std::collections::btree_map::Entry::Vacant(entry) =
257                blobs.entry(candidate.value.id)
258            {
259                entry.insert(read(self, candidate.value.id, &mut budget, memory)?);
260            }
261        }
262        let mut selection = Selection::new(&sources, &destinations, options);
263        selection.exact(&blobs, &mut budget)?;
264        let inexact_skipped = if options.similarity < 100 {
265            selection.edited(&mut blobs, &mut budget)?
266        } else {
267            None
268        };
269        budget.check()?;
270        let mut records = selection.records;
271        if let Some(targets) = targets {
272            // Build once so target filtering cannot multiply candidate work by filter count.
273            let mut selected = std::collections::BTreeSet::new();
274            for target in targets {
275                budget.work(
276                    target
277                        .len()
278                        .saturating_mul((selected.len() + 1).ilog2() as usize + 1),
279                )?;
280                selected.insert(*target);
281            }
282            records.retain(|record| selected.contains(record.target.as_slice()));
283        }
284        records.sort_by(|a, b| a.target.cmp(&b.target));
285        budget.check()?;
286        Ok(Detection {
287            rewrites: records,
288            inexact_skipped,
289        })
290    }
291}
292
293struct Candidate<'a> {
294    path: &'a [u8],
295    value: TreeValue,
296    source_id: ObjectId,
297    deleted: bool,
298}
299
300fn candidates<'a>(
301    changes: &'a [TreeChange],
302    copies: Copies,
303    budget: &mut Budget<'_>,
304) -> Result<(Vec<Candidate<'a>>, Vec<Candidate<'a>>), Error> {
305    let mut sources = Vec::new();
306    let mut targets = Vec::new();
307    for change in changes {
308        budget.work(1)?;
309        if let Some(old) = change.old.filter(|value| is_file(value.mode))
310            && (change.new.is_none()
311                || (copies != Copies::Disabled && change.new.is_some_and(|v| is_file(v.mode))))
312        {
313            let value = if copies == Copies::ModifiedPostimage {
314                change.new.unwrap_or(old)
315            } else {
316                old
317            };
318            sources.push(Candidate {
319                path: &change.path,
320                value,
321                source_id: old.id,
322                deleted: change.new.is_none(),
323            });
324        }
325        if change.old.is_none()
326            && let Some(new) = change.new.filter(|value| is_file(value.mode))
327        {
328            targets.push(Candidate {
329                path: &change.path,
330                value: new,
331                source_id: new.id,
332                deleted: false,
333            });
334        }
335    }
336    Ok((sources, targets))
337}
338
339fn is_file(mode: EntryMode) -> bool {
340    matches!(mode, EntryMode::Blob | EntryMode::Executable)
341}
342
343struct Blob {
344    bytes: Vec<u8>,
345    content: Option<Content>,
346}
347
348impl Blob {
349    fn binary(&self) -> bool {
350        self.bytes[..self.bytes.len().min(8000)].contains(&0)
351    }
352}
353
354fn read(
355    objects: &Objects,
356    id: ObjectId,
357    budget: &mut Budget<'_>,
358    memory: Option<&BTreeMap<ObjectId, Vec<u8>>>,
359) -> Result<Blob, Error> {
360    let mut limits = budget.remaining.read;
361    limits.max_object_bytes = limits.max_object_bytes.min(budget.remaining.max_blob_bytes);
362    if let Some(bytes) = memory.and_then(|memory| memory.get(&id)) {
363        if bytes.len() > limits.max_object_bytes {
364            return Err(Error::Limit("overlay blob bytes"));
365        }
366        spend(
367            &mut budget.remaining.max_blob_bytes,
368            bytes.len(),
369            "blob bytes",
370        )?;
371        budget.check()?;
372        return Ok(Blob {
373            bytes: bytes.clone(),
374            content: None,
375        });
376    }
377    let object = objects
378        .read_with(id, limits)
379        .map_err(|source| Error::Read { id, source })?
380        .ok_or(Error::Missing(id))?;
381    if object.kind() != ObjectKind::Blob {
382        return Err(Error::NotBlob {
383            id,
384            actual: object.kind(),
385        });
386    }
387    let bytes = object.into_data();
388    spend(
389        &mut budget.remaining.max_blob_bytes,
390        bytes.len(),
391        "blob bytes",
392    )?;
393    budget.check()?;
394    Ok(Blob {
395        bytes,
396        content: None,
397    })
398}
399
400struct Selection<'a> {
401    sources: &'a [Candidate<'a>],
402    targets: &'a [Candidate<'a>],
403    used_sources: Vec<bool>,
404    used_targets: Vec<bool>,
405    options: Options,
406    records: Vec<Rewrite>,
407}
408
409impl<'a> Selection<'a> {
410    fn new(sources: &'a [Candidate<'a>], targets: &'a [Candidate<'a>], options: Options) -> Self {
411        Self {
412            sources,
413            targets,
414            used_sources: vec![false; sources.len()],
415            used_targets: vec![false; targets.len()],
416            options,
417            records: Vec::new(),
418        }
419    }
420
421    fn available(&self, source: usize) -> bool {
422        !self.used_sources[source] || self.options.copies != Copies::Disabled
423    }
424
425    fn exact(
426        &mut self,
427        blobs: &BTreeMap<ObjectId, Blob>,
428        budget: &mut Budget<'_>,
429    ) -> Result<(), Error> {
430        let mut by_id: BTreeMap<ObjectId, Vec<usize>> = BTreeMap::new();
431        for (index, source) in self.sources.iter().enumerate() {
432            budget.work(1)?;
433            if self.options.track_empty || !blobs[&source.value.id].bytes.is_empty() {
434                by_id.entry(source.value.id).or_default().push(index);
435            }
436        }
437        for (target, destination) in self.targets.iter().enumerate() {
438            budget.work(1)?;
439            let Some(sources) = by_id.get(&destination.value.id) else {
440                continue;
441            };
442            let mut best = None;
443            for &source in sources {
444                budget.pair()?;
445                if !self.available(source) {
446                    continue;
447                }
448                let candidate = &self.sources[source];
449                let rank = (
450                    !candidate.deleted || self.used_sources[source],
451                    basename(candidate.path) != basename(destination.path),
452                    candidate.path,
453                );
454                if best.is_none_or(|(_, previous)| rank < previous) {
455                    best = Some((source, rank));
456                }
457            }
458            if let Some((source, _)) = best {
459                self.select(source, target, 100);
460            }
461        }
462        Ok(())
463    }
464
465    /// Pairs remaining candidates by edited content, unless the candidate limit skips it.
466    fn edited(
467        &mut self,
468        blobs: &mut BTreeMap<ObjectId, Blob>,
469        budget: &mut Budget<'_>,
470    ) -> Result<Option<InexactSkipped>, Error> {
471        self.same_basename(blobs, budget)?;
472        // Git counts available sources (reusable ones too, with copies) and unmatched destinations,
473        // including binary files, and empty files when empty renames are enabled. Decide the skip
474        // before filtering to scorable files, which can leave nothing to score.
475        let counted = |blob: &Blob| self.options.track_empty || !blob.bytes.is_empty();
476        let scorable = |blob: &Blob| {
477            !blob.bytes.is_empty() && (self.options.approximate_binary || !blob.binary())
478        };
479        let source_count = (0..self.sources.len())
480            .filter(|&s| self.available(s) && counted(&blobs[&self.sources[s].value.id]))
481            .count();
482        let target_count = (0..self.targets.len())
483            .filter(|&t| !self.used_targets[t] && counted(&blobs[&self.targets[t].value.id]))
484            .count();
485        // Git compares the candidate product with the squared limit, so one source may be scored
486        // against many destinations. Widen so the comparison cannot overflow.
487        let limit = self.options.candidate_limit as u128;
488        if source_count as u128 * target_count as u128 > limit * limit {
489            return Ok(Some(InexactSkipped {
490                sources: source_count,
491                targets: target_count,
492                limit: self.options.candidate_limit,
493            }));
494        }
495        let sources: Vec<_> = (0..self.sources.len())
496            .filter(|&s| self.available(s) && scorable(&blobs[&self.sources[s].value.id]))
497            .collect();
498        let targets: Vec<_> = (0..self.targets.len())
499            .filter(|&t| !self.used_targets[t] && scorable(&blobs[&self.targets[t].value.id]))
500            .collect();
501        if sources.is_empty() || targets.is_empty() {
502            return Ok(None);
503        }
504        for id in sources
505            .iter()
506            .map(|&s| self.sources[s].value.id)
507            .chain(targets.iter().map(|&t| self.targets[t].value.id))
508        {
509            let blob = blobs.get_mut(&id).expect("candidate blob was loaded");
510            if blob.content.is_none() {
511                blob.content = Some(Content::new(&blob.bytes, budget)?);
512            }
513        }
514        let mut pairs = Vec::new();
515        for source in sources {
516            for &target in &targets {
517                budget.pair()?;
518                let old = blobs[&self.sources[source].value.id]
519                    .content
520                    .as_ref()
521                    .unwrap();
522                let new = blobs[&self.targets[target].value.id]
523                    .content
524                    .as_ref()
525                    .unwrap();
526                if let Some(score) = old.score(new, self.options.similarity, budget)? {
527                    pairs.push((source, target, score));
528                }
529            }
530        }
531        budget.check()?;
532        pairs.sort_by_key(|&(source, target, score)| {
533            (
534                std::cmp::Reverse(score),
535                basename(self.sources[source].path) != basename(self.targets[target].path),
536                self.sources[source].path,
537                self.targets[target].path,
538            )
539        });
540        budget.check()?;
541        for (source, target, score) in pairs {
542            budget.work(1)?;
543            if self.available(source) && !self.used_targets[target] {
544                self.select(source, target, score);
545            }
546        }
547        Ok(None)
548    }
549
550    /// Unique unmatched basenames get first refusal at halfway from the requested score to 100%.
551    /// Copy detection skips this pass because its sources remain reusable.
552    fn same_basename(
553        &mut self,
554        blobs: &mut BTreeMap<ObjectId, Blob>,
555        budget: &mut Budget<'_>,
556    ) -> Result<(), Error> {
557        if self.options.copies != Copies::Disabled {
558            return Ok(());
559        }
560        let sources = unique_basenames(
561            self.sources,
562            &self.used_sources,
563            blobs,
564            self.options.track_empty,
565            budget,
566        )?;
567        let targets = unique_basenames(
568            self.targets,
569            &self.used_targets,
570            blobs,
571            self.options.track_empty,
572            budget,
573        )?;
574        for (name, source) in sources {
575            budget.work(
576                name.len()
577                    .saturating_mul((targets.len() + 1).ilog2() as usize + 1),
578            )?;
579            let (Some(source), Some(&Some(target))) = (source, targets.get(name)) else {
580                continue;
581            };
582            budget.pair()?;
583            let source_id = self.sources[source].value.id;
584            let target_id = self.targets[target].value.id;
585            if [source_id, target_id].iter().any(|id| {
586                let blob = &blobs[id];
587                blob.bytes.is_empty() || (!self.options.approximate_binary && blob.binary())
588            }) {
589                continue;
590            }
591            for id in [source_id, target_id] {
592                let blob = blobs.get_mut(&id).expect("candidate blob was loaded");
593                if blob.content.is_none() {
594                    blob.content = Some(Content::new(&blob.bytes, budget)?);
595                }
596            }
597            let old = blobs[&source_id].content.as_ref().unwrap();
598            let new = blobs[&target_id].content.as_ref().unwrap();
599            let minimum = 100 + u16::from(self.options.similarity);
600            if let Some(score) = old.score_half_percent(new, minimum, budget)? {
601                self.select(source, target, score);
602            }
603        }
604        Ok(())
605    }
606
607    fn select(&mut self, source: usize, target: usize, similarity: u8) {
608        let old = &self.sources[source];
609        let new = &self.targets[target];
610        let kind = if old.deleted && !self.used_sources[source] {
611            Kind::Rename
612        } else {
613            Kind::Copy
614        };
615        self.records.push(Rewrite {
616            source: old.path.to_vec(),
617            target: new.path.to_vec(),
618            source_id: old.source_id,
619            target_id: new.value.id,
620            similarity,
621            kind,
622        });
623        self.used_sources[source] = true;
624        self.used_targets[target] = true;
625    }
626}
627
628/// Ambiguous basenames remain present as `None`, so a later collision cannot become unique again.
629fn unique_basenames<'a>(
630    candidates: &[Candidate<'a>],
631    used: &[bool],
632    blobs: &BTreeMap<ObjectId, Blob>,
633    track_empty: bool,
634    budget: &mut Budget<'_>,
635) -> Result<BTreeMap<&'a [u8], Option<usize>>, Error> {
636    let mut names = BTreeMap::new();
637    for (index, candidate) in candidates.iter().enumerate() {
638        budget.work(
639            candidate
640                .path
641                .len()
642                .saturating_mul((names.len() + 1).ilog2() as usize + 1),
643        )?;
644        if !used[index] && (track_empty || !blobs[&candidate.value.id].bytes.is_empty()) {
645            names
646                .entry(basename(candidate.path))
647                .and_modify(|entry| *entry = None)
648                .or_insert(Some(index));
649        }
650    }
651    Ok(names)
652}
653
654fn basename(path: &[u8]) -> &[u8] {
655    path.rsplit(|&b| b == b'/').next().unwrap_or(path)
656}