Skip to content

Merge-strategy builds of stacked PRs always fail with "not something we can merge" (baseHash derived from parent PR's synthetic merge ref) #1545

Description

@fruch

Summary

For a stacked PR — one whose base branch is the head branch of another open PR — merge-strategy builds fail 100% of the time, permanently, with:

ERROR: Checkout failed
hudson.plugins.git.GitException: Command "/usr/bin/git merge 735f70a5a6bd3d80d9cb773c0ac969226fb88df7" returned status code 1:
stdout:
stderr: merge: 735f70a5a6bd3d80d9cb773c0ac969226fb88df7 - not something we can merge
	at ...MergeWithGitSCMExtension.decorateRevisionToBuild(MergeWithGitSCMExtension.java:120)
	at ...GitSCM.determineRevisionToBuild(GitSCM.java:1162)

The baseHash the plugin picks is a commit that exists only under refs/pull/<parent>/merge and is reachable from no branch, so the fetch refspec can never bring it into the workspace.

This is the same code path as #1534, but a different trigger, and unlike #1534 it never self-heals — no rescan, close/reopen, or plugin upgrade helps.

Environment

github-branch-source 1917.v9ee8a_39b_3d0d (also present on master today)
git / git-client 5.7.0 / 6.5.0
branch-api 2.1244.vf95c81f1641c
Discovery trait OriginPullRequestDiscoveryTrait strategyId=1 (Merging the pull request with the current target branch revision)

Root cause

Step 1 — GitHub stacks the merge preview. For a PR whose base branch is another open PR's head, GitHub computes the child's merge ref against the parent PR's merge ref, not against the base branch tip:

refs/pull/15618/merge = 7334a316  "Merge e6f95c52 into 56f891d6"
                        parents = [56f891d6, e6f95c52]
                                   ^^^^^^^^ == refs/pull/15617/merge  (synthetic!)

refs/heads/feature/minicloud-integration = 6e317e6a   <-- the actual base tip

56f891d6 is reachable from no branch — it only exists as refs/pull/15617/merge.

Step 2 — the plugin trusts merge_commit_sha. GitHubSCMSource.createPullRequestSCMRevision():

// We found a merge_commit_sha with 2 parents and one matches the prHeadHash
// Use the other parent hash as the base. ...
mergeHash = proposedMergeHash;
baseHash = prHeadHash.equals(parents.get(0)) ? parents.get(1) : parents.get(0);

The guard above it only checks parents.size() == 2 and parents.contains(prHeadHash) — both true here. Nothing checks that the other parent is on the base branch. The comment "Merge commits always merge against the most recent base commit they can detect" is false for stacks.

Step 3 — the fetch can never satisfy it. GitHubSCMBuilder only ever emits:

+refs/pull/15618/head:refs/remotes/origin/PR-15618
+refs/heads/feature/minicloud-integration:refs/remotes/origin/feature/minicloud-integration

Never refs/pull/*/merge. So baseHash is absent locally, and MergeWithGitSCMExtension runs git merge 56f891d6not something we can merge.

Reproducer

  1. PR Amaster, head branch feature/a.
  2. PR Bfeature/a, head branch feature/b. Both open, both mergeable.
  3. Discover origin PRs with strategy "Merging the pull request with the current target branch revision".
  4. Every build of B fails in checkout. A builds fine.

Live proof it is deterministic, not stale state

Calling the plugin's own resolver from the script console (SCMSource.fetch(head, listener)) reproduces it on demand:

PR-15617  target=master                            strategy=MERGE
   -> 6e317e6a+4698ab66 (56f891d6)     base = real master tip            ✅ builds green

PR-15618  target=feature/minicloud-integration     strategy=MERGE
   -> e6f95c52+56f891d6 (7334a316)     base = refs/pull/15617/merge      ❌ always fails

Recorded SCMRevisionAction for every build of the child PR — the base always equals the parent PR's merge_commit_sha at that moment:

child build baseHash used equals
#21–24 948d822c parent's merge ref, rev 1
#25 18f54ec4 parent's merge ref, rev 2
#26–27 735f70a5 parent's merge ref, rev 3
#28 56f891d6 parent's merge ref, rev 4

8/8 FAILURE. It also propagates down a 3-deep stack: refs/pull/15668/merge = "Merge <head> into 7334a316", i.e. the grandchild's base becomes the child's merge ref. 10/10 FAILURE there.

Control: a PR based on a long-lived branch with no open PR of its own resolves to a real reachable commit and builds green — confirming the trigger is precisely "base branch has an open PR".

Why this is worse than #1534

#1534 describes the same baseHash-from-merge-parents inference going stale after a retarget — transient, self-heals once GitHub recomputes. Here the derived base is unreachable by construction: it is a synthetic merge ref that will never be reachable from any branch for as long as the parent PR stays open. Rescan, close/reopen, and upgrading the plugin all have no effect. Stacked PRs simply cannot be built with merge strategy.

Proposed fix

This is #1534's proposed fix (a), and it resolves both issues:

After deriving baseHash from the merge parents, verify it is reachable from heads/<base.ref>; if not, fall back to the existing branch-tip lookup.

The fallback already exists a few lines below, for the mergeHash == null path:

baseHash = ghRepository.getRef("heads/" + pr.getBase().getRef()).getObject().getSha();

So the change is to extend the validity check at line ~1736 — e.g. also require that the non-head parent is reachable from the base ref (one compare call, or simply prefer the branch-tip lookup and let MergeWithGitSCMExtension merge against the real tip).

A cheaper heuristic that covers this case without an extra API call: if the non-head parent is itself a 2-parent commit whose parents include the base branch's tip, it is a synthetic PR merge ref and must not be used as baseHash.

Workaround

Switch origin PR discovery from strategyId 1 (merge with target) to strategyId 2 (the current pull request revision). MergeWithGitSCMExtension and the base-branch refspec are both inside if (head.isMerge()) in GitHubSCMBuilder, so HEAD strategy sidesteps the failure — at the cost of no longer testing PRs against their target branch. Flattening the stack (retargeting children to the default branch) also works.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions