Skip to content

Commit 9b4abfd

Browse files
committed
Add BlameRanges to enable multi-range blame support
This update replaces single-range handling with the `BlameRanges` type, allowing multiple 1-based inclusive line ranges to be specified for blame operations. It hides some of the implementation details of the range logic, prepares for compatibility with `git` behavior, and adds tests to validate multi-range scenarios. # Conflicts: # gix-blame/src/types.rs
1 parent 48079a5 commit 9b4abfd

File tree

6 files changed

+217
-35
lines changed

6 files changed

+217
-35
lines changed

gix-blame/src/file/function.rs

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,15 @@ pub fn file(
9494
return Ok(Outcome::default());
9595
}
9696

97-
let range_in_blamed_file = one_based_inclusive_to_zero_based_exclusive_range(options.range, num_lines_in_blamed)?;
98-
let mut hunks_to_blame = vec![UnblamedHunk {
99-
range_in_blamed_file: range_in_blamed_file.clone(),
100-
suspects: [(suspect, range_in_blamed_file)].into(),
101-
}];
97+
let ranges = options.range.to_zero_based_exclusive(num_lines_in_blamed)?;
98+
let mut hunks_to_blame = Vec::with_capacity(ranges.len());
99+
100+
for range in ranges {
101+
hunks_to_blame.push(UnblamedHunk {
102+
range_in_blamed_file: range.clone(),
103+
suspects: [(suspect, range)].into(),
104+
});
105+
}
102106

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

345-
/// This function assumes that `range` has 1-based inclusive line numbers and converts it to the
346-
/// format internally used: 0-based line numbers stored in ranges that are exclusive at the
347-
/// end.
348-
fn one_based_inclusive_to_zero_based_exclusive_range(
349-
range: Option<Range<u32>>,
350-
max_lines: u32,
351-
) -> Result<Range<u32>, Error> {
352-
let Some(range) = range else { return Ok(0..max_lines) };
353-
if range.start == 0 {
354-
return Err(Error::InvalidLineRange);
355-
}
356-
let start = range.start - 1;
357-
let end = range.end;
358-
if start >= max_lines || end > max_lines || start == end {
359-
return Err(Error::InvalidLineRange);
360-
}
361-
Ok(start..end)
362-
}
363-
364349
/// Pass ownership of each unblamed hunk of `from` to `to`.
365350
///
366351
/// This happens when `from` didn't actually change anything in the blamed file.

gix-blame/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
mod error;
1818
pub use error::Error;
1919
mod types;
20-
pub use types::{BlameEntry, Options, Outcome, Statistics};
20+
pub use types::{BlameEntry, BlameRanges, Options, Outcome, Statistics};
2121

2222
mod file;
2323
pub use file::function::file;

gix-blame/src/types.rs

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,142 @@ use gix_object::bstr::BString;
88
use smallvec::SmallVec;
99

