fix(read): fail when --from and --to share no merge-base #4555#4754
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens @commitlint/read when linting a two-ended git range (--from A --to B, with --to defaulting to HEAD) by failing fast if the two refs do not share a merge-base. This prevents false-positive “success” results in shallow clones where git log A..B can silently return an incomplete subset of commits.
Changes:
- Add a pre-flight
git merge-base <from> <to|HEAD>check before walking history viagit-raw-commits. - Throw a targeted error when no merge-base exists (common in shallow/incomplete history scenarios).
- Add tests that construct unrelated histories via
git checkout --orphanto validate the new failure mode.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| @commitlint/read/src/read.ts | Adds merge-base validation for from/to ranges before delegating to git-raw-commits. |
| @commitlint/read/src/read.test.ts | Adds coverage for the new error behavior when refs share no merge-base (including implicit to=HEAD). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Can you split the commit in two (first commit the tests, after that commit the fix) so that we can see the red CI first? This way it's pure TDD style, you know. |
c03f27f to
d8ed579
Compare
you want it red in CI or is it okay to commit with |
|
I think two commits are good, one fails CI, one makes it green. Does this work? |
Sorry I don't know what you mean with this. BTW I just saw that you did a single push with 2 commits. The point is do a push per commit. Or if you want a script, I developed one: Although, not sure if you have to wait first for @escapedcat to approve the workflows |
|
Try this:
|
But bro, you're the one that broke the formatting in master 😆 |
What I did was two commits:
|
d8ed579 to
d75f703
Compare
Fixed: #4768 |
ca98be0 to
8e986a5
Compare
|
First commit btw, the current |
8e986a5 to
ac89151
Compare
|
rebased and re-formatted |
When `--from` is used, there's no check that the history between `--from` and `--to` (implicitly HEAD if not given) is actually walkeable in the git-log (for example a shallow-clone.) Add tests that check that `read` throws an error when the history between `from` and `to` is not wakleable (no merge-base.) Fix follows in subsequent commit.
When linting a range with `--from A --to B`, we hand the refs to git-raw-commits, which runs `git log A..B`. If A and B share no common ancestor — typically because we're in a shallow clone where the merge-base wasn't fetched — `git log` doesn't fail; it silently emits whatever subset of the range happens to exist locally. We validate that subset and report success, even though we never saw the rest of the range. This bites hardest in CI. `actions/checkout@v4` clones with `fetch-depth: 1` by default, which is fine for validating `HEAD` but inadequate for any comparison against `origin/HEAD`. A branch with six commits ahead of master surfaces only `HEAD`; if `HEAD`'s message is well-formed, the lint passes regardless of what the unfetched commits look like. Verify the two refs share a merge-base before walking the range, and fail with a clear message when they don't. The check runs only when `from` is set — `--last`, `--edit`, and running without `--from`/`--to` all bypass it, since none of them depend on a two-ended range. When `--to` is omitted, mirror what git-raw-commits does and treat it as `HEAD`, so `--from A` alone is covered too. The accompanying tests use `git checkout --orphan` to create two unrelated histories. That produces the exact condition the check enforces — no merge-base — without coupling the test to the specific shallow-clone trigger. A related case, `--from-last-tag` in a shallow clone, can fail the same way: `git describe` falls back to a hash, we turn that into a no-op range, and zero commits get validated. The merge-base check doesn't catch that path (the no-op sets `from = HEAD-hash`, which trivially merge-bases with `HEAD`), and distinguishing "no tag exists upstream" from "tag isn't fetched" requires shallow-clone probing that's better treated separately. Left for follow-up. Refs conventional-changelog#4555 Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Erik Cervin-Edin <erik.cervin-edin@einride.tech>
ac89151 to
901c7ff
Compare
You still pushed both commits with the same push operation, so we only see one CI status. |
Earlier I pushed them separetly
I will re force-push the first commit |
901c7ff to
d1fd7c5
Compare
No need to wait IME. However, I see what's happening here: everytime you push, @escapedcat needs to approve the CI workflow run. So I'm afraid you have to push 1st commit first, then wait until he approves, and then push again. |
|
Looks good! I see broken CI now, then you can now push 2nd commit. |
|
Looks good, thanks! undraft? |
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
|
Thanks everybody! ❤️ |
e4595eb
into
conventional-changelog:master

Fixes #4555.
When linting a range with
--from A --to Bin a shallow clone where the merge-base wasn't fetched,git log A..Bsilently returns whatever subset of the range exists locally. We validate that subset and report success — even though we never saw the rest of the range. Most visible in CI:actions/checkout@v4clones withfetch-depth: 1, so a branch with N commits ahead oforigin/mastersurfaces onlyHEAD; ifHEADis well-formed, the lint passes regardless of the unfetched commits.This PR adds a
git merge-basecheck betweenfromandto(defaultingtotoHEADto mirror whatgit-raw-commitsdoes internally) before walking the range, and errors with a clear message if no common ancestor exists.--last,--edit, and the no-flag default all bypass the check, since none of them depend on a two-ended range.Tests
Two new tests in
@commitlint/read/src/read.test.tsusegit checkout --orphanto create unrelated histories — the exact condition the check enforces, decoupled from shallow-clone trigger specifics.Out of scope
--from-last-tagin a shallow clone has a related failure mode (git describefalls back to a hash → no-op range → silent pass). The merge-base check doesn't catch it because the no-op setsfrom = HEAD-hash, which trivially merge-bases withHEAD. Surfacing this requires a separate decision (warn vs. error, and how a library function should communicate diagnostics) and is left for follow-up.--from === --tosilently succeeds) is a related but distinct issue, also out of scope here.Alternative considered
The issue thread suggested gating on
.git/shallowinstead. Merge-base is more precise — it detects the actual broken state regardless of cause, and matchesgit diff --three-dot's behavior. A path-based shallow check would also be wrong in worktrees and submodules, where.gitis a file pointing elsewhere; if such a check is ever wanted,git rev-parse --is-shallow-repositoryis the right primitive.