github / github/gh-stack

Stack split across merge groups closes the pull request at the boundary instead of continuing it in the next group

Open
#444 3 comments 20 reactions 0 assignees View on GitHub
bug topic: merge queue
Dominant language
Go
Stars
1.5k
Forks
70
Avg merge
1d 8h
Merged PRs (30d)
7

Description

## Summary

When a stack is split across consecutive merge groups, the pull request at the
split boundary is **closed** instead of continuing in the next group. Its base
branch (the head branch of the last PR in the merged group) is deleted by the
merge, GitHub re-targets the PR one link up the chain — onto the base's base,
which the same merge also deleted — and then closes it because that branch no
longer exists.

The stack never finishes landing, and the closed PR cannot be recovered through
the API or the UI: each obvious route is refused by a different validation (see
*Impact*). Recovery required force-pushing two deleted refs back to the remote.

Observed twice in the same stack, ~50 minutes apart, with `gh stack` v0.1.0.

Pull requests are referred to below as `PR A` … `PR E` on branches `a` … `e`;
timings, error strings, configuration values and documentation quotes are
verbatim from the real occurrences.

## Environment

- `gh stack` v0.1.0
- Base branch `main` uses a merge queue:
`mergeMethod=SQUASH`, `mergingStrategy=ALLGREEN`,
`maximumEntriesToBuild=8`, `maximumEntriesToMerge=5`, `minimumEntriesToMerge=1`
- Repository settings: `delete_branch_on_merge: true`,
`squash_merge_commit_message: PR_BODY`
- **No ruleset on the base branch** — `GET /repos/{owner}/{repo}/rules/branches/main`
returns `[]`, so `require_last_push_approval` and `dismiss_stale_reviews_on_push`
are both off. This distinguishes this report from #174 and #323, where
invalidated latest-push approval is the stated mechanism. Every PR here kept
`reviewDecision: APPROVED` throughout, including across force-pushes.
- All PRs `APPROVED`, `MERGEABLE`, required checks green when enqueued.
- **Every pull request in the stack was in the queue and building concurrently**
when the merge happened — `added_to_merge_queue` is recorded for all of them at
the same second, and none was ejected before the merge. The stack was not too
large for the group either: in the second occurrence it was 5 pull requests
against `maximumEntriesToMerge=5`, so the documented 50-percent buffer was not
even needed. Only the first three merged.

## Reproduction

1. Create a 5-PR stack: `main <- a (PR A) <- b (PR B) <- c (PR C) <- d (PR D) <- e (PR E)`.
2. Merge the whole stack (`gh stack merge --yes`). All five enter the queue
together — every PR records `added_to_merge_queue` at the same second — and
all five build concurrently (`maximumEntriesToBuild=8`).
3. The queue merges only `PR A`, `PR B`, `PR C`, while `PR D` and `PR E` are
still in the queue.

## Actual

```
T+0s PR A, PR B, PR C merged (all three, same second)
T+2s PR D base_ref_deleted
T+2s PR D closed
T+2s PR E removed_from_merge_queue (actor: github-merge-queue[bot])
```

- `PR D`'s base was `c`. After the merge it reads **`b`** — the base of its base,
deleted in the same operation — and the PR is closed.
- `PR E` being ejected is expected and documented; it stays open because its own
base branch `d` still exists.
- The remainder of the stack does not continue in a subsequent merge group. It
cannot: the PR at the boundary is closed.
- Why the queue stopped after three is unexplained by any configuration value —
all five were enqueued and building, and five is within
`maximumEntriesToMerge`. Whatever the reason for the boundary, the failure
reported here is what happens **at** it.

The first occurrence was identical with an 8-PR stack: three merged, the fourth
closed 2 seconds later, the rest left open.

## Expected

Three documentation statements describe what should happen, and the observed
behavior contradicts the first two:

