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}