change(gix-blame)!: make invalid BlameRanges unrepresentable - #2981
Open
Ivan Žužak (izuzak) wants to merge 3 commits into
Open
change(gix-blame)!: make invalid BlameRanges unrepresentable#2981Ivan Žužak (izuzak) wants to merge 3 commits into
Ivan Žužak (izuzak) wants to merge 3 commits into
Conversation
`BlameRanges::from_one_based_inclusive_range()`,
`BlameRanges::from_one_based_inclusive_ranges()` and
`BlameRanges::add_one_based_inclusive_range()` only rejected ranges
starting at `0`, and silently turned a reversed range like `2..=1` into
the empty 0-based range `1..1`.
Empty ranges are not representable further down: `gix_blame::file()`
eventually builds a `BlameEntry` whose `len` is a `NonZeroU32`, so an
empty hunk trips `expect("BUG: hunks are never empty")` in
`force_non_zero()`. This commit makes it impossible to construct a
`BlameRanges` value with empty internal ranges using the methods
mentioned above. Directly constructed `BlameRanges::PartialFile` values
can still hold empty ranges and that gap is addressed in a separate
commit.
This matches the `gix blame -L <start>,<end>` CLI command which already
rejects reversed ranges. The `git blame` CLI instead swaps `<start>`
and `<end>` if they were provided in reverse order, but its own test
suite labels that as undocumented behaviour, and `git` does reject
other malformed line numbers (`git blame -L 0,3` fails with
`fatal: -L invalid line number: 0`), so rejecting is the more
predictable choice for a library.
`BlameRanges` was a public enum whose `PartialFile(Vec<Range<u32>>)` variant could be constructed and mutated directly, bypassing the constructors that establish its invariants. It is now an opaque newtype over a private `Selection` enum, so every value is built by `merge_zero_based_exclusive_range()` and is valid by construction. The constructors already validated and normalized their input, but nothing stopped a caller from writing the invalid state out by hand, and every such state misbehaved differently: * `PartialFile(vec![2..1])` panics with `BUG: hunks are never empty`, * `PartialFile(vec![0..3, 1..4])` succeeds, blaming lines 2 and 3 twice, * `PartialFile(vec![0..2, 0..2])` succeeds, duplicating every entry, * `PartialFile(vec![])` succeeds and blames nothing, while `from_one_based_inclusive_ranges(vec![])` blames everything. Validating at the point of use would only catch the cases we remember to check, in the call sites we remember to check them in. Making the state unrepresentable retires all four at once, and means no new public error variant is needed to describe a state that can no longer exist. `BlameRanges::WholeFile` and `BlameRanges::PartialFile` are no longer public. Callers can use the following migration paths: * to select the whole file, use `BlameRanges::default()`, * to select ranges, use the existing `from_one_based_inclusive_range()`, `from_one_based_inclusive_ranges()` and `add_one_based_inclusive_range()` constructors, * to inspect a selection, use the new `is_whole_file()` and `selected_ranges()` accessors, * callers holding 0-based exclusive ranges can convert each `start..end` to the 1-based inclusive `(start + 1)..=end`, which is exact for every non-empty range. `to_zero_based_exclusive_ranges()` changes from `pub` to `pub(crate)` in the same move. It resolves the selection against a particular file's line count, rather than reporting the selection as stored. Callers can compute that count from the same content and line-counting rules, but `file()` already does so internally. `is_whole_file()` and `selected_ranges()` provide inspection without requiring a line count. Making this method `pub(crate)` is an API-surface choice, not a requirement for ensuring the stored-range invariants -- it avoids maintaining a separate public API for file-specific resolution.
`BlameRanges::to_zero_based_exclusive_ranges()` returned `vec![0..0]` for a whole-file selection when asked to resolve against a file of `0` lines. That is an empty range, which the blame algorithm cannot represent as a hunk -- the `NonZeroU32` length of a `BlameEntry` would panic on it. Rather than special-case `0` inside the method, the `max_lines` parameter is now a `NonZeroU32`, so a file without lines cannot be described in the first place. This lets the method handle every permitted input without needing a special case for zero lines and restores the invariant that a whole-file selection always resolves to exactly one non-empty range. `gix_blame::file()` never hit the bad case, but only because `initial_state()` happens to return early when the blamed content has no lines, three functions away from where the invariant is needed. That check was load-bearing by coincidence. It is now a `let ... else` on `NonZeroU32::new()`, so the compiler enforces what was previously a convention. The method became `pub(crate)` in the previous commit, so none of this reaches users -- what changes is an internal invariant, not the API. The clamping and dropping behaviour for ranges that reach past the end of the file is unchanged, and is now documented. Note that this differs from `git blame -L 100,200`, which fails with `fatal: file <path> has only <n> lines` where we return an empty result.
Sebastian Thiel (Byron)
requested a review
from Christoph Rüßler (cruessler)
September 9, 2026 04:28
Member
|
Hi Ivan Žužak (@izuzak), it's great having you, and of course, to talk to a human for a change :D! I am looking forward to seeing you contribute more as you will make the implementation more suitable for the TUI of yours, as that's exactly what I thought With that said, I read the tl;dr and think it's a great first improvement to see :). |
Contributor
|
Thanks a lot, in particular for the detailed description! I’ll have a look and hope to be able to provide a first review in a couple of days! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hello! 👋 This is my first PR to this project, so I'm going to do a brief introduction: my name is Ivan, and ~2 years ago I built a small TUI app for exploring Git blames (nothing popular, just a personal project). The app is written in Rust and uses command-line Git for all Git information extraction/querying. I'd like to try replacing that direct Git usage with gitoxide, specifically gix-blame. And as a part of that, I'm hoping to contribute a bit to the gitoxide project.
Okay, now about this PR. Note: I did write most of the code and PR description myself (my issues/PRs are often detailed+long -- sorry! 🙈), but also used an AI agent for a final review and edit -- it caught a few small problems both in my changes and in pre-existing code, and also added a few notes to the commit messages and code comments.
TL;DR version since the full explanation is a bit long
Currently, it's possible to construct
BlameRanges::PartialFilevalues with empty internal ranges (e.g. a reversed range2..1). Thegix_blame::file()function takes aBlameRangesvalue as input and eventually attempts to create aBlameEntry. Attempting to create aBlameEntryfrom an empty range violates a pre-existing internal invariant and causes a panic. Directly constructed overlapping or duplicate ranges can also silently produce duplicate blame entries.With the changes in this PR, the constructors and the
add_one_based_inclusive_range()method reject reversed one-based ranges (which were previously converted into empty internal ranges) and return the existingInvalidOneBasedLineRangeerror.BlameRangesalso becomes an opaque struct, so callers can no longer bypass those methods to create or mutate invalid selections directly. It either selects the whole file or holds a non-empty collection of non-empty, sorted and disjoint ranges.This is a breaking change: the public
BlameRangesenum variants were removed, andto_zero_based_exclusive_ranges()is now crate-private to reduce public API surface. The existing constructors remain, and two new read-only accessors (is_whole_file()andselected_ranges()) allow callers to inspect the internalBlameRangesselections.And here's the longer version below. 📚
The problem I observed
I noticed that the
gix blamecommand line wrapper (correctly) rejects reversed ranges (e.g.-L 2,1instead of-L 1,2):However, there's no such validation for reversed ranges in the API and trying to use such ranges leads to panics. Specifically:
BlameRanges::inclusive_to_zero_based_exclusivemethod only checks that the start of the range is greater than 0. This method is called from the publicBlameRanges::from_one_based_inclusive_range,BlameRanges::from_one_based_inclusive_rangesandBlameRanges::add_one_based_inclusive_rangemethods used for creating and modifyingBlameRanges::PartialFilevalues. So, aBlameRanges::PartialFilevalue with an empty internal range can be created here, e.g.BlameRanges::from_one_based_inclusive_range(2..=1)results in a1..1range.BlameRanges::PartialFilevalue with a reversed/empty range can also be created directly since the enum and variants are public, e.g.PartialFile(vec![2..1]).In both cases, a
BlameRangesvalue is passed togix_blame::file()as input, andgix_blame::file()doesn't validate the included ranges. Things eventually lead to aBlameEntrybeing created and as a part of that creation --force_non_zero()is called with the range's length which then callsNonZeroU32::new(n).expect(...). For an empty range, the range's lengthnis 0, causingNonZeroU32::new(n)to returnNoneand thus theexpect()call panics withBUG: hunks are never empty.While looking at direct creation of
BlameRanges::PartialFilevalues, I also found a few other problems:PartialFile(vec![0..3, 1..4])succeeds, but blames lines 2 and 3 twice.PartialFile(vec![0..2, 0..2])succeeds, but duplicates every entry.PartialFile(vec![])succeeds and blames nothing, whilefrom_one_based_inclusive_ranges(vec![])blames everything.The
BlameRangesconstructors already merge overlapping and adjacent ranges and sort the result, but direct construction bypasses all of that.I tried searching prior issues and PRs for reports of this reversed-range panic, but couldn't find any. I did find #1766 which added
gix blame -L start,endand thestart <= endvalidation for the CLI call. I also found #1976 where therange.start() == 0check was suggested (and finally implemented in the follow-up #2204), but it doesn't seem there were discussions about thestart > endorRangeInclusive::is_empty()cases.The solution in this PR
Initially, I thought "I'll just add a bit of validation in a few places", and even implemented that approach. But as I was writing up the PR for that change -- it occurred to me that preventing invalid
BlameRangesfrom being constructed in the first place (the old "parse, don't validate" idea) might be a more robust and durable approach, even though it would be a breaking change.So, that "make invalid
BlameRangesunrepresentable" approach is what's in this PR and I split the changes into three commits:BlameRanges::inclusive_to_zero_based_exclusive(), returning the existingError::InvalidOneBasedLineRange. This covers both constructors andadd_one_based_inclusive_range().BlameRangesenum with an opaque struct over a privateSelectionenum. All construction and modification of selected ranges go through validation and the existingmerge_zero_based_exclusive_range()logic. The result is either the whole file or a non-empty collection of non-empty, sorted, disjoint and non-adjacent ranges. Also, add read-only accessors (is_whole_file()andselected_ranges()) and maketo_zero_based_exclusive_ranges()crate-private (a few notes about this change are in a separate section below).to_zero_based_exclusive_ranges()method'smax_linesparameter fromu32toNonZeroU32. Previously, resolving a whole-file selection with0producedvec![0..0], another empty range.gix_blame::file()already returned early for content without lines, so it didn't hit this case. But the new parameter type makes handling0a requirement for calling the resolver (which should be more robust).Regression tests cover reversed-range rejection (including unchanged state on error), normalized range inspection, and non-empty whole-file resolution. An integration test also checks that blaming empty untracked content returns no entries.
The
expect()inforce_non_zero()stays since aBlameEntrywithout lines is still a bug. This PR prevents such ranges from being created and passed togix_blame::file().If the breaking changes feel too disruptive or you don't like that approach in general, I'm happy to go back to the validation approach or something else based on your feedback. (That said, in my first attempt -- the validation approach also introduced a small breaking change due to a new variant in an exhaustive
Errorenum.)Also, one trade-off is that callers who previously constructed
BlameRanges::PartialFilemanually from already-normalized ranges must now go through themerge_zero_based_exclusive_range()normalization too. This will obviously have worse performance, so if this is a concern -- optimizing bulk construction could be something to discuss.Breaking changes and migration paths
One breaking change is that the
BlameRanges::WholeFileandBlameRanges::PartialFileenum variants are no longer public.BlameRanges::default().from_one_based_inclusive_range(),from_one_based_inclusive_ranges()andadd_one_based_inclusive_range()methods.is_whole_file()orselected_ranges(). The latter returnsOption<&[Range<u32>]>:Nonefor the whole file, or a borrowed slice of normalized 0-based exclusive ranges.start..endto the 1-based inclusive(start + 1)..=endbefore passing it to a constructor. If this feels like a common use-case, we might offer new constructors for that as well?The other breaking change is that
to_zero_based_exclusive_ranges()is no longerpubbut ratherpub(crate).selected_ranges()andis_whole_file()instead.Options::rangestogix_blame::file(), which resolves it internally.Why make the
to_zero_based_exclusive_ranges()range resolution internal?TLDR: changing the
to_zero_based_exclusive_ranges()method frompubtopub(crate)reduces the public API surface since it felt like it didn't need to be public. If this doesn't feel helpful, I can remove that change.to_zero_based_exclusive_ranges()does more than expose the stored ranges: it resolves a selection against the line count of a particular file, clamping ranges that reach past its end and dropping those that start past it. That behavior is unchanged. (Also note: for ordered positive ranges, Git also clamps ends past EOF, but errors on starts past EOF rather than dropping those ranges.)gix_blame::file()already obtains the actual content and counts its lines. Callers could compute that count themselves, but would need the same content and line-counting rules -- it isn't something known from the selection alone. The new accessors let callers inspect a selection without a line count.Also, the PR which introduced this method removed the previous version of it called
to_zero_based_exclusive()which was alsopub. But that method was documented as "used internally by the blame algorithm" implying that this method is primarily there for internal use and perhaps could have beenpub(crate)from the start. And as far as I could tell, there's no external usage of this method in public repositories. Of course, there could be some usage in private repositories.What does Git do for reversed ranges?
While I was working on this, I checked what command-line
git blamedoes and -- it actually behaves differently! Instead of rejecting reversed ranges, it swaps them for you:The behavior comes from here and there's even a regression test for it, but that regression test explicitly classifies this as undocumented behavior. Also, the
git blamedocs only say that the two numbers should be 1-based and don't mention the swapping:So, technically,
gix_blame::file()is not matching Git's behavior with respect to reversed ranges, but on the other hand -- Git's swapping behavior is explicitly marked as undocumented and thegix blameCLI command already rejects reversed ranges. So, I think rejecting them in the API also makes more sense than silently swapping them.Thanks for reading my novel! 🙇