use gix::ObjectId; use gix::bstr::ByteSlice as _; use gix::objs::tree::EntryKind; use line_numbers::{LineNumber, SingleLineSpan}; use rustc_hash::FxHashSet; use tracing::info; /// Blobs larger than this are treated as binary. const LARGE_FILE_THRESHOLD_BYTES: u64 = 1024 * 1024; #[derive(Debug)] pub struct FileContent { pub(crate) path: String, pub(crate) oid: String, pub(crate) size: usize, pub(crate) is_binary: bool, pub(crate) is_submodule: bool, /// Raw file bytes, inlined when the `oid` is not fetchable by the client (interdiff's /// synthetic rebased tree). `None` for ordinary diffs and for binary/oversized blobs. pub(crate) content: Option>, } impl From for crate::protocol::v1::FileContent { fn from(f: FileContent) -> Self { Self { path: f.path, oid: f.oid, size: f.size as u64, is_binary: f.is_binary, is_submodule: f.is_submodule, content: f.content, } } } #[derive(Debug, Clone)] pub struct Hunk { pub(crate) novel_lhs: FxHashSet, pub(crate) novel_rhs: FxHashSet, pub(crate) lines: Vec<(Option, Option)>, } impl From for crate::protocol::v1::Hunk { fn from(h: Hunk) -> Self { // Sort the novel line sets for deterministic output. let sorted = |set: FxHashSet| -> Vec { let mut v: Vec = set.into_iter().map(|n| n.0).collect(); v.sort_unstable(); v }; Self { novel_lhs: sorted(h.novel_lhs), novel_rhs: sorted(h.novel_rhs), lines: h .lines .into_iter() .map(|(lhs, rhs)| crate::protocol::v1::LinePair { lhs: lhs.map(|n| n.0), rhs: rhs.map(|n| n.0), }) .collect(), } } } /// A matched token (an atom, a delimiter, or a comment word). #[derive(PartialEq, Eq, Debug, Clone)] pub enum MatchKind { // TBD } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MatchedPos { pub(crate) kind: MatchKind, pub(crate) pos: SingleLineSpan, } #[derive(Debug)] pub struct Diff { pub(crate) lhs_src: FileContent, pub(crate) rhs_src: FileContent, pub(crate) hunks: Vec, #[allow(unused)] pub(crate) lhs_positions: Vec, #[allow(unused)] pub(crate) rhs_positions: Vec, /// If the two files do not have exactly the same bytes, the /// number of bytes in each file. pub(crate) has_byte_changes: Option<(usize, usize)>, pub(crate) has_syntactic_changes: bool, } impl From for crate::protocol::v1::FileDiff { fn from(d: Diff) -> Self { Self { lhs_src: Some(d.lhs_src.into()), rhs_src: Some(d.rhs_src.into()), hunks: d.hunks.into_iter().map(Into::into).collect(), has_byte_changes: d.has_byte_changes.map(|(lhs, rhs)| { crate::protocol::v1::ByteChanges { lhs: lhs as u64, rhs: rhs as u64, } }), has_syntactic_changes: d.has_syntactic_changes, } } } /// Diff two trees, yielding one [`Diff`] per changed file (line-level [`Hunk`]s included). /// /// Returns a lazy iterator of `anyhow::Result`. pub fn diff<'repo>( repo: &'repo gix::Repository, old_tree: &gix::Tree<'_>, new_tree: &gix::Tree<'_>, embed_content: bool, ) -> anyhow::Result> { let changes = repo.diff_tree_to_tree(Some(old_tree), Some(new_tree), None)?; let mut cache = repo.diff_resource_cache_for_tree_diff()?; cache.filter.options.large_file_threshold_bytes = LARGE_FILE_THRESHOLD_BYTES; Ok(DiffIter { repo, changes: changes.into_iter(), cache, embed_content, }) } /// Lazy iterator over per-file [`Diff`]s. pub struct DiffIter<'repo> { repo: &'repo gix::Repository, changes: std::vec::IntoIter, cache: gix::diff::blob::Platform, embed_content: bool, } impl Iterator for DiffIter<'_> { type Item = anyhow::Result; fn next(&mut self) -> Option { loop { let change = self.changes.next()?; let (old, new) = change_sides(&change); // Trees are recursed into by gix and never diffed directly; skip defensively. if old.is_kind(EntryKind::Tree) || new.is_kind(EntryKind::Tree) { continue; } // Submodules (gitlinks) can't go through the blob pipeline — their commit lives in // another repo. Emit the commit oids directly for the caller to compare. if old.is_kind(EntryKind::Commit) || new.is_kind(EntryKind::Commit) { return Some(Ok(submodule_diff(self.repo, &change, old, new))); } let result = build_diff(self.repo, &change, &mut self.cache, self.embed_content); self.cache.clear_resource_cache_keep_allocation(); return Some(result); } } } enum Side { Absent, Present { oid: ObjectId, kind: EntryKind }, } impl Side { fn is_kind(&self, wanted: EntryKind) -> bool { matches!(self, Side::Present { kind, .. } if *kind == wanted) } } /// Decompose a change into its `(old, new)` sides regardless of variant. fn change_sides(change: &gix::object::tree::diff::ChangeDetached) -> (Side, Side) { use gix::object::tree::diff::ChangeDetached as C; let present = |oid, mode: gix::object::tree::EntryMode| Side::Present { oid, kind: mode.kind(), }; match change { C::Addition { id, entry_mode, .. } => (Side::Absent, present(*id, *entry_mode)), C::Deletion { id, entry_mode, .. } => (present(*id, *entry_mode), Side::Absent), C::Modification { previous_id, previous_entry_mode, id, entry_mode, .. } => ( present(*previous_id, *previous_entry_mode), present(*id, *entry_mode), ), C::Rewrite { source_id, source_entry_mode, id, entry_mode, .. } => ( present(*source_id, *source_entry_mode), present(*id, *entry_mode), ), } } /// Build a [`Diff`] for a submodule (gitlink) change: no hunks, both sides carry their commit /// oid with `is_submodule` set. The caller compares the oids as submodule pointers. fn submodule_diff( repo: &gix::Repository, change: &gix::object::tree::diff::ChangeDetached, old: Side, new: Side, ) -> Diff { let path = change.location().to_string(); let side = |s: Side| FileContent { path: path.clone(), oid: match s { Side::Absent => ObjectId::null(repo.object_hash()).to_string(), Side::Present { oid, .. } => oid.to_string(), }, size: 0, is_binary: false, is_submodule: true, content: None, }; Diff { lhs_src: side(old), rhs_src: side(new), hunks: Vec::new(), lhs_positions: Vec::new(), rhs_positions: Vec::new(), has_byte_changes: None, has_syntactic_changes: false, } } /// Build the [`Diff`] for a single changed file. fn build_diff( repo: &gix::Repository, change: &gix::object::tree::diff::ChangeDetached, cache: &mut gix::diff::blob::Platform, embed_content: bool, ) -> anyhow::Result { use gix::diff::blob::platform::prepare_diff::Operation; use gix::diff::blob::platform::resource::Data; use gix::diff::blob::{Diff as BlobDiff, InternedInput}; use gix::prelude::TreeDiffChangeExt as _; let change = change.attach(repo, repo); let blob = change.diff(cache)?; let out = blob.resource_cache.prepare_diff()?; // Per-side file metadata, derived from the prepared resources. let side = |res: &gix::diff::blob::platform::Resource<'_>, embed_content: bool| -> FileContent { let (size, is_binary, content) = match res.data { // Only textual buffers carry inlinable bytes; binary/missing sides fall back to oid. Data::Buffer { buf, .. } => (buf.len(), false, embed_content.then(|| buf.to_vec())), Data::Binary { size } => (size as usize, true, None), Data::Missing => (0, false, None), }; FileContent { path: res.rela_path.to_string(), oid: res.id.to_string(), size, is_binary, is_submodule: false, content, } }; let mut lhs_src = side(&out.old, embed_content); let mut rhs_src = side(&out.new, false); let has_byte_changes = if lhs_src.size == rhs_src.size && out.old.id == out.new.id { None } else { Some((lhs_src.size, rhs_src.size)) }; let hunks = match out.operation { Operation::InternalDiff { algorithm } => { let input = InternedInput::new(out.old.intern_source(), out.new.intern_source()); let mut d = BlobDiff::compute(algorithm, &input); d.postprocess_lines(&input); d.hunks() .map(|h| { let before: Vec = h.before.clone().map(LineNumber).collect(); let after: Vec = h.after.clone().map(LineNumber).collect(); let n = before.len().max(after.len()); let lines = (0..n) .map(|i| (before.get(i).copied(), after.get(i).copied())) .collect(); Hunk { novel_lhs: before.iter().copied().collect(), novel_rhs: after.iter().copied().collect(), lines, } }) .collect() } Operation::SourceOrDestinationIsBinary => { lhs_src.is_binary = true; rhs_src.is_binary = true; Vec::new() } Operation::ExternalCommand { .. } => unreachable!("we disabled that"), }; Ok(Diff { lhs_src, rhs_src, hunks, lhs_positions: Vec::new(), rhs_positions: Vec::new(), has_byte_changes, has_syntactic_changes: false, }) } /// Compute the two trees whose diff is the interdiff between an old patch /// version (`from_base..from_head`) and a new one (`to_base..to_head`). /// /// Rebase `from_base..from_head` onto `to_base` and return rebased tree so /// caller can diff between `rebased_tree` and `to_head.tree` pub fn prepare_interdiff<'repo>( repo: &'repo gix::Repository, (from_base_id, from_head_id): (ObjectId, ObjectId), to_base_id: ObjectId, ) -> anyhow::Result> { let from_head = repo.find_commit(from_head_id)?; let from_base = repo.find_commit(from_base_id)?; let to_base = repo.find_commit(to_base_id)?; // Endpoint trees for the 3-way merge. let from_base_tree = from_base.tree_id()?.detach(); let from_head_tree = from_head.tree_id()?.detach(); let to_base_tree = to_base.tree_id()?.detach(); let options = repo.tree_merge_options()?; let labels = gix::merge::blob::builtin_driver::text::Labels { ancestor: Some("from.base".as_bytes().as_bstr()), current: Some("to.base".as_bytes().as_bstr()), other: Some("from.head".as_bytes().as_bstr()), }; let mut outcome = repo.merge_trees( from_base_tree, to_base_tree, from_head_tree, labels, options, )?; if !outcome.conflicts.is_empty() { info!( conflicts = outcome.conflicts.len(), "interdiff rebase produced conflicts; tree contains conflict markers" ); } let rebased_id = outcome.tree.write()?.detach(); let rebased_tree = repo.find_tree(rebased_id)?; Ok(rebased_tree) }