Skip to content

Support for multiple blame ranges #1973

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions gix-blame/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ authors = ["Christoph Rüßler <christoph.ruessler@mailbox.org>", "Sebastian Thi
edition = "2021"
rust-version = "1.70"

[lib]
doctest = false

[dependencies]
gix-commitgraph = { version = "^0.28.0", path = "../gix-commitgraph" }
gix-revwalk = { version = "^0.20.1", path = "../gix-revwalk" }
Expand Down
33 changes: 9 additions & 24 deletions gix-blame/src/file/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,15 @@ pub fn file(
return Ok(Outcome::default());
}

let range_in_blamed_file = one_based_inclusive_to_zero_based_exclusive_range(options.range, num_lines_in_blamed)?;
let mut hunks_to_blame = vec![UnblamedHunk {
range_in_blamed_file: range_in_blamed_file.clone(),
suspects: [(suspect, range_in_blamed_file)].into(),
}];
let ranges = options.range.to_zero_based_exclusive(num_lines_in_blamed)?;
let mut hunks_to_blame = Vec::with_capacity(ranges.len());

for range in ranges {
hunks_to_blame.push(UnblamedHunk {
range_in_blamed_file: range.clone(),
suspects: [(suspect, range)].into(),
});
}

let (mut buf, mut buf2) = (Vec::new(), Vec::new());
let commit = find_commit(cache.as_ref(), &odb, &suspect, &mut buf)?;
Expand Down Expand Up @@ -342,25 +346,6 @@ pub fn file(
})
}

/// This function assumes that `range` has 1-based inclusive line numbers and converts it to the
/// format internally used: 0-based line numbers stored in ranges that are exclusive at the
/// end.
fn one_based_inclusive_to_zero_based_exclusive_range(
range: Option<Range<u32>>,
max_lines: u32,
) -> Result<Range<u32>, Error> {
let Some(range) = range else { return Ok(0..max_lines) };
if range.start == 0 {
return Err(Error::InvalidLineRange);
}
let start = range.start - 1;
let end = range.end;
if start >= max_lines || end > max_lines || start == end {
return Err(Error::InvalidLineRange);
}
Ok(start..end)
}

/// Pass ownership of each unblamed hunk of `from` to `to`.
///
/// This happens when `from` didn't actually change anything in the blamed file.
Expand Down
2 changes: 1 addition & 1 deletion gix-blame/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
mod error;
pub use error::Error;
mod types;
pub use types::{BlameEntry, Options, Outcome, Statistics};
pub use types::{BlameEntry, BlameRanges, Options, Outcome, Statistics};

mod file;
pub use file::function::file;
143 changes: 135 additions & 8 deletions gix-blame/src/types.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,150 @@
use gix_hash::ObjectId;
use gix_object::bstr::BString;
use smallvec::SmallVec;
use std::ops::RangeInclusive;
use std::{
num::NonZeroU32,
ops::{AddAssign, Range, SubAssign},
};

use gix_hash::ObjectId;
use gix_object::bstr::BString;
use smallvec::SmallVec;

use crate::file::function::tokens_for_diffing;
use crate::Error;

/// 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
/// 0-based exclusive ranges used by the blame algorithm.
///
/// # Examples
///
/// ```rust
/// use gix_blame::BlameRanges;
///
/// // Blame lines 20 through 40 (inclusive)
/// let range = BlameRanges::from_range(20..=40);
///
/// // Blame multiple ranges
/// let mut ranges = BlameRanges::new();
/// ranges.add_range(1..=4); // Lines 1-4
/// ranges.add_range(10..=14); // Lines 10-14
/// ```
///
/// # Line Number Representation
///
/// This type uses 1-based inclusive ranges to mirror `git`'s behaviour:
/// - A range of `20..=40` represents 21 lines, spanning from line 20 up to and including line 40
/// - This will be converted to `19..40` internally as the algorithm uses 0-based ranges that are exclusive at the end
///
/// # Empty Ranges
///
/// An empty `BlameRanges` (created via `BlameRanges::new()` or `BlameRanges::default()`) means
/// to blame the entire file, similar to running `git blame` without line number arguments.
#[derive(Debug, Clone, Default)]
pub struct BlameRanges {
/// The ranges to blame, stored as 1-based inclusive ranges
/// An empty Vec means blame the entire file
ranges: Vec<RangeInclusive<u32>>,
}