1010
use crate::file::function::tokens_for_diffing;
11+
use crate::Error;
12+
13+
/// A type to represent one or more line ranges to blame in a file.
14+
///
15+
/// This type handles the conversion between git's 1-based inclusive ranges and the internal
16+
/// 0-based exclusive ranges used by the blame algorithm.
17+
///
18+
/// # Examples
19+
///
20+
/// ```rust
21+
/// use gix_blame::BlameRanges;
22+
///
23+
/// // Blame lines 20 through 40 (inclusive)
24+
/// let range = BlameRanges::from_range(20..41);
25+
///
26+
/// // Blame multiple ranges
27+
/// let mut ranges = BlameRanges::new();
28+
/// ranges.add_range(1..5); // Lines 1-4
29+
/// ranges.add_range(10..15); // Lines 10-14
30+
/// ```
31+
///
32+
/// # Line Number Representation
33+
///
34+
/// This type uses 1-based inclusive ranges to mirror `git`'s behaviour:
35+
/// - A range of `20..41` represents 21 lines, spanning from line 20 up to and including line 40
36+
/// - This will be converted to `19..40` internally as the algorithm uses 0-based ranges that are exclusive at the end
37+
///
38+
/// # Empty Ranges
39+
///
40+
/// An empty `BlameRanges` (created via `BlameRanges::new()` or `BlameRanges::default()`) means
41+
/// to blame the entire file, similar to running `git blame` without line number arguments.
42+
#[derive(Debug, Clone, Default)]
43+
pub struct BlameRanges {
44+
/// The ranges to blame, stored as 1-based inclusive ranges
45+
/// An empty Vec means blame the entire file
46+
ranges: Vec<Range<u32>>,
47+
}
48+
49+
impl BlameRanges {
50+
/// Create a new empty BlameRanges instance.
51+
///
52+
/// An empty instance means to blame the entire file.
53+
pub fn new() -> Self {
54+
Self { ranges: Vec::new() }
55+
}
56+
57+
/// Add a single range to blame.
58+
///
59+
/// The range should be 1-based inclusive.
60+
/// If the new range overlaps with or is adjacent to an existing range,
61+
/// they will be merged into a single range.
62+
pub fn add_range(&mut self, new_range: Range<u32>) {
63+
self.merge_range(new_range);
64+
}
65+
66+
/// Create from a single range.
67+
///
68+
/// The range should be 1-based inclusive, similar to git's line number format.
69+
pub fn from_range(range: Range<u32>) -> Self {
70+
Self { ranges: vec![range] }
71+
}
72+
73+
/// Create from multiple ranges.
74+
///
75+
/// All ranges should be 1-based inclusive.
76+
/// Overlapping or adjacent ranges will be merged.
77+
pub fn from_ranges(ranges: Vec<Range<u32>>) -> Self {
78+
let mut result = Self::new();
79+
for range in ranges {
80+
result.merge_range(range);
81+
}
82+
result
83+
}
84+
85+
/// Attempts to merge the new range with any existing ranges.
86+
/// If no merge is possible, adds it as a new range.
87+
fn merge_range(&mut self, new_range: Range<u32>) {
88+
// First check if this range can be merged with any existing range
89+
for range in &mut self.ranges {
90+
// Check if ranges overlap or are adjacent
91+
if new_range.start <= range.end && range.start <= new_range.end {
92+
// Merge the ranges by taking the minimum start and maximum end
93+
range.start = range.start.min(new_range.start);
94+
range.end = range.end.max(new_range.end);
95+
return;
96+
}
97+
}
98+
// If no overlap found, add as new range
99+
self.ranges.push(new_range);
100+
}
101+
102+
/// Convert the 1-based inclusive ranges to 0-based exclusive ranges.
103+
///
104+
/// This is used internally by the blame algorithm to convert from git's line number format
105+
/// to the internal format used for processing.
106+
///
107+
/// # Errors
108+
///
109+
/// Returns `Error::InvalidLineRange` if:
110+
/// - Any range starts at 0 (must be 1-based)
111+
/// - Any range extends beyond the file's length
112+
/// - Any range has the same start and end
113+
pub fn to_zero_based_exclusive(&self, max_lines: u32) -> Result<Vec<Range<u32>>, Error> {
114+
if self.ranges.is_empty() {
115+
let range = 0..max_lines;
116+
return Ok(vec![range]);
117+
}
118+
119+
let mut result = Vec::with_capacity(self.ranges.len());
120+
for range in &self.ranges {
121+
if range.start == 0 {
122+
return Err(Error::InvalidLineRange);
123+
}
124+
let start = range.start - 1;
125+
let end = range.end;
126+
if start >= max_lines || end > max_lines || start == end {
127+
return Err(Error::InvalidLineRange);
128+
}
129+
result.push(start..end);
130+
}
131+
Ok(result)
132+
}
133+
134+
/// Returns true if no specific ranges are set (meaning blame entire file)
135+
pub fn is_empty(&self) -> bool {
136+
self.ranges.is_empty()
137+
}
138+
}
11139

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

gix-blame/tests/blame.rs

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::path::PathBuf;
22

3+
use gix_blame::BlameRanges;
34
use gix_hash::ObjectId;
45
use gix_object::bstr;
56

@@ -193,7 +194,7 @@ macro_rules! mktest {
193194
format!("{}.txt", $case).as_str().into(),
194195
gix_blame::Options {
195196
diff_algorithm: gix_diff::blob::Algorithm::Histogram,
196-
range: None,
197+
range: BlameRanges::default(),
197198
since: None,
198199
},
199200
)?
@@ -264,7 +265,7 @@ fn diff_disparity() {
264265
format!("{case}.txt").as_str().into(),
265266
gix_blame::Options {
266267
diff_algorithm: gix_diff::blob::Algorithm::Histogram,
267-
range: None,
268+
range: BlameRanges::default(),
268269
since: None,
269270
},
270271
)
@@ -296,7 +297,7 @@ fn line_range() {
296297
"simple.txt".into(),
297298
gix_blame::Options {
298299
diff_algorithm: gix_diff::blob::Algorithm::Histogram,
299-
range: Some(1..2),
300+
range: BlameRanges::from_range(1..2),
300301
since: None,
301302
},
302303
)
@@ -327,7 +328,7 @@ fn since() {
327328
"simple.txt".into(),
328329
gix_blame::Options {
329330
diff_algorithm: gix_diff::blob::Algorithm::Histogram,
330-
range: None,
331+
range: BlameRanges::default(),
331332
since: Some(gix_date::parse("2025-01-31", None).unwrap()),
332333
},
333334
)
@@ -342,6 +343,75 @@ fn since() {
342343
assert_eq!(lines_blamed, baseline);
343344
}
344345

