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
- Create a stack
main ← A ← B ← C with PRs for each.
- Squash-merge A's PR into main.
- From B or C, run
gh stack rebase --upstack.
- Observe: B and C are rebased but still contain A's pre-squash commits.
- 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:
-
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.
-
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.
Summary
When
gh stack rebase --upstackis 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
main ← A ← B ← Cwith PRs for each.gh stack rebase --upstack.Plain
gh stack rebase(no flag) handles the same scenario correctly — it detects A as merged, setsneedsOnto, and rebases B and C with--ontoto skip A's pre-squash commits.Root cause
In
cmd/rebase.go:The squash-merge detection runs inside the loop that iterates over
branchesToRebase:--upstacksetsstartIdx = currentIdx, so any merged branch belowcurrentIdxis excluded from the slice.IsMerged()never fires on it,needsOntostaysfalse, and the subsequent rebases use the old (pre-squash) parent instead of--ontoto the squashed base.Suggested fixes
Two options:
Seed merge state before the upstack loop. Scan
s.Branches[:startIdx]for merged branches before starting the loop and, if any is found, pre-populateontoOldBase/needsOntowith the appropriate old base so that the first branch in the upstack slice rebases with--onto.Refuse
--upstackwhen a merged branch exists below. Detect the condition up front and error out with a message directing the user to run plaingh stack rebaseinstead. 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.