Skip to content

feat(cli): introduce commit ranges with interval-notation endpoint flags#1489

Open
cds-amal wants to merge 23 commits intoorhun:mainfrom
cds-rs:feat/rous
Open

feat(cli): introduce commit ranges with interval-notation endpoint flags#1489
cds-amal wants to merge 23 commits intoorhun:mainfrom
cds-rs:feat/rous

Conversation

@cds-amal
Copy link
Copy Markdown

@cds-amal cds-amal commented Apr 24, 2026

This PR refactors commit-range selection on the CLI by borrowing math-interval notation, which captures both location (which commit) and inclusivity (whether the endpoint is in the range). A range is bounded by a start and an end; [ / ] are inclusive, ( / ) are exclusive.

Internally, the range is now a pure value (CommitRange with explicit Endpoints) flowing through a transform -> resolve -> execute pipeline that's inspectable (--dry-run) and unit-testable.

New endpoint options

CLI flag Config key Meaning
--start-at X start_at = "X" Include X; walk forward. [X, ...
--start-after X start_after = "X" Exclude X; walk forward. (X, ...
--end-at Y end_at = "Y" Include Y; walk back. ..., Y]
--end-before Y end_before = "Y" Exclude Y; stop before it. ..., Y)

Naming convention: *_at is inclusive, *_after / *_before is exclusive. Within each pair, at most one may be set. The two pairs are independent, so any inclusivity combination is describable.

How legacy flags map to endpoints

Every git-cliff invocation already selects a contiguous slice of commits; the legacy flags are named shortcuts for picking the two endpoints of that slice, with inclusivity baked in.

Legacy Equivalent endpoint form
(no flags) --end-at HEAD (left defaults to first commit)
--unreleased --start-after <last_tag> --end-at HEAD
--latest --start-after <prev_tag> --end-at <last_tag>
--current --start-after <prev_tag> --end-at <current_tag>
<A>..<B> --start-after A --end-at B

Conflicts and precedence

  • --start-at and --start-after cannot be combined; same for --end-at and --end-before. This holds within the CLI and within cliff.toml independently.
  • Across sources, the CLI overrides config one side at a time. Setting --start-at or --start-after replaces both start_at and start_after from config (so --start-at X cleanly wins over a stale start_after = "Y" in cliff.toml); the right side behaves the same way.
  • The new endpoint options cannot be combined with the legacy range flags (--latest, --current, --unreleased, --bump, positional A..B). Pick one style.

Previewing with --dry-run

--dry-run prints the computed interval, the number of commits it covers, and the git revision range that will be walked, then exits without rendering a changelog:

$ git cliff --start-at v0.1.0 --end-at v0.2.0 --dry-run
range:    [v0.1.0, v0.2.0]                <-- user-facing interval
commits:  3                               <-- commit count
emitted:  02deb7a7...^..9f14f5d1...       <-- git revision range

Examples

Select a range with explicit endpoint inclusivity (*-at is inclusive; *-after / *-before is exclusive):

# include v1.0.0 itself; walk forward to HEAD
git cliff --start-at v1.0.0

# everything since v1.0.0, but not v1.0.0 itself
git cliff --start-after v1.0.0

# everything up to and including v2.0.0
git cliff --end-at v2.0.0

# strictly between two tags, both excluded
git cliff --start-after v1.0.0 --end-before v2.0.0

These options can also be set in cliff.toml under [git]:

[git]
start_after = "v1.0.0"
end_at      = "v2.0.0"

Motivation and Context

This started from a discussion / research described in #655 .The legacy commit-range flags (--latest, --current, --unreleased, positional A..B) are named shortcuts that bundle endpoint location and inclusivity together: --unreleased resolves to "after the last tag, up to and including HEAD", --latest to "after the previous tag, up to and including the last tag", and so on. Inclusivity is implicit, locked into each flag's semantics. Adding new endpoint kinds (Nth-most-recent-tag, dates, branch tips) would either expand that combinatorics or thread a parallel path through argument parsing, repo traversal, and release assembly.

Math-interval notation factors location from inclusivity cleanly: [X, Y] is bounded inclusive on both sides; (X, Y) is bounded exclusive; [X, Y) mixes them. Modeling a CommitRange as two optional Endpoints (each with rev and inclusive) captures any combination as a pure data value flowing through a transform -> resolve -> execute pipeline. Future endpoint kinds become pure follow-ups: new transforms thread through the same data path, no fan-out across the CLI / config / walker layers. --dry-run falls out for free as "render the interval and the resolved revspec, then exit".