346+
#[test]
347+
fn multiple_ranges_using_add_range() {
348+
let Fixture {
349+
odb,
350+
mut resource_cache,
351+
suspect,
352+
} = Fixture::new().unwrap();
353+
354+
let mut ranges = BlameRanges::new();
355+
ranges.add_range(1..2); // Lines 1-2
356+
ranges.add_range(1..1); // Duplicate range, should be ignored
357+
ranges.add_range(4..4); // Line 4
358+
359+
let lines_blamed = gix_blame::file(
360+
&odb,
361+
suspect,
362+
None,
363+
&mut resource_cache,
364+
"simple.txt".into(),
365+
gix_blame::Options {
366+
diff_algorithm: gix_diff::blob::Algorithm::Histogram,
367+
range: ranges,
368+
since: None,
369+
},
370+
)
371+
.unwrap()
372+
.entries;
373+
374+
assert_eq!(lines_blamed.len(), 3); // Should have 3 lines total (2 from first range + 1 from second range)
375+
376+
let git_dir = fixture_path().join(".git");
377+
let baseline = Baseline::collect(git_dir.join("simple-lines-multiple-1-2-and-4.baseline")).unwrap();
378+
379+
assert_eq!(lines_blamed, baseline);
380+
}
381+
382+
#[test]
383+
fn multiple_ranges_usingfrom_ranges() {
384+
let Fixture {
385+
odb,
386+
mut resource_cache,
387+
suspect,
388+
} = Fixture::new().unwrap();
389+
390+
let ranges = BlameRanges::from_ranges(vec![1..2, 1..1, 4..4]);
391+
392+
let lines_blamed = gix_blame::file(
393+
&odb,
394+
suspect,
395+
None,
396+
&mut resource_cache,
397+
"simple.txt".into(),
398+
gix_blame::Options {
399+
diff_algorithm: gix_diff::blob::Algorithm::Histogram,
400+
range: ranges,
401+
since: None,
402+
},
403+
)
404+
.unwrap()
405+
.entries;
406+
407+
assert_eq!(lines_blamed.len(), 3); // Should have 3 lines total (2 from first range + 1 from second range)
408+
409+
let git_dir = fixture_path().join(".git");
410+
let baseline = Baseline::collect(git_dir.join("simple-lines-multiple-1-2-and-4.baseline")).unwrap();
411+
412+
assert_eq!(lines_blamed, baseline);
413+
}
414+
345415
fn fixture_path() -> PathBuf {
346416
gix_testtools::scripted_fixture_read_only("make_blame_repo.sh").unwrap()
347417
}

gix-blame/tests/fixtures/make_blame_repo.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ git merge branch-that-has-earlier-commit || true
227227

228228
git blame --porcelain simple.txt > .git/simple.baseline
229229
git blame --porcelain -L 1,2 simple.txt > .git/simple-lines-1-2.baseline
230+
git blame --porcelain -L 1,2 -L 4 simple.txt > .git/simple-lines-multiple-1-2-and-4.baseline
230231
git blame --porcelain --since 2025-01-31 simple.txt > .git/simple-since.baseline
231232
git blame --porcelain multiline-hunks.txt > .git/multiline-hunks.baseline
232233
git blame --porcelain deleted-lines.txt > .git/deleted-lines.baseline

src/plumbing/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use anyhow::{anyhow, Context, Result};
1111
use clap::{CommandFactory, Parser};
1212
use gitoxide_core as core;
1313
use gitoxide_core::{pack::verify, repository::PathsOrPatterns};
14-
use gix::bstr::{io::BufReadExt, BString};
14+
use gix::{bstr::{io::BufReadExt, BString}};
1515

1616
use crate::{
1717
plumbing::{
@@ -1578,7 +1578,7 @@ pub fn main() -> Result<()> {
15781578
&file,
15791579
gix::blame::Options {
15801580
diff_algorithm,
1581-
range,
1581+
range: range.map(BlameRanges::from_range).unwrap_or_default(),
15821582
since,
15831583
},
15841584
out,

0 commit comments

Comments
 (0)