Skip to content

fix(read): fail when --from and --to share no merge-base #4555#4754

Merged
escapedcat merged 2 commits into
conventional-changelog:masterfrom
CervEdin:fix/shallow-clone-merge-base
May 14, 2026
Merged

fix(read): fail when --from and --to share no merge-base #4555#4754
escapedcat merged 2 commits into
conventional-changelog:masterfrom
CervEdin:fix/shallow-clone-merge-base

Conversation

@CervEdin

@CervEdin CervEdin commented May 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #4555.

When linting a range with --from A --to B in a shallow clone where the merge-base wasn't fetched, git log A..B silently 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@v4 clones with fetch-depth: 1, so a branch with N commits ahead of origin/master surfaces only HEAD; if HEAD is well-formed, the lint passes regardless of the unfetched commits.

This PR adds a git merge-base check between from and to (defaulting to to HEAD to mirror what git-raw-commits does 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.ts use git checkout --orphan to create unrelated histories — the exact condition the check enforces, decoupled from shallow-clone trigger specifics.

Out of scope

  • --from-last-tag in a shallow clone has a related failure mode (git describe falls back to a hash → no-op range → silent pass). The merge-base check doesn't catch it because the no-op sets from = HEAD-hash, which trivially merge-bases with HEAD. Surfacing this requires a separate decision (warn vs. error, and how a library function should communicate diagnostics) and is left for follow-up.
  • Using same commit for --from and --to does nothing and returns exit-code=0 (success) #3376 (--from === --to silently succeeds) is a related but distinct issue, also out of scope here.

Alternative considered

The issue thread suggested gating on .git/shallow instead. Merge-base is more precise — it detects the actual broken state regardless of cause, and matches git diff --three-dot's behavior. A path-based shallow check would also be wrong in worktrees and submodules, where .git is a file pointing elsewhere; if such a check is ever wanted, git rev-parse --is-shallow-repository is the right primitive.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via git-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 --orphan to 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.

Comment thread @commitlint/read/src/read.ts
@knocte

knocte commented May 9, 2026

Copy link
Copy Markdown
Contributor

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.

@CervEdin CervEdin force-pushed the fix/shallow-clone-merge-base branch from c03f27f to d8ed579 Compare May 14, 2026 10:02
@CervEdin

Copy link
Copy Markdown
Contributor Author

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.

you want it red in CI or is it okay to commit with test.fails -> test?

@escapedcat

Copy link
Copy Markdown
Member

I think two commits are good, one fails CI, one makes it green. Does this work?

@knocte

knocte commented May 14, 2026

Copy link
Copy Markdown
Contributor

to commit with test.fails -> test?

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.
You can do this with git using this kind of command: https://stackoverflow.com/a/45155607/23158491

Or if you want a script, I developed one:
https://github.com/tarsgate/conventions/blob/master/scripts/gitPush1by1.fsx

Although, not sure if you have to wait first for @escapedcat to approve the workflows

@escapedcat

Copy link
Copy Markdown
Member

Try this:

  1. fix format issues
  2. create a commit that fails CI
  3. fix code
  4. create a commit that passes CI
  5. tackle copilot feedback

@knocte

knocte commented May 14, 2026

Copy link
Copy Markdown
Contributor

Try this:
fix format issues

But bro, you're the one that broke the formatting in master 😆

@CervEdin

Copy link
Copy Markdown
Contributor Author

to commit with test.fails -> test?

Sorry I don't know what you mean with this.

What I did was two commits:

  1. assert the tests fail
  2. commit fix and change the tests to assert success

BTW I just saw that you did a single push with 2 commits. The point is do a push per commit.
Okay, sure I can do it like that

@CervEdin CervEdin force-pushed the fix/shallow-clone-merge-base branch from d8ed579 to d75f703 Compare May 14, 2026 10:29
@escapedcat

Copy link
Copy Markdown
Member

Try this:
fix format issues

But bro, you're the one that broke the formatting in master 😆

Fixed: #4768

@CervEdin CervEdin force-pushed the fix/shallow-clone-merge-base branch from ca98be0 to 8e986a5 Compare May 14, 2026 10:38
@CervEdin

Copy link
Copy Markdown
Contributor Author

First commit
d75f703 (test: add tests for missing coverage, 2026-05-14)
fails the tests, like you asked @knocte

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  @commitlint/read/src/read.test.ts > throws when from and to share no merge-base
AssertionError: promise resolved "[ 'orphan commit\n\n' ]" instead of rejecting

- Expected:
Error {
  "message": "rejected promise",
}

+ Received:
[
  "orphan commit

",
]

 ❯ @commitlint/read/src/read.test.ts:170:62
    168|  });
    169|
    170|  await expect(read({ from: initialBranch, to: "other", cwd })).rejects.toThrow(
       |                                                              ^
    171|   /Cannot find merge-base/,
    172|  );

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL  @commitlint/read/src/read.test.ts > throws when from has no merge-base with HEAD (no --to)
AssertionError: promise resolved "[ 'orphan commit\n\n' ]" instead of rejecting

- Expected:
Error {
  "message": "rejected promise",
}

+ Received:
[
  "orphan commit

",
]

 ❯ @commitlint/read/src/read.test.ts:191:49
    189|  });
    190|
    191|  await expect(read({ from: initialBranch, cwd })).rejects.toThrow(
       |                                                 ^
    192|   /Cannot find merge-base/,
    193|  );

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯


 Test Files  1 failed | 89 passed (90)
      Tests  2 failed | 1188 passed (1190)
Type Errors  no errors
   Start at  12:48:46
   Duration  24.71s (transform 4.25s, setup 0ms, import 12.65s, tests 54.74s, environment 1.32s, typecheck 499ms)

error Command failed with exit code 1.

btw, the current vitest.config.ts doesn't play nice with vim undo/swap files. Maybe consider excluding hidden files or tightening the include smh

diff --git a/vitest.config.ts b/vitest.config.ts
index 09b59699..0d9f802d 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -15,6 +15,7 @@ export default defineConfig({
                coverage: {
                        provider: "istanbul",
                        include: ["**/@commitlint/*/src/**"],
+                       exclude: ["**/.*"],
                },
        },
        environments: {

@CervEdin CervEdin force-pushed the fix/shallow-clone-merge-base branch from 8e986a5 to ac89151 Compare May 14, 2026 11:06
@CervEdin

Copy link
Copy Markdown
Contributor Author

rebased and re-formatted

CervEdin and others added 2 commits May 14, 2026 13:07
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>
@CervEdin CervEdin force-pushed the fix/shallow-clone-merge-base branch from ac89151 to 901c7ff Compare May 14, 2026 11:08
@knocte

knocte commented May 14, 2026

Copy link
Copy Markdown
Contributor

fails the tests, like you asked @knocte

You still pushed both commits with the same push operation, so we only see one CI status.

@CervEdin

Copy link
Copy Markdown
Contributor Author

fails the tests, like you asked @knocte

You still pushed both commits with the same push operation, so we only see one CI status.

Earlier I pushed them separetly

image

I will re force-push the first commit
how long do I need to wait before pushing the full series for CI to register in a way which shows the red CI like you want?

@CervEdin CervEdin force-pushed the fix/shallow-clone-merge-base branch from 901c7ff to d1fd7c5 Compare May 14, 2026 11:18
@knocte

knocte commented May 14, 2026

Copy link
Copy Markdown
Contributor

how long do I need to wait

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.

@knocte

knocte commented May 14, 2026

Copy link
Copy Markdown
Contributor

Looks good! I see broken CI now, then you can now push 2nd commit.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@escapedcat

Copy link
Copy Markdown
Member

Looks good, thanks! undraft?

@CervEdin CervEdin marked this pull request as ready for review May 14, 2026 11:53
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@escapedcat escapedcat changed the title fix(read): fail when --from and --to share no merge-base fix(read): fail when --from and --to share no merge-base #4555 May 14, 2026
@escapedcat

Copy link
Copy Markdown
Member

Thanks everybody! ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

fix: silently succeeds in shallow clones, missing invalid commits

4 participants