This repository has no description
1use gix::ObjectId;
2use gix::bstr::ByteSlice as _;
3use gix::objs::tree::EntryKind;
4use line_numbers::{LineNumber, SingleLineSpan};
5use rustc_hash::FxHashSet;
6use tracing::info;
7
8/// Blobs larger than this are treated as binary.
9const LARGE_FILE_THRESHOLD_BYTES: u64 = 1024 * 1024;
10
11#[derive(Debug)]
12pub struct FileContent {
13 pub(crate) path: String,
14 pub(crate) oid: String,
15 pub(crate) size: usize,
16 pub(crate) is_binary: bool,
17 pub(crate) is_submodule: bool,
18 /// Raw file bytes, inlined when the `oid` is not fetchable by the client (interdiff's
19 /// synthetic rebased tree). `None` for ordinary diffs and for binary/oversized blobs.
20 pub(crate) content: Option<Vec<u8>>,
21}
22
23impl From<FileContent> for crate::protocol::v1::FileContent {
24 fn from(f: FileContent) -> Self {
25 Self {
26 path: f.path,
27 oid: f.oid,
28 size: f.size as u64,
29 is_binary: f.is_binary,
30 is_submodule: f.is_submodule,
31 content: f.content,
32 }
33 }
34}
35
36#[derive(Debug, Clone)]
37pub struct Hunk {
38 pub(crate) novel_lhs: FxHashSet<LineNumber>,
39 pub(crate) novel_rhs: FxHashSet<LineNumber>,
40 pub(crate) lines: Vec<(Option<LineNumber>, Option<LineNumber>)>,
41}
42
43impl From<Hunk> for crate::protocol::v1::Hunk {
44 fn from(h: Hunk) -> Self {
45 // Sort the novel line sets for deterministic output.
46 let sorted = |set: FxHashSet<LineNumber>| -> Vec<u32> {
47 let mut v: Vec<u32> = set.into_iter().map(|n| n.0).collect();
48 v.sort_unstable();
49 v
50 };
51 Self {
52 novel_lhs: sorted(h.novel_lhs),
53 novel_rhs: sorted(h.novel_rhs),
54 lines: h
55 .lines
56 .into_iter()
57 .map(|(lhs, rhs)| crate::protocol::v1::LinePair {
58 lhs: lhs.map(|n| n.0),
59 rhs: rhs.map(|n| n.0),
60 })
61 .collect(),
62 }
63 }
64}
65
66/// A matched token (an atom, a delimiter, or a comment word).
67#[derive(PartialEq, Eq, Debug, Clone)]
68pub enum MatchKind {
69 // TBD
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct MatchedPos {
74 pub(crate) kind: MatchKind,
75 pub(crate) pos: SingleLineSpan,
76}
77
78#[derive(Debug)]
79pub struct Diff {
80 pub(crate) lhs_src: FileContent,
81 pub(crate) rhs_src: FileContent,
82 pub(crate) hunks: Vec<Hunk>,
83
84 #[allow(unused)]
85 pub(crate) lhs_positions: Vec<MatchedPos>,
86 #[allow(unused)]
87 pub(crate) rhs_positions: Vec<MatchedPos>,
88
89 /// If the two files do not have exactly the same bytes, the
90 /// number of bytes in each file.
91 pub(crate) has_byte_changes: Option<(usize, usize)>,
92 pub(crate) has_syntactic_changes: bool,
93}
94
95impl From<Diff> for crate::protocol::v1::FileDiff {
96 fn from(d: Diff) -> Self {
97 Self {
98 lhs_src: Some(d.lhs_src.into()),
99 rhs_src: Some(d.rhs_src.into()),
100 hunks: d.hunks.into_iter().map(Into::into).collect(),
101 has_byte_changes: d.has_byte_changes.map(|(lhs, rhs)| {
102 crate::protocol::v1::ByteChanges {
103 lhs: lhs as u64,
104 rhs: rhs as u64,
105 }
106 }),
107 has_syntactic_changes: d.has_syntactic_changes,
108 }
109 }
110}
111
112/// Diff two trees, yielding one [`Diff`] per changed file (line-level [`Hunk`]s included).
113///
114/// Returns a lazy iterator of `anyhow::Result<Diff>`.
115pub fn diff<'repo>(
116 repo: &'repo gix::Repository,
117 old_tree: &gix::Tree<'_>,
118 new_tree: &gix::Tree<'_>,
119 embed_content: bool,
120) -> anyhow::Result<DiffIter<'repo>> {
121 let changes = repo.diff_tree_to_tree(Some(old_tree), Some(new_tree), None)?;
122 let mut cache = repo.diff_resource_cache_for_tree_diff()?;
123 cache.filter.options.large_file_threshold_bytes = LARGE_FILE_THRESHOLD_BYTES;
124 Ok(DiffIter {
125 repo,
126 changes: changes.into_iter(),
127 cache,
128 embed_content,
129 })
130}
131
132/// Lazy iterator over per-file [`Diff`]s.
133pub struct DiffIter<'repo> {
134 repo: &'repo gix::Repository,
135 changes: std::vec::IntoIter<gix::object::tree::diff::ChangeDetached>,
136 cache: gix::diff::blob::Platform,
137 embed_content: bool,
138}
139
140impl Iterator for DiffIter<'_> {
141 type Item = anyhow::Result<Diff>;
142
143 fn next(&mut self) -> Option<Self::Item> {
144 loop {
145 let change = self.changes.next()?;
146 let (old, new) = change_sides(&change);
147 // Trees are recursed into by gix and never diffed directly; skip defensively.
148 if old.is_kind(EntryKind::Tree) || new.is_kind(EntryKind::Tree) {
149 continue;
150 }
151 // Submodules (gitlinks) can't go through the blob pipeline — their commit lives in
152 // another repo. Emit the commit oids directly for the caller to compare.
153 if old.is_kind(EntryKind::Commit) || new.is_kind(EntryKind::Commit) {
154 return Some(Ok(submodule_diff(self.repo, &change, old, new)));
155 }
156 let result = build_diff(self.repo, &change, &mut self.cache, self.embed_content);
157 self.cache.clear_resource_cache_keep_allocation();
158 return Some(result);
159 }
160 }
161}
162
163enum Side {
164 Absent,
165 Present { oid: ObjectId, kind: EntryKind },
166}
167
168impl Side {
169 fn is_kind(&self, wanted: EntryKind) -> bool {
170 matches!(self, Side::Present { kind, .. } if *kind == wanted)
171 }
172}
173
174/// Decompose a change into its `(old, new)` sides regardless of variant.
175fn change_sides(change: &gix::object::tree::diff::ChangeDetached) -> (Side, Side) {
176 use gix::object::tree::diff::ChangeDetached as C;
177 let present = |oid, mode: gix::object::tree::EntryMode| Side::Present {
178 oid,
179 kind: mode.kind(),
180 };
181 match change {
182 C::Addition { id, entry_mode, .. } => (Side::Absent, present(*id, *entry_mode)),
183 C::Deletion { id, entry_mode, .. } => (present(*id, *entry_mode), Side::Absent),
184 C::Modification {
185 previous_id,
186 previous_entry_mode,
187 id,
188 entry_mode,
189 ..
190 } => (
191 present(*previous_id, *previous_entry_mode),
192 present(*id, *entry_mode),
193 ),
194 C::Rewrite {
195 source_id,
196 source_entry_mode,
197 id,
198 entry_mode,
199 ..
200 } => (
201 present(*source_id, *source_entry_mode),
202 present(*id, *entry_mode),
203 ),
204 }
205}
206
207/// Build a [`Diff`] for a submodule (gitlink) change: no hunks, both sides carry their commit
208/// oid with `is_submodule` set. The caller compares the oids as submodule pointers.
209fn submodule_diff(
210 repo: &gix::Repository,
211 change: &gix::object::tree::diff::ChangeDetached,
212 old: Side,
213 new: Side,
214) -> Diff {
215 let path = change.location().to_string();
216 let side = |s: Side| FileContent {
217 path: path.clone(),
218 oid: match s {
219 Side::Absent => ObjectId::null(repo.object_hash()).to_string(),
220 Side::Present { oid, .. } => oid.to_string(),
221 },
222 size: 0,
223 is_binary: false,
224 is_submodule: true,
225 content: None,
226 };
227 Diff {
228 lhs_src: side(old),
229 rhs_src: side(new),
230 hunks: Vec::new(),
231 lhs_positions: Vec::new(),
232 rhs_positions: Vec::new(),
233 has_byte_changes: None,
234 has_syntactic_changes: false,
235 }
236}
237
238/// Build the [`Diff`] for a single changed file.
239fn build_diff(
240 repo: &gix::Repository,
241 change: &gix::object::tree::diff::ChangeDetached,
242 cache: &mut gix::diff::blob::Platform,
243 embed_content: bool,
244) -> anyhow::Result<Diff> {
245 use gix::diff::blob::platform::prepare_diff::Operation;
246 use gix::diff::blob::platform::resource::Data;
247 use gix::diff::blob::{Diff as BlobDiff, InternedInput};
248 use gix::prelude::TreeDiffChangeExt as _;
249
250 let change = change.attach(repo, repo);
251 let blob = change.diff(cache)?;
252 let out = blob.resource_cache.prepare_diff()?;
253
254 // Per-side file metadata, derived from the prepared resources.
255 let side =
256 |res: &gix::diff::blob::platform::Resource<'_>, embed_content: bool| -> FileContent {
257 let (size, is_binary, content) = match res.data {
258 // Only textual buffers carry inlinable bytes; binary/missing sides fall back to oid.
259 Data::Buffer { buf, .. } => (buf.len(), false, embed_content.then(|| buf.to_vec())),
260 Data::Binary { size } => (size as usize, true, None),
261 Data::Missing => (0, false, None),
262 };
263 FileContent {
264 path: res.rela_path.to_string(),
265 oid: res.id.to_string(),
266 size,
267 is_binary,
268 is_submodule: false,
269 content,
270 }
271 };
272 let mut lhs_src = side(&out.old, embed_content);
273 let mut rhs_src = side(&out.new, false);
274
275 let has_byte_changes = if lhs_src.size == rhs_src.size && out.old.id == out.new.id {
276 None
277 } else {
278 Some((lhs_src.size, rhs_src.size))
279 };
280
281 let hunks = match out.operation {
282 Operation::InternalDiff { algorithm } => {
283 let input = InternedInput::new(out.old.intern_source(), out.new.intern_source());
284 let mut d = BlobDiff::compute(algorithm, &input);
285 d.postprocess_lines(&input);
286 d.hunks()
287 .map(|h| {
288 let before: Vec<LineNumber> = h.before.clone().map(LineNumber).collect();
289 let after: Vec<LineNumber> = h.after.clone().map(LineNumber).collect();
290 let n = before.len().max(after.len());
291 let lines = (0..n)
292 .map(|i| (before.get(i).copied(), after.get(i).copied()))
293 .collect();
294 Hunk {
295 novel_lhs: before.iter().copied().collect(),
296 novel_rhs: after.iter().copied().collect(),
297 lines,
298 }
299 })
300 .collect()
301 }
302 Operation::SourceOrDestinationIsBinary => {
303 lhs_src.is_binary = true;
304 rhs_src.is_binary = true;
305 Vec::new()
306 }
307 Operation::ExternalCommand { .. } => unreachable!("we disabled that"),
308 };
309
310 Ok(Diff {
311 lhs_src,
312 rhs_src,
313 hunks,
314 lhs_positions: Vec::new(),
315 rhs_positions: Vec::new(),
316 has_byte_changes,
317 has_syntactic_changes: false,
318 })
319}
320
321/// Compute the two trees whose diff is the interdiff between an old patch
322/// version (`from_base..from_head`) and a new one (`to_base..to_head`).
323///
324/// Rebase `from_base..from_head` onto `to_base` and return rebased tree so
325/// caller can diff between `rebased_tree` and `to_head.tree`
326pub fn prepare_interdiff<'repo>(
327 repo: &'repo gix::Repository,
328 (from_base_id, from_head_id): (ObjectId, ObjectId),
329 to_base_id: ObjectId,
330) -> anyhow::Result<gix::Tree<'repo>> {
331 let from_head = repo.find_commit(from_head_id)?;
332
333 let from_base = repo.find_commit(from_base_id)?;
334 let to_base = repo.find_commit(to_base_id)?;
335
336 // Endpoint trees for the 3-way merge.
337 let from_base_tree = from_base.tree_id()?.detach();
338 let from_head_tree = from_head.tree_id()?.detach();
339 let to_base_tree = to_base.tree_id()?.detach();
340
341 let options = repo.tree_merge_options()?;
342 let labels = gix::merge::blob::builtin_driver::text::Labels {
343 ancestor: Some("from.base".as_bytes().as_bstr()),
344 current: Some("to.base".as_bytes().as_bstr()),
345 other: Some("from.head".as_bytes().as_bstr()),
346 };
347 let mut outcome = repo.merge_trees(
348 from_base_tree,
349 to_base_tree,
350 from_head_tree,
351 labels,
352 options,
353 )?;
354
355 if !outcome.conflicts.is_empty() {
356 info!(
357 conflicts = outcome.conflicts.len(),
358 "interdiff rebase produced conflicts; tree contains conflict markers"
359 );
360 }
361
362 let rebased_id = outcome.tree.write()?.detach();
363 let rebased_tree = repo.find_tree(rebased_id)?;
364 Ok(rebased_tree)
365}