From 2e3dbbb544fe97878a672dcf8964c3a615ffed73 Mon Sep 17 00:00:00 2001 From: Vicent Marti Date: Thu, 5 Mar 2026 10:20:39 +0100 Subject: [PATCH 1/8] gix-commitgraph: implement support for bloom caches The `git commit-graph write` command also supports writing a separate section on the cache file that contains information about the paths changed between a commit and its first parent. This information can be used to significantly speed up the performance of some traversal operations, such as `git log -- ` and `git blame`. This commit teaches the git-commitgraph crate in gitoxide how to parse and access this information. We've only implemented support for reading v2 of this cache, because v1 is deprecated in Git as it can return bad results in some corner cases. The implementation is 100% compatible with Git itself; it uses the exact same version of murmur3 that Git is using, including the seed hashes. --- Cargo.lock | 7 + gix-commitgraph/Cargo.toml | 1 + gix-commitgraph/src/access.rs | 7 +- gix-commitgraph/src/bloom.rs | 190 ++++++++++++++++++ gix-commitgraph/src/file/access.rs | 14 +- gix-commitgraph/src/file/commit.rs | 27 ++- gix-commitgraph/src/file/init.rs | 52 ++++- gix-commitgraph/src/file/mod.rs | 3 + gix-commitgraph/src/init.rs | 22 +- gix-commitgraph/src/lib.rs | 21 ++ gix-commitgraph/tests/access/mod.rs | 169 ++++++++++++++++ .../tests/fixtures/changed_paths_v2.sh | 19 ++ .../split_chain_changed_paths_mismatch.sh | 21 ++ .../fixtures/split_chain_top_without_bloom.sh | 21 ++ 14 files changed, 557 insertions(+), 17 deletions(-) create mode 100644 gix-commitgraph/src/bloom.rs create mode 100755 gix-commitgraph/tests/fixtures/changed_paths_v2.sh create mode 100755 gix-commitgraph/tests/fixtures/split_chain_changed_paths_mismatch.sh create mode 100755 gix-commitgraph/tests/fixtures/split_chain_top_without_bloom.sh diff --git a/Cargo.lock b/Cargo.lock index 2c1a294ee9..b971e56a6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,7 @@ dependencies = [ "gix-hash", "gix-testtools", "memmap2", + "murmur3", "nonempty", "serde", ] @@ -3549,6 +3550,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "murmur3" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b" + [[package]] name = "native-tls" version = "0.2.18" diff --git a/gix-commitgraph/Cargo.toml b/gix-commitgraph/Cargo.toml index ea950dea9d..7aafa41c73 100644 --- a/gix-commitgraph/Cargo.toml +++ b/gix-commitgraph/Cargo.toml @@ -32,6 +32,7 @@ nonempty = "0.12.0" serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] } document-features = { version = "0.2.0", optional = true } +murmur3 = "0.5.2" [dev-dependencies] gix-testtools = { path = "../tests/tools" } diff --git a/gix-commitgraph/src/access.rs b/gix-commitgraph/src/access.rs index ad89c2ada0..41c78db139 100644 --- a/gix-commitgraph/src/access.rs +++ b/gix-commitgraph/src/access.rs @@ -1,4 +1,4 @@ -use crate::{file, file::Commit, File, Graph, Position}; +use crate::{file, file::Commit, BloomFilterSettings, File, Graph, Position}; /// Access impl Graph { @@ -52,6 +52,11 @@ impl Graph { pub fn num_commits(&self) -> u32 { self.files.iter().map(File::num_commits).sum() } + + /// Return changed-path Bloom filter settings used by the top-most compatible graph layer, if available. + pub fn bloom_filter_settings(&self) -> Option { + self.files.iter().rev().find_map(File::bloom_filter_settings) + } } /// Access fundamentals diff --git a/gix-commitgraph/src/bloom.rs b/gix-commitgraph/src/bloom.rs new file mode 100644 index 0000000000..88e4606caf --- /dev/null +++ b/gix-commitgraph/src/bloom.rs @@ -0,0 +1,190 @@ +//! Query support for changed-path Bloom filters stored in commit-graph files. + +use std::io::Cursor; + +use bstr::BStr; + +use crate::{file, from_be_u32, BloomFilterSettings, File, Graph, Position}; + +const SEED0: u32 = 0x293a_e76f; +const SEED1: u32 = 0x7e64_6e2c; +const BITS_PER_WORD: u64 = 8; + +/// Precomputed hash positions for a path using Bloom filter settings. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BloomKey { + h0: u32, + h1: u32, + num_hashes: u32, +} + +impl BloomKey { + /// Build a key for `path`. + /// + /// `path` must use `/` as separator, matching Git's changed-path Bloom filter expectations. + pub fn from_path(path: &BStr, settings: BloomFilterSettings) -> Self { + Self::from_bytes(path.as_ref(), settings) + } + + /// Build keys for `path` and each directory prefix. + /// + /// For `a/b/c`, this yields keys for `a/b/c`, `a/b`, and `a`. + /// `path` must use `/` as separator. + pub fn from_path_with_prefixes(path: &BStr, settings: BloomFilterSettings) -> Vec { + let bytes = path.as_ref(); + let mut out = vec![Self::from_bytes(bytes, settings)]; + + let mut idx = bytes.len(); + while idx > 0 { + idx -= 1; + if bytes[idx] == b'/' { + out.push(Self::from_bytes(&bytes[..idx], settings)); + } + } + out + } + + fn from_bytes(path: &[u8], settings: BloomFilterSettings) -> Self { + Self { + h0: murmur3_v2(SEED0, path), + h1: murmur3_v2(SEED1, path), + num_hashes: settings.num_hashes, + } + } + + /// Query whether this key may be contained in `filter_data`. + /// + /// Returns `None` if the filter is unusable (empty data), `Some(false)` on a definite miss, + /// and `Some(true)` on a possible hit. + pub fn contains(&self, filter_data: &[u8]) -> Option { + let modulo = (filter_data.len() as u64) * BITS_PER_WORD; + if modulo == 0 { + return None; + } + + for i in 0..self.num_hashes { + let hash = self.h0.wrapping_add(i.wrapping_mul(self.h1)); + let bit_pos = u64::from(hash) % modulo; + let byte_pos = (bit_pos / BITS_PER_WORD) as usize; + let mask = 1u8 << (bit_pos % BITS_PER_WORD); + if filter_data[byte_pos] & mask == 0 { + return Some(false); + } + } + Some(true) + } +} + +impl File { + /// Query if `path` may be present in the changed-path Bloom filter for commit `pos`. + /// + /// Checks the full path and every directory prefix against the filter, + /// matching Git's `bloom_filter_contains_vec()` behavior for reduced false positives. + pub fn maybe_contains_path(&self, pos: file::Position, path: &BStr) -> Option { + let (data, settings) = self.bloom_filter_at(pos)?; + let keys = BloomKey::from_path_with_prefixes(path, settings); + for key in &keys { + match key.contains(data) { + Some(false) => return Some(false), + None => return None, + Some(true) => {} + } + } + Some(true) + } + + /// Query if all `keys` may be present in the changed-path Bloom filter for commit `pos`. + /// + /// This corresponds to Git's `bloom_filter_contains_vec()` behavior. + pub fn maybe_contains_all_keys(&self, pos: file::Position, keys: &[BloomKey]) -> Option { + let (data, _settings) = self.bloom_filter_at(pos)?; + if keys.iter().all(|key| key.contains(data) == Some(true)) { + Some(true) + } else { + Some(false) + } + } + + fn bloom_filter_at(&self, pos: file::Position) -> Option<(&[u8], BloomFilterSettings)> { + let settings = self.bloom_filter_settings?; + let index_offset = self.bloom_filter_index_offset?; + let data_offset = self.bloom_filter_data_offset?; + if pos.0 >= self.num_commits() { + return None; + } + + let lex = pos.0 as usize; + let end = from_be_u32(&self.data[index_offset + lex * 4..][..4]); + let start = if lex == 0 { + 0 + } else { + from_be_u32(&self.data[index_offset + (lex - 1) * 4..][..4]) + }; + let start = start as usize; + let end = end as usize; + if start > end || end > self.bloom_filter_data_len { + return None; + } + let start = data_offset.checked_add(start)?; + let end = data_offset.checked_add(end)?; + self.data.get(start..end).map(|data| (data, settings)) + } +} + +impl Graph { + /// Query by commit id if `path` may be present in changed-path Bloom filters. + pub fn maybe_contains_path_by_id(&self, id: impl AsRef, path: &BStr) -> Option { + let pos = self.lookup(id)?; + self.maybe_contains_path(pos, path) + } + + /// Query by graph position if `path` may be present in changed-path Bloom filters. + pub fn maybe_contains_path(&self, pos: Position, path: &BStr) -> Option { + self.commit_at(pos).maybe_contains_path(path) + } +} + +pub(crate) fn murmur3_v2(seed: u32, data: &[u8]) -> u32 { + let mut reader = Cursor::new(data); + murmur3::murmur3_32(&mut reader, seed).expect("reading from memory does not fail") +} +#[cfg(test)] +mod tests { + use super::{murmur3_v2, BloomKey}; + use crate::BloomFilterSettings; + use bstr::BStr; + + #[test] + fn murmur3_known_vectors_match_git_and_reference_values() { + assert_eq!(murmur3_v2(0, b""), 0x0000_0000); + assert_eq!(murmur3_v2(0, b"Hello world!"), 0x627b_0c2c); + assert_eq!( + murmur3_v2(0, b"The quick brown fox jumps over the lazy dog"), + 0x2e4f_f723 + ); + } + + #[test] + fn bloom_key_for_empty_path_matches_git_vector() { + let settings = BloomFilterSettings { + hash_version: 2, + num_hashes: 7, + bits_per_entry: 10, + }; + let key = BloomKey::from_path(BStr::new(b""), settings); + assert_eq!( + (0..key.num_hashes) + .map(|i| key.h0.wrapping_add(i.wrapping_mul(key.h1))) + .collect::>(), + &[ + 0x5615_800c, + 0x5b96_6560, + 0x6117_4ab4, + 0x6698_3008, + 0x6c19_155c, + 0x7199_fab0, + 0x771a_e004 + ] + ); + } +} diff --git a/gix-commitgraph/src/file/access.rs b/gix-commitgraph/src/file/access.rs index 6ccb98cc32..064ac8e903 100644 --- a/gix-commitgraph/src/file/access.rs +++ b/gix-commitgraph/src/file/access.rs @@ -5,7 +5,7 @@ use std::{ use crate::{ file::{self, commit::Commit, COMMIT_DATA_ENTRY_SIZE_SANS_HASH}, - File, + BloomFilterSettings, File, }; /// Access @@ -107,6 +107,11 @@ impl File { pub fn path(&self) -> &Path { &self.path } + + /// Return changed-path Bloom filter settings if this file has a usable Bloom index and data pair. + pub fn bloom_filter_settings(&self) -> Option { + self.bloom_filter_settings + } } impl File { @@ -131,6 +136,13 @@ impl File { pub(crate) fn extra_edges_data(&self) -> Option<&[u8]> { Some(&self.data[self.extra_edges_list_range.clone()?]) } + + pub(crate) fn clear_bloom_filters(&mut self) { + self.bloom_filter_data_len = 0; + self.bloom_filter_data_offset = None; + self.bloom_filter_index_offset = None; + self.bloom_filter_settings = None; + } } impl Debug for File { diff --git a/gix-commitgraph/src/file/commit.rs b/gix-commitgraph/src/file/commit.rs index 29522bb5ca..d4f8a0ec5d 100644 --- a/gix-commitgraph/src/file/commit.rs +++ b/gix-commitgraph/src/file/commit.rs @@ -1,8 +1,10 @@ //! Low-level operations on individual commits. use crate::{ + bloom::BloomKey, file::{self, EXTENDED_EDGES_MASK, LAST_EXTENDED_EDGE_MASK, NO_PARENT}, - File, Position, + from_be_u32, File, Position, }; +use bstr::BStr; use gix_error::{message, Message}; use std::{ fmt::{Debug, Formatter}, @@ -22,11 +24,6 @@ pub struct Commit<'a> { root_tree_id: &'a gix_hash::oid, } -#[inline] -fn read_u32(b: &[u8]) -> u32 { - u32::from_be_bytes(b.try_into().unwrap()) -} - impl<'a> Commit<'a> { pub(crate) fn new(file: &'a File, pos: file::Position) -> Self { let bytes = file.commit_data_bytes(pos); @@ -34,12 +31,12 @@ impl<'a> Commit<'a> { file, pos, root_tree_id: gix_hash::oid::from_bytes_unchecked(&bytes[..file.hash_len]), - parent1: ParentEdge::from_raw(read_u32(&bytes[file.hash_len..][..4])), - parent2: ParentEdge::from_raw(read_u32(&bytes[file.hash_len + 4..][..4])), + parent1: ParentEdge::from_raw(from_be_u32(&bytes[file.hash_len..][..4])), + parent2: ParentEdge::from_raw(from_be_u32(&bytes[file.hash_len + 4..][..4])), // TODO: Add support for corrected commit date offset overflow. // See https://github.com/git/git/commit/e8b63005c48696a26f976f5f9b0ccaf1983e439d and // https://github.com/git/git/commit/f90fca638e99a031dce8e3aca72427b2f9b4bb38 for more details and hints at a test. - generation: read_u32(&bytes[file.hash_len + 8..][..4]) >> 2, + generation: from_be_u32(&bytes[file.hash_len + 8..][..4]) >> 2, commit_timestamp: u64::from_be_bytes(bytes[file.hash_len + 8..][..8].try_into().unwrap()) & 0x0003_ffff_ffff, } @@ -90,6 +87,16 @@ impl<'a> Commit<'a> { pub fn root_tree_id(&self) -> &gix_hash::oid { self.root_tree_id } + + /// Query if `path` may be present in this commit's changed-path Bloom filter. + pub fn maybe_contains_path(&self, path: &BStr) -> Option { + self.file.maybe_contains_path(self.pos, path) + } + + /// Query if all `keys` may be present in this commit's changed-path Bloom filter. + pub fn maybe_contains_all_keys(&self, keys: &[BloomKey]) -> Option { + self.file.maybe_contains_all_keys(self.pos, keys) + } } impl Debug for Commit<'_> { @@ -176,7 +183,7 @@ impl Iterator for Parents<'_> { }, ParentIteratorState::Extra(mut chunks) => { if let Some(chunk) = chunks.next() { - let extra_edge = read_u32(chunk); + let extra_edge = from_be_u32(chunk); match ExtraEdge::from_raw(extra_edge) { ExtraEdge::Internal(pos) => { self.state = ParentIteratorState::Extra(chunks); diff --git a/gix-commitgraph/src/file/init.rs b/gix-commitgraph/src/file/init.rs index b78e77a60a..ca0e680fd3 100644 --- a/gix-commitgraph/src/file/init.rs +++ b/gix-commitgraph/src/file/init.rs @@ -4,10 +4,11 @@ use gix_error::{message, ErrorExt, Exn, Message, ResultExt}; use crate::{ file::{ - BASE_GRAPHS_LIST_CHUNK_ID, COMMIT_DATA_CHUNK_ID, COMMIT_DATA_ENTRY_SIZE_SANS_HASH, - EXTENDED_EDGES_LIST_CHUNK_ID, FAN_LEN, HEADER_LEN, OID_FAN_CHUNK_ID, OID_LOOKUP_CHUNK_ID, SIGNATURE, + BASE_GRAPHS_LIST_CHUNK_ID, BLOOM_FILTER_DATA_CHUNK_ID, BLOOM_FILTER_HEADER_SIZE, BLOOM_FILTER_INDEX_CHUNK_ID, + COMMIT_DATA_CHUNK_ID, COMMIT_DATA_ENTRY_SIZE_SANS_HASH, EXTENDED_EDGES_LIST_CHUNK_ID, FAN_LEN, HEADER_LEN, + OID_FAN_CHUNK_ID, OID_LOOKUP_CHUNK_ID, SIGNATURE, }, - File, + from_be_u32, BloomFilterSettings, File, }; const MIN_FILE_SIZE: usize = HEADER_LEN @@ -129,6 +130,32 @@ impl File { .or_raise(|| message("Error getting offset for OID lookup chunk"))?; let extra_edges_list_range = chunks.usize_offset_by_id(EXTENDED_EDGES_LIST_CHUNK_ID).ok(); + let bloom_filter_index_range = chunks.usize_offset_by_id(BLOOM_FILTER_INDEX_CHUNK_ID).ok(); + let bloom_filter_data_range = chunks.usize_offset_by_id(BLOOM_FILTER_DATA_CHUNK_ID).ok(); + + let mut bloom_filter_data_len = 0usize; + let mut bloom_filter_data_offset = None; + let mut bloom_filter_index_offset = None; + let mut bloom_filter_settings = None; + if let (Some(index_range), Some(data_range)) = + (bloom_filter_index_range.as_ref(), bloom_filter_data_range.as_ref()) + { + let expected_index_chunk_size = commit_data_count as usize * std::mem::size_of::(); + if index_range.len() == expected_index_chunk_size && data_range.len() >= BLOOM_FILTER_HEADER_SIZE { + let settings = BloomFilterSettings { + hash_version: from_be_u32(&data[data_range.start..][..4]), + num_hashes: from_be_u32(&data[data_range.start + 4..][..4]), + bits_per_entry: from_be_u32(&data[data_range.start + 8..][..4]), + }; + let bloom_data_payload_len = data_range.len() - BLOOM_FILTER_HEADER_SIZE; + if bloom_index_offsets_are_valid(&data[index_range.clone()], bloom_data_payload_len) { + bloom_filter_settings = Some(settings); + bloom_filter_data_len = bloom_data_payload_len; + bloom_filter_data_offset = Some(data_range.start + BLOOM_FILTER_HEADER_SIZE); + bloom_filter_index_offset = Some(index_range.start); + } + } + } let trailer = &data[chunks.highest_offset() as usize..]; if trailer.len() != object_hash.len_in_bytes() { @@ -162,6 +189,10 @@ impl File { Ok(File { base_graph_count, base_graphs_list_offset, + bloom_filter_data_len, + bloom_filter_data_offset, + bloom_filter_index_offset, + bloom_filter_settings, commit_data_offset, data, extra_edges_list_range, @@ -201,3 +232,18 @@ fn read_fan(d: &[u8]) -> ([u32; FAN_LEN], usize) { } (fan, FAN_LEN * 4) } + +fn bloom_index_offsets_are_valid(index_data: &[u8], bloom_data_payload_len: usize) -> bool { + let mut previous = 0usize; + for offset_bytes in index_data.chunks_exact(std::mem::size_of::()) { + let offset = from_be_u32(offset_bytes) as usize; + if offset > bloom_data_payload_len { + return false; + } + if offset < previous { + return false; + } + previous = offset; + } + true +} diff --git a/gix-commitgraph/src/file/mod.rs b/gix-commitgraph/src/file/mod.rs index 93b90a012d..d64cc0873c 100644 --- a/gix-commitgraph/src/file/mod.rs +++ b/gix-commitgraph/src/file/mod.rs @@ -17,6 +17,9 @@ const SIGNATURE: &[u8] = b"CGPH"; type ChunkId = gix_chunk::Id; const BASE_GRAPHS_LIST_CHUNK_ID: ChunkId = *b"BASE"; +const BLOOM_FILTER_DATA_CHUNK_ID: ChunkId = *b"BDAT"; +const BLOOM_FILTER_INDEX_CHUNK_ID: ChunkId = *b"BIDX"; +const BLOOM_FILTER_HEADER_SIZE: usize = 3 * std::mem::size_of::(); const COMMIT_DATA_CHUNK_ID: ChunkId = *b"CDAT"; const EXTENDED_EDGES_LIST_CHUNK_ID: ChunkId = *b"EDGE"; const OID_FAN_CHUNK_ID: ChunkId = *b"OIDF"; diff --git a/gix-commitgraph/src/init.rs b/gix-commitgraph/src/init.rs index 27166e75db..2d6f19b4ef 100644 --- a/gix-commitgraph/src/init.rs +++ b/gix-commitgraph/src/init.rs @@ -1,4 +1,4 @@ -use crate::{File, Graph, MAX_COMMITS}; +use crate::{BloomFilterSettings, File, Graph, MAX_COMMITS}; use gix_error::{message, ErrorExt, Exn, Message, ResultExt}; use std::{ io::{BufRead, BufReader}, @@ -54,7 +54,7 @@ impl Graph { /// Create a new commit graph from a list of `files`. pub fn new(files: Vec) -> Result { - let files = nonempty::NonEmpty::from_vec(files) + let mut files = nonempty::NonEmpty::from_vec(files) .ok_or_else(|| message!("Commit-graph must contain at least one file"))?; let num_commits: u64 = files.iter().map(|f| u64::from(f.num_commits())).sum(); if num_commits > u64::from(MAX_COMMITS) { @@ -77,10 +77,28 @@ impl Graph { f1 = f2; } + disable_incompatible_bloom_layers(&mut files); + Ok(Self { files }) } } +fn disable_incompatible_bloom_layers(files: &mut nonempty::NonEmpty) { + let mut preferred: Option = None; + for file in files.iter_mut().rev() { + let Some(settings) = file.bloom_filter_settings else { + continue; + }; + if preferred.is_none() { + preferred = Some(settings); + continue; + } + if preferred != Some(settings) { + file.clear_bloom_filters(); + } + } +} + impl TryFrom<&Path> for Graph { type Error = Exn; diff --git a/gix-commitgraph/src/lib.rs b/gix-commitgraph/src/lib.rs index 3370a6bfc5..7824da1c76 100644 --- a/gix-commitgraph/src/lib.rs +++ b/gix-commitgraph/src/lib.rs @@ -25,6 +25,10 @@ use std::path::Path; pub struct File { base_graph_count: u8, base_graphs_list_offset: Option, + bloom_filter_data_len: usize, + bloom_filter_data_offset: Option, + bloom_filter_index_offset: Option, + bloom_filter_settings: Option, commit_data_offset: usize, data: memmap2::Mmap, extra_edges_list_range: Option>, @@ -35,6 +39,17 @@ pub struct File { object_hash: gix_hash::Kind, } +/// Bloom filter parameters as stored in a commit-graph `BDAT` chunk header. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct BloomFilterSettings { + /// Version of the hash algorithm used by changed-path Bloom filters. + pub hash_version: u32, + /// Number of hashes computed for each key. + pub num_hashes: u32, + /// Number of bits per Bloom filter entry. + pub bits_per_entry: u32, +} + /// A complete commit graph. /// /// The data in the commit graph may come from a monolithic `objects/info/commit-graph` file, or it @@ -50,6 +65,7 @@ pub fn at(path: impl AsRef) -> Result> { } mod access; +pub mod bloom; pub mod file; /// pub mod init; @@ -67,6 +83,11 @@ pub const GENERATION_NUMBER_MAX: u32 = 0x3fff_ffff; /// The maximum number of commits that can be stored in a commit graph. pub const MAX_COMMITS: u32 = (1 << 30) + (1 << 29) + (1 << 28) - 1; +#[inline] +pub(crate) fn from_be_u32(b: &[u8]) -> u32 { + u32::from_be_bytes(b.try_into().unwrap()) +} + /// A generalized position for use in [`Graph`]. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)] pub struct Position(pub u32); diff --git a/gix-commitgraph/tests/access/mod.rs b/gix-commitgraph/tests/access/mod.rs index a6ec1913da..f50e8e09fd 100644 --- a/gix-commitgraph/tests/access/mod.rs +++ b/gix-commitgraph/tests/access/mod.rs @@ -1,4 +1,14 @@ use crate::{check_common, graph_and_expected, graph_and_expected_named}; +use gix_testtools::scripted_fixture_writable; +use std::{fs, path::Path}; + +fn should_skip_path_v2_unsupported() -> bool { + // Git learned to read and write changed-path Bloom filter version 2 in 2.46. + // Older binaries ignore the config in these fixtures and keep producing v1 data. + // Use the raw version here instead of `should_skip_as_git_version_is_smaller_than()`, + // which intentionally never skips on CI. + *gix_testtools::GIT_VERSION < (2, 46, 0) +} #[test] fn single_parent() { @@ -111,3 +121,162 @@ fn two_parents() { assert_eq!(cg.commit_at(refs["parent2"].pos()).generation(), 1); assert_eq!(cg.commit_at(refs["child"].pos()).generation(), 2); } + +#[test] +fn changed_paths_v2_settings_are_read() { + if should_skip_path_v2_unsupported() { + return; + } + let (cg, _refs) = graph_and_expected("changed_paths_v2.sh", &["HEAD"]); + let settings = cg + .bloom_filter_settings() + .expect("changed-path Bloom settings are available"); + assert_eq!(settings.hash_version, 2, "fixture explicitly requests v2 filters"); + assert_eq!(settings.bits_per_entry, 10, "git default bits per entry"); + assert_eq!(settings.num_hashes, 7, "git default hash count"); +} + +#[test] +fn incompatible_split_chain_prefers_top_layer_bloom_settings() { + if should_skip_path_v2_unsupported() { + return; + } + let (cg, _refs) = graph_and_expected("split_chain_changed_paths_mismatch.sh", &["c1", "c2"]); + let settings = cg + .bloom_filter_settings() + .expect("top layer has changed-path Bloom settings"); + assert_eq!(settings.hash_version, 2, "top layer uses v2 and should remain usable"); +} + +#[test] +fn split_chain_uses_base_bloom_when_top_has_none() { + if should_skip_path_v2_unsupported() { + return; + } + let (cg, _refs) = graph_and_expected("split_chain_top_without_bloom.sh", &["c1", "c2"]); + let settings = cg + .bloom_filter_settings() + .expect("base layer changed-path settings remain usable"); + assert_eq!(settings.hash_version, 2); +} + +#[test] +fn bloom_is_disabled_if_bidx_chunk_is_missing() { + let tmp = scripted_fixture_writable("changed_paths_v2.sh").expect("fixture available"); + mutate_commit_graph(tmp.path(), |data| { + let entries = parse_chunk_table(data); + let bidx = find_chunk_index(&entries, *b"BIDX").expect("BIDX present in fixture"); + set_chunk_id(data, bidx, *b"XIDX"); + }); + let graph = gix_commitgraph::Graph::from_info_dir(&info_dir(tmp.path())).expect("graph remains readable"); + assert!(graph.bloom_filter_settings().is_none(), "missing BIDX disables Bloom"); +} + +#[test] +fn bloom_is_disabled_if_bdat_chunk_is_missing() { + let tmp = scripted_fixture_writable("changed_paths_v2.sh").expect("fixture available"); + mutate_commit_graph(tmp.path(), |data| { + let entries = parse_chunk_table(data); + let bdat = find_chunk_index(&entries, *b"BDAT").expect("BDAT present in fixture"); + set_chunk_id(data, bdat, *b"XDAT"); + }); + let graph = gix_commitgraph::Graph::from_info_dir(&info_dir(tmp.path())).expect("graph remains readable"); + assert!(graph.bloom_filter_settings().is_none(), "missing BDAT disables Bloom"); +} + +#[test] +fn bloom_is_disabled_if_bidx_is_too_small() { + let tmp = scripted_fixture_writable("changed_paths_v2.sh").expect("fixture available"); + mutate_commit_graph(tmp.path(), |data| { + let entries = parse_chunk_table(data); + let bidx = find_chunk_index(&entries, *b"BIDX").expect("BIDX present in fixture"); + let bidx_offset = entries[bidx].offset; + set_chunk_offset(data, bidx + 1, bidx_offset + 4); + }); + let graph = gix_commitgraph::Graph::from_info_dir(&info_dir(tmp.path())).expect("graph remains readable"); + assert!(graph.bloom_filter_settings().is_none(), "too-small BIDX disables Bloom"); +} + +#[test] +fn bloom_is_disabled_if_bdat_is_too_small() { + let tmp = scripted_fixture_writable("changed_paths_v2.sh").expect("fixture available"); + mutate_commit_graph(tmp.path(), |data| { + let entries = parse_chunk_table(data); + let bdat = find_chunk_index(&entries, *b"BDAT").expect("BDAT present in fixture"); + let next_offset = entries[bdat + 1].offset; + set_chunk_offset(data, bdat, next_offset - 4); + }); + let graph = gix_commitgraph::Graph::from_info_dir(&info_dir(tmp.path())).expect("graph remains readable"); + assert!(graph.bloom_filter_settings().is_none(), "too-small BDAT disables Bloom"); +} + +#[test] +fn bloom_is_disabled_if_bidx_offsets_are_invalid() { + let tmp = scripted_fixture_writable("changed_paths_v2.sh").expect("fixture available"); + mutate_commit_graph(tmp.path(), |data| { + let entries = parse_chunk_table(data); + let bidx = find_chunk_index(&entries, *b"BIDX").expect("BIDX present in fixture"); + let start = entries[bidx].offset as usize; + data[start..start + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + data[start + 4..start + 8].copy_from_slice(&1u32.to_be_bytes()); + }); + let graph = gix_commitgraph::Graph::from_info_dir(&info_dir(tmp.path())).expect("graph remains readable"); + assert!( + graph.bloom_filter_settings().is_none(), + "out-of-range and decreasing BIDX offsets disable Bloom" + ); +} + +#[derive(Clone, Copy)] +struct ChunkTableEntry { + id: [u8; 4], + offset: u64, +} + +fn info_dir(repo_path: &Path) -> std::path::PathBuf { + repo_path.join(".git").join("objects").join("info") +} + +#[allow(clippy::permissions_set_readonly_false)] +fn mutate_commit_graph(repo_path: &Path, mutate: impl FnOnce(&mut [u8])) { + let graph_path = info_dir(repo_path).join("commit-graph"); + let mut permissions = fs::metadata(&graph_path).expect("commit-graph metadata").permissions(); + permissions.set_readonly(false); + fs::set_permissions(&graph_path, permissions).expect("set commit-graph writable"); + let mut data = fs::read(&graph_path).expect("read commit-graph fixture"); + mutate(&mut data); + fs::write(graph_path, data).expect("rewrite mutated commit-graph"); +} + +fn parse_chunk_table(data: &[u8]) -> Vec { + let chunk_count = usize::from(data[6]); + let mut out = Vec::with_capacity(chunk_count + 1); + let table_start = 8; + for idx in 0..=chunk_count { + let entry_offset = table_start + idx * 12; + let id = data[entry_offset..entry_offset + 4] + .try_into() + .expect("chunk id has 4 bytes"); + let offset = u64::from_be_bytes( + data[entry_offset + 4..entry_offset + 12] + .try_into() + .expect("chunk offset has 8 bytes"), + ); + out.push(ChunkTableEntry { id, offset }); + } + out +} + +fn find_chunk_index(entries: &[ChunkTableEntry], id: [u8; 4]) -> Option { + entries.iter().position(|entry| entry.id == id) +} + +fn set_chunk_id(data: &mut [u8], chunk_index: usize, id: [u8; 4]) { + let entry_offset = 8 + chunk_index * 12; + data[entry_offset..entry_offset + 4].copy_from_slice(&id); +} + +fn set_chunk_offset(data: &mut [u8], chunk_index: usize, offset: u64) { + let entry_offset = 8 + chunk_index * 12 + 4; + data[entry_offset..entry_offset + 8].copy_from_slice(&offset.to_be_bytes()); +} diff --git a/gix-commitgraph/tests/fixtures/changed_paths_v2.sh b/gix-commitgraph/tests/fixtures/changed_paths_v2.sh new file mode 100755 index 0000000000..5624d5ae1e --- /dev/null +++ b/gix-commitgraph/tests/fixtures/changed_paths_v2.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name committer +git config user.email committer@example.com +git config commitGraph.changedPathsVersion 2 + +mkdir -p dir/subdir +echo one > dir/subdir/file +git add dir/subdir/file +git commit -q -m c1 + +echo two > dir/subdir/file +echo hello > other +git add dir/subdir/file other +git commit -q -m c2 + +git commit-graph write --no-progress --reachable --changed-paths diff --git a/gix-commitgraph/tests/fixtures/split_chain_changed_paths_mismatch.sh b/gix-commitgraph/tests/fixtures/split_chain_changed_paths_mismatch.sh new file mode 100755 index 0000000000..ae3215a079 --- /dev/null +++ b/gix-commitgraph/tests/fixtures/split_chain_changed_paths_mismatch.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name committer +git config user.email committer@example.com + +echo one > tracked +git add tracked +git commit -q -m c1 +git branch c1 + +echo two > tracked +git add tracked +git commit -q -m c2 +git branch c2 + +git show-ref -s c1 | git -c commitGraph.changedPathsVersion=1 commit-graph write \ + --no-progress --changed-paths --split=no-merge --stdin-commits +git show-ref -s c2 | git -c commitGraph.changedPathsVersion=2 commit-graph write \ + --no-progress --changed-paths --split=no-merge --stdin-commits diff --git a/gix-commitgraph/tests/fixtures/split_chain_top_without_bloom.sh b/gix-commitgraph/tests/fixtures/split_chain_top_without_bloom.sh new file mode 100755 index 0000000000..89d76efd0b --- /dev/null +++ b/gix-commitgraph/tests/fixtures/split_chain_top_without_bloom.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name committer +git config user.email committer@example.com + +echo one > tracked +git add tracked +git commit -q -m c1 +git branch c1 + +echo two > tracked +git add tracked +git commit -q -m c2 +git branch c2 + +git show-ref -s c1 | git -c commitGraph.changedPathsVersion=2 commit-graph write \ + --no-progress --changed-paths --split=no-merge --stdin-commits +git show-ref -s c2 | git commit-graph write \ + --no-progress --split=no-merge --stdin-commits From d04cec52429711c82657db2fd6314d7dcba0c8c0 Mon Sep 17 00:00:00 2001 From: Vicent Marti Date: Thu, 5 Mar 2026 15:54:54 +0100 Subject: [PATCH 2/8] gix-blame: add incremental blame API Implement a gix_blame::incremental API that yelds the blame entries as they're discovered, similarly to Git's `git blame --incremental`. The implementation simply takes the original gix_blame::file and replaces the Vec of blame entries with a generic BlameSink trait. The original gix_blame::file is now implemented as a wrapper for gix_blame::incremental, by implementing the BlameSink trait on Vec and sorting + coalescing the entries before returning. --- gix-blame/src/file/function.rs | 105 +++++++++++++++++--------- gix-blame/src/lib.rs | 4 +- gix-blame/src/types.rs | 28 +++++++ gix-blame/tests/blame.rs | 133 +++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 39 deletions(-) diff --git a/gix-blame/src/file/function.rs b/gix-blame/src/file/function.rs index cf6c0400fb..f3ee8f2483 100644 --- a/gix-blame/src/file/function.rs +++ b/gix-blame/src/file/function.rs @@ -10,7 +10,7 @@ use gix_traverse::commit::find as find_commit; use smallvec::SmallVec; use super::{process_changes, Change, UnblamedHunk}; -use crate::{types::BlamePathEntry, BlameEntry, Error, Options, Outcome, Statistics}; +use crate::{types::BlamePathEntry, BlameEntry, BlameSink, Error, IncrementalOutcome, Options, Outcome, Statistics}; /// Produce a list of consecutive [`BlameEntry`] instances to indicate in which commits the ranges of the file /// at `suspect:` originated in. @@ -60,26 +60,21 @@ use crate::{types::BlamePathEntry, BlameEntry, Error, Options, Outcome, Statisti /// <---><----------><-------><-----><-------> /// <---><---><-----><-------><-----><-------> /// <---><---><-----><-------><-----><-><-><-> -pub fn file( +pub fn incremental( odb: impl gix_object::Find + gix_object::FindHeader, suspect: ObjectId, - cache: Option, + cache: Option<&gix_commitgraph::Graph>, resource_cache: &mut gix_diff::blob::Platform, file_path: &BStr, + sink: &mut impl BlameSink, options: Options, -) -> Result { - let _span = gix_trace::coarse!("gix_blame::file()", ?file_path, ?suspect); +) -> Result { + let _span = gix_trace::coarse!("gix_blame::incremental()", ?file_path, ?suspect); let mut stats = Statistics::default(); let (mut buf, mut buf2, mut buf3) = (Vec::new(), Vec::new(), Vec::new()); let blamed_file_entry_id = find_path_entry_in_commit( - &odb, - &suspect, - file_path, - cache.as_ref(), - &mut buf, - &mut buf2, - &mut stats, + &odb, &suspect, file_path, cache, &mut buf, &mut buf2, &mut stats, )? .ok_or_else(|| Error::FileMissing { file_path: file_path.to_owned(), @@ -90,7 +85,7 @@ pub fn file( // Binary or otherwise empty? if num_lines_in_blamed == 0 { - return Ok(Outcome::default()); + return Ok(IncrementalOutcome::default()); } let ranges_to_blame = options.ranges.to_zero_based_exclusive_ranges(num_lines_in_blamed); @@ -100,12 +95,11 @@ pub fn file( .collect::>(); let (mut buf, mut buf2) = (Vec::new(), Vec::new()); - let commit = find_commit(cache.as_ref(), &odb, &suspect, &mut buf)?; + let commit = find_commit(cache, &odb, &suspect, &mut buf)?; let mut queue: gix_revwalk::PriorityQueue = gix_revwalk::PriorityQueue::new(); queue.insert(commit.commit_time()?, suspect); - let mut out = Vec::new(); let mut diff_state = gix_diff::tree::State::default(); let mut previous_entry: Option<(ObjectId, ObjectId)> = None; let mut blame_path = if options.debug_track_path { @@ -132,12 +126,12 @@ pub fn file( .clone() .unwrap_or_else(|| file_path.to_owned()); - let commit = find_commit(cache.as_ref(), &odb, &suspect, &mut buf)?; + let commit = find_commit(cache, &odb, &suspect, &mut buf)?; let commit_time = commit.commit_time()?; if let Some(since) = options.since { if commit_time < since.seconds { - if unblamed_to_out_is_done(&mut hunks_to_blame, &mut out, suspect) { + if unblamed_to_out_is_done(&mut hunks_to_blame, sink, suspect) { break 'outer; } @@ -145,7 +139,7 @@ pub fn file( } } - let parent_ids: ParentIds = collect_parents(commit, &odb, cache.as_ref(), &mut buf2)?; + let parent_ids: ParentIds = collect_parents(commit, &odb, cache, &mut buf2)?; if parent_ids.is_empty() { if queue.is_empty() { @@ -154,7 +148,7 @@ pub fn file( // the remaining lines to it, even though we don’t explicitly check whether that is // true here. We could perhaps use diff-tree-to-tree to compare `suspect` against // an empty tree to validate this assumption. - if unblamed_to_out_is_done(&mut hunks_to_blame, &mut out, suspect) { + if unblamed_to_out_is_done(&mut hunks_to_blame, sink, suspect) { if let Some(ref mut blame_path) = blame_path { let entry = previous_entry .take() @@ -188,7 +182,7 @@ pub fn file( &odb, &suspect, current_file_path.as_ref(), - cache.as_ref(), + cache, &mut buf, &mut buf2, &mut stats, @@ -239,7 +233,7 @@ pub fn file( &odb, parent_id, current_file_path.as_ref(), - cache.as_ref(), + cache, &mut buf, &mut buf2, &mut stats, @@ -259,12 +253,13 @@ pub fn file( let more_than_one_parent = parent_ids.len() > 1; for (index, (parent_id, parent_commit_time)) in parent_ids.iter().enumerate() { queue.insert(*parent_commit_time, *parent_id); + let changes_for_file_path = tree_diff_at_file_path( &odb, current_file_path.as_ref(), suspect, *parent_id, - cache.as_ref(), + cache, &mut stats, &mut diff_state, resource_cache, @@ -292,7 +287,7 @@ pub fn file( // Do nothing under the assumption that this always (or almost always) // implies that the file comes from a different parent, compared to which // it was modified, not added. - } else if unblamed_to_out_is_done(&mut hunks_to_blame, &mut out, suspect) { + } else if unblamed_to_out_is_done(&mut hunks_to_blame, sink, suspect) { if let Some(ref mut blame_path) = blame_path { let blame_path_entry = BlamePathEntry { source_file_path: current_file_path.clone(), @@ -389,8 +384,8 @@ pub fn file( // At this point, we have copied blame for every hunk to a parent. Hunks // that have only `suspect` left in `suspects` have not passed blame to any // parent, and so they can be converted to a `BlameEntry` and moved to - // `out`. - out.push(entry); + // the sink. + sink.push(entry); return false; } } @@ -405,17 +400,51 @@ pub fn file( "only if there is no portion of the file left we have completed the blame" ); - // I don’t know yet whether it would make sense to use a data structure instead that preserves - // order on insertion. - out.sort_by(|a, b| a.start_in_blamed_file.cmp(&b.start_in_blamed_file)); - Ok(Outcome { - entries: coalesce_blame_entries(out), + Ok(IncrementalOutcome { blob: blamed_file_blob, statistics: stats, blame_path, }) } +/// Produce a list of consecutive [`BlameEntry`] instances to indicate in which commits the ranges of the file +/// at `suspect:` originated in. +/// +/// This is built on top of [`incremental()`], collecting entries into a [`Vec`] sink. +pub fn file( + odb: impl gix_object::Find + gix_object::FindHeader, + suspect: ObjectId, + cache: Option, + resource_cache: &mut gix_diff::blob::Platform, + file_path: &BStr, + options: Options, +) -> Result { + let mut entries = Vec::new(); + let IncrementalOutcome { + blob, + statistics, + blame_path, + } = incremental( + odb, + suspect, + cache.as_ref(), + resource_cache, + file_path, + &mut entries, + options, + )?; + + // Keep the stable output semantics of `file()` even though `incremental()` emits in generation order. + entries.sort_by(|a, b| a.start_in_blamed_file.cmp(&b.start_in_blamed_file)); + + Ok(Outcome { + entries: coalesce_blame_entries(entries), + blob, + statistics, + blame_path, + }) +} + /// Pass ownership of each unblamed hunk of `from` to `to`. /// /// This happens when `from` didn't actually change anything in the blamed file. @@ -425,21 +454,23 @@ fn pass_blame_from_to(from: ObjectId, to: ObjectId, hunks_to_blame: &mut Vec, - out: &mut Vec, + sink: &mut impl BlameSink, suspect: ObjectId, ) -> bool { let mut without_suspect = Vec::new(); - out.extend(hunks_to_blame.drain(..).filter_map(|hunk| { - BlameEntry::from_unblamed_hunk(&hunk, suspect).or_else(|| { + for hunk in hunks_to_blame.drain(..) { + if let Some(entry) = BlameEntry::from_unblamed_hunk(&hunk, suspect) { + sink.push(entry); + } else { without_suspect.push(hunk); - None - }) - })); + } + } *hunks_to_blame = without_suspect; hunks_to_blame.is_empty() } diff --git a/gix-blame/src/lib.rs b/gix-blame/src/lib.rs index 2a31c874f8..36e8aa85a7 100644 --- a/gix-blame/src/lib.rs +++ b/gix-blame/src/lib.rs @@ -17,7 +17,7 @@ mod error; pub use error::Error; mod types; -pub use types::{BlameEntry, BlamePathEntry, BlameRanges, Options, Outcome, Statistics}; +pub use types::{BlameEntry, BlamePathEntry, BlameRanges, BlameSink, IncrementalOutcome, Options, Outcome, Statistics}; mod file; -pub use file::function::file; +pub use file::function::{file, incremental}; diff --git a/gix-blame/src/types.rs b/gix-blame/src/types.rs index 79c49b7a62..64de5c5b24 100644 --- a/gix-blame/src/types.rs +++ b/gix-blame/src/types.rs @@ -10,6 +10,19 @@ use std::{ use crate::file::function::tokens_for_diffing; use crate::Error; +/// Receives [`BlameEntry`] values incrementally as they are discovered by +/// [`incremental()`](crate::incremental()). +pub trait BlameSink { + /// Receive a single blame chunk in generation order. + fn push(&mut self, entry: BlameEntry); +} + +impl BlameSink for Vec { + fn push(&mut self, entry: BlameEntry) { + Vec::push(self, entry); + } +} + /// A type to represent one or more line ranges to blame in a file. /// /// It handles the conversion between git's 1-based inclusive ranges and the internal @@ -204,6 +217,21 @@ pub struct Outcome { pub blame_path: Option>, } +/// The outcome of [`incremental()`](crate::incremental()). +/// +/// It contains all non-entry information so callers can process [`BlameEntry`] instances +/// incrementally through a [`BlameSink`] while still receiving the metadata that was +/// previously available through [`Outcome`]. +#[derive(Debug, Default, Clone)] +pub struct IncrementalOutcome { + /// A buffer with the file content of the *Blamed File*, ready for tokenization. + pub blob: Vec, + /// Additional information about the amount of work performed to produce the blame. + pub statistics: Statistics, + /// Contains a log of all changes that affected the outcome of this blame. + pub blame_path: Option>, +} + /// Additional information about the performed operations. #[derive(Debug, Default, Copy, Clone)] pub struct Statistics { diff --git a/gix-blame/tests/blame.rs b/gix-blame/tests/blame.rs index 8ac0564904..79b633734c 100644 --- a/gix-blame/tests/blame.rs +++ b/gix-blame/tests/blame.rs @@ -621,6 +621,139 @@ mod rename_tracking { } } +mod incremental { + use std::num::NonZeroU32; + + use gix_blame::{BlameEntry, BlameRanges, BlameSink}; + + use crate::Fixture; + + #[derive(Default)] + struct CountingSink { + chunks_received: usize, + } + + impl BlameSink for CountingSink { + fn push(&mut self, _entry: BlameEntry) { + self.chunks_received += 1; + } + } + + fn options() -> gix_blame::Options { + gix_blame::Options { + diff_algorithm: gix_diff::blob::Algorithm::Histogram, + ranges: BlameRanges::default(), + since: None, + rewrites: Some(gix_diff::Rewrites::default()), + debug_track_path: false, + } + } + + fn coalesce_blame_entries(lines_blamed: Vec) -> Vec { + let len = lines_blamed.len(); + lines_blamed + .into_iter() + .fold(Vec::with_capacity(len), |mut acc, entry| { + let previous_entry = acc.last(); + + if let Some(previous_entry) = previous_entry { + let previous_blamed_range = previous_entry.range_in_blamed_file(); + let current_blamed_range = entry.range_in_blamed_file(); + let previous_source_range = previous_entry.range_in_source_file(); + let current_source_range = entry.range_in_source_file(); + if previous_entry.commit_id == entry.commit_id + && previous_blamed_range.end == current_blamed_range.start + && previous_source_range.end == current_source_range.start + { + let coalesced_entry = BlameEntry { + start_in_blamed_file: previous_blamed_range.start as u32, + start_in_source_file: previous_source_range.start as u32, + len: NonZeroU32::new((current_source_range.end - previous_source_range.start) as u32) + .expect("BUG: hunks are never zero-sized"), + commit_id: previous_entry.commit_id, + source_file_name: previous_entry.source_file_name.clone(), + }; + + acc.pop(); + acc.push(coalesced_entry); + } else { + acc.push(entry); + } + + acc + } else { + acc.push(entry); + + acc + } + }) + } + + #[test] + fn streams_chunks_to_custom_sink() -> gix_testtools::Result { + let Fixture { + odb, + mut resource_cache, + suspect, + } = Fixture::new()?; + let source_file_name: gix_object::bstr::BString = "simple.txt".into(); + let mut sink = CountingSink::default(); + + gix_blame::incremental( + &odb, + suspect, + None, + &mut resource_cache, + source_file_name.as_ref(), + &mut sink, + options(), + )?; + + assert!( + sink.chunks_received > 0, + "incremental blame should stream at least one chunk" + ); + Ok(()) + } + + #[test] + fn vec_sink_can_reconstruct_file_entries() -> gix_testtools::Result { + let Fixture { + odb, + mut resource_cache, + suspect, + } = Fixture::new()?; + let source_file_name: gix_object::bstr::BString = "simple.txt".into(); + + let mut streamed_entries = Vec::new(); + gix_blame::incremental( + &odb, + suspect, + None, + &mut resource_cache, + source_file_name.as_ref(), + &mut streamed_entries, + options(), + )?; + + streamed_entries.sort_by(|a, b| a.start_in_blamed_file.cmp(&b.start_in_blamed_file)); + let streamed_entries = coalesce_blame_entries(streamed_entries); + + let file_entries = gix_blame::file( + &odb, + suspect, + None, + &mut resource_cache, + source_file_name.as_ref(), + options(), + )? + .entries; + + pretty_assertions::assert_eq!(streamed_entries, file_entries); + Ok(()) + } +} + fn fixture_path() -> gix_testtools::Result { gix_testtools::scripted_fixture_read_only("make_blame_repo.sh") } From a84beaa081e623f3058df6910014b3260461fd90 Mon Sep 17 00:00:00 2001 From: Vicent Marti Date: Thu, 5 Mar 2026 15:55:51 +0100 Subject: [PATCH 3/8] gix-blame: prefilter blame walk with changed-path bloom cache Use the new changed-path bloom filters from the commit graph to greatly speed up blame our implementation. Whenever we find a rejection on the bloom filter for the current path, we skip it altogether and pass the blame without diffing the trees. --- gix-blame/src/file/function.rs | 44 ++++++++++++++++++ gix-blame/src/types.rs | 10 +++++ gix-blame/tests/blame.rs | 82 +++++++++++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/gix-blame/src/file/function.rs b/gix-blame/src/file/function.rs index f3ee8f2483..6f671f10fd 100644 --- a/gix-blame/src/file/function.rs +++ b/gix-blame/src/file/function.rs @@ -173,6 +173,20 @@ pub fn incremental( continue; } + let first_parent_bloom_result = + maybe_changed_path_in_bloom_filter(cache, suspect, current_file_path.as_ref(), &mut stats); + + if first_parent_bloom_result == Some(false) { + let (first_parent_id, first_parent_commit_time) = parent_ids[0]; + pass_blame_from_to(suspect, first_parent_id, &mut hunks_to_blame); + queue.insert(first_parent_commit_time, first_parent_id); + previous_entry = previous_entry + .take() + .filter(|(id, _)| *id == suspect) + .map(|(_, entry)| (first_parent_id, entry)); + continue 'outer; + } + let mut entry = previous_entry .take() .filter(|(id, _)| *id == suspect) @@ -269,6 +283,9 @@ pub fn incremental( options.rewrites, )?; let Some(modification) = changes_for_file_path else { + if index == 0 && first_parent_bloom_result == Some(true) { + stats.bloom_false_positive += 1; + } if more_than_one_parent { // None of the changes affected the file we’re currently blaming. // Copy blame to parent. @@ -910,6 +927,33 @@ fn collect_parents( Ok(parent_ids) } +fn maybe_changed_path_in_bloom_filter( + cache: Option<&gix_commitgraph::Graph>, + commit_id: ObjectId, + path: &BStr, + stats: &mut Statistics, +) -> Option { + if let Some(cache) = cache { + let result = cache.maybe_contains_path_by_id(commit_id, path); + match result { + Some(false) => { + stats.bloom_queries += 1; + stats.bloom_definitely_not += 1; + } + Some(true) => { + stats.bloom_queries += 1; + stats.bloom_maybe += 1; + } + None => { + stats.bloom_filter_not_present += 1; + } + } + result + } else { + None + } +} + /// Return an iterator over tokens for use in diffing. These are usually lines, but it's important /// to unify them so the later access shows the right thing. pub(crate) fn tokens_for_diffing(data: &[u8]) -> impl TokenSource { diff --git a/gix-blame/src/types.rs b/gix-blame/src/types.rs index 64de5c5b24..7e2ceda80d 100644 --- a/gix-blame/src/types.rs +++ b/gix-blame/src/types.rs @@ -250,6 +250,16 @@ pub struct Statistics { /// The amount of blobs there were compared to each other to learn what changed between commits. /// Note that in order to diff a blob, one needs to load both versions from the database. pub blobs_diffed: usize, + /// Number of times changed-path Bloom filters were queried successfully. + pub bloom_queries: usize, + /// Number of queries where Bloom filters were unavailable for the commit. + pub bloom_filter_not_present: usize, + /// Number of Bloom answers that were `maybe`. + pub bloom_maybe: usize, + /// Number of Bloom answers that were `definitely not`. + pub bloom_definitely_not: usize, + /// Number of `maybe` Bloom answers that turned out not to affect the path. + pub bloom_false_positive: usize, } impl Outcome { diff --git a/gix-blame/tests/blame.rs b/gix-blame/tests/blame.rs index 79b633734c..fcc65eca19 100644 --- a/gix-blame/tests/blame.rs +++ b/gix-blame/tests/blame.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeMap, path::PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::Command, +}; use gix_blame::BlameRanges; use gix_hash::ObjectId; @@ -351,6 +355,56 @@ fn diff_algorithm_parity() { } } +#[test] +fn changed_path_bloom_prefilter_keeps_blame_output_identical() -> gix_testtools::Result { + let worktree_path = fixture_path()?; + write_changed_path_commit_graph(&worktree_path)?; + + let source_file_name: gix_object::bstr::BString = "simple.txt".into(); + let options = gix_blame::Options { + diff_algorithm: gix_diff::blob::Algorithm::Histogram, + ranges: BlameRanges::default(), + since: None, + rewrites: Some(gix_diff::Rewrites::default()), + debug_track_path: false, + }; + + let Fixture { + odb, + mut resource_cache, + suspect, + } = Fixture::for_worktree_path(worktree_path.clone())?; + let without_bloom = gix_blame::file( + &odb, + suspect, + None, + &mut resource_cache, + source_file_name.as_ref(), + options.clone(), + )?; + + let Fixture { + odb, + mut resource_cache, + suspect, + } = Fixture::for_worktree_path(worktree_path.clone())?; + let cache = gix_commitgraph::at(worktree_path.join(".git/objects/info")) + .map_err(|err| std::io::Error::other(format!("loading commit-graph failed: {err}")))?; + let with_bloom = gix_blame::file( + &odb, + suspect, + Some(cache), + &mut resource_cache, + source_file_name.as_ref(), + options, + )?; + + pretty_assertions::assert_eq!(with_bloom.entries, without_bloom.entries); + assert!(with_bloom.statistics.bloom_queries > 0); + assert!(with_bloom.statistics.trees_diffed <= without_bloom.statistics.trees_diffed); + Ok(()) +} + #[test] fn file_that_was_added_in_two_branches() -> gix_testtools::Result { let worktree_path = gix_testtools::scripted_fixture_read_only("make_blame_two_roots_repo.sh")?; @@ -754,6 +808,32 @@ mod incremental { } } +fn write_changed_path_commit_graph(worktree_path: &Path) -> gix_testtools::Result { + let config_status = Command::new("git") + .args(["config", "commitGraph.changedPathsVersion", "2"]) + .current_dir(worktree_path) + .status()?; + assert!( + config_status.success(), + "setting commitGraph.changedPathsVersion should succeed" + ); + + let write_status = Command::new("git") + .args([ + "commit-graph", + "write", + "--no-progress", + "--reachable", + "--changed-paths", + ]) + .current_dir(worktree_path) + .status()?; + assert!( + write_status.success(), + "writing changed-path commit-graph should succeed" + ); + Ok(()) +} fn fixture_path() -> gix_testtools::Result { gix_testtools::scripted_fixture_read_only("make_blame_repo.sh") } From 1be20656f201a2a0a2f615473db691836302ab7a Mon Sep 17 00:00:00 2001 From: Vicent Marti Date: Thu, 5 Mar 2026 10:21:19 +0100 Subject: [PATCH 4/8] core: implement log_file Implement the log_file method in gitoxide-core, which allows performing path-delimited log commands. With the new changed paths bloom filter, it is not possible to perform this operation very efficiently. --- gitoxide-core/src/repository/log.rs | 57 +++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/gitoxide-core/src/repository/log.rs b/gitoxide-core/src/repository/log.rs index ed4d1cb4fd..49299bf9fc 100644 --- a/gitoxide-core/src/repository/log.rs +++ b/gitoxide-core/src/repository/log.rs @@ -1,5 +1,4 @@ -use anyhow::bail; -use gix::bstr::{BString, ByteSlice}; +use gix::bstr::{BStr, BString, ByteSlice}; pub fn log(mut repo: gix::Repository, out: &mut dyn std::io::Write, path: Option) -> anyhow::Result<()> { repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?)); @@ -25,8 +24,20 @@ fn log_all(repo: gix::Repository, out: &mut dyn std::io::Write) -> Result<(), an Ok(()) } -fn log_file(_repo: gix::Repository, _out: &mut dyn std::io::Write, _path: BString) -> anyhow::Result<()> { - bail!("File-based lookup isn't yet implemented in a way that is competitively fast"); +fn log_file(repo: gix::Repository, out: &mut dyn std::io::Write, path: BString) -> anyhow::Result<()> { + let path = gix::path::to_unix_separators_on_windows(path.as_bstr()).into_owned(); + let head = repo.head()?.peel_to_commit()?; + let cache = repo.commit_graph_if_enabled()?; + let topo = gix::traverse::commit::topo::Builder::from_iters(&repo.objects, [head.id], None::>) + .build()?; + + for info in topo { + let info = info?; + if commit_changes_path(&repo, cache.as_ref(), &info, path.as_ref())? { + write_info(&repo, &mut *out, &info)?; + } + } + Ok(()) } fn write_info( @@ -48,3 +59,41 @@ fn write_info( Ok(()) } + +fn commit_changes_path( + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, + info: &gix::traverse::commit::Info, + path: &BStr, +) -> anyhow::Result { + let commit = repo.find_commit(info.id)?; + let commit_tree = commit.tree()?; + let commit_entry = lookup_path_entry(&commit_tree, path)?; + + if info.parent_ids.is_empty() { + return Ok(commit_entry.is_some()); + } + + for (index, parent_id) in info.parent_ids.iter().enumerate() { + if index == 0 && cache.and_then(|graph| graph.maybe_contains_path_by_id(info.id, path)) == Some(false) { + continue; + } + + let parent = repo.find_commit(*parent_id)?; + let parent_tree = parent.tree()?; + let parent_entry = lookup_path_entry(&parent_tree, path)?; + if commit_entry != parent_entry { + return Ok(true); + } + } + + Ok(false) +} + +fn lookup_path_entry( + tree: &gix::Tree<'_>, + path: &BStr, +) -> anyhow::Result> { + let entry = tree.lookup_entry(path.split(|b| *b == b'/'))?; + Ok(entry.map(|entry| (entry.mode(), entry.object_id()))) +} From 939357deff6f2df7d4b25f7624379f1be2b91d48 Mon Sep 17 00:00:00 2001 From: Vicent Marti Date: Thu, 5 Mar 2026 12:25:03 +0100 Subject: [PATCH 5/8] gix-blame: optimize process_changes to avoid redundant work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change `process_changes` to take `&[Change]` instead of `Vec`, eliminating the `changes.clone()` heap allocation at every call site. Replace the O(H×C) restart-from-beginning approach with a cursor that advances through the changes list across hunks. Non-suspect hunks are now skipped immediately. When the rare case of overlapping suspect ranges is detected (from merge blame convergence), the cursor safely resets to maintain correctness. --- gix-blame/src/file/function.rs | 4 ++-- gix-blame/src/file/mod.rs | 39 ++++++++++++++++++++++++---------- gix-blame/src/file/tests.rs | 30 +++++++++++++------------- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/gix-blame/src/file/function.rs b/gix-blame/src/file/function.rs index 6f671f10fd..ad1a018e10 100644 --- a/gix-blame/src/file/function.rs +++ b/gix-blame/src/file/function.rs @@ -334,7 +334,7 @@ pub fn incremental( options.diff_algorithm, &mut stats, )?; - hunks_to_blame = process_changes(hunks_to_blame, changes.clone(), suspect, *parent_id); + hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, *parent_id); if let Some(ref mut blame_path) = blame_path { let has_blame_been_passed = hunks_to_blame.iter().any(|hunk| hunk.has_suspect(parent_id)); @@ -366,7 +366,7 @@ pub fn incremental( options.diff_algorithm, &mut stats, )?; - hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, *parent_id); + hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, *parent_id); let mut has_blame_been_passed = false; diff --git a/gix-blame/src/file/mod.rs b/gix-blame/src/file/mod.rs index 85b3f3a8f3..b23012ceb3 100644 --- a/gix-blame/src/file/mod.rs +++ b/gix-blame/src/file/mod.rs @@ -322,24 +322,36 @@ fn process_change( /// Consume `hunks_to_blame` and `changes` to pair up matches ranges (also overlapping) with each other. /// Once a match is found, it's pushed onto `out`. /// -/// `process_changes` assumes that ranges coming from the same *Source File* can and do -/// occasionally overlap. If it were a desirable property of the blame algorithm as a whole to -/// never have two different lines from a *Blamed File* mapped to the same line in a *Source File*, -/// this property would need to be enforced at a higher level than `process_changes`. -/// Then the nested loops could potentially be flattened into one. +/// Uses a cursor to avoid restarting the changes iteration from the beginning for each hunk. +/// When suspect ranges overlap (rare, from merge blame convergence), the cursor resets to +/// maintain correctness. fn process_changes( hunks_to_blame: Vec, - changes: Vec, + changes: &[Change], suspect: ObjectId, parent: ObjectId, ) -> Vec { let mut new_hunks_to_blame = Vec::new(); + let mut offset_in_destination = Offset::Added(0); + let mut changes_pos = 0; + let mut last_suspect_end: Option = None; + + for hunk in hunks_to_blame { + if !hunk.has_suspect(&suspect) { + new_hunks_to_blame.push(hunk); + continue; + } - for mut hunk in hunks_to_blame.into_iter().map(Some) { - let mut offset_in_destination = Offset::Added(0); + let suspect_range = hunk.get_range(&suspect).expect("has_suspect was true"); + if last_suspect_end.is_some_and(|end| suspect_range.start < end) { + changes_pos = 0; + offset_in_destination = Offset::Added(0); + } + last_suspect_end = Some(suspect_range.end); - let mut changes_iter = changes.iter().cloned(); - let mut change = changes_iter.next(); + let mut hunk = Some(hunk); + let mut pos = changes_pos; + let mut change: Option = changes.get(pos).cloned(); loop { (hunk, change) = process_change( @@ -351,12 +363,17 @@ fn process_changes( change, ); - change = change.or_else(|| changes_iter.next()); + if change.is_none() { + pos += 1; + change = changes.get(pos).cloned(); + } if hunk.is_none() { break; } } + + changes_pos = pos; } new_hunks_to_blame } diff --git a/gix-blame/src/file/tests.rs b/gix-blame/src/file/tests.rs index 2e60524205..ef5b48f0c7 100644 --- a/gix-blame/src/file/tests.rs +++ b/gix-blame/src/file/tests.rs @@ -742,7 +742,7 @@ mod process_changes { fn nothing() { let suspect = zero_sha(); let parent = one_sha(); - let new_hunks_to_blame = process_changes(vec![], vec![], suspect, parent); + let new_hunks_to_blame = process_changes(vec![], &[], suspect, parent); assert_eq!(new_hunks_to_blame, []); } @@ -753,7 +753,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(0..4, suspect).into()]; let changes = vec![Change::AddedOrReplaced(0..4, 0)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!(new_hunks_to_blame, [(0..4, suspect).into()]); } @@ -764,7 +764,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(0..6, suspect).into()]; let changes = vec![Change::AddedOrReplaced(0..4, 0), Change::Unchanged(4..6)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -782,7 +782,7 @@ mod process_changes { Change::AddedOrReplaced(2..4, 0), Change::Unchanged(4..6), ]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -804,7 +804,7 @@ mod process_changes { Change::AddedOrReplaced(1..4, 0), Change::Unchanged(4..6), ]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -822,7 +822,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(0..6, suspect).into()]; let changes = vec![Change::AddedOrReplaced(0..1, 0)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -836,7 +836,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(2..6, suspect, 0..4).into()]; let changes = vec![Change::AddedOrReplaced(0..1, 0)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -850,7 +850,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(0..6, suspect).into()]; let changes = vec![Change::AddedOrReplaced(0..4, 3), Change::Unchanged(4..6)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -864,7 +864,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(4..6, suspect, 3..5).into()]; let changes = vec![Change::AddedOrReplaced(0..3, 0), Change::Unchanged(3..5)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!(new_hunks_to_blame, [(4..6, parent, 0..2).into()]); } @@ -875,7 +875,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(1..3, suspect, 0..2).into()]; let changes = vec![Change::AddedOrReplaced(0..1, 2)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -893,7 +893,7 @@ mod process_changes { Change::Unchanged(2..3), Change::AddedOrReplaced(3..4, 0), ]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -915,7 +915,7 @@ mod process_changes { Change::AddedOrReplaced(16..17, 0), Change::Unchanged(17..37), ]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -938,7 +938,7 @@ mod process_changes { Change::AddedOrReplaced(6..9, 0), Change::Unchanged(9..11), ]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -958,7 +958,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(0..4, suspect).into(), (4..7, suspect).into()]; let changes = vec![Change::Deleted(0, 3), Change::AddedOrReplaced(0..4, 0)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, @@ -972,7 +972,7 @@ mod process_changes { let parent = one_sha(); let hunks_to_blame = vec![(13..16, suspect).into(), (10..17, suspect).into()]; let changes = vec![Change::AddedOrReplaced(10..14, 0)]; - let new_hunks_to_blame = process_changes(hunks_to_blame, changes, suspect, parent); + let new_hunks_to_blame = process_changes(hunks_to_blame, &changes, suspect, parent); assert_eq!( new_hunks_to_blame, From 248303eda9fea5d99bed9f01f126a1d997e586a8 Mon Sep 17 00:00:00 2001 From: Vicent Marti Date: Fri, 6 Mar 2026 11:42:30 +0100 Subject: [PATCH 6/8] gix-blame: implement benchmarks for incremental blame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare the performance of the implementation with and without the commit graph cache. gix-blame::incremental/without-commit-graph time: [14.852 s 14.895 s 14.944 s] change: [+0.2968% +0.7623% +1.2529%] (p = 0.00 < 0.05) Change within noise threshold. gix-blame::incremental/with-commit-graph time: [287.55 ms 290.30 ms 292.85 ms] change: [−3.1181% −1.6720% −0.4502%] (p = 0.11 > 0.05) No change in performance detected. Signed-off-by: Vicent Marti --- Cargo.lock | 2 + gix-blame/Cargo.toml | 7 ++ gix-blame/benches/incremental_blame.rs | 131 +++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 gix-blame/benches/incremental_blame.rs diff --git a/Cargo.lock b/Cargo.lock index b971e56a6e..c400f3653d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1507,6 +1507,8 @@ dependencies = [ name = "gix-blame" version = "0.10.0" dependencies = [ + "criterion", + "gix", "gix-commitgraph", "gix-date", "gix-diff", diff --git a/gix-blame/Cargo.toml b/gix-blame/Cargo.toml index fdbfc527f0..793d55c43b 100644 --- a/gix-blame/Cargo.toml +++ b/gix-blame/Cargo.toml @@ -10,6 +10,11 @@ authors = ["Christoph Rüßler ", "Sebastian Thi edition = "2021" rust-version = "1.82" +[[bench]] +name = "incremental-blame" +harness = false +path = "./benches/incremental_blame.rs" + [features] ## Enable support for the SHA-1 hash by forwarding the feature to dependencies. sha1 = [ @@ -38,6 +43,8 @@ smallvec = "1.15.1" thiserror = "2.0.18" [dev-dependencies] +criterion = "0.8.0" +gix = { path = "../gix", default-features = false, features = ["basic", "sha1"] } gix-ref = { path = "../gix-ref" } gix-filter = { path = "../gix-filter" } gix-fs = { path = "../gix-fs" } diff --git a/gix-blame/benches/incremental_blame.rs b/gix-blame/benches/incremental_blame.rs new file mode 100644 index 0000000000..9258bf1dea --- /dev/null +++ b/gix-blame/benches/incremental_blame.rs @@ -0,0 +1,131 @@ +use std::{ + env, + path::{Path, PathBuf}, + process::Command, +}; + +use criterion::{criterion_group, criterion_main, Criterion}; +use gix_blame::{BlameEntry, BlameRanges, BlameSink}; +use gix_object::bstr::BString; + +const DEFAULT_BENCH_PATH: &str = "gix-blame/src/file/function.rs"; + +struct DiscardSink; + +impl BlameSink for DiscardSink { + fn push(&mut self, _entry: BlameEntry) {} +} + +fn incremental_options() -> gix_blame::Options { + gix_blame::Options { + diff_algorithm: gix_diff::blob::Algorithm::Histogram, + ranges: BlameRanges::default(), + since: None, + rewrites: Some(gix_diff::Rewrites::default()), + debug_track_path: false, + } +} + +fn benchmark_incremental_blame(c: &mut Criterion) { + let repo_path = env::var_os("GIX_BLAME_BENCH_REPO").map_or_else( + || { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("gix-blame crate has a parent directory") + .to_path_buf() + }, + PathBuf::from, + ); + let source_file_path: BString = env::var("GIX_BLAME_BENCH_PATH") + .unwrap_or_else(|_| DEFAULT_BENCH_PATH.into()) + .into_bytes() + .into(); + let commit_spec = env::var("GIX_BLAME_BENCH_COMMIT").unwrap_or_else(|_| "HEAD".into()); + let git_dir = repo_path.join(".git"); + + if !has_commit_graph_cache(&git_dir) { + write_changed_path_commit_graph(&repo_path).expect("commit-graph should be writable for benchmark repository"); + } + + let repo = gix::open(&repo_path).expect("repository can be opened"); + let suspect = repo + .rev_parse_single(commit_spec.as_str()) + .expect("commit spec resolves to one object") + .detach(); + + let mut group = c.benchmark_group("gix-blame::incremental"); + group.bench_function("without-commit-graph", |b| { + let mut resource_cache = repo + .diff_resource_cache_for_tree_diff() + .expect("tree-diff resource cache can be created"); + + b.iter(|| { + let mut sink = DiscardSink; + gix_blame::incremental( + &repo, + suspect, + None, + &mut resource_cache, + source_file_path.as_ref(), + &mut sink, + incremental_options(), + ) + .expect("incremental blame should succeed"); + }); + }); + group.bench_function("with-commit-graph", |b| { + let mut resource_cache = repo + .diff_resource_cache_for_tree_diff() + .expect("tree-diff resource cache can be created"); + let cache = repo.commit_graph().expect("commit-graph can be loaded from repository"); + b.iter(|| { + let mut sink = DiscardSink; + gix_blame::incremental( + &repo, + suspect, + Some(&cache), + &mut resource_cache, + source_file_path.as_ref(), + &mut sink, + incremental_options(), + ) + .expect("incremental blame should succeed"); + }); + }); + group.finish(); +} + +fn has_commit_graph_cache(git_dir: &Path) -> bool { + let info_dir = git_dir.join("objects/info"); + info_dir.join("commit-graph").is_file() || info_dir.join("commit-graphs").is_dir() +} + +fn write_changed_path_commit_graph(worktree_path: &Path) -> std::io::Result<()> { + let config_status = Command::new("git") + .args(["config", "commitGraph.changedPathsVersion", "2"]) + .current_dir(worktree_path) + .status()?; + assert!( + config_status.success(), + "setting commitGraph.changedPathsVersion should succeed" + ); + + let write_status = Command::new("git") + .args([ + "commit-graph", + "write", + "--no-progress", + "--reachable", + "--changed-paths", + ]) + .current_dir(worktree_path) + .status()?; + assert!( + write_status.success(), + "writing changed-path commit-graph should succeed" + ); + Ok(()) +} + +criterion_group!(benches, benchmark_incremental_blame); +criterion_main!(benches); From a85c1fe07ee204c85a4c76652acafc68290673f1 Mon Sep 17 00:00:00 2001 From: Vicent Marti Date: Wed, 11 Mar 2026 12:04:33 +0100 Subject: [PATCH 7/8] gix-blame: allow interrupting an incremental blame The BlameSink type now returns a std::ops::ControlFlow value that can be used to interrupt the blame early. Signed-off-by: Vicent Marti --- gix-blame/benches/incremental_blame.rs | 5 +- gix-blame/src/file/function.rs | 293 +++++++++++++++---------- gix-blame/src/file/mod.rs | 50 +++++ gix-blame/src/types.rs | 10 +- gix-blame/tests/blame.rs | 100 ++++----- 5 files changed, 291 insertions(+), 167 deletions(-) diff --git a/gix-blame/benches/incremental_blame.rs b/gix-blame/benches/incremental_blame.rs index 9258bf1dea..cf045b6f21 100644 --- a/gix-blame/benches/incremental_blame.rs +++ b/gix-blame/benches/incremental_blame.rs @@ -1,5 +1,6 @@ use std::{ env, + ops::ControlFlow, path::{Path, PathBuf}, process::Command, }; @@ -13,7 +14,9 @@ const DEFAULT_BENCH_PATH: &str = "gix-blame/src/file/function.rs"; struct DiscardSink; impl BlameSink for DiscardSink { - fn push(&mut self, _entry: BlameEntry) {} + fn push(&mut self, _entry: BlameEntry) -> ControlFlow<()> { + ControlFlow::Continue(()) + } } fn incremental_options() -> gix_blame::Options { diff --git a/gix-blame/src/file/function.rs b/gix-blame/src/file/function.rs index ad1a018e10..fbbcaec7a6 100644 --- a/gix-blame/src/file/function.rs +++ b/gix-blame/src/file/function.rs @@ -1,4 +1,4 @@ -use std::num::NonZeroU32; +use std::ops::ControlFlow; use gix_diff::{blob::intern::TokenSource, tree::Visit}; use gix_hash::ObjectId; @@ -9,7 +9,7 @@ use gix_object::{ use gix_traverse::commit::find as find_commit; use smallvec::SmallVec; -use super::{process_changes, Change, UnblamedHunk}; +use super::{coalesce_blame_entries, process_changes, Change, UnblamedHunk}; use crate::{types::BlamePathEntry, BlameEntry, BlameSink, Error, IncrementalOutcome, Options, Outcome, Statistics}; /// Produce a list of consecutive [`BlameEntry`] instances to indicate in which commits the ranges of the file @@ -107,6 +107,7 @@ pub fn incremental( } else { None }; + let mut stopped_early = false; 'outer: while let Some(suspect) = queue.pop_value() { stats.commits_traversed += 1; @@ -131,11 +132,14 @@ pub fn incremental( if let Some(since) = options.since { if commit_time < since.seconds { - if unblamed_to_out_is_done(&mut hunks_to_blame, sink, suspect) { - break 'outer; + match emit_unblamed_hunks(&mut hunks_to_blame, sink, suspect) { + ControlFlow::Continue(true) => break 'outer, + ControlFlow::Continue(false) => continue, + ControlFlow::Break(()) => { + stopped_early = true; + break 'outer; + } } - - continue; } } @@ -148,25 +152,32 @@ pub fn incremental( // the remaining lines to it, even though we don’t explicitly check whether that is // true here. We could perhaps use diff-tree-to-tree to compare `suspect` against // an empty tree to validate this assumption. - if unblamed_to_out_is_done(&mut hunks_to_blame, sink, suspect) { - if let Some(ref mut blame_path) = blame_path { - let entry = previous_entry - .take() - .filter(|(id, _)| *id == suspect) - .map(|(_, entry)| entry); - - let blame_path_entry = BlamePathEntry { - source_file_path: current_file_path.clone(), - previous_source_file_path: None, - commit_id: suspect, - blob_id: entry.unwrap_or(ObjectId::null(gix_hash::Kind::Sha1)), - previous_blob_id: ObjectId::null(gix_hash::Kind::Sha1), - parent_index: 0, - }; - blame_path.push(blame_path_entry); - } + match emit_unblamed_hunks(&mut hunks_to_blame, sink, suspect) { + ControlFlow::Continue(true) => { + if let Some(ref mut blame_path) = blame_path { + let entry = previous_entry + .take() + .filter(|(id, _)| *id == suspect) + .map(|(_, entry)| entry); - break 'outer; + let blame_path_entry = BlamePathEntry { + source_file_path: current_file_path.clone(), + previous_source_file_path: None, + commit_id: suspect, + blob_id: entry.unwrap_or(ObjectId::null(gix_hash::Kind::Sha1)), + previous_blob_id: ObjectId::null(gix_hash::Kind::Sha1), + parent_index: 0, + }; + blame_path.push(blame_path_entry); + } + + break 'outer; + } + ControlFlow::Continue(false) => {} + ControlFlow::Break(()) => { + stopped_early = true; + break 'outer; + } } } // There is more, keep looking. @@ -304,20 +315,29 @@ pub fn incremental( // Do nothing under the assumption that this always (or almost always) // implies that the file comes from a different parent, compared to which // it was modified, not added. - } else if unblamed_to_out_is_done(&mut hunks_to_blame, sink, suspect) { - if let Some(ref mut blame_path) = blame_path { - let blame_path_entry = BlamePathEntry { - source_file_path: current_file_path.clone(), - previous_source_file_path: None, - commit_id: suspect, - blob_id: id, - previous_blob_id: ObjectId::null(gix_hash::Kind::Sha1), - parent_index: index, - }; - blame_path.push(blame_path_entry); + } else { + match emit_unblamed_hunks(&mut hunks_to_blame, sink, suspect) { + ControlFlow::Continue(true) => { + if let Some(ref mut blame_path) = blame_path { + let blame_path_entry = BlamePathEntry { + source_file_path: current_file_path.clone(), + previous_source_file_path: None, + commit_id: suspect, + blob_id: id, + previous_blob_id: ObjectId::null(gix_hash::Kind::Sha1), + parent_index: index, + }; + blame_path.push(blame_path_entry); + } + + break 'outer; + } + ControlFlow::Continue(false) => {} + ControlFlow::Break(()) => { + stopped_early = true; + break 'outer; + } } - - break 'outer; } } TreeDiffChange::Deletion => { @@ -395,27 +415,19 @@ pub fn incremental( } } - hunks_to_blame.retain_mut(|unblamed_hunk| { - if unblamed_hunk.suspects.len() == 1 { - if let Some(entry) = BlameEntry::from_unblamed_hunk(unblamed_hunk, suspect) { - // At this point, we have copied blame for every hunk to a parent. Hunks - // that have only `suspect` left in `suspects` have not passed blame to any - // parent, and so they can be converted to a `BlameEntry` and moved to - // the sink. - sink.push(entry); - return false; - } - } - unblamed_hunk.remove_blame(suspect); - true - }); + if let ControlFlow::Break(()) = emit_single_suspect_hunks(&mut hunks_to_blame, sink, suspect) { + stopped_early = true; + break 'outer; + } } - debug_assert_eq!( - hunks_to_blame, - vec![], - "only if there is no portion of the file left we have completed the blame" - ); + if !stopped_early { + debug_assert_eq!( + hunks_to_blame, + vec![], + "only if there is no portion of the file left we have completed the blame" + ); + } Ok(IncrementalOutcome { blob: blamed_file_blob, @@ -471,75 +483,60 @@ fn pass_blame_from_to(from: ObjectId, to: ObjectId, hunks_to_blame: &mut Vec, sink: &mut impl BlameSink, suspect: ObjectId, -) -> bool { +) -> ControlFlow<(), bool> { let mut without_suspect = Vec::new(); - for hunk in hunks_to_blame.drain(..) { + let mut remaining_hunks = std::mem::take(hunks_to_blame).into_iter(); + while let Some(hunk) = remaining_hunks.next() { if let Some(entry) = BlameEntry::from_unblamed_hunk(&hunk, suspect) { - sink.push(entry); + if let ControlFlow::Break(()) = sink.push(entry) { + without_suspect.extend(remaining_hunks); + *hunks_to_blame = without_suspect; + return ControlFlow::Break(()); + } } else { without_suspect.push(hunk); } } *hunks_to_blame = without_suspect; - hunks_to_blame.is_empty() + ControlFlow::Continue(hunks_to_blame.is_empty()) } -/// This function merges adjacent blame entries. It merges entries that are adjacent both in the -/// blamed file and in the source file that introduced them. This follows `git`’s -/// behaviour. `libgit2`, as of 2024-09-19, only checks whether two entries are adjacent in the -/// blamed file which can result in different blames in certain edge cases. See [the commit][1] -/// that introduced the extra check into `git` for context. See [this commit][2] for a way to test -/// for this behaviour in `git`. -/// -/// [1]: https://github.com/git/git/commit/c2ebaa27d63bfb7c50cbbdaba90aee4efdd45d0a -/// [2]: https://github.com/git/git/commit/6dbf0c7bebd1c71c44d786ebac0f2b3f226a0131 -fn coalesce_blame_entries(lines_blamed: Vec) -> Vec { - let len = lines_blamed.len(); - lines_blamed - .into_iter() - .fold(Vec::with_capacity(len), |mut acc, entry| { - let previous_entry = acc.last(); - - if let Some(previous_entry) = previous_entry { - let previous_blamed_range = previous_entry.range_in_blamed_file(); - let current_blamed_range = entry.range_in_blamed_file(); - let previous_source_range = previous_entry.range_in_source_file(); - let current_source_range = entry.range_in_source_file(); - if previous_entry.commit_id == entry.commit_id - && previous_blamed_range.end == current_blamed_range.start - // As of 2024-09-19, the check below only is in `git`, but not in `libgit2`. - && previous_source_range.end == current_source_range.start - { - let coalesced_entry = BlameEntry { - start_in_blamed_file: previous_blamed_range.start as u32, - start_in_source_file: previous_source_range.start as u32, - len: NonZeroU32::new((current_source_range.end - previous_source_range.start) as u32) - .expect("BUG: hunks are never zero-sized"), - commit_id: previous_entry.commit_id, - source_file_name: previous_entry.source_file_name.clone(), - }; - - acc.pop(); - acc.push(coalesced_entry); - } else { - acc.push(entry); +/// Convert hunks that still have `suspect` as their only candidate into [`BlameEntry`] values. +fn emit_single_suspect_hunks( + hunks_to_blame: &mut Vec, + sink: &mut impl BlameSink, + suspect: ObjectId, +) -> ControlFlow<()> { + let mut remaining_hunks = Vec::with_capacity(hunks_to_blame.len()); + let mut pending_hunks = std::mem::take(hunks_to_blame).into_iter(); + while let Some(mut unblamed_hunk) = pending_hunks.next() { + if unblamed_hunk.suspects.len() == 1 { + if let Some(entry) = BlameEntry::from_unblamed_hunk(&unblamed_hunk, suspect) { + // At this point, we have copied blame for every hunk to a parent. Hunks that have + // only `suspect` left in `suspects` have not passed blame to any parent, and so + // they can be converted to a `BlameEntry` and moved to the sink. + if let ControlFlow::Break(()) = sink.push(entry) { + remaining_hunks.extend(pending_hunks); + *hunks_to_blame = remaining_hunks; + return ControlFlow::Break(()); } - - acc - } else { - acc.push(entry); - - acc + continue; } - }) + } + unblamed_hunk.remove_blame(suspect); + remaining_hunks.push(unblamed_hunk); + } + + *hunks_to_blame = remaining_hunks; + ControlFlow::Continue(()) } /// The union of [`gix_diff::tree::recorder::Change`] and [`gix_diff::tree_with_rewrites::Change`], @@ -959,3 +956,79 @@ fn maybe_changed_path_in_bloom_filter( pub(crate) fn tokens_for_diffing(data: &[u8]) -> impl TokenSource { gix_diff::blob::sources::byte_lines_with_terminator(data) } + +#[cfg(test)] +mod tests { + use gix_object::bstr::BString; + use gix_testtools::Result; + + use super::{file, incremental}; + use crate::{file::coalesce_blame_entries, BlameRanges, Options}; + + struct Fixture { + repo: gix::Repository, + resource_cache: gix_diff::blob::Platform, + suspect: gix_hash::ObjectId, + } + + impl Fixture { + fn new() -> Result { + let repo_path = gix_testtools::scripted_fixture_read_only("make_blame_repo.sh")?; + let repo = gix::open(&repo_path)?; + let suspect = repo.rev_parse_single("HEAD")?.detach(); + let resource_cache = repo.diff_resource_cache_for_tree_diff()?; + Ok(Self { + repo, + resource_cache, + suspect, + }) + } + } + + fn options() -> Options { + Options { + diff_algorithm: gix_diff::blob::Algorithm::Histogram, + ranges: BlameRanges::default(), + since: None, + rewrites: Some(gix_diff::Rewrites::default()), + debug_track_path: false, + } + } + + #[test] + fn vec_sink_can_reconstruct_file_entries() -> Result { + let Fixture { + repo, + mut resource_cache, + suspect, + } = Fixture::new()?; + let source_file_name: BString = "simple.txt".into(); + + let mut streamed_entries = Vec::new(); + incremental( + &repo, + suspect, + None, + &mut resource_cache, + source_file_name.as_ref(), + &mut streamed_entries, + options(), + )?; + + streamed_entries.sort_by(|a, b| a.start_in_blamed_file.cmp(&b.start_in_blamed_file)); + let streamed_entries = coalesce_blame_entries(streamed_entries); + + let file_entries = file( + &repo, + suspect, + None, + &mut resource_cache, + source_file_name.as_ref(), + options(), + )? + .entries; + + pretty_assertions::assert_eq!(streamed_entries, file_entries); + Ok(()) + } +} diff --git a/gix-blame/src/file/mod.rs b/gix-blame/src/file/mod.rs index b23012ceb3..4e6cd561dc 100644 --- a/gix-blame/src/file/mod.rs +++ b/gix-blame/src/file/mod.rs @@ -8,6 +8,56 @@ use crate::types::{BlameEntry, Change, Either, LineRange, Offset, UnblamedHunk}; pub(super) mod function; +/// This function merges adjacent blame entries. It merges entries that are adjacent both in the +/// blamed file and in the source file that introduced them. This follows `git`'s +/// behaviour. `libgit2`, as of 2024-09-19, only checks whether two entries are adjacent in the +/// blamed file which can result in different blames in certain edge cases. See [the commit][1] +/// that introduced the extra check into `git` for context. See [this commit][2] for a way to test +/// for this behaviour in `git`. +/// +/// [1]: https://github.com/git/git/commit/c2ebaa27d63bfb7c50cbbdaba90aee4efdd45d0a +/// [2]: https://github.com/git/git/commit/6dbf0c7bebd1c71c44d786ebac0f2b3f226a0131 +pub(super) fn coalesce_blame_entries(lines_blamed: Vec) -> Vec { + let len = lines_blamed.len(); + lines_blamed + .into_iter() + .fold(Vec::with_capacity(len), |mut acc, entry| { + let previous_entry = acc.last(); + + if let Some(previous_entry) = previous_entry { + let previous_blamed_range = previous_entry.range_in_blamed_file(); + let current_blamed_range = entry.range_in_blamed_file(); + let previous_source_range = previous_entry.range_in_source_file(); + let current_source_range = entry.range_in_source_file(); + if previous_entry.commit_id == entry.commit_id + && previous_blamed_range.end == current_blamed_range.start + // As of 2024-09-19, the check below only is in `git`, but not in `libgit2`. + && previous_source_range.end == current_source_range.start + { + let coalesced_entry = BlameEntry { + start_in_blamed_file: previous_blamed_range.start as u32, + start_in_source_file: previous_source_range.start as u32, + len: NonZeroU32::new((current_source_range.end - previous_source_range.start) as u32) + .expect("BUG: hunks are never zero-sized"), + commit_id: previous_entry.commit_id, + source_file_name: previous_entry.source_file_name.clone(), + }; + + acc.pop(); + acc.push(coalesced_entry); + } else { + acc.push(entry); + } + + acc + } else { + acc.push(entry); + + acc + } + }) +} + /// Compare a section from a potential *Source File* (`hunk`) with a change from a diff and see if /// there is an intersection with `change`. Based on that intersection, we may generate a /// [`BlameEntry`] for `out` and/or split the `hunk` into multiple. diff --git a/gix-blame/src/types.rs b/gix-blame/src/types.rs index 7e2ceda80d..2b65eefe1f 100644 --- a/gix-blame/src/types.rs +++ b/gix-blame/src/types.rs @@ -4,7 +4,7 @@ use smallvec::SmallVec; use std::ops::RangeInclusive; use std::{ num::NonZeroU32, - ops::{AddAssign, Range, SubAssign}, + ops::{AddAssign, ControlFlow, Range, SubAssign}, }; use crate::file::function::tokens_for_diffing; @@ -14,12 +14,16 @@ use crate::Error; /// [`incremental()`](crate::incremental()). pub trait BlameSink { /// Receive a single blame chunk in generation order. - fn push(&mut self, entry: BlameEntry); + /// + /// Return [`ControlFlow::Break`] to stop streaming early while still allowing + /// [`incremental()`](crate::incremental()) to return the partial metadata gathered so far. + fn push(&mut self, entry: BlameEntry) -> ControlFlow<()>; } impl BlameSink for Vec { - fn push(&mut self, entry: BlameEntry) { + fn push(&mut self, entry: BlameEntry) -> ControlFlow<()> { Vec::push(self, entry); + ControlFlow::Continue(()) } } diff --git a/gix-blame/tests/blame.rs b/gix-blame/tests/blame.rs index fcc65eca19..1f22539fe4 100644 --- a/gix-blame/tests/blame.rs +++ b/gix-blame/tests/blame.rs @@ -676,7 +676,7 @@ mod rename_tracking { } mod incremental { - use std::num::NonZeroU32; + use std::ops::ControlFlow; use gix_blame::{BlameEntry, BlameRanges, BlameSink}; @@ -688,8 +688,25 @@ mod incremental { } impl BlameSink for CountingSink { - fn push(&mut self, _entry: BlameEntry) { + fn push(&mut self, _entry: BlameEntry) -> ControlFlow<()> { self.chunks_received += 1; + ControlFlow::Continue(()) + } + } + + struct BreakingSink { + chunks_received: usize, + stop_after: usize, + } + + impl BlameSink for BreakingSink { + fn push(&mut self, _entry: BlameEntry) -> ControlFlow<()> { + self.chunks_received += 1; + if self.chunks_received >= self.stop_after { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } } } @@ -703,46 +720,6 @@ mod incremental { } } - fn coalesce_blame_entries(lines_blamed: Vec) -> Vec { - let len = lines_blamed.len(); - lines_blamed - .into_iter() - .fold(Vec::with_capacity(len), |mut acc, entry| { - let previous_entry = acc.last(); - - if let Some(previous_entry) = previous_entry { - let previous_blamed_range = previous_entry.range_in_blamed_file(); - let current_blamed_range = entry.range_in_blamed_file(); - let previous_source_range = previous_entry.range_in_source_file(); - let current_source_range = entry.range_in_source_file(); - if previous_entry.commit_id == entry.commit_id - && previous_blamed_range.end == current_blamed_range.start - && previous_source_range.end == current_source_range.start - { - let coalesced_entry = BlameEntry { - start_in_blamed_file: previous_blamed_range.start as u32, - start_in_source_file: previous_source_range.start as u32, - len: NonZeroU32::new((current_source_range.end - previous_source_range.start) as u32) - .expect("BUG: hunks are never zero-sized"), - commit_id: previous_entry.commit_id, - source_file_name: previous_entry.source_file_name.clone(), - }; - - acc.pop(); - acc.push(coalesced_entry); - } else { - acc.push(entry); - } - - acc - } else { - acc.push(entry); - - acc - } - }) - } - #[test] fn streams_chunks_to_custom_sink() -> gix_testtools::Result { let Fixture { @@ -771,39 +748,56 @@ mod incremental { } #[test] - fn vec_sink_can_reconstruct_file_entries() -> gix_testtools::Result { + fn sink_can_stop_streaming_early() -> gix_testtools::Result { let Fixture { odb, mut resource_cache, suspect, } = Fixture::new()?; let source_file_name: gix_object::bstr::BString = "simple.txt".into(); + let mut sink = BreakingSink { + chunks_received: 0, + stop_after: 1, + }; - let mut streamed_entries = Vec::new(); - gix_blame::incremental( + let outcome = gix_blame::incremental( &odb, suspect, None, &mut resource_cache, source_file_name.as_ref(), - &mut streamed_entries, + &mut sink, options(), )?; - streamed_entries.sort_by(|a, b| a.start_in_blamed_file.cmp(&b.start_in_blamed_file)); - let streamed_entries = coalesce_blame_entries(streamed_entries); - - let file_entries = gix_blame::file( + let Fixture { + odb, + mut resource_cache, + suspect, + } = Fixture::new()?; + let mut full_sink = CountingSink::default(); + gix_blame::incremental( &odb, suspect, None, &mut resource_cache, source_file_name.as_ref(), + &mut full_sink, options(), - )? - .entries; + )?; - pretty_assertions::assert_eq!(streamed_entries, file_entries); + assert_eq!( + sink.chunks_received, 1, + "the sink should stop after the first streamed chunk" + ); + assert!( + full_sink.chunks_received > sink.chunks_received, + "the early-stopping sink should observe fewer chunks than a full run" + ); + assert!( + !outcome.blob.is_empty(), + "incremental blame should still return partial metadata on early stop" + ); Ok(()) } } From 5b9460485488ff209bc8ce77c5db97c9e32d7944 Mon Sep 17 00:00:00 2001 From: Vicent Marti Date: Fri, 3 Apr 2026 09:36:27 +0000 Subject: [PATCH 8/8] gix-commitgraph: add support for v1 hashes too Signed-off-by: Vicent Marti --- gix-commitgraph/src/bloom.rs | 168 +++++++++++++++++- gix-commitgraph/src/file/init.rs | 4 +- gix-commitgraph/src/lib.rs | 11 ++ gix-commitgraph/tests/access/mod.rs | 163 ++++++++++++++++- .../tests/fixtures/changed_paths_v1.sh | 20 +++ .../tests/fixtures/changed_paths_v2.sh | 1 + .../generated-archives/changed_paths_v1.tar | Bin 0 -> 77824 bytes .../generated-archives/changed_paths_v2.tar | Bin 0 -> 77824 bytes .../split_chain_changed_paths_mismatch.tar | Bin 0 -> 75776 bytes .../split_chain_top_without_bloom.tar | Bin 0 -> 75776 bytes .../split_chain_changed_paths_mismatch.sh | 1 + .../fixtures/split_chain_top_without_bloom.sh | 3 +- 12 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 gix-commitgraph/tests/fixtures/changed_paths_v1.sh create mode 100644 gix-commitgraph/tests/fixtures/generated-archives/changed_paths_v1.tar create mode 100644 gix-commitgraph/tests/fixtures/generated-archives/changed_paths_v2.tar create mode 100644 gix-commitgraph/tests/fixtures/generated-archives/split_chain_changed_paths_mismatch.tar create mode 100644 gix-commitgraph/tests/fixtures/generated-archives/split_chain_top_without_bloom.tar diff --git a/gix-commitgraph/src/bloom.rs b/gix-commitgraph/src/bloom.rs index 88e4606caf..be2823542d 100644 --- a/gix-commitgraph/src/bloom.rs +++ b/gix-commitgraph/src/bloom.rs @@ -45,9 +45,14 @@ impl BloomKey { } fn from_bytes(path: &[u8], settings: BloomFilterSettings) -> Self { + let (h0, h1) = match settings.hash_version { + 1 => (murmur3_v1(SEED0, path), murmur3_v1(SEED1, path)), + 2 => (murmur3_v2(SEED0, path), murmur3_v2(SEED1, path)), + version => panic!("BUG: unsupported Bloom hash version {version} should have been filtered earlier"), + }; Self { - h0: murmur3_v2(SEED0, path), - h1: murmur3_v2(SEED1, path), + h0, + h1, num_hashes: settings.num_hashes, } } @@ -144,16 +149,91 @@ impl Graph { } } +pub(crate) fn murmur3_v1(seed: u32, data: &[u8]) -> u32 { + const C1: u32 = 0xcc9e_2d51; + const C2: u32 = 0x1b87_3593; + const R1: u32 = 15; + const R2: u32 = 13; + const M: u32 = 5; + const N: u32 = 0xe654_6b64; + + fn byte_to_u32(byte: u8) -> u32 { + u32::from_ne_bytes(i32::from(i8::from_ne_bytes([byte])).to_ne_bytes()) + } + + let mut seed = seed; + let chunks = data.chunks_exact(4); + let tail = chunks.remainder(); + for chunk in chunks { + let byte1 = byte_to_u32(chunk[0]); + let byte2 = byte_to_u32(chunk[1]) << 8; + let byte3 = byte_to_u32(chunk[2]) << 16; + let byte4 = byte_to_u32(chunk[3]) << 24; + let mut k = byte1 | byte2 | byte3 | byte4; + k = k.wrapping_mul(C1); + k = k.rotate_left(R1); + k = k.wrapping_mul(C2); + + seed ^= k; + seed = seed.rotate_left(R2).wrapping_mul(M).wrapping_add(N); + } + + let mut k1 = 0u32; + match tail.len() { + 3 => { + k1 ^= byte_to_u32(tail[2]) << 16; + k1 ^= byte_to_u32(tail[1]) << 8; + k1 ^= byte_to_u32(tail[0]); + } + 2 => { + k1 ^= byte_to_u32(tail[1]) << 8; + k1 ^= byte_to_u32(tail[0]); + } + 1 => { + k1 ^= byte_to_u32(tail[0]); + } + 0 => {} + _ => unreachable!("remainder is shorter than 4 bytes"), + } + if !tail.is_empty() { + k1 = k1.wrapping_mul(C1); + k1 = k1.rotate_left(R1); + k1 = k1.wrapping_mul(C2); + seed ^= k1; + } + + seed ^= data.len() as u32; + seed ^= seed >> 16; + seed = seed.wrapping_mul(0x85eb_ca6b); + seed ^= seed >> 13; + seed = seed.wrapping_mul(0xc2b2_ae35); + seed ^= seed >> 16; + seed +} + pub(crate) fn murmur3_v2(seed: u32, data: &[u8]) -> u32 { let mut reader = Cursor::new(data); murmur3::murmur3_32(&mut reader, seed).expect("reading from memory does not fail") } #[cfg(test)] mod tests { - use super::{murmur3_v2, BloomKey}; + use super::{murmur3_v2, BloomKey, BITS_PER_WORD}; use crate::BloomFilterSettings; use bstr::BStr; + fn filter_bytes_for_path(path: &[u8], settings: BloomFilterSettings, len: usize) -> Vec { + let key = BloomKey::from_path(BStr::new(path), settings); + let mut out = vec![0u8; len]; + let modulo = (len as u64) * BITS_PER_WORD; + for i in 0..key.num_hashes { + let hash = key.h0.wrapping_add(i.wrapping_mul(key.h1)); + let bit_pos = u64::from(hash) % modulo; + let byte_pos = (bit_pos / BITS_PER_WORD) as usize; + out[byte_pos] |= 1u8 << (bit_pos % BITS_PER_WORD); + } + out + } + #[test] fn murmur3_known_vectors_match_git_and_reference_values() { assert_eq!(murmur3_v2(0, b""), 0x0000_0000); @@ -165,7 +245,36 @@ mod tests { } #[test] - fn bloom_key_for_empty_path_matches_git_vector() { + fn murmur3_v2_matches_git_high_bit_vector() { + assert_eq!(murmur3_v2(0, b"\x99\xaa\xbb\xcc\xdd\xee\xff"), 0xa183_ccfd); + } + + #[test] + fn bloom_key_for_empty_path_matches_git_v1_vector() { + let settings = BloomFilterSettings { + hash_version: 1, + num_hashes: 7, + bits_per_entry: 10, + }; + let key = BloomKey::from_path(BStr::new(b""), settings); + assert_eq!( + (0..key.num_hashes) + .map(|i| key.h0.wrapping_add(i.wrapping_mul(key.h1))) + .collect::>(), + &[ + 0x5615_800c, + 0x5b96_6560, + 0x6117_4ab4, + 0x6698_3008, + 0x6c19_155c, + 0x7199_fab0, + 0x771a_e004 + ] + ); + } + + #[test] + fn bloom_key_for_empty_path_matches_git_v2_vector() { let settings = BloomFilterSettings { hash_version: 2, num_hashes: 7, @@ -187,4 +296,55 @@ mod tests { ] ); } + + #[test] + fn bloom_key_for_high_bit_path_differs_between_versions() { + let path = BStr::new(b"\xc2\xa2"); + let v1 = BloomKey::from_path( + path, + BloomFilterSettings { + hash_version: 1, + num_hashes: 7, + bits_per_entry: 10, + }, + ); + let v2 = BloomKey::from_path( + path, + BloomFilterSettings { + hash_version: 2, + num_hashes: 7, + bits_per_entry: 10, + }, + ); + assert_ne!(v1, v2); + } + + #[test] + fn bloom_filter_for_high_bit_path_matches_git_v1_and_v2_vectors() { + let path = b"\xc2\xa2"; + assert_eq!( + filter_bytes_for_path( + path, + BloomFilterSettings { + hash_version: 1, + num_hashes: 7, + bits_per_entry: 10, + }, + 2, + ), + vec![0x52, 0xa9] + ); + assert_eq!( + filter_bytes_for_path( + path, + BloomFilterSettings { + hash_version: 2, + num_hashes: 7, + bits_per_entry: 10, + }, + 2, + ), + vec![0xc0, 0x1f] + ); + } } diff --git a/gix-commitgraph/src/file/init.rs b/gix-commitgraph/src/file/init.rs index ca0e680fd3..8a51506827 100644 --- a/gix-commitgraph/src/file/init.rs +++ b/gix-commitgraph/src/file/init.rs @@ -148,7 +148,9 @@ impl File { bits_per_entry: from_be_u32(&data[data_range.start + 8..][..4]), }; let bloom_data_payload_len = data_range.len() - BLOOM_FILTER_HEADER_SIZE; - if bloom_index_offsets_are_valid(&data[index_range.clone()], bloom_data_payload_len) { + if settings.is_supported() + && bloom_index_offsets_are_valid(&data[index_range.clone()], bloom_data_payload_len) + { bloom_filter_settings = Some(settings); bloom_filter_data_len = bloom_data_payload_len; bloom_filter_data_offset = Some(data_range.start + BLOOM_FILTER_HEADER_SIZE); diff --git a/gix-commitgraph/src/lib.rs b/gix-commitgraph/src/lib.rs index 7824da1c76..ae2f3cd5be 100644 --- a/gix-commitgraph/src/lib.rs +++ b/gix-commitgraph/src/lib.rs @@ -50,6 +50,17 @@ pub struct BloomFilterSettings { pub bits_per_entry: u32, } +impl BloomFilterSettings { + pub(crate) fn is_supported(&self) -> bool { + match self.hash_version { + // Git's changed-path Bloom filter v1 hashes are deprecated, but we still need to + // read them to match Git's historical commit-graph behavior. + 1 | 2 => true, + _ => false, + } + } +} + /// A complete commit graph. /// /// The data in the commit graph may come from a monolithic `objects/info/commit-graph` file, or it diff --git a/gix-commitgraph/tests/access/mod.rs b/gix-commitgraph/tests/access/mod.rs index f50e8e09fd..726452878b 100644 --- a/gix-commitgraph/tests/access/mod.rs +++ b/gix-commitgraph/tests/access/mod.rs @@ -1,5 +1,6 @@ use crate::{check_common, graph_and_expected, graph_and_expected_named}; -use gix_testtools::scripted_fixture_writable; +use bstr::BStr; +use gix_testtools::{scripted_fixture_read_only, scripted_fixture_writable}; use std::{fs, path::Path}; fn should_skip_path_v2_unsupported() -> bool { @@ -10,6 +11,11 @@ fn should_skip_path_v2_unsupported() -> bool { *gix_testtools::GIT_VERSION < (2, 46, 0) } +fn fixture_changed_path_version(script_name: &str, layer: BloomLayer) -> Option { + let repo_path = scripted_fixture_read_only(script_name).expect("fixture available"); + bloom_hash_version(&repo_path, layer) +} + #[test] fn single_parent() { let (cg, refs) = graph_and_expected("single_parent.sh", &["parent", "child"]); @@ -122,11 +128,32 @@ fn two_parents() { assert_eq!(cg.commit_at(refs["child"].pos()).generation(), 2); } +#[test] +fn changed_paths_v1_settings_are_read() { + assert_eq!( + fixture_changed_path_version("changed_paths_v1.sh", BloomLayer::Monolithic), + Some(1), + "fixture explicitly requests v1 filters" + ); + let (cg, _refs) = graph_and_expected("changed_paths_v1.sh", &["HEAD"]); + let settings = cg + .bloom_filter_settings() + .expect("changed-path Bloom settings are available"); + assert_eq!(settings.hash_version, 1, "fixture explicitly requests v1 filters"); + assert_eq!(settings.bits_per_entry, 10, "git default bits per entry"); + assert_eq!(settings.num_hashes, 7, "git default hash count"); +} + #[test] fn changed_paths_v2_settings_are_read() { if should_skip_path_v2_unsupported() { return; } + assert_eq!( + fixture_changed_path_version("changed_paths_v2.sh", BloomLayer::Monolithic), + Some(2), + "fixture explicitly requests v2 filters" + ); let (cg, _refs) = graph_and_expected("changed_paths_v2.sh", &["HEAD"]); let settings = cg .bloom_filter_settings() @@ -136,11 +163,50 @@ fn changed_paths_v2_settings_are_read() { assert_eq!(settings.num_hashes, 7, "git default hash count"); } +#[test] +fn changed_paths_v1_maybe_contains_changed_paths() { + let (cg, refs) = graph_and_expected("changed_paths_v1.sh", &["HEAD"]); + assert_eq!( + cg.maybe_contains_path_by_id(refs["HEAD"].id(), BStr::new(b"dir/subdir/file")), + Some(true) + ); + assert_eq!( + cg.maybe_contains_path_by_id(refs["HEAD"].id(), BStr::new(b"other")), + Some(true) + ); +} + +#[test] +fn changed_paths_v2_maybe_contains_changed_paths() { + if should_skip_path_v2_unsupported() { + return; + } + let (cg, refs) = graph_and_expected("changed_paths_v2.sh", &["HEAD"]); + assert_eq!( + cg.maybe_contains_path_by_id(refs["HEAD"].id(), BStr::new(b"dir/subdir/file")), + Some(true) + ); + assert_eq!( + cg.maybe_contains_path_by_id(refs["HEAD"].id(), BStr::new(b"other")), + Some(true) + ); +} + #[test] fn incompatible_split_chain_prefers_top_layer_bloom_settings() { if should_skip_path_v2_unsupported() { return; } + assert_eq!( + fixture_changed_path_version("split_chain_changed_paths_mismatch.sh", BloomLayer::Base), + Some(1), + "base layer should keep v1 settings" + ); + assert_eq!( + fixture_changed_path_version("split_chain_changed_paths_mismatch.sh", BloomLayer::Top), + Some(2), + "top layer should keep v2 settings" + ); let (cg, _refs) = graph_and_expected("split_chain_changed_paths_mismatch.sh", &["c1", "c2"]); let settings = cg .bloom_filter_settings() @@ -148,11 +214,34 @@ fn incompatible_split_chain_prefers_top_layer_bloom_settings() { assert_eq!(settings.hash_version, 2, "top layer uses v2 and should remain usable"); } +#[test] +fn incompatible_split_chain_disables_base_bloom_queries() { + if should_skip_path_v2_unsupported() { + return; + } + let (cg, refs) = graph_and_expected("split_chain_changed_paths_mismatch.sh", &["c1", "c2"]); + assert_eq!( + cg.maybe_contains_path_by_id(refs["c1"].id(), BStr::new(b"tracked")), + None, + "base layer Bloom data is cleared when the top layer uses incompatible settings" + ); + assert_eq!( + cg.maybe_contains_path_by_id(refs["c2"].id(), BStr::new(b"tracked")), + Some(true), + "top layer Bloom data remains usable" + ); +} + #[test] fn split_chain_uses_base_bloom_when_top_has_none() { if should_skip_path_v2_unsupported() { return; } + assert_eq!( + fixture_changed_path_version("split_chain_top_without_bloom.sh", BloomLayer::Base), + Some(2), + "base layer should keep v2 settings" + ); let (cg, _refs) = graph_and_expected("split_chain_top_without_bloom.sh", &["c1", "c2"]); let settings = cg .bloom_filter_settings() @@ -160,6 +249,21 @@ fn split_chain_uses_base_bloom_when_top_has_none() { assert_eq!(settings.hash_version, 2); } +#[test] +fn split_chain_uses_base_bloom_only_for_base_commits() { + let (cg, refs) = graph_and_expected("split_chain_top_without_bloom.sh", &["c1", "c2"]); + assert_eq!( + cg.maybe_contains_path_by_id(refs["c1"].id(), BStr::new(b"tracked")), + Some(true), + "base layer Bloom data remains usable" + ); + assert_eq!( + cg.maybe_contains_path_by_id(refs["c2"].id(), BStr::new(b"tracked")), + None, + "top layer without Bloom data should not answer Bloom queries" + ); +} + #[test] fn bloom_is_disabled_if_bidx_chunk_is_missing() { let tmp = scripted_fixture_writable("changed_paths_v2.sh").expect("fixture available"); @@ -227,16 +331,73 @@ fn bloom_is_disabled_if_bidx_offsets_are_invalid() { ); } +#[test] +fn bloom_is_disabled_if_hash_version_is_unsupported() { + let tmp = scripted_fixture_writable("changed_paths_v2.sh").expect("fixture available"); + mutate_commit_graph(tmp.path(), |data| { + let entries = parse_chunk_table(data); + let bdat = find_chunk_index(&entries, *b"BDAT").expect("BDAT present in fixture"); + let bdat_offset = entries[bdat].offset as usize; + data[bdat_offset..bdat_offset + 4].copy_from_slice(&3u32.to_be_bytes()); + }); + let graph = gix_commitgraph::Graph::from_info_dir(&info_dir(tmp.path())).expect("graph remains readable"); + assert!( + graph.bloom_filter_settings().is_none(), + "unsupported hash versions disable Bloom so callers fall back safely" + ); +} + #[derive(Clone, Copy)] struct ChunkTableEntry { id: [u8; 4], offset: u64, } +#[derive(Clone, Copy)] +enum BloomLayer { + Monolithic, + Base, + Top, +} + fn info_dir(repo_path: &Path) -> std::path::PathBuf { repo_path.join(".git").join("objects").join("info") } +fn bloom_hash_version(repo_path: &Path, layer: BloomLayer) -> Option { + let graph_path = bloom_graph_path(repo_path, layer)?; + let data = fs::read(graph_path).expect("read commit-graph fixture"); + let entries = parse_chunk_table(&data); + let bdat = find_chunk_index(&entries, *b"BDAT")?; + let start = entries[bdat].offset as usize; + let bytes: [u8; 4] = data.get(start..start + 4)?.try_into().ok()?; + Some(u32::from_be_bytes(bytes)) +} + +fn bloom_graph_path(repo_path: &Path, layer: BloomLayer) -> Option { + let info_dir = info_dir(repo_path); + let monolithic = info_dir.join("commit-graph"); + if monolithic.is_file() { + return match layer { + BloomLayer::Monolithic => Some(monolithic), + BloomLayer::Base | BloomLayer::Top => None, + }; + } + + let chain_dir = info_dir.join("commit-graphs"); + let chain = fs::read_to_string(chain_dir.join("commit-graph-chain")).ok()?; + let graphs: Vec<_> = chain + .lines() + .map(|hash| chain_dir.join(format!("graph-{hash}.graph"))) + .collect(); + let graph_path = match layer { + BloomLayer::Monolithic => return None, + BloomLayer::Base => graphs.first()?, + BloomLayer::Top => graphs.last()?, + }; + Some(graph_path.clone()) +} + #[allow(clippy::permissions_set_readonly_false)] fn mutate_commit_graph(repo_path: &Path, mutate: impl FnOnce(&mut [u8])) { let graph_path = info_dir(repo_path).join("commit-graph"); diff --git a/gix-commitgraph/tests/fixtures/changed_paths_v1.sh b/gix-commitgraph/tests/fixtures/changed_paths_v1.sh new file mode 100644 index 0000000000..146e11e1ca --- /dev/null +++ b/gix-commitgraph/tests/fixtures/changed_paths_v1.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init -q +git config user.name committer +git config user.email committer@example.com +git config commitGraph.changedPathsVersion 1 + +mkdir -p dir/subdir +echo one > dir/subdir/file +git add dir/subdir/file +git commit -q -m c1 + +echo two > dir/subdir/file +echo hello > other +git add dir/subdir/file other +git commit -q -m c2 + +# Keep this fixture distinct so cached outputs are regenerated with a Git that can emit v1 data. +git commit-graph write --no-progress --reachable --changed-paths diff --git a/gix-commitgraph/tests/fixtures/changed_paths_v2.sh b/gix-commitgraph/tests/fixtures/changed_paths_v2.sh index 5624d5ae1e..20ec0fc2aa 100755 --- a/gix-commitgraph/tests/fixtures/changed_paths_v2.sh +++ b/gix-commitgraph/tests/fixtures/changed_paths_v2.sh @@ -16,4 +16,5 @@ echo hello > other git add dir/subdir/file other git commit -q -m c2 +# Keep this fixture distinct so cached outputs are regenerated with a Git that can emit v2 data. git commit-graph write --no-progress --reachable --changed-paths diff --git a/gix-commitgraph/tests/fixtures/generated-archives/changed_paths_v1.tar b/gix-commitgraph/tests/fixtures/generated-archives/changed_paths_v1.tar new file mode 100644 index 0000000000000000000000000000000000000000..6f23731d9ccff9a95b7e1c2ace6e5400bb0df5af GIT binary patch literal 77824 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 literal 0 HcmV?d00001 diff --git a/gix-commitgraph/tests/fixtures/generated-archives/changed_paths_v2.tar b/gix-commitgraph/tests/fixtures/generated-archives/changed_paths_v2.tar new file mode 100644 index 0000000000000000000000000000000000000000..e76473c41016efb088ead7166f917ff4ce5e4123 GIT binary patch literal 77824 zcmeHw4RoAYd7kzMSPhgcDWrsilaH}>CE1$)KS@U3)ym$rz-v3Uyk25^wcpHqBh6Zx zk>{JSEW9QMXmV&8dh!PZ3WRpsl%D{F5Pr&`91=L)g_MPcv}t}xXtR``q#OvOrz!M# z-uHgrd^00yBw6EK*5k8Y&&+r4d+&YU_rCx4e$m)K&;P{nCzr_#@L&9C|5tuX#M9YC zJQ+`>vaWh4Sz2ImR}Jvm0GOX&DA%i(%0*{>*{R#}^F0`r zsM#L<6Uj_6*Qb9EXweR`gZ?qFLZx1=ueG<_`FGMkn@+dyd+&QZp2!Wf?*BwOm4zJG z`H)-hd7bb7bRv<+W^&0)JkqF?FKr!f{gzJ)40P^)=!3{br{V33XtU=1XHPQ>RO^dQt;cKXy8q+Z_WiGZK#%7o;IDK2 zzvwJ2Rr_21T5#G^a*QYxK(8mp{l7i`FP_U~l70WL*MtFl(m!_i(DcC`j@4EEBi7UA z|Hadwf8YP>AyEwInp2vz@IN=U=-5R!wrrOxeRa@#ygyY&ko{kD+(NCqQr|t7uVs1N z_CG=p?fXBO%=P0xElBLqms6FBz3dbr0#>RnBEhw_{gzXNQ&?XtyH=^ZHbxmR)nKLUnl=i6l3QmUmZcmTj%nst-AZy0u(&>q~1^xnj+X zi5e+Y5rbGQR~D?*YGbKr(s4k2@uR=tQEUncWM>a8fjF_2n4Zgl}pxI zwPCH=6{xPN)o?{aEssv3^WNy0s(p?hUe#AqZJZ#4EmSL|@wmV1=p8io>96sh9YE;bWe4<6AOPH_e|OYgb&EFoOZ@MufwlqYrvKxa&iGFbH9>v- z--gH@d@b5`zL?IAPuPj{crl$Bw=>y7B3(=tQ<-Ew>BI}Ue4?01_qA;gkpC0_+1&qi zd!a{0z)s|UHr=oPycX;s^q)C)@4d5g^M?-3&fR}1p4xS*GbU< zu+aCTI&gA5{UH0lTq!y|EdA}a|I^8Id;B*M&-UX#*MbiZ&YqaTI5+Zd`SD*`{fY8- zzv}%vaKlYE4BYbK(!jv0|INU_O<&c&12=!~$N%h<{_5y6zx{u|dGjO0#dY&X_WxqFdcH@t9}&3|`wsH7ejlHAe*ir(^w!8mX_N5GBA?;@^JFaUlI85@-AO{G!kYBUH zEGf`NVJ!+^FWKtVl4kr_S06;=f(MD`C;kCL!6^@- z=OF8LFx!}Q`|Z|{F!H{A))a~k)|TVX!kMP_c&H*aK%rqI(aV9gXfCinTDnq;)nCTN&np2@7a_AU0_s{V(~ zT`sLztE~1@O$SW?X$g6#`cL&e(WQ>4H6@`Hd4Xp}nDrn1HWYr&7*SUm!C!)Mf z(^__k3asq94OR)-70X^hH6eBfM3kpxFdM*d>qV!ArX}hORs)L8ck9&^BiO;;X2wR@ zZQU`cIW7BxN;*ne5pfscmed<7A>Hklu-hqy5L=5;eY-Kp5woenkaQtRRQ?g)W|Ov| z$!+cBEt0&YSz!N{+~sP8M*t$LcD=B;Y*%(k0&H&o^8$*F^S{|-yl?-v5UNLCu>XU$ zM<_&d%dOYSh5CN^ylU4f*mSObn>{`=IeBcwsU-JP^==$5p!!^>aeJON%!KA6i}m`7 zI~j|WphNT68RWjjMm}1u#yp;jjj9Ns4u*VjR~)GB#tO{;I*Nxe6TqoBs}KT3R1(9) zDC4;lFPr@gYfo)v82+|P#c%sJ9E=niH3S`4ew-W?5KA`nvxXKh+6V8b( z*bu1$Q&TKpBT|a^hR2*t5CFkqj_`(bT97-CnTz&C2g1^+G$-xCxK?vG*c}F*jY3Cj z874EpK)%AbwCiPbH^ynLS`{OlhU#jycAg@!PPc$4aT%ro+QkvW-0={u*3nZ(wkz!} zG@RO+FA2g1IHxpB(rC#r{;4@pUvO9fY0}ysW@2c0%^Dek9^uU*W7ZHcKd&pF7tD{M zs`Jk26ZhOFtaZw-?`C+Z=;Rv<3rc-8icZcQJapm&ByHUCfUW}*)~G<6S#%2LMFSXr z3i^ah=y65}3d5fqNJ{p&92zW^VJV-gxQ!KtvVc@wRdc}t&8*29_9ikM6)QM2NFg7- z8>~PYm$Jg(9{5WTg;g@Vw;V;g6TUVxte2dlgJ0WxCRSfR6ofk~9#~@K(cAINgcusL- zs5&ZC7}RYyUvu2XQXM}>4qPrQRSV~@j9GVHmaifJu3RCwD?}(~(bCJzVPr@;mW${q zeT3=ev6jI-)&?x+&)Gm^Q#hd&YlMp`Esj~?17ZA6nBQApL`RR}pD<^Iom{R+4Yl@| zY}8BRKqDR~KjUXGvzgu5iq1~>15NNV#4?(2q8=dZfMciTFt88HKr%o8Tz?3kjdLVm z4E!-PMi$Ut^sx>ld`Pr;E0`BB9_@hpm1+f(n6fTh@`W`g2Br?TGH*DB(PZn~P0y)F zrUy5h#$||6cw((x{2epcWxLR_8@j`K30y?t0-K>xt5`mJM-D(7M-C9Vkpo4uYcu z2f2$VFUT9okx^8~bMceb&`XRo=(03r!r6+5DV>;e3E*7{Oeg|G!>m|GD>$emLMo`1BqW60!renDh3$cUBc13Rv*Ke`V$^ajSm8ogg;r-P*?8g3 z-_ObbgDnrt&=)S80rM!1!AZf%iOw85hpAoBnJddoSucHQc<|8CgPio>nXvpy@zlEE z@XO^Ea2yaF20bE7T4%y!uJE}r`8j}6c~q?f#F;QOle9AOFMJLMHU_WYf0)6hLRG96 zV;ZVjC55`nRasAz>deU1ruZC^XIOTdz|x)ZljbduskT&#XJU|dJwiasD?M2-K?osAu4$GGW5ufMf`deg%R|yAY`JR z?ib1;P*e8iNOV-1tytdjWECD`m@tVj1k zGe8pZc)f-2<0wNYl)j=(oFaUl%h)zLMkInE+nF(_bEQ+qex}ZCSGvpgdAJDPCewZ! zD&8&Fsx8VF^Ho$4X`Xr6P5+|PCWvllG;Qk{(_1nP1UlJflP&6FMCUJUy9*v|>>G6L zLI&SY`_P_UA9VN2LvXw3Ay7hYRMq6}&i{v&fLEB2c7=g0+A zfk0aec2$vx1o5fF%NNVV2Fyq*VrWd^(-ym4EJCsCHzPHAwh5K1qP5ayT?b(# z91D-q0&mr?qCI81`_6=LjrvgS9t^BG3IfmEgy{*T+aVWQm(a!9lfC3~t06tTF?C+r zygwtkhK#>YhY)iRr}cqj6qm!x?wUJnJ^ZjWWPT{DE#OS`un{eRj#%~SawV0VoV>@W z&#V>=v07TZ3K50a*|V{;G0K%ImV<+yBBMOt68(sf;Di7dzfMjT3`kQH2I=EYY5;CUtM-s`VKJ;zf!P8DXG8l24+4{VORLPQ}gm2wUPR3Cv?C z0l(imir>5{6tfN}%hkm)v?Tj;AnNSAL*-pWaR$34+4aOA3W`2p{9wOqt! zGjDxhzVJztDq4NY#NaAPv`TzII$cEMUNMV!hh`l)d&d-&1nQAEJ(M0|u0ql9>Dl99 zYlIq(Gezof0rt5&iUgjHKNTKIghP$m5cx1wkl+F!X!*PUyLO?0v6gAwa2LlSc1oCxdA&Q8m)M@-p;!*{yM!7&g0|E~emLU(gB7*P~YAh-icP0qCVhull4y=!m$%he} z^Ra?vh_cH45X^#D5xaS=XYAO42APPw#jOxg-CPyJF+)ykr?{QN8wmr5t-FfC+h zCWJlHge#+84LAT{<{QOJQPyTOd3=`$Ovoc%n$$qhn`#xMXJsjGr`m+SEs9d-K8s*8 zG=bvgt~JQ)l8rpp7}P7Q1r*eCspy6kB5)3ks`aPFA5$!w31Q&~qhng809Iva01skh z!F-b1JH=Ns#P6>a0D`MkakyV;k%!478h+#PEtnDo92k&h2B;Cwa-kPWA)%6W@sj+J z_R<=<0J(Rr7z?eJSDH&vx-g(0U>>LySTceYd}IKlhVn{*WUvSTc?Ze$GU|T!(^g>2 zL?^}zi!^IebIQD2NGgwlots?&#^m~8H&BVKR9SaP-2*zq@F>&v(0YS{P~2m6Q*B`Q zL~3shF4XpD+Id*6M>m5sPF|S_YhJ*J64>F2MXMGkL!Pcnu-al)(k6j2sg9Cl>S1Lg zS*CE-h_}U9K?^8ub&MO1^#!8LWKnC+}rs6e9t z;qC9Sj-m2OCn=#vGGYZA9yQ_!d}>ZgiYMCP>xR=n7VC#it|*Vt%0f5tXe~Z zWwiEC6^c5HNW+ROTf9^owg&7Sx3L7v2d zYA;$)&N@o(S7=W#!dAZThC@#c)|>e0*`}&9Jb_dK4;|M1C{k;h;X53 z@oWLGtUjw>!-NYQ7m;dZz0FI6*K}{9vYLB7asehhs`5!yBwHbx8hR@wgc>}y#SkzB z|3^Z{r&egJN(x!{)#Tg5A-WGfc=D8gw>o$%;{U~So$=pHD%rpPWqpe6?ypV%Z}aoNDq^Xq)Fg$z_fP*> zb>x|$>(-zHnoOi>33d{y=1~ddmFcVQoAWfD7XJrq+HR%)Iz~tFKZFPjm(GHJoo(Pe zAUrLCFlP&CkLhjq{=&?mHIw1o-budZ>bZs1IUu0Z!81hEGS2f?ERKpc)odxDZ47`AobM;FJP zY|OFXhBZhLPp@f|S;+&pCyXg%FQCfMtp|-W`0+Sp12@bdXHxtg%d~_i3=eUo4i}BC zq$VChgC3lXjXK{ZR9jB+M?u?PBp%#_N@V)v%YTy(;!Z84TfD4XqfFsp{63Ub8fk`D<`W)v`cxnKlj2gPZp=V$Yvosk<91$)8 zZD5r%5XvnG3A1K7Otglrb36pb^h=8e$f{ZR?{IgKB1Lr5hC2c1HfVt(#>m1jNQi12 zCcMT_G7le>K$sT8`lRF^W$pBAd04^DmzVGsXCmNabqUc)*Gk0U)%wSM>a|h$h^!AL z6k>u0sF?-S2|%CoF7HL?G&~hj9BN+A9y)e+=n#weCar1QGLAhYtWzWgTUkf)r`XXFYGfow51E7Qa6id1j&!$r z&Gd{lNU`En5k_SBx?E1>b1WU&ISExN=FtYsMVGL6vFz%tCk)IH1Ilw!Jr$bOfzDT0 z*-Z}Qh9wD#5)0%vPOs>};3ic=OuXvJ5Ka<`izuP!^|qnx;}B8f#p&oBi*NwNF%m~Z zFbBjF#+HI4lzD5(ESUnQI1dc1BALI%{fPNU^Ddhp~!1imdWF1cq-jdpe zTkzzXo>hw{=Yt4(a)BSW=gIAiTiW)&iBs?5=Et<7VSN}5!laqS1hI)LZOgec+-|%(F<_-Wh?h4UKXvl({ITP6v&W9&KABTT z=MaODBBnx23grPD3#@59F%D|Q)fGx0tniet9hi6 zT*iZL9)k9=j7gKPVM)RIX&;Igvy=#3F&x0TEFkvmow^ccSrdEW@*Ebh6T1qDB}yz@ zl6!PeAqD?#q-^cOIW|0(GNyh2eVe=Khp%}+{v$hFlovBaHYOqL7#9GeX2% zEZc}^iL)rdbSMkSb#FWJAH4JjLwIp|c#RRLlI0VS?5hd&(9QZoa$G#09+|A|e&KRIP?HN%NLPn(8%h z5=QV#*;xGsv@tM2y)Ho6#DD7a_j})3acJLSjKLuw|4$GU!9|-E_q=fjGqgNuqC9rY=kSe&% zhS4H#n)3w~wauf5o54h_uVS19P6Y~w5SQa{ll}y>NU3wE5msREt{m{6jE=pBnyo>3 zTOTU3{Na?+>x^>qjLTjS8EMph$jWa~Rk;3dWNefVF&{QCJLKUtU2TfPq|p3QZH^e*L4p z$x|K#))HBf66;C?{p7!}93-Ti!;OoWv5Ueg9)fXWI6KV`{I?``tO6o!s4_MoE0}`L!1yc~0T||Om5?s#&a{aJ zd7@QB{*x9NB@M7TGr*RZRXw9NW}Z7XH+{sL1*hc`OP1MxaRJ&yJ2aVS*_J1xBPqyFxpUS5D@!ud0d-b_# z|I^Uf*a67n{1+|)sNm|dDr>J{m*IVJYFNXfhSi7*%>bb-|L=t*c-+HO0r)2sCt)p= zBqio9+KC9PDHQAC0}A-I8LU;#H8%N-B*QQjRm%7fNKXeAfCuIZXXdaS7^@JjtZa55 z7*V&{zEsZymaHz;j{;_|VBNw>ypt#>HEMbVv>0id^OA+U+>#!ci`vpaW0#poJK7?G z@y=;*5CO51Noe(d_emfNjcX^C?KOHYdJY0O(6hY-cq%M-js{jP6B2E{$=bEDd#lU$ z>cD+xq)CaLqJXD*WcJRMrlK|mToGL{NYXJUK!jhXtM^LzH)0J$E}0|NQZzc0=mw^> zW^g=_p-{P!$?Oqz2(U-s-r_S3sbZVx=ak&)&5kE+94O#H3mmf6A_Td4+87L|JJ?S| zp@3TAhv9?5mppYKCb&6J?S~ba-teY@T5bs=YDW|wG*jeblC>^KS*{!yUh^#!4#x8l zt(`4g*@#2HJQl3$!3SN;fl;re#wMAo#z@Hi^8#&y2?SfK`h)gvepetJ%WUus1&E1m zIBMsh9vyhXON(Fv;-K9WT(=G;(0)`Q=yq3z*%F9ghtW_wt9Ap?-o9lP*vsAe3tRdk z%JlZ7#g=;et)&=9xKhefa4A*bWey=z?!%t|mM%Xk-G>twc!1ZiE{dC!J0WFooY#cO z105w;j+;8EPMAaRwp@5GN=Cvw0j!+6ckf>7c&#d@xdSk@wgI*BDy+b@&;_k*!T7m% z2fmkufWaZA(S!R?;>hi9!&b%TSGa^{$3z9JPrfuW;Ud(>REN7#%V zIOg5l>pHD@;ZhyixHiEDTDOBr8gta3?kz{F$qd519XRFI$sHp?2vi}C zZdeJk^pY+RZJLs4u6gg6l1K4oxCA4$p+Xa8#)>zcC5F~1w$n98 zFn{m2%n}}$6f8B1C_Sle9kBcgMlGOhJDeXD(gN?pUYB90P97OqWA(U;w|z9R%oB>z z;a=Vly|xV;1c0oM1Riy1=W$~^_$=!wCja6FvPyWUT_nqPH5GyDG4hPqRYE@wDv^=6o5Wzq2zk=4e;A;&DRU>w8|9t{RO!J)t@tyo3fLd9oAW5XTKoJYKQHw zwGHZ35lND~^ux>rIE=*bc4XRZtkw(ueg}79Ksf159GU=bR9I99!w&k1p?-5#3fc!) z;zf(5#;f9pk0mu z(GJRGGExVGk8S@V)0^&y|D@CX_kUj#_G*p)%v4v_kSANLTO+8kPR8TeG5&wtdRe1V zu39s=b_gyZj&Q@|rI{6O@(f-i#;YP}LcyU7zVJ(4_EmGpSYfZ4UJ9IPHzVTmP&KdT z@R=|Srf)q5R6J5Z<)!(oFr?t&M`ptSgT>t)$M!ipZ$jI>+zYa{nSkXNlWSVVAz&DK zyv>ADVz^7M^kN&M@ifV-t1Vh1NN?$B4!Np8e&)>C!Miv^J0h#3NCg5nyZ#CW1r)7G73`telQh`}SX95-CgB1qYC ziR}^0yOB<}t6apULd2|z6lhygq=PvF2=w+0*6AOZ(swiRm4|xmvF;08e$98*oQd-$ zez^`Ar*L^?-(Jh`EANibhzNtd>ajMNNl5NV^hsQpZorJ(7MMD3Dqlvf6Zz!9!Po;M z-hG4nMlHGbdvFBTQcfK}=_Y$$K_v*zevQP=R$>Tz$n+BDsuFg@|<~33qv*|FA*{1B7mflfvCSM14a%2OIP$lV)2znhIqJH zS2bu}R$|`k!zWw|{@@TCeHQ-*#)jHj^eP?Y#;54@`i@5X~drK4BIZKEM;uXT*C?`YE;G(k+}Eo`Qaw=bAa;(bzH z@g;MH%Oq0;O(I28D6oOqganr!&4f$`OW>@Y9MoJ|gB`NbxW3#iz|g})nHva>;7wtS zLt{9!SC^XaN}bh&0qdW*@1@8lp!3iizv4<6Bn<29@7OgI$bng*(r~)~D$?{#TS0tt z1kK*a^>V}ub*e1@u*TrEP~PO^CIIxqf1p0hQNHO6Nbs+z5}jaHz@m52!FY=S z$|EV}@(drn1`4@7qU|)~s3;3+1#hil_W;R8xNGm=Wmwd&9MR*VF-0pC)3T-+!`wt+ z)4(u}2E=K>$)f$z%`xy_t;kDy)I{)xHCmoA*AOWH>s_F#5>$Et8z|RY;zFh1AHQl< z2Cli|yW8os zjl(H11&#W`G`^EmxJD-o621Z(Xn`mLxE6buE`)497q4T)nPHE8G%l-FEpAxQ?SU5( zG3^UYWbPd{r~*FKA;C)U<~uCN+tg$kxsRAb2YFx5gL(w?N*~EK;4Ne1iJn6hlg!)3 zu}XQJop+Q}j+SC+cflg5a@Q+G%#<8dq)BL*OAr8@iigmegPY!wk%qky8o`^(HpZ$T zwB360_l5wTW73Xe5tq7w3E8slMQN{vJI{Eyn6J>&88vMm>AlXzMq<(&m(}C5Od@-F z(5yhZ>Xsn;5b1Jbl#*Sxvd|fP(N+N}yGgoxs|a3vC0o#c>krs;fs#ZqYivba)JS4d zcyPlRh8lsg$H`|?+{xeNwlr=Yyf;wpqSD$2Jd*zA20CvNAjA=ei641?f>Z%xQkh$19y~ute zlg2OzZymLO5sr!rR#B*oe0k_D)37=492N93Zu>?=Ix$Knrl;vUd!9e@fMS!nWrG%m zC!~e?5sMC{Ah(2~@?3U0ksnwfVi0WJTwZW8$7W!r~`~AQ;ZKIB;0@h!!_ZF*7)zV zZ$+9jI)z$U@6pie4Jtxqli>Yd>(u>eBUAB5A?j8A>B$o6QSSr_&ki8aWQ;7eSgfTvJzz{lRo%fcZLM4Gp~ARJJ1?BJLX8X`Q^8rjYJ z!gMU}uCU-s<%ns$ycqJDmGHK85;v|m#Rx9sh#&|q@iaVRLk~}w{XxA&SY#bf2zfDZ z+sfmLMqHk3GoGt&Yh3crV$hh4nO4IzaCXGf!ZhhixM!X8U*?vGq> zQL8=#&|0L}6>Td@Qz->qEI@-^1joz_iRIY$S;czw(`M?)v9c=O3jmDrg0ybja z70@;z1`HB7%mTxPF*vLPxewe+TJTnc2vF>SGuGg_y$Sw5RvbpzT3c-O;O6u2>Oh|S zHzA4q$6bDI52Z~P`OnAg@;{zSWCt=moRbgwI=}zlr+CXFK66qxDfL)!%2EeX!`YTDg|DS2#iJ~2!a1!=JHc@g?`CKAlgG^4+8BaO!Vk(~; zpGc*Q{^W7Sm#TWh$2*Ojj^dXC)+Ocqm_WIpM{3%Puvm`U$=Vwg_yX?tx|bN@31y1^c~%AZN( zjq5-7v_t+UvZxp7%m1rH{1&>m(my6C@GW%PRud9f=Vsm$YByG0xr?3Ba7w=CMXtMwHmf;?0|E}>rvR}LZmq^D` zeffV4m?TL57*FZ;U`!HtyZWE+f9TtPJs?IK$QJs;=kCRy9ne3KO{V+w?*%d1V7Ade zwlyce4M>--r2l62Un1A>{)cQ1cY^kY8`~y(oyUJJ-Qc`_I&@>=IRjJQd%?$^{VN~- zz^whePoDULvCtbo`(K{%8?kr(*0YYK-eiC1BNu<-%fI>;um8+*UiZi^oqFSoPDa1@ ztlJL!{HG`9-~E=w+y3Qm|Jq-DcJh5!ULE_*A9?({W8b{+k=K6r;%knK4i1LD@x14~ z?LW@{-jDtLfB(?`n*ZE4e(%3d{@vd^W9rM9J#A#@e|>T=(6axXTu&yk6Zzkd|M!Fx z9YEXYA9E1U&814|OfE5=DNdx)iDDvd=X3a_SQt;n)9Hzvlj;Do=?`7yf3oBJR~DB7 z_2vJjSlyk6mu|S>o`H}4KwdV3E4S066-$E$M!r=}CO#0Je8(5&mruN=G5zKrak_X&an<`hvRLl+jVaL z&)jqT@C`RSbKuzQ!MhFK&f@bC{e1J;nS;}F=JP+j=iqeGeE$8rW)I$PK7Z-1W}83N z-}wKVzt#Nwq2@4WcEd$bNR({_lzI?Z?Dh3B{6o-iG{~M@%dl;(%=62yH~Iv z{>7i0$186bc;c^~>wf3-3(x)P^cO=P`OdjVt%Gm+3Lly_x3G=*Lt5XAzk~QooIDGE zLkB|G!I1R7kge_HgvZTAEXJFx!} zxokiF+Y@560d1pytTPftFMR6M zH@`jnyB~aO?h{A<(bqrvr$2qmz>hzA>u%PC>zW;w!l-*kA zHu}f5R!h4z*j}q{@;{l(w)_9NM6U1ud-&_+zsUdni_e9AH-78D)D!z^|D>4v_%}=c z>UE#|mD|re{}&^-z2-M!2j0m9;Hw{V-}|P&zUiy)dvEE_KYin`f8o<_d+-~-@S}z4 z!;c*K(>MI&P4V}<@ME{V_u}QBd&h0Be$nmUypVj+GZGgDp8aEwz2gfn{MSGFv3m81 zwZEHx&QHDl8^8Ru+V9M~{p(-c_q{9s3YfBLDD|I4QO_1{4{_3CpQ{bQ-|v{THD7gN~@JC#Tl(z#4B zn=6#^#ey?lD5jm##Q1jI#BTCGp2>B{{}j%D_3i&&QN0P*xBnlEBfA$re)zGm{1@&! zu;(9c|LXYLK z|7=J7M=ry2qFvzb%Hw|gN8>*wmH#8rdtU|>@aTs=c;$CLbmn8f{Wo8mooRgh%U`+j zgIE0KjRUv7__^QS6|DDxJVh|jvj4LaJ(<-`rH@@<5ByH)ud{#lw)f8r>_7axCwei9KFlWz z11_Pun&%Rl?ru@mvaqlf?VV~;&?_i3!vBvXu zCRfa-3)x(%5O;W7e_MaL%l}kI{x^>EU;X&cw$Qwz#-9AQAAS7g|MSLy7vyicb4PIc zoqjkm(6awaPo4T76ae<^|7*ZfZS;>NCY(|+or)vmlP{DC$mrv+{$KEKZ!*U^uf^c~F`je`@T!Mi|JlC%AJ1j8efi&O zx)46-A9EY|p0dIA>7Pg>P!qb%H1p2Tb)DD$H0dAXi&%Ds!!EtL`hW3syZ@JrBmUd> h|GFS{7arGFt54gz?RNn^eGq*N^fA!Kz|$53{~xi!@^b(H literal 0 HcmV?d00001 diff --git a/gix-commitgraph/tests/fixtures/generated-archives/split_chain_changed_paths_mismatch.tar b/gix-commitgraph/tests/fixtures/generated-archives/split_chain_changed_paths_mismatch.tar new file mode 100644 index 0000000000000000000000000000000000000000..b0ee1b726278c57640b9a4b651aa4ad72d67248d GIT binary patch literal 75776 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 C{(|KI literal 0 HcmV?d00001 diff --git a/gix-commitgraph/tests/fixtures/generated-archives/split_chain_top_without_bloom.tar b/gix-commitgraph/tests/fixtures/generated-archives/split_chain_top_without_bloom.tar new file mode 100644 index 0000000000000000000000000000000000000000..7a954fb0c2d87b74f38a27fb1bf403f9310f10fb GIT binary patch literal 75776 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 literal 0 HcmV?d00001 diff --git a/gix-commitgraph/tests/fixtures/split_chain_changed_paths_mismatch.sh b/gix-commitgraph/tests/fixtures/split_chain_changed_paths_mismatch.sh index ae3215a079..ab26e184f7 100755 --- a/gix-commitgraph/tests/fixtures/split_chain_changed_paths_mismatch.sh +++ b/gix-commitgraph/tests/fixtures/split_chain_changed_paths_mismatch.sh @@ -15,6 +15,7 @@ git add tracked git commit -q -m c2 git branch c2 +# Keep this fixture distinct so cached outputs are regenerated with a Git that can emit both versions. git show-ref -s c1 | git -c commitGraph.changedPathsVersion=1 commit-graph write \ --no-progress --changed-paths --split=no-merge --stdin-commits git show-ref -s c2 | git -c commitGraph.changedPathsVersion=2 commit-graph write \ diff --git a/gix-commitgraph/tests/fixtures/split_chain_top_without_bloom.sh b/gix-commitgraph/tests/fixtures/split_chain_top_without_bloom.sh index 89d76efd0b..d82509ab2f 100755 --- a/gix-commitgraph/tests/fixtures/split_chain_top_without_bloom.sh +++ b/gix-commitgraph/tests/fixtures/split_chain_top_without_bloom.sh @@ -15,7 +15,8 @@ git add tracked git commit -q -m c2 git branch c2 +# Keep this fixture distinct so cached outputs are regenerated with a Git that can emit v2 data. git show-ref -s c1 | git -c commitGraph.changedPathsVersion=2 commit-graph write \ --no-progress --changed-paths --split=no-merge --stdin-commits git show-ref -s c2 | git commit-graph write \ - --no-progress --split=no-merge --stdin-commits + --no-progress --no-changed-paths --split=no-merge --stdin-commits