Skip to content

gh stack rebase --upstack doesn't detect squash-merged branches below the current branch #31

Description

@benschoel

Summary

When gh stack rebase --upstack is run from a branch that has a squash-merged PR somewhere below it in the stack, the merged branch is never detected and skipped. Pre-squash commits from the merged branch stay in the history of every branch above, which (after push) shows up as bloated PR diffs containing unrelated code from the already-merged PR.

Reproducer

  1. Create a stack main ← A ← B ← C with PRs for each.
  2. Squash-merge A's PR into main.
  3. From B or C, run gh stack rebase --upstack.
  4. Observe: B and C are rebased but still contain A's pre-squash commits.
  5. The PRs for B and C now show a diff that includes A's changes in addition to their own.

Plain gh stack rebase (no flag) handles the same scenario correctly — it detects A as merged, sets needsOnto, and rebases B and C with --onto to skip A's pre-squash commits.

Root cause

In cmd/rebase.go:

if opts.upstack {
    startIdx = currentIdx
}
branchesToRebase := s.Branches[startIdx:endIdx]

The squash-merge detection runs inside the loop that iterates over branchesToRebase:

if br.IsMerged() {
    ontoOldBase = originalRefs[br.Branch]
    needsOnto = true
    cfg.Successf("Skipping %s (PR %s merged)", br.Branch, ...)
    continue
}

--upstack sets startIdx = currentIdx, so any merged branch below currentIdx is excluded from the slice. IsMerged() never fires on it, needsOnto stays false, and the subsequent rebases use the old (pre-squash) parent instead of --onto to the squashed base.

Suggested fixes

Two options:

  1. Seed merge state before the upstack loop. Scan s.Branches[:startIdx] for merged branches before starting the loop and, if any is found, pre-populate ontoOldBase / needsOnto with the appropriate old base so that the first branch in the upstack slice rebases with --onto.

  2. Refuse --upstack when a merged branch exists below. Detect the condition up front and error out with a message directing the user to run plain gh stack rebase instead. Simpler and surfaces the problem loudly rather than silently producing a bad rebase.

Option 2 is probably cheapest and prevents silent data corruption. Option 1 is more user-friendly but needs more care around multiple merged branches in sequence.

Impact

Repeatedly observed in the wild — the bad rebase is silent, the push succeeds, and the only signal is that PRs suddenly show hundreds of lines of unrelated diff. Easy to miss if you don't self-check every push.

Activity

  1. benschoel commented on Apr 15, 2026

    @benschoel
    Author

    Hit a related manifestation of the same root cause today, this time with plain gh stack rebase (no --upstack). Worth flagging since it shows the bug is broader than just the --upstack flag.

    Trigger

    A cascade of merged branches at the bottom of the stack, where ontoOldBase ends up stale.

    main ← A (merged) ← B (merged) ← C (merged) ← D (merged) ← E (merged) ← F (active) ← G ← H ...
    

    gh stack rebase correctly skips A–E via squash-merge detection. But because the loop body does:

    if br.IsMerged() {
        ontoOldBase = originalRefs[br.Branch]   // overwrites each iteration
        needsOnto = true
        continue
    }

    ontoOldBase is overwritten on each merged branch. After the cascade, ontoOldBase = originalRefs[E] — E's local pre-squash tip.

    What goes wrong

    For F's rebase:

    git rebase --onto main <originalRefs[E]> F
    

    But <originalRefs[E]> (E's local pre-squash tip) is NOT an ancestor of F if F was independently advanced past it (e.g. by a previous gh-stack rebase that brought F up to recent main, or a manual git rebase main on F). git falls back to merge-base(<originalRefs[E]>, F), which can be much older than F's actual divergence from main.

    Result: the rebase tries to apply many commits that are already in main but as different SHAs (post-squash). Most get patch-id-skipped, but any that touch the same files as the squash commits hit a 3-way merge conflict on totally unrelated code.

    Concrete example

    In our case, A–E were 5 squash-merged PRs. ontoOldBase ended up as the local pre-squash tip of E (chore/remove-nexus-benchmarks). F's actual merge-base(main, F) was a much later main commit. The rebase tried to apply ~10 unrelated PRs from main on top of main, and PR #19419 conflicted on delete_account/* files because the squashed E branch had also touched those files. The conflict markers all referenced 9129510f7c (delete account opportunities activity (#19419)) — a commit nobody on the stack had ever interacted with.

    Doing git rebase main on F manually first didn't help either — gh stack rebase ignores F's current state and re-runs git rebase --onto main <stale ontoOldBase> F, hitting the same conflict.

    Suggested fix

    Compute the rebase base as merge-base(parent, branch) rather than using the parent's local tip. That auto-handles both the cascade case and the case where the upstack branch was independently rebased forward.

    Or as a narrower fix: when the parent (or last-skipped merged branch) is squash-merged, use the squash commit on --onto target (main) instead of the local pre-squash tip — git merge-base main branch would give the right divergence point.

  2. self-assigned this
    on Apr 16, 2026
  3. added theissue type on Apr 16, 2026
  4. skarim commented on Apr 16, 2026

    @skarim
    Collaborator

    Thanks for the detailed bug report! Will look into this and get a fix out in the next release.

  5. linked a pull request that will close this issuefix --onto rebase for merged branches #43on Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions