Skip to content

Commit 90fef01

Browse files
authored
Merge pull request #1766 from cruessler/add-range-to-gix-blame
Add `gix blame -L start,end`
2 parents 47e44c5 + 1500c08 commit 90fef01

File tree

8 files changed

+113
-12
lines changed

8 files changed

+113
-12
lines changed

gitoxide-core/src/repository/blame.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::ffi::OsStr;
55
pub fn blame_file(
66
mut repo: gix::Repository,
77
file: &OsStr,
8+
range: Option<std::ops::Range<u32>>,
89
out: impl std::io::Write,
910
err: Option<&mut dyn std::io::Write>,
1011
) -> anyhow::Result<()> {
@@ -40,7 +41,7 @@ pub fn blame_file(
4041
.with_commit_graph(repo.commit_graph_if_enabled()?)
4142
.build()?;
4243
let mut resource_cache = repo.diff_resource_cache_for_tree_diff()?;
43-
let outcome = gix::blame::file(&repo.objects, traverse, &mut resource_cache, file.as_bstr())?;
44+
let outcome = gix::blame::file(&repo.objects, traverse, &mut resource_cache, file.as_bstr(), range)?;
4445
let statistics = outcome.statistics;
4546
write_blame_entries(out, outcome)?;
4647

gix-blame/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ pub enum Error {
2727
Traverse(#[source] Box<dyn std::error::Error + Send + Sync>),
2828
#[error(transparent)]
2929
DiffTree(#[from] gix_diff::tree::Error),
30+
#[error("Invalid line range was given, line range is expected to be a 1-based inclusive range in the format '<start>,<end>'")]
31+
InvalidLineRange,
3032
}

gix-blame/src/file/function.rs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ use std::ops::Range;
2525
/// - The first commit returned here is the first eligible commit to be responsible for parts of `file_path`.
2626
/// * `file_path`
2727
/// - A *slash-separated* worktree-relative path to the file to blame.
28+
/// * `range`
29+
/// - A 1-based inclusive range, in order to mirror `git`’s behaviour. `Some(20..40)` represents
30+
/// 21 lines, spanning from line 20 up to and including line 40. This will be converted to
31+
/// `19..40` internally as the algorithm uses 0-based ranges that are exclusive at the end.
2832
/// * `resource_cache`
2933
/// - Used for diffing trees.
3034
///
@@ -61,6 +65,7 @@ pub fn file<E>(
6165
traverse: impl IntoIterator<Item = Result<gix_traverse::commit::Info, E>>,
6266
resource_cache: &mut gix_diff::blob::Platform,
6367
file_path: &BStr,
68+
range: Option<Range<u32>>,
6469
) -> Result<Outcome, Error>
6570
where
6671
E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
@@ -85,19 +90,17 @@ where
8590
.tokenize()
8691
.map(|token| interner.intern(token))
8792
.count()
88-
};
93+
} as u32;
8994

9095
// Binary or otherwise empty?
9196
if num_lines_in_blamed == 0 {
9297
return Ok(Outcome::default());
9398
}
9499

95-
let mut hunks_to_blame = vec![{
96-
let range_in_blamed_file = 0..num_lines_in_blamed as u32;
97-
UnblamedHunk {
98-
range_in_blamed_file: range_in_blamed_file.clone(),
99-
suspects: [(suspect, range_in_blamed_file)].into(),
100-
}
100+
let range_in_blamed_file = one_based_inclusive_to_zero_based_exclusive_range(range, num_lines_in_blamed)?;
101+
let mut hunks_to_blame = vec![UnblamedHunk {
102+
range_in_blamed_file: range_in_blamed_file.clone(),
103+
suspects: [(suspect, range_in_blamed_file)].into(),
101104
}];
102105

103106
let mut out = Vec::new();
@@ -260,6 +263,25 @@ where
260263
})
261264
}
262265

266+
/// This function assumes that `range` has 1-based inclusive line numbers and converts it to the
267+
/// format internally used: 0-based line numbers stored in ranges that are exclusive at the
268+
/// end.
269+
fn one_based_inclusive_to_zero_based_exclusive_range(
270+
range: Option<Range<u32>>,
271+
max_lines: u32,
272+
) -> Result<Range<u32>, Error> {
273+
let Some(range) = range else { return Ok(0..max_lines) };
274+
if range.start == 0 {
275+
return Err(Error::InvalidLineRange);
276+
}
277+
let start = range.start - 1;
278+
let end = range.end;
279+
if start >= max_lines || end > max_lines || start == end {
280+
return Err(Error::InvalidLineRange);
281+
}
282+
Ok(start..end)
283+
}
284+
263285
/// Pass ownership of each unblamed hunk of `from` to `to`.
264286
///
265287
/// This happens when `from` didn't actually change anything in the blamed file.

gix-blame/tests/blame.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ macro_rules! mktest {
194194
commits,
195195
&mut resource_cache,
196196
format!("{}.txt", $case).as_str().into(),
197+
None,
197198
)?
198199
.entries;
199200

@@ -254,6 +255,7 @@ fn diff_disparity() {
254255
commits,
255256
&mut resource_cache,
256257
format!("{case}.txt").as_str().into(),
258+
None,
257259
)
258260
.unwrap()
259261
.entries;
@@ -267,6 +269,26 @@ fn diff_disparity() {
267269
}
268270
}
269271

