Skip to content

Commit 623b7e6

Browse files
authored
Fix rebase treating queued PRs as merged (#173)
* Don't treat queued PRs as merged when rebasing the stack gh stack rebase and gh stack sync share cascadeRebase, which skipped branches via IsSkipped() (merged or queued) and then switched to a `git rebase --onto` that drops the skipped branch's commits from every downstream branch. That is right for a merged PR — its commits are already in trunk — but wrong for a queued PR: its commits only exist on its own branch, which is frozen in the merge queue, so the branches above it were rebased onto trunk and lost work they depend on. Handle the two cases separately. A merged branch still activates --onto so its commits are dropped. A queued branch is still skipped (its branch is frozen and is not rebased or pushed), but onto mode is reset so downstream branches rebase normally onto the queued branch, keeping its commits underneath. The --onto target search, the runRebase --onto seed, and the continueRebase display base now key on IsMerged() instead of IsSkipped(), so a queued predecessor no longer forces downstream branches onto trunk. gh stack sync is fixed through the same shared helper. Add rebase coverage for a queued branch mid-stack, a merged branch below a queued branch, and --upstack above a queued branch, plus a sync test that also asserts the queued branch is excluded from the push. The transient queued state is injected through the GitHub mock's merge-queue entry. * Refresh queued PR state when continuing a stack rebase continueRebase reloads the stack from disk, where the Queued flag is transient (json:"-") and therefore lost, and it only called syncStackPRs after the cascade. So if the initial rebase conflicted on a branch below a queued branch, `gh stack rebase --continue` resumed with that branch seen as active: it rebased the frozen merge-queue branch and rebuilt the downstream branches on a local history that differs from the queued branch. Call syncStackPRs right after resolving the stack — before selecting the base and cascading the remaining branches — mirroring the refresh runRebase already does before its cascade. The queued flag is repopulated, so queued branches stay skipped and downstream branches stay stacked on them. Add TestRebase_Continue_QueuedBranchBelowConflict, which conflicts below a queued branch and asserts the frozen branch is not rebased and the branch above stays stacked on it. Verified to fail without the refresh.
1 parent 46b5dc4 commit 623b7e6

4 files changed

Lines changed: 364 additions & 12 deletions

File tree

cmd/rebase.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,14 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error {
198198
return fmt.Errorf("resolving branch refs: %w", err)
199199
}
200200

201-
// Get --onto state from merged/queued branches below the rebase range.
202-
// Ensures that when --upstack excludes skipped branches, we still check
203-
// the immediate predecessor and use --onto if needed.
201+
// Get --onto state from a merged branch immediately below the rebase range.
202+
// Ensures that when --upstack excludes merged branches, we still check the
203+
// immediate predecessor and use --onto if needed.
204204
needsOnto := false
205205
var ontoOldBase string
206206
if startIdx > 0 {
207207
prev := s.Branches[startIdx-1]
208-
if prev.IsSkipped() {
208+
if prev.IsMerged() {
209209
if sha, ok := originalRefs[prev.Branch]; ok {
210210
needsOnto = true
211211
ontoOldBase = sha
@@ -315,6 +315,14 @@ func continueRebase(cfg *config.Config, gitDir string) error {
315315
return fmt.Errorf("no stack found for branch %s", state.OriginalBranch)
316316
}
317317

318+
// Refresh PR state before selecting the base and cascading the remaining
319+
// branches. The queued flag is transient (not persisted), so it was lost
320+
// when the stack was reloaded from disk above. Without this, a queued
321+
// branch in the remaining cascade would be treated as active and its
322+
// frozen merge-queue branch would be rebased. Mirrors the syncStackPRs
323+
// call in runRebase before its cascade.
324+
_ = syncStackPRs(cfg, s)
325+
318326
// The branch that had the conflict is stored in state; fall back to
319327
// looking it up by index for backwards compatibility with older state files.
320328
conflictBranch := state.ConflictBranch
@@ -334,10 +342,10 @@ func continueRebase(cfg *config.Config, gitDir string) error {
334342

335343
var baseBranch string
336344
if state.UseOnto {
337-
// The --onto path targets the first non-skipped ancestor, or trunk.
345+
// The --onto path targets the first non-merged ancestor, or trunk.
338346
baseBranch = s.Trunk.Branch
339347
for j := state.CurrentBranchIndex - 1; j >= 0; j-- {
340-
if !s.Branches[j].IsSkipped() {
348+
if !s.Branches[j].IsMerged() {
341349
baseBranch = s.Branches[j].Branch
342350
break
343351
}

cmd/rebase_test.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/github/gh-stack/internal/config"
1313
"github.com/github/gh-stack/internal/git"
14+
"github.com/github/gh-stack/internal/github"
1415
"github.com/github/gh-stack/internal/stack"
1516
"github.com/stretchr/testify/assert"
1617
"github.com/stretchr/testify/require"
@@ -680,6 +681,188 @@ func TestRebase_SkipsMergedBranches(t *testing.T) {
680681
assert.Equal(t, "b2", rebaseCalls[0].branch)
681682
}
682683

684+
// queuedPRClient returns a MockClient whose FindPRByNumber reports the given PR
685+
// numbers as queued (in a merge queue, open, not merged) and finds no PR by
686+
// branch name. Used to drive the transient Queued state through syncStackPRs in
687+
// rebase/sync tests.
688+
func queuedPRClient(headByNumber map[int]string) *github.MockClient {
689+
return &github.MockClient{
690+
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
691+
head, ok := headByNumber[n]
692+
if !ok {
693+
return nil, nil
694+
}
695+
return &github.PullRequest{
696+
Number: n,
697+
HeadRefName: head,
698+
State: "OPEN",
699+
Merged: false,
700+
MergeQueueEntry: &github.MergeQueueEntry{ID: fmt.Sprintf("MQ_%d", n)},
701+
}, nil
702+
},
703+
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
704+
}
705+
}
706+
707+
// TestRebase_QueuedBranch_DownstreamStaysStacked verifies the #144 fix: a queued
708+
// PR is NOT treated as merged. Its branch is skipped (frozen in the merge queue),
709+
// but downstream branches stay stacked on top of it — they rebase onto the queued
710+
// branch, not --onto trunk with the queued commits dropped.
711+
func TestRebase_QueuedBranch_DownstreamStaysStacked(t *testing.T) {
712+
s := stack.Stack{
713+
Trunk: stack.BranchRef{Branch: "main"},
714+
Branches: []stack.BranchRef{
715+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
716+
{Branch: "b2"},
717+
{Branch: "b3"},
718+
},
719+
}
720+
721+
tmpDir := t.TempDir()
722+
writeStackFile(t, tmpDir, s)
723+
724+
var rebaseCalls []rebaseCall
725+
726+
mock := newRebaseMock(tmpDir, "b2")
727+
mock.BranchExistsFn = func(name string) bool { return true }
728+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
729+
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
730+
return nil
731+
}
732+
733+
restore := git.SetOps(mock)
734+
defer restore()
735+
736+
cfg, _, errR := config.NewTestConfig()
737+
cfg.GitHubClientOverride = queuedPRClient(map[int]string{10: "b1"})
738+
cmd := RebaseCmd(cfg)
739+
cmd.SetOut(io.Discard)
740+
cmd.SetErr(io.Discard)
741+
err := cmd.Execute()
742+
743+
cfg.Err.Close()
744+
errOut, _ := io.ReadAll(errR)
745+
output := string(errOut)
746+
747+
assert.NoError(t, err)
748+
assert.Contains(t, output, "Skipping b1")
749+
assert.Contains(t, output, "queued")
750+
assert.NotContains(t, output, "adjusted for merged PR",
751+
"queued branches must not trigger the merged --onto path")
752+
753+
// b2 stays stacked on the queued b1 (not rebased --onto main); b3 onto b2.
754+
require.Len(t, rebaseCalls, 2)
755+
assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseCalls[0],
756+
"b2 should rebase onto the queued branch b1, keeping its commits")
757+
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1],
758+
"b3 should rebase onto b2")
759+
}
760+
761+
// TestRebase_MergedBelowQueued_KeepsStackedOnQueued verifies that when a merged
762+
// branch sits below a queued branch, the branch above the queued one stays
763+
// stacked on the queued branch. The queued branch is frozen and still carries the
764+
// merged branch's commits, so downstream cannot drop them via --onto.
765+
func TestRebase_MergedBelowQueued_KeepsStackedOnQueued(t *testing.T) {
766+
s := stack.Stack{
767+
Trunk: stack.BranchRef{Branch: "main"},
768+
Branches: []stack.BranchRef{
769+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
770+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
771+
{Branch: "b3"},
772+
},
773+
}
774+
775+
tmpDir := t.TempDir()
776+
writeStackFile(t, tmpDir, s)
777+
778+
var rebaseCalls []rebaseCall
779+
780+
mock := newRebaseMock(tmpDir, "b3")
781+
mock.BranchExistsFn = func(name string) bool { return true }
782+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
783+
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
784+
return nil
785+
}
786+
787+
restore := git.SetOps(mock)
788+
defer restore()
789+
790+
cfg, _, errR := config.NewTestConfig()
791+
cfg.GitHubClientOverride = queuedPRClient(map[int]string{11: "b2"})
792+
cmd := RebaseCmd(cfg)
793+
cmd.SetOut(io.Discard)
794+
cmd.SetErr(io.Discard)
795+
err := cmd.Execute()
796+
797+
cfg.Err.Close()
798+
errOut, _ := io.ReadAll(errR)
799+
output := string(errOut)
800+
801+
assert.NoError(t, err)
802+
assert.Contains(t, output, "Skipping b1")
803+
assert.Contains(t, output, "PR #10 merged")
804+
assert.Contains(t, output, "Skipping b2")
805+
assert.Contains(t, output, "queued")
806+
807+
// b1 merged and b2 queued are both skipped. b3 stays stacked on the queued
808+
// b2 — it must NOT be rebased --onto main (which would drop b2's + b1's
809+
// commits while b2 is frozen).
810+
require.Len(t, rebaseCalls, 1)
811+
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[0],
812+
"b3 should rebase onto the queued b2, not --onto main")
813+
assert.NotContains(t, output, "adjusted for merged PR")
814+
}
815+
816+
// TestRebase_UpstackAboveQueuedBranch verifies the onto-seed fix: with --upstack
817+
// starting just above a queued branch, the first in-range branch rebases normally
818+
// onto the queued predecessor rather than dropping its commits via --onto.
819+
func TestRebase_UpstackAboveQueuedBranch(t *testing.T) {
820+
s := stack.Stack{
821+
Trunk: stack.BranchRef{Branch: "main"},
822+
Branches: []stack.BranchRef{
823+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
824+
{Branch: "b2"},
825+
{Branch: "b3"},
826+
},
827+
}
828+
829+
tmpDir := t.TempDir()
830+
writeStackFile(t, tmpDir, s)
831+
832+
var rebaseCalls []rebaseCall
833+
834+
mock := newRebaseMock(tmpDir, "b2")
835+
mock.BranchExistsFn = func(name string) bool { return true }
836+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
837+
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
838+
return nil
839+
}
840+
841+
restore := git.SetOps(mock)
842+
defer restore()
843+
844+
cfg, _, errR := config.NewTestConfig()
845+
cfg.GitHubClientOverride = queuedPRClient(map[int]string{10: "b1"})
846+
cmd := RebaseCmd(cfg)
847+
cmd.SetArgs([]string{"--upstack"})
848+
cmd.SetOut(io.Discard)
849+
cmd.SetErr(io.Discard)
850+
err := cmd.Execute()
851+
852+
cfg.Err.Close()
853+
errOut, _ := io.ReadAll(errR)
854+
output := string(errOut)
855+
856+
assert.NoError(t, err)
857+
// upstack from b2 = [b2, b3]; b1 (queued) is below the range.
858+
require.Len(t, rebaseCalls, 2)
859+
assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseCalls[0],
860+
"b2 should rebase onto the queued predecessor b1, not --onto main")
861+
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1],
862+
"b3 should rebase onto b2")
863+
assert.NotContains(t, output, "adjusted for merged PR")
864+
}
865+
683866
// TestRebase_StateRoundTrip verifies that rebase state can be saved and loaded
684867
// back with all fields preserved, including the --onto fields.
685868
func TestRebase_StateRoundTrip(t *testing.T) {
@@ -791,6 +974,80 @@ func TestRebase_Continue_RebasesRemainingBranches(t *testing.T) {
791974
assert.Contains(t, checkouts, "b1", "should checkout original branch")
792975
}
793976

977+
// TestRebase_Continue_QueuedBranchBelowConflict verifies that a queued branch is
978+
// still skipped when the cascade resumes via --continue after a conflict below
979+
// it. The Queued flag is transient and lost when continueRebase reloads the
980+
// stack from disk, so it must be refreshed before the remaining cascade — else
981+
// the frozen merge-queue branch would be rebased.
982+
func TestRebase_Continue_QueuedBranchBelowConflict(t *testing.T) {
983+
s := stack.Stack{
984+
Trunk: stack.BranchRef{Branch: "main"},
985+
Branches: []stack.BranchRef{
986+
{Branch: "b1"},
987+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 20}},
988+
{Branch: "b3"},
989+
},
990+
}
991+
992+
tmpDir := t.TempDir()
993+
writeStackFile(t, tmpDir, s)
994+
995+
// State: b1 (below the queued b2) conflicted; b2 and b3 remain.
996+
state := &rebaseState{
997+
CurrentBranchIndex: 0,
998+
ConflictBranch: "b1",
999+
RemainingBranches: []string{"b2", "b3"},
1000+
OriginalBranch: "b3",
1001+
OriginalRefs: map[string]string{
1002+
"main": "main-orig-sha",
1003+
"b1": "sha-b1",
1004+
"b2": "sha-b2",
1005+
"b3": "sha-b3",
1006+
},
1007+
}
1008+
stateData, _ := json.MarshalIndent(state, "", " ")
1009+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644))
1010+
1011+
var rebaseCalls []rebaseCall
1012+
1013+
mock := newRebaseMock(tmpDir, "b1")
1014+
mock.BranchExistsFn = func(name string) bool { return true }
1015+
mock.IsRebaseInProgressFn = func() bool { return true }
1016+
mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil }
1017+
mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error {
1018+
rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch})
1019+
return nil
1020+
}
1021+
mock.CheckoutBranchFn = func(string) error { return nil }
1022+
1023+
restore := git.SetOps(mock)
1024+
defer restore()
1025+
1026+
cfg, _, errR := config.NewTestConfig()
1027+
cfg.GitHubClientOverride = queuedPRClient(map[int]string{20: "b2"})
1028+
cmd := RebaseCmd(cfg)
1029+
cmd.SetArgs([]string{"--continue"})
1030+
cmd.SetOut(io.Discard)
1031+
cmd.SetErr(io.Discard)
1032+
err := cmd.Execute()
1033+
1034+
cfg.Err.Close()
1035+
errOut, _ := io.ReadAll(errR)
1036+
output := string(errOut)
1037+
1038+
assert.NoError(t, err)
1039+
assert.Contains(t, output, "Skipping b2")
1040+
assert.Contains(t, output, "queued")
1041+
1042+
// Only b3 is rebased, onto the queued b2. The queued b2 itself must not be
1043+
// rebased (its branch is frozen in the merge queue).
1044+
require.Len(t, rebaseCalls, 1)
1045+
assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[0])
1046+
for _, c := range rebaseCalls {
1047+
assert.NotEqual(t, "b2", c.branch, "the frozen queued branch must not be rebased")
1048+
}
1049+
}
1050+
7941051
// TestRebase_Continue_OntoMode verifies the --continue path when UseOnto is
7951052
// set (merged branches upstream). With no remaining branches, only
7961053
// RebaseContinue runs and the state is cleaned up.

0 commit comments

Comments
 (0)