Skip to main content

girt/
tree_diff.rs

1//! Tree changes combined with inferred renames and copies.
2//! This composes [`Objects::compare_trees`] and [`Objects::detect_rewrites_or_exact`], retaining
3//! unconsumed changes and sorting by destination path. Rename inference uses stored bytes and no
4//! attributes.
5//!
6//! ```
7//! use girt::tree::TreeEditor;
8//! use girt::tree_diff::Entry;
9//! use girt::{InitKind, ObjectFormat, Repository};
10//!
11//! let directory = tempfile::tempdir()?;
12//! let repo = Repository::init(
13//!     ObjectFormat::Sha1,
14//!     directory.path().join("r"),
15//!     InitKind::Bare,
16//! )?;
17//!
18//! let loose = repo.loose_objects();
19//! let blob = loose.write_blob(b"retained content\n")?;
20//! let mut editor = TreeEditor::new(&loose);
21//! editor.set(b"old", blob)?;
22//! let old = editor.write()?;
23//!
24//! editor.remove(b"old")?;
25//! editor.set(b"new", blob)?;
26//! let new = editor.write()?;
27//!
28//! let objects = repo.objects()?;
29//! let changes = objects.diff_trees(Some(old), Some(new))?;
30//!
31//! assert!(matches!(
32//!     &changes.entries[0],
33//!     Entry::Rewrite {
34//!         similarity: 100,
35//!         ..
36//!     }
37//! ));
38//! # Ok::<(), Box<dyn std::error::Error>>(())
39//! ```
40use std::collections::{BTreeMap, BTreeSet};
41use std::sync::atomic::AtomicBool;
42
43use crate::pathspec::Pathspec;
44pub use crate::rewrites::Limits;
45use crate::rewrites::{Kind, Options};
46use crate::{ObjectId, Objects, TreeChange, TreeValue};
47
48/// One line of the comparison: a plain change or an inferred rename/copy.
49#[derive(Clone, Debug, Eq, PartialEq)]
50#[non_exhaustive]
51pub enum Entry {
52    /// An unconsumed structural change.
53    Change(TreeChange),
54    /// An inferred rename or copy, carrying both original tree values.
55    Rewrite {
56        /// Rename or copy.
57        kind: Kind,
58        /// Similarity percentage, from 0 through 100.
59        similarity: u8,
60        /// Original byte path.
61        source: Vec<u8>,
62        /// Destination byte path.
63        target: Vec<u8>,
64        /// Original entry.
65        old: TreeValue,
66        /// Destination entry.
67        new: TreeValue,
68    },
69}
70
71impl Entry {
72    /// Returns the content sides as a structural change for [`crate::content_diff::BlobContent`].
73    /// For rewrites the path is the destination; the source remains available on this entry.
74    pub fn content_change(&self) -> TreeChange {
75        match self {
76            Self::Change(change) => change.clone(),
77            Self::Rewrite {
78                target, old, new, ..
79            } => TreeChange {
80                path: target.clone(),
81                old: Some(*old),
82                new: Some(*new),
83            },
84        }
85    }
86
87    /// The path Git sorts output by: the destination for rewrites.
88    pub fn sort_path(&self) -> &[u8] {
89        match self {
90            Entry::Change(change) => &change.path,
91            Entry::Rewrite { target, .. } => target,
92        }
93    }
94}
95
96/// Combined changes ordered by destination bytes, with an explicit rename-limit diagnostic.
97#[derive(Clone, Debug)]
98#[non_exhaustive]
99pub struct Comparison {
100    /// Unconsumed changes and inferred rewrites. Exact matches survive candidate-limit skips.
101    pub entries: Vec<Entry>,
102    /// Exhaustive edited-content inference was skipped; unmatched paths remain plain changes.
103    pub inexact_skipped: Option<crate::rewrites::InexactSkipped>,
104}
105
106/// A structural comparison or rewrite inference failed.
107#[derive(Debug, thiserror::Error)]
108#[non_exhaustive]
109pub enum Error {
110    /// Structural tree comparison failed.
111    #[error(transparent)]
112    Trees(#[from] crate::TreeCompareError),
113    /// Rename/copy inference failed.
114    #[error(transparent)]
115    Rewrites(#[from] crate::rewrites::Error),
116    /// Inference returned a path absent from the structural comparison.
117    #[error("rewrite paths missing from tree comparison")]
118    MissingPath,
119}
120impl Objects {
121    /// Compares trees and infers renames using default options and limits, without cancellation.
122    ///
123    /// Wraps [`Self::diff_trees_with`] with default options and limits.
124    ///
125    /// # Errors
126    ///
127    /// Returns storage, malformed-tree, resource or inference errors from the two explicit APIs.
128    pub fn diff_trees(
129        &self,
130        old: Option<ObjectId>,
131        new: Option<ObjectId>,
132    ) -> Result<Comparison, Error> {
133        self.diff_trees_with(
134            old,
135            new,
136            Options::default(),
137            Limits::default(),
138            &AtomicBool::new(false),
139        )
140    }
141    /// Compares trees and infers rewrites under explicit policy, limits and cancellation.
142    /// Copies retain their source change; renames consume deleted sources and added targets.
143    /// The existing rewrite limits supply tree bounds for one structural traversal, reused during
144    /// inference. Candidate-limit skips retain exact matches and are reported on the result;
145    /// resource-budget exhaustion still returns an error. Memory grows with admitted tree changes
146    /// and candidate content; path lookups use ordered maps and sets.
147    ///
148    /// # Errors
149    ///
150    /// Returns storage, malformed-tree, resource, cancellation or inference errors. Nothing is
151    /// written.
152    pub fn diff_trees_with(
153        &self,
154        old: Option<ObjectId>,
155        new: Option<ObjectId>,
156        options: Options,
157        limits: Limits,
158        cancel: &AtomicBool,
159    ) -> Result<Comparison, Error> {
160        self.diff_trees_matching(old, new, &Pathspec::all(), options, limits, cancel)
161    }
162
163    /// [`Self::diff_trees_with`], limited to paths `paths` selects.
164    ///
165    /// As in `git diff -M <old> <new> -- <pathspec>`, the pathspec applies before inference: a
166    /// file moved from outside the pathspec to inside it is an addition, not a rename.
167    ///
168    /// # Errors
169    ///
170    /// Returns the errors of [`Self::diff_trees_with`], and an unsupported-pathspec tree
171    /// comparison error for `attr:` magic.
172    pub fn diff_trees_matching(
173        &self,
174        old: Option<ObjectId>,
175        new: Option<ObjectId>,
176        paths: &Pathspec,
177        options: Options,
178        limits: Limits,
179        cancel: &AtomicBool,
180    ) -> Result<Comparison, Error> {
181        let objects = self;
182        let changes = objects.compare_trees_matching(old, new, paths, limits.trees, cancel)?;
183        let detection =
184            objects.detect_rewrites_from_changes(&changes, options, limits, None, cancel)?;
185        let by_path: BTreeMap<_, _> = changes
186            .iter()
187            .map(|change| (change.path.as_slice(), change))
188            .collect();
189        let find = |path: &[u8]| by_path.get(path).copied();
190        let mut renamed_sources = BTreeSet::new();
191        let mut targets = BTreeSet::new();
192        let mut entries = Vec::new();
193        for rewrite in detection.rewrites {
194            let (Some(old), Some(new)) = (
195                find(&rewrite.source).and_then(|change| change.old),
196                find(&rewrite.target).and_then(|change| change.new),
197            ) else {
198                return Err(Error::MissingPath);
199            };
200            if rewrite.kind == Kind::Rename {
201                renamed_sources.insert(rewrite.source.clone());
202            }
203            targets.insert(rewrite.target.clone());
204            entries.push(Entry::Rewrite {
205                kind: rewrite.kind,
206                similarity: rewrite.similarity,
207                source: rewrite.source,
208                target: rewrite.target,
209                old,
210                new,
211            });
212        }
213        drop(by_path);
214        for change in changes {
215            let consumed = (change.new.is_none() && renamed_sources.contains(&change.path))
216                || (change.old.is_none() && targets.contains(&change.path));
217            if !consumed {
218                entries.push(Entry::Change(change));
219            }
220        }
221        entries.sort_by(|a, b| a.sort_path().cmp(b.sort_path()));
222        Ok(Comparison {
223            entries,
224            inexact_skipped: detection.inexact_skipped,
225        })
226    }
227}