Implications of this architectural change

See: this issue comment

No tracking issue.

How Has This Been Tested?

The collapsed block below records actual --dry-run output across the verified scenarios on this very repo (git-cliff itself); the test plan after it lists what reviewers should rerun locally and what CI exercises.

Verified end-to-end (click to expand)

Spot-checks that the four config keys flow through the same pipeline as their CLI counterparts, that the CLI overrides config (same-key, cross-key, and partial-side), and that the trickier walker shapes (right-only exclusive) resolve cleanly. All runs use --dry-run so no changelog is written.

TOML drives the range:

$ cat cliff-range.toml
[git]
start_after = "v2.12.0"
end_at      = "v2.13.0"

$ git cliff --config cliff-range.toml --dry-run
range:    (v2.12.0, v2.13.0]
commits:  85
emitted:  988e8638...^..d2354923...

Flipping inclusivity on both sides:

$ cat cliff-range.toml
[git]
start_at   = "v2.12.0"
end_before = "v2.13.0"

$ git cliff --config cliff-range.toml --dry-run
range:    [v2.12.0, v2.13.0)
commits:  85
emitted:  988e8638...^..d2354923...^

Only one side set; the other falls back to its default:

$ cat cliff-range.toml
[git]
end_at = "v2.13.0"

$ git cliff --config cliff-range.toml --dry-run
range:    [<first-commit>, v2.13.0]
commits:  1546
emitted:  d2354923...

Conflict detection fires from TOML alone:

$ cat cliff-range.toml
[git]
start_at    = "v2.12.0"
start_after = "v2.12.0"

$ git cliff --config cliff-range.toml --dry-run
Error: ArgumentError("`start_at` and `start_after` are mutually exclusive")

CLI overrides config, same key. TOML pins the upper bound to v2.13.0; the CLI bumps it to v2.13.1:

$ cat cliff-range.toml
[git]
start_after = "v2.12.0"
end_at      = "v2.13.0"

$ git cliff --config cliff-range.toml --end-at v2.13.1 --dry-run
range:    (v2.12.0, v2.13.1]
commits:  87
emitted:  988e8638...^..5d3a6702...

CLI overrides config, cross-key (flips inclusivity). Config sets start_after = "v2.12.0" (exclusive); CLI passes --start-at v2.12.0 (inclusive). The CLI replaces the entire left side, so the resolved range becomes [v2.12.0, v2.13.0] and the commit count picks up v2.12.0 itself:

$ git cliff --config cliff-range.toml --start-at v2.12.0 --dry-run
range:    [v2.12.0, v2.13.0]
commits:  86
emitted:  988e8638...^..d2354923...

CLI overrides one side; config still drives the other. CLI moves the left endpoint back to v2.11.0; the right endpoint stays where the config put it:

$ git cliff --config cliff-range.toml --start-after v2.11.0 --dry-run
range:    (v2.11.0, v2.13.0]
commits:  102
emitted:  3ddfb1ee...^..d2354923...

Sanity: git rev-list --count v2.12.0..v2.13.0 is 85, ..v2.13.1 is 87, v2.12.0^..v2.13.0 is 86, and v2.11.0..v2.13.0 is 102, all matching the dry-run counts above.

Right side only, exclusive. No left bound; --end-before gives an upper-exclusive range that walks back from the parent of the named tag:

$ git cliff --end-before v2.12.0 --dry-run
range:    [<first-commit>, v2.12.0)
commits:  1460
emitted:  988e8638...^

Compare with --end-at v2.12.0 for the inclusive sibling: [<first-commit>, v2.12.0] at 1461 commits (one more, picking up v2.12.0 itself).

Test plan

  • cargo test -- --skip "repo::test::git_upstream_remote" passes
  • cargo test --no-default-features -- --skip "repo::test::git_upstream_remote" passes
  • CI Nix flake check passes (skips resolve_with tests like command / repo)
  • CI fixture matrix includes test-range-start-at-inclusive, test-range-end-before-exclusive, and the new test-range-end-before-only (right-only-exclusive regression)
  • Manual: git cliff --start-at <tag> --end-at <tag> --dry-run prints expected interval/commits/revspec
  • Manual: git cliff --end-before <tag> --dry-run (no left bound) prints [<first-commit>, <tag>) and resolves without error
  • Manual: legacy flags (--latest, --current, --unreleased, A..B) still produce identical changelogs to main