> A large stack may also split across consecutive merge groups: the merge queue
> allows a merge group to exceed its configured maximum size by up to 50 percent
> to keep a stack together, and any pull requests that don't fit continue in
> subsequent groups until the full stack has landed.

— *Troubleshooting stacked pull requests*

> Merge part of the stack by merging a mid-stack pull request. The pull requests
> below it merge too, and the pull requests above stay open and automatically
> re-target the stack's base branch.

— *About stacked pull requests*

> Stacks are kept together in the merge queue. If a pull request is removed or
> ejected from the queue, all pull requests above it in the stack are also
> ejected and removed.

— *Troubleshooting stacked pull requests* (this part works as documented)

So the PR at a group boundary should either continue in the next group, or at
minimum stay open re-targeted to **the stack's base branch**. Re-targeting to
the immediate parent's base is only safe when that branch still exists, which it
never does when the stack is merged as a unit with `delete_branch_on_merge`
enabled.

Any of these would fix it: re-target survivors to the stack's base rather than
one link up; re-target before deleting merged head branches; or skip head-branch
deletion for a branch that is the base of an open stacked pull request.

## Impact: the closed pull request cannot be recovered

Four recovery routes, each refused by a different validation:

```
PATCH /repos/{owner}/{repo}/pulls/{D} base=main state=open
422 Cannot change the base branch because the pull request is part of a stack.

PATCH /repos/{owner}/{repo}/pulls/{D} state=open
422 state cannot be changed. The b branch has been deleted.

# after `gh stack unstack ` lifted the stack restriction:
PATCH /repos/{owner}/{repo}/pulls/{D} base=main state=open
422 Cannot change the base branch of a closed pull request.

# and where the head had been force-pushed after closing (e.g. by `gh stack sync`):
PATCH /repos/{owner}/{repo}/pulls/{D} state=open
422 state cannot be changed. The d branch was force-pushed or recreated.
```

Changing the base requires the PR to be open; reopening requires the base branch
to exist; and reopening also requires the head to be at the SHA recorded when the
PR closed. With the base branch deleted, there is no ordering of these calls that
succeeds.

## Recovery that worked

1. `git push origin :refs/heads/b` — restore the deleted base branch.
The SHA survives only in `refs/pull/{B}/head`.
2. Where the head had moved since closing, rewind it to the recorded
`headRefOid` (also recoverable from `refs/pull/{D}/head`).
3. `PATCH /pulls/{D} state=open` — now permitted.
4. `gh stack unstack ` — required before the base can be changed.
5. `PATCH /pulls/{D} base=main`.
6. Restore the real head, delete the scaffold branch, and `gh stack link` to
rebuild the stack.

A user who did not know the SHAs live in `refs/pull/{n}/head` would have to
abandon the pull request and open a new one, losing its review history.

## Notes

- `gh stack merge --help` says that under a merge queue the pull requests "are
added to the queue together but merge as the queue processes them, so they may
land in separate groups rather than all at once", while *Merging stacked pull
requests* describes the selected PR and everything below it landing "together
as a single operation". Both can be true, but with `delete_branch_on_merge`
enabled the first has a consequence the docs don't mention: any group boundary
inside a stack closes the pull request at that boundary.
- `gh stack unstack ` reported
`⚠ Some pull requests are queued for merge or have auto-merge enabled` while no
pull request had either — that is #372.
- `gh stack view --json` reported the closed pull request as `"state": "OPEN"`,
so neither `view` nor `--short` surfaced the closure; only the REST/GraphQL API
showed `CLOSED`. Filing separately.

Contributor guide

Open the contributing guide

Research direction

Start at the implementation behind `gh stack merge` and its merge-queue handling, then trace the REST/GraphQL updates triggered when a merge group deletes branches. Reproduce the five-PR stack with `delete_branch_on_merge` enabled; done means a boundary PR remains open and is re-targeted to the stack base or continues in the next group without losing the stack.

Written by the indexing model from the issue text.

Assessment

Tech stack
github, go
Domain
api, cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.