forked from
tangled.org/core
Monorepo for Tangled
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 an iterator of `anyhow::Result<Diff>`: per-file diffing can fail independently, so
115/// each item is fallible. The consumer drives it lazily and can stop at any point by dropping
116/// the iterator (e.g. when the gRPC client hangs up).
117pub fn diff<'repo>(
118 repo: &'repo gix::Repository,
119 old_tree: &gix::Tree<'_>,
120 new_tree: &gix::Tree<'_>,
121 embed_content: bool,
122) -> anyhow::Result<DiffIter<'repo>> {
123 let changes = repo.diff_tree_to_tree(Some(old_tree), Some(new_tree), None)?;
124 let mut cache = repo.diff_resource_cache_for_tree_diff()?;
125 cache.filter.options.large_file_threshold_bytes = LARGE_FILE_THRESHOLD_BYTES;
126 Ok(DiffIter {
127 repo,
128 changes: changes.into_iter(),
129 cache,
130 embed_content,
131 })
132}
133
134/// Lazy iterator over per-file [`Diff`]s. Created by [`diff`].
135pub struct DiffIter<'repo> {
136 repo: &'repo gix::Repository,
137 changes: std::vec::IntoIter<gix::object::tree::diff::ChangeDetached>,
138 cache: gix::diff::blob::Platform,
139 embed_content: bool,
140}
141
142impl Iterator for DiffIter<'_> {
143 type Item = anyhow::Result<Diff>;
144
145 fn next(&mut self) -> Option<Self::Item> {
146 loop {
147 let change = self.changes.next()?;
148 let (old, new) = change_sides(&change);
149 // Trees are recursed into by gix and never diffed directly; skip defensively.
150 if old.is_kind(EntryKind::Tree) || new.is_kind(EntryKind::Tree) {
151 continue;
152 }
153 // Submodules (gitlinks) can't go through the blob pipeline — their commit lives in
154 // another repo. Emit the commit oids directly for the caller to compare.
155 if old.is_kind(EntryKind::Commit) || new.is_kind(EntryKind::Commit) {
156 return Some(Ok(submodule_diff(self.repo, &change, old, new)));
157 }
158 let result = build_diff(self.repo, &change, &mut self.cache, self.embed_content);
159 self.cache.clear_resource_cache_keep_allocation();
160 return Some(result);
161 }
162 }
163}
164
165/// One side (old or new) of a change.
166enum Side {
167 Absent,
168 Present { oid: ObjectId, kind: EntryKind },
169}
170
171impl Side {
172 fn is_kind(&self, wanted: EntryKind) -> bool {
173 matches!(self, Side::Present { kind, .. } if *kind == wanted)
174 }
175}
176
177/// Decompose a change into its `(old, new)` sides regardless of variant.
178fn change_sides(change: &gix::object::tree::diff::ChangeDetached) -> (Side, Side) {
179 use gix::object::tree::diff::ChangeDetached as C;
180 let present = |oid, mode: gix::object::tree::EntryMode| Side::Present {
181 oid,
182 kind: mode.kind(),
183 };
184 match change {
185 C::Addition { id, entry_mode, .. } => (Side::Absent, present(*id, *entry_mode)),
186 C::Deletion { id, entry_mode, .. } => (present(*id, *entry_mode), Side::Absent),
187 C::Modification {
188 previous_id,
189 previous_entry_mode,
190 id,
191 entry_mode,
192 ..
193 } => (
194 present(*previous_id, *previous_entry_mode),
195 present(*id, *entry_mode),
196 ),
197 C::Rewrite {
198 source_id,
199 source_entry_mode,
200 id,
201 entry_mode,
202 ..
203 } => (
204 present(*source_id, *source_entry_mode),
205 present(*id, *entry_mode),
206 ),
207 }
208}
209
210/// Build a [`Diff`] for a submodule (gitlink) change: no hunks, both sides carry their commit
211/// oid with `is_submodule` set. The caller compares the oids as submodule pointers.
212fn submodule_diff(
213 repo: &gix::Repository,
214 change: &gix::object::tree::diff::ChangeDetached,
215 old: Side,
216 new: Side,
217) -> Diff {
218 let path = change.location().to_string();
219 let side = |s: Side| FileContent {
220 path: path.clone(),
221 oid: match s {
222 Side::Absent => ObjectId::null(repo.object_hash()).to_string(),
223 Side::Present { oid, .. } => oid.to_string(),
224 },
225 size: 0,
226 is_binary: false,
227 is_submodule: true,
228 content: None,
229 };
230 Diff {
231 lhs_src: side(old),
232 rhs_src: side(new),
233 hunks: Vec::new(),
234 lhs_positions: Vec::new(),
235 rhs_positions: Vec::new(),
236 has_byte_changes: None,
237 has_syntactic_changes: false,
238 }
239}
240
241/// Build the [`Diff`] for a single changed file.
242fn build_diff(
243 repo: &gix::Repository,
244 change: &gix::object::tree::diff::ChangeDetached,
245 cache: &mut gix::diff::blob::Platform,
246 embed_content: bool,
247) -> anyhow::Result<Diff> {
248 use gix::diff::blob::platform::prepare_diff::Operation;
249 use gix::diff::blob::platform::resource::Data;
250 use gix::diff::blob::{Diff as BlobDiff, InternedInput};
251 use gix::prelude::TreeDiffChangeExt as _;
252
253 let change = change.attach(repo, repo);
254 let blob = change.diff(cache)?;
255 let out = blob.resource_cache.prepare_diff()?;
256
257 // Per-side file metadata, derived from the prepared resources.
258 let side =
259 |res: &gix::diff::blob::platform::Resource<'_>, embed_content: bool| -> FileContent {
260 let (size, is_binary, content) = match res.data {
261 // Only textual buffers carry inlinable bytes; binary/missing sides fall back to oid.
262 Data::Buffer { buf, .. } => (buf.len(), false, embed_content.then(|| buf.to_vec())),
263 Data::Binary { size } => (size as usize, true, None),
264 Data::Missing => (0, false, None),
265 };
266 FileContent {
267 path: res.rela_path.to_string(),
268 oid: res.id.to_string(),
269 size,
270 is_binary,
271 is_submodule: false,
272 content,
273 }
274 };
275 let mut lhs_src = side(&out.old, embed_content);
276 let mut rhs_src = side(&out.new, false);
277
278 let has_byte_changes = if lhs_src.size == rhs_src.size && out.old.id == out.new.id {
279 None
280 } else {
281 Some((lhs_src.size, rhs_src.size))
282 };
283
284 let hunks = match out.operation {
285 Operation::InternalDiff { algorithm } => {
286 let input = InternedInput::new(out.old.intern_source(), out.new.intern_source());
287 let mut d = BlobDiff::compute(algorithm, &input);
288 d.postprocess_lines(&input);
289 d.hunks()
290 .map(|h| {
291 let before: Vec<LineNumber> = h.before.clone().map(LineNumber).collect();
292 let after: Vec<LineNumber> = h.after.clone().map(LineNumber).collect();
293 let n = before.len().max(after.len());
294 let lines = (0..n)
295 .map(|i| (before.get(i).copied(), after.get(i).copied()))
296 .collect();
297 Hunk {
298 novel_lhs: before.iter().copied().collect(),
299 novel_rhs: after.iter().copied().collect(),
300 lines,
301 }
302 })
303 .collect()
304 }
305 Operation::SourceOrDestinationIsBinary => {
306 lhs_src.is_binary = true;
307 rhs_src.is_binary = true;
308 Vec::new()
309 }
310 Operation::ExternalCommand { .. } => unreachable!("we disabled that"),
311 };
312
313 Ok(Diff {
314 lhs_src,
315 rhs_src,
316 hunks,
317 lhs_positions: Vec::new(),
318 rhs_positions: Vec::new(),
319 has_byte_changes,
320 has_syntactic_changes: false,
321 })
322}
323
324/// Compute the two trees whose diff is the interdiff between an old patch
325/// version (`from_base..from_head`) and a new one (`to_base..to_head`).
326///
327/// Rebase `from_base..from_head` onto `to_base` and return rebased tree so
328/// caller can diff between `rebased_tree` and `to_head.tree`
329pub fn prepare_interdiff<'repo>(
330 repo: &'repo gix::Repository,
331 (from_base_id, from_head_id): (ObjectId, ObjectId),
332 to_base_id: ObjectId,
333) -> anyhow::Result<gix::Tree<'repo>> {
334 let from_head = repo.find_commit(from_head_id)?;
335
336 let from_base = repo.find_commit(from_base_id)?;
337 let to_base = repo.find_commit(to_base_id)?;
338
339 // Endpoint trees for the 3-way merge.
340 let from_base_tree = from_base.tree_id()?.detach();
341 let from_head_tree = from_head.tree_id()?.detach();
342 let to_base_tree = to_base.tree_id()?.detach();
343
344 // Rebase = replay (from_base_tree -> from_head_tree) onto to_base_tree:
345 // ancestor = from_base_tree, ours = to_base_tree, theirs = from_head_tree.
346 let options = repo.tree_merge_options()?;
347 let labels = gix::merge::blob::builtin_driver::text::Labels {
348 ancestor: Some("from.base".as_bytes().as_bstr()),
349 current: Some("to.base".as_bytes().as_bstr()),
350 other: Some("from.head".as_bytes().as_bstr()),
351 };
352 let mut outcome = repo.merge_trees(
353 from_base_tree,
354 to_base_tree,
355 from_head_tree,
356 labels,
357 options,
358 )?;
359
360 if !outcome.conflicts.is_empty() {
361 info!(
362 conflicts = outcome.conflicts.len(),
363 "interdiff rebase produced conflicts; tree contains conflict markers"
364 );
365 }
366
367 let rebased_id = outcome.tree.write()?.detach();
368 let rebased_tree = repo.find_tree(rebased_id)?;
369 Ok(rebased_tree)
370}