/// Lifecycle
impl BlameRanges {
/// Create a new empty BlameRanges instance.
///
/// An empty instance means to blame the entire file.
pub fn new() -> Self {
Self::default()
}

/// Create from a single range.
///
/// The range is 1-based, similar to git's line number format.
pub fn from_range(range: RangeInclusive<u32>) -> Self {
Self { ranges: vec![range] }
}

/// Create from multiple ranges.
///
/// All ranges are 1-based.
/// Overlapping or adjacent ranges will be merged.
pub fn from_ranges(ranges: Vec<RangeInclusive<u32>>) -> Self {
let mut result = Self::new();
for range in ranges {
result.merge_range(range);
}
result
}
}

impl BlameRanges {
/// Add a single range to blame.
///
/// The range should be 1-based inclusive.
/// If the new range overlaps with or is adjacent to an existing range,
/// they will be merged into a single range.
pub fn add_range(&mut self, new_range: RangeInclusive<u32>) {
self.merge_range(new_range);
}

/// Attempts to merge the new range with any existing ranges.
/// If no merge is possible, add it as a new range.
fn merge_range(&mut self, new_range: RangeInclusive<u32>) {
// Check if this range can be merged with any existing range
for range in &mut self.ranges {
// Check if ranges overlap or are adjacent
if new_range.start() <= range.end() && range.start() <= new_range.end() {
*range = *range.start().min(new_range.start())..=*range.end().max(new_range.end());
return;
}
}
// If no overlap found, add it as a new range
self.ranges.push(new_range);
}

/// Convert the 1-based inclusive ranges to 0-based exclusive ranges.
///
/// This is used internally by the blame algorithm to convert from git's line number format
/// to the internal format used for processing.
///
/// # Errors
///
/// Returns `Error::InvalidLineRange` if:
/// - Any range starts at 0 (must be 1-based)
/// - Any range extends beyond the file's length
/// - Any range has the same start and end
pub fn to_zero_based_exclusive(&self, max_lines: u32) -> Result<Vec<Range<u32>>, Error> {
if self.ranges.is_empty() {
let range = 0..max_lines;
return Ok(vec![range]);
}

let mut result = Vec::with_capacity(self.ranges.len());
for range in &self.ranges {
if *range.start() == 0 {
return Err(Error::InvalidLineRange);
}
let start = range.start() - 1;
let end = *range.end();
if start >= max_lines || end > max_lines || start == end {
return Err(Error::InvalidLineRange);
}
result.push(start..end);
}
Ok(result)
}

/// Returns true if no specific ranges are set (meaning blame entire file)
pub fn is_empty(&self) -> bool {
self.ranges.is_empty()
}
}

/// Options to be passed to [`file()`](crate::file()).
#[derive(Default, Debug, Clone)]
pub struct Options {
/// The algorithm to use for diffing.
pub diff_algorithm: gix_diff::blob::Algorithm,
/// A 1-based inclusive range, in order to mirror `git`’s behaviour. `Some(20..40)` represents
/// 21 lines, spanning from line 20 up to and including line 40. This will be converted to
/// `19..40` internally as the algorithm uses 0-based ranges that are exclusive at the end.
pub range: Option<std::ops::Range<u32>>,
/// The ranges to blame in the file.
pub range: BlameRanges,
/// Don't consider commits before the given date.
pub since: Option<gix_date::Time>,
}
Expand Down
Loading
Loading