Screenshots / Logs (if applicable)

See the collapsed "Verified end-to-end" block under How Has This Been Tested? above for the full set of --dry-run logs.

Types of Changes

  • New feature (non-breaking change which adds functionality)
  • Documentation (no code change)
  • Refactor (refactoring production code)

(Notes: the --end-before walker fix is for in-PR code, not released behavior, so I left "Bug fix" unchecked. Every change here is additive or strictly more permissive: the new endpoint flags are new surface, the set_commit_range widening accepts a superset of what it used to, and legacy flag behavior is unchanged.)

Checklist:

  • My code follows the code style of this project.
  • I have updated the documentation accordingly (if applicable).
  • I have formatted the code with rustfmt.
    • cargo +nightly fmt --all (verified clean via --check).
  • I checked the lints with clippy.
    • cargo clippy --tests --verbose -- -D warnings — no new warnings from main. (Pre-existing clippy::unnecessary_sort_by hits in git-cliff-core are present on main and untouched by this branch.)
  • I have added tests to cover my changes.
  • [?] All new and existing tests passed locally, except for 2-3 persistent woes on CI

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 24, 2026

Codecov Report

❌ Patch coverage is 84.76821% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.76%. Comparing base (5d3a670) to head (28df16a).

Files with missing lines Patch % Lines
git-cliff/src/lib.rs 0.00% 20 Missing ⚠️
git-cliff/src/range.rs 97.50% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1489      +/-   ##
==========================================
+ Coverage   48.86%   51.76%   +2.91%     
==========================================
  Files          26       27       +1     
  Lines        2272     2390     +118     