272+
#[test]
273+
fn line_range() {
274+
let Fixture {
275+
odb,
276+
mut resource_cache,
277+
commits,
278+
} = Fixture::new().unwrap();
279+
280+
let lines_blamed = gix_blame::file(&odb, commits, &mut resource_cache, "simple.txt".into(), Some(1..2))
281+
.unwrap()
282+
.entries;
283+
284+
assert_eq!(lines_blamed.len(), 2);
285+
286+
let git_dir = fixture_path().join(".git");
287+
let baseline = Baseline::collect(git_dir.join("simple-lines-1-2.baseline")).unwrap();
288+
289+
assert_eq!(lines_blamed, baseline);
290+
}
291+
270292
fn fixture_path() -> PathBuf {
271293
gix_testtools::scripted_fixture_read_only("make_blame_repo.sh").unwrap()
272294
}

gix-blame/tests/fixtures/make_blame_repo.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ git commit -q -m c14.2
199199
git merge branch-that-has-one-of-the-changes || true
200200

201201
git blame --porcelain simple.txt > .git/simple.baseline
202+
git blame --porcelain -L 1,2 simple.txt > .git/simple-lines-1-2.baseline
202203
git blame --porcelain multiline-hunks.txt > .git/multiline-hunks.baseline
203204
git blame --porcelain deleted-lines.txt > .git/deleted-lines.baseline
204205
git blame --porcelain deleted-lines-multiple-hunks.txt > .git/deleted-lines-multiple-hunks.baseline

src/plumbing/main.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,15 +1542,25 @@ pub fn main() -> Result<()> {
15421542
},
15431543
),
15441544
},
1545-
Subcommands::Blame { statistics, file } => prepare_and_run(
1545+
Subcommands::Blame {
1546+
statistics,
1547+
file,
1548+
range,
1549+
} => prepare_and_run(
15461550
"blame",
15471551
trace,
15481552
verbose,
15491553
progress,
15501554
progress_keep_open,
15511555
None,
15521556
move |_progress, out, err| {
1553-
core::repository::blame::blame_file(repository(Mode::Lenient)?, &file, out, statistics.then_some(err))
1557+
core::repository::blame::blame_file(
1558+
repository(Mode::Lenient)?,
1559+
&file,
1560+
range,
1561+
out,
1562+
statistics.then_some(err),
1563+
)
15541564
},
15551565
),
15561566
Subcommands::Completions { shell, out_dir } => {

src/plumbing/options/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use clap_complete::Shell;
44
use gitoxide_core as core;
55
use gix::bstr::BString;
66

7+
use crate::shared::AsRange;
8+
79
#[derive(Debug, clap::Parser)]
810
#[clap(name = "gix", about = "The git underworld", version = option_env!("GIX_VERSION"))]
911
#[clap(subcommand_required = true)]
@@ -162,6 +164,9 @@ pub enum Subcommands {
162164
statistics: bool,
163165
/// The file to create the blame information for.
164166
file: std::ffi::OsString,
167+
/// Only blame lines in the given 1-based inclusive range '<start>,<end>', e.g. '20,40'.
168+
#[clap(short='L', value_parser=AsRange)]
169+
range: Option<std::ops::Range<u32>>,
165170
},
166171
/// Generate shell completions to stdout or a directory.
167172
#[clap(visible_alias = "generate-completions", visible_alias = "shell-completions")]

src/shared.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,14 +408,40 @@ mod clap {
408408
.parse_ref(cmd, arg, value)
409409
}
410410
}
411+
412+
#[derive(Clone)]
413+
pub struct AsRange;
414+
415+
impl TypedValueParser for AsRange {
416+
type Value = std::ops::Range<u32>;
417+
418+
fn parse_ref(&self, cmd: &Command, arg: Option<&Arg>, value: &OsStr) -> Result<Self::Value, Error> {
419+
StringValueParser::new()
420+
.try_map(|arg| -> Result<_, Box<dyn std::error::Error + Send + Sync>> {
421+
let parts = arg.split_once(',');
422+
if let Some((start, end)) = parts {
423+
let start = u32::from_str(start)?;
424+
let end = u32::from_str(end)?;
425+
426+
if start <= end {
427+
return Ok(start..end);
428+
}
429+
}
430+
431+
Err(Box::new(Error::new(ErrorKind::ValueValidation)))
432+
})
433+
.parse_ref(cmd, arg, value)
434+
}
435+
}
411436
}
412437
pub use self::clap::{
413-
AsBString, AsHashKind, AsOutputFormat, AsPartialRefName, AsPathSpec, AsTime, CheckPathSpec, ParseRenameFraction,
438+
AsBString, AsHashKind, AsOutputFormat, AsPartialRefName, AsPathSpec, AsRange, AsTime, CheckPathSpec,
439+
ParseRenameFraction,
414440
};
415441

416442
#[cfg(test)]
417443
mod value_parser_tests {
418-
use super::ParseRenameFraction;
444+
use super::{AsRange, ParseRenameFraction};
419445
use clap::Parser;
420446

421447
#[test]
@@ -441,4 +467,16 @@ mod value_parser_tests {
441467
let c = Cmd::parse_from(["cmd", "-a=75"]);
442468
assert_eq!(c.arg, Some(Some(0.75)));
443469
}
470+
471+
#[test]
472+
fn range() {
473+
#[derive(Debug, clap::Parser)]
474+
pub struct Cmd {
475+
#[clap(long, short='l', value_parser = AsRange)]
476+
pub arg: Option<std::ops::Range<u32>>,
477+
}
478+
479+
let c = Cmd::parse_from(["cmd", "-l=1,10"]);
480+
assert_eq!(c.arg, Some(1..10));
481+
}
444482
}

0 commit comments

Comments
 (0)