From 721a6486bf8bc53bc84510148bf6207a71f8f240 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 6 Aug 2026 15:08:12 +0000 Subject: [PATCH 1/6] fix: resolve merge-base refs that exist only on the remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CC202 reported "not rebased onto target branch" for a branch that was correctly based on main the whole time — in every CI checkout of a pull request. Two halves, same mistake: exit 128 from git means "could not resolve that name", and both call sites read it as "not an ancestor". _find_target_branch verifies refs/heads/main, falls back to verifying refs/remotes/origin/main, then returns the bare name either way. A pull request checkout has only the remote-tracking ref, so the caller ran git merge-base --is-ancestor main HEAD fatal: Not a valid object name main (exit 128) and the failure was reported as a rebase problem. The remote fallback now returns origin/ — the ref that was just verified. Note that writing require_rebase_target = "origin/main" in config is not a workaround: _find_target_branch tries refs/heads/origin/main and refs/remotes/origin/origin/main, finds neither, returns None, and the check silently passes without checking anything. The second half: get_branch_name() falls back to GITHUB_HEAD_REF, so a detached CI checkout reports a branch name that exists on no local ref. Same 128, same misreading. validate() now retries against HEAD — the same commit, always resolvable — and only a real non-zero ancestry answer fails. Measured in a clone shaped like the runner's checkout: _find_target_branch('main') -> 'main' (before fix) git_merge_base('main', 'HEAD') -> 128 git_merge_base('origin/main', 'HEAD') -> 0 The existing tests never caught this because none of them ran the code they named: two patched commit_check.util.git_merge_base while the engine imports the name directly, so the mock never bound and real git ran against whatever checkout pytest was in — one of them passed only because 128 was misread as FAIL. A third built its rule with no regex, so validate() returned PASS before reaching the mocked call (call count: 0). All three now assert against the engine's own reference, and two new tests drive real git in pull-request-shaped clones. Reverting either fix fails its test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U9zFxq8V4qxG4aMzJhGBFn --- commit_check/engine.py | 16 +++++- tests/engine_test.py | 122 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/commit_check/engine.py b/commit_check/engine.py index d06ac03d..2277d7db 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -481,6 +481,13 @@ def validate(self, context: ValidationContext) -> ValidationResult: return ValidationResult.PASS result = git_merge_base(target_branch, current_branch) + if result == 128: + # 128 is git failing to resolve a name, not an answer about + # ancestry. A CI checkout of a pull request leaves a detached HEAD + # with no local branch created, while get_branch_name() still + # reports a name from GITHUB_HEAD_REF — so the name here refers to + # nothing on disk. HEAD is the same commit and always resolves. + result = git_merge_base(target_branch, "HEAD") if result == 0: return ValidationResult.PASS @@ -527,7 +534,14 @@ def _find_target_branch(self, pattern: str) -> str | None: stderr=subprocess.DEVNULL, check=True, ) - return branch_name + # Qualified with the remote, because that is the ref that was just + # verified. Returning the bare name here made the caller run + # ``git merge-base --is-ancestor main HEAD`` in a checkout that has + # only ``origin/main``; git exits 128 on the unresolvable name and + # the branch was reported as "not rebased onto target branch" when + # it was correctly based all along. A CI checkout of a pull request + # is exactly that shape. + return f"origin/{branch_name}" except subprocess.CalledProcessError: pass diff --git a/tests/engine_test.py b/tests/engine_test.py index 17f0ed6c..abc92e43 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -35,6 +35,43 @@ USE_CONVENTIONAL_FORMAT = "Use conventional format" +def _pull_request_shaped_clone(tmp_path): + """Build a clone shaped like a CI checkout of a pull request. + + One commit on origin/main, one commit of work on top, then every local + branch removed and HEAD detached — so the target exists only as a + remote-tracking ref and the branch name resolves to nothing. + + Returns the clone path. + """ + import subprocess as sp + + def git(*args, cwd): + return sp.run( + ["git", *args], + cwd=cwd, + check=True, + stdout=sp.PIPE, + stderr=sp.PIPE, + text=True, + ) + + origin = tmp_path / "origin" + origin.mkdir() + git("init", "-q", "-b", "main", ".", cwd=origin) + git("config", "user.name", "Dev", cwd=origin) + git("config", "user.email", "dev@example.com", cwd=origin) + git("commit", "-q", "--allow-empty", "-m", "feat: base", cwd=origin) + + clone = tmp_path / "clone" + git("clone", "-q", str(origin), str(clone), cwd=tmp_path) + git("config", "user.name", "Dev", cwd=clone) + git("config", "user.email", "dev@example.com", cwd=clone) + git("checkout", "-q", "-b", "feat/work", cwd=clone) + git("commit", "-q", "--allow-empty", "-m", "feat: work", cwd=clone) + return clone, git + + class TestValidationResult: @pytest.mark.benchmark def test_validation_result_enum(self): @@ -1045,22 +1082,38 @@ def test_validate_with_whitespace_only_message(self): class TestMergeBaseValidator: - @patch("commit_check.util.git_merge_base") + @patch("commit_check.engine.git_merge_base") @pytest.mark.benchmark def test_merge_base_validator_valid(self, mock_git_merge_base): - """Test MergeBaseValidator with valid merge base.""" + """Test MergeBaseValidator with valid merge base. + + Patched on commit_check.engine, not commit_check.util: the engine + imported the name directly, so patching the util module rebound + nothing and these tests were running real git against the checkout + they happened to be executed in. + """ mock_git_merge_base.return_value = 0 - rule = ValidationRule(check="merge_base") + # With no regex, validate() returns PASS at the "no target configured" + # exit without ever calling git_merge_base — the assertion below would + # hold no matter what the mock returned. Give it a target so the + # merge-base path actually runs. + rule = ValidationRule(check="merge_base", regex=r"^main$") validator = MergeBaseValidator(rule) context = ValidationContext() - result = validator.validate(context) + with ( + patch.object(validator, "_find_target_branch", return_value="origin/main"), + patch("commit_check.engine.get_branch_name", return_value="feature/test"), + patch("commit_check.engine.has_commits", return_value=True), + ): + result = validator.validate(context) assert result == ValidationResult.PASS + mock_git_merge_base.assert_called_once_with("origin/main", "feature/test") @patch("commit_check.engine.has_commits") @patch("commit_check.engine.get_branch_name") - @patch("commit_check.util.git_merge_base") + @patch("commit_check.engine.git_merge_base") @pytest.mark.benchmark def test_merge_base_validator_invalid( self, mock_git_merge_base, mock_get_branch_name, mock_has_commits @@ -1122,14 +1175,21 @@ def test_find_target_branch_local_found(self, mock_run): @patch("subprocess.run") def test_find_target_branch_local_missing_remote_found(self, mock_run): - """Local missing, remote tracking exists: returns the stripped branch name.""" + """Local missing, remote tracking exists: returns the remote-qualified name. + + Qualified, not bare: the caller feeds this straight to ``git merge-base + --is-ancestor``, and a checkout holding only ``origin/develop`` cannot + resolve ``develop``. Git exits 128 there, which the validator reports as + "not rebased onto target branch" — a clean branch failing for a name it + could not look up. + """ mock_run.side_effect = [ subprocess.CalledProcessError(1, []), # local fails subprocess.CompletedProcess(args=[], returncode=0), # remote succeeds ] validator = MergeBaseValidator(ValidationRule(check="merge_base")) result = validator._find_target_branch("develop") - assert result == "develop" + assert result == "origin/develop" assert mock_run.call_args_list[1][0][0][:5] == [ "git", "rev-parse", @@ -1148,6 +1208,54 @@ def test_find_target_branch_not_found(self, mock_run): result = validator._find_target_branch("nonexistent-branch") assert result is None + def test_merge_base_against_a_real_pull_request_shaped_checkout(self, tmp_path): + """A branch based on origin/main passes when no local main exists. + + Mocked subprocess is what let this through: every call was asserted + against the arguments the code happened to pass, so a target name git + could not resolve looked correct. This drives real git instead. + """ + from commit_check.util import git_merge_base + + clone, git = _pull_request_shaped_clone(tmp_path) + git("branch", "-q", "-D", "main", cwd=clone) + + cwd = os.getcwd() + try: + os.chdir(clone) + validator = MergeBaseValidator(ValidationRule(check="merge_base")) + target = validator._find_target_branch("main") + assert target == "origin/main" + # The point of the qualification: this is what the caller runs. + assert git_merge_base(target, "feat/work") == 0 + finally: + os.chdir(cwd) + + def test_merge_base_on_a_detached_checkout_with_no_local_branch(self, tmp_path): + """A detached checkout passes when the branch name resolves to nothing. + + The other half of the same mistake: get_branch_name() falls back to + GITHUB_HEAD_REF, so on a CI checkout of a pull request it reports a + branch that was never created locally. git exits 128 on the name, and + 128 was read as "not an ancestor" rather than "could not look that up". + """ + clone, git = _pull_request_shaped_clone(tmp_path) + git("checkout", "-q", "--detach", "HEAD", cwd=clone) + git("branch", "-q", "-D", "main", cwd=clone) + git("branch", "-q", "-D", "feat/work", cwd=clone) + + cwd = os.getcwd() + try: + os.chdir(clone) + with patch.dict(os.environ, {"GITHUB_HEAD_REF": "feat/never-created"}): + validator = MergeBaseValidator( + ValidationRule(check="merge_base", regex="main") + ) + result = validator.validate(ValidationContext()) + finally: + os.chdir(cwd) + assert result == ValidationResult.PASS + @patch("subprocess.run") def test_find_target_branch_empty_pattern(self, mock_run): """Empty or anchor-only pattern: returns None without calling subprocess.""" From 9943d240f91eb612ac8ae7924a579e8452f190b6 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 6 Aug 2026 15:14:29 +0000 Subject: [PATCH 2/6] ci: run the checks again after a runner provisioning failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build job died before reaching any code: GitHub's runner could not download its own actions ("Failed to resolve action download info. Error: Service Unavailable", three attempts). The workflow token is read-only, so a re-run cannot be requested through the API — an empty commit re-triggers everything and disappears in the squash merge. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U9zFxq8V4qxG4aMzJhGBFn From 47cd5955573afb1efb9fd972a7cecdf1f260943d Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 6 Aug 2026 15:34:12 +0000 Subject: [PATCH 3/6] fix: check the remote branch before HEAD when resolving merge-base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review caught this before merge: the HEAD fallback traded the false failure for a false pass. On a pull_request event the runner checks out GitHub's synthetic merge commit, whose first parent IS the target tip — so is-ancestor(target, HEAD) is true by construction, for every branch, rebased or not. Measured on a diverged branch in that shape: git_merge_base('origin/main', 'feat/work') -> 128 git_merge_base('origin/main', 'HEAD') -> 0 <- wrong git_merge_base('origin/main', 'origin/feat/work') -> 1 <- the truth An unresolvable branch name now resolves through its remote-tracking ref first; HEAD remains only as the last resort, where it still gives a real answer on checkouts whose HEAD is the branch commit itself (push events, or a branch that was never pushed). A new test builds the merge-ref shape with a genuinely diverged branch and asserts FAIL — disabling the origin/ step fails it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U9zFxq8V4qxG4aMzJhGBFn --- commit_check/engine.py | 10 +++++++++- tests/engine_test.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/commit_check/engine.py b/commit_check/engine.py index 2277d7db..242f1aca 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -486,7 +486,15 @@ def validate(self, context: ValidationContext) -> ValidationResult: # ancestry. A CI checkout of a pull request leaves a detached HEAD # with no local branch created, while get_branch_name() still # reports a name from GITHUB_HEAD_REF — so the name here refers to - # nothing on disk. HEAD is the same commit and always resolves. + # nothing on disk. The remote-tracking ref is the real branch. + result = git_merge_base(target_branch, f"origin/{current_branch}") + if result == 128: + # Last resort, and only when even the remote ref is absent. On a + # pull_request event HEAD is GitHub's synthetic merge commit, whose + # first parent IS the target tip — so answering from HEAD passes + # any branch, rebased or not. That is why origin/ must be + # tried first: this line only gives a real answer on checkouts + # where HEAD is the branch commit itself (e.g. a push event). result = git_merge_base(target_branch, "HEAD") if result == 0: return ValidationResult.PASS diff --git a/tests/engine_test.py b/tests/engine_test.py index abc92e43..ffa023ac 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -1256,6 +1256,46 @@ def test_merge_base_on_a_detached_checkout_with_no_local_branch(self, tmp_path): os.chdir(cwd) assert result == ValidationResult.PASS + def test_merge_base_fails_a_diverged_branch_on_a_merge_ref_checkout(self, tmp_path): + """A branch that is NOT rebased must fail, even on a merge-ref checkout. + + On a pull_request event the runner checks out GitHub's synthetic merge + commit, whose first parent IS the target tip — so answering the + ancestry question from HEAD passes every branch, rebased or not. The + branch must be resolved through its remote-tracking ref instead; this + test pins that, and fails if the HEAD fallback is consulted first. + """ + clone, git = _pull_request_shaped_clone(tmp_path) + # Publish the branch, then move main forward so feat/work is diverged. + git("push", "-q", "origin", "feat/work", cwd=clone) + origin = tmp_path / "origin" + git("commit", "-q", "--allow-empty", "-m", "feat: main moved on", cwd=origin) + git("fetch", "-q", "origin", cwd=clone) + # Build the merge-ref shape: detached at a merge of target and branch. + git("checkout", "-q", "--detach", "origin/main", cwd=clone) + git( + "merge", + "-q", + "--no-ff", + "-m", + "Merge feat/work into main", + "feat/work", + cwd=clone, + ) + git("branch", "-q", "-D", "main", "feat/work", cwd=clone) + + cwd = os.getcwd() + try: + os.chdir(clone) + with patch.dict(os.environ, {"GITHUB_HEAD_REF": "feat/work"}): + validator = MergeBaseValidator( + ValidationRule(check="merge_base", regex="main") + ) + result = validator.validate(ValidationContext(no_banner=True)) + finally: + os.chdir(cwd) + assert result == ValidationResult.FAIL + @patch("subprocess.run") def test_find_target_branch_empty_pattern(self, mock_run): """Empty or anchor-only pattern: returns None without calling subprocess.""" From 5e98643a51c03e1d0f6ca33053c1af1e9ddc590c Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 7 Aug 2026 05:31:25 +0000 Subject: [PATCH 4/6] chore: retrigger checks after a stuck workflow queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CodSpeed run for 47cd595 has sat in "queued" for nine hours after yesterday's GitHub Actions incident and can no longer be cancelled ("Cannot cancel a workflow re-run that has not yet queued"), so its check never reports. CodeQL's Analyze (python) on the same SHA cannot be re-run through the API either — it answers 403 "cannot be retried". An empty commit is the only lever that reaches both: a new head SHA starts fresh check runs for the whole suite. No file changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U9zFxq8V4qxG4aMzJhGBFn From 843e949d370b113090ae4c91b12ab0af0e3f4add Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 7 Aug 2026 05:42:45 +0000 Subject: [PATCH 5/6] fix: resolve the pull request head when merge-base falls back to HEAD Review caught a residual false pass in the fallback chain. When the branch is unresolvable under both its own name and origin/, the last resort asked about HEAD -- but on a pull_request event HEAD is GitHub's synthetic merge commit, whose first parent IS the target tip, so it passes any branch. Measured in that shape with the remote ref removed, on a branch that is genuinely behind: git_merge_base('origin/main', 'feat/work') -> 128 git_merge_base('origin/main', 'origin/feat/work') -> 128 git_merge_base('origin/main', 'HEAD') -> 0 <- wrong git_merge_base('origin/main', 'HEAD^2') -> 1 <- the truth HEAD's second parent is the pull request head, the commit actually under review, so the fallback now asks about that whenever HEAD is a merge. This answers rather than giving up: where HEAD has a single parent it is the branch commit itself and still answers for itself, so the rebased detached-checkout case keeps passing. Adds git_rev_parse_verify to test for the second parent, and a test that builds the merge-ref shape with no remote ref and asserts FAIL -- restoring the plain HEAD fallback fails it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U9zFxq8V4qxG4aMzJhGBFn --- commit_check/engine.py | 18 +++++++++++------- commit_check/util.py | 18 ++++++++++++++++++ tests/engine_test.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/commit_check/engine.py b/commit_check/engine.py index 242f1aca..f3d200e5 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -21,6 +21,7 @@ get_upstream_remote_sha, has_commits, git_merge_base, + git_rev_parse_verify, ) from commit_check.imperatives import IMPERATIVES @@ -489,13 +490,16 @@ def validate(self, context: ValidationContext) -> ValidationResult: # nothing on disk. The remote-tracking ref is the real branch. result = git_merge_base(target_branch, f"origin/{current_branch}") if result == 128: - # Last resort, and only when even the remote ref is absent. On a - # pull_request event HEAD is GitHub's synthetic merge commit, whose - # first parent IS the target tip — so answering from HEAD passes - # any branch, rebased or not. That is why origin/ must be - # tried first: this line only gives a real answer on checkouts - # where HEAD is the branch commit itself (e.g. a push event). - result = git_merge_base(target_branch, "HEAD") + # Last resort, when the branch is unresolvable under either name. + # On a pull_request event HEAD is GitHub's synthetic merge commit, + # whose first parent IS the target tip, so asking about HEAD would + # pass every branch, rebased or not. Its *second* parent is the + # pull request head — the commit actually under review — so ask + # about that instead whenever HEAD is a merge. Where HEAD has a + # single parent it is the branch commit itself (a push event, or a + # branch that was never pushed) and answers for itself. + source = "HEAD^2" if git_rev_parse_verify("HEAD^2") else "HEAD" + result = git_merge_base(target_branch, source) if result == 0: return ValidationResult.PASS diff --git a/commit_check/util.py b/commit_check/util.py index 6de1af03..4a78ecf7 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -195,6 +195,24 @@ def has_commits() -> bool: return False +def git_rev_parse_verify(rev: str) -> bool: + """Check whether a revision resolves in the current repository. + :param rev: any revision expression, e.g. ``HEAD^2`` + + :returns: `True` if the revision resolves, `False` otherwise. + """ + try: + subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return True + except subprocess.CalledProcessError: + return False + + def get_commit_info(format_string: str, sha: str = "HEAD") -> str: """Get latest commits information :param format_string: could be diff --git a/tests/engine_test.py b/tests/engine_test.py index ffa023ac..95272711 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -1296,6 +1296,46 @@ def test_merge_base_fails_a_diverged_branch_on_a_merge_ref_checkout(self, tmp_pa os.chdir(cwd) assert result == ValidationResult.FAIL + def test_merge_base_fails_a_diverged_branch_with_no_remote_ref(self, tmp_path): + """The same diverged branch must still fail when even the remote ref is + gone, which is where the fallback chain runs out of names. + + Measured in this shape: origin/main vs feat/work is 128, vs + origin/feat/work is 128, and vs HEAD is 0 -- so falling through to HEAD + would pass a branch that is genuinely behind. HEAD's *second* parent is + the pull request head and answers 1, the truth. + """ + clone, git = _pull_request_shaped_clone(tmp_path) + git("push", "-q", "origin", "feat/work", cwd=clone) + origin = tmp_path / "origin" + git("commit", "-q", "--allow-empty", "-m", "feat: main moved on", cwd=origin) + git("fetch", "-q", "origin", cwd=clone) + git("checkout", "-q", "--detach", "origin/main", cwd=clone) + git( + "merge", + "-q", + "--no-ff", + "-m", + "Merge feat/work into main", + "feat/work", + cwd=clone, + ) + git("branch", "-q", "-D", "main", "feat/work", cwd=clone) + # Leave the branch unresolvable under every name. + git("update-ref", "-d", "refs/remotes/origin/feat/work", cwd=clone) + + cwd = os.getcwd() + try: + os.chdir(clone) + with patch.dict(os.environ, {"GITHUB_HEAD_REF": "feat/work"}): + validator = MergeBaseValidator( + ValidationRule(check="merge_base", regex="main") + ) + result = validator.validate(ValidationContext(no_banner=True)) + finally: + os.chdir(cwd) + assert result == ValidationResult.FAIL + @patch("subprocess.run") def test_find_target_branch_empty_pattern(self, mock_run): """Empty or anchor-only pattern: returns None without calling subprocess.""" From b5a557768a3aba12550650ef4f1982b8e2e6c2db Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 7 Aug 2026 05:44:40 +0000 Subject: [PATCH 6/6] test: share the merge-ref setup between the diverged cases SonarCloud failed the quality gate at 14.2% duplication on new code (limit 3%). The two diverged-branch tests repeated the same twenty lines of setup and the same chdir/patch/validate dance. Extracts _diverged_merge_ref_clone for the shape and _validate_merge_base for the invocation, leaving each test as its distinguishing step plus an assertion. Net 13 lines lighter, and the regression test still fails when the plain HEAD fallback is restored. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U9zFxq8V4qxG4aMzJhGBFn --- tests/engine_test.py | 103 +++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 58 deletions(-) diff --git a/tests/engine_test.py b/tests/engine_test.py index 95272711..eb0f7c8b 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -72,6 +72,47 @@ def git(*args, cwd): return clone, git +def _diverged_merge_ref_clone(tmp_path): + """Extend the pull-request shape into the one that hides false passes. + + Publishes feat/work, moves main past it so the branch is genuinely behind, + then detaches at a merge of the two — the shape of GitHub's synthetic merge + commit, whose first parent is the target tip. Every local branch is removed, + so the branch resolves only through refs/remotes/origin/feat/work. + """ + clone, git = _pull_request_shaped_clone(tmp_path) + git("push", "-q", "origin", "feat/work", cwd=clone) + origin = tmp_path / "origin" + git("commit", "-q", "--allow-empty", "-m", "feat: main moved on", cwd=origin) + git("fetch", "-q", "origin", cwd=clone) + git("checkout", "-q", "--detach", "origin/main", cwd=clone) + git( + "merge", + "-q", + "--no-ff", + "-m", + "Merge feat/work into main", + "feat/work", + cwd=clone, + ) + git("branch", "-q", "-D", "main", "feat/work", cwd=clone) + return clone, git + + +def _validate_merge_base(clone, branch="feat/work", target="main"): + """Run MergeBaseValidator inside ``clone`` as a CI checkout would.""" + cwd = os.getcwd() + try: + os.chdir(clone) + with patch.dict(os.environ, {"GITHUB_HEAD_REF": branch}): + validator = MergeBaseValidator( + ValidationRule(check="merge_base", regex=target) + ) + return validator.validate(ValidationContext(no_banner=True)) + finally: + os.chdir(cwd) + + class TestValidationResult: @pytest.mark.benchmark def test_validation_result_enum(self): @@ -1265,36 +1306,8 @@ def test_merge_base_fails_a_diverged_branch_on_a_merge_ref_checkout(self, tmp_pa branch must be resolved through its remote-tracking ref instead; this test pins that, and fails if the HEAD fallback is consulted first. """ - clone, git = _pull_request_shaped_clone(tmp_path) - # Publish the branch, then move main forward so feat/work is diverged. - git("push", "-q", "origin", "feat/work", cwd=clone) - origin = tmp_path / "origin" - git("commit", "-q", "--allow-empty", "-m", "feat: main moved on", cwd=origin) - git("fetch", "-q", "origin", cwd=clone) - # Build the merge-ref shape: detached at a merge of target and branch. - git("checkout", "-q", "--detach", "origin/main", cwd=clone) - git( - "merge", - "-q", - "--no-ff", - "-m", - "Merge feat/work into main", - "feat/work", - cwd=clone, - ) - git("branch", "-q", "-D", "main", "feat/work", cwd=clone) - - cwd = os.getcwd() - try: - os.chdir(clone) - with patch.dict(os.environ, {"GITHUB_HEAD_REF": "feat/work"}): - validator = MergeBaseValidator( - ValidationRule(check="merge_base", regex="main") - ) - result = validator.validate(ValidationContext(no_banner=True)) - finally: - os.chdir(cwd) - assert result == ValidationResult.FAIL + clone, _ = _diverged_merge_ref_clone(tmp_path) + assert _validate_merge_base(clone) == ValidationResult.FAIL def test_merge_base_fails_a_diverged_branch_with_no_remote_ref(self, tmp_path): """The same diverged branch must still fail when even the remote ref is @@ -1305,36 +1318,10 @@ def test_merge_base_fails_a_diverged_branch_with_no_remote_ref(self, tmp_path): would pass a branch that is genuinely behind. HEAD's *second* parent is the pull request head and answers 1, the truth. """ - clone, git = _pull_request_shaped_clone(tmp_path) - git("push", "-q", "origin", "feat/work", cwd=clone) - origin = tmp_path / "origin" - git("commit", "-q", "--allow-empty", "-m", "feat: main moved on", cwd=origin) - git("fetch", "-q", "origin", cwd=clone) - git("checkout", "-q", "--detach", "origin/main", cwd=clone) - git( - "merge", - "-q", - "--no-ff", - "-m", - "Merge feat/work into main", - "feat/work", - cwd=clone, - ) - git("branch", "-q", "-D", "main", "feat/work", cwd=clone) + clone, git = _diverged_merge_ref_clone(tmp_path) # Leave the branch unresolvable under every name. git("update-ref", "-d", "refs/remotes/origin/feat/work", cwd=clone) - - cwd = os.getcwd() - try: - os.chdir(clone) - with patch.dict(os.environ, {"GITHUB_HEAD_REF": "feat/work"}): - validator = MergeBaseValidator( - ValidationRule(check="merge_base", regex="main") - ) - result = validator.validate(ValidationContext(no_banner=True)) - finally: - os.chdir(cwd) - assert result == ValidationResult.FAIL + assert _validate_merge_base(clone) == ValidationResult.FAIL @patch("subprocess.run") def test_find_target_branch_empty_pattern(self, mock_run):