==========================================
+ Hits         1110     1237     +127     
+ Misses       1162     1153       -9     
Flag Coverage Δ
unit-tests 51.76% <84.77%> (+2.91%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cds-amal
Copy link
Copy Markdown
Author

Hi @orhun , I don't know how to proceed with the failing tests; any advice please?

@orhun
Copy link
Copy Markdown
Owner

orhun commented Apr 26, 2026

Seems like a flaky test due to a network error (404) - I just restarted the job.

cds-amal added 23 commits April 26, 2026 08:11
- resolve_rev(rev): resolve a revision string (tag/branch/SHA prefix/HEAD)
  to a full commit SHA, error if unresolvable.
- is_root_commit(rev): detect whether a revision is the root (no parents),
  so callers can handle the `A^` fallback for inclusive-root endpoints.
- inclusive range descriptors `[`, `(`, `)`, `]`
- all four default to `None` via `#[derive(Default)]`.
args:

- `start_at`,
- `start_after`,
- `end_at`,
- `end_before`

TODO: wire up
todo -- docu them tests
Replace the procedural if/else chain with a three-line call into the
`range` module: transform_range -> resolve_with -> execute_range. Net
behavior is unchanged because every legacy flag (--latest, --current,
--unreleased, positional A..B) produces the same commit range as before,
verified by the 20+ range-related fixtures that already cover this
surface. Range module's unit tests back-stop behavior at a finer grain.

The pipeline normalizes endpoints too full SHAs via resolve_with, which
is what the old hand-written body effectively did by pulling commit SHAs
from the `tags` map keys.
Make the range endpoint cli args operational. New tests: one parse per
option, intra-pair conflicts on both sides, and a parametric sweep over
the legacy-vs-new cross product.
`--dry-run` prints the computed interval, the number of commits the
range covers, and the emitted git revision range, then exits without
rendering the changelog. Intended for users sanity-checking their range
selection before committing to a full render, and for CI pipelines that
want a cheap "any good goods to release?" signal.

Output shape (`--start-at v0.1.0 --end-at v0.2.0`):

    range:    [v0.1.0, v0.2.0]
    commits:  3
    emitted:  <sha>^..<sha>

The `range:` line displays user friendly refs; the `emitted:` line shows
the actual git range the walker receives (SHAs, post-resolve), matching
what `set_commit_range` expects.
- s/tracing/log/ for logging
- cargo +nightly fmt
Break `transform_range`'s one-long-function shape into a dispatcher plus
a handful of small helpers, each responsible for one translation row:

`current_range` preserves teh legacy `tag_names.len() < 2` fallback by
delegating to `latest_range`. The old `determine_commit_range` code
had this behavior and it's exercised (indirectly) by teh fixture suite;
the stricter "always error if current tag is mising" variant would be
a behavior regression.
…range

Three small clarity changes to the `range` module's surface:

- Import `git_cliff_core::{config::GitConfig, error::{Error, Result}}`
  at the top of `range.rs`. Drops fully-qualified paths from seven
  signatures and helper bodies; call sites now read as plain
  `Result<CommitRange>` instead of teh two-colon wall.

- Introduce `RangeSelection { canonical, emitted }` for
  `determine_commit_range`'s return value. The tuple-of-two shape was
  easy to misread at the call site (which is which?); the named fields
  document themselves. The two consumers in `lib.rs` now spell the
  relevant half explicitly (`.emitted` for the walker, `.canonical` for
  display).

- Move the dry-run output block (12 lines of `println!` + match) out of
  `run_with_changelog_modifier` and into `range::print_dry_run`. The
  formatting was only used for dry-run and it's cohesive with
  `format_interval`, which can now drop `pub(crate)` and stay private.

- TODO
Two new fixtures exercise the new endpoint options on a small
deterministic repo (template: Initial, two tagged releases v0.1.0 and
v0.2.0 each with feat+fix, plus an unreleased test commit):
`Option<&String>` is unidiomatic. The standard Rust signature for a
borrowed optional string is `Option<&str>`. Flip the helper's parameters
and use `.as_deref()` at the call sites.

`Endpoint::inclusive` / `::exclusive` already accept `impl Into<String>`,
so use `&str`
Extending the skip patterns (`command` and `repo`) to include
`resolve_with`. Hopefully this is the right call.
Reduce dry-run testing to string comparisons.
Add tests indicated by tarpaulin (LLVM engine --all-features)
- usage/args.md: list --dry-run, the four range endpoint options, and a "Range selection" section.
- configuration/git.md: document the `start_at` / `start_after` / `end_at` / `end_before` keys.
- usage/examples.md: examples for the new endpoint options and --dry-run.
The unbounded-left placeholder rendered as `first` in `--dry-run` output,
which sat awkwardly next to a real ref like `HEAD` and read as if it
might be something a user could type. `<first-commit>` makes the
placeholder nature obvious; the angle brackets are the same convention
git uses for value names in usage strings. No behavioral change beyond
the cosmetic; the four test assertions on the literal are updated to
match.
`transform_range` previously merged CLI and config field-by-field with
`or`, so `--start-at X` on the CLI plus `start_after = "Y"` in the
config ended up with both inclusive and exclusive slots filled and
tripped the mutual-exclusion check; users expecting CLI to win got an
error instead of an override, rats!

The new shape is precedence-first: build a baseline `CommitRange` from
config (which still errors if config alone is internally inconsistent),
then let the CLI replace each side as a unit. Setting `--start-at` or
`--start-after` overrides both `start_at` and `start_after` from config;
same for the right side. The two sides remain independent, so a CLI
`--start-at` does not disturb config's `end_at`.

Three new tests cover the cross-key override (CLI `start_after` over
config `start_at`, resulting endpoint is exclusive), the partial-side
override (CLI replaces left, config still drives right), and the
config-only conflict (both keys set in TOML still errors).
Whoops! `--end-before X` (and the equivalent `end_before` config key)
without a left bound crashed with "unable to parse OID - too long". The
path: `execute_range` for `(left=None, right=Some(exclusive))` emits
`<sha>^` (a single revspec, no `..` in it), `set_commit_range` saw no
`..`, took the single-OID branch, and `Oid::from_str` choked on the `^`.

The narrow contract on `set_commit_range` was a  bug: the function
comment said "When a single SHA is provided", but in practice the input
can be a tag, a branch, or a parent expression like `<sha>^`. Switch to
`revparse_single().id()`, which handles the full revspec grammar.

Add regression test in a fixture (`test-range-end-before-only`) that
exercises `--end-before <tag>` with no left bound, wired into the CI
matrix next to the existing `test-range-end-before-exclusive`.
@cds-amal cds-amal changed the title Refactor Of Unusual Size: express commit range and inclusivity from cli feat(cli): introduce commit ranges with interval-notation endpoint flags Apr 26, 2026
@cds-amal cds-amal marked this pull request as ready for review April 